Dynamic Sheet Platform
Map + decisions reconciling the four parallel sheet systems: engine, IPD graphic sheet, openEHR presets, assessment builder.
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) anddocs/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). InDynamicContentRenderer.tsx:4151it (and everyDS_IPD_*/DS_SCORE_*) routes toDynamicSheetPresetRendererV2— 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 callsgetPresetDataByTemplateGroupId(...)→ compiled-inpresets/ipd/ipd-icu-monitoring.json. It never reads the DB. - The Template Store (
DynamicSheetTemplateStore) is CRUD over thedynamic_sheet_templatestable. CoreApp enumsDynamicGraphicSheetGroupingandDynamicSheetTemplateStoreboth 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 opensDynamicGraphicSheetwith atemplateRef. The 3 sections currently leavetemplateRefunset, 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-storewritesdynamic_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 acrossnavigation/andsetup/system/navigation/). Reachable only by typing the URL, or via the in-profile CoreApp tabDynamicGraphicSheetGrouping. - 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, notdynamic_sheet_templates/JSON. Convergence ⇒ either (a) a projectionassessment_templates → dynamic_sheet_templates, or (b) teach V2 renderer to readassessment_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_templatesrow 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:
- Make the sections explicit first (recommended, additive): assign each
SECTIONS[*].templateRefto a realdynamic_sheet_templatesrow, 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 atemplateRefis present. (Confirm with user: this is the one small edit to the IPD miniapp; if even that is off-limits, use #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”)
- 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. - Rendering bridge: YES, shipped.
DynamicSheetPresetRendererV2now prefers a livedynamic_sheet_templatesrow (sametemplate_group_id,is_current), falls back to bundled JSON on no-row / error / loading. Additive, fail-soft. - IPD snapshot: zero-edit path — the functioning IPD Graphic Sheet is untouched; reuse is via new additive preset entries only (staged, not yet authored).
- Table unification: deferred — bridged at the renderer for now;
assessment_templates↔dynamic_sheet_templatesprojection 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.ts → DynamicSheetSettingsContext (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 +isCellEditablelock + locked-cell visual) → both grid callersMainDataGrid(clean/preset path — ICU Monitoring) andMainDataGridStable(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_changes → setRowsDataMaster → 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 viasaveRowData) and the inconsistentfilled_datashape ({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 (explicitui_metadata.news2_componentor name/code heuristics), maps each time column →News2Vitals, reuses the pure@miniapps/focus-list-enhanced/news2calculator, and appends a synthetic pinned OUTPUT row tobuildPinnedRows(never mutates the user’sBuildPinnedRow.ts). Cells coloured by tier via_scoreMeta. ToggleshowScoreRow(Σ). - 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. ToggleshowRowIndicators. - 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 fromhospital_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. ToggleshowEvents.
Note: the OUTPUT-row regression report was investigated and ruled out —
BuildPinnedRow.tshas one commit (the user’s81daa3da, 2026-05-13) and was never touched; OUTPUT rows always rendered only for GDL templates withtype:'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 balance —io-balance-core.ts(pure, 9 tests) recognizes intake/output rows (ui_metadata.io_roleor name heuristics; matches the bundled Fluid-Balance set) and folds each column →{intake,output,net,cumulative};IoBalanceChart.tsxdraws a pure-SVG “candle” (intake up / output down from a central zero baseline + running net line).OutputPanel.tsxgathers rows via the newutils/gatherSheetRows.ts, offers only renderers whosecanRenderpasses. ToggleshowOutputs; mounted in both grid variants. - Linked Devices panel (
devices/):useDeviceRegistry.tsreads the realdevice_pairingsregistry (migration 022) by patient, realtime;device-status-core.ts(pure, 11 tests) derives live/idle/down/standby/maintenance fromlast_heartbeat;LinkedDevicesPanel.tsxlists devices + capabilities + connection dot + last device-sourced value, headed by central sync status fromuseObservationSync(online + pending count). ToggleshowDevices; 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 = !!onClick →
text.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:
TbBtnrendersnullwhen unwired — no more greyed dead buttons. A wired-but- conditionally-disabledbutton (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 localAddVitalSignDialog, Print →window.print. - “File” → “Save”, wired to the real
saveDynamicTemplate()(it was calling the no-opsaveDatastub inuseDynamicSheet, 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(thesearchTermbox); 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. Commit2393f8b34.
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 3DBodyModelViewer(@medical-kit/body-model-3d, R3F + three, lazy-loaded). ItsBodyOverlaytype already supportskind: 'line' | 'drain' | 'airway' | 'count' | 'packing'with per-kind colour, so surgical count + packing are first-class. Purelda/lda-core.ts(19 tests):recognizeLdaKind(explicitui_metadata.lda_role→ device-type code CVC/PICC/FOLEY/ETT/NG/JP → keyword heuristic),mapRegion→ realBODY_REGION_3Dtokens (chest / bladder / mouth / neck / forearm-L / abdomen …),buildLdaItemsfrom (1)encounter_journey_cache.access_lines(viauseManifestRow) + (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+ pureregdoc/regdoc-print.ts, 5 tests):buildRegDocModelfromuseManifestRow(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 theDynamicSheetMastershell (which haspatientId/encounterId) →FlowsheetToolbarbuttons (onLdaAvatar/onRegDoc) call the context openers fromCustomDynamicSheetClean.useManifestRowis passedundefinedwhile the dialog is closed (no realtime subscription). Suite: 161 tests / 17 suites. Commit927e3370b.
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.LdaItemgainsdomain+pin(false= case-level, listed but not anchored on the body). buildPackingItems(fromor_packing_management): pinned packing items; region from category (vaginal→pelvis,nasal→nose,abdominal→abdomen,ear→ear-R,rectal→rectum) or the free-textsite(exportedmatchSiteRegion— a specific site beats the generic category); skipsfully_removed; surfaces retained packing + “N in situ”.buildCountItems(fromor_intraop_counts): onepin:falsecase-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.tsreads both tables byencounter_id(both carry the column + an index — so noor_request_id/or_state_cacheresolution is needed) viauseMedBase, fetch-on-open- fail-soft (mirrors
useDeviceRegistry). Stays decoupled from@periops-kit(whose count hook isor_request_id-only).
- fail-soft (mirrors
- 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.
+10lda-core tests. Suite: 171 tests / 17 suites. Commit9598b2535.
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(defaultundefined, so Patient 360 / Clinical Diagnosis Hub render byte-identically).OverlayBadgeis 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.
useOrLdaDatasubscribes topostgres_changesonor_packing_management+or_intraop_counts(filterencounter_id) and re-fetches — packing placed/removed or a count reconciled mid-case updates the body without reopening the dialog. (Cleanup viaremoveChannel.) - Surgical drains. New
resolveDomain(kind, typeOrName)inlda-coreroutes 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 fromaccess_lines(thehandleAccessLinesProjection.tsread-model column) + flowsheet rows; the OR time-out’s drain fields are dialog state, not a clean read model.buildLdaItemsnow usesresolveDomain(notkindDomain). +4lda-core tests. Suite: 175 tests / 17 suites. Commit68c177e8d.
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 existed — DynamicSheetWorkGroup
(+ 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.ts—hasSections+groupSheetsBySection(first-seen order, trailing ungrouped bucket, non-mutating). 7 tests. DynamicSheetWorkGroupSheetgains optionalsection. When any sheet sets it, the rail renders a grouped sidebar (MUIList+ a header per phase) instead of the flatTabs. 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.sectionis threaded end-to-end:DynamicSheetWorkGroupRendereroverride mapping → serviceWorkGroupSheetOverride→ the admin edit dialog (a per-member Section field + a chip in the collapsed row), so DB-driven groups can author phases indynamic_sheet_work_groups.sheet_overrides.- Demo: sandbox target
PerioperativeWorkspace(Pre / Intra / Post with apaneRendererstub) +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. Commit3658b3af3.
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_KINDSmeta (bilingual label / colour / viz),recognizeActionCellKind, injectionrouteMeta(IV/IM/SC/ID/PO/INH colours), value parsers (scalar or rich object), anddeviceStatusColor. 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 anActionCelldispatcher.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. Commit8b87a0697.
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.tsxregisters the starter set — numeric, select, fetal_position, nursing_intervention, injection, medical_device — plus a brand-newpain_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’sui_metadata. CustomRenderCellis now registry-driven — resolvesui_metadata.cell_kind→getCellKind→renderDisplay, explicit-only + fail-soft (try/catch). Replaces the round-9 hardcoded action branch; any registered kind now renders in the grid.- Sandbox
CellDesigner+CellKindGallerytargets +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).CellDisplayArgsgainsprev;CustomRenderCellcomputes 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 carriesgiven/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): aCALCULATIONSregistry — MAP, pulse pressure, shock index, NEWS2, BMI, GCS total, urine-output rate — each declaring its input keys + formula;recognizeRowKey/recognizeCalcId(explicitui_metadata.calc_key/calc_idor 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.AlertDotalso gained anvariant='icon'(warning/error glyph).- Sandbox
CalcLinkagetarget: hover a parameter → linked rows + badges glow + the formula chips appear. Suite: 230 tests / 22 suites; sandbox e2e 1/1. Commitdac94bc89.
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.
Shipped — V2 round 13 (2026-06-04): full adornment toolkit + calc-link badge in the real grid
Delivered the whole “more nice-to-haves” batch as composable indicators, plus the calc badge in production.
indicators-coregainedrangePosition,computeDelta(vs baseline),dueStatus(due/overdue per interval,nowinjected); newprovenance-core(parseProvenance+provenanceSummary).CellIndicatorsgained 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:
MainDataGridbuilds aLinkageGraphover 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 theCalcLinkagesandbox. Suite: 241 tests / 23 suites; sandbox e2e green. Commits49ad65a99(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_templatesso 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_templatesrow withtemplate_group_id= a preset’s id (e.g. ICUfc686303-bd45-4526-b98e-06c5fa7a99fc),is_current=true, editedcontent/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)