diff --git a/docs/branch-review-records/b569eab9ffb958ffaebe6eeaaeccc2325bc626719590f2878d6270fe8cae032d.record.md b/docs/branch-review-records/b569eab9ffb958ffaebe6eeaaeccc2325bc626719590f2878d6270fe8cae032d.record.md new file mode 100644 index 000000000..f11870f12 --- /dev/null +++ b/docs/branch-review-records/b569eab9ffb958ffaebe6eeaaeccc2325bc626719590f2878d6270fe8cae032d.record.md @@ -0,0 +1 @@ +| 2026-08-22 | codex/ward-management-design (PR #2289) | cef2f3eea634d07b9a191bc72a4f3a4ed7c00d10 | Repair CI drift after main merge | Repaired stale documentation paths and generated contract drift; contextual Ward back navigation now uses the standard history-aware control; PR shard coverage includes Ward roles. | Focused Vitest: 3 files, 59 passed; docs links: 2,664 references resolve; adoption, site-map, codebase-index, design-system contract, and diff check passed. npm run format launcher was blocked by executor Network request disconnected; changed paths were formatted with repository Prettier. | diff --git a/docs/branch-review-records/c7b727bab40ad00fb24a30334953c787ddd0d4d7b3d8d78ce589aff3c3a18250.record.md b/docs/branch-review-records/c7b727bab40ad00fb24a30334953c787ddd0d4d7b3d8d78ce589aff3c3a18250.record.md new file mode 100644 index 000000000..b5ca6841f --- /dev/null +++ b/docs/branch-review-records/c7b727bab40ad00fb24a30334953c787ddd0d4d7b3d8d78ce589aff3c3a18250.record.md @@ -0,0 +1 @@ +| 2026-08-22 | codex/ward-management-design (PR #2289) | 6e1300fa6dcb31ba63727b9d5b3b52cab0b05cb5 | Run PR sweep: CI fix + threads + drift | Before: mergeable_state dirty (PR mergeability + PR policy both failing; real CI never triggered), 3 unresolved P1 review threads. After: all 6 review threads resolved (3 fixed with regression tests + pushed commit 6e1300fa; 3 pre-existing already resolved). mergeable_state remains dirty and was deliberately left unresolved: git merge-tree shows 34 conflicts (14 content, 20 add/add) against origin/main, all traced to PR #2140 ("Add Ward Flow: synthetic ward/bed-management coordination prototype", already merged to main) independently re-implementing the same ward-management/legal-detention-workflow feature this PR builds from a much older, unrelated base (zero common history until git fetch --deepen=5000 recovered a real merge-base 752 commits back). This is a genuine duplicate-feature conflict on clinical workflow content (Mental Health Act detention forms, bed/patient placement), not staleness — hard-stopped per policy rather than resolved with ours/theirs. Real CI (static-pr/pr-required/build/etc.) cannot run until a human resolves this at the product level. | Local only, no provider-backed checks: npx vitest run on 9 touched/related ward-flow test files (84 passed, including 2 new regression tests — one for the reducer closure/capacity-release fix, one for the provider clock-monotonicity fix, both confirmed to fail against pre-fix code), npx tsc --noEmit -p tsconfig.json (0 errors, full project), npx eslint on all 6 changed files (0 findings), npx prettier --check on all 6 changed files (all formatted). No verify:cheap/verify:pr-local/verify:ui run (diff too large and blocked on unrelated merge conflict; would not have exercised anything beyond the focused tests already run). No eval:rag, eval:quality, eval:retrieval:quality, verify:release, check:supabase-project, test:live, or any other provider-backed gate was run. | diff --git a/docs/branch-review-records/d8a5b14dd4d19ea98ba238e61bc032a805eea1597bfaf53a2892856afb90488d.record.md b/docs/branch-review-records/d8a5b14dd4d19ea98ba238e61bc032a805eea1597bfaf53a2892856afb90488d.record.md new file mode 100644 index 000000000..ec5810a34 --- /dev/null +++ b/docs/branch-review-records/d8a5b14dd4d19ea98ba238e61bc032a805eea1597bfaf53a2892856afb90488d.record.md @@ -0,0 +1 @@ +| 2026-08-22 | codex/ward-management-design (PR #2289) | d784b33eed0347a4b439935e78e189a0088dafb6 | Merge current main and validate Ward Flow review fixes | Merged current main with semantic Ward Flow conflict resolution; preserved external review fixes and removed the retired constellation route. | Formatter plus generated site-map/adoption, codebase-index, and design-system contract passed; focused Vitest blocked by coordinator admission (exit 75); no provider-backed checks. | diff --git a/docs/codebase-index.md b/docs/codebase-index.md index e37ba0e46..8f0b4f0b6 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -68,32 +68,32 @@ Smaller top-level directories that are easy to miss: ### Product pages (`src/app/`) -| Route | File | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `/` | `src/app/(search-app)/page.tsx` | -| Shared mode-home route group (`/(search-app)`) | `src/app/(search-app)/` | -| Mode homes (`/services`, `/dsm`, `/documents/…`, …) | `src/app/(search-app)/` shared shell group | -| `/applications` | `src/app/applications/route.ts` | -| `/differentials`, `/diagnoses`, `/presentations`, `/compare` | `src/app/(search-app)/differentials/` | -| `/dsm`, `/dsm/search`, `/dsm/compare`, `/dsm/diagnoses/[slug]` | `src/app/(search-app)/dsm/` | -| `/documents/search`, `/source`, `/evidence`, `/[id]` | `src/app/(search-app)/documents/` | -| `/factsheets`, `/factsheets/search`, `/factsheets/[slug]` | `src/app/(search-app)/factsheets/` | -| `/dictionary`, Terms (`/search`, one catalogue — `/browse` redirects to it), Topics, Definition, Compare, Sources | `src/app/(search-app)/dictionary/` | -| `/favourites` | `src/app/(search-app)/favourites/page.tsx` | -| `/forms`, `/forms/[slug]` | `src/app/(search-app)/forms/` | -| `/medications`, `/medications/[slug]` | `src/app/(search-app)/medications/` | -| `/privacy` | `src/app/privacy/page.tsx` → `privacy-quiet-signal-page.tsx` + `privacy-page-content.tsx` | -| `/reference/colour-coding` | `src/app/reference/` | -| `/safety-plan` | `src/app/safety-plan/page.tsx` | -| `/calculators`, `/calculators/search` | `src/app/(search-app)/calculators/` | -| `/services`, `/services/[slug]` | `src/app/(search-app)/services/` | -| `/therapy-compass` | `src/app/(search-app)/therapy-compass/` | -| `/tools` | `src/app/(search-app)/tools/` | -| `/specifiers`, `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, `/specifiers/map` | `src/app/(search-app)/specifiers/` | -| `/formulation`, `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, `/formulation/map` | `src/app/(search-app)/formulation/` | -| `/ward-management`, `/constellation`, `/network`, `/queue`, `/capacity`, `/movements`, `/exceptions`, `/transport`, `/governance`, `/patients/[patientId]` | `src/app/ward-management/` — Ward Flow synthetic patient-flow prototype | -| `/mockups/*` | `src/app/mockups/` (404 in production) | -| `/auth/callback` | `src/app/auth/callback/route.ts` | +| Route | File | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `/` | `src/app/(search-app)/page.tsx` | +| Shared mode-home route group (`/(search-app)`) | `src/app/(search-app)/` | +| Mode homes (`/services`, `/dsm`, `/documents/…`, …) | `src/app/(search-app)/` shared shell group | +| `/applications` | `src/app/applications/route.ts` | +| `/differentials`, `/diagnoses`, `/presentations`, `/compare` | `src/app/(search-app)/differentials/` | +| `/dsm`, `/dsm/search`, `/dsm/compare`, `/dsm/diagnoses/[slug]` | `src/app/(search-app)/dsm/` | +| `/documents/search`, `/source`, `/evidence`, `/[id]` | `src/app/(search-app)/documents/` | +| `/factsheets`, `/factsheets/search`, `/factsheets/[slug]` | `src/app/(search-app)/factsheets/` | +| `/dictionary`, Terms (`/search`, one catalogue — `/browse` redirects to it), Topics, Definition, Compare, Sources | `src/app/(search-app)/dictionary/` | +| `/favourites` | `src/app/(search-app)/favourites/page.tsx` | +| `/forms`, `/forms/[slug]` | `src/app/(search-app)/forms/` | +| `/medications`, `/medications/[slug]` | `src/app/(search-app)/medications/` | +| `/privacy` | `src/app/privacy/page.tsx` → `privacy-quiet-signal-page.tsx` + `privacy-page-content.tsx` | +| `/reference/colour-coding` | `src/app/reference/` | +| `/safety-plan` | `src/app/safety-plan/page.tsx` | +| `/calculators`, `/calculators/search` | `src/app/(search-app)/calculators/` | +| `/services`, `/services/[slug]` | `src/app/(search-app)/services/` | +| `/therapy-compass` | `src/app/(search-app)/therapy-compass/` | +| `/tools` | `src/app/(search-app)/tools/` | +| `/specifiers`, `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, `/specifiers/map` | `src/app/(search-app)/specifiers/` | +| `/formulation`, `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, `/formulation/map` | `src/app/(search-app)/formulation/` | +| `/ward-management`, `/network`, `/queue`, `/capacity`, `/movements`, `/exceptions`, `/transport`, `/transport/officer`, `/governance`, `/ed/[edId]`, `/patients/[patientId]`, `/ward/[unitId]` | `src/app/ward-management/` — Ward Flow synthetic patient-flow prototype | +| `/mockups/*` | `src/app/mockups/` (404 in production) | +| `/auth/callback` | `src/app/auth/callback/route.ts` | ### API routes (`src/app/api/`) @@ -344,8 +344,35 @@ visible reasons and a human confirms or overrides. `ward-movements.ts` (48 movements, 6 bed releases), `ward-derivations.ts` (shared pure UI derivations) - **Surfaces:** `ward-management-console.tsx` (command), `ward-management-modes.tsx` (mode - workspaces), `ward-management-network.tsx` (network diagram), `ward-management-navigation.tsx` -- **Tests:** `tests/ward-management.test.ts`, `tests/ui-ward-management.spec.ts` + workspaces), `ward-management-network.tsx` (network diagram), `ward-management-navigation.tsx`, + `coordinator/coordinator-screen.tsx` (Phase 3 live coordinator screen — priority queue, statewide + flow diagram, explainable shortlist), `ward/ward-screen.tsx` (Task 8: one inpatient unit's own + view — `/ward-management/ward/[unitId]`; capacity confirmation, incoming-referral accept/hold/ + decline, restriction notices, withdrawn referrals), `officer/officer-screen.tsx` (Task 9: the + transport officer's phone — `/ward-management/transport/officer`; every transport job not yet + arrived, since `TransportJob` carries a `provider` organisation and no officer identity; + queue-plus-pinned-action-bar pattern inherited from `shortlist-panel.tsx`, one job "active" at a + time with its four transport actions — accepted, en route, collected, arrived — pinned to the + viewport bottom on phone widths), `tracker/live-tracker.tsx` (Task 10: the coordinator's live + tracker, rewriting the existing route — `/ward-management/transport`; every open movement that + carries a transport job, its leg via `tracker/tracker-derivations.ts`'s `trackerRowState` + (delegating to `transportLeg`) and how long since that leg's own stamp via `stampAgeText`; a + movement with no transport job at all is never listed as a row — the banner states the excluded + count in real text instead, the same on-screen-honesty discipline as the officer screen's "no + officer identity" notice), `ed/ed-screen.tsx` (Task 11: one emergency department's own view — + `/ward-management/ed/[edId]`, resolved via `ward-sites.ts`'s `edById`; both clocks (time in + department from `openedAt`, the legal clock from `formedAt` where earlier, marked + `data-community-formed`), the four-hour `ED_ACCESS_TARGET_MINUTES` departmental access target + — labelled and computed so it can never be mistaken for a legal deadline and never touches a + `LegalForm`/`dueAt` — a police-attendance flag, and each movement's single outstanding item; a + raise-referral form (`RAISE_REFERRAL`), a record-examination form (`RECORD_EXAMINATION`), and + the mark-handover-ready control (`HANDOVER_READY`) that is the only producer of a transport job; + statewide capacity shown read-only) +- **State layer (Phase 3):** `ward-flow-provider.tsx` (`WardFlowProvider`/`useWardFlow`, mounted at + `src/app/ward-management/layout.tsx`), `ward-flow-reducer.ts` (the one mutation path), + `ward-flow-events.ts` (event/role table) +- **Tests:** `tests/ward-management.test.ts`, `tests/ui-ward-management.spec.ts`, + `tests/ui-ward-coordinator.spec.ts`, `tests/ui-ward-roles.spec.ts` ### Developer hub (`src/app/mockups/development/`, `src/lib/developer-area/`) diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index e6705cba4..835f1c9a4 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -362,12 +362,12 @@ product exclusions; the only route-only disposition is the documented legacy doc redirect. Shared shell/component roots carry their own explicit `shared-shell` disposition. Registered public components: 54 -Declared product roots: 88 +Declared product roots: 93 Roots with a literal `.ckb-v2` opt-in: 1 -Roots inheriting `.ckb-v2` from the global ``: 87 +Roots inheriting `.ckb-v2` from the global ``: 92 Production surfaces observed under v2: 15/15 Dynamic `ckb-v2` constructions: 0 -Declared production page routes: 75/75 +Declared production page routes: 77/77 Source observation and contract declaration are independent. A literal `ckb-v2` on the global `` makes every production surface inherit v2, but it does not approve that adoption. The Proof column summarizes each surface's dark, forced-colours, 320px, print and browser declarations; exact statuses and evidence paths live in the manifest. @@ -385,7 +385,7 @@ Observed v2 under a compatibility declaration fails closed. A declared v2 shell | `documents-source-legacy-redirect` | legacy-redirect | 1 | 0 | v2 | v2 (inherited-global-root) | not-applicable | not-applicable | | `favourites` | owned | 1 | 1 | v2 | v2 (inherited-global-root) | passed | not-committed | | `tools-and-calculators` | owned | 3 | 3 | v2 | v2 (inherited-global-root) | passed | not-committed | -| `ward-management` | owned | 10 | 15 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `ward-management` | owned | 12 | 20 | v2 | v2 (inherited-global-root) | passed | not-committed | | `privacy-safety-and-reference` | owned | 3 | 3 | v2 | v2 (inherited-global-root) | passed | not-committed | | `search-results-shared` | shared-shell | 0 | 1 | v2 | v2 (inherited-global-root) | passed | not-committed | | `answers-shared` | shared-shell | 0 | 2 | v2 | v2 (inherited-global-root) | passed | not-committed | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 7de9d95ef..934467336 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1009,7 +1009,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 8 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 26 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-contract.json b/docs/design-system/adoption-contract.json index f2a3cf587..7785fcdde 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -509,22 +509,27 @@ "routes": [ "src/app/ward-management/page.tsx", "src/app/ward-management/capacity/page.tsx", - "src/app/ward-management/constellation/page.tsx", + "src/app/ward-management/ed/[edId]/page.tsx", "src/app/ward-management/exceptions/page.tsx", "src/app/ward-management/governance/page.tsx", "src/app/ward-management/movements/page.tsx", "src/app/ward-management/network/page.tsx", "src/app/ward-management/patients/[patientId]/page.tsx", "src/app/ward-management/queue/page.tsx", - "src/app/ward-management/transport/page.tsx" + "src/app/ward-management/transport/page.tsx", + "src/app/ward-management/transport/officer/page.tsx", + "src/app/ward-management/ward/[unitId]/page.tsx" ], "routeRoots": true, "roots": [ "src/components/ward-management/coordinator/coordinator-screen.tsx", + "src/components/ward-management/ed/ed-screen.tsx", + "src/components/ward-management/officer/officer-screen.tsx", "src/components/ward-management/ward-management-console.tsx", "src/components/ward-management/ward-management-modes.tsx", "src/components/ward-management/ward-management-navigation.tsx", - "src/components/ward-management/ward-management-network.tsx" + "src/components/ward-management/ward-management-network.tsx", + "src/components/ward-management/ward/ward-screen.tsx" ], "permittedComponentFamilies": ["controls", "feedback", "layout"], "expectedShellState": "v2", @@ -556,14 +561,13 @@ }, "sanctionedSpecialPatterns": [ "CoordinatorScreen", - "WardManagementConsole", "PatientMovementWorkspace", "WardModeWorkspace", "WardModeNavigation", "ClinicalRail", "WardNetworkWorkspace" ], - "documentedDisposition": "Ward Flow is a synthetic operational coordination surface with a command home, full-screen constellation, role-aware queue-first phone fallback, linked operational workspaces, and an explainable human-confirmed destination workflow." + "documentedDisposition": "Ward Flow is a synthetic operational coordination surface with a coordinator command home, statewide flow diagram and explainable shortlist, rail-based mode navigation, role-aware queue-first phone fallback, linked operational workspaces, and an explainable human-confirmed destination workflow." }, { "id": "privacy-safety-and-reference", diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 8117c5862..05a443a5e 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1546,7 +1546,8 @@ "tests/search-results-header-band.dom.test.tsx", "tests/ui-formulation.spec.ts", "tests/ui-smoke.spec.ts", - "tests/ui-v2-components.dom.test.tsx" + "tests/ui-v2-components.dom.test.tsx", + "tests/ui-ward-coordinator.spec.ts" ], "baseline": { "targetLayer": "v2", @@ -1593,8 +1594,7 @@ "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/tools/tools-search-results-page.tsx", - "src/components/ui/confirm-dialog.tsx", - "src/components/ward-management/ward-management-console.tsx" + "src/components/ui/confirm-dialog.tsx" ], "productImportFiles": [ "src/components/AccessibleTable.tsx", @@ -1621,8 +1621,7 @@ "src/components/forms/form-priority-facts-section.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", - "src/components/tools/tools-search-results-page.tsx", - "src/components/ward-management/ward-management-console.tsx" + "src/components/tools/tools-search-results-page.tsx" ], "designSync": { "listedInSourceMap": true, @@ -3303,10 +3302,10 @@ { "id": "ward-management", "disposition": "owned", - "documentedDisposition": "Ward Flow is a synthetic operational coordination surface with a command home, full-screen constellation, role-aware queue-first phone fallback, linked operational workspaces, and an explainable human-confirmed destination workflow.", + "documentedDisposition": "Ward Flow is a synthetic operational coordination surface with a coordinator command home, statewide flow diagram and explainable shortlist, rail-based mode navigation, role-aware queue-first phone fallback, linked operational workspaces, and an explainable human-confirmed destination workflow.", "routes": [ "src/app/ward-management/capacity/page.tsx", - "src/app/ward-management/constellation/page.tsx", + "src/app/ward-management/ed/[edId]/page.tsx", "src/app/ward-management/exceptions/page.tsx", "src/app/ward-management/governance/page.tsx", "src/app/ward-management/movements/page.tsx", @@ -3314,7 +3313,9 @@ "src/app/ward-management/page.tsx", "src/app/ward-management/patients/[patientId]/page.tsx", "src/app/ward-management/queue/page.tsx", - "src/app/ward-management/transport/page.tsx" + "src/app/ward-management/transport/officer/page.tsx", + "src/app/ward-management/transport/page.tsx", + "src/app/ward-management/ward/[unitId]/page.tsx" ], "routeRoots": true, "proofApplicability": "required", @@ -3356,7 +3357,6 @@ "ClinicalRail", "CoordinatorScreen", "PatientMovementWorkspace", - "WardManagementConsole", "WardModeNavigation", "WardModeWorkspace", "WardNetworkWorkspace" @@ -3373,14 +3373,14 @@ "sanctionedPatternsPresent": ["WardModeWorkspace"] }, { - "file": "src/app/ward-management/constellation/page.tsx", + "file": "src/app/ward-management/ed/[edId]/page.tsx", "exists": true, "imports": [], "importedFamilies": [], "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": ["WardModeWorkspace"] + "sanctionedPatternsPresent": [] }, { "file": "src/app/ward-management/exceptions/page.tsx", @@ -3452,6 +3452,16 @@ "v2MountMode": "inherited-global-root", "sanctionedPatternsPresent": ["WardModeWorkspace"] }, + { + "file": "src/app/ward-management/transport/officer/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, { "file": "src/app/ward-management/transport/page.tsx", "exists": true, @@ -3460,7 +3470,17 @@ "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": ["WardModeWorkspace"] + "sanctionedPatternsPresent": [] + }, + { + "file": "src/app/ward-management/ward/[unitId]/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] }, { "file": "src/components/ward-management/coordinator/coordinator-screen.tsx", @@ -3470,17 +3490,37 @@ "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": ["CoordinatorScreen", "WardModeNavigation", "ClinicalRail"] + "sanctionedPatternsPresent": ["CoordinatorScreen", "ClinicalRail"] + }, + { + "file": "src/components/ward-management/ed/ed-screen.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["ClinicalRail"] + }, + { + "file": "src/components/ward-management/officer/officer-screen.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["ClinicalRail"] }, { "file": "src/components/ward-management/ward-management-console.tsx", "exists": true, - "imports": ["Sheet"], - "importedFamilies": ["layout"], + "imports": [], + "importedFamilies": [], "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": ["WardManagementConsole", "WardModeNavigation", "ClinicalRail"] + "sanctionedPatternsPresent": ["ClinicalRail"] }, { "file": "src/components/ward-management/ward-management-modes.tsx", @@ -3490,12 +3530,7 @@ "literalCkbV2": false, "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", - "sanctionedPatternsPresent": [ - "WardModeWorkspace", - "WardModeNavigation", - "ClinicalRail", - "WardNetworkWorkspace" - ] + "sanctionedPatternsPresent": ["WardModeWorkspace", "ClinicalRail", "WardNetworkWorkspace"] }, { "file": "src/components/ward-management/ward-management-navigation.tsx", @@ -3516,6 +3551,16 @@ "dynamicCkbV2": false, "v2MountMode": "inherited-global-root", "sanctionedPatternsPresent": ["WardNetworkWorkspace"] + }, + { + "file": "src/components/ward-management/ward/ward-screen.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["ClinicalRail"] } ] }, @@ -3842,7 +3887,7 @@ "src/app/reference/colour-coding/page.tsx", "src/app/safety-plan/page.tsx", "src/app/ward-management/capacity/page.tsx", - "src/app/ward-management/constellation/page.tsx", + "src/app/ward-management/ed/[edId]/page.tsx", "src/app/ward-management/exceptions/page.tsx", "src/app/ward-management/governance/page.tsx", "src/app/ward-management/movements/page.tsx", @@ -3850,7 +3895,9 @@ "src/app/ward-management/page.tsx", "src/app/ward-management/patients/[patientId]/page.tsx", "src/app/ward-management/queue/page.tsx", - "src/app/ward-management/transport/page.tsx" + "src/app/ward-management/transport/officer/page.tsx", + "src/app/ward-management/transport/page.tsx", + "src/app/ward-management/ward/[unitId]/page.tsx" ], "declared": [ "src/app/(search-app)/calculators/page.tsx", @@ -3919,7 +3966,7 @@ "src/app/reference/colour-coding/page.tsx", "src/app/safety-plan/page.tsx", "src/app/ward-management/capacity/page.tsx", - "src/app/ward-management/constellation/page.tsx", + "src/app/ward-management/ed/[edId]/page.tsx", "src/app/ward-management/exceptions/page.tsx", "src/app/ward-management/governance/page.tsx", "src/app/ward-management/movements/page.tsx", @@ -3927,7 +3974,9 @@ "src/app/ward-management/page.tsx", "src/app/ward-management/patients/[patientId]/page.tsx", "src/app/ward-management/queue/page.tsx", - "src/app/ward-management/transport/page.tsx" + "src/app/ward-management/transport/officer/page.tsx", + "src/app/ward-management/transport/page.tsx", + "src/app/ward-management/ward/[unitId]/page.tsx" ], "undeclared": [], "missing": [], @@ -3935,7 +3984,7 @@ }, "adoption": { "literalCkbV2RootCount": 1, - "inheritedCkbV2RootCount": 87, + "inheritedCkbV2RootCount": 92, "dynamicCkbV2RootCount": 0, "v2MountedSurfaceCount": 15, "declaredV2SurfaceCount": 15 @@ -3944,8 +3993,8 @@ "registeredComponentCount": 54, "previewCount": 54, "productImportedComponentCount": 34, - "rootCount": 88, - "productionRouteCount": 75, - "nextUiEntryCount": 132 + "rootCount": 93, + "productionRouteCount": 77, + "nextUiEntryCount": 135 } } diff --git a/docs/site-map.md b/docs/site-map.md index efb0f6114..3f4170f9c 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -58,13 +58,15 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/tools` - Clinical tools and applications launcher directory. Source: `src/app/(search-app)/tools/page.tsx`. - `/ward-management` - Statewide psychiatry ward demand, bed capacity, and patient flow console. Source: `src/app/ward-management/page.tsx`. - `/ward-management/capacity` - Ward bed availability, unit occupancy, and staffing capacity. Source: `src/app/ward-management/capacity/page.tsx`. -- `/ward-management/constellation` - Statewide psychiatric hospital network constellation view. Source: `src/app/ward-management/constellation/page.tsx`. +- `/ward-management/ed/[edId]` - Route discovered from app directory Source: `src/app/ward-management/ed/[edId]/page.tsx`. - `/ward-management/exceptions` - Patient flow exceptions, delays, and escalation alerts. Source: `src/app/ward-management/exceptions/page.tsx`. - `/ward-management/governance` - Ward coordination governance, compliance, and audit log. Source: `src/app/ward-management/governance/page.tsx`. - `/ward-management/movements` - Scheduled and completed patient transfers and bed movements. Source: `src/app/ward-management/movements/page.tsx`. - `/ward-management/network` - Psychiatric bed network status and regional catchment map. Source: `src/app/ward-management/network/page.tsx`. - `/ward-management/queue` - Priority referral queue and triage waiting list. Source: `src/app/ward-management/queue/page.tsx`. - `/ward-management/transport` - Patient inter-hospital transfer and transport logistics. Source: `src/app/ward-management/transport/page.tsx`. +- `/ward-management/transport/officer` - Route discovered from app directory Source: `src/app/ward-management/transport/officer/page.tsx`. +- `/ward-management/ward/[unitId]` - Route discovered from app directory Source: `src/app/ward-management/ward/[unitId]/page.tsx`. ## Mode/query routes diff --git a/docs/superpowers/plans/2026-08-19-ward-flow-phase-3-role-screens.md b/docs/superpowers/plans/2026-08-19-ward-flow-phase-3-role-screens.md new file mode 100644 index 000000000..af9d04ee9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-ward-flow-phase-3-role-screens.md @@ -0,0 +1,1548 @@ +# Ward Flow Phase 3 — the other three roles — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Ward Flow move — add the emergency department, ward and transport officer screens plus the coordinator's live tracker, on top of the first mutable state this build has had. + +**Architecture:** One `WardFlowProvider` in a new `src/app/ward-management/layout.tsx` holds the movements, the units, the refusals and the clock. Every change goes through one pure reducer with no React in it, so the bulk of the proof is Vitest rather than browser. The coordinator screen is the primary, guiding screen; the other three answer it. Every existing Ward Flow route is rewired to the same provider so no two surfaces can disagree. + +**Tech Stack:** Next.js 16 App Router, React 19 (`useReducer` + `createContext`, no state library), TypeScript 6 strict, CSS Modules with a local token scale, Vitest for the reducer and contracts, Playwright Chromium for journeys. + +**Spec:** [`docs/superpowers/specs/2026-08-19-ward-flow-phase-3-role-screens-design.md`](../specs/2026-08-19-ward-flow-phase-3-role-screens-design.md) — read it alongside this plan. §3 is the model surgery, §6 the events, §8 the ten routes, §10 the refusals, §15 the build order. + +**Read also:** [`docs/ward-flow-context.md`](../../ward-flow-context.md) — the cold-start orientation, corrected 2026-08-19. + +**Build-order item 1 (correcting the context document) is already complete** — commit `7f373e80f`. This plan starts at the model. + +## Global Constraints + +- **Nothing auto-allocates.** Every placement is a human confirm or override with the reason recorded. No timeout default, no confidence threshold. Withdrawing a referral is not allocating and is exempt. +- **Authorisation gates the destination only.** Detention in an unauthorised emergency department is lawful and normal. No surface may treat a patient's current department as a compliance problem. +- **Urgency tier leads.** The operational score orders only _within_ a tier, contains no urgency component, and is never described as severity, acuity or risk. +- **Conservative failure.** Missing or stale data narrows what is shown. A missing lookup renders an explicit absence, never a substituted record. No `?? array[0]`, no `.find()!`, no defaulted-parameter equivalent. +- **Display less rather than something plausible.** The governing rule. Every review this project has run found a surface stating something the data does not support. +- **Synthetic only.** No name, date of birth, medical record number, address, diagnosis, narrative history or treatment. `Sex` is the single permitted patient attribute. Free text counts — a guard that checks properties and never reads strings is how the Phase 1 privacy defect survived. +- **Determinism.** No `Math.random()`. No wall-clock read outside `ward-clock.ts` — and inside it, only `wallClockNow()`, consumed exactly once, inside the provider. Every function takes `now: Instant`. +- **The reducer is pure.** No React, no I/O, no clock read; `now` arrives on the event. +- **The fixture is copied at seed time**, never mutated in place, or tests become order-dependent. +- **Design tokens only.** No raw hex; no raw padding, gap, z-index or line-height literal in a CSS Module without declaring a local token in the module's root block first. `npm run check:design-system-contract` ratchets these and fails on any increase. +- **Tap targets are `3rem` (48px) minimum.** Never `2.75rem` — that reintroduces a known `ui-smoke` flake. +- **Button wiring.** Every `` immediately after the Arrived button (confirmed + present: `grep -n "mutation-decoy" officer-screen.tsx` → line 252). Ran + `gives the officer four actions and nothing else` alone → + `Expected: 4 / Received: 5`, test failed. Reverted, confirmed `mutation-decoy` gone from the + file, test green again. +2. **Tap-target floor.** Changed `.actionButton`'s `min-height` from `var(--of-space-48)` to + `1.5rem` (confirmed: `grep -n "min-height: 1.5rem;"` → present). Same test → + `Expected: >= 48 / Received: 24`, test failed. Reverted, confirmed the token restored, test + green. +3. **Escort regex.** Changed the `
Escort required
` label to `
Chaperone +required
` (confirmed present via `grep`). Same test → `toContainText` failed, the printed + received string showing "Chaperone required" where "Escort required" should have been. + Reverted, confirmed restored, test green. +4. **Governance "every" claim.** Changed `shows every transport job` to `shows +all outstanding transport job` (confirmed present via `grep`). Ran + `states it is showing every job rather than inventing an officer to own them` alone → + `toContainText(/every/i)` failed against the printed received string. Reverted, confirmed + restored, test green. +5. **Job count.** Added `&& movement.transport.escortRequired` to the `jobs` filter (confirmed + present via `grep`). Same test → `[data-testid^="ward-officer-job-"]` resolved to 5 elements, + not 8 — the exact count of `escortRequired: true` jobs from my re-measurement, which is itself + a nice cross-check that the mutation did precisely what I intended. Reverted, confirmed the + filter restored, test green. + +## The one mutation I could not make kill anything — reported plainly, not hidden + +I attempted to prove the horizontal-overflow assertion +(`document.documentElement.scrollWidth - document.documentElement.clientWidth <= 2`) could fail, +and could not, after four separate, escalating mutation attempts targeting different layers: + +1. Removed `.main`'s `overflow-x: hidden`. No effect — confirmed via a throwaway Playwright probe + that the computed `overflow-x` on `.main` stayed `auto`, not `visible`: per the CSS Overflow + spec, when `overflow-y` is anything other than `visible` (here `auto`, needed for the + scrollable list), a UA must compute `overflow-x` as `auto` too rather than `visible` if it was + only left at its default — a genuine CSS coupling I hadn't accounted for, not a no-op edit. +2. Set `.main`'s `overflow-x: visible` explicitly (not just removed). Same result — the pairing + rule above overrides an explicit `visible` the same way it overrides the default. +3. Widened `.screen` itself to `width: 900px` (confirmed via `getBoundingClientRect()`: the + `.screen` div genuinely rendered at 900px, and `document.body.scrollWidth` correctly reported + 900). Still `document.documentElement.scrollWidth` stayed `390`. I traced this to a genuine, + deliberate, **site-wide** safety net: `src/app/globals.css` sets `overflow-x: clip` on both + `html` (line 722) and `body` (line 755), unconditionally, for the whole application — not + scoped to any route. `overflow: clip` (stronger than `hidden`) stops overflow from + contributing to an ancestor's measured scroll dimensions at the box where it's declared, so + `body`'s own clip absorbs any normal-flow overflow from any of its descendants before it can + ever reach `documentElement`. +4. Widened the pinned `.actionRow` itself (`position: fixed`) with `right: -900px` instead of + `right: 0`, keeping `left: 0`. Confirmed via direct `getBoundingClientRect()` on the actual + button row (not a proxy element) that this genuinely rendered at 1290px wide (390 viewport + + 900), starting at `x: 0` — a real, measurable overflow of the pinned bar past the right edge. + `document.documentElement.scrollWidth` and `document.body.scrollWidth` both still read exactly + `390`. This is a second, independent, more fundamental reason on top of (3): CSS excludes + `position: fixed` elements from contributing to any ancestor's scrollable-overflow region at + all (by specification, since a fixed element never moves with scrolling, so its extent can't + sensibly define how far the page scrolls) — this is not specific to this site, it would hold + on any standards-compliant browser. + +I verified the assertion _can_ detect real overflow in principle — injecting a 2000px +`position: absolute` div directly as a child of `` via `page.evaluate` did move +`document.documentElement.scrollWidth` from 390 to 2000 in the same session. But neither vector +available to me inside `officer-screen.tsx`/`officer.module.css` (normal-flow widening, or +widening the one `position: fixed` element the design calls for) can reach that failure mode, +because of the two compounding reasons above — one this site's own deliberate global defence, one +universal CSS behaviour. All four attempts were reverted; `git diff` on `officer.module.css` +showed no residue (verified: `grep -n "overflow-x: hidden;\|grid-template-columns"` after the +last revert showed exactly the original two lines, and the file is back to 249 lines). + +**Conclusion, stated plainly per the mutation-testing discipline: this assertion, as written, did +not fail under any mutation I could confine to this screen's own two files.** That is not because +it is a vacuous or untestable assertion in general — it is a real, working safety net for the +whole application, and would catch a regression to the global clip rules themselves, or a route +that somehow escaped them. But for this specific component, the protection it verifies is +inherited wholesale from `src/app/globals.css`, not created by anything local `officer.module.css` +does, so I cannot claim to have proven the assertion "kills" a defect a Task 9 code change could +plausibly introduce. I stopped there rather than reformulating further, per the explicit +instruction not to keep hunting once a mutation survives and the reason is diagnosed. + +## Screenshot + +`artifacts/ward-management/phase3-officer-390.png`, 390×844, headless Chromium, script placed +under `artifacts/probe/` (deleted afterwards along with two follow-up diagnostic screenshots and +every other throwaway script from the mutation-testing work — `git status --porcelain` confirmed +clean before finishing; `artifacts/` itself is gitignored, so nothing under it is tracked either +way). + +**Could a driver use this one-handed?** Yes, for the one job that currently needs them +(WF-005): the governance banner, WF-005's five info fields, and the four pinned action buttons +(Accepted/En route/Collected/Arrived, one of them — En route — genuinely enabled and coloured +distinctly from the other three) are all visible without scrolling on a 390-wide phone, and the +buttons are large enough to tap without precision. Scrolling to reach a _different_ job (e.g. +WF-327, the last of the eight) requires a second tap ("Work this job") before its own actions +appear pinned at the bottom — confirmed via a full-page scroll capture that the last card's +"Work this job" button sits fully clear of the pinned bar, with real blank space between them +(the `--of-bar-reserve` padding is working, not just present in the CSS). + +**Does the pinned bar cover content, or does content reserve room for it?** Content reserves +room. `.main:has(.actionRow) { padding-bottom: var(--of-bar-reserve) }` only engages while the +bar exists (i.e., while any job remains), confirmed by scrolling every card into view — none sit +underneath the bar. + +**Is an unavailable action's reason legible at 390px?** The screenshot shows the button labels +themselves clearly (Accepted/En route/Collected/Arrived, each on one row, not truncated). The +_reason_ text itself is `sr-only` (screen-reader only, matching `ward-screen.tsx`'s established +pattern for blocked buttons) rather than visibly printed under each button — so a sighted driver +sees which buttons are greyed out but has to long-press or use the browser's own title tooltip to +read _why_, exactly as `ward-screen.tsx`'s Accept/Decline buttons already work. I did not deviate +from that established pattern, but flagging it here rather than silently treating "legible" as +satisfied: the reason is present and correct, but not visibly printed at a glance. + +## Status + +DONE. Two real defects found in my own first pass and fixed before any gate went green +(`RailLink` invisibility to the reachability scanner; the "Transport officer" label's substring +collision with the existing "Transport" nav link) — both confirmed via red-then-green re-runs, +not assumed. One assertion (`ui-ward-roles.spec.ts`'s horizontal-overflow check) I could not +prove would fail on a plausible local regression, diagnosed and reported above rather than +silently claimed as proven. diff --git a/docs/ward-flow-phase-3-workspace/transport-leg-helper-report.md b/docs/ward-flow-phase-3-workspace/transport-leg-helper-report.md new file mode 100644 index 000000000..475680171 --- /dev/null +++ b/docs/ward-flow-phase-3-workspace/transport-leg-helper-report.md @@ -0,0 +1,332 @@ +# Transport leg helper — report + +Scope: add one small pure function separating the discrete transport leg from +`transportStatusLabel`'s provider narrative, plus unit tests. No component, no screen, +no touching `src/components/ward-management/coordinator/**` or any `tests/ui-*.spec.ts` +file (another agent owns those in this worktree right now). + +## What was added + +File: `src/components/ward-management/ward-derivations.ts` (added immediately after the +existing `transportStatusLabel`, around line 146). + +```ts +/** The five discrete stages a transport job progresses through, in order. */ +export type TransportLeg = "Requested" | "Accepted" | "En route" | "Collected" | "Arrived"; + +export function transportLeg(transport: TransportJob | undefined): TransportLeg | "Cancelled" | undefined { + if (!transport) return undefined; + if (transport.cancelledAt !== undefined) return "Cancelled"; + if (transport.arrivedAt !== undefined) return "Arrived"; + if (transport.collectedAt !== undefined) return "Collected"; + if (transport.enRouteAt !== undefined) return "En route"; + if (transport.acceptedAt !== undefined) return "Accepted"; + return "Requested"; +} +``` + +Signature: `transportLeg(transport: TransportJob | undefined): TransportLeg | "Cancelled" | undefined`. + +### Why this shape + +- **Union of string literals, not `string`.** `TransportLeg` is a five-member string-literal + union (`"Requested" | "Accepted" | "En route" | "Collected" | "Arrived"`), and the function's + full return type adds `"Cancelled"` and `undefined` on top. A caller comparing against a typo + (`"enroute"`, `"En Route"`) gets a compile error instead of a silently-false comparison — this + satisfies "a caller cannot accidentally compare against prose" without introducing a discriminated + object the existing call sites would all need to unwrap. I considered a richer discriminated + union (`{ kind: "no-transport" } | { kind: "cancelled" } | { kind: "leg"; leg: TransportLeg }`) + but rejected it: nothing in the brief or Task 10's stated test (`/Requested|Accepted|En +route|Collected|Arrived/`) needs a structured payload, and the plain literal-union return is + already exactly the five capitalised display strings the regex expects, so a caller can either + match the string directly or render it as-is. +- **`undefined` for absence, not a sixth leg or a default.** Matches the existing pattern + `unitById`/`destinationUnit`/`restrictionNotice` already use in this same file. A movement + with no transport has not reached `"Requested"` — returning `undefined` and letting the caller + render an explicit absence keeps that distinction real, per the project's "no substituted + record" constraint. +- **`"Cancelled"` kept outside the `TransportLeg` union.** Cancelled is not a stage on the + five-leg progression (a job can be cancelled from any leg, or before reaching `"Requested"`'s + peers), so folding it into `TransportLeg` would let a caller iterate/index the five-leg union + and unexpectedly hit `"Cancelled"`. Keeping it as a separate literal in the function's return + type keeps `TransportLeg` meaning exactly "one of the five progression stages" for any future + caller that wants that guarantee (e.g. an ordered index into a five-step tracker UI). +- **Precedence order copied verbatim from `transportStatusLabel`.** Same branch order (cancelled + first, then arrived → collected → en route → accepted, defaulting to requested) so the two + functions can never disagree about what stage a job is in. + +`transportStatusLabel` itself was **not modified** — same five narrative branches, same six +return strings, same signature. + +### `transportStatusLabel` callers + +Confirmed still in use — did not remove it: + +- `src/components/ward-management/ward-management-console.tsx:31,286,314` +- `src/components/ward-management/ward-management-network.tsx:17,60` + +Both are outside `coordinator/**`, so this task did not need to touch them. + +## Tests + +File: `tests/ward-derivations.test.ts` — new `describe("transportLeg", ...)` block appended at +the end of the existing file (node-environment Vitest, already covers this module). Added a +local `transportJob(overrides)` builder so every case constructs its own `TransportJob` object +rather than relying on the fixture (the real fixture only exercises two of the five legs). + +Eight tests, covering the required seven cases plus the explicit precedence test: + +1. `undefined` transport → `undefined` +2. no stamps → `"Requested"` +3. `acceptedAt` only → `"Accepted"` +4. `+ enRouteAt` → `"En route"` +5. `+ collectedAt` → `"Collected"` +6. `+ arrivedAt` → `"Arrived"` +7. `cancelledAt` only → `"Cancelled"` +8. precedence: a job with every progression stamp set resolves to `"Arrived"` (furthest + progressed); the same job with `cancelledAt` also set resolves to `"Cancelled"` (cancelled + wins over everything) + +## Gates run + +- **`npx tsc --noEmit -p tsconfig.json`** — final run after all mutation reverts: no output, + exit clean. No `.next/dev/types/` corruption encountered, so no deletion was needed. +- **Prettier** — `npx prettier --write src/components/ward-management/ward-derivations.ts` and + `tests/ward-derivations.test.ts` (specific files, not whole-tree format, per the task's + "npm run format can hang" guidance). Final check: `npx prettier --check +src/components/ward-management/ward-derivations.ts tests/ward-derivations.test.ts` → + `All matched files use Prettier code style!` +- **Node-environment suite, one invocation** (exact command from the brief): + `npx vitest run tests/ward-flow-reducer.test.ts tests/ward-flow-contracts.test.ts +tests/ward-model-phase3.test.ts tests/ward-model.test.ts tests/ward-flow-single-source.test.ts +tests/ward-clock.test.ts tests/ward-priority.test.ts tests/ward-pressure.test.ts +tests/ward-derivations.test.ts tests/ward-management.test.ts` + Ran this twice (once before mutation testing to confirm the addition was clean, once after all + mutations were reverted). Both runs reported the same decisive line: + `Test Files 10 passed (10)` / `Tests 126 passed (126)`. + Baseline was 118 across 10 files; 126 = 118 + 8 new tests, file count stayed at 10 (no + truncation on either run). +- **Playwright / browser gates** — not run, per instructions (another agent is using the browser + gate; this change touches no rendered surface). +- **`npm run verify:ui`, `npm run verify:release`, `tests/guard-push.test.ts`, or anything + provider-backed** — not run, per instructions. + +## Mutation testing — every mutation, line printed back, and result + +All mutations applied with `sed -i` (or a small inline Python patch for the reorder mutation) +directly against `src/components/ward-management/ward-derivations.ts`, verified by printing the +edited lines back with `sed -n`, run against `tests/ward-derivations.test.ts` only (for speed — +the full 10-file baseline was re-run clean after every mutation was reverted, see above), then +reverted and the revert also printed back and re-verified. + +### Mutation 1 — absence branch: `undefined` → `"Requested"` + +Line 165 changed to: + +``` + if (!transport) return "Requested"; +``` + +Printed back and confirmed via `sed -n '164,172p'` before running. + +Result: **killed**. `returns undefined when the movement carries no transport job at all` failed: +`AssertionError: expected 'Requested' to be undefined`. 14 of 15 tests in the file still passed. + +Reverted to `if (!transport) return undefined;`, printed back and confirmed. + +### Mutation 2 — cancelled branch: `"Cancelled"` → `"Requested"` + +Line 166 changed to: + +``` + if (transport.cancelledAt !== undefined) return "Requested"; +``` + +Result: **killed two tests**, as expected (cancelled is exercised both directly and inside the +precedence test): + +- `returns Cancelled when cancelledAt is stamped, distinct from every leg` — + `expected 'Requested' to be 'Cancelled'` +- `resolves precedence to the furthest-progressed stamp when several are set at once, and +cancelledAt always wins` — `expected 'Requested' to be 'Cancelled'` + +13 of 15 passed. Reverted, printed back and confirmed. + +### Mutation 3 — arrived branch: `"Arrived"` → `"Collected"` + +Line 167 changed to: + +``` + if (transport.arrivedAt !== undefined) return "Collected"; +``` + +Result: **killed two tests**: + +- `returns Arrived once arrivedAt is stamped` — `expected 'Collected' to be 'Arrived'` +- `resolves precedence to the furthest-progressed stamp when several are set at once, and +cancelledAt always wins` (the `fullyProgressed` half, which expects `"Arrived"`) — + `expected 'Collected' to be 'Arrived'` + +13 of 15 passed. Reverted, printed back and confirmed. + +### Mutation 4 — collected branch: `"Collected"` → `"En route"` + +Line 168 changed to: + +``` + if (transport.collectedAt !== undefined) return "En route"; +``` + +Result: **killed**. `returns Collected once collectedAt is stamped` failed: +`expected 'En route' to be 'Collected'`. 14 of 15 passed (the precedence test's `fullyProgressed` +case carries `arrivedAt`, so it never reaches the collected branch and was unaffected by this +mutation — expected, since that assertion's job is to catch the arrived/cancelled ordering, not +this branch). + +Reverted, printed back and confirmed. + +### Mutation 5 — en route branch: `"En route"` → `"Accepted"` + +Line 169 changed to: + +``` + if (transport.enRouteAt !== undefined) return "Accepted"; +``` + +Result: **killed**. `returns En route once enRouteAt is stamped` failed: +`expected 'Accepted' to be 'En route'`. 14 of 15 passed. + +Reverted, printed back and confirmed. + +### Mutation 6 — accepted branch: `"Accepted"` → `"Requested"` + +Line 170 changed to: + +``` + if (transport.acceptedAt !== undefined) return "Requested"; +``` + +Result: **killed**. `returns Accepted once acceptedAt is stamped` failed: +`expected 'Requested' to be 'Accepted'`. 14 of 15 passed. + +Reverted, printed back and confirmed. + +### Mutation 7 — default fallback: `"Requested"` → `"Accepted"` + +Line 171 changed to: + +``` + return "Accepted"; +``` + +Result: **killed**. `returns Requested for a transport job with no stamps at all` failed: +`expected 'Accepted' to be 'Requested'`. 14 of 15 passed. + +Reverted, printed back and confirmed. + +### Mutation 8 — precedence-specific: reorder so `cancelledAt` is checked last instead of first + +This is the mutation aimed specifically at the precedence-order test, isolated from the +individual-branch mutations above (several of which already killed the precedence test as a side +effect of also killing their own dedicated test — this one is designed so that every dedicated +single-leg test still passes and only the precedence test can catch it). Applied with a small +Python patch (safer than `sed` for a multi-line reorder), printed back afterward: + +``` +export function transportLeg(transport: TransportJob | undefined): TransportLeg | "Cancelled" | undefined { + if (!transport) return undefined; + if (transport.arrivedAt !== undefined) return "Arrived"; + if (transport.collectedAt !== undefined) return "Collected"; + if (transport.enRouteAt !== undefined) return "En route"; + if (transport.acceptedAt !== undefined) return "Accepted"; + if (transport.cancelledAt !== undefined) return "Cancelled"; + return "Requested"; +} +``` + +Result: **killed, and isolated exactly as intended**. Only +`resolves precedence to the furthest-progressed stamp when several are set at once, and +cancelledAt always wins` failed: `expected 'Arrived' to be 'Cancelled'` (the +`cancelledAfterProgress` case, which carries every stamp including `cancelledAt`, now resolves to +`"Arrived"` because the reordered cancelled-check runs last). All 14 other tests — including the +six single-leg tests and the `fullyProgressed` half of the same precedence test — still passed, +confirming the precedence test is the only one of the eight actually pinning branch order rather +than just branch content. + +Reverted with a Python patch back to the original order, printed back and confirmed identical to +the pre-mutation source. + +### Post-mutation verification + +After all eight mutations were reverted: + +- `git diff src/components/ward-management/ward-derivations.ts` showed exactly the intended + 28-line addition (the `transportLeg` function and its `TransportLeg` type, unchanged from the + original write) — no stray leftover edits from the mutation/revert cycle. +- Re-ran `npx prettier --check` on both changed files — clean (one `--write` pass was needed + after the Python-based revert of mutation 8, since the Python patch didn't reproduce Prettier's + exact formatting; re-ran `--write` and confirmed the diff was unchanged except for + normalization). +- Re-ran `npx tsc --noEmit -p tsconfig.json` — clean. +- Re-ran the full 10-file node-environment suite — `Test Files 10 passed (10)`, `Tests 126 passed +(126)`, matching the pre-mutation run exactly. + +No mutation survived. Every one of the eight kills is real and was observed directly in the +failing-test output above, not inferred from exit codes. + +## Commit + +One commit, staged and committed by exact path (`git commit +src/components/ward-management/ward-derivations.ts tests/ward-derivations.test.ts -m …`), so that +the other agent's already-staged `src/components/ward-management/coordinator/coordinator-screen.tsx` +and `tests/ui-ward-coordinator.spec.ts`, and my own out-of-scope `docs/ward-flow-phase-3-workspace/task-8-addendum.md` +(left untouched throughout, per instructions), were excluded from the commit and remain exactly +as they were before this task started. + +SHA: `cecc9539e893c06531723c8a0b4b825217e2ca64` + +``` +feat(ward-flow): separate the transport leg from the provider narrative + +transportStatusLabel mixes the discrete transport leg with provider prose +(e.g. "St John WA accepted, awaiting departure"), so it can never be +matched against a fixed leg pattern. Add transportLeg alongside it, +returning only the leg using transportStatusLabel's exact precedence +order, with a distinct "Cancelled" and an explicit undefined for no +transport job at all — never collapsed into one of the five leg names. +transportStatusLabel is unchanged and still has callers in +ward-management-console.tsx and ward-management-network.tsx. +``` + +Files changed: `src/components/ward-management/ward-derivations.ts` (+28), +`tests/ward-derivations.test.ts` (+82/-1, the one deletion being a trailing-line normalization +from Prettier on the existing file, not a content change). + +### A note on the commit itself + +The first two commit attempts (with and without a short wait in between) were rejected by +`.githooks/pre-commit`'s documentation-sync check: + +``` +[pre-commit] Documentation inputs have unstaged or untracked changes: +src/components/ward-management/coordinator/coordinator-screen.tsx +tests/ui-ward-coordinator.spec.ts +[pre-commit] Stage or separate these inputs before regenerating commit documentation. +``` + +This was not caused by my change. The hook reads `git diff --cached` across the _entire_ index, +not just the pathspec given to `git commit`, and the other agent's two files (both under +`src/components/` / `tests/`, matching the hook's design-system-adoption sync pattern) were +mid-edit at the time — staged with unstaged deltas on top from concurrent work I am explicitly +told not to touch. A brief retry did not resolve it (the delta reappeared between check and +commit — a live race with the other agent's process, not a stale/settled state). Since fixing the +"underlying issue" would have meant staging or editing the other agent's in-progress files, which +this task forbids, I used the hook's own documented, narrowly-scoped escape hatch — +`SKIP_DOCS_SYNC_HOOK=1` — for this one commit. That variable only short-circuits this specific +doc-regeneration hook (site-map / scripts-index / codebase-index / design-system-adoption sync); +it does not touch formatting, linting, or test hooks, all of which I had already verified +manually before committing (Prettier `--check`, `tsc --noEmit`, and the full node-environment +suite, all reported above). Nothing about my own two files needed doc regeneration in a way this +skip put at risk. Post-commit, `git status` confirms the other agent's two files remain exactly +as staged/unstaged as before, and `docs/ward-flow-phase-3-workspace/task-8-addendum.md` is +untouched. diff --git a/docs/ward-flow-phase-3-workspace/transport-stage-coherence-report.md b/docs/ward-flow-phase-3-workspace/transport-stage-coherence-report.md new file mode 100644 index 000000000..657afd718 --- /dev/null +++ b/docs/ward-flow-phase-3-workspace/transport-stage-coherence-report.md @@ -0,0 +1,342 @@ +# Transport stage/stamp coherence fix — report + +Commit: `1349c213fa6f3294a6a8fc22b0aded8c186e8429` +Branch: `codex/ward-management-design` (worktree `C:\Users\joshs\.codex\worktrees\ward-management-design\Database`) + +## The defect + +Eight movements in `src/components/ward-management/ward-movements.ts` carry a `transport` +job. Six of them (WF-006, WF-014, WF-306, WF-313, WF-320, WF-327) were stage `"moving"` with +`transport.collectedAt` unset. Confirmed against `ward-flow-reducer.ts`: +`PATIENT_COLLECTED` is the only transition that produces stage `"moving"`, and it always sets +`transport.collectedAt` in the same update (`case "PATIENT_COLLECTED"`, lines 359-371). No +sequence of events can reach the state those six shipped in. Because `PATIENT_ARRIVED` +requires `movement.stage === "moving" && movement.transport?.collectedAt`, all four officer +actions refused on all six, leaving `officer-screen.tsx` with four dead controls on six of its +eight jobs. + +Two of the six (WF-006, WF-014) are hand-authored records in `seededMovements`. The other four +(WF-306, WF-313, WF-320, WF-327) are produced by the generator's `stageFields` function +(`ward-movements.ts`), whose `"moving"` case set `acceptedAt` and `enRouteAt` but never +`collectedAt` — the actual root cause behind those four. Fixed at the generator, not by +overriding four generated literals, so any future index/count change to `routineMovements` +still produces coherent `"moving"` records. + +## 1. The six `collectedAt` values + +All computed relative to `NOW_ANCHOR = 642` (10:42). + +| id | acceptedAt | enRouteAt | collectedAt | pickup-drive (collected - enRoute) | in-transit so far (NOW - collected) | source | +| ------ | ---------- | --------- | ----------- | ---------------------------------- | ----------------------------------- | --------------------- | +| WF-006 | 592 | 627 | 635 | 8 min | 7 min | hand-authored | +| WF-014 | 597 | 632 | 638 | 6 min | 4 min | hand-authored | +| WF-306 | 596 | 612 | 620 | 8 min | 22 min | generated (index 306) | +| WF-313 | 589 | 602 | 617 | 15 min | 25 min | generated (index 313) | +| WF-320 | 597 | 607 | 629 | 22 min | 13 min | generated (index 320) | +| WF-327 | 590 | 607 | 618 | 11 min | 24 min | generated (index 327) | + +**WF-006** (hand-authored): collected 8 minutes after going en route — a short +RGH-to-RGH-Adult-Secure hop — leaving 7 minutes of in-transit time at `NOW_ANCHOR`. Chosen +because the whole accepted-to-en-route-to-now window is only 15 minutes; a short pickup-drive +followed by a few minutes still on the road reads as a coherent short transfer. + +**WF-014** (hand-authored): collected 6 minutes after going en route (FSH to RPH Adult +Secure, a secure escort with `specialling: true`), leaving 4 minutes in transit. The window +here is only 10 minutes total (accepted-to-now), the tightest of the six, so both intervals are +necessarily small. + +**WF-306, WF-313, WF-320, WF-327** (generated): rather than hand-picking four more literals, I +fixed the generator's `"moving"` case in `stageFields()`: + +```ts +const collectedAt = enRouteAt + Math.min(NOW_ANCHOR - enRouteAt, 8 + (index % 18)); +``` + +The `8 + (index % 18)` term gives an 8-25 minute pickup-drive that varies by index instead of +being one shared constant — the four real indices produce 8, 15, 22, and 11 minutes +respectively, so the four generated journeys read as being at different points, not identical +clones. The `Math.min` against `NOW_ANCHOR - enRouteAt` is a correctness clamp: it guarantees +`collectedAt <= NOW_ANCHOR` for any index/count the generator is ever called with, not just the +four values that exist today (the smallest possible gap for this generator's own +`acceptedAt`/`enRouteAt` formulas is 21 minutes, below the unclamped offset's ceiling of 25, so +the clamp is not just defensive — it can genuinely engage for some indices). + +**On the "close enough to also need `arrivedAt`" check** (explicitly asked for): none of the +six needed it. The two hand-authored records have only 4 and 7 minutes of in-transit time — +clearly just collected, the opposite of "about to arrive." The four generated records range +13-25 minutes in transit, comfortably inside a normal Perth-metro interfacility transfer and +well short of anything that would read as overdue for arrival. I did not deliberately avoid +this case — I checked after choosing the values — but no combination of pickup-drive time and +remaining gap in this fixture pushes any of the six into "should already have arrived" +territory, so I left all six as `collectedAt`-only and did not add `arrivedAt` to any of them. +Adding an arrival would also consume a bed and close the movement (`PATIENT_ARRIVED`'s own +effects), which is out of this fix's scope. + +## 2. The invariants added, and how they were derived + +Added a new `describe("fixture stage/stamp coherence (ward-movements.ts)", ...)` block to +`tests/ward-flow-contracts.test.ts`, distinct from the existing `describe("invariants across +every reachable state", ...)` block above it — that block walks one movement through +`wardFlowReducer` and checks reducer-produced states; this one inspects `wardMovements` +(the raw fixture) directly, since the fixture never goes through the reducer at all. + +Every rule is read off `ward-flow-reducer.ts`'s transport-related `case` blocks: + +- `PATIENT_COLLECTED` requires stage `"handover_ready"` + `transport.enRouteAt`, and sets stage + `"moving"` + `transport.collectedAt` in the same update -> **stage `"moving"` without + `collectedAt` is unreachable.** +- `PATIENT_ARRIVED` requires stage `"moving"` + `transport.collectedAt`, and sets stage + `"arrived"` + `transport.arrivedAt` in the same update -> **a movement that is stage + `"arrived"` with a transport job but no `arrivedAt` is unreachable.** +- `TRANSPORT_EN_ROUTE` requires `transport.acceptedAt`; `PATIENT_COLLECTED` requires + `transport.enRouteAt`; `PATIENT_ARRIVED` requires `transport.collectedAt` -> **the four + transport stamps can only be present in the order acceptedAt, enRouteAt, collectedAt, + arrivedAt** — a later one is never set without every earlier one. +- Every event's `now` becomes the stamp it writes and nothing moves the clock backward -> + **every stamp is `<= NOW_ANCHOR` and `>=` whichever stamp on the same job preceded it.** + +I did not find the task's list incomplete or wrong against the reducer — all four rules match +what the reducer actually enforces. + +Three `it()` blocks: + +1. **`"never leaves a 'moving' movement without the collection its stage implies"`** — asserts + `transport?.collectedAt` is defined for every `stage === "moving"` record. +2. **`"never leaves an 'arrived' movement's transport job without the arrival it implies"`** — + asserts `transport.arrivedAt` is defined for every record where `stage === "arrived" && +transport`. +3. **`"only ever fills transport stamps in the order the reducer allows, never after +NOW_ANCHOR"`** — for every movement with a transport job: the existence chain + (`enRouteAt` implies `acceptedAt`, `collectedAt` implies `enRouteAt`, `arrivedAt` implies + `collectedAt`), every present stamp `<= NOW_ANCHOR`, and every present stamp + `>=` its predecessor in stamp order. + +### The counter bug the coordinator caught, and the fix + +My first version counted **loop iterations**, not **matches**: + +```ts +let inspected = 0; +for (const movement of wardMovements) { + inspected += 1; // wrong: increments for every movement + if (movement.stage === "moving") { expect(...).toBeDefined(); } +} +expect(inspected).toBeGreaterThan(0); +``` + +That only proves `wardMovements` is non-empty (always true), not that the `if` body ever ran. +For the `"moving"` test it happened not to matter (six records match today), but for the +`"arrived"`-with-transport test it mattered completely: **no current fixture record is stage +`"arrived"` while still carrying a `transport` job** — both the hand-authored WF-007 and every +generated `"arrived"` record close without ever having had a transport job. That `if` body ran +zero times, and the old `expect(inspected).toBeGreaterThan(0)` tripwire passed anyway — the +exact defect class this project shipped before (Task 1's privacy guard, whose loops executed +zero times). + +Fixed by moving the counter inside the `if`, so it counts **matches**, not iterations: + +- `"moving"` test: `expect(matched).toBeGreaterThan(0)` — true today (six matches). +- `"arrived"`-with-transport test: `expect(matched).toBe(0)`, with an inline comment stating + plainly that no current record exercises this branch, that this is a forward-looking guard, + and that the assertion becomes live the moment a record does — rather than inventing a + fixture record just to make a `toBeGreaterThan(0)` pass. +- The third test (`"only ever fills transport stamps..."`) already counted matches correctly + (its counter increments only after `if (!transport) continue`, i.e. only for movements the + assertions actually inspect), so it needed no change. + +## Mutations, printed back and killed + +Every mutation below was made with `sed`, printed back with `sed -n`/`grep` from the file, +run, observed failing (or in one deliberate case, not failing — see below), then reverted and +reconfirmed green. No mutation markers remain in the committed diff. + +**1. Primary mutation — `moving` without `collectedAt`.** Removed WF-006's `collectedAt` line +(replaced with a comment). Printed back: + +``` + // MUTATION collectedAt removed +``` + +Result: `AssertionError: WF-006 is stage "moving" but transport.collectedAt is unset — +PATIENT_COLLECTED is the only reducer transition that produces "moving" and it always sets +collectedAt: expected undefined to be defined`. **Killed.** Reverted; confirmed green (10/10). + +**2. `arrived` without `arrivedAt` (inner assertion).** Changed WF-006's `stage` from +`"moving"` to `"arrived"` (transport still present, no `arrivedAt`). Printed back: + +``` + stage: "arrived", // MUTATION +``` + +Result: `AssertionError: WF-006 is stage "arrived" with a transport job but +transport.arrivedAt is unset: expected undefined to be defined`. **Killed.** Reverted; +confirmed green. + +**3. Existence chain — `enRouteAt` without `acceptedAt`.** Set WF-006's `acceptedAt` to +`undefined`. Printed back: + +``` + acceptedAt: undefined, // MUTATION +``` + +Result: `AssertionError: WF-006 has transport.enRouteAt without transport.acceptedAt: expected +undefined to be defined`. **Killed.** Reverted; confirmed green. + +**4. Stamp after `NOW_ANCHOR`.** Set WF-006's `collectedAt` to `NOW_ANCHOR + 5`. Printed back: + +``` + collectedAt: NOW_ANCHOR + 5, // MUTATION +``` + +Result: `AssertionError: WF-006 has a transport stamp after NOW_ANCHOR (642): expected 647 to +be less than or equal to 642`. **Killed.** Reverted; confirmed green. + +**5. Stamp ordering — swapped `acceptedAt`/`enRouteAt`.** Swapped WF-006's two values so +`acceptedAt (627) > enRouteAt (592)`. Printed back: + +``` + acceptedAt: NOW_ANCHOR - 15, // MUTATION swapped + enRouteAt: NOW_ANCHOR - 50, // MUTATION swapped +``` + +Result: `AssertionError: WF-006's transport stamps are not in non-decreasing order: expected +592 to be greater than or equal to 627`. **Killed.** Reverted; confirmed green. + +**6. Zero-record tripwire (test 1's loop, "moving").** Pointed the loop at `[] as typeof +wardMovements`. Printed back: + +``` + for (const movement of [] as typeof wardMovements) { // MUTATION empty +``` + +Result: `AssertionError: expected 0 to be greater than 0` (`matched`). **Killed.** Reverted; +confirmed green. + +**7. Corrected-counter mutation, "moving" test.** Same as #6, re-run against the corrected +`matched`-based counter (post-fix), to prove the fix itself is meaningful and not just a +rename. Same result: `expected 0 to be greater than 0`. **Killed.** + +**8. Corrected-counter mutation, "arrived" test — the interesting one.** Per the coordinator's +instruction to "point a loop at an empty array," I tried that first on the `"arrived"` test's +loop, printed back: + +``` + for (const movement of [] as typeof wardMovements) { // MUTATION empty (expected NOT to kill toBe(0)) +``` + +Result: **10/10 still passed — this mutation did not kill the assertion.** This is expected, +not a defect: `matched` is already `0` with the real fixture, so emptying the array leaves +`matched` at `0` either way, and `expect(matched).toBe(0)` is unaffected. Emptying-the-array is +the right kill mutation for a `toBeGreaterThan(0)` assertion (tests 1 and the third test) but +not for a `toBe(0)` assertion — for that, the count has to move _away from_ zero. This is a +mistimed mutation, not an untestable assertion — the correct mutation is #9 below. Reverted the +empty-array change before applying #9. + +**9. Correct kill mutation for the `toBe(0)` counter.** To isolate the _outer_ counter from the +_inner_ per-record assertion (so a failure clearly indicts the counter, not the inner check), I +mutated WF-006 into a state that is internally coherent — stage `"arrived"` **and** +`transport.arrivedAt` set — so the inner `expect(movement.transport.arrivedAt).toBeDefined()` +passes, and only the outer count-of-matches can fail. Printed back: + +``` + stage: "arrived", // MUTATION isolate-outer-counter +... + collectedAt: NOW_ANCHOR - 7, + arrivedAt: NOW_ANCHOR - 1, // MUTATION isolate-outer-counter (inner check still passes) +``` + +Result: + +``` +AssertionError: expected 1 to be +0 // Object.is equality +- Expected ++ Received +- 0 ++ 1 + v tests/ward-flow-contracts.test.ts:258:21 + expect(matched).toBe(0); +``` + +Failure lands exactly on the outer `expect(matched).toBe(0)` line, with the inner assertion +never firing (it can't — `arrivedAt` is set). **Killed**, and specifically proves the outer +counter is live and not hardcoded/tautological. Reverted both lines (removed the `arrivedAt` +line entirely, restored `stage: "moving"`); confirmed `git diff` matches the intended fixture +change exactly and 10/10 pass. + +No assertion survived a mutation that should have killed it. Mutation #8 "survived" a mutation +that, on inspection, should not have killed it — documented above rather than treated as a +red flag, per the mistimed-mutation-vs-untestable-assertion distinction. + +## Gates run + +- **`npx tsc --noEmit -p tsconfig.json`** — clean, no output, both before and after the + counter fix and after `prettier --write`. +- **Node-env ward suites** (`tests/ward-flow-reducer.test.ts tests/ward-flow-contracts.test.ts +tests/ward-model-phase3.test.ts tests/ward-model.test.ts tests/ward-flow-single-source.test.ts +tests/ward-clock.test.ts tests/ward-priority.test.ts tests/ward-pressure.test.ts +tests/ward-derivations.test.ts tests/ward-management.test.ts`, one invocation): + `Test Files 10 passed (10)` / `Tests 129 passed (129)`. **Baseline moved from 126 to 129 — + explained: exactly the three new `it()` blocks in `ward-flow-contracts.test.ts` added by this + change.** +- **jsdom, one file per invocation:** + - `tests/ward-screen.dom.test.tsx` -> `Tests 3 passed (3)` — matches baseline. + - `tests/ward-flow-clock-consistency.dom.test.tsx` -> `Tests 1 passed (1)` — matches baseline. + - `tests/ward-flow-provider.dom.test.tsx` -> `Tests 4 passed (4)` — matches baseline. + - `tests/ward-flow-queue-selection.dom.test.tsx` -> `Tests 1 passed (1)` — matches baseline. +- **Ward Chromium gate** (`PLAYWRIGHT_BASE_URL=http://localhost:3718 npx playwright test +tests/ui-ward-coordinator.spec.ts tests/ui-ward-management.spec.ts tests/ui-ward-roles.spec.ts +--project=chromium --reporter=line`): `30 passed (55.5s)` — matches baseline exactly, + including `Transport officer screen > gives the officer four actions and nothing else` + (per-job button count is unaffected by which of the four are enabled) and `states it is +showing every job rather than inventing an officer to own them` (job count, still 8, is + unaffected). +- **`npm run lint`** — **not run; confirmed lock-held, not a pass.** The first attempt + exceeded a 120-second foreground timeout and was backgrounded. Per the coordinator's + instruction, I stopped waiting on it and moved on rather than blocking. It finished on its + own well after the rest of this task was done, and its output is now available and confirms + the diagnosis exactly: + ``` + > prompt-for-codex-medical-knowledge-base@0.1.0 lint + > node scripts/run-heavy.mjs --npm-script lint:internal + + DATABASE_HEAVY_RUN_ADMISSION_BUSY + Another Database heavyweight command is active (PID 42780, worktree D:\Worktrees\Database\cc-2a-live, started 2026-08-22T06:31:47.361Z): vitest run --reporter=dot + + [exited with code 0] + ``` + Exit code 0 here is the documented soft-skip-on-busy behaviour, not a pass — lint never + actually ran. Recorded as "lint not run, lock held," per the read-output-not-exit-codes rule. +- **`npx prettier --write`** on both changed files: `ward-movements.ts` unchanged; + `ward-flow-contracts.test.ts` reformatted (still 10/10 passing after). + +## Officer screen — live DOM check + +Ran a throwaway Playwright script from the repo root (deleted afterward, never committed) at +390x844 against `http://localhost:3718/ward-management/transport/officer` (server already +running, not restarted). For each of the 8 jobs found in the live DOM +(`WF-005, WF-006, WF-014, WF-015, WF-306, WF-313, WF-320, WF-327`), selected the job (or used +the one already active) and read each of the four action buttons' `aria-disabled` attribute +directly from the rendered page. + +**Result: 8 of 8 jobs now have at least one available (non-`aria-disabled`) action**, counted +from the live DOM, up from 2 before this fix (WF-005 and WF-015, both `handover_ready` with an +available "En route" action). The six previously-dead jobs (WF-006, WF-014, WF-306, WF-313, +WF-320, WF-327) each now show "Arrived" as their available action — consistent with the fixture +now putting them at stage `"moving"` with `collectedAt` set and no `arrivedAt`, which is exactly +what `PATIENT_ARRIVED`'s guard requires. + +## Files touched + +- `src/components/ward-management/ward-movements.ts` — six `collectedAt` values (two literal, + four via a generator fix). +- `tests/ward-flow-contracts.test.ts` — new `describe("fixture stage/stamp coherence +(ward-movements.ts)", ...)` block, three `it()`s, `wardMovements` import added. + +## Commit + +Single commit, staged by exact path (`git add src/components/ward-management/ward-movements.ts +tests/ward-flow-contracts.test.ts`): `1349c213fa6f3294a6a8fc22b0aded8c186e8429` +`fix(ward-flow): give every in-transit patient the collection its stage implies`. The +pre-commit docs hook ran the design-system-adoption regenerator (`54 components, 77 roots`), +reported documentation already synchronized, and did not need a scoped override — no +generated-doc drift was introduced by this change. `git status` is clean at HEAD. diff --git a/docs/ward-management-mode-map.md b/docs/ward-management-mode-map.md index 2f440e3f5..d8779e5a1 100644 --- a/docs/ward-management-mode-map.md +++ b/docs/ward-management-mode-map.md @@ -7,6 +7,15 @@ That design has not been built yet — Phase 2 is what actually retires these mo document still describes the routes as they exist today. Read the design spec for where the product is headed; read this document for what is currently live. +**Phase 2 update (Task 9):** Constellation (`/ward-management/constellation`) is retired. Command +is rebuilt as the coordinator screen (`CoordinatorScreen`) — priority queue, statewide flow +diagram and explainable shortlist in one view — and absorbs the working-surface role Constellation +used to carry; the table below is corrected to drop the Constellation row. The eight remaining +mode links (Command, Network, Priority queue, Capacity, Movements, Exceptions, Transport, +Governance) moved out of the horizontal mode strip and into the left `ClinicalRail`, per the +owner's direction that Ward Flow is its own application inside the Clinical KB shell and free to +use that rail for its own navigation. See "Navigation" below. + **Status:** Synthetic product wireframe. The routes below model a WA mental-health patient-flow coordination layer; they do not claim to reproduce an internal WA Health system or current allocation policy. ## Operating model @@ -27,27 +36,33 @@ The public WA sources used to ground the wireframes establish five important con ## Primary route system -| Mode | Route | Primary question | Dominant visual | Primary owner | -| -------------- | -------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------- | -| Command | `/ward-management` | What needs a decision now? | Priority queue + pipeline + capacity ledger + AI decision dock | Flow coordinator | -| Constellation | `/ward-management/constellation` | How is demand moving across the statewide network? | Full-screen spatial service network with selective patient routes | Flow coordinator | -| Network | `/ward-management/network` | Where is bed pressure concentrated, and which movements cross a catchment? | Schematic node/edge diagram: fill is bed pressure, edges are movements | Flow coordinator | -| Priority queue | `/ward-management/queue` | Which placement-ready movement should be reviewed next, and why? | Sortable queue with tier, operational score and current blocker | Flow coordinator / ED | -| Capacity | `/ward-management/capacity` | Which currently confirmed bed states could support a review? | Hospital and ward capacity matrix with freshness and capability | Ward manager | -| Movements | `/ward-management/movements` | Where is every patient movement in the six-stage pathway? | Stage board with owned next action and elapsed time | All roles | -| Exceptions | `/ward-management/exceptions` | Which time-sensitive exception needs an owner action? | Action inbox organised by overdue, expiring and stale state | All roles | -| Transport | `/ward-management/transport` | Is the legal/document/booking chain ready for safe transfer? | Transport readiness board and metro/country pathway cues | ED / Flow coordinator | -| Governance | `/ward-management/governance` | Why did the system recommend this, who confirmed it, and what is synthetic? | AI assurance, audit trail, data boundary and source register | Authorised reviewers | - -The global Clinical KB rail remains the application switcher. The Ward Flow mode strip is the local navigation owner and appears consistently on every route. +| Mode | Route | Primary question | Dominant visual | Primary owner | +| -------------- | ----------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------- | +| Command | `/ward-management` | What needs a decision now? | Priority queue + statewide flow diagram + explainable shortlist | Flow coordinator | +| Network | `/ward-management/network` | Where is bed pressure concentrated, and which movements cross a catchment? | Schematic node/edge diagram: fill is bed pressure, edges are movements | Flow coordinator | +| Priority queue | `/ward-management/queue` | Which placement-ready movement should be reviewed next, and why? | Sortable queue with tier, operational score and current blocker | Flow coordinator / ED | +| Capacity | `/ward-management/capacity` | Which currently confirmed bed states could support a review? | Hospital and ward capacity matrix with freshness and capability | Ward manager | +| Movements | `/ward-management/movements` | Where is every patient movement in the six-stage pathway? | Stage board with owned next action and elapsed time | All roles | +| Exceptions | `/ward-management/exceptions` | Which time-sensitive exception needs an owner action? | Action inbox organised by overdue, expiring and stale state | All roles | +| Transport | `/ward-management/transport` | Is the legal/document/booking chain ready for safe transfer? | Transport readiness board and metro/country pathway cues | ED / Flow coordinator | +| Governance | `/ward-management/governance` | Why did the system recommend this, who confirmed it, and what is synthetic? | AI assurance, audit trail, data boundary and source register | Authorised reviewers | + +**Navigation:** the left `ClinicalRail` carries both the global Clinical KB application switcher +and, below it, Ward Flow's own eight mode links (icon-only, each with its own accessible name) — +there is no separate horizontal mode strip. This follows from Ward Flow being its own application +inside the Clinical KB shell, free to use that rail as its own local navigation, and it also gives +the coordinator screen back the vertical space the horizontal strip used to take. The rail stays +visible and reachable down to 320px; on a short viewport the mode-link section scrolls +independently of the pinned app-switcher icons above it and the pinned favourites/settings/avatar +controls below it. ## Role-to-mode defaults -| Role | Default focus | Can act on | Read-only context | -| ---------------- | ----------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------ | -| Flow coordinator | Command / Constellation | Match review, cross-catchment escalation, hold coordination, exception ownership | ED readiness, ward capacity, transport documents | -| ED mental health | Queue / Transport | Referral readiness, legal/form timing, handover and transport request readiness | Destination shortlist and ward response | -| Ward manager | Capacity / Movements | Capacity freshness, suitability response, acceptance and hold | ED handover and transport status | +| Role | Default focus | Can act on | Read-only context | +| ---------------- | -------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------ | +| Flow coordinator | Command | Match review, cross-catchment escalation, hold coordination, exception ownership | ED readiness, ward capacity, transport documents | +| ED mental health | Queue / Transport | Referral readiness, legal/form timing, handover and transport request readiness | Destination shortlist and ward response | +| Ward manager | Capacity / Movements | Capacity freshness, suitability response, acceptance and hold | ED handover and transport status | Role switching changes task emphasis and owned controls, not the underlying record. @@ -79,9 +94,9 @@ legibility and carry no geographic meaning; the mode says so on the page. bed states, freshness and inbound movements. Nothing on this mode confirms or changes a placement; it is a read surface that hands off to the queue and decision surfaces. -Constellation and Network deliberately overlap. Constellation is the working surface — it -carries the queue and the decision dock. Network is the diagnostic one: it answers "where is -the system under strain" without asking the coordinator to act. +Command and Network deliberately overlap now that Constellation is retired. Command is the +working surface — it carries the queue and the explainable shortlist. Network is the diagnostic +one: it answers "where is the system under strain" without asking the coordinator to act. ## Capacity semantics diff --git a/playwright.config.ts b/playwright.config.ts index e1666a455..3a2ab8e85 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,7 +23,7 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // `tests/playwright-project-isolation.test.ts` asserts every such file on disk is // matched here. const productionSpecPattern = - /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|dictionary|document-canvas|tools|ward-(?:management|coordinator)|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/; + /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|dictionary|document-canvas|tools|ward-(?:management|coordinator|roles)|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/; const mockupSpecPattern = /.*ui-(caring-contact-mockup|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; @@ -31,7 +31,7 @@ const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|caring-contact-mockup|dictionary|document-canvas|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|ward-(?:management|coordinator)|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/, + /.*(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(smoke|stress|accessibility|caring-contact-mockup|dictionary|document-canvas|document-top-navigation-mockup|sidebar-live-mockup|therapy-navigation-mockup|tools|tools-collapse|tools-search-mode-mockup|tools-task-directory|ward-(?:management|coordinator|roles)|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently diff --git a/scripts/playwright-pr-shards.mjs b/scripts/playwright-pr-shards.mjs index 341331e29..21e8213dc 100644 --- a/scripts/playwright-pr-shards.mjs +++ b/scripts/playwright-pr-shards.mjs @@ -19,7 +19,7 @@ import { childProcessExitCode } from "./child-process-result.mjs"; /** Same matcher as playwright.config.ts `productionSpecPattern` (keep in sync). */ export const productionSpecFilePattern = - /^(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:smoke|stress|accessibility|dictionary|document-canvas|tools|ward-(?:management|coordinator)|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts$/; + /^(?:answer-progress-ui-smoke|dsm-ui-smoke|ui-(?:smoke|stress|accessibility|dictionary|document-canvas|tools|ward-(?:management|coordinator|roles)|overlap|universal-search|specifiers|formulation(?:-result-cards)?|forms-section-nav|chrome-scroll|therapy-nav-scroll|mode-nav-density|phone-motion|phone-scroll(?:-[a-z0-9-]+)?|pwa|route-coverage|style-contract|visual-artifacts|hydration))\.spec\.ts$/; /** * One source of truth for shard membership and its latest hosted timing sample. @@ -49,10 +49,11 @@ export const prUiSpecProfiles = Object.freeze([ // Added after the timing sample. Measured locally at ~4.8s for 3 tests; replace // with hosted evidence at the next timing refresh. { file: "tests/ui-phone-motion.spec.ts", shard: 2, fullSeconds: 5.0, criticalSeconds: 0 }, - // New ward-management/ward-coordinator suites; keep on the lightest measured + // New Ward role suites; keep on the lightest measured // shard until hosted timing is available. { file: "tests/ui-ward-coordinator.spec.ts", shard: 2, fullSeconds: 0, criticalSeconds: 0 }, { file: "tests/ui-ward-management.spec.ts", shard: 2, fullSeconds: 0, criticalSeconds: 0 }, + { file: "tests/ui-ward-roles.spec.ts", shard: 2, fullSeconds: 0, criticalSeconds: 0 }, { file: "tests/ui-tools.spec.ts", shard: 3, fullSeconds: 110.5, criticalSeconds: 3.1 }, { file: "tests/ui-chrome-scroll.spec.ts", shard: 3, fullSeconds: 60.7, criticalSeconds: 0 }, diff --git a/src/app/ward-management/constellation/page.tsx b/src/app/ward-management/constellation/page.tsx deleted file mode 100644 index c3c23054d..000000000 --- a/src/app/ward-management/constellation/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Metadata } from "next"; - -import { WardModeWorkspace } from "@/components/ward-management/ward-management-modes"; - -export const metadata: Metadata = { - title: "Operational constellation - Ward Flow", - description: "Synthetic full-screen statewide mental health patient-flow constellation.", -}; - -export default function WardConstellationPage() { - return ; -} diff --git a/src/app/ward-management/ed/[edId]/page.tsx b/src/app/ward-management/ed/[edId]/page.tsx new file mode 100644 index 000000000..0c439b5e5 --- /dev/null +++ b/src/app/ward-management/ed/[edId]/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { EdScreen } from "@/components/ward-management/ed/ed-screen"; + +export const metadata: Metadata = { + title: "Emergency department — Ward Flow", + description: "Synthetic single-department emergency department view for the Ward Flow prototype.", +}; + +export default async function EdDepartmentPage({ params }: { params: Promise<{ edId: string }> }) { + const { edId } = await params; + return ; +} diff --git a/src/app/ward-management/layout.tsx b/src/app/ward-management/layout.tsx new file mode 100644 index 000000000..f43961777 --- /dev/null +++ b/src/app/ward-management/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +import { WardFlowProvider } from "@/components/ward-management/ward-flow-provider"; + +/** + * Holds the shared reducer state and clock above every ward route. No screen wires this + * itself: a route rendered without this layout in its path must throw via `useWardFlow` + * rather than render a substituted empty world. + */ +export default function WardManagementLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/ward-management/transport/officer/page.tsx b/src/app/ward-management/transport/officer/page.tsx new file mode 100644 index 000000000..c47852ed2 --- /dev/null +++ b/src/app/ward-management/transport/officer/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { OfficerScreen } from "@/components/ward-management/officer/officer-screen"; + +export const metadata: Metadata = { + title: "Transport officer — Ward Flow", + description: "Synthetic transport officer phone view for the Ward Flow prototype.", +}; + +export default function TransportOfficerPage() { + return ; +} diff --git a/src/app/ward-management/transport/page.tsx b/src/app/ward-management/transport/page.tsx index 3cb23ea59..5eb8a72af 100644 --- a/src/app/ward-management/transport/page.tsx +++ b/src/app/ward-management/transport/page.tsx @@ -1,12 +1,12 @@ import type { Metadata } from "next"; -import { WardModeWorkspace } from "@/components/ward-management/ward-management-modes"; +import { LiveTracker } from "@/components/ward-management/tracker/live-tracker"; export const metadata: Metadata = { - title: "Transport - Ward Flow", - description: "Synthetic legal, document, booking and handover transport-readiness view.", + title: "Live tracker - Ward Flow", + description: "Synthetic coordinator's live tracker of every vehicle: which patient, which leg, how long since.", }; export default function WardTransportPage() { - return ; + return ; } diff --git a/src/app/ward-management/ward/[unitId]/page.tsx b/src/app/ward-management/ward/[unitId]/page.tsx new file mode 100644 index 000000000..92e4d70c7 --- /dev/null +++ b/src/app/ward-management/ward/[unitId]/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { WardScreen } from "@/components/ward-management/ward/ward-screen"; + +export const metadata: Metadata = { + title: "Ward — Ward Flow", + description: "Synthetic single-unit ward view for the Ward Flow prototype.", +}; + +export default async function WardUnitPage({ params }: { params: Promise<{ unitId: string }> }) { + const { unitId } = await params; + return ; +} diff --git a/src/components/ward-management/coordinator/coordinator-screen.tsx b/src/components/ward-management/coordinator/coordinator-screen.tsx index 1ec6a8379..451585105 100644 --- a/src/components/ward-management/coordinator/coordinator-screen.tsx +++ b/src/components/ward-management/coordinator/coordinator-screen.tsx @@ -1,43 +1,151 @@ "use client"; -import { useState } from "react"; -import { ChevronDown, ChevronUp } from "lucide-react"; +import { useLayoutEffect, useMemo, useState } from "react"; -import { elapsedLabel } from "@/components/ward-management/ward-derivations"; -import { ClinicalRail, WardModeNavigation } from "@/components/ward-management/ward-management-navigation"; -import { wardMovements } from "@/components/ward-management/ward-movements"; +import { buildActionInbox, isOpen } from "@/components/ward-management/ward-derivations"; +import { useWardFlow } from "@/components/ward-management/ward-flow-provider"; +import { ClinicalRail } from "@/components/ward-management/ward-management-navigation"; import { queueOrder } from "@/components/ward-management/ward-priority"; -import { allEmergencyDepartments, NOW_ANCHOR } from "@/components/ward-management/ward-sites"; +import { allEmergencyDepartments } from "@/components/ward-management/ward-sites"; import styles from "./coordinator.module.css"; +import { ExceptionDrawer } from "./exception-drawer"; +import { FlowDiagram } from "./flow-diagram"; import { PressureStrip } from "./pressure-strip"; +import { PriorityQueue } from "./priority-queue"; +import { ShortlistPanel } from "./shortlist-panel"; /** * Task 3 shell: five landmark regions, all present and stubbed with real synthetic volume * (`edPressure` returns 8 departments, `queueOrder` returns 41 open movements) so the layout is - * judged against real volume rather than three placeholder rows. Task 4 builds out the pressure - * strip region and the queue's department filter; the remaining regions are built by later tasks. + * judged against real volume rather than three placeholder rows. Task 4 built out the pressure + * strip and the queue's department filter; Task 5 built the real `PriorityQueue`; Task 6 built + * the real `FlowDiagram`; Task 7 built the real `ShortlistPanel`; Task 8 built the real + * `ExceptionDrawer` and the phone form. */ + +// Must match the `@media (max-width: 48rem)` breakpoint in coordinator.module.css that hides +// `.diagramRegion` and `.pressureStrip`. CSS already hides the diagram visually on first paint +// (server-rendered markup cannot know the viewport), but Task 8 review Minor 5 found the +// underlying `FlowDiagram` still MOUNTED underneath that `display: none` — its `useLayoutEffect`, +// `ResizeObserver` and window-resize `measure()` all kept running against a zero-size box. This +// query drives whether `FlowDiagram` mounts at all, so a phone stops paying for work nobody can +// see rather than merely hiding the result of it. +const PHONE_DIAGRAM_MEDIA_QUERY = "(max-width: 48rem)"; + export function CoordinatorScreen() { - const [selectedMovementId, setSelectedMovementId] = useState(undefined); + // Task 5: the screen's props stop being derived from the frozen `wardMovements` fixture and + // `NOW_ANCHOR` constant and start coming from the shared provider (`WardFlowProvider`, already + // wrapping every `/ward-management` route via `src/app/ward-management/layout.tsx`). `units` + // is not destructured here — nothing this screen renders yet reads live unit state, and an + // unused destructured value would be dead weight rather than real wiring. + const { movements, rejections, now, dispatch, focusMovementId, setFocusMovementId } = useWardFlow(); + // Task 12: seeded from the shared `focusMovementId` (not always `undefined`) so a coordinator + // who switched away to answer a referral as another role and switches back finds the same + // patient still selected — this screen remounts on every route change (it is a route + // segment under the persistent `WardFlowProvider` layout, not the layout itself), so a plain + // `useState(undefined)` would otherwise forget the selection on every round trip. See + // `ward-flow-provider.tsx`'s doc comment on `focusMovementId` for why this lives in the shared + // context rather than only here. + // + // Restored only while that movement is still OPEN. Found while reviewing the journey's own + // final screenshot: an arrived movement is never reachable through the queue (`queueOrder` + // filters to `isOpen` before this screen ever renders a row for it to click), so restoring the + // selection unconditionally re-selected WF-315 after its own arrival and left the shortlist + // showing "Currently at ARM" / "waiting in the emergency department" for a patient who had, in + // fact, already left the department — stale copy for a record this screen has no other way to + // select. Gating on `isOpen` keeps the restore doing its actual job (surviving a role switch + // mid-journey) without inventing a selection path a live click could never produce. + const [selectedMovementId, setSelectedMovementId] = useState(() => { + const restored = focusMovementId ? movements.find((movement) => movement.id === focusMovementId) : undefined; + return restored && isOpen(restored) ? restored.id : undefined; + }); const [selectedUnitId, setSelectedUnitId] = useState(undefined); const [selectedEdId, setSelectedEdId] = useState(undefined); const [exceptionsOpen, setExceptionsOpen] = useState(false); + // SSR and the first client paint must agree (matchMedia is unavailable on the server), so this + // starts false — the same "assume desktop, correct after mount" convention + // `usesPhoneSearchLayout` uses in master-search-header.tsx. `useLayoutEffect` (not `useEffect`) + // keeps the window where `FlowDiagram` is needlessly mounted on a real phone as short as + // possible — synchronously before the browser paints, rather than after. + const [isPhoneDiagramLayout, setIsPhoneDiagramLayout] = useState(false); + + useLayoutEffect(() => { + const phoneMedia = window.matchMedia(PHONE_DIAGRAM_MEDIA_QUERY); + const sync = () => setIsPhoneDiagramLayout(phoneMedia.matches); + sync(); + phoneMedia.addEventListener("change", sync); + return () => phoneMedia.removeEventListener("change", sync); + }, []); + + // Task 8 review Important 3 named the real constraint: on a phone, the full explainable + // shortlist (candidates, all eight eligibility gates, every decline, the score breakdown) is + // real, long content that does not fit above the fold on a 390px screen, so Confirm has to stay + // reachable in one tap rather than a scroll a coordinator has to go looking for. This used to be + // solved with a double-`requestAnimationFrame` `scrollIntoView` effect here, because the control + // could land off-screen below the fold and the fix had to MEASURE where the scroll container had + // settled after a resize (`.main`'s grid rows and `.screen`'s `100dvh` height had not always + // finished resolving to the new viewport at the moment a single rAF ran — measured landing 256px + // short on some runs, which is why a second rAF was nested on top of it). + // + // Task 7 replaces that with `.shortlistActionRow` pinned to the literal viewport bottom by CSS + // on phone widths (`coordinator.module.css`, `@media (max-width: 48rem)`). A CSS-pinned bar + // cannot land off-screen, so it never measures `.main`'s grid or `.screen`'s height — the exact + // thing the deleted effect's comment named as the race it was working around. That race + // dissolves rather than needing a replacement fix here. + // Whole-branch review Critical 2, second half. Confirm now requires an explicit unit selection, + // but `selectedUnitId` is screen-level state that outlived the movement it was chosen for: pick + // a ward for patient A, then select patient B from the queue, and B inherited A's selection with + // Confirm already available against it. That is the same defect the panel-level fix closes, just + // sourced from a selection made for a different patient rather than from `shortlist[0]`. A unit + // choice belongs to the movement it was made against, so changing movement clears it. + function selectMovement(movementId: string | undefined) { + setSelectedMovementId(movementId); + setSelectedUnitId(undefined); + // Task 12: mirrored into the shared context so `WardRoleSwitcher` — rendered on every + // ward-management route, not just this one — can infer a Ward/ED destination for whichever + // patient was last selected here, even after a role switch has unmounted this screen. + setFocusMovementId(movementId); + } + + // The provider's own `movements` array — never `movementById`, which reads the frozen fixture + // and would never see a referral this screen just dispatched. An id the live array cannot + // resolve still renders as `undefined` (Task 6 conservative-failure rule), so no fallback is + // threaded through here. + const selectedMovement = selectedMovementId + ? movements.find((movement) => movement.id === selectedMovementId) + : undefined; + + const selectedEd = selectedEdId ? allEmergencyDepartments().find((ed) => ed.id === selectedEdId) : undefined; + // A `selectedEdId` this lookup cannot name must not leave the queue silently filtered with no + // notice and no Clear control (Task 4 review Minor 8) — fall back to "nothing selected" rather + // than substituting a record or keeping a filter active that can't be described on screen. + const activeEdId = selectedEd?.id; // A one-line filter, not a derivation: it keys on a field the model already carries // (`Movement.originEdId`), so it belongs here rather than in a ward-*.ts module. - const filteredMovements = selectedEdId - ? wardMovements.filter((movement) => movement.originEdId === selectedEdId) - : wardMovements; - const queue = queueOrder(filteredMovements, NOW_ANCHOR); - const selectedEd = selectedEdId ? allEmergencyDepartments().find((ed) => ed.id === selectedEdId) : undefined; + const filteredMovements = activeEdId ? movements.filter((movement) => movement.originEdId === activeEdId) : movements; + const queue = queueOrder(filteredMovements, now); + + // The exception inbox is the coordinator's global work list, not a view scoped to whatever ED + // filter the queue happens to have selected — a breached legal deadline at a filtered-out + // department must not silently drop off the work list just because the queue is filtered. + // `movements` and `now` are both live now (Task 5), not constants, so this must recompute + // whenever either changes rather than caching a single mount-time snapshot forever. + // + // Whole-branch review Minor 6: the inbox is scoped to OPEN movements. `buildActionInbox` has no + // `isOpen` guard of its own and `movements` carries all 48 records including the 7 closed + // ones, so passing the raw array put an arrived or did-not-proceed patient's breached deadline + // on a live work list. Latent on today's fixture — no closed movement currently qualifies for + // any of the three categories — but it is precisely the shape of Phase 1's "48 open movements" + // defect, which was also a closed record counted as live. + const actionInbox = useMemo(() => buildActionInbox(movements.filter(isOpen), now), [movements, now]); return (
- +

Ward Flow coordinator

-
Synthetic prototype @@ -48,55 +156,23 @@ export function CoordinatorScreen() {

-
- +
+
-
-
-

Priority queue

- {queue.length} open movements - {selectedEd ? ( -

- - Filtered to {selectedEd.siteCode} — {selectedEd.name} - - -

- ) : null} -
-
    - {queue.map((movement) => ( -
  • - -
  • - ))} -
-
+ setSelectedEdId(undefined)} + />

Statewide flow

{selectedUnitId ? ( - // Task 6 renders the real network diagram and calls `setSelectedUnitId` from a - // unit node click; this button is the one interaction this stub owns itself — - // clearing a selection made elsewhere (e.g. from a shortlist row) without - // requiring the not-yet-built diagram to be present.
-

- {selectedUnitId - ? `Network diagram centred on unit ${selectedUnitId}. Built in a later task.` - : "Network diagram of the hospital network. Built in a later task."} -

+ {/* Task 8 review Minor 5: `.diagramRegion` is already hidden by CSS below 48rem; + this stops `FlowDiagram` from mounting there too, rather than only painting + over its (still-running) ResizeObserver and layout-effect work. */} + {isPhoneDiagramLayout ? null : ( + setSelectedUnitId((current) => (current === unitId ? undefined : unitId))} + /> + )}
@@ -118,28 +200,32 @@ export function CoordinatorScreen() {

Explainable shortlist

-

- {selectedMovementId - ? `Shortlist for ${selectedMovementId}. Built in a later task.` - : "Select a movement from the priority queue to see its explainable shortlist."} -

+ setSelectedUnitId((current) => (current === unitId ? undefined : unitId))} + dispatch={dispatch} + />
-
- - {exceptionsOpen ?

Exceptions inbox. Built in a later task.

: null} -
+ setExceptionsOpen((open) => !open)} + // Task 8 review Important 3: on a phone the open drawer's own panel is what stands + // between a coordinator and Confirm — selecting an exception has done the drawer's + // job (a movement is now chosen), so it closes itself in the same tap rather than + // leaving a coordinator to scroll past it to reach the shortlist underneath. + onSelectMovement={(movementId) => { + selectMovement(movementId); + setExceptionsOpen(false); + }} + />
); diff --git a/src/components/ward-management/coordinator/coordinator.module.css b/src/components/ward-management/coordinator/coordinator.module.css index 2ae17761b..1c0f70f82 100644 --- a/src/components/ward-management/coordinator/coordinator.module.css +++ b/src/components/ward-management/coordinator/coordinator.module.css @@ -7,6 +7,27 @@ --co-space-14: 0.875rem; --co-space-20: 1.25rem; --co-space-48: 3rem; + /* + * Task 7: the phone-pinned referral bar's own vertical footprint, computed rather than + * measured — one `--co-space-48` button row, `--co-shortlist-bar-pad-y` above it, at least the + * same padding (more on a device with a taller home-indicator safe area) below it, plus its own + * `0.0625rem` top border. `.shortlistActionRow`'s padding and `.main:has(.shortlistActionRow)`'s + * reserve both read these same tokens rather than restating the numbers, so the bar's real + * height and the space carved out for it can never drift apart. + */ + --co-shortlist-bar-pad-y: var(--co-space-8); + --co-shortlist-bar-safe-bottom: max(var(--co-space-8), var(--safe-area-bottom)); + --co-shortlist-bar-reserve: calc( + var(--co-space-48) + var(--co-shortlist-bar-pad-y) + var(--co-shortlist-bar-safe-bottom) + 0.0625rem + ); + /* Custom-property assignments, not `z-index:` declarations — the design-system contract + ratchets raw `z-index:` literals, not the tokens that hold them. Every actual `z-index` + declaration in this file reads through `var(--co-z-*)`, except the phone referral bar below, + which reaches straight for the global `--z-overlay` ladder rung: once pinned, it escapes this + component's own stacking context and has to sort against page-level chrome, not against the + flow-diagram's local connectors/nodes pair. */ + --co-z-connectors: 1; + --co-z-nodes: 2; --co-leading-compact: 1.1; --co-leading-tight: 1.2; --co-leading-body: 1.4; @@ -26,26 +47,21 @@ display: grid; min-width: 0; min-height: 0; - grid-template-rows: auto auto minmax(0, 1fr) auto; + /* + * Task 9 Ruling 4: the horizontal `WardModeNavigation` row this used to reserve is gone — the + * eight mode links live in `ClinicalRail` (grid-column 1) now — so `.main` is down to three + * rows: the governance banner, the scrollable body, and the exceptions drawer. + */ + grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; } /* - * `ClinicalRail` (from the shared ward-management.module.css) sets `display: none` on itself at - * <=40rem, but that only removes the element — a fixed `4.5rem` track in `.screen`'s own - * grid-template-columns still reserves the width regardless of what occupies it, leaving a - * blank 72px band down the left edge of the narrowest supported layout. Collapse to one column - * at the same breakpoint the rail already hides at. + * `ClinicalRail` (shared ward-management.module.css) now carries Ward Flow's own mode + * navigation (Task 9 Ruling 4), so it stays visible and reachable down to 320px on this screen — + * it no longer collapses to `display: none` here, and `.screen`'s fixed `4.5rem` rail track + * stays reserved at every supported width rather than collapsing to one column. */ -@media (max-width: 40rem) { - .screen { - grid-template-columns: minmax(0, 1fr); - } - - .main { - grid-column: 1; - } -} .governanceBanner { display: flex; @@ -158,6 +174,13 @@ * the strip's only job is a one-second read). `auto-fit` sizes eight columns comfortably at * 1600px and wraps onto more rows as the region narrows, without ever clipping a card. */ +.pressureRule { + margin: 0 0 var(--co-space-8); + color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + .pressureList { display: grid; min-width: 0; @@ -188,18 +211,36 @@ .pressureCardSelected { border-color: var(--clinical-accent-border); background: var(--clinical-accent-soft); + /* + * `outline` rather than another border/background/box-shadow property: the breaching rule + * below sets border-color and background on `.pressureCardSelected[data-breaching]` too (a + * breaching card is always danger-tinted, selected or not), which fully overrides those two + * properties here regardless of selector order. `outline` is never touched by that rule, so + * it survives on a selected+breaching card — previously the only surviving difference there + * was a 1px inset shadow, which forced-colors flattens to `none`, leaving zero visible + * difference between selected and unselected on any breaching card (Task 4 review Important + * 4). `outline-offset: -0.125rem` draws it just inside the border so it doesn't add to the + * card's footprint in the grid. + */ + outline: 0.125rem solid var(--clinical-accent-border); + outline-offset: -0.125rem; box-shadow: var(--shadow-inset); } +.pressureCardSelected:hover { + outline-width: 0.1875rem; +} + /* * The worst department leads the list by data (breach count beats wait length beats volume — * see `edPressure`), but ordering alone reads as arbitrary at a glance. A department with a * breached legal deadline gets the danger tint regardless of selection state, so the reason it * leads is visible on the card itself, not just implied by its position (Task 4 ruling — "the - * worst card must say why it is worst"). + * worst card must say why it is worst"). `data-breaching` is always the real numeric count + * (never a boolean), so this matches any non-zero count rather than one hard-coded sentinel. */ -.pressureCard[data-breaching="true"], -.pressureCardSelected[data-breaching="true"] { +.pressureCard:not([data-breaching="0"]), +.pressureCardSelected:not([data-breaching="0"]) { border-color: var(--danger-border); background: var(--danger-bg); } @@ -244,10 +285,25 @@ * real phone composition (hiding the diagram, one-tap confirm); this only keeps the frame from * breaking in the meantime. */ +/* + * `align-self: start` (rather than the grid default `stretch`) is load-bearing for the diagram's + * own `min-height` floor below to actually work. Without it, `.body`'s `minmax(0, 1fr)` row + * force-stretches this grid to a fixed, viewport-derived height, and when `.diagramRegion`'s + * explicit floor exceeds that fixed budget, CSS Grid's row-sizing algorithm satisfies it by + * squeezing the SIBLING rows that have no floor of their own — measured: the priority queue's + * list collapsed to zero visible rows (its own `overflow-y: auto` absorbing the entire deficit) + * at 1280x900, breaking the queue's click target along with it (Controller finding 8 follow-up). + * `align-self: start` instead lets this grid size to its own natural content height — including + * every row's own explicit minimum — and overflow `.body`'s box when that natural height is + * taller than the viewport, which `.body`'s own `overflow: auto` (declared above) already + * reveals via one ordinary page-level scrollbar. No sibling is squeezed below its own minimum to + * pay for another row's floor. + */ .regionGrid { display: grid; min-width: 0; min-height: 0; + align-self: start; grid-template-columns: minmax(0, 1fr); gap: var(--co-space-10); } @@ -255,16 +311,55 @@ @media (min-width: 90rem) { .regionGrid { grid-template-columns: 14rem minmax(38rem, 1fr) 23rem; + /* + * Restores the ORIGINAL `stretch` (the grid default; `align-self: start` above is what + * overrides it) — at this width the three regions share one row, and stretch-then-squeeze + * is what already gave every region (queue's own internal scroll included) a sensible, + * viewport-proportional height before this task touched anything, exactly as Controller + * finding 8's own 1600x1100 screenshot showed working. Content-sizing (`start`) is only + * needed below this width, where the region grid switches to separate stacked rows and + * `.diagramRegion`'s floor would otherwise steal space from a sibling that shares no row + * with it — see that override's own comment. Verified empirically: applying `start` + * unscoped here let `.queueRegion`'s now-uncapped natural height (41 unclamped rows, + * ~4300px) become the shared row's height, stretching every column to match it. + */ + align-self: stretch; } } +/* + * `max-height` here is what makes `.queueList`'s own `overflow-y: auto` below actually engage + * now that `.regionGrid` sizes to its own content (`align-self: start`, above) rather than being + * force-stretched to a fixed, viewport-derived height. Before that change, this region's compact, + * internally-scrolling card relied entirely on the OUTER grid squeeze to give it a bounded box — + * with that squeeze gone (deliberately, so the diagram's own floor below can be honoured without + * stealing this region's space), this needs its own explicit cap to keep behaving the same way, + * rather than growing to show all 41 movements unclamped. + */ .queueRegion { display: flex; min-height: 0; + max-height: 26rem; flex-direction: column; padding: var(--co-space-12); } +/* + * The 26rem cap above exists only to keep the STACKED layout's page length sane (see its own + * comment). At >=90rem the three regions share one row instead, and `.queueRegion` should + * stretch to fill whatever height that shared row naturally ends up being — its own long-standing + * behaviour, unchanged since Task 5 — not stay pinned to a fixed cap regardless of how much room + * the row actually has. This rule must stay AFTER the base `.queueRegion` rule above: CSS resolves + * two equal-specificity declarations of the same property by source order, not by which one sits + * inside a media query, so placing this earlier let the unconditional 26rem cap win even at wide + * viewports (found and fixed while verifying this task's own review fix empirically). + */ +@media (min-width: 90rem) { + .queueRegion { + max-height: none; + } +} + /* The 14rem queue column is too narrow for "Priority queue" and the open-movement count to share one line without the heading wrapping mid-word; stack them instead of fighting for width. */ @@ -318,6 +413,41 @@ line-height: var(--co-leading-body); } +/* + * The tier badge is a pill rather than plain text so it reads as a distinct field at a glance, + * not a continuation of the id line above it — with 16 tier-1, 13 tier-2 and 12 tier-3 open + * movements, a coordinator scans this field on nearly every row (Task 5 ruling 2). + * `justify-self: start` keeps the pill sized to its own text rather than stretching across the + * full 14rem row width the way an ordinary grid item would. + */ +.queueRow .queueTier, +.queueRowSelected .queueTier { + justify-self: start; + border-radius: var(--radius-pill, 9999px); + background: var(--surface-subtle); + padding: 0 var(--co-space-8); + font-weight: 650; +} + +/* Overrides the plain-span colour above: the operational score is the number a coordinator + reads off this row, so it gets heading weight rather than the muted metadata tone. */ +.queueRow .queueScore, +.queueRowSelected .queueScore { + color: var(--text-heading); + font-weight: 700; +} + +/* + * The one line on this row a coordinator must not miss (Task 5 ruling — WF-001 ranks above + * WF-006 despite waiting 6 hours less because its Form 1A deadline has already passed). Danger + * tone, same token pair `pressureBreach` uses on the pressure strip for the same reason. + */ +.queueRow .queueLegalBreach, +.queueRowSelected .queueLegalBreach { + color: var(--danger-text); + font-weight: 700; +} + .diagramRegion, .shortlistRegion { display: flex; @@ -326,62 +456,1423 @@ padding: var(--co-space-14); } -.shortlistColumn { - display: flex; - min-height: 0; - min-width: 0; +/* + * Controller finding 8: the region grid's row-sizing algorithm treats a `min-height: 0` item as + * free to squeeze toward zero when the grid's own available height is tight — that is what let + * this region collapse to 208px at 1280x900. This override applies to the diagram only (later in + * source, same specificity as the shared selector above, so it wins for `.diagramRegion` without + * touching `.shortlistRegion`), giving the whole region — header plus the `.diagramScroll` panel + * below it — a floor the grid squeeze cannot compress past. + */ +.diagramRegion { + min-height: 32rem; } -.shortlistColumn > .shortlistRegion { +/* + * Controller finding 8: the region grid squeezes `.diagramRegion` to fit whatever share of + * `.body`'s available height the grid's row-sizing algorithm allocates it — measured 208px for + * ~1080px of content at 1280x900, an unusable letterbox for a common laptop width. This scroll + * wrapper gives the diagram its own bounded, independently-scrolling box instead: a real + * `min-height` floor the outer grid squeeze cannot compress past, plus `overflow-y: auto` so + * whatever height it ends up with beyond that floor, the excess scrolls cleanly rather than + * clipping or spilling into whatever sits below it in the grid. + * + * Deliberately a SEPARATE element from `.diagramCanvas`, not `overflow-y: auto` on the canvas + * itself. `.diagramCanvas` is the positioning root for the absolutely-positioned connector + * `` (`inset: 0`, sized to the canvas's own padding box). If the canvas itself scrolled, + * that padding box would only be as tall as the visible slice, and the svg would size to that + * slice rather than the full scrollable content — misaligning every connector the moment the + * user scrolls. With the scroll boundary here instead, `.diagramCanvas` (svg and every node + * together) is ordinary in-flow content inside this scroller: scrolling moves the whole block as + * one rigid unit, so the svg and the nodes it draws lines between never drift apart. + */ +/* + * Deliberately NOT `flex: 1 1 auto`. A flex-grow item asks to "fill the flex container's + * available height" — but `.diagramRegion`'s own height is itself resolved from ITS content + * (via `.regionGrid`'s `align-self: start`, above), so "available height" has no independent + * value to grow toward: it circularly depends on how tall this element decides to be. Verified + * empirically — with `flex: 1 1 auto` here, `.diagramRegion` inflated to several thousand pixels + * at 1600x1100 (and dragged the stretch-aligned `.queueRegion` up with it) rather than the + * ~1400px its real content actually needs. Plain intrinsic sizing (`min-height` as a floor, + * content grows it further when taller) has no such loop. + */ +.diagramScroll { + min-height: 30rem; + overflow-y: auto; + overflow-x: hidden; +} + +/* + * `position: relative` is the positioning root every connector coordinate in + * `flow-diagram.tsx` is measured against (`getBoundingClientRect()` minus this box's own + * `left`/`top`) — move this rule's positioning scheme and every connector goes wrong. No + * `overflow` is set here — see `.diagramScroll` above for why the scroll boundary lives one + * level up instead. + */ +.diagramCanvas { + position: relative; + min-height: 20rem; +} + +/* + * Absolutely positioned, `aria-hidden`, `pointer-events: none` — a rendering of relationships + * the node buttons already carry in their own `data-*` attributes, never itself a click target. + */ +.diagramConnectors { + position: absolute; + z-index: var(--co-z-connectors); + inset: 0; width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; } -.placeholder { - margin: 0; +.diagramConnectorDemand, +.diagramConnectorRouteEligible, +.diagramConnectorRouteIneligible, +.diagramConnectorDestination { + fill: none; + vector-effect: non-scaling-stroke; +} + +.diagramConnectorDemand { + stroke: var(--border-strong); + stroke-width: 0.0625rem; +} + +.diagramConnectorRouteEligible { + stroke: var(--clinical-accent); + stroke-width: 0.125rem; +} + +/* + * `eligibleCandidates` sorts eligible-first but never filters, so a shortlisted unit can still + * fail a gate (Critical 1 — already declined, wrong security tier, stale capacity, ...). This + * line must never look like the eligible route above: dashed, and danger-toned rather than + * accent-toned, so the arrow itself does not read as an endorsement of a route the data denies. + */ +.diagramConnectorRouteIneligible { + stroke: var(--danger-text); + stroke-width: 0.125rem; + stroke-dasharray: 6 4; +} + +.diagramArrowDemand { + fill: var(--border-strong); +} + +.diagramArrowRouteEligible { + fill: var(--clinical-accent); +} + +.diagramArrowRouteIneligible { + fill: var(--danger-text); +} + +/* + * Whole-branch review Important 3: the unit a movement is actually recorded against (accepted, or + * a live parallel referral) had no connector at all for 18 of the 41 open movements, while three + * candidate arrows pointed at wards the patient is not going to. This is a RECORDED FACT, not a + * suggestion, so it must not borrow either candidate treatment: solid and heavier than the + * eligible-candidate line, success-toned rather than accent-toned, and entering the node higher up + * its left edge so a unit that is both recorded and a candidate shows two separable lines rather + * than one drawn twice. + */ +.diagramConnectorDestination { + stroke: var(--success-text); + stroke-width: 0.1875rem; +} + +.diagramArrowDestination { + fill: var(--success-text); +} + +/* + * Mobile-first, same convention as `.regionGrid` above: one column by default, so the diagram + * keeps reflowing without overflow down to 320px (this task's own responsibility — Task 8 owns + * hiding the diagram entirely on phones, but until then it must not break the frame). The + * three-column layout with departments left, hub centre, units right only turns on at a width + * comfortably wider than the three tracks' combined minimum (ruling 1, below). + * + * Ruling 4 continued: this is the "node container" — `pointer-events: none` here, with + * `pointer-events: auto` re-enabled only on the interactive unit buttons inside + * `.diagramUnitsColumn`. Without this the container sits on top of everything (it shares the + * same stacking area as the connector overlay) and swallows every click meant for a node. + */ +.diagramNodes { + position: relative; + z-index: var(--co-z-nodes); + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr); + align-items: start; + gap: var(--co-space-20); + pointer-events: none; +} + +.diagramDepartmentsColumn { + grid-column: 1 / 2; + min-width: 0; +} + +.diagramHub { + grid-column: 1 / 2; + display: grid; + justify-items: center; + align-content: start; + gap: var(--co-space-2); + border: 0.0625rem solid var(--clinical-accent-border); + border-radius: var(--radius-md); + background: var(--surface); + padding: var(--co-space-12) var(--co-space-10); + text-align: center; + box-shadow: var(--e1); +} + +.diagramHub svg { + width: 1.25rem; + height: 1.25rem; + color: var(--clinical-accent); +} + +.diagramHub strong { + color: var(--clinical-accent); + font-size: var(--text-3xs); + font-weight: 700; + letter-spacing: 0.04em; +} + +.diagramHub span { color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-tight); +} + +.diagramUnitsColumn { + grid-column: 1 / 2; + min-width: 0; +} + +/* + * Ruling 1: the brief's `grid-column: 1 / 2 / 3` is invalid — `grid-column` takes at most two + * values. Each of the three tracks is pinned explicitly with the valid `start / end` form + * instead (never left to auto-placement, which is what put the hub in the wrong column + * previously). `64rem` leaves a comfortable margin over the three tracks' combined minimum + * (13 + 10 + 16 = 39rem of column content plus two 1.25rem gaps = 41.5rem) once the region + * grid's own chrome (the rail, `.body` padding, `.diagramRegion` padding) is subtracted from + * the viewport width — cutting it closer risked overflowing right at the boundary. + */ +@media (min-width: 64rem) { + .diagramNodes { + grid-template-columns: minmax(11rem, 13rem) minmax(9rem, 10rem) minmax(16rem, 1fr); + } + + .diagramDepartmentsColumn { + grid-column: 1 / 2; + } + + .diagramHub { + grid-column: 2 / 3; + } + + .diagramUnitsColumn { + grid-column: 3 / 4; + } +} + +.diagramColumnHeading { + margin: 0 0 var(--co-space-8); + color: var(--text-heading); + font-size: var(--text-3xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.diagramDepartmentsList { + display: grid; + gap: var(--co-space-8); +} + +.diagramEdCard { + display: grid; + gap: var(--co-space-2); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-subtle); + padding: var(--co-space-8) var(--co-space-10); +} + +.diagramEdCard[data-origin="true"] { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); + outline: 0.125rem solid var(--clinical-accent-border); + outline-offset: -0.125rem; +} + +.diagramEdCode { + color: var(--text-heading); font-size: var(--text-2xs); - line-height: var(--co-leading-prose); + font-weight: 700; } -.exceptionsDrawer { - border-top: 0.0625rem solid var(--border); - background: var(--surface-chrome); - padding: var(--co-space-8) var(--co-space-20); +.diagramEdName { + color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); } -.exceptionsToggle { - display: inline-flex; - min-height: var(--co-space-48); - align-items: center; +.diagramEdStats { + color: var(--text-muted); + font-size: var(--text-3xs); +} + +.diagramOriginBadge { + justify-self: start; + border-radius: var(--radius-pill, 9999px); + background: var(--clinical-accent-soft); + padding: 0 var(--co-space-6); + color: var(--clinical-accent); + font-size: var(--text-3xs); + font-weight: 700; +} + +.diagramServiceGroup { + display: grid; + gap: var(--co-space-6); + margin-bottom: var(--co-space-10); +} + +.diagramServiceHeading { + margin: 0; + color: var(--text-heading); + font-size: var(--text-3xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.diagramUnitGrid { + display: grid; + min-width: 0; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: var(--co-space-8); +} + +.diagramUnit, +.diagramUnitSelected { + display: grid; + width: 100%; + min-width: 0; + min-height: var(--co-space-48); + align-content: start; + gap: var(--co-space-2); border: 0.0625rem solid var(--border); border-radius: var(--radius-md); background: var(--surface); - padding: 0 var(--co-space-14); + padding: var(--co-space-8) var(--co-space-10); + color: var(--text); + text-align: left; + /* Re-enables clicks on this one interactive node inside the otherwise `pointer-events: none` + `.diagramNodes` container (ruling 4). */ + pointer-events: auto; +} + +.diagramUnit:hover { + background: var(--surface-subtle); +} + +.diagramUnitSelected { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); + box-shadow: var(--shadow-inset); +} + +.diagramUnit[data-routed="true"][data-eligible="true"], +.diagramUnitSelected[data-routed="true"][data-eligible="true"] { + outline: 0.125rem solid var(--clinical-accent-border); + outline-offset: -0.125rem; +} + +/* + * Review Critical 1: a routed-but-ineligible node must not share the accent outline above — that + * reads as the same kind of endorsement the connector line and hub wording were also fixed to + * stop giving it. Dashed and danger-toned instead, matching the ineligible connector line. + */ +.diagramUnit[data-routed="true"][data-eligible="false"], +.diagramUnitSelected[data-routed="true"][data-eligible="false"] { + outline: 0.125rem dashed var(--danger-border); + outline-offset: -0.125rem; +} + +.diagramUnitName { color: var(--text-heading); - font-size: var(--text-xs); + font-size: var(--text-2xs); font-weight: 700; } -.exceptionsToggle svg { - width: 1rem; - height: 1rem; - color: var(--clinical-accent); +.diagramUnitCapability { + color: var(--text-muted); + font-size: var(--text-3xs); } -.exceptionsDrawer[data-open="true"] .placeholder { - margin-top: var(--co-space-8); +.diagramBedRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--co-space-6); } -@media (prefers-reduced-motion: reduce) { - .screen * { - scroll-behavior: auto !important; - transition: none !important; - } +.diagramBedChip { + display: inline-flex; + align-items: center; + border: 0.0625rem solid var(--border-strong); + border-radius: var(--radius-xs); + padding: 0 var(--co-space-6); + font-size: var(--text-3xs); + font-weight: 700; } -@media (forced-colors: active) { - .queueRowSelected { - border: 0.125rem solid Highlight; +.diagramBedChip[data-state="available"] { + border-color: var(--success-text); + background: var(--success-bg); + color: var(--success-text); +} + +.diagramBedChip[data-state="held"] { + border-color: var(--warning-text); + background: var(--warning-bg); + color: var(--warning-text); +} + +.diagramBedChip[data-state="blocked"] { + border-color: var(--danger-text); + background: var(--danger-bg); + color: var(--danger-text); +} + +.diagramBedChip[data-state="occupied"] { + border-color: var(--border-strong); + background: var(--surface-subtle); + color: var(--text-muted); +} + +/* + * `potential` is drawn from bed releases, never from `unit.beds`, and must never be summed into + * the four states above (ruling 3). Dashed styling marks it visually as the separate, + * forward-looking figure it is — the same convention the Phase 1 network diagram already uses. + */ +.diagramBedChip[data-state="potential"] { + border-style: dashed; + border-color: var(--clinical-accent); + color: var(--clinical-accent); +} + +.diagramUnauthorisedBadge { + justify-self: start; + color: var(--danger-text); + font-size: var(--text-3xs); + font-weight: 700; +} + +/* + * Every shortlisted node's real eligibility, in text — never colour alone, so it survives + * forced-colors, and never conveyed only by the connector's own colour (review Important 5: + * routed state was previously invisible to a screen reader, carried only by an outline colour + * plus an aria-hidden svg). `candidateReason(verdict)` supplies the exact wording either way: + * "Eligible now", or the specific failing gate's own detail text. + */ +.diagramEligibleBadge { + justify-self: start; + border-radius: var(--radius-pill, 9999px); + background: var(--clinical-accent-soft); + padding: 0 var(--co-space-6); + color: var(--clinical-accent); + font-size: var(--text-3xs); + font-weight: 700; +} + +/* + * Review Critical 1: a shortlisted unit that fails a gate (already declined, wrong security + * tier, stale capacity, ...) must never read as a viable route. Danger-toned and textual, paired + * with the dashed `.diagramConnectorRouteIneligible` line — colour is never the only signal. + */ +.diagramIneligibleBadge { + justify-self: start; + border-radius: var(--radius-pill, 9999px); + background: var(--danger-bg); + padding: 0 var(--co-space-6); + color: var(--danger-text); + font-size: var(--text-3xs); + font-weight: 700; +} + +/* + * Review Important 2: an accepted bed and an outstanding referral are different facts a + * coordinator acts on differently — `destinationUnit`'s single conflated field used to badge + * both the same way (and only the first of a movement's parallel referrals). Two distinct badges + * instead, one per unit that actually carries that fact. + */ +.diagramAcceptedBadge { + justify-self: start; + border-radius: var(--radius-pill, 9999px); + background: var(--success-bg); + padding: 0 var(--co-space-6); + color: var(--success-text); + font-size: var(--text-3xs); + font-weight: 700; +} + +.diagramReferredBadge { + justify-self: start; + border-radius: var(--radius-pill, 9999px); + background: var(--warning-bg); + padding: 0 var(--co-space-6); + color: var(--warning-text); + font-size: var(--text-3xs); + font-weight: 700; +} + +/* + * Whole-branch review Important 5: the security gate passes a locked ward for an open-status + * movement, with the affirmative detail "Secure ward meets an open requirement". That is correct + * and `ward-eligibility.ts` is a protected surface, so the fact is surfaced here rather than the + * gate being changed. Solid border, not colour alone, so it survives forced-colors; wraps rather + * than truncating, because the whole point is that it is read. + */ +.diagramRestrictiveBadge { + justify-self: start; + border: 0.0625rem solid var(--warning-border); + border-radius: var(--radius-sm); + background: var(--warning-bg); + padding: 0 var(--co-space-6); + color: var(--warning-text); + font-size: var(--text-3xs); + font-weight: 700; + text-wrap: balance; +} + +/* + * Task 5 fix: the voluntary-on-locked warning (`restrictionNotice`'s sharper level) reads more + * prominently than the plain over-restrictive one above -- a voluntary person who cannot leave a + * locked ward is detained in fact without an order. Danger-toned rather than warning-toned, same + * shape as `.diagramRestrictiveBadge` and the same "never colour alone" real-text discipline as + * `.shortlistRestrictiveBadgeProminent`, so the diagram and the shortlist read as one system. + */ +.diagramRestrictiveBadgeProminent { + justify-self: start; + border: 0.0625rem solid var(--danger-border); + border-radius: var(--radius-sm); + background: var(--danger-bg); + padding: 0 var(--co-space-6); + color: var(--danger-text); + font-size: var(--text-3xs); + font-weight: 700; + text-wrap: balance; +} + +/* + * Review Minor 6: a unit whose `siteByCode` lookup misses would otherwise vanish from every + * service group with nothing on screen accounting for it. Rendered as an explicit anomaly + * instead — never a normal actionable node (no button, no click handler), so it cannot be + * mistaken for a real routing candidate. + */ +.diagramUnplacedUnit { + display: grid; + min-width: 0; + align-content: start; + gap: var(--co-space-2); + border: 0.0625rem dashed var(--danger-border); + border-radius: var(--radius-md); + background: var(--danger-bg); + padding: var(--co-space-8) var(--co-space-10); + color: var(--text); + text-align: left; +} + +.shortlistColumn { + display: flex; + min-height: 0; + min-width: 0; +} + +.shortlistColumn > .shortlistRegion { + width: 100%; +} + +.placeholder { + margin: 0; + color: var(--text-muted); + font-size: var(--text-2xs); + line-height: var(--co-leading-prose); +} + +/* + * Task 7: the explainable shortlist. `.shortlistRegion` above is already `display: flex; + * flex-direction: column` with its own padding — this is the scrolling body inside it, the same + * convention `.queueList` and `.diagramScroll` already use elsewhere in this file, so a long + * movement (many declines, an expanded score) scrolls inside its own box rather than growing the + * whole region past its grid track. + */ +.shortlistBody { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + gap: var(--co-space-12); + overflow-y: auto; +} + +.shortlistHeader { + display: grid; + gap: var(--co-space-2); + border-bottom: 0.0625rem solid var(--border); + padding-bottom: var(--co-space-10); +} + +.shortlistHeaderTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--co-space-8); +} + +.shortlistMovementId { + color: var(--text-heading); + font-size: var(--text-sm); + font-weight: 700; + line-height: var(--co-leading-tight); +} + +/* Same pill convention as `.queueTier` in the priority queue, kept as a distinct rule (rather + than composed with it) since the two live in different regions and must be free to diverge. */ +.shortlistTierBadge { + border-radius: var(--radius-pill, 9999px); + background: var(--surface-subtle); + padding: 0 var(--co-space-8); + color: var(--text-heading); + font-size: var(--text-3xs); + font-weight: 650; +} + +.shortlistMetaLine { + color: var(--text-muted); + font-size: var(--text-2xs); + line-height: var(--co-leading-body); +} + +/* + * The one line a coordinator must not miss on this movement, same danger tone `.queueLegalBreach` + * and `.pressureBreach` already use elsewhere for the same reason: a passed statutory deadline. + */ +.shortlistLegalBreach { + color: var(--danger-text); + font-size: var(--text-2xs); + font-weight: 700; + line-height: var(--co-leading-body); +} + +/* + * Review ruling 5: an accepted destination, an outstanding referral, and a merely-suggested + * candidate are three different facts a coordinator acts on differently, so each gets its own + * badge rather than one conflated "destination" field. The suggested badge is deliberately never + * accent- or success-toned — it is a computed guess, not a recommendation, and reads as neutral. + */ +.shortlistAcceptedBadge, +.shortlistReferredBadge, +.shortlistSuggestedBadge, +.shortlistUnresolvedBadge { + justify-self: start; + border-radius: var(--radius-pill, 9999px); + padding: 0 var(--co-space-8); + font-size: var(--text-3xs); + font-weight: 700; +} + +.shortlistAcceptedBadge { + background: var(--success-bg); + color: var(--success-text); +} + +.shortlistReferredBadge { + background: var(--warning-bg); + color: var(--warning-text); +} + +.shortlistSuggestedBadge { + border: 0.0625rem dashed var(--border-strong); + background: var(--surface-subtle); + color: var(--text-muted); +} + +.shortlistUnresolvedBadge { + border: 0.0625rem dashed var(--danger-border); + background: var(--danger-bg); + color: var(--danger-text); +} + +/* + * Whole-branch review Important 5. Deliberately NOT pill-shaped like the badges above: those + * report what a movement is, this reports a restriction a coordinator has to weigh, and it must + * wrap to full sentences rather than being squeezed into a chip. + */ +.shortlistRestrictiveBadge { + justify-self: start; + border: 0.0625rem solid var(--warning-border); + border-radius: var(--radius-sm); + background: var(--warning-bg); + padding: var(--co-space-2) var(--co-space-8); + color: var(--warning-text); + font-size: var(--text-3xs); + font-weight: 700; +} + +/* + * Task 5: the voluntary-on-locked warning (`restrictionNotice`'s sharper level) reads more + * prominently than the plain over-restrictive one — a voluntary person who cannot leave a locked + * ward is detained in fact without an order. Danger-toned rather than warning-toned, with the same + * "never colour alone" real-text discipline as the warning variant above. + */ +.shortlistRestrictiveBadgeProminent { + justify-self: start; + border: 0.0625rem solid var(--danger-border); + border-radius: var(--radius-sm); + background: var(--danger-bg); + padding: var(--co-space-2) var(--co-space-8); + color: var(--danger-text); + font-size: var(--text-3xs); + font-weight: 700; +} + +.shortlistSectionHeading { + margin: 0 0 var(--co-space-8); + color: var(--text-heading); + font-size: var(--text-3xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* + * Whole-branch review Critical 1: the candidate list used to be headed "Nearest candidates", a + * proximity claim nothing in the model can support. The heading now says only "Candidates" and + * this note carries the real ordering rule, so the reader is told how the list is sorted instead + * of being left to assume. + */ +.shortlistSectionNote { + margin: calc(-1 * var(--co-space-2)) 0 var(--co-space-8); + color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + +/* + * Whole-branch review Important 5, stated at the moment of decision: immediately above the gate + * list, where the security check will read "Met". Warning-toned with a real border so it is not + * carried by colour alone under forced-colors. + */ +.shortlistRestrictiveNote { + margin: 0 0 var(--co-space-8); + border: 0.0625rem solid var(--warning-border); + border-radius: var(--radius-sm); + background: var(--warning-bg); + padding: var(--co-space-6) var(--co-space-8); + color: var(--warning-text); + font-size: var(--text-3xs); + font-weight: 700; + line-height: var(--co-leading-body); +} + +/* Task 5: same shape as `.shortlistRestrictiveNote`, danger-toned for the sharper + * voluntary-on-locked case (see `.shortlistRestrictiveBadgeProminent` above). */ +.shortlistRestrictiveNoteProminent { + margin: 0 0 var(--co-space-8); + border: 0.0625rem solid var(--danger-border); + border-radius: var(--radius-sm); + background: var(--danger-bg); + padding: var(--co-space-6) var(--co-space-8); + color: var(--danger-text); + font-size: var(--text-3xs); + font-weight: 700; + line-height: var(--co-leading-body); +} + +.shortlistCandidateList { + display: grid; + gap: var(--co-space-8); +} + +/* + * Ruling 1: every candidate row states its own verdict in text (the row's own last line, driven + * by `candidateReason`), and an ineligible row is visually distinct by BORDER STYLE, not colour + * alone, so the distinction survives `forced-colors`. Eligible and ineligible share this one base + * rule; the two states below only ever add a dashed danger outline for the ineligible case, never + * an accent/success treatment that would read as a recommendation. + */ +.shortlistCandidateRow { + display: grid; + width: 100%; + min-height: var(--co-space-48); + gap: var(--co-space-2); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: var(--co-space-8) var(--co-space-10); + color: var(--text); + text-align: left; +} + +.shortlistCandidateRow:hover { + background: var(--surface-subtle); +} + +/* + * `data-showing`, not `aria-pressed` — this highlight marks which candidate's gates the panel + * is currently displaying, including the default (nothing explicitly selected) candidate. + * `aria-pressed` is reserved for the real, explicit selection the component was given + * (`selectedUnitId`), so a screen reader is never told a control is pressed when nobody + * pressed it (review Minor 5). + */ +.shortlistCandidateRow[data-showing="true"] { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); + box-shadow: var(--shadow-inset); +} + +.shortlistCandidateRow[data-eligible="false"] { + border-style: dashed; + border-color: var(--danger-border); +} + +.shortlistCandidateRow[data-showing="true"][data-eligible="false"] { + background: var(--danger-bg); + box-shadow: none; +} + +.shortlistCandidateName { + color: var(--text-heading); + font-size: var(--text-2xs); + font-weight: 700; +} + +.shortlistCandidateCapacity { + color: var(--text-muted); + font-size: var(--text-3xs); +} + +.shortlistCandidateReasonOk { + color: var(--clinical-accent); + font-size: var(--text-3xs); + font-weight: 700; +} + +.shortlistCandidateReasonBad { + color: var(--danger-text); + font-size: var(--text-3xs); + font-weight: 700; +} + +/* Whole-branch review Important 5, on the candidate row itself, so the restriction is visible + * while scanning the list rather than only after opening the gates for that unit. */ +.shortlistCandidateRestrictive { + color: var(--warning-text); + font-size: var(--text-3xs); + font-weight: 700; + line-height: var(--co-leading-body); +} + +/* Task 5: the voluntary-on-locked case, danger-toned to read more prominently on the row itself + * (see `.shortlistRestrictiveBadgeProminent` above for the same distinction on the badge). */ +.shortlistCandidateRestrictiveProminent { + color: var(--danger-text); + font-size: var(--text-3xs); + font-weight: 700; + line-height: var(--co-leading-body); +} + +/* + * Ruling 2/3: all eight gates render every time (never `.slice()`), failures first. The icon is + * driven ONLY by `gate.pass` in the component — nothing in this stylesheet may substitute for + * that, which is the exact defect this task exists to close (a green tick beside a failing gate). + * "Met"/"Not met" is real text in every row, not merely implied by the icon or by colour. + */ +.shortlistGateList { + display: grid; + gap: var(--co-space-6); +} + +.shortlistGateRow { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: baseline; + gap: var(--co-space-2) var(--co-space-8); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: var(--co-space-6) var(--co-space-8); +} + +.shortlistGateRow[data-pass="false"] { + border-style: dashed; + border-color: var(--danger-border); + background: var(--danger-bg); +} + +.shortlistGateRow svg { + grid-row: 1 / 3; + width: 1rem; + height: 1rem; +} + +.shortlistGateIconOk { + color: var(--success-text); +} + +.shortlistGateIconBad { + color: var(--danger-text); +} + +.shortlistGateLabel { + grid-column: 2 / 3; + color: var(--text-heading); + font-size: var(--text-2xs); + font-weight: 700; +} + +.shortlistGateVerdictOk { + grid-column: 3 / 4; + justify-self: end; + color: var(--success-text); + font-size: var(--text-3xs); + font-weight: 700; +} + +.shortlistGateVerdictBad { + grid-column: 3 / 4; + justify-self: end; + color: var(--danger-text); + font-size: var(--text-3xs); + font-weight: 700; +} + +.shortlistGateDetail { + grid-column: 2 / 4; + color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + +.shortlistDeclineList { + display: grid; + gap: var(--co-space-6); +} + +.shortlistDeclineRow { + display: grid; + gap: var(--co-space-2); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-subtle); + padding: var(--co-space-6) var(--co-space-8); + font-size: var(--text-3xs); +} + +.shortlistDeclineRow strong { + color: var(--text-heading); +} + +.shortlistDeclineRow span { + color: var(--text-muted); + line-height: var(--co-leading-body); +} + +.shortlistScoreDetails { + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-subtle); + padding: var(--co-space-8) var(--co-space-10); +} + +.shortlistScoreSummary { + min-height: var(--co-space-48); + display: flex; + align-items: center; + color: var(--text-heading); + font-size: var(--text-2xs); + font-weight: 700; + cursor: pointer; +} + +.shortlistScoreNote { + margin: var(--co-space-6) 0; + color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + +.shortlistScoreList { + display: grid; + gap: var(--co-space-6); +} + +.shortlistScoreFactor { + color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + +.shortlistScoreFactor strong { + color: var(--text-heading); +} + +.shortlistActions { + display: grid; + gap: var(--co-space-8); + border-top: 0.0625rem solid var(--border); + padding-top: var(--co-space-10); +} + +.shortlistAutoAllocationNote { + margin: 0; + color: var(--text-muted); + font-size: var(--text-3xs); + font-weight: 650; + line-height: var(--co-leading-body); +} + +.shortlistConfirmationRecord { + margin: 0; + border: 0.0625rem solid var(--success-border); + border-radius: var(--radius-md); + background: var(--success-bg); + padding: var(--co-space-8) var(--co-space-10); + color: var(--success-text); + font-size: var(--text-2xs); + font-weight: 650; + line-height: var(--co-leading-body); +} + +.shortlistActionRow { + display: flex; + flex-wrap: wrap; + gap: var(--co-space-8); +} + +.shortlistConfirmButton, +.shortlistOverrideButton { + display: inline-flex; + min-height: var(--co-space-48); + flex: 1 1 auto; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + padding: 0 var(--co-space-14); + font-size: var(--text-xs); + font-weight: 700; +} + +.shortlistConfirmButton { + border: 0.0625rem solid var(--clinical-accent-border); + background: var(--clinical-accent); + color: var(--clinical-accent-contrast); +} + +.shortlistConfirmButton[aria-disabled="true"] { + cursor: not-allowed; + border-color: var(--border); + background: var(--surface-subtle); + color: var(--text-muted); +} + +.shortlistOverrideButton { + border: 0.0625rem solid var(--border); + background: var(--surface); + color: var(--text-heading); +} + +.shortlistOverrideButton[aria-disabled="true"] { + cursor: not-allowed; + color: var(--text-muted); +} + +.shortlistOverrideForm { + display: grid; + gap: var(--co-space-6); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-subtle); + padding: var(--co-space-10); +} + +.shortlistOverrideLabel { + color: var(--text-heading); + font-size: var(--text-3xs); + font-weight: 700; +} + +.shortlistOverrideTextarea { + min-height: var(--co-space-48); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: var(--co-space-8); + color: var(--text); + font-size: var(--text-2xs); + line-height: var(--co-leading-body); +} + +.shortlistOverrideSubmit { + display: inline-flex; + min-height: var(--co-space-48); + align-items: center; + justify-content: center; + justify-self: start; + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: 0 var(--co-space-14); + color: var(--text-heading); + font-size: var(--text-xs); + font-weight: 700; +} + +@media (forced-colors: active) { + .shortlistCandidateRow[data-showing="true"] { + border: 0.125rem solid Highlight; + } + + .shortlistCandidateRow[data-eligible="false"] { + border-color: GrayText; + } + + .shortlistGateRow[data-pass="false"] { + border-color: GrayText; + } +} + +.exceptionsDrawer { + border-top: 0.0625rem solid var(--border); + background: var(--surface-chrome); + padding: var(--co-space-8) var(--co-space-20); +} + +.exceptionsToggle { + display: inline-flex; + min-height: var(--co-space-48); + align-items: center; + gap: var(--co-space-8); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: 0 var(--co-space-14); + color: var(--text-heading); + font-size: var(--text-xs); + font-weight: 700; +} + +.exceptionsToggle svg { + width: 1rem; + height: 1rem; + color: var(--clinical-accent); +} + +/* + * Ruling 3: this count is always `items.length` from the same array the rows in + * `.exceptionsPanel` below are mapped from — a pill styled distinctly from the "Exceptions" + * label so the true count reads as its own field, not decoration, the same convention + * `.regionCount` uses elsewhere for "N open movements" / "N departments". + */ +.exceptionsToggleCount { + display: inline-flex; + min-width: var(--co-space-20); + align-items: center; + justify-content: center; + border-radius: var(--radius-pill, 9999px); + background: var(--clinical-accent-soft); + padding: 0 var(--co-space-8); + color: var(--clinical-accent); + font-size: var(--text-3xs); + font-weight: 700; +} + +.exceptionsDrawer[data-open="true"] .placeholder { + margin-top: var(--co-space-8); +} + +/* + * Task 8: the exceptions drawer is the coordinator's work list, not a report — every row + * `buildActionInbox` returns is real work, so this panel gets its own bounded, scrolling box + * (the same pattern `.queueList` already uses) rather than pushing the fixed-height chrome above + * it off screen when the inbox is long. + */ +/* + * Task 8 review Important 2: a fixed `18rem` cap showed only 4 of the real fixture's 8 rows — + * a header that says "8" over a box that shows 4 is the "48 open movements" defect Ruling 3 + * exists to prevent, and it held at every width, desktop included. Bounded against the viewport + * instead of a flat rem value, so it scales with the screen rather than silently truncating + * again if the fixture's exception count grows. Measured: the real fixture's eight rows need + * ~37rem (each row wraps to three text lines, not the `3rem` tap-target floor) — an EARLIER fix + * here that used `34rem` still clipped the eighth row by ~30px, the exact defect this ruling + * exists to close, just moved from "4 of 8" to "7.5 of 8". `40rem` clears that with margin; + * `60vh` is what actually governs on anything shorter than a genuinely tall screen, so a truly + * short viewport still scrolls honestly rather than pretending to fit. + */ +.exceptionsPanel { + margin-top: var(--co-space-8); + max-height: min(60vh, 40rem); + overflow-y: auto; +} + +.exceptionsList { + display: grid; + min-width: 0; + gap: var(--co-space-6); +} + +.exceptionRow { + display: grid; + width: 100%; + min-height: var(--co-space-48); + grid-template-columns: 1.125rem minmax(0, 1fr); + align-items: start; + gap: var(--co-space-8); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + padding: var(--co-space-8) var(--co-space-10); + background: var(--surface); + color: var(--text); + text-align: left; +} + +.exceptionRowBody { + display: grid; + min-width: 0; + gap: var(--co-space-2); +} + +.exceptionRow:hover { + background: var(--surface-subtle); +} + +/* + * Tone-coded like `.pressureCard`'s breaching state and `.diagramUnplacedUnit` elsewhere in this + * file: danger and warning are real, distinct facts about the movement (a legal breach is not + * merely "worth a look" the way a stalled transport job is), so each gets its own border and + * background rather than one generic "exception" tint. Colour is never the only signal — the icon + * below and `item.title`/`item.detail` text carry the same distinction in words. + */ +.exceptionRow[data-tone="danger"] { + border-color: var(--danger-border); + background: var(--danger-bg); +} + +.exceptionRow[data-tone="warning"] { + border-color: var(--warning-border); + background: var(--warning-bg); +} + +.exceptionRow svg { + width: 1.125rem; + height: 1.125rem; +} + +.exceptionIconDanger { + color: var(--danger-text); +} + +.exceptionIconWarning { + color: var(--warning-text); +} + +.exceptionTitle { + color: var(--text-heading); + font-size: var(--text-2xs); + font-weight: 700; +} + +.exceptionDetail { + color: var(--text); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + +.exceptionOwner { + color: var(--text-muted); + font-size: var(--text-3xs); +} + +/* + * Task 5: the refusals list. A refused transition (`wardFlowReducer`'s `reject`) is otherwise + * silent — this section is present even with zero rows so a coordinator learns where to look + * before the first refusal ever happens, the same "present even when empty" reasoning + * `.exceptionsPanel` already applies to the exceptions list above it. + */ +.refusalsSection { + margin-top: var(--co-space-12); + border-top: 0.0625rem solid var(--border); + padding-top: var(--co-space-8); +} + +.refusalsHeading { + margin: 0 0 var(--co-space-6); + color: var(--text-heading); + font-size: var(--text-2xs); + font-weight: 700; +} + +.refusalsList { + display: grid; + min-width: 0; + gap: var(--co-space-6); +} + +.refusalRow { + display: grid; + gap: var(--co-space-2); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + padding: var(--co-space-8) var(--co-space-10); + background: var(--surface); +} + +.refusalAttempt { + color: var(--text-heading); + font-size: var(--text-2xs); + font-weight: 700; +} + +.refusalReason { + color: var(--text); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + +.refusalAt { + color: var(--text-muted); + font-size: var(--text-3xs); +} + +@media (forced-colors: active) { + .exceptionRow[data-tone="danger"], + .exceptionRow[data-tone="warning"] { + border-color: GrayText; + } +} + +/* + * Task 8 ruling: after-hours coordination happens on a phone, and the screen collapses to + * queue, exceptions and one-tap confirm. A node/edge canvas is unreadable at 390px, and showing + * an unreadable one is worse than showing none — so the diagram is hidden outright rather than + * shrunk. The eight-department pressure strip is hidden alongside it: its own worst-first + * ordering exists to answer "which ED is worst", a question the queue already answers per-row via + * `.queueLegalBreach`, and the strip's `repeat(auto-fit, minmax(9rem, 1fr))` grid has no useful + * arrangement left once its track width drops under 390px. `.regionGrid` is already a single + * column below 90rem (see its own comment), so hiding the diagram here leaves the priority queue + * and the shortlist column stacked directly on top of each other — the shortlist's own Confirm + * button is one selection tap away, never behind a second navigation step. + */ +@media (max-width: 48rem) { + .pressureStrip, + .diagramRegion { + display: none; + } + + /* + * Task 8 review Important 4 (controller finding): `.queueRegion`'s base `max-height: 26rem` + * was sized for the STACKED desktop layout, where the queue still competes with a diagram and + * a shortlist for the same page. On a real phone the diagram and pressure strip above are both + * gone, so that cap left the queue — the phone's primary surface — squeezed to about 3.5 of 41 + * rows while empty space sat below the shortlist placeholder. `min(60vh, 34rem)` gives the + * queue most of the screen instead, and stays viewport-relative rather than a flat rem value + * for the same reason `.exceptionsPanel` above does. Source order matters here: this must + * follow the base `.queueRegion` rule (same specificity, later wins) — see that rule's own + * comment on why the >=90rem override already relies on the same ordering. + */ + .queueRegion { + max-height: min(60vh, 34rem); + } + + /* + * Task 7: the coordinator screen used to chase the Confirm control into view with a nested + * double-`requestAnimationFrame` `scrollIntoView` effect (deleted from `coordinator-screen.tsx`) + * because the control could land off-screen below the fold. A bar pinned by CSS to the literal + * viewport bottom cannot land off-screen, so there is nothing left to measure or chase — the + * comment on the deleted effect named a measurement race against `.main`'s grid rows and + * `.screen`'s `100dvh` height not having settled after a resize; a fixed-position bar never + * reads either of those, so that race dissolves rather than needing a fix here. + * + * Flush to the viewport edge (no `bottom` gap — search-chrome-behaviour.md's edge-to-edge + * contract) and painting its own `--safe-area-bottom` inset via + * `--co-shortlist-bar-safe-bottom`, so a coordinator on a notched phone never has to reach past + * the home-indicator region to tap Refer. + */ + .shortlistActionRow { + position: fixed; + z-index: var(--z-overlay); + right: 0; + bottom: 0; + left: 0; + flex-wrap: nowrap; + border-top: 0.0625rem solid var(--border); + background: var(--surface); + box-shadow: var(--e2); + padding: var(--co-shortlist-bar-pad-y) var(--co-space-20) var(--co-shortlist-bar-safe-bottom); + } + + /* + * `ShortlistPanel` renders `.shortlistActionRow` only once a movement is selected (with nothing + * selected it returns just its placeholder paragraph), so `:has()` scopes this reserve to + * exactly the moment the bar exists — deselecting a movement gives the queue and the exceptions + * drawer the full screen back rather than paying for a bar that is not rendered. `.main` is a + * `100dvh`-tall grid (`auto` governance banner, `1fr` `.body`, `auto` exceptions drawer); adding + * bottom padding here shrinks the `1fr` `.body` row by the same amount, which is what keeps the + * exceptions drawer — and the tail of `.body`'s own scrolled content — clear of the fixed bar + * rather than sitting underneath it. No separate reserve is needed on `.body` itself: it is the + * only row here, so shrinking `.main`'s available height already shrinks `.body` by the same + * amount. + */ + .main:has(.shortlistActionRow) { + padding-bottom: var(--co-shortlist-bar-reserve); + } +} + +@media (prefers-reduced-motion: reduce) { + .screen * { + scroll-behavior: auto !important; + transition: none !important; + } +} + +@media (forced-colors: active) { + .queueRowSelected { + border: 0.125rem solid Highlight; + } + + /* + * The system palette forced-colors substitutes for `--clinical-accent-border` and + * `--danger-border` can render close enough to each other that the outline alone (still an + * author-specified colour subject to the same substitution) risks blending into a breaching + * card's forced border too. Pin it explicitly to `Highlight` so the selected state stays + * legible even when every other colour on the card has been flattened to the system palette + * (Task 4 review Important 4). + */ + .pressureCardSelected { + outline-color: Highlight; + } + + /* Same reasoning as `.pressureCardSelected` above, applied to the flow diagram's own selected, + origin and routed states — all three currently rely on an author-specified outline colour + that forced-colors can otherwise flatten into indistinguishability from the default border. */ + .diagramUnitSelected { + border: 0.125rem solid Highlight; + } + + .diagramEdCard[data-origin="true"], + .diagramUnit[data-routed="true"][data-eligible="true"], + .diagramUnitSelected[data-routed="true"][data-eligible="true"], + .diagramUnit[data-routed="true"][data-eligible="false"], + .diagramUnitSelected[data-routed="true"][data-eligible="false"] { + outline-color: Highlight; } } diff --git a/src/components/ward-management/coordinator/exception-drawer.tsx b/src/components/ward-management/coordinator/exception-drawer.tsx new file mode 100644 index 000000000..9a3296eee --- /dev/null +++ b/src/components/ward-management/coordinator/exception-drawer.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { ChevronDown, ChevronUp } from "lucide-react"; + +import { formatInstant } from "@/components/ward-management/ward-clock"; +import type { InboxItem } from "@/components/ward-management/ward-derivations"; +import type { Rejection } from "@/components/ward-management/ward-model"; + +import styles from "./coordinator.module.css"; + +type ExceptionDrawerProps = { + items: InboxItem[]; + /** Transitions the reducer refused (`ward-flow-reducer.ts`'s `reject`), newest first. A + * refusal is otherwise silent — the reducer returns state with the rejection appended rather + * than throwing — so this is the one surface that makes a wrong role or a stale action visible + * to a coordinator instead of swallowing it (Task 5). Rendered even when empty: an empty list + * still tells a coordinator where to look, rather than leaving the section absent until the + * first refusal ever happens. */ + rejections: Rejection[]; + open: boolean; + onToggle: () => void; + onSelectMovement: (movementId: string) => void; +}; + +/** + * Task 8: the coordinator's work list, not a report — every item `buildActionInbox` returns + * renders as its own row, and selecting one drives the same movement selection the priority + * queue drives (`onSelectMovement` is the same setter passed to `PriorityQueue`'s `onSelect`), + * so the explainable shortlist follows. + * + * Ruling 2: this renders exactly what `buildActionInbox` returns — never an invented category. + * Spec §6 names six exception categories; the model only computes three (breached legal timing, + * exhausted parallel referrals, transport accepted but not departed). The other three need new + * derivations over capacity freshness and holds that this task does not own; inventing them here + * would put computation in a component. See the task report for that gap. + * + * Ruling 3: the toggle's count is `items.length` — the exact same array the rows below are + * mapped from, never a number computed independently of what actually renders (the "48 open + * movements" defect in miniature is a header count that disagrees with the rows beneath it). + */ +export function ExceptionDrawer({ items, rejections, open, onToggle, onSelectMovement }: ExceptionDrawerProps) { + // Newest first: `rejections` is appended to in raise order, so the array itself reads oldest + // first — a coordinator wants to see what just got refused, not what got refused first today. + const refusalsNewestFirst = [...rejections].reverse(); + return ( +
+ + {open ? ( +
+ {items.length === 0 ? ( +

No exceptions right now.

+ ) : ( +
    + {items.map((item) => { + const Icon = item.icon; + return ( +
  • + +
  • + ); + })} +
+ )} + + {/* Task 5: a transition the reducer refused is otherwise silent — `wardFlowReducer` + returns state with a `Rejection` appended rather than throwing, so nothing forces + a wrong-role or out-of-stage action onto anyone's screen unless a surface renders + it. This list is present even with zero rows, so a coordinator learns where a + refusal would show up before the first one ever happens. */} +
+

Refused actions

+ {refusalsNewestFirst.length === 0 ? ( +

No refused actions recorded yet.

+ ) : ( +
    + {refusalsNewestFirst.map((rejection) => ( +
  • + {rejection.attempted} + {rejection.reason} + {formatInstant(rejection.at)} +
  • + ))} +
+ )} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/ward-management/coordinator/flow-diagram.tsx b/src/components/ward-management/coordinator/flow-diagram.tsx new file mode 100644 index 000000000..b799797ca --- /dev/null +++ b/src/components/ward-management/coordinator/flow-diagram.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { Network } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import type { Instant } from "@/components/ward-management/ward-clock"; +import { + candidateReason, + eligibleCandidates, + restrictionNotice, + unitCapacity, + wardServiceOrder, +} from "@/components/ward-management/ward-derivations"; +import { useWardFlow } from "@/components/ward-management/ward-flow-provider"; +import { PARALLEL_REFERRAL_CAP, type Movement, type Unit } from "@/components/ward-management/ward-model"; +import { edPressure } from "@/components/ward-management/ward-pressure"; +import { allUnits, siteByCode, unitById } from "@/components/ward-management/ward-sites"; + +import styles from "./coordinator.module.css"; + +type FlowDiagramProps = { + movement: Movement | undefined; + now: Instant; + selectedUnitId: string | undefined; + onSelectUnit: (unitId: string) => void; +}; + +type Point = { x: number; y: number }; +type ShortlistCandidate = ReturnType[number]; +type Connector = + | { id: string; path: string; kind: "demand" } + | { id: string; path: string; kind: "route"; eligible: boolean } + | { id: string; path: string; kind: "destination"; recorded: "accepted" | "referred" }; + +/** Elbow: leave the source edge, run along a mid trunk, then enter the target edge. Same shape + * as the Phase 1 network diagram's connector, kept identical rather than reinvented so the two + * read as one visual language. */ +function elbowPath(from: Point, to: Point) { + const trunk = from.x + (to.x - from.x) / 2; + return `M ${from.x} ${from.y} H ${trunk} V ${to.y} H ${to.x}`; +} + +function capabilityLabel(unit: { security: string; cohort: string; beds: number }) { + return `${unit.security} · ${unit.cohort} · ${unit.beds} beds`; +} + +/** + * Every unit this movement is actually recorded against: the acceptance, plus every live parallel + * referral. Not `destinationUnit()` -- that collapses both facts into one and only ever reads + * `referredUnitIds[0]`, which is how WF-013's second referral went missing (Task 6 review + * Important 2). Ids are returned rather than units so an id the fixture cannot resolve is still + * counted as recorded rather than silently disappearing. + */ +function recordedDestinationIds(movement: Movement | undefined): Set { + if (!movement) return new Set(); + const ids = new Set(movement.referredUnitIds); + if (movement.acceptedUnitId) ids.add(movement.acceptedUnitId); + return ids; +} + +/** + * The hub's one-line status. Must be true for every movement, not just the ones with a clean + * eligible shortlist -- Task 6 review Critical 1 found "Routing WF-009 to 3 shortlisted units" + * displayed for a movement whose three candidates were all ineligible (two already declined it, + * the third fails the security gate), which reads as three viable routes where zero exist. + * + * Two further corrections from the whole-branch review: + * + * Critical 1 -- the word "nearest" is gone. `eligibleCandidates` ranks cohort-matching units + * eligible-first and breaks ties on array order; the model holds no distance data at all, so a + * proximity claim here was simply false. + * + * Important 3 -- a movement that ALREADY has a recorded destination is not looking for three of + * them. WF-004 sits at stage `bed_held` with a bed held at BTY Adult Secure, and this line read + * "WF-004 -- 3 eligible destinations". The recorded fact now leads; the candidate count follows + * as secondary context, because a coordinator may still be considering alternatives. + */ +function hubStatusText(movement: Movement | undefined, shortlist: ShortlistCandidate[]) { + if (!movement) return "Select a movement from the priority queue to route it"; + + // "Other" means other than the units already recorded against this movement -- a candidate that + // IS the accepted or referred unit must not be counted a second time as an alternative to itself. + const recordedIds = recordedDestinationIds(movement); + const otherCount = shortlist.filter((candidate) => !recordedIds.has(candidate.unit.id)).length; + const candidateTail = otherCount === 0 ? "" : `; ${otherCount} other candidate${otherCount === 1 ? "" : "s"} shown`; + + if (movement.acceptedUnitId) { + const accepted = unitById(movement.acceptedUnitId); + return accepted + ? `${movement.id} — accepted destination: ${accepted.name}${candidateTail}` + : `${movement.id} — an accepted destination is recorded but could not be resolved`; + } + if (movement.referredUnitIds.length > 0) { + const names = movement.referredUnitIds + .map((id) => unitById(id)?.name) + .filter((name): name is string => Boolean(name)); + return names.length === movement.referredUnitIds.length && names.length > 0 + ? `${movement.id} — outstanding referral${names.length === 1 ? "" : "s"}: ${names.join(", ")}${candidateTail}` + : `${movement.id} — ${movement.referredUnitIds.length} outstanding referral${movement.referredUnitIds.length === 1 ? "" : "s"}, not all resolvable`; + } + + if (shortlist.length === 0) return `${movement.id} — no destinations found for this cohort`; + const eligibleCount = shortlist.filter((candidate) => candidate.verdict.eligible).length; + if (eligibleCount === shortlist.length) { + return `${movement.id} — ${eligibleCount} eligible destination${eligibleCount === 1 ? "" : "s"}`; + } + if (eligibleCount === 0) { + return `${movement.id} — no eligible destination; ${shortlist.length} candidates, all excluded`; + } + return `${movement.id} — ${eligibleCount} eligible of ${shortlist.length} candidates`; +} + +/** + * The reshape the spec asks for: demand enters left from the eight emergency departments, + * passes through a statewide-flow hub in the centre, and lands right on the 22 inpatient units, + * grouped by health service. Departments are always shown (ordered worst-first by `edPressure`) + * and always connected to the hub -- that part of the network exists regardless of what a + * coordinator has selected. Routes from the hub to specific units only appear once a movement is + * selected, and only to that movement's own three cohort-matching candidates plus whatever unit it + * is already recorded against; with nothing selected, this renders the network with nothing routed + * rather than a guessed selection (ruling: display less rather than something plausible). + * + * `eligibleCandidates` sorts eligible-first but never filters -- it can and does return units + * that fail a gate (already declined the movement, wrong security tier, stale capacity, ...). + * Every shortlisted node therefore carries its own verdict (`data-eligible`, plus + * `candidateReason` rendered as real text) and an ineligible route is drawn visually distinct + * from an eligible one, so neither the arrow nor the node can read as an endorsement the data + * does not support. + * + * Connector paths are computed from real DOM geometry (ruling 4), never from hard-coded + * percentages -- a percentage-based layout looks right at exactly one viewport width and is wrong + * at every other. `measure` reads `getBoundingClientRect` on the canvas, the hub and every node, + * and reruns on a `ResizeObserver` plus a window resize listener, so the diagram survives a + * resize rather than only ever being screenshotted once. + */ +export function FlowDiagram({ movement, now, selectedUnitId, onSelectUnit }: FlowDiagramProps) { + const { units } = useWardFlow(); + const pressure = useMemo(() => edPressure(now), [now]); + const shortlist = useMemo( + () => (movement ? eligibleCandidates(movement, now, PARALLEL_REFERRAL_CAP, units) : []), + [movement, now, units], + ); + const shortlistByUnitId = useMemo( + () => new Map(shortlist.map((candidate) => [candidate.unit.id, candidate])), + [shortlist], + ); + const originEdId = movement?.originEdId; + + // Grouped by health service in `wardServiceOrder`, one lookup per unit (22 units -- cheap). + // `siteByCode` returning `undefined` for a broken site code excludes that unit from every + // group rather than guessing one -- conservative failure, not a crash. `unplacedUnits` below + // catches exactly that case so the unit still renders (as an explicit anomaly) rather than + // silently vanishing from the board (review Minor 6). + const serviceGroups = useMemo( + () => + wardServiceOrder + .map((service) => ({ + service, + units: allUnits().filter((unit) => siteByCode(unit.siteCode)?.service === service), + })) + .filter((group) => group.units.length > 0), + [], + ); + const unplacedUnits = useMemo(() => { + const grouped = new Set(serviceGroups.flatMap((group) => group.units.map((unit) => unit.id))); + return allUnits().filter((unit) => !grouped.has(unit.id)); + }, [serviceGroups]); + + const canvasRef = useRef(null); + const hubRef = useRef(null); + const edRefs = useRef(new Map()); + const unitRefs = useRef(new Map()); + const [connectors, setConnectors] = useState([]); + + const registerEdNode = useCallback((id: string, node: HTMLElement | null) => { + edRefs.current.set(id, node); + }, []); + const registerUnitNode = useCallback((id: string, node: HTMLButtonElement | null) => { + unitRefs.current.set(id, node); + }, []); + + const measure = useCallback(() => { + const canvas = canvasRef.current; + const hub = hubRef.current; + if (!canvas || !hub) return; + const base = canvas.getBoundingClientRect(); + const hubBox = hub.getBoundingClientRect(); + const hubLeft: Point = { x: hubBox.left - base.left, y: hubBox.top - base.top + hubBox.height / 2 }; + const hubRight: Point = { x: hubBox.right - base.left, y: hubLeft.y }; + const next: Connector[] = []; + + for (const row of pressure) { + const node = edRefs.current.get(row.ed.id); + if (!node) continue; + const box = node.getBoundingClientRect(); + const from: Point = { x: box.right - base.left, y: box.top - base.top + box.height / 2 }; + next.push({ id: `demand-${row.ed.id}`, path: elbowPath(from, hubLeft), kind: "demand" }); + } + + for (const candidate of shortlist) { + const node = unitRefs.current.get(candidate.unit.id); + if (!node) continue; + const box = node.getBoundingClientRect(); + const to: Point = { x: box.left - base.left, y: box.top - base.top + box.height / 2 }; + next.push({ + id: `route-${candidate.unit.id}`, + path: elbowPath(hubRight, to), + kind: "route", + eligible: candidate.verdict.eligible, + }); + } + + // Whole-branch review Important 3: routes were drawn ONLY to the three candidates, so for 18 + // of the 41 open movements the unit the patient is actually going to had no connector at all + // while three arrows pointed at wards they are not going to. The recorded destination is a + // fact, not a suggestion, so it gets its own connector in its own visual language. + // + // Drawn IN ADDITION to any route connector for the same unit, not instead of it: seven open + // movements have a recorded destination that is also a candidate, and suppressing the route + // there would make the route count silently disagree with the routed node count. The two + // enter the node at different heights (destination high, route mid) so they read as two + // distinct lines rather than one drawn twice. + for (const unitId of recordedDestinationIds(movement)) { + const node = unitRefs.current.get(unitId); + if (!node) continue; + const box = node.getBoundingClientRect(); + const to: Point = { x: box.left - base.left, y: box.top - base.top + box.height / 4 }; + next.push({ + id: `destination-${unitId}`, + path: elbowPath(hubRight, to), + kind: "destination", + recorded: movement?.acceptedUnitId === unitId ? "accepted" : "referred", + }); + } + + setConnectors(next); + }, [pressure, shortlist, movement]); + + useLayoutEffect(() => { + measure(); + }, [measure]); + + // The window resize listener is the fallback path -- it must attach unconditionally. + // `ResizeObserver` is the finer-grained signal (canvas-only size changes that a window resize + // wouldn't cause, e.g. a sidebar toggling), so it is added separately when available rather + // than gating the window listener behind the same `if` (review Minor 7 -- the previous single + // guard skipped the resize listener too whenever `ResizeObserver` was undefined, leaving no + // resize handling at all in that environment). + useEffect(() => { + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, [measure]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => measure()); + observer.observe(canvas); + return () => observer.disconnect(); + }, [measure]); + + return ( + // The diagram can carry substantially more content (22 units) than the region grid's own + // squeeze-to-fit sizing gives it room for at moderate viewport heights -- the outer grid's + // `auto` row is compressed to fit `.body`'s available space, which at 1280x900 left this + // region 208px tall for ~1080px of content (Controller finding 8). `.diagramScroll` owns its + // own bounded, independently-scrolling box (`min-height` floor + `overflow-y: auto`) so the + // diagram is always a usable panel rather than a letterbox, without needing the canvas itself + // to scroll -- see the note on `.diagramScroll` in the CSS for why that distinction matters + // for the connector overlay. +
+
+ {/* `aria-hidden` + `pointer-events: none`: this layer is a rendering of the relationships + the node buttons below already carry in their own attributes, never itself a target. */} + + + {/* The node container is `pointer-events: none`; only the interactive unit buttons below + re-enable `pointer-events: auto`. Without this the full-canvas overlay swallows clicks + on everything beneath it -- the exact defect Phase 1's network diagram cost an hour + on. */} +
+
+

Emergency departments

+
    + {pressure.map((row) => { + const isOrigin = originEdId === row.ed.id; + return ( +
  • +
    registerEdNode(row.ed.id, node)} + className={styles.diagramEdCard} + data-testid={`ward-diagram-ed-${row.ed.id}`} + data-origin={isOrigin ? "true" : undefined} + > + {row.ed.siteCode} + {row.ed.name} + + {row.waiting === 0 ? "No patients waiting" : `${row.waiting} waiting`} + + {isOrigin ? Origin : null} +
    +
  • + ); + })} +
+
+ +
+
+ +
+

Inpatient units

+ {serviceGroups.map((group) => ( +
+

{group.service}

+
+ {group.units.map((unit) => ( + + ))} +
+
+ ))} + {unplacedUnits.length > 0 ? ( +
+

Unresolved health service

+
+ {unplacedUnits.map((unit) => ( +
+ {unit.name} + {capabilityLabel(unit)} + + Could not resolve this unit's health service — not placed in a group above. + +
+ ))} +
+
+ ) : null} +
+
+
+
+ ); +} + +function UnitNode({ + unit, + movement, + candidate, + selected, + onSelectUnit, + registerUnitNode, +}: { + unit: Unit; + movement: Movement | undefined; + candidate: ShortlistCandidate | undefined; + selected: boolean; + onSelectUnit: (unitId: string) => void; + registerUnitNode: (id: string, node: HTMLButtonElement | null) => void; +}) { + const capacity = unitCapacity(unit); + const routed = candidate !== undefined; + // `destinationUnit(movement)` (`acceptedUnitId ?? referredUnitIds[0]`) conflates two different + // facts into one badge and only ever looks at the first referral (review Important 2) -- WF-017 + // showed "Current recorded destination" on BTY when BTY is only an outstanding referral (no + // acceptance), and WF-013's second parallel referral was invisible because only index 0 was + // checked. Checked directly here instead: an accepted destination and an outstanding referral + // are different facts a coordinator acts on differently, and every unit in `referredUnitIds` -- + // not only the first -- gets its own badge on its own node. + const isAccepted = movement?.acceptedUnitId === unit.id; + const isReferred = !isAccepted && (movement?.referredUnitIds.includes(unit.id) ?? false); + // Whole-branch review Important 5, Task 5 fix: shown for any unit this movement could be sent + // to -- a candidate, or a unit it is already recorded against -- never for the other 19 units + // on the board, where the comparison is meaningless because this movement is not going there. + // `restrictionNotice` replaces the superseded `isMoreRestrictiveThanRequired`/ + // `MORE_RESTRICTIVE_NOTE` pair (still kept in ward-derivations.ts, unreferenced, pending + // review) so the diagram reads the same two-level warning the shortlist already renders, + // including the sharper voluntary-on-locked case the old pair could never see -- it only ever + // compared `movement.security` to `unit.security` and had no way to read `legalStatus` at all. + const notice = + movement !== undefined && (routed || isAccepted || isReferred) ? restrictionNotice(movement, unit) : undefined; + + return ( + + ); +} diff --git a/src/components/ward-management/coordinator/pressure-strip.tsx b/src/components/ward-management/coordinator/pressure-strip.tsx index 76463a053..739b65297 100644 --- a/src/components/ward-management/coordinator/pressure-strip.tsx +++ b/src/components/ward-management/coordinator/pressure-strip.tsx @@ -1,6 +1,7 @@ "use client"; import { splitDuration, type Instant } from "@/components/ward-management/ward-clock"; +import type { Movement } from "@/components/ward-management/ward-model"; import { edPressure } from "@/components/ward-management/ward-pressure"; import styles from "./coordinator.module.css"; @@ -9,6 +10,13 @@ type PressureStripProps = { now: Instant; selectedEdId: string | undefined; onSelectEd: (edId: string | undefined) => void; + /** + * Injectable for tests only — mirrors `edPressure`'s own `(now, movements)` injection point. + * Production call sites never pass this, so `edPressure` falls back to the real fixture; a + * dedicated dom test passes `[]` to exercise a department with nobody waiting without having + * to wait for the live fixture to grow one (Task 4 review Important 5). + */ + movements?: Movement[]; }; /** @@ -17,11 +25,11 @@ type PressureStripProps = { * outranks sheer volume) — this component only renders that order, it never re-derives it. * * The visible label is `ed.siteCode`, never a name shortened by string surgery: `ed.name` is - * carried as the accessible name and `title` instead, so the unabbreviated hospital reaches a - * screen reader and a hover without ever displaying a plausible-but-wrong guess. + * carried in the card's accessible name and `title` instead, so the unabbreviated hospital + * reaches a screen reader and a hover without ever displaying a plausible-but-wrong guess. */ -export function PressureStrip({ now, selectedEdId, onSelectEd }: PressureStripProps) { - const pressure = edPressure(now); +export function PressureStrip({ now, selectedEdId, onSelectEd, movements }: PressureStripProps) { + const pressure = movements === undefined ? edPressure(now) : edPressure(now, movements); return (
@@ -29,26 +37,60 @@ export function PressureStrip({ now, selectedEdId, onSelectEd }: PressureStripPr

Emergency department pressure

{pressure.length} departments +

Ordered worst first: breached deadlines, then longest wait.

    {pressure.map((row) => { const selected = row.ed.id === selectedEdId; + // `aria-label` REPLACES an element's accessible name computed from its content, so + // setting it to just `ed.name` (Task 4 review Important 3) hid the waiting count, + // longest wait and breach count from assistive technology entirely. Compose the whole + // card into the name instead — the hospital name the ruling requires, plus every + // figure a sighted coordinator sees. `title` stays the bare hospital name for a hover + // tooltip, a different surface with a different length budget. The visible spans are + // `aria-hidden` so a screen reader isn't read the same figures twice. + const accessibleNameParts = [row.ed.name]; + if (row.waiting === 0) { + accessibleNameParts.push("no patients waiting"); + } else { + accessibleNameParts.push(`${row.waiting} waiting`, `longest ${splitDuration(row.longestWaitMinutes)}`); + } + if (row.breaching > 0) accessibleNameParts.push(`${row.breaching} breaching`); + const accessibleName = accessibleNameParts.join(", "); + return (
  • ); diff --git a/src/components/ward-management/coordinator/priority-queue.tsx b/src/components/ward-management/coordinator/priority-queue.tsx new file mode 100644 index 000000000..1f1b91efd --- /dev/null +++ b/src/components/ward-management/coordinator/priority-queue.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { clockState, type Instant } from "@/components/ward-management/ward-clock"; +import { elapsedLabel } from "@/components/ward-management/ward-derivations"; +import type { Movement } from "@/components/ward-management/ward-model"; +import { operationalScore } from "@/components/ward-management/ward-priority"; +import { allEmergencyDepartments } from "@/components/ward-management/ward-sites"; + +import styles from "./coordinator.module.css"; + +type PriorityQueueProps = { + movements: Movement[]; + now: Instant; + selectedId: string | undefined; + onSelect: (movementId: string) => void; + filterEdId: string | undefined; + onClearFilter: () => void; +}; + +/** + * Urgency tier text carries its own direction on every row (Task 5 ruling 2). With 16 tier-1, + * 13 tier-2 and 12 tier-3 open movements, long runs of the same bare "Tier 1" badge are the + * normal case and tell a coordinator nothing about which end of the scale that is. Tier 1 is + * the clinician's most urgent judgement; tier 3 the least. `queueOrder` never lets the + * operational score below reorder across a tier boundary — the score only breaks ties inside + * one tier. + */ +const TIER_QUALIFIER: Record = { + 1: "most urgent", + 2: "urgent", + 3: "least urgent", +}; + +/** + * The seven fields this column must carry at a fixed 14rem width (Task 5 ruling 1): movement + * id, tier badge, elapsed wait, cohort, security, origin department and the operational score. + * The column stays fixed so Task 6's three-column network diagram keeps its space; the row + * grows taller instead of the column growing wider — five short lines rather than one cramped + * one. The origin department renders as `siteCode` (e.g. "RPH"), never `ed.name`, which runs to + * 50 characters on this fixture and would blow out the column on its own. + */ +export function PriorityQueue({ movements, now, selectedId, onSelect, filterEdId, onClearFilter }: PriorityQueueProps) { + // Built once per render rather than called once per row (41 calls) — every row below reads + // the same fixed department list, so a single lookup map replaces 41 redundant scans. + const edsById = new Map(allEmergencyDepartments().map((ed) => [ed.id, ed])); + // A one-line lookup keyed on a field the model already carries (`filterEdId`) — the same + // reasoning `coordinator-screen.tsx` uses for its own ED lookup — not a derivation that + // belongs in a ward-*.ts module. If the id it was given cannot be resolved, say so rather than + // silently dropping the filter notice or naming the wrong department. + const filterEd = filterEdId ? edsById.get(filterEdId) : undefined; + + return ( +
    +
    +

    Priority queue

    + {movements.length} open movements + {filterEdId ? ( +

    + + {filterEd ? `Filtered to ${filterEd.siteCode} — ${filterEd.name}` : "Filtered — department unavailable"} + + +

    + ) : null} +
    +
      + {movements.map((movement) => { + const selected = movement.id === selectedId; + // Same one-line lookup reasoning as `filterEd` above, keyed on + // `Movement.originEdId`. An unresolved id renders as an explicit absence, never a + // substituted department (Task 5 ruling 1). + const originEd = edsById.get(movement.originEdId); + // Unlabelled, "Secure · JHC" reads as a destination on a screen whose whole purpose is + // finding one — JHC is where the patient currently is, not where they are going + // (display-honesty rule; Task 5 review Minor 5). "from" makes the direction explicit. + const originLabel = originEd ? `from ${originEd.siteCode}` : "from an unknown ED"; + const { score, factors } = operationalScore(movement, now); + // `clockState` (not the presence of a factor) is the source of truth for "is this + // breached" — the factor list is scoped to Task 7's expandable shortlist, not this + // row, but a breached statutory deadline is the one thing this row must never let a + // coordinator miss, so it always renders here regardless of what Task 7 later shows. + // A form with no `dueAt` (Task 6A: a Form 3B honestly carries none) is never breached — + // `undefined` must never reach `clockState`'s arithmetic. + const legalDueAt = movement.legalForm?.dueAt; + const legalBreached = legalDueAt !== undefined && clockState(legalDueAt, now) === "breached"; + const legalFactor = factors.find((factor) => factor.label === "Statutory timing"); + + return ( +
    • + +
    • + ); + })} +
    +
    + ); +} diff --git a/src/components/ward-management/coordinator/shortlist-panel.tsx b/src/components/ward-management/coordinator/shortlist-panel.tsx new file mode 100644 index 000000000..1f428ded5 --- /dev/null +++ b/src/components/ward-management/coordinator/shortlist-panel.tsx @@ -0,0 +1,611 @@ +"use client"; + +import { CheckCircle2, CircleAlert } from "lucide-react"; +import { useMemo, useState, type Dispatch, type FormEvent } from "react"; + +import { clockState, formatInstant, minutesUntil, type Instant } from "@/components/ward-management/ward-clock"; +import { + candidateReason, + destinationUnit, + elapsedLabel, + eligibleCandidates, + referralBlockedReason, + restrictionNotice, + unitCapacity, +} from "@/components/ward-management/ward-derivations"; +import { eligibility, type GateResult } from "@/components/ward-management/ward-eligibility"; +import type { WardFlowEvent } from "@/components/ward-management/ward-flow-events"; +import { PARALLEL_REFERRAL_CAP, type Movement, type Unit } from "@/components/ward-management/ward-model"; +import { useWardFlow } from "@/components/ward-management/ward-flow-provider"; +import { operationalScore } from "@/components/ward-management/ward-priority"; +import { allEmergencyDepartments, unitById } from "@/components/ward-management/ward-sites"; +import { ignoreUnavailableActivation } from "@/components/ui-primitives"; + +import styles from "./coordinator.module.css"; + +type ShortlistPanelProps = { + movement: Movement | undefined; + now: Instant; + selectedUnitId: string | undefined; + onSelectUnit: (unitId: string) => void; + dispatch: Dispatch; +}; + +/** + * Task 5 fix round 1. Refer no longer carries any local "you just did this" record — see the + * comment above `handleRefer` for why: the "Parallel referral" badges above already render + * straight from `movement.referredUnitIds`, the reducer's own live output, so a second, + * optimistic local flag would only ever be a second place for the truth to diverge from. + * + * Override still needs local state, because the typed reason has nowhere else to live — + * `REFER_TO_UNITS` carries no reason field, so a typed override reason is never written to + * shared state. But this record is never trusted at face value: `overrideSucceeded` below reads + * `movement.referredUnitIds` fresh on every render and only renders a success message when those + * ids are actually present there, so a refused override (the movement was not in a referable + * stage, or any other reducer-side reason) can never be reported as one that happened. + */ +type OverrideRecord = { unitIds: string[]; at: Instant; reason: string }; + +/** + * Human labels for the eight `eligibility()` gates. Order here is irrelevant — the rendered list + * is sorted failures-first from the real `GateResult[]`, never from this map's key order. + */ +const GATE_LABELS: Record = { + authorisation: "Mental Health Act authorisation", + cohort: "Cohort match", + security: "Security level", + sex_mix: "Sex mix", + specialling: "Specialling capacity", + prior_decline: "Prior decline", + capacity_freshness: "Capacity freshness", + allocatable_bed: "Allocatable bed", +}; + +function capacityLine(unit: Unit) { + const capacity = unitCapacity(unit); + return `Ready ${capacity.available} · Held ${capacity.held} · Blocked ${capacity.blocked} · Occupied ${capacity.occupied}`; +} + +/** + * Task 6A: a Form 3B honestly carries no `dueAt` — the Mental Health Act imposes no + * post-examination deadline (clinician-confirmed). For that case this states the form and the + * real elapsed ED time via the existing `elapsedLabel` (never a new formatter), worded as time + * IN the department rather than time left against anything, so it can never be misread as a + * statutory countdown the way a bare number next to a form code could be. + */ +function legalFormLine(movement: Movement, now: Instant) { + if (!movement.legalForm) return "No legal form recorded for this movement"; + const named = `Form ${movement.legalForm.code} (${movement.legalForm.label})`; + if (movement.legalForm.dueAt === undefined) { + return `${named} — no statutory deadline; ${elapsedLabel(movement, now)} in the emergency department`; + } + const remaining = minutesUntil(movement.legalForm.dueAt, now); + return remaining < 0 + ? `${named} passed its deadline ${Math.abs(remaining)} min ago` + : `${named} due in ${remaining} min`; +} + +/** + * The explainable shortlist: where the placement decision is actually made. Every gate row states + * its own verdict in real text (never icon-only), all eight gates render every time (never + * `.slice()`), an ineligible candidate is never presented or styled as a recommendation, and + * nothing is allocated until a human clicks Confirm or records an override reason. + * + * Controller finding this task exists to close: the whole-branch review found a green tick + * rendered beside "is not authorised under the Mental Health Act" — a gate row whose icon was + * driven by something other than the gate's own `pass` boolean. Every icon below reads directly + * off `gate.pass`; nothing else is permitted to decide it (see the report's red/green proof). + */ +export function ShortlistPanel({ movement, now, selectedUnitId, onSelectUnit, dispatch }: ShortlistPanelProps) { + const { units } = useWardFlow(); + const shortlist = useMemo( + () => (movement ? eligibleCandidates(movement, now, PARALLEL_REFERRAL_CAP, units) : []), + [movement, now, units], + ); + + // The unit whose gates this panel currently explains. A selection carried over from another + // page (the diagram shares the same `selectedUnitId` state) is honoured even when it falls + // outside this movement's own candidate list — the truth about an arbitrary unit against this + // movement is still real data, never fabricated. With nothing selected, this defaults to the + // list's own first (eligible-first) candidate, so the gate list is never empty the moment a + // movement is chosen. + // + // Whole-branch review Critical 2: this default is ORIENTATION ONLY. It may never be the thing + // Refer acts on — see `canRefer` below. + const activeUnit = useMemo(() => { + if (selectedUnitId) return unitById(selectedUnitId); + return shortlist[0]?.unit; + }, [selectedUnitId, shortlist]); + + const activeVerdict = useMemo(() => { + if (!movement || !activeUnit) return undefined; + const cached = shortlist.find((candidate) => candidate.unit.id === activeUnit.id); + return cached ? cached.verdict : eligibility(movement, activeUnit, now); + }, [movement, activeUnit, shortlist, now]); + + // Failures first, stable otherwise — never a `.slice()`. All eight gates render every time. + const sortedGates: GateResult[] = useMemo( + () => (activeVerdict ? [...activeVerdict.gates].sort((a, b) => Number(a.pass) - Number(b.pass)) : []), + [activeVerdict], + ); + + const [overrideRecord, setOverrideRecord] = useState(undefined); + const [overrideOpen, setOverrideOpen] = useState(false); + const [overrideReason, setOverrideReason] = useState(""); + const [confirmationMovementId, setConfirmationMovementId] = useState(movement?.id); + // Task 5: which candidate wards a human has explicitly picked to refer to, capped at + // `PARALLEL_REFERRAL_CAP`. This is a separate, multi-select truth from `selectedUnitId` (which + // stays single-valued and shared with the diagram, driving only which candidate's gates are + // shown) — a coordinator can refer to up to three wards at once, but the diagram and the gate + // list can only ever explain one at a time. + const [referTargets, setReferTargets] = useState([]); + + // A confirmation, an open override form, or a referral selection all belong to the movement + // they were made against — moving to a different movement must never leave a stale "Referred" + // record from the last one on screen, a half-typed override reason attached to the wrong + // patient, or a ward selection meant for a different patient. Reset during render (React's + // documented "adjusting state when a prop changes" pattern) rather than in an effect — an + // effect body calling `setState` synchronously forces an extra, avoidable render pass. + if (movement?.id !== confirmationMovementId) { + setConfirmationMovementId(movement?.id); + setOverrideRecord(undefined); + setOverrideOpen(false); + setOverrideReason(""); + setReferTargets([]); + } + + if (!movement) { + return ( +

    Select a movement from the priority queue to see its explainable shortlist.

    + ); + } + + // TypeScript's narrowing of `movement` above does not reach into the `handleRefer` / + // `handleOverrideSubmit` closures defined further down, so this plain string is what they + // close over instead of re-checking `movement` themselves. + const movementId = movement.id; + + const originEd = allEmergencyDepartments().find((ed) => ed.id === movement.originEdId); + // Neutral "currently at" language, never framed as an authorisation requirement — authorisation + // gates the destination only, and a patient's current ED is never itself a compliance problem. + const originLabel = originEd + ? `Currently at ${originEd.siteCode} — ${originEd.name}` + : "Currently at an unresolved department"; + + // A form with no `dueAt` (Task 6A: a Form 3B honestly carries none) is never breached — + // `undefined` must never reach `clockState`'s arithmetic. + const legalDueAt = movement.legalForm?.dueAt; + const legalBreached = legalDueAt !== undefined && clockState(legalDueAt, now) === "breached"; + + // The destination slot. An accepted unit and every outstanding referral are independent facts + // a coordinator acts on differently (same reasoning `flow-diagram.tsx` already applies), so + // each renders its own badge below from the raw movement fields — never only the first + // referral, and never conflated with an acceptance (review Minor 6). `destinationUnit` + // ("accepted, or else the first referral") is consulted only for ruling 5's exact condition: + // when it is `undefined`, the top ELIGIBLE candidate — never merely the first-listed ineligible + // one — is offered instead, and only ever labelled "Suggested destination": a computed + // suggestion must never sit unlabelled in the destination slot, and an ineligible candidate must + // never be presented as a suggestion at all (review Important 3). + const acceptedUnit = movement.acceptedUnitId ? unitById(movement.acceptedUnitId) : undefined; + const referredUnits = movement.referredUnitIds.map((id) => ({ id, unit: unitById(id) })); + const recordedDestination = destinationUnit(movement); + const hasRecordedReferral = + recordedDestination !== undefined || Boolean(movement.acceptedUnitId) || movement.referredUnitIds.length > 0; + const topEligible = shortlist.find((candidate) => candidate.verdict.eligible); + const topEligibleNotice = topEligible ? restrictionNotice(movement, topEligible.unit) : undefined; + + // Whole-branch review Critical 2, carried forward into Task 5's Refer/Override. The old Confirm + // acted on `activeUnit`, which falls back to `shortlist[0]` — a system-chosen default that no + // human ever picked and that no candidate row reported as `aria-pressed`. A default that Refer + // will act on IS an auto-allocation with one tap of consent, which is the one thing this phase + // says it never does. So referring now requires `referTargets` — the real, explicit multi-select + // state driven by the same candidate-row clicks `aria-pressed` reports below — never a fallback + // to a default nobody chose. Showing the default's gate list for orientation is still fine; + // acting on it is not. + const referredCandidates = referTargets.map((unitId) => shortlist.find((candidate) => candidate.unit.id === unitId)); + const hasReferSelection = referTargets.length > 0; + const allSelectedEligible = referredCandidates.every((candidate) => candidate?.verdict.eligible === true); + // Fix round 1, Finding 1: `REFER_TO_UNITS` only accepts a movement at `placement_requested` or + // `destination_review` (`ward-flow-reducer.ts`'s `REFERRABLE_MOVEMENT_STAGES`) — nine of the + // eighteen hand-authored fixture movements sit outside that, at stages like `bed_held`, while + // still open and still offering eligible candidates. Refer used to dispatch anyway and + // unconditionally claim success, so a coordinator on one of those nine read "Referred by a + // human coordinator" while the reducer had silently refused every one of them. Folding this + // into `canRefer` stops the control from ever advertising an action it cannot perform — the + // stated reason below names the movement's own real stage, never a generic string. + const referralBlocked = referralBlockedReason(movement); + const canRefer = hasReferSelection && allSelectedEligible && referralBlocked === undefined; + // Override deliberately carries only the explicit-selection guard, NOT the stage guard above. + // It is the "a human decided to try anyway, with a stated reason" path — for an ineligible + // candidate (its original purpose) and, now, for a non-referable stage too. Its own success + // message is never optimistic either: `overrideSucceeded` below reads `movement.referredUnitIds` + // fresh, so an override attempted against a non-referable movement is refused by the reducer + // exactly like Refer would be, the refusal surfaces on the Exceptions drawer via `rejections`, + // and no local flag here is ever left claiming a success that did not happen. + const canOverride = hasReferSelection; + const firstIneligibleSelected = referredCandidates.find((candidate) => candidate && !candidate.verdict.eligible); + const referUnavailableReason = referralBlocked + ? referralBlocked + : !hasReferSelection + ? "Choose at least one candidate ward before referring — nothing is referred against a default." + : firstIneligibleSelected + ? `Not eligible — ${candidateReason(firstIneligibleSelected.verdict)}. Use Override instead.` + : "Eligibility could not be determined for one of the selected wards."; + const overrideUnavailableReason = + "Choose at least one candidate ward before overriding — nothing is overridden against a default."; + const activeNotice = activeUnit ? restrictionNotice(movement, activeUnit) : undefined; + + const { score, factors } = operationalScore(movement, now); + + /** Adds or removes a unit from the referral selection, capped at `PARALLEL_REFERRAL_CAP` — a + * click past the cap on a NOT-yet-selected unit is a no-op (never silently swaps out an earlier + * choice), but a click on an already-selected unit can always toggle it back off. */ + function toggleReferTarget(unitId: string) { + setReferTargets((current) => { + if (current.includes(unitId)) return current.filter((id) => id !== unitId); + if (current.length >= PARALLEL_REFERRAL_CAP) return current; + return [...current, unitId]; + }); + } + + /** + * Dispatches `REFER_TO_UNITS` and nothing else — deliberately no local "it worked" flag. + * `canRefer` already gates on `referralBlockedReason`, so this can only be reached when the + * reducer is expected to accept the event; the honest record of whether it actually did is + * `movement.referredUnitIds` on the next render (the "Parallel referral" badges above), sourced + * straight from the provider, never a value this function sets and then leaves behind. + */ + function handleRefer() { + if (!canRefer) return; + dispatch({ type: "REFER_TO_UNITS", role: "coordinator", now, movementId, unitIds: [...referTargets] }); + setOverrideOpen(false); + } + + function handleOverrideSubmit(event: FormEvent) { + event.preventDefault(); + if (!canOverride) return; + const reason = overrideReason.trim(); + if (reason.length === 0) return; + dispatch({ type: "REFER_TO_UNITS", role: "coordinator", now, movementId, unitIds: [...referTargets] }); + setOverrideRecord({ unitIds: [...referTargets], at: now, reason }); + setOverrideOpen(false); + setOverrideReason(""); + } + + // Structurally incapable of claiming an override succeeded when it did not: this checks the + // movement's OWN post-dispatch `referredUnitIds` — read fresh on every render from the live + // provider — not a flag captured once at click time. Override is not stage-gated (see the + // comment above `canOverride`), so a movement outside `REFERRABLE_MOVEMENT_STAGES` really can + // reach this dispatch; when the reducer refuses it, `referredUnitIds` is untouched, every id + // below is missing, `overrideSucceeded` is `false`, and nothing renders here — the refusal is + // instead visible through `rejections` on the Exceptions drawer. + const overrideSucceeded = + overrideRecord !== undefined && + overrideRecord.unitIds.length > 0 && + overrideRecord.unitIds.every((id) => movement.referredUnitIds.includes(id)); + const overrideRecordUnits = overrideRecord + ? overrideRecord.unitIds.map((id) => unitById(id)?.name ?? "an unresolved unit") + : []; + + return ( +
    +
    +
    +

    {movement.id}

    + + Tier {movement.urgency} + +
    + + {movement.cohort} · {movement.security} + + {originLabel} + + {movement.legalStatus} · {legalFormLine(movement, now)} + + + {hasRecordedReferral ? ( + <> + {movement.acceptedUnitId ? ( + acceptedUnit ? ( + Accepted destination: {acceptedUnit.name} + ) : ( + Accepted destination could not be resolved. + ) + ) : null} + {/* Every parallel referral, not only `referredUnitIds[0]` — a movement can carry up + to PARALLEL_REFERRAL_CAP live referrals at once, and each is a fact a coordinator + acts on (review Minor 6: a hidden parallel referral is exactly the trust failure + the cap and this record exist to prevent). "Parallel referral" is the label Task + 5's Refer action uses everywhere this fact is surfaced. */} + {referredUnits.map(({ id, unit }) => + unit ? ( + + Parallel referral: {unit.name} + + ) : ( + + Parallel referral to an unresolved unit. + + ), + )} + + ) : topEligible ? ( + <> + Suggested destination: {topEligible.unit.name} + {/* Whole-branch review Important 5: on WF-001 (an OPEN-status movement) the top + eligible candidate is a locked ward, and the security gate passes it with an + affirmative "Secure ward meets an open requirement". The suggestion is not + withdrawn — the gate is a protected surface and a locked ward really can hold this + patient — but a coordinator must read the restriction here, in the destination + slot, rather than infer it from a ward's name. Task 5: `restrictionNotice` covers + both this and the sharper voluntary-on-locked warning; the badge renders whichever + one applies rather than assuming the older, narrower case. */} + {topEligibleNotice ? ( + + {topEligibleNotice.text} + + ) : null} + + ) : ( + No eligible destination found yet. + )} +
    + +
    + {/* Whole-branch review Critical 1: this list was headed "Nearest candidates", a proximity + claim the model cannot support — `Unit` has no distance, geo, locality or catchment + field, and `eligibleCandidates` filters on cohort and sorts eligible-first, breaking + ties on `allUnits()` array order. WF-018, sitting in SCGH's own emergency department, + was shown RPH Older Adult above SCGH Older Adult under that heading. The subtitle + states the real ordering rather than leaving the reader to assume one. */} +

    Candidates

    +

    + Units matching this movement's cohort, listed eligible first. Not ranked by distance — this prototype + holds no location data. +

    + {shortlist.length === 0 ? ( +

    No cohort-matching units found.

    + ) : ( +
      + {shortlist.map((candidate) => { + // `data-showing` is purely visual — which candidate's gates this panel is + // currently displaying, including the default (nothing explicitly selected) + // case. `aria-pressed` is Task 5's real, explicit MULTI-select state + // (`referTargets`) — a screen-reader user must never be told a control is pressed + // when nobody pressed it, and a default-only "selection" is not clearable the way + // a real one is (review Minor 5, extended to referral selection). + const isShown = activeUnit?.id === candidate.unit.id; + const isSelected = referTargets.includes(candidate.unit.id); + const notice = restrictionNotice(movement, candidate.unit); + return ( +
    • + +
    • + ); + })} +
    + )} +
    + +
    +

    + Eligibility checks{activeUnit ? ` for ${activeUnit.name}` : ""} +

    + {/* The security gate below will read "Met — Secure ward meets an open requirement" for this + pairing, which is true and is deliberately left alone (`ward-eligibility.ts` is a + protected surface). What a tick cannot say is that this is a clinical decision rather + than a neutral match, so it is said here, immediately above the gate list a coordinator + reads before referring (review Important 5, Task 5: wording now comes from + `restrictionNotice`, which distinguishes the sharper voluntary-on-locked case). */} + {activeNotice ? ( +

    + {activeNotice.level === "voluntary_on_locked" + ? `${activeNotice.text}. The security check below passes, but a voluntary patient held on a locked ward is a decision for a human, not a match.` + : `${activeNotice.text}. The security check below passes, but placing an open-status patient on a locked ward is a decision for a human, not a match.`} +

    + ) : null} + {sortedGates.length === 0 ? ( +

    Select a candidate unit to see its eligibility checks.

    + ) : ( +
      + {sortedGates.map((gate) => ( +
    1. + {gate.pass ? ( +
    2. + ))} +
    + )} +
    + +
    +

    Declines

    + {movement.declines.length === 0 ? ( +

    No destination has declined this movement.

    + ) : ( +
      + {movement.declines.map((decline, index) => { + const unit = unitById(decline.unitId); + return ( +
    • + {unit ? unit.name : "Unresolved unit"} + + {decline.reason.replace(/_/g, " ")} + {decline.note ? ` — ${decline.note}` : ""} + + {formatInstant(decline.at)} +
    • + ); + })} +
    + )} +
    + +
    + Operational score {score} +

    + Urgency tier orders the queue; this operational score only breaks ties inside a tier and never represents the + patient's clinical presentation. +

    + {factors.length === 0 ? ( +

    No contributing factors currently.

    + ) : ( +
      + {factors.map((factor) => ( +
    • + {factor.label} +{factor.points} — {factor.detail} +
    • + ))} +
    + )} +
    + +