Hospital Timeline (Real Data)
Wiring the hospital timeline to hospital_events real data with full UX upgrade.
Status: Phases 1–4 SHIPPED + browser-verified (2026-06-04) — all mock data removed, full UX upgrade. Phase 3 announcement-board migration still deferred (the feed runs on
hospital_eventsinstead). Phase 5 sandbox target still TODO. Route:/hospital-timeline— the post-login landing page (every login redirect lands here:web/src/middleware.ts:22,web/src/auth0.config.tsx:36,Login.tsx:197). Read before: any work on the timeline landing page, its stats, patient stories, or the feed.
Shipped 2026-06-04 — real-data + UX upgrade (all surfaces)
The whole page is now real, branded, and English-consistent. Key components (all under
hospital-timeline/):
components/KpiHeader.tsx←useHospitalTimelineStats— branded “Hospital pulse” KPI header (occupancy / avg wait / waiting / active-staff) with icons + LIVE dot. ReplacedStatsCards.components/ActivePatients.tsx←hooks/useTimelinePatients(readsdepartment_queues) — real active patients. Replaced the mockPatientStories/mockData.ts(John Doe…).components/ActivityFeed.tsx←hooks/useHospitalEventFeed(readshospital_events) — live activity timeline. Replaced the broken announcementsHospitalFeed.LeftSidebar.tsx“Department Pulse” +RightSidebar.tsx“Department load” bars + “Recent activity” chips ←useTimelinePatients/useHospitalEventFeed. Replaced the$10,023mock and the hardcoded Japanese nursing-home KPI charts / events / online-staff / topics. Quick-actions + chat kept (real) and Anglicized.services/timelineRest.ts— shared direct-PostgREST + AbortController helper (D8) used by the new hooks; all reads are timeout-bounded, 45 s poll, fail-soft.DATA REALITY (important): on this deployment
encounter_journey_cacheidentity fields (name/hn/ward/dx) are null (identity backfill incomplete — seeorchestrator-layer-3-hardening). So Patient Stories could not show real rich patient cards; the honest reframe uses the populated operational models instead —department_queues(dept/status/priority/HN; names mostly null → HN is the identifier) andhospital_events(real activity). No fake names, no fake vitals.Orphaned (dead, not deleted per guardrails):
PatientStories.tsx,components/mockData.ts,components/StatsCards.tsx,HospitalFeed.tsx,components/ChartCards.tsx,TimelineFeed.tsx,EnhancedTimelineFeed.tsxare no longer rendered. Safe to remove in a dedicated cleanup. Verified: tsc clean;bed/department_queues/hospital_eventsall 200; 0 console errors from the new code; login flow unaffected.
Update 2026-06-04b — real-data-only + full i18n
- No fake fallbacks:
useHospitalTimelineStatsFALLBACK values (78%/24 min/152) →—. An unreadable metric shows an em-dash, never a fabricated number.- Localized everything: all new strings go through i18n (namespace
hospital-timeline, every call has an EnglishdefaultValueso the page can never show a raw key or break). KPI labels moved fromdashboard-components.statsCards.*→hospital-timeline.pulse.kpi.*. New key groups:pulse,activePatients,activity(+events.*per event_type),deptPulse,deptLoad,quickActions,recentActivity,chatBar,status.*,dept.*.- Bundled (not
public/locales): i18next uses build-timeresourcesfromsrc/locales/*viasrc/i18n/resources.ts. Added the key set tosrc/locales/en+src/locales/ja, createdsrc/locales/th, and registeredhospital-timeline: hospitalTimelineTHin thethblock ofresources.ts(TH previously fell back to EN — that’s why the page was English under the Thai nav).filhas no resources block in this app (PH already falls back to EN globally) — out of scope.- Locale on localhost =
VITE_DEFAULT_LOCALE(commented →en), so the dev page renders the English i18n variant;his-thailand→th,his-japan→javia domain detection. Proven the TH bundle resolves through real i18next (ภาพรวมโรงพยาบาล,อัตราครองเตียง, count-interpolation, en per-key fallback all correct).
Update 2026-06-04c — discussable feed + studio-linked threads + richer cards
- Facebook-style discussion: every activity-feed event is now clickable → expands an inline comment thread; patient cards open a discussion dialog. New
components/EventCommentThread.tsx.- Reuses the body-collab system (
@/services/body-collabusePresence) — the SAME hook + presence the 3D Body Annotation Studio uses. A thread is(roomId, annoId): roomId = patient (so the timeline shares live presence + comments with anyone viewing that patient in the 3D studio) and annoId = studio-compatible key (own-ord-…/own-img-…/own-blood-…for orders,pt-…for a patient-level thread,evt-…for context-less events). Commenting on an order in the timeline and on that order’s body marker in the studio resolve to the sameannoId.timelineCollab.ts:useTimelineIdentity()(CollabUser from Redux), deterministiccolorFor/initialsFor(avatars),orderAnnoId().- Richer patient cards: real avatar (deterministic colour + initials/HN), order name + assigned doctor (from
department_queues.metadata—imagingItemName/productName/assignedDoctorName), active-orders count chip, click → discussion dialog.useTimelinePatients+useHospitalEventFeednow exposepatientId/orderRef/orderName/doctor/activeCount.- All new strings localized (
discussion.*,activePatients.orders) in en/th/ja with EnglishdefaultValue.- ⚠️ Persistence caveat (same as the studio today): body-collab comments are session-durable via Supabase realtime broadcast, not yet persisted — the
body_collab_messagestable the transport is designed for does not exist. Live discussion works within/across open sessions; it won’t survive a full reload until that table is created (one migration; the SupabaseTransport already intends to load/persist it). True cross-room sharing with the studio (timeline patient-room ↔ studio patient-room) is live now via shared(roomId, annoId); cross-surface history lands with persistence.- Verified: tsc clean across all 6 changed files; reuses the proven studio hook. A fresh live screenshot of the thread UI wasn’t captured this run — the dev JWT expired mid-session and the “Dev Login” control isn’t headless-actuatable (harness limitation, not code).
Update 2026-06-04e — dedup audit (reuse over rebuild)
Audited for existing hooks before keeping the new ones. Findings:
usePatientSyncdoes not exist (the “patient-sync” hits are a workflow-editor node);useEncounter/EncountersProvideris a single-encounter context (wrong scope);useCurrentUseris next-auth-based (this app runs on Redux JWT — empty here). No existing hook does hospital-wide, lock-safe reads (useSupabaseQueue/useDoctorWorkloadStats/useManifestListare queue/doctor/provider-scoped and use the lock-prone supabase-js.from()path + need ManifestProvider). So the timeline data hooks fill a genuine gap — not duplicates. But three hand-rolled PostgREST fetchers I’d created were real duplication and are now consolidated into one sharedweb/src/lib/supabaseRest.ts(supabaseRestSelect+supabaseRestInsert), used by the stats service,useTimelinePatients,useHospitalEventFeed, AND the body-collab transport;timelineRest.tsdeleted. Identity now uses the canonicaluseAuth()(not rawuseSelector). Intentionally NOT consolidated:EventCommentThread/initialsFor/timeAgomirror sandbox-only equivalents inBodyModelAnnotationStudioTarget.tsxthat can’t be imported fromsrc/(the reusable part —usePresence+ the(roomId, annoId)keying + persistence — IS shared). tsc clean.
Update 2026-06-04d — comment persistence (durable + cross-surface)
Shipped the persistence the transport was always designed for (resolves the §2026-06-04c caveat):
- Migration
web/supabase/migrations/20260604c_body_collab_messages.sql—body_collab_messages(id, room_id, anno_id, kind, author_*, text, created_at)+ 2 indexes + RLS. Policies are anon-permissive (read/insertUSING/ WITH CHECK (true)), matching the other read-model tables: the app talks to PostgREST with the anon key (no Supabase auth session yet), soTO authenticatedwould lock it out. Tighten per-tenant when Supabase auth lands. MANUAL APPLY (not auto-deployed).SupabaseTransport(web/src/services/body-collab/transports.ts): on join, loads the room’s full history (all annoIds for thatroom_id) and replays it to the client as a localsync-replytothis.userId(whichusePresencemerges); onaddComment/sendChat, inserts a row in addition to the live broadcast. Both use direct PostgRESTfetch(anon key, AbortController timeout) — NOT supabase-js.from()— to dodge the getSessionnavigator.locksdeadlock (D8). Benefits BOTH the 3D studio and the timeline.- Fail-soft proven: with the table absent, GET→404 returns empty and POST→404 (PGRST205) is swallowed → transport silently stays broadcast-only, zero regression. Applying the migration is the only step to activate persistence. tsc clean.
- Cross-surface nuance: persistence makes a room’s entire comment log durable + loaded for anyone entering that patient’s room, and
commentsFor(annoId)shares across surfaces for matching annoIds. Exact feed-event ↔ studio-marker order sharing still needs a key reconciliation — the activity feed keys order events byown-ord-${orderRequestId}(from the event payload) while the studio keys order markers byown-ord-${department_queues.id}. Aligning those two ids is a separate follow-up (the(roomId, annoId)model itself is unchanged, per scope). Patient cards’pt-${patientId}threads + room presence already overlap live with studio users on the same patient.
1. What the page is
A social-media-style “hospital feed” rendered by HospitalTimeline.tsx, mounted from the container (which also bolts on a super-admin ReactFlow navigation panel). Layout:
┌──────────── LeftSidebar ────────────┬──────── Center column ────────┬──── RightSidebar ────┐
│ shortcuts (localStorage) │ AnnouncementBanner (i18n) │ KPI trend chart │
│ status overview cards ($ amounts) │ StatsCards (4 KPI tiles) │ quick actions │
│ │ PatientStories (card carousel)│ upcoming events │
│ │ HospitalFeed (the "feed") │ online staff │
│ │ │ trending topics │
│ │ │ chat (Redux) │
└─────────────────────────────────────┴───────────────────────────────┴──────────────────────┘
Responsive: mobile collapses sidebars into drawers; tablet hides the right sidebar
(HospitalTimeline.tsx has three render branches — a stats/feed change must be applied to all three).
2. Current data audit
| Surface | File | Current source | Status |
|---|---|---|---|
| Center feed | HospitalFeed.tsx | useAnnouncementService → Supabase announcements/comments/likes/departments |
⚠️ Broken — none of these tables or the 5 count RPCs have a migration anywhere. Queries error / return empty. |
| Stats cards | StatsCards.tsx | DEFAULT_STAT_CONFIG (92% satisfaction, 24 min wait, 78% occupancy, 152 staff) |
❌ Hardcoded |
| Patient Stories | PatientStories.tsx | mockPatients (mockData.ts) |
❌ Hardcoded (John Doe…) |
| Left status cards | LeftSidebar.tsx:181 | hardcoded $10,023 + goals |
❌ Hardcoded |
| Right KPI chart / events / online staff / topics | RightSidebar.tsx | hardcoded Japanese nursing-home data | ❌ Hardcoded (demo-specific) |
| Right sidebar chat | RightSidebar.tsx | Redux state.chat |
✅ Real-ish |
TimelineFeed.tsx / EnhancedTimelineFeed.tsx are imported in HospitalTimeline.tsx but never rendered — dead; leave untouched.
organizationId resolves to the literal "PLACEHOLDER_ORG_ID" when the user has no organization.id
(container index.tsx:178). Any org-scoped query must tolerate that.
3. Real data sources (grounded map)
All confirmed to exist with the columns below.
| # | Source | Table / view | Key columns | Existing hook | Migration |
|---|---|---|---|---|---|
| 1 | Activity feed | hospital_events |
event_type, payload (jsonb), encounter_id, user_id, department_id, created_at |
none (read+written ad-hoc in ~15 files) | created in backend; AFTER-INSERT orchestrator trigger in infrastructure/medbase/migrations/* |
| 2 | Patient cards | encounter_journey_cache |
patient_display_name, patient_hn, current_an, ward_id, bed_label, working_dx, attending_doctor_name, clinical_context (jsonb, holds vitals), active_alerts (jsonb), financial_summary (jsonb), er_esi_level, er_chief_complaint, admit_at, updated_at |
useManifestList/useManifestRow — but needs ManifestProvider, which is NOT mounted on this route |
001_phase1_hardening.sql + later |
| 3 | Queue / wait time | department_queues |
dept_type, status (WAITING/ACKNOWLEDGED/IN_PROGRESS/COMPLETED/CANCELLED), created_at, location_id, patient_name |
useDepartmentQueue(locationId, deptType) — location-scoped, not hospital-wide |
003_department_queues.sql |
| 4 | Bed occupancy | bed table + ward_bed_availability view |
view: vacant, admitted, discharge_pending, out_of_service, total, occupancy_pct (per ward) |
none | 048_…view.sql, 20260518k_ipd_ward_bed_tables.sql |
| 5 | Vitals / labs | observations (+ patient_observation_timeline view) |
category (‘vital-signs’), code (LOINC), value_numeric, value_systolic/diastolic, interpretation, effective_at |
useObservations({ patientId, categories, ... }) |
20260512_observation_unification.sql |
| 6 | Staff on duty | — | — | — | ⚠️ No realtime presence/shift table. Staff session lives in Mongo / Socket.IO. |
LOINC vitals: 8480-6 sys BP, 8462-4 dia BP, 8867-4 HR, 8310-5 temp, 9279-1 RR, 59408-5 SpO₂.
Established realtime pattern (reuse verbatim):
supabase.channel(`<name>-${key}`)
.on('postgres_changes', { event: '*', schema: 'public', table: '<t>', filter: `<col>=eq.${key}` }, cb)
.subscribe();
4. Design decisions
-
D1 — Hospital-wide aggregates, not location-scoped. The landing page is whole-hospital.
useDepartmentQueueis location-filtered anduseManifestListneeds a provider that isn’t mounted here. So the page gets a dedicated read service (hospitalTimelineStats.service.ts) doing direct aggregate reads on the centralizedsupabaseclient (@/lib/supabaseClient) — the same pattern asusage-dashboard.service.tsanddepartment-command-center.tsx. -
D2 — Patient Stories via direct
encounter_journey_cachequery, notuseManifestList(noManifestProvideron this route; pulling it in defeats the landing page’s light weight). Vitals come from the row’sclinical_contextJSONB (no N+1observationsqueries); fall back to oneuseObservationscall only on card expand. -
D3 — Feed = BOTH (per scope decision “everything”):
- Auto activity stream off
hospital_events(read-only, realtime) — the default, always-populated content. - Announcements board — create the missing social-table suite (§6) so the existing
useAnnouncementService“create post” path works; render staff announcements pinned above the event stream.
- Auto activity stream off
-
D4 — Staff-on-duty gap, labeled honestly. No presence table exists. Derive “active staff (last 12 h)” =
COUNT(DISTINCT user_id)fromhospital_eventsand relabel the tile accordingly — do not present it as a real-time on-shift count. A true presence feed is out of scope (needs Socket.IO presence or astaff_shiftmodel). -
D5 — Read-model-only invariant.
hospital_events,encounter_journey_cache,department_queues,bed,observationsare read models — never written from the frontend here. Theannouncements/comments/likestables are frontend-owned social tables (not projections of backend truth), so writing to them from the FE is allowed — they are the one legitimate FE write surface on this page. -
D6 — Fail-soft, zero-regression. Every wired widget falls back to today’s hardcoded values on empty/error, shows a skeleton while loading, and an
isLivedot when realtime is connected. If Supabase is unreachable the page looks exactly as it does now. This is the headline guardrail: the landing page must never break the login flow. -
D8 — Read read-models via direct PostgREST
fetch, not supabase-js (Phase 1, verified). supabase-js callsauth.getSession()(anavigator.locksacquisition) before every query. On this page that lock deadlocks under React StrictMode’s dev-only double-invoke (mount → cleanup → remount): the first mount’sgetSessionlock callback is abandoned by the StrictMode unmount and never releases, so the next query wedges before any HTTP request is issued (observed:load()runs, butbednever hits the network and never resolves). A plainfetchto/rest/v1/<table>with the anon key (the same access supabase-js uses when there is no Supabase session) + anAbortControllertimeout cannot hang and was verified live (bed200/1000 rows,department_queues200/529,hospital_events200). These are read-only public aggregates, so anon-read is appropriate; if a locked-down deployment denies anon, the tile fails soft to its default. Liveness = a 45 s poll, not a supabase-js realtime channel (same lock exposure). NB (2026-06-04 investigation): this is a bounded StrictMode double-invoke, not an infinite remount loop — measured 0 effect re-triggers over a 10 s window after settle. Production builds invoke once. The “loop” appearance came from verbose mislabeled debugconsole.logs inHospitalFeed.tsx(since removed) + a no-op effect that logged “Effect: Cleanup”. -
D7 —
departmentsdropdown. The feed’s department filter queries adepartmentstable that doesn’t exist. Prefer backing it with an existing clinic/sub-clinic source if one is FE-queryable; otherwise the thindepartmentstable in §6. Resolve during Phase 3 (don’t blind-create a duplicate of an existing clinic table — see [check-existing-tables guidance]).
5. New / changed files
| File | Change | Phase |
|---|---|---|
…/hospital-timeline/services/hospitalTimelineStats.service.ts |
new — hospital-wide KPI aggregate reads | P1 |
…/hospital-timeline/hooks/useHospitalTimelineStats.ts |
new — hook wrapping the service + realtime | P1 |
…/hospital-timeline/HospitalTimeline.tsx |
edit — call hook once, pass data to all 3 `` |
P1 |
…/hospital-timeline/services/hospitalTimelinePatients.service.ts |
new — recent/critical encounters from encounter_journey_cache |
P2 |
…/hospital-timeline/hooks/useTimelinePatients.ts |
new | P2 |
…/hospital-timeline/PatientStories.tsx |
edit — default to live patients, mock fallback | P2 |
…/hospital-timeline/hooks/useHospitalEventFeed.ts |
new — hospital_events → feed posts + realtime |
P3 |
…/hospital-timeline/eventFeedMapping.ts |
new — event_type+payload → {icon, severity, title, body} (bilingual) |
P3 |
…/hospital-timeline/HospitalFeed.tsx |
edit — merge pinned announcements + event stream | P3 |
web/supabase/migrations/<date>_announcements_social.sql |
new — 6 tables + 5 RPCs + RLS (§6) | P3 |
…/hospital-timeline/LeftSidebar.tsx |
edit — status cards from financial_summary or remove |
P4 |
…/hospital-timeline/RightSidebar.tsx |
edit — KPI trend real or demo badge |
P4 |
web/sandbox/targets/HospitalTimelineTarget.tsx + registry |
new — sandbox verification target | P5 |
No build-config, vite.config, or tsconfig changes. All edits are additive/targeted.
6. Migration draft — announcements social suite (Phase 3)
Manual apply only (Supabase migrations are not auto-deployed — see root CLAUDE.md deployment table). Idempotent (
IF NOT EXISTS/ON CONFLICT). Bilingual demo seed required per repo rule.
-- <date>_announcements_social.sql (frontend-owned social feed; NOT a read-model projection)
create table if not exists public.announcements (
id uuid primary key default gen_random_uuid(),
title text not null,
content text not null,
author_id text not null, author_name text, author_role text, author_avatar text,
organization_id text, department_id text,
is_global boolean default false, is_important boolean default false,
visibility text default 'global' check (visibility in ('global','organization','department')),
priority text default 'medium' check (priority in ('low','medium','high')),
tags text[], attachments text[],
likes_count int default 0, comments_count int default 0,
created_at timestamptz default now(), updated_at timestamptz default now()
);
create index if not exists idx_ann_global on public.announcements(is_global, created_at desc);
create index if not exists idx_ann_org on public.announcements(organization_id, created_at desc);
create index if not exists idx_ann_dept on public.announcements(department_id, created_at desc);
create table if not exists public.comments (
id uuid primary key default gen_random_uuid(),
announcement_id uuid references public.announcements(id) on delete cascade,
author_id text not null, author_name text, author_avatar text,
content text not null, parent_id uuid references public.comments(id) on delete cascade,
likes_count int default 0, created_at timestamptz default now(), updated_at timestamptz default now()
);
create table if not exists public.likes (
id uuid primary key default gen_random_uuid(),
announcement_id uuid references public.announcements(id) on delete cascade,
user_id text not null, created_at timestamptz default now(),
unique (announcement_id, user_id)
);
create table if not exists public.comment_likes (
id uuid primary key default gen_random_uuid(),
comment_id uuid references public.comments(id) on delete cascade,
user_id text not null, created_at timestamptz default now(),
unique (comment_id, user_id)
);
-- D7: only if no existing clinic source is reused
create table if not exists public.departments (
id uuid primary key default gen_random_uuid(),
name text not null, organization_id text, description text, parent_department_id uuid
);
create table if not exists public.user_departments (
id uuid primary key default gen_random_uuid(),
user_id text not null, department_id uuid references public.departments(id) on delete cascade
);
-- count RPCs referenced by announcement.service.ts
create or replace function public.increment_likes_count(announcement_id uuid) returns void language sql as
$$ update public.announcements set likes_count = likes_count + 1 where id = announcement_id $$;
create or replace function public.decrement_likes_count(announcement_id uuid) returns void language sql as
$$ update public.announcements set likes_count = greatest(0, likes_count - 1) where id = announcement_id $$;
create or replace function public.increment_comments_count(announcement_id uuid) returns void language sql as
$$ update public.announcements set comments_count = comments_count + 1 where id = announcement_id $$;
create or replace function public.increment_comment_likes_count(comment_id uuid) returns void language sql as
$$ update public.comments set likes_count = likes_count + 1 where id = comment_id $$;
create or replace function public.decrement_comment_likes_count(comment_id uuid) returns void language sql as
$$ update public.comments set likes_count = greatest(0, likes_count - 1) where id = comment_id $$;
-- RLS: authenticated read all; insert/like as self. (Tighten per tenant model.)
alter table public.announcements enable row level security;
alter table public.comments enable row level security;
alter table public.likes enable row level security;
alter table public.comment_likes enable row level security;
-- + policies (read: true; write: auth.role() = 'authenticated') — fill per project auth model.
-- bilingual demo seed (TH + EN) so the feed is non-empty on first load
insert into public.announcements (title, content, author_name, author_role, is_global, is_important, priority)
values
('ยินดีต้อนรับสู่ MedOS / Welcome to MedOS',
'ระบบไทม์ไลน์โรงพยาบาลพร้อมใช้งาน / The hospital timeline is now live.',
'System', 'Admin', true, true, 'high')
on conflict do nothing;
7. Phase plan
Phase 1 — Stats cards → live KPIs ✅ SHIPPED + VERIFIED (2026-06-04)
hospitalTimelineStats.service.ts: four aggregates, each independently try/caught, read via direct PostgRESTfetch(D8) with a 6 sAbortControllertimeout:- Bed occupancy % —
bed?select=status→ reduce:100 * (admitted+discharge_pending) / (total − out_of_service). - Avg wait (min) —
department_queues?select=status,created_at&status=in.(WAITING,ACKNOWLEDGED,IN_PROGRESS)→ mean ofnow − created_atover WAITING rows windowed to last 24 h (stale never-closed seed rows otherwise explode the average — observed 32,089 min before the window guard). - Patients waiting — count of WAITING + ACKNOWLEDGED (true board depth, un-windowed).
- Active staff (12 h) — distinct
user_idfromhospital_events?created_at=gte.…&user_id=not.is.null(relabeled per D4).
- Bed occupancy % —
useHospitalTimelineStats()→{ stats: StatItem[] | undefined, isLoading, isLive }; 45 s poll (D8).HospitalTimeline.tsx: call hook once; passdata={stats}to each `` (all 3 breakpoints). Undefineddatakeeps today’sDEFAULT_STAT_CONFIG(fail-soft).- New EN i18n keys under
statsCards.*(patientsWaiting,activeStaff,context.*); TH falls back viadefaultValue(theth/dashboard-components.jsonfile is a pre-existing gap). - Verified in the live app (
localhost:3000/hospital-timeline, authenticated): all three tables return 200; tiles render real values — Bed Occupancy 1% (11/999 beds), Patients Waiting 529, Avg Wait 0 min; the staff tile fails soft to its default (0 events on that instance). TypeScript clean. Login flow unaffected.
Phase 2 — Patient Stories → live encounters
from('encounter_journey_cache').select(...).order('updated_at',{desc}).limit(12), prioritisingactive_alerts/er_esi_level.- Map row →
Patientshape (vitals fromclinical_context); card click → real patient profile route. - Mock fallback when zero rows. Accept: live cards + working navigation; mock only when empty.
Phase 3 — Center feed (events + announcements)
- Apply §6 migration; resolve D7.
useHospitalEventFeed()reads recenthospital_events, maps viaeventFeedMapping.ts, subscribes realtime. HospitalFeed.tsx: render pinned announcements (existing service) above the live event stream; keep create/like/comment.- Accept: feed shows real events live; announcement post→appears pinned; empty states clean.
Phase 4 — Sidebars
- Left status cards ←
financial_summaryaggregate (or remove the$cards). Right KPI trend ← real series or visibledemobadge. Online staff stays gapped (D4).
Phase 5 — Verification
- Sandbox target
?target=HospitalTimeline+*.sandbox.spec.ts; fast-config Playwright against/hospital-timelineasserting live tiles render and the page never errors when read models are empty.
8. Invariants
- Login flow never breaks — empty/erroring read models ⇒ page renders today’s defaults (D6).
- Read models are read-only from this page; only the social tables are FE-writable (D5).
- Every aggregate is independently fail-soft — one failing query never blanks the others.
- Seed + UI copy stay bilingual (TH/EN).
- No build/tooling-config edits; changes confined to the
hospital-timeline/tree + one migration + one sandbox target. - Staff-on-duty is labeled as a proxy, never as true presence, until a real shift/presence model exists.
9. Open questions
- D7: is there an existing clinic/sub-clinic Supabase table the feed’s department dropdown should reuse instead of a new
departmentstable? - RLS/tenancy policy for the social tables (on-prem single-tenant vs cloud multi-tenant).
- Should the auto event-feed be filtered by the viewer’s department/org, or always hospital-wide?