medOS ultra

ICU Flowsheet Master Plan

World-best ICU flowsheet roadmap: fix-first patient-isolation/staleness, Tier-1 wiring, table-stakes vs MetaVision/ICCA/Epic, differentiators.

14 min read diagramsUpdated 2026-06-10docs/architecture/icu-flowsheet-world-best-master-plan.md

Status: Review completed 2026-06-11 (multi-agent: 2 deep review dimensions + 3 benchmark researchers vs MetaVision/ICCA/Epic/Centricity + unwired-asset audit, top findings code-verified firsthand). This doc is the single entry point for all further flowsheet work. Scope: web/packages/miniapps/dynamic-sheet-labour-room-v2/ — the V2 renderer behind ICU Monitoring + all DS_* presets. Read together with: docs/architecture/dynamic-sheet-platform-reconciliation.md (the 4-system map) and the companion docs/architecture/biomarker-graph-platform.md (reusable graphs over the central biomarker cache).


0. Verdict

The 2026-06 feature sprint (Filter, Saved Views, Row Pin, Patient Ranges, Flags, Comments, Auto-save chip, export, editor affordances — 474 jest/50 suites) built a visualization layer that “already exceeds many commercial flowsheets” (benchmark agent’s words). But it optimized for safe-additive shipping, and three structural debts now gate everything else:

  1. A critical patient-scoping bug (one-line fix).
  2. Clinical content living in browser localStorage (flags / comments / target ranges — a chart-integrity and compliance problem, not a style nit).
  3. Decorative UI — wired-looking controls that do nothing (sort, hideCompleted, showLastFiled).

Fix order: §1 → §2 → §3 (wire what’s built) → §4 (table-stakes) → §5 (differentiators).


1. Fix First (verified findings)

1.1 🔴 CRITICAL — cross-patient pref leakage (one-line fix)

patientId never reaches CustomDynamicSheetClean in the live render path:

  • dynamicSheetProps comes from useDynamicSheet({...}) which is never given and never returns patientId.
  • The callsite DynamicSheetMaster.tsx:405 adds only processRowUpdate / filledData / layoutType / customUiMetadata / showComponents.
  • So (props as any)?.patientId is always undefinedusePersonalRanges / useCellFlags / useCommentThreads all fall back to the shared 'default' localStorage key.

Impact: a target-range override set “for this patient” re-colours alarm cells for every patient opened on that browser; flags and comments surface on the wrong patient.

Fix: DynamicSheetMaster has patientId (and encounterId) in scope at the callsite — pass them:

<CustomDynamicSheetClean
  {...dynamicSheetProps}
  patientId={patientId}
  encounterId={encounterId}
  ...
/>

Prefer scoping flags/comments by encounterId (chart = per-encounter) and ranges by patientId.

1.2 🔴 Carry-forward charts stale vitals as fresh measurements

The editor’s “same as last” adornment (CustomCellEditor) has no staleness bound, hides the source timestamp, and commits with zero provenance — the value flows mirrorSheetVitals → observations → CDS/EWS/NEWS2/FHIR indistinguishable from a measured vital. Cloned flowsheet data is a known chart-integrity red flag (all 3 benchmark agents).

Fix (additive): (a) staleness cutoff — refuse carry-forward older than 2× the row’s effectiveIntervalMinutes; (b) show the source timestamp in the adornment tooltip; © stamp { provenance: 'carried-forward', sourceIso } on the committed cell value (object-cell path already exists) and render a small ↧ glyph via the cell-annotation wiring (§3.6).

1.3 🔴 The autosave chip can lie

AutoSaveChip is a 700 ms timer on rowDataMap ref changes; it never observes save outcomes, while saveRowData swallows Supabase upsert errors with console.warn and no setError. “Saved 14:32” can display over data that was never persisted (lost on refresh). Meanwhile autosave-core’s per-cell markDirty/markSaving/markSaved/markError/errorKeys state machine — the clinically meaningful part — is tested and unused.

Fix: thread the real save promise outcome from saveRowData / the upsert path into autosave-core; chip shows error state + which rows failed; surface a retry.

1.4 🟠 Range-override semantics are incoherent

PersonalRangesPanel edits only min/max while population warnMin/warnMax persist and are checked first — the module’s own documented COPD example (SpO₂ floor 88) still renders critical-low at 88-91. An unvalidated override (min>max, dropped digit) silences colouring entirely. The note/by/at provenance fields exist in the type but the panel never writes them.

Fix: override the whole band (warn bounds scale or clear with the override); validate min<max + plausible-range sanity; require a reason note; show an “override active” chip on the toolbar + a marker on affected rows.

1.5 🟠 Filter / saved-views safety holes

  • The “N hidden / Show all” banner counts only manual hides; filter-hidden rows get no banner and aren’t restored by Show all.
  • Rows with no configured range can never classify warn/critical → the builtin Abnormal/Critical views hide them permanently regardless of charted extremes.
  • Filter classifies on population ranges while cells colour on personal overrides — two truth systems.

Fix: one banner counting both sources; unknown-range rows treated as “unclassifiable ≠ normal” (kept visible in Abnormal view); feed effectiveNormalRanges to enrichRowsForFilter so filter and colour agree. (Verified mitigations: filters reset on reload; NEWS2 pinned row computes from full finalRows.)

1.6 🟠 Decorative UI — wire or hide

  • Sort is a no-op on the grid: applyFilterSort returns sorted rows and the picker + saved views persist sort, but Clean consumes only factsById — grid order comes solely from rowOrderPrefs. Wiring = derive sortOrderIds from filterResult.rows (Clean:545) → optional prop into the orderedRows memo (MainDataGrid.tsx:377).
  • hideCompleted / showLastFiled toggles (Clean:370) are consumed by nothing.
  • Toolbar buttons that never render because no caller passes the handler: onLastFiled, onSearch, onScreenshot.

1.7 🟠 i18n + UX debt (project-rule violations)

  • All 7 new panels are English-only — the project mandates bilingual TH+EN (and SORT_OPTIONS / CELL_KIND_CATEGORY_LABEL already carry Thai labels the panels discard).
  • Toolbar at 23 rendered controls, no overflow menu, 3 duplicate pairs; the 6 new buttons all piled into “Navigate”.
  • Five panels can stack ~1,300 px above the grid → all clinical data below the fold on 1080p; no panel has its own close (✕).
  • Touch targets 22–26 px — unusable at the bedside; no badge counts when panels are closed; flags/comments row pickers are flat Selects that don’t scale to 40-row sheets.

Fix: one shared `` wrapper; mutually-exclusive panel open (opening one closes others); badge counts on toolbar buttons (open flags, active filter, override count); group + overflow the toolbar; ≥40 px touch targets; searchable row pickers.


2. The Strategic Fix — clinical content out of localStorage

Classification (regulatory reviewer, code-verified):

Data Today Verdict Target home
Saved views, row order/pin, column pins localStorage personal pref — OK, better roaming per-user server store (sheet_settings-style JSONB keyed by user)
Cell flags (“recheck”, “query the doctor”) localStorage, authorless clinical content bridge to acknowledgement_requests (assignee/escalation/resolution already exist)
Comment threads (@mentions, author literally 'you') localStorage clinical content new app-owned table flowsheet_comments (author, encounter scope, realtime)
Per-patient target ranges (drive alarm colouring) localStorage functionally an alarm-limit order new patient_range_overrides table: who/why/when + auto-expiry/review interval + optional ordering-provider link

All five persistence hooks are one copy-pasted pattern — extract a single usePersistedPref(key, codec, backend) with a pluggable backend so the localStorage→server migration is one change, not five. Migration path: on first load with server backend, import any existing localStorage payload once, then mark migrated.

This is also the unlock for: multi-workstation consistency (two screens showing the same patient must colour the same SpO₂ the same way), shift handoff (flags survive browser clears), the legal print export (§4), and Japan’s 真正性/見読性/保存性 requirements.


3. Tier 1 — wire what’s already built (audit-confirmed injection points)

All cores below are tested and confirmed unwired (imported only by tests + sandbox). Ranked by value÷risk:

# Core Value Risk Injection point
1 handoff/handoff-core — SBAR shift digest 9 safe-additive Clean: inputs already in scope (filterRows:541 enriched, cellFlags:363); toolbar prop + panel cloned from the Flags block (:964-975). Benchmark agents independently called auto-SBAR a genuine differentiator — and ours is already built.
2 due/due-core — overdue-first round checklist 9 safe-additive Same toolbar+panel pattern; rows from exportRows:514; effectiveIntervalMinutes from DynamicSheetSettingsContext. Agrees with live per-row dueStatus by construction.
3 trend/trend-detect-core — “deteriorating” detection 9 safe-additive (panel) Phase 1: isDeteriorating(rowSeries, effectiveNormalRanges[r.id]) over filterRows → chip section inside the handoff panel. Phase 2 (display-path): extend rowIndicators call at MainDataGrid.tsx:568.
4 Activate sort (§1.6) 7 low filterResult.rowssortOrderIds prop → orderedRows memo after applyRowOrder.
5 columns/column-pin-core — freeze latest-N 6 low (MUI-native) resolvePinnedFields → DataGridPro pinnedColumns={{right}} on StylesDataGridPro (MainDataGrid.tsx:506) — dodges the finalColumns perf trap entirely. One grid per group → pass to each SingleSheetItem.
6 annotations/cell-annotation-core — on-cell flag/comment badges 7 display-path, attended Context provider in Clean (pattern: usePresence in CustomRenderCell.tsx:50); badge in cell body (:131-135); MUST amend the memo comparator cellPropsEqual (:150-161) or badges go stale on unedited cells. Ship with a Playwright cell-badge spec.
7 running/running-total-core — shift totals 7 safe-additive Add “since shift” cumulative lines to the already-wired OutputPanel (Clean:1083); shift boundaries from live shift-lock-engine (DEFAULT_SHIFTS/resolveShift).
8 cosign/cosign-core — witness for high-alert meds 8 clinical / 6 as-is write-path-adjacent Hook handleValueCommit (CustomCellEditor.tsx:259-334): after commit, requiresCosign(param) → pending-cosign row + toolbar badge; does NOT block the chart write. localStorage persistence materially weakens a two-person control — land server-side (§2) before claiming compliance value, and require witness re-auth (PIN/badge) or “witnessed” = one nurse clicking twice.
9 undo/undo-core 7 write-path Capture before/after in Clean’s processRowUpdate wrapper; undo re-charts through the SAME processRowUpdate so CDS/NEWS2/observation mirror stay consistent. Toolbar buttons first (keyboard collides with grid editing).
10 paste/paste-core — monitor-printout backfill 6 write-path Paste listener → planClipboardPastepreview/confirm dialog → commit each write through processRowUpdate. Never silent-paste.
11 keyboard/keyboard-nav-core 6 interaction-path Scope to the one real gap: Enter → next-cell-down while editing (MUI handles arrows natively).
12 derive/derive-core — shock index, anion gap, P/F… 6 safe-additive (tooltip) Blocker: registry inputs are canonical keys (sbp,hr) but live rows are openEHR gtXXXX ids — needs an id→canonical map (live utils/computedRows.ts already solves this per-preset via formula_inputs).
13 units/unit-convert-core 4 (TH) ↑ multi-region safe-additive Editor adornment slot (same pattern as plausibility) or display tooltip. Never change the stored canonical value.
14 bundles/value-bundle-core 5 write-path + blocked Pre-fill BatchEditModal (already imports fill-core). Same gtXXXX id blocker; prefer capture-current-column-as-bundle over clinically-dubious starter presets.

Dead code / unused capabilities (audit-verified): MainDataGridStable.tsx (692 lines) is dead in v2 — but do NOT delete without sign-off; aggregation/bucketRows is NOT dead (CCU sheet imports it through the live DynamicContentRenderer); shift-lock-engine is live core infrastructure (only hourOf/hourInShift unconsumed); per-cell openFlagsForCell/threadFor APIs activate with wiring #6.

Cross-cutting cautions for the wirer:

  • Copy the established fail-open shapes: hiddenRowIds union (Clean:547) and optional-prop-into-MainDynamicSheet (rowOrderPrefs, MainDataGrid.tsx:114, :377).
  • Anything touching CustomRenderCell must update cellPropsEqual.
  • Prefer grid-level props over column-builder changes — finalColumns (MainDataGrid.tsx:441-498) still has the finalRows→finalColumns rebuild coupling (the known structural perf debt; fixing it needs the editor’s CDS/NEWS2 merge re-pointed at live rows first — see dynamic-sheet-v2-upgrade memory, Round 15).
  • All new UI bilingual TH+EN, MUI icons only (heat-rule/derive cores already carry TH labels; due/handoff are EN-only — add TH at wiring time).

4. Tier 2 — table-stakes vs MetaVision / ICCA / Epic (all 3 researchers converged)

  1. Device-data validation workflow — monitor values land unvalidated (visually striped), nurse reviews/edits/files them; unfiled data ages out and never silently becomes chart-of-record. Today device vitals flow straight into observations/CDS — artifact (NIBP during shivering) becomes legal record. Biggest single credibility gap vs commercial CIS.
  2. Late entry / amendment / dual timestamps — every cell stores entered-at vs effective-at; corrections preserve the prior value (strikethrough + reason + author, on the chart face, not only in the audit modal); late entries auto-flagged past a policy window.
  3. End-of-shift sign-off + lock — missed-cell completeness check → attestation → segment lock → addenda-only. shift-lock-engine is the live substrate; this converts Japan 真正性 from blocked to demonstrable.
  4. Titration charting — infusion rows linked to the order’s titration bounds (start/increment/frequency/max/endpoint); each rate change captures the driving endpoint measurement (MAP/RASS); dual units (mL/h ↔ mcg/kg/min) via tracked dosing weight (admission/actual/dosing/ideal). The single most surveyed ICU charting requirement; nothing does this today.
  5. MAR/order-aware infusion rows — active drips auto-create linked flowsheet rows; volume-infused auto-flows into I&O intake.
  6. In-grid I&O hierarchy — this-hour / this-shift / 24 h / since-admission stacked subtotal rows per route (urine, drains, GI), urine in mL/kg/hr (running-total-core #7 is the seed).
  7. Score library beyond NEWS2 — GCS, RASS, CAM-ICU, Braden, pain scales, SOFA/APACHE. Cell-kind registry + score-row plumbing make these mostly data + per-score ui_metadata work.
  8. Nurse event markers — one-click timestamped clinical events (intubation, position change, code start) as vertical markers across all rows + event macros; today only system events overlay.
  9. Care-bundle rows tied to LDAs — CLABSI/CAUTI/VAP per-device checklist rows, dwell-day counters (line days/foley days/vent days), removal prompts, compliance % roll-up. LDA avatar + device registry are the substrate.
  10. Downtime story — read-only cached snapshot, printable forms matching the live layout, marked backfill mode.
  11. Mobile/bedside ergonomics — a focused single-time-column “chart now” entry view for tablets/WOWs (don’t shrink the grid; make a different surface), wristband-scan patient confirm (reuse @medical-kit/scanning).
  12. Legal-record-complete export — print/PDF includes amendment history, flags, comments, overrides, validation status (blocked on §2).
  13. Charting-by-exception (WDL) — facility-defined within-defined-limits criteria per assessment row, one-tap WDL, forced structured exception. Pairs with the assessment-template system.

5. Tier 3 — differentiators

  • Block charting mode — JC-sanctioned retrospective summary entry for a crashing patient (max 4 h window; emergency 5-min mode does the live-densification half; this is the legal retro half).
  • Retrospective monitor trend backfill — import stored monitor trends after transport/network gaps (marked as backfilled).
  • OR→ICU continuity — fluid totals + infusion states roll across the periop work-group rail as one record.
  • Site-configurable calc engine — hospital-authored formula rows (derive-core has the formulas; needs the id→canonical map + admin authoring UI; same data-not-code philosophy as policy_gates/cds_rules).
  • Alert governance analytics — unit-level alert burden, override audit, snooze patterns (feeds the e-Kardex shift_alert_log work).
  • Reusable biomarker graphs everywhere — see docs/architecture/biomarker-graph-platform.md (companion doc): the flowsheet becomes one writer and one reader of the central biomarker cache; graphs become portable miniapps.

6. Sequencing

Phase Content Effort
W1 — stop the bleeding §1.1 patientId (+encounter scoping), §1.2 carry-forward provenance+staleness, §1.3 honest autosave, §1.6 wire-or-hide, §1.7 i18n pass + `` wrapper + badges days
W2-3 — wire the gold Tier-1 #1-7 (handoff/due/trend/sort/column-pin/cell-badges/shift-totals) + §2 usePersistedPref + server tables + flag→ack bridge 1-2 wks
W4+ — first table-stakes epics Device validation workflow → sign-off/amendments (→ unlocks JP market claims) → titration charting per-epic
Then Scores library, bundles/LDA dwell, event markers, bedside surface, CBE backlog

Invariants (inherit from the loop discipline): additive + fail-open; never touch the CDS/commit write path blindly; pure tested core for new logic; package jest green per iteration; module-load-clean; bilingual; MUI icons only; localStorage only for true per-user view prefs.


7. How to build this (process + AI model)

Process

  • Run as module-loop iterations (docs/module-loop/00-MASTER.md, autonomous mode H) exactly like the R16/R17 sprints: one feature per fire, commit+push per sub-deliverable, sandbox targets + package jest as the substance, Playwright spec for anything display-path.
  • Verify recipe: Node 20 on PATH, vite sandbox :5179 (start bg + poll curl), ?target= smoke per surface; live toolbar can’t mount in sandbox (needs Supabase) — module-load-clean + adapter tests + eyeball on deploy.
  • Display-path items (#6 cell badges, device-validation striping) are attended — run with a real Supabase session and a screenshot check.

Which Claude model for which work (Claude Code)

Work Model Why
Architecture, clinical-safety-sensitive wiring (§1, write-path items, device validation), cross-package refactors Fable 5 (claude-fable-5), 1M context Highest-capability tier; this codebase’s cross-file blast radius (orchestrator + kit + grid + tests) benefits from the 1M window; correctness >> cost here.
Autonomous overnight loops (Tier-1 wirings, 50-100 iteration fix loops) Opus 4.8 (claude-opus-4-8) State-of-the-art long-horizon agentic execution at half Fable’s price ($5/$25 vs $10/$50 per MTok); 1M context; give the full task spec up front, run effort high/xhigh. Fast mode available for interactive iteration.
Mechanical sweeps: i18n namespace fills, test scaffolds, seed files, doc mirrors Sonnet 4.6 (claude-sonnet-4-6) Best speed/intelligence balance, $3/$15, 1M context — these tasks don’t need Opus-tier reasoning.
Explore/search subagents inside any session Haiku 4.5 Fast, cheap fan-out; keeps the main loop’s cache intact.

Rule of thumb: Fable 5 to design and to touch anything that can hurt a patient; Opus 4.8 to grind the long loops; Sonnet 4.6 for the mechanical tail. (Runtime in-product AI is a separate question — PHI inference stays on the local Ollama stack per opd-ai-autopilot.md; any cloud recommender uses claude-opus-4-8 per the repo’s API conventions.)

Ask Anything