medOS ultra

Dynamic Sheet Platform

Map + decisions reconciling the four parallel sheet systems: engine, IPD graphic sheet, openEHR presets, assessment builder.

27 min read diagramsUpdated 2026-06-04docs/architecture/dynamic-sheet-platform-reconciliation.md

Status: Map / decision aid (2026-06-02). No code changed. Written to pick a convergence direction before touching anything. Companion to web/packages/miniapps/dynamic-sheet-template-store/DYNAMIC-SHEET-PRESETS.md (preset internals) and docs/architecture/ipd-nursing-assessment.md (assessment surfaces).

Why this doc exists

A user looking at the ICU Monitoring flowsheet (Minimized · 60m bucket / Default rule: latest / Raw interval / Hide Comp'd + biomarker rows) asked where the management page is. The honest answer is: there are four parallel systems that each own a slice of “sheets,” grown at different times, with three different template tables and four different “stores.” The page they found (/dynamic-sheet-template-store) is orphaned from nav and edits a table the ICU sheet never reads.

This document inventories all four, shows the seams, and lays out convergence options with trade-offs.

Hard guardrail for any follow-up work: the IPD Graphic Sheet is functioning and must not be changed. It is a source to reuse (snapshot into presets), never a refactor target.


The four systems

# System Author/Manage surface Route Renders via Backing store
1 Original graphic-sheet engine — (engine) DynamicGraphicSheet (@miniapps/dynamic-graphic-sheet) dynamic_sheet_templates + dynamic_sheet_usages
2 Functioning IPD Graphic Sheet ⚠️ don’t change — (built surface) CoreApp IpdGraphicSheet / GraphicSheet / GraphicSheetOpd; IPDMasterGraphicSheet IpdGraphicSheetMiniapp (@miniapps/ipd-graphic-sheet) → engine #1 engine #1’s templates (per-section templateRef)
3 openEHR preset library (ICU Monitoring lives here) Template Store /dynamic-sheet-template-store (also CoreApp DynamicGraphicSheetGrouping) DynamicSheetPresetRendererV2 (@miniapps/dynamic-sheet-labour-room-v2) bundled JSON in presets/ (store edits dynamic_sheet_templates, a different source — see Seam A)
4 Assessment guideline builder (richest authoring) Assessment Builder / Store /assessment-manager, /assessment-manager/:id builder UI (tree + GDL + calc + visualizer) assessment_templates + assessment_comments
+ Work Groups (the grouping layer) Work Groups admin /admin/dynamic-sheet-work-groups DynamicSheetWorkGroupRenderer dynamic_sheet_work_groups

What each render path actually is (verified)

  • ICU Monitoring = CoreApp DS_IPD_ICU (modules.DsIpdIcu). In DynamicContentRenderer.tsx:4151 it (and every DS_IPD_* / DS_SCORE_*) routes to DynamicSheetPresetRendererV2 — the MasterMenuBar / bucket / aggregation renderer.
  • That V2 renderer’s own header says it “imports the preset JSON registry directly so it works without a row in Supabase dynamic_sheet_templates.” It calls getPresetDataByTemplateGroupId(...) → compiled-in presets/ipd/ipd-icu-monitoring.json. It never reads the DB.
  • The Template Store (DynamicSheetTemplateStore) is CRUD over the dynamic_sheet_templates table. CoreApp enums DynamicGraphicSheetGrouping and DynamicSheetTemplateStore both mount it (DynamicContentRenderer.tsx:3845-3851).
  • The IPD Graphic Sheet (IpdGraphicSheetMiniapp.tsx) is a card launcher over 3 grouped sectionsสัญญาณชีพ (vitals) / สารน้ำเข้า–ออก (I/O) / การประเมินทางการพยาบาล (nursing). Each card opens DynamicGraphicSheet with a templateRef. The 3 sections currently leave templateRef unset, so the engine falls back to its default IPD template ((ใช้เทมเพลต IPD เริ่มต้น) in the fallback dialog).

The data tables

Table Owner system Key columns Holds
dynamic_sheet_templates #1 engine, #3 store id, template_group_id, is_current, name, description, version_number, content{graphs,toolsOrGroups,tabColumns}, sheet_settings{startTime,endTime,trackHeight,timeIntervalInMinutes,multiTabs,dynamicSheetType,firstColumnWidth,columnWidth,default*Panel,vitalSignsDevices} sheet structure
dynamic_sheet_usages #1, #3 template_id, patient_id, encounter_id, filled_data (jsonb), updated_at filled values per patient. Conflict key (template_id, patient_id, encounter_id)
dynamic_sheet_column_deletions #1 tombstones for removed columns
assessment_templates #4 builder id, template_group_id, is_current, name, description, version_number, content assessment structure (tree/GDL) — separate table from sheets
assessment_comments #4 assessment_template_id, user_id, comment_text, created_at review comments / collaboration
dynamic_sheet_work_groups Work Groups name, label_en/th, description, sheet_enum_values[], sheet_overrides{pinned,readonly,timeWindowMinutes,badge,hint}, orientation, vertical_width, category, use_case, icon_color, is_template, enabled, region, organization_id groupings (reference sheets by CoreApp enum, with per-sheet overrides)

Migrations: web/supabase/migrations/20260514_dynamic_sheet_work_groups*.sql, 20260514_user_workgroup_state.sql; flowsheet/CDS/device sync in infrastructure/medbase/migrations/022_flowsheet_cds_device_sync.sql.


The seams (why it feels disconnected)

  • Seam A — store edits a source the renderer ignores. /dynamic-sheet-template-store writes dynamic_sheet_templates; ICU Monitoring (and all V2 presets) render from bundled JSON. Editing the store changes nothing on screen for those presets.
  • Seam B — store is orphaned from navigation. Zero nav links to /dynamic-sheet-template-store (verified across navigation/ and setup/system/navigation/). Reachable only by typing the URL, or via the in-profile CoreApp tab DynamicGraphicSheetGrouping.
  • Seam C — three template tables. dynamic_sheet_templates (sheets), assessment_templates (assessments), and bundled JSON all describe “a template,” none is canonical, and the richest editor (#4) writes the table the renderers (#1/#3) don’t read.
  • Seam D — name collisions. “IPD Graphic Sheet,” “IPD Master Graphic Sheet,” “Graphic Sheet,” “Dynamic Graphic Sheet Grouping,” and “ICU Monitoring” are five different modules with three different backings. Users (reasonably) assume one governs another.

Convergence options (author-home) — pick one

User input so far: “map first, decide later” on the home; “snapshot” for IPD reuse.

Option A — Assessment Builder (/assessment-manager) becomes the home

  • For: Already the richest — tree node editor, biomarker picker, GDL rule generation, calculation builder, algorithm visualizer, multi-language, version history, comments, app-store listing (Hero/Featured/Ratings/Install). Authoring UX is largely done.
  • Against: Writes assessment_templates, not dynamic_sheet_templates/JSON. Convergence ⇒ either (a) a projection assessment_templates → dynamic_sheet_templates, or (b) teach V2 renderer to read assessment_templates. Non-trivial schema bridge.
  • Best when: we want one authoring tool and are willing to add a publish/projection step.

Option B — Work Groups (/admin/dynamic-sheet-work-groups) becomes the catalog home

  • For: Already the grouping layer; references sheets by enum with per-sheet overrides; has is_template, region, org scoping, enable/disable. Natural “catalog of catalogs.”
  • Against: It composes sheets, it doesn’t author their rows. Still need an authoring tool underneath (A or #3). Good as the surface, not the editor.
  • Best when: we want a curated, per-hospital catalog and keep authoring elsewhere.

Option C — Template Store (/dynamic-sheet-template-store) gets un-orphaned + promoted

  • For: Smallest move — add nav link; make V2 renderer prefer a dynamic_sheet_templates row over bundled JSON (fallback preserved). Then the store edits actually show up.
  • Against: Weakest editor of the three; duplicates capability the Assessment Builder already has; risks entrenching the 3-table split rather than healing it.
  • Best when: we want the fastest “edits take effect” win and defer the bigger unification.

Recommendation: B for the surface + A for authoring. Work Groups is the catalog users browse; the Assessment Builder is the one editor; presets and the IPD-sheet snapshot are entries in the catalog. Option C’s “prefer-DB-row-then-JSON” trick is still worth doing as the rendering bridge regardless of home, because it’s what makes any live edit visible without touching working renderers.


IPD Graphic Sheet → preset (the “snapshot” path you chose)

Goal: expose the functioning IPD Graphic Sheet’s sections as read-only preset-template entries in the catalog, without changing the working sheet.

Source of the snapshot: the dynamic_sheet_templates content the IPD sheet resolves to. Today the 3 sections (vitals / io / nursing) have no explicit templateRef → engine default. So the snapshot has two honest sub-options:

  1. Make the sections explicit first (recommended, additive): assign each SECTIONS[*].templateRef to a real dynamic_sheet_templates row, then snapshot those rows into preset entries. Deterministic, and it documents what the IPD sheet actually shows. Touches only the SECTIONS array — additive, the render path is unchanged when a templateRef is present. (Confirm with user: this is the one small edit to the IPD miniapp; if even that is off-limits, use #2.)
  2. Snapshot the engine default template as-is: capture whatever the default IPD template renders and freeze it as preset entries. Zero edits to the IPD miniapp, but the snapshot source is implicit (wherever the engine’s default lives).

Mechanism: there is already a Duplicate path in the store (DynamicSheetTemplateStore.tsx handleDuplicateTemplate) and a PresetSelector. A snapshot = duplicate the resolved template → tag as a bundled/registry preset (or a dynamic_sheet_templates row with is_template), register an enum + registry entry per the “Adding a New Preset” steps in DYNAMIC-SHEET-PRESETS.md.

Result: IPD Graphic Sheet stays exactly as-is; its content also appears as a reusable preset the catalog/work-groups can offer to other wards — frozen copy (snapshot), edited later only via the chosen author-home.


Decisions (locked 2026-06-02 — “you decide all”)

  1. Author-home: B-surface + A-author — Work Groups (/admin/dynamic-sheet-work-groups) is the catalog users browse; the Assessment Builder (/assessment-manager) is the one authoring tool.
  2. Rendering bridge: YES, shipped. DynamicSheetPresetRendererV2 now prefers a live dynamic_sheet_templates row (same template_group_id, is_current), falls back to bundled JSON on no-row / error / loading. Additive, fail-soft.
  3. IPD snapshot: zero-edit path — the functioning IPD Graphic Sheet is untouched; reuse is via new additive preset entries only (staged, not yet authored).
  4. Table unification: deferred — bridged at the renderer for now; assessment_templatesdynamic_sheet_templates projection is a later workstream.

Implementation status

Shipped (this change — additive, fail-soft, no working renderer touched)

What File Effect
Rendering bridge web/packages/miniapps/dynamic-sheet-labour-room-v2/DynamicSheetPresetRendererV2.tsx Fetches a live dynamic_sheet_templates row by template_group_id; live row wins, bundled JSON is the fallback. Remount key on source flip.
Customize preserves group id web/packages/miniapps/dynamic-sheet-template-store/components/PresetSelector.tsx + DynamicSheetTemplateStore.tsx “Load from Preset” now keeps the preset’s real template_group_id, so a saved row becomes a live override the bridge picks up (instead of a random, unreachable id).
Un-orphan surfaces web/src/common/components/navigation/navigation-ever/menuData.tsx Admin-Panel menu now links Dynamic Sheet Template Store (/dynamic-sheet-template-store) and Dynamic Sheet Work Groups (/admin/dynamic-sheet-work-groups).

End-to-end customize flow now works: open Template Store → Load from Preset → pick e.g. ICU Monitoring (form pre-fills content/settings and the canonical group id) → edit rows/bucket/rule/ interval JSON → Save → patient-profile ICU Monitoring tab renders the live row. Delete the row → falls back to bundled JSON. No DS_* preset changes behavior until someone saves an override.

Shipped — shift system + time-based edit lock (V2 flowsheet)

Two config-driven features on the time-based grid, built on a pure tested engine (shift-lock-engine.ts, 12 unit tests):

Feature Behaviour
Shifts (เวรเช้า/บ่าย/ดึก, 8h, settable) Each time column is bucketed by local hour → coloured top-border on the header; filter chips in MasterMenuBar (icon + colour, click to show/hide a shift’s columns). Edit labels/windows/colours in Sheet Settings. Handles midnight wrap.
Time-based edit lock Cells older than editLockHours become read-only (dimmed + tooltip); future/current always editable. Default 24h ON (set 0/blank in Sheet Settings to disable).
Shift band per day BuildColumnGroupingModel.ts nests the column-group header day → shift → columns (coloured shift band beneath each date). Default shift order = ดึก → เช้า → บ่าย (chronological).
View & Filter drawer components/ViewFilterDrawer.tsx — ONE consolidated slide-in panel (toolbar Tune button, replaces the inline chips) holding: global shift filter chips, edit-lock field, shift definitions (colour/label/window), and History + Audit launchers. Self-contained (reads DynamicSheetSettingsContext). Audit/History removed from the overflow menu. Toolbar button shows a dot when a filter is active.
Per-day shift toggle Kept in the grid day-header DayShiftHeader.tsx chips (always lists all shifts → re-showable) → toggleDayShift(day, shiftId). The coloured shift bands are now display-only. Per-day state dayShiftFilter ({YYYY-MM-DD: shiftId[]}); BuildFlowSheet ANDs the global filter (drawer) with the per-day filter. Default shift order ดึก → เช้า → บ่าย.
History calendar (range + IPD auto-range) modals/HistoryCalendarModal.tsx — overflow menu → “ปฏิทินย้อนหลัง” and a button beneath the chart (CustomDynamicSheetClean, sticky-left bar with a live “ช่วงที่แสดง: start → end” range summary). UTC month grid + data-availability heatmap (usage-history.ts, 10 unit tests). Range selection: click a day → auto-selects the whole IPD encounter span containing it (encounterRanges/encounterForDay); click inside the selection → begin a custom narrower range; encounter quick-pick chips; Apply → sets startTime/endTime to the range so the grid renders multiple days. First/last-data quick-jumps.
All-data audit log modals/AuditLogModal.tsx — overflow menu → “ประวัติการบันทึก · Audit log”. Queries dynamic_sheet_usages for this patient_id (+ template_id), flattens filled_data into (parameter, timestamp, value) entries, groups by encounter, dark log viewer + search + CSV export. Context flag auditLogModalOpen; mounted in DynamicSheetMasterContent (where patientId lives). Scope: latest value per (param, time-column) = the charted timeline, NOT per-keystroke overwrite history (that needs a dedicated append-only audit table — staged).

Wiring (additive; empty shift filter + engine defaults = colour-only, no hidden columns): shift-lock-engine.tsDynamicSheetSettingsContext (state shifts/visibleShiftIds/editLockHours

  • persist to sheet_settings + load hydration) → SheetSettingsModal (lock-hours field + per-shift colour/label/window editor) → MasterMenuBar (filter chips) → BuildFlowSheet.buildFlowsheetColumns (header tint + column filter + isCellEditable lock + locked-cell visual) → both grid callers MainDataGrid (clean/preset path — ICU Monitoring) and MainDataGridStable (standard path).

⚠️ 24h lock is ON by default — historical columns (e.g. demo seed timestamps from 2025) render read-only. Intended; set lock to 0/blank in Sheet Settings to edit old cells. Persistence for a bundled preset follows the same rule as timeless (session-only unless saved as a Template-Store override row).

Shipped — V2 upgrade (2026-06-04): perf · bug · linkage · timeline · rules

Five additive, fail-soft workstreams on the V2 renderer (@miniapps/dynamic-sheet-labour-room-v2), all behind defaults that reproduce today’s behavior. Pure logic unit-tested (84 tests, 8 suites).

# What Key files
1 Perf — “slow on enter value then enter” fixed. Stripped hot-path console.log of large objects (fired every render + keystroke); broke the RowDataProvider realtime echo loop (own upsert → postgres_changessetRowsDataMaster → upsert again) with an echo-suppress window + no-op guard, and debounced the full-blob dynamic_sheet_usages upsert (700 ms, flush on unmount/id-change/reconnect); wrapped processRowUpdate/handleProcessRowUpdate in useCallback so the finalColumns memo holds. contexts/RowDataProvider.tsx, useDynamicSheet.tsx, DynamicSheetMaster.tsx, components/{MainDataGrid,BuildFlowSheet}.tsx, datagrid-components/CustomCellEditor.tsx
2 Icon/Option bug fixed. Root cause = ui_metadata shape mismatch (AddVitalSignDialog wrote group-level {type:'OBSERVATION', value_type, normal_range, icon}; editor read type/min/max/options; grid icon read only row.ui_metadata.icon). New utils/resolveFieldMetadata.ts (1 resolver: value_type→editor type, normal_range→min/max, surfaces options/icon, group-level resolution) used by BuildFlowSheet.renderEditCell + the grid icon renderer; dialog gained Select/Radio + an options editor; radio branch aligned to select’s option-value resolution. utils/resolveFieldMetadata.ts, modals/AddVitalSignDialog.tsx, components/{BuildFlowSheet,MainDataGrid}.tsx, datagrid-components/CustomCellEditor.tsx
3 Central-hook linkage (was siloed). WRITE: DynamicSheetMaster.handleProcessRowUpdate now calls mirrorSheetVitals (the previously-unused bridge) fire-and-forget → Mongo VitalSign → orchestrator → observations → CDS/EWS/worklist/Patient 360. READ: ObservationsContext + buildHydrationMap render DISPLAY-ONLY ghost values in empty cells. No-ops on non-Mongo ids. DynamicSheetMaster.tsx, contexts/ObservationsContext.ts, components/{MainDataGrid,BuildFlowSheet,FlowsheetCell}.tsx, reuses observation-bridge.ts
4 Timeline lens (per-row strips and aligned overview). timeline/timeline-core.ts (pure): grey total-time axis + central baseline (normal midpoint) + markers deviating up/down, colour+shape by evaluateRange status (●normal ▲warn ◆critical). RowTimelineSparkline in the grid name column; SheetTimelineOverview full-width panel above the grid. Toggle in MasterMenuBar (TimelineIcon), persisted showTimeline in sheet_settings. timeline/{timeline-core,RowTimelineSparkline,SheetTimelineOverview}.tsx, DynamicSheetSettingsContext.tsx, CustomDynamicSheetClean.tsx, components/{MainDataGrid,MasterMenuBar}.tsx
5 Configurable scheduling rules (CCU 1h / emergency 5m / pause-after-N + regenerate). schedule-engine.ts (pure, policy_gates-style scope/predicate/action+priority): resolveEffectiveSchedule(rules, ctx). Context derives effectiveIntervalMinutes/effectiveBucketMinutes/schedulePaused (real wall-clock tick); column generation + add-column consume the effective interval; auto-extend respects pause; guarded self-limiting “regenerate” rolls the window. Emergency (⚡) toggle in MasterMenuBar; full rules editor in SheetSettingsModal. Persisted schedulingRules in sheet_settings. schedule-engine.ts, DynamicSheetSettingsContext.tsx, useDynamicSheet.tsx, components/MasterMenuBar.tsx, modals/SheetSettingsModal.tsx

Gotchas hardened in passing: the dual save path (rules field via setRowsDataMaster; non-rules via saveRowData) and the inconsistent filled_data shape ({groupId:{rowId:row}} vs {groupId:[rows]}) — the timeline overview reads tolerantly across both. IPD Graphic Sheet (#2) remains untouched.

Shipped — V2 lenses round 2 (2026-06-04): score row + 4 opt-in lenses

All additive, default-off, persisted in sheet_settings, toggled from MasterMenuBar. Pure cores injected/relative so they unit-test under the mapper-less jest (112 tests, 11 suites).

  • Computed NEWS2 score OUTPUT row (scoring/sheet-score-core.ts + sheet-score.ts): the answer to “scores must have an OUTPUT row” on non-GDL sheets. Recognizes RR/SpO2/SBP/HR/Temp/ consciousness/onOxygen rows (explicit ui_metadata.news2_component or name/code heuristics), maps each time column → News2Vitals, reuses the pure @miniapps/focus-list-enhanced/news2 calculator, and appends a synthetic pinned OUTPUT row to buildPinnedRows (never mutates the user’s BuildPinnedRow.ts). Cells coloured by tier via _scoreMeta. Toggle showScoreRow (Σ).
  • Name-row indicators (indicators/row-indicators-core.ts + RowIndicators.tsx): per biomarker — status dot, trend Δ vs previous, reference-range chip, and a “due now” alarm when the last reading is older than the scheduling engine’s effective interval. Toggle showRowIndicators.
  • Timeline enrichment (in timeline-core.ts + RowTimelineSparkline.tsx): shaded normal-range band, dashed warn bounds, peak/trough rings — rendered whenever the timeline is on (no separate toggle); the overview gets them for free.
  • Clock + events overlay (events/events-core.ts + useSheetEvents.ts + SheetEventsContext): time-anchored flags from hospital_events (orders/meds/acks/alerts/status) + shift-change boundaries + a live “now” line, on the overview’s shared axis. Query skipped while off; fail-soft. Toggle showEvents.

Note: the OUTPUT-row regression report was investigated and ruled outBuildPinnedRow.ts has one commit (the user’s 81daa3da, 2026-05-13) and was never touched; OUTPUT rows always rendered only for GDL templates with type:'OUTPUT' data_bindings. The score row above makes a score OUTPUT row available on any sheet.

Shipped — V2 round 3 (2026-06-04): Output surface + Linked Devices

  • Pluggable Output panel (output/): opt-in panel with an output-renderer registry (output-registry.tsx) so the output side can host multiple “pages”, not just the inline score row. Flagship renderer = Fluid I/O balanceio-balance-core.ts (pure, 9 tests) recognizes intake/output rows (ui_metadata.io_role or name heuristics; matches the bundled Fluid-Balance set) and folds each column → {intake,output,net,cumulative}; IoBalanceChart.tsx draws a pure-SVG “candle” (intake up / output down from a central zero baseline + running net line). OutputPanel.tsx gathers rows via the new utils/gatherSheetRows.ts, offers only renderers whose canRender passes. Toggle showOutputs; mounted in both grid variants.
  • Linked Devices panel (devices/): useDeviceRegistry.ts reads the real device_pairings registry (migration 022) by patient, realtime; device-status-core.ts (pure, 11 tests) derives live/idle/down/standby/maintenance from last_heartbeat; LinkedDevicesPanel.tsx lists devices + capabilities + connection dot + last device-sourced value, headed by central sync status from useObservationSync (online + pending count). Toggle showDevices; mounted in the master shell.
  • Menu bar now has 8 opt-in toggles, all covered by the render+click validation test (menu-bar-toggles.test.tsx). Suite: 131 tests / 14 suites.

Shipped — V2 round 4 (2026-06-04): flowsheet toolbar button sweep

The Epic-style components/FlowsheetToolbar.tsx greyed out (isWired = !!onClicktext.disabled + 0.55 opacity) every button whose handler the sole caller (CustomDynamicSheetClean, the ICU/clean grid) never passed — so most of the toolbar was dead/decorative. Fixed without touching the working render path:

  • TbBtn renders null when unwired — no more greyed dead buttons. A wired-but- conditionally-disabled button (e.g. Clear All when there are no columns) still renders.
  • Caller now passes real handlers: Go to Date → openHistoryCalendar, History / Correct → openAuditLogModal, Device Data → toggleDevices (the round-3 panel), Graph → openAddGraphModal (existing local graph-builder), Add Rows → a local AddVitalSignDialog, Print → window.print.
  • “File” → “Save”, wired to the real saveDynamicTemplate() (it was calling the no-op saveData stub in useDynamicSheet, so the button did nothing; that stub stays for its other call site).
  • Dead dropdown items wired: View Corrections → onChartCorrection, Bar Graph → onGraph.
  • Removed 3 hardcoded prop-less placeholders (Responsible / LDA Avatar / Reg Doc) — no backing feature. Search already exists in LeftPanel (the searchTerm box); Snapshot folds into Print — both simply auto-hide rather than show greyed.
  • New __tests__/flowsheet-toolbar.test.tsx (6 cases, createRoot+act, pure-props no mocks) locks the contract: unwired → hidden, wired → renders + fires on click, both MUI portal dropdowns route to their handlers. Suite: 137 tests / 15 suites. Commit 2393f8b34.

Shipped — V2 round 5 (2026-06-04): real LDA Avatar (3D) + Reg Doc

The two toolbar placeholders removed in round 4 are now fully-wired features (the user asked for “real” buttons — LDA Avatar as a 3D model, possibly count/packing; Reg Doc built after).

  • LDA Avatar (lda/LdaAvatarDialog.tsx) mounts the existing 3D BodyModelViewer (@medical-kit/body-model-3d, R3F + three, lazy-loaded). Its BodyOverlay type already supports kind: 'line' | 'drain' | 'airway' | 'count' | 'packing' with per-kind colour, so surgical count + packing are first-class. Pure lda/lda-core.ts (19 tests): recognizeLdaKind (explicit ui_metadata.lda_role → device-type code CVC/PICC/FOLEY/ETT/NG/JP → keyword heuristic), mapRegion → real BODY_REGION_3D tokens (chest / bladder / mouth / neck / forearm-L / abdomen …), buildLdaItems from (1) encounter_journey_cache.access_lines (via useManifestRow) + (2) flowsheet rows (gatherSheetRows + latest value), ldaItemsToOverlays, summarizeLda. Dialog = 3D body + side list grouped by kind + male/female toggle + WebGL error boundary + bilingual empty state.
  • Reg Doc (regdoc/RegDocDialog.tsx + pure regdoc/regdoc-print.ts, 5 tests): buildRegDocModel from useManifestRow (HN / AN / name / class / ward / bed / admit / doctor / working-dx) + regDocToHtml (HTML-escaped, bilingual TH·EN). Dialog shows an exact print preview of the same HTML the Print button opens in a new window — one source of truth.
  • Wiring mirrors the AuditLogModal pattern: context flags openLdaAvatar / closeLdaAvatar / openRegDoc / closeRegDoc → both dialogs mounted in the DynamicSheetMaster shell (which has patientId / encounterId) → FlowsheetToolbar buttons (onLdaAvatar / onRegDoc) call the context openers from CustomDynamicSheetClean. useManifestRow is passed undefined while the dialog is closed (no realtime subscription). Suite: 161 tests / 17 suites. Commit 927e3370b.

Shipped — V2 round 6 (2026-06-04): real OR count/packing + domain grouping

The LDA Avatar’s count + packing (the OR / surgical domain) now pull the canonical operating-room data, and the avatar is organized by clinical domain.

  • Domain model in lda-core: LdaDomain = 'access' | 'surgical' + kindDomain (line/drain/airway → access; count/packing → surgical) + DOMAIN_LABEL / DOMAIN_ORDER. LdaItem gains domain + pin (false = case-level, listed but not anchored on the body).
  • buildPackingItems (from or_packing_management): pinned packing items; region from category (vaginal→pelvis, nasal→nose, abdominal→abdomen, ear→ear-R, rectal→rectum) or the free-text site (exported matchSiteRegion — a specific site beats the generic category); skips fully_removed; surfaces retained packing + “N in situ”.
  • buildCountItems (from or_intraop_counts): one pin:false case-level status per count phase (initial / first / second / pre-implant / closing) with discrepancy + X-ray flags. Counts are listed under the OR domain but never become a body pin (they have no anatomical site).
  • Reader lda/useOrLdaData.ts reads both tables by encounter_id (both carry the column + an index — so no or_request_id / or_state_cache resolution is needed) via useMedBase, fetch-on-open
    • fail-soft (mirrors useDeviceRegistry). Stays decoupled from @periops-kit (whose count hook is or_request_id-only).
  • Dialog: merges OR rows into the item set; the side list now groups by domain (Access & Airway vs Surgical · OR) then by kind; case-level count rows render “case-level” instead of a region. +10 lda-core tests. Suite: 171 tests / 17 suites. Commit 9598b2535.

Shipped — V2 round 7 (2026-06-04): LDA Avatar polish (clickable pins · realtime · surgical drains)

The three follow-ups flagged at the end of round 6:

  • Clickable body pins ⇄ side-list. BodyModelViewer (@medical-kit/body-model-3d) gained two purely additive optional props — selectedOverlayId + onSelectOverlay (default undefined, so Patient 360 / Clinical Diagnosis Hub render byte-identically). OverlayBadge is now a selectable button (ring + fill when selected). The avatar wires both directions: click a body pin → its side-list row highlights + scrolls into view; click a side-list row → its body pin highlights; click again clears.
  • Live realtime. useOrLdaData subscribes to postgres_changes on or_packing_management + or_intraop_counts (filter encounter_id) and re-fetches — packing placed/removed or a count reconciled mid-case updates the body without reopening the dialog. (Cleanup via removeChannel.)
  • Surgical drains. New resolveDomain(kind, typeOrName) in lda-core routes operative drains (JP / Penrose / Hemovac / Blake / chest tube / ICD / thoracostomy / … — by type code or free-text name) into the Surgical · OR domain, while a bedside Foley / generic drain stays in Access & Airway. There is no drains table — surgical drains are surfaced from access_lines (the handleAccessLinesProjection.ts read-model column) + flowsheet rows; the OR time-out’s drain fields are dialog state, not a clean read model. buildLdaItems now uses resolveDomain (not kindDomain).
  • +4 lda-core tests. Suite: 175 tests / 17 suites. Commit 68c177e8d.

Shipped — V2 round 8 (2026-06-04): phase-grouped work-group rail (pre/intra/post in one miniapp)

The parent that groups N sheets into one tabbed shell already existedDynamicSheetWorkGroup (+ DynamicSheetWorkGroupRenderer system wrapper, DynamicSheetWorkGroupById, the dynamic_sheet_work_groups table with ordered sheet_enum_values + per-member sheet_overrides, the /admin/dynamic-sheet-work-groups CRUD, the modules.DynamicSheetWorkGroup module, and cross-device user_workgroup_state). It renders one DynamicSheetMaster per tab (lazy-mounted; horizontal / vertical / print; presence). The only thing missing for the user’s “pre / intra / post in one miniapp” was phase grouping — added additively:

  • New pure components/workgroup-sections.tshasSections + groupSheetsBySection (first-seen order, trailing ungrouped bucket, non-mutating). 7 tests.
  • DynamicSheetWorkGroupSheet gains optional section. When any sheet sets it, the rail renders a grouped sidebar (MUI List + a header per phase) instead of the flat Tabs. The right-hand panes, activeId, and lazy-mount logic are shared/unchanged; with no section the rail is byte-identical to before. Section mode preserves the authored order (skips the pinned-first sort) so each phase stays contiguous.
  • section is threaded end-to-end: DynamicSheetWorkGroupRenderer override mapping → service WorkGroupSheetOverride → the admin edit dialog (a per-member Section field + a chip in the collapsed row), so DB-driven groups can author phases in dynamic_sheet_work_groups.sheet_overrides.
  • Demo: sandbox target PerioperativeWorkspace (Pre / Intra / Post with a paneRenderer stub) + e2e/perioperative-workspace.sandbox.spec.ts (3 serial specs). Verified the rail renders every phase’s members, selecting a sheet swaps the pane, and the flat-tabs fallback works. Suite: 182 tests / 18 suites; sandbox e2e 3/3. Commit 3658b3af3.

Shipped — V2 round 9 (2026-06-04): heterogeneous action cell kinds (intervention / injection / device)

New flowsheet row/cell types beyond vitals, each with its own in-cell visualization — and they render inside the same fixed-width time-column grid (no column-width recalculation), exactly like the existing custom_fetal_position (LOA/ROA/OA) diagram cell. Pattern: a row opts in via ui_metadata.cell_kind (or type); CustomRenderCell dispatches to a type-specific renderer; the cell content is fixed-size with the detail on hover.

  • New pure cell-kinds/cell-kind-core.ts (17 tests): ActionCellKind = nursing_intervention | injection | medical_device, ACTION_CELL_KINDS meta (bilingual label / colour / viz), recognizeActionCellKind, injection routeMeta (IV/IM/SC/ID/PO/INH colours), value parsers (scalar or rich object), and deviceStatusColor.
  • cell-kinds/ActionCells.tsx: InterventionCell (icon/diagram badge + pop-in + done check), InjectionCell (route-coloured dose marker + optional “given” pulse animation), MedicalDeviceCell (device chip + live status dot), plus an ActionCell dispatcher.
  • datagrid-components/CustomRenderCell.tsx: additive, explicit-only dispatch — an action cell renders only when a row declares one of the three kinds, so existing vital/observation rows are byte-identical. (The edit path falls back to the default text editor for now; richer per-kind editors are a follow-up the builder will drive.)
  • Sandbox CellKindGallery (faux flowsheet showing every kind in fixed cells) + e2e/cell-kind-gallery.sandbox.spec.ts. Suite: 199 tests / 19 suites; sandbox e2e 2/2. Commit 8b87a0697.

Shipped — V2 round 10 (2026-06-04): cell-kind registry (extensible system) + Cell Designer builder

Turned the cell kinds into a proper plugin system + a per-row builder, so adding a new cell type later is one registration and the builder/grid pick it up automatically.

  • Registry cell-kinds/cell-kind-registry.ts (pure, 5 tests): CellKindDefinition (id / label / description / category / color / iconName / renderDisplay / parseValue / sampleValue / previewValue / configFields / editorType) + registerCellKind / getCellKind / listCellKinds / listCellKindsByCategory.
  • Defaults cell-kinds/defaults.tsx registers the starter set — numeric, select, fetal_position, nursing_intervention, injection, medical_device — plus a brand-new pain_score (0–10 SVG pain-face, no emoji) that proves the extensibility end-to-end (appears in the builder + renders in the grid with no other wiring).
  • CellDesigner cell-kinds/CellDesigner.tsx — the strong per-row builder: a registry palette grouped by category, per-kind config fields, and a live in-cell visualizer preview; emits { cell_kind, cell_config } to write onto the row’s ui_metadata.
  • CustomRenderCell is now registry-driven — resolves ui_metadata.cell_kindgetCellKindrenderDisplay, explicit-only + fail-soft (try/catch). Replaces the round-9 hardcoded action branch; any registered kind now renders in the grid.
  • Sandbox CellDesigner + CellKindGallery targets + e2e/cell-designer.sandbox.spec.ts. Suite: 204 tests / 20 suites; sandbox e2e green. (Committed via GitHub-Desktop sync: 8bd185dec + 7ae687370.)

Shipped — V2 round 11 (2026-06-04): composable cell indicators (trend / alert / progress / dots / petals)

A kind-agnostic indicator layer any cell can compose, plus wiring into the built-in kinds — so cells show rise/fall, out-of-range alerts, and dose-course progress, not just a value.

  • New pure cell-kinds/indicators-core.ts (14 tests): computeTrend (up/down/flat + delta/pct + dead-band), alertLevel (normal/warn/critical vs range), doseProgress (done/left/pct/complete), toNum, colour maps.
  • cell-kinds/CellIndicators.tsx: TrendArrow (↑/↓/→ vs the previous reading), AlertDot (amber warn / red-pulse critical), DoseProgressRing (course done/left), StatusDot, PetalGauge (radial).
  • CellDisplayArgs gains prev; CustomRenderCell computes the previous time-column value (from the row’s sorted ISO time keys) and passes it, so any kind can draw a trend. The numeric kind now renders value + trend arrow + out-of-range alert dot; the injection kind shows a dose-progress ring when the value carries given/total.
  • Gallery updated: heart-rate is a numeric kind with live arrows + a warn alert (101 > 100), Morphine shows course progress (1/4 → 2/4), and an “Indicator vocabulary” showcase. Suite: 218 tests / 21 suites; sandbox e2e 3/3. Commit 7ee142b32.

Shipped — V2 round 12 (2026-06-04): calculation linkage (hover → light up linked calcs)

The “everything’s connected” affordance: hover a parameter name and the calculations it feeds (and their sibling inputs) highlight, with a ƒ/Σ icon on every linked row.

  • New pure cell-kinds/calc-linkage-core.ts (12 tests): a CALCULATIONS registry — MAP, pulse pressure, shock index, NEWS2, BMI, GCS total, urine-output rate — each declaring its input keys + formula; recognizeRowKey/recognizeCalcId (explicit ui_metadata.calc_key/calc_id or a name keyword); buildLinkageGraph(rows) → per-row {key, calcId, feeds}; getLinkageGroup(rowId) → the rows + calcs to highlight (a calc row → its inputs; an input → every calc it feeds + their sibling inputs + the calc rows).
  • cell-kinds/CalcLinkBadge.tsx: the ƒ/Σ icon + tooltip (formula for calcs, “Feeds → …” for inputs); glows while its linkage group is hovered. AlertDot also gained an variant='icon' (warning/error glyph).
  • Sandbox CalcLinkage target: hover a parameter → linked rows + badges glow + the formula chips appear. Suite: 230 tests / 22 suites; sandbox e2e 1/1. Commit dac94bc89.

Adding a calculation = one entry in CALCULATIONS. Remaining for the real grid: render CalcLinkBadge in the biomarker (name) column + a shared hovered-linkage-group state to cross-highlight rows (the demo proves the UX; grid wiring is the next step). Other queued cell “nice-to-haves”: reference-range mini-bar, hover-to- sparkline, cell provenance, cross-signal correlation, due/overdue per frequency, device-source link icon, and per-kind indicator toggles in CellDesigner.

Delivered the whole “more nice-to-haves” batch as composable indicators, plus the calc badge in production.

  • indicators-core gained rangePosition, computeDelta (vs baseline), dueStatus (due/overdue per interval, now injected); new provenance-core (parseProvenance + provenanceSummary).
  • CellIndicators gained 8 components: ReferenceRangeBar, DeltaBadge, DueBadge, DeviceSourceDot, CriticalEscalationChip, GoalMarker, ProvenanceBadge, MiniSparkline. The numeric kind now also wears a reference-range bar + provenance badge.
  • Sandbox CellAdornments target showcases all 13 adornments (+ e2e).
  • Real grid: MainDataGrid builds a LinkageGraph over its rows and renders the CalcLinkBadge beside each biomarker name — the ƒ/Σ icon + linkage tooltip now appears in the production flowsheet (additive, fail-soft). The cross-row hover glow in the production DataGrid is deferred (shared hovered-group state + getRowClassName, hover-rerender risk) — the full glow is proven in the CalcLinkage sandbox. Suite: 241 tests / 23 suites; sandbox e2e green. Commits 49ad65a99 (toolkit) + 1858bccbf (grid badge).

Staged (next, not in this change)

  • IPD-sheet-as-preset (zero-edit snapshot): author 3 new preset entries (vitals / I-O / nursing) mirroring the IPD Graphic Sheet sections; register enum + registry + CoreApp + switch case per DYNAMIC-SHEET-PRESETS.md. Source content must come from the IPD sheet’s real default template, not be invented.
  • Work Groups as catalog home: surface presets + the IPD snapshots as selectable entries; lean on existing sheet_enum_values / sheet_overrides.
  • Assessment Builder as author home: publish/projection assessment_templates → dynamic_sheet_templates so the richest editor drives sheets too (heals the table split).

Verification

  • Syntax-checked all four changed files via esbuild (transpile-only) — pass.
  • Full runtime verify is limited locally (stale local Supabase, 8GB build). Manual check on a real env: create a dynamic_sheet_templates row with template_group_id = a preset’s id (e.g. ICU fc686303-bd45-4526-b98e-06c5fa7a99fc), is_current=true, edited content/sheet_settings; open that preset in a patient profile → it should render the edited row; delete the row → bundled JSON.

Guardrails (apply to all follow-up)

  • IPD Graphic Sheet (#2) + the two engines (#1, V2) are not refactor targets — reuse only.
  • Snapshots are additive: new registry/enum/preset entries, never edits to working renderers.
  • Bundled-JSON fallback must remain so no live sheet can break if a DB row is missing.
  • Bilingual (TH + EN) labels on any new preset/catalog entry (repo i18n rule).

Appendix — quick inventory

Routes
  /dynamic-sheet-template-store ............ DynamicSheetTemplateStore (orphaned from nav)
  /admin/dynamic-sheet-work-groups ......... DynamicSheetWorkGroupsPage
  /assessment-manager  /:id ................ AssessmentManager list + AssessmentStorePage
  /admin/dynamic-table-builder ............. DynamicTableBuilderPage (the one nav-linked builder)

CoreApp enums (src/setup/dynamic/DynamicCoreApp.ts)
  DS_IPD_ICU='modules.DsIpdIcu' (+ 23 DS_*/~50 DS_SCORE_*) → DynamicSheetPresetRendererV2 (bundled JSON)
  IpdGraphicSheet / GraphicSheet / GraphicSheetOpd → IpdGraphicSheetMiniapp (engine #1)
  IPDMasterGraphicSheet → @medical-kit/graphic-sheet-ipd
  DynamicGraphicSheetGrouping / DynamicSheetTemplateStore → DynamicSheetTemplateStore

Packages
  @miniapps/dynamic-graphic-sheet ............ original engine (scoring, event log/timeline, dialog flow)
  @miniapps/dynamic-sheet-labour-room-v2 ..... V2 master (MasterMenuBar, aggregation/bucketing)
  @miniapps/dynamic-sheet-template-store ..... presets + store + registry
  @miniapps/ipd-graphic-sheet ................ functioning IPD sheet (DON'T CHANGE)
  src/common/.../builder/assessment-guideline  assessment builder (tree+GDL+calc+visualizer+store)
Ask Anything