medOS ultra

Hospital Timeline (Real Data)

Wiring the hospital timeline to hospital_events real data with full UX upgrade.

18 min read diagramsUpdated 2026-06-05docs/architecture/hospital-timeline-real-data.md

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_events instead). 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.tsxuseHospitalTimelineStats — branded “Hospital pulse” KPI header (occupancy / avg wait / waiting / active-staff) with icons + LIVE dot. Replaced StatsCards.
  • components/ActivePatients.tsxhooks/useTimelinePatients (reads department_queues) — real active patients. Replaced the mock PatientStories/mockData.ts (John Doe…).
  • components/ActivityFeed.tsxhooks/useHospitalEventFeed (reads hospital_events) — live activity timeline. Replaced the broken announcements HospitalFeed.
  • LeftSidebar.tsx “Department Pulse” + RightSidebar.tsx “Department load” bars + “Recent activity” chips ← useTimelinePatients/useHospitalEventFeed. Replaced the $10,023 mock 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_cache identity fields (name/hn/ward/dx) are null (identity backfill incomplete — see orchestrator-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) and hospital_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.tsx are no longer rendered. Safe to remove in a dedicated cleanup. Verified: tsc clean; bed/department_queues/hospital_events all 200; 0 console errors from the new code; login flow unaffected.

Update 2026-06-04b — real-data-only + full i18n

  • No fake fallbacks: useHospitalTimelineStats FALLBACK 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 English defaultValue so the page can never show a raw key or break). KPI labels moved from dashboard-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-time resources from src/locales/* via src/i18n/resources.ts. Added the key set to src/locales/en + src/locales/ja, created src/locales/th, and registered hospital-timeline: hospitalTimelineTH in the th block of resources.ts (TH previously fell back to EN — that’s why the page was English under the Thai nav). fil has 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-thailandth, his-japanja via 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-collab usePresence) — 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 same annoId.
  • timelineCollab.ts: useTimelineIdentity() (CollabUser from Redux), deterministic colorFor/initialsFor (avatars), orderAnnoId().
  • Richer patient cards: real avatar (deterministic colour + initials/HN), order name + assigned doctor (from department_queues.metadataimagingItemName/productName/assignedDoctorName), active-orders count chip, click → discussion dialog. useTimelinePatients + useHospitalEventFeed now expose patientId/orderRef/orderName/doctor/activeCount.
  • All new strings localized (discussion.*, activePatients.orders) in en/th/ja with English defaultValue.
  • ⚠️ Persistence caveat (same as the studio today): body-collab comments are session-durable via Supabase realtime broadcast, not yet persisted — the body_collab_messages table 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: usePatientSync does not exist (the “patient-sync” hits are a workflow-editor node); useEncounter/EncountersProvider is a single-encounter context (wrong scope); useCurrentUser is next-auth-based (this app runs on Redux JWT — empty here). No existing hook does hospital-wide, lock-safe reads (useSupabaseQueue/useDoctorWorkloadStats/useManifestList are 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 shared web/src/lib/supabaseRest.ts (supabaseRestSelect + supabaseRestInsert), used by the stats service, useTimelinePatients, useHospitalEventFeed, AND the body-collab transport; timelineRest.ts deleted. Identity now uses the canonical useAuth() (not raw useSelector). Intentionally NOT consolidated: EventCommentThread / initialsFor / timeAgo mirror sandbox-only equivalents in BodyModelAnnotationStudioTarget.tsx that can’t be imported from src/ (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.sqlbody_collab_messages(id, room_id, anno_id, kind, author_*, text, created_at) + 2 indexes + RLS. Policies are anon-permissive (read/insert USING/ WITH CHECK (true)), matching the other read-model tables: the app talks to PostgREST with the anon key (no Supabase auth session yet), so TO authenticated would 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 that room_id) and replays it to the client as a local sync-reply to this.userId (which usePresence merges); on addComment/sendChat, inserts a row in addition to the live broadcast. Both use direct PostgREST fetch (anon key, AbortController timeout) — NOT supabase-js .from() — to dodge the getSession navigator.locks deadlock (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 by own-ord-${orderRequestId} (from the event payload) while the studio keys order markers by own-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/useManifestRowbut 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. useDepartmentQueue is location-filtered and useManifestList needs a provider that isn’t mounted here. So the page gets a dedicated read service (hospitalTimelineStats.service.ts) doing direct aggregate reads on the centralized supabase client (@/lib/supabaseClient) — the same pattern as usage-dashboard.service.ts and department-command-center.tsx.

  • D2 — Patient Stories via direct encounter_journey_cache query, not useManifestList (no ManifestProvider on this route; pulling it in defeats the landing page’s light weight). Vitals come from the row’s clinical_context JSONB (no N+1 observations queries); fall back to one useObservations call 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.
  • D4 — Staff-on-duty gap, labeled honestly. No presence table exists. Derive “active staff (last 12 h)” = COUNT(DISTINCT user_id) from hospital_events and 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 a staff_shift model).

  • D5 — Read-model-only invariant. hospital_events, encounter_journey_cache, department_queues, bed, observations are read models — never written from the frontend here. The announcements/comments/likes tables 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 isLive dot 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 calls auth.getSession() (a navigator.locks acquisition) before every query. On this page that lock deadlocks under React StrictMode’s dev-only double-invoke (mount → cleanup → remount): the first mount’s getSession lock callback is abandoned by the StrictMode unmount and never releases, so the next query wedges before any HTTP request is issued (observed: load() runs, but bed never hits the network and never resolves). A plain fetch to /rest/v1/<table> with the anon key (the same access supabase-js uses when there is no Supabase session) + an AbortController timeout cannot hang and was verified live (bed 200/1000 rows, department_queues 200/529, hospital_events 200). 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 debug console.logs in HospitalFeed.tsx (since removed) + a no-op effect that logged “Effect: Cleanup”.

  • D7 — departments dropdown. The feed’s department filter queries a departments table that doesn’t exist. Prefer backing it with an existing clinic/sub-clinic source if one is FE-queryable; otherwise the thin departments table 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 newhospital_events → feed posts + realtime P3
…/hospital-timeline/eventFeedMapping.ts newevent_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 PostgREST fetch (D8) with a 6 s AbortController timeout:
    • 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 of now − created_at over 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_id from hospital_events?created_at=gte.…&user_id=not.is.null (relabeled per D4).
  • useHospitalTimelineStats(){ stats: StatItem[] | undefined, isLoading, isLive }; 45 s poll (D8).
  • HospitalTimeline.tsx: call hook once; pass data={stats} to each `` (all 3 breakpoints). Undefined data keeps today’s DEFAULT_STAT_CONFIG (fail-soft).
  • New EN i18n keys under statsCards.* (patientsWaiting, activeStaff, context.*); TH falls back via defaultValue (the th/dashboard-components.json file 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), prioritising active_alerts/er_esi_level.
  • Map row → Patient shape (vitals from clinical_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 recent hospital_events, maps via eventFeedMapping.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_summary aggregate (or remove the $ cards). Right KPI trend ← real series or visible demo badge. Online staff stays gapped (D4).

Phase 5 — Verification

  • Sandbox target ?target=HospitalTimeline + *.sandbox.spec.ts; fast-config Playwright against /hospital-timeline asserting live tiles render and the page never errors when read models are empty.

8. Invariants

  1. Login flow never breaks — empty/erroring read models ⇒ page renders today’s defaults (D6).
  2. Read models are read-only from this page; only the social tables are FE-writable (D5).
  3. Every aggregate is independently fail-soft — one failing query never blanks the others.
  4. Seed + UI copy stay bilingual (TH/EN).
  5. No build/tooling-config edits; changes confined to the hospital-timeline/ tree + one migration + one sandbox target.
  6. 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 departments table?
  • 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?
Ask Anything