From daab244b260f8ac86a3761ec15688fe57d780bb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:08:27 +0000 Subject: [PATCH 01/42] Record D-134: ward arrival and beds batch rulings Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK --- docs/ward-flow/OWNER-RULINGS.md | 8 +++++--- docs/ward-flow/decisions.md | 12 ++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/ward-flow/OWNER-RULINGS.md b/docs/ward-flow/OWNER-RULINGS.md index 0ca875566c..187586d03d 100644 --- a/docs/ward-flow/OWNER-RULINGS.md +++ b/docs/ward-flow/OWNER-RULINGS.md @@ -9,7 +9,7 @@ Scanned `docs/ward-flow/decisions.md`, `docs/ward-flow/owner-*.md` and `docs/his One file matches `*owner*` but not `owner-*` and is deliberately excluded: `how-to-write-to-the-owner.md` — it is instructions for writing TO the owner, not a record of what he ruled. -**690 rulings/items extracted, across 43 of 44 files.** +**691 rulings/items extracted, across 43 of 44 files.** **0 file(s) UNPARSED** — no recognised ruling structure found; listed, not dropped. See below. **1 file(s) recorded as holding no ruling of their own**, each with its reason. See below. @@ -32,7 +32,7 @@ line, in the order discovered — confirm which instance you mean before citing --- -## ID index — 440 distinct IDs +## ID index — 441 distinct IDs So an ID like `D-9` or `O-17.1` can be looked up directly, without knowing which file it lives in. Bare numbered rulings (files that number "1., 2., 3. …" with no owner-issued letter code) are @@ -299,6 +299,7 @@ safe global key across 30 files. | `D-126` | `decisions.md`:1566 | D-126. Ranked lists: neutral orders approved (10 November 2026) | | `D-127` | `decisions.md`:1585 | D-127. Neutral wording for a community treatment order at arrival (10 November 2026) | | `D-128` | `decisions.md`:1592 | D-128. Final master audit: nine answers (10 November 2026) | +| `D-134` | `decisions.md`:1607 | D-134. Ward arrival and beds batch: how to hold, advocate line, night list and the feature freeze (10 November 2026) | | `D9-1` | `../history/archive/dated-notes/ward-flow-phase-9-decisions.md`:62 | D9-1 — The wait becomes prominent, and its ceiling is removed; it never outranks urgency (SESSION) | | `D9-2` | `../history/archive/dated-notes/ward-flow-phase-9-decisions.md`:102 | D9-2 — Declaring escalation records it and marks the screens, and changes nothing else (SESSION, and partly blocked) | | `D9-3` | `../history/archive/dated-notes/ward-flow-phase-9-decisions.md`:126 | D9-3 — The retrospective is one person's journey replayed, plus the ward prediction track record last (SESSION) | @@ -607,7 +608,7 @@ safe global key across 30 files. **Title:** Ward Flow — decision log -128 item(s): +129 item(s): - `D-1` — line 157, heading: "D-1. Ward Flow is local only, with no linked repository (Replaced by D-27)" - `D-2` — line 167, heading: "D-2. Railway is disconnected" @@ -737,6 +738,7 @@ safe global key across 30 files. - `D-126` — line 1566, heading: "D-126. Ranked lists: neutral orders approved (10 November 2026)" - `D-127` — line 1585, heading: "D-127. Neutral wording for a community treatment order at arrival (10 November 2026)" - `D-128` — line 1592, heading: "D-128. Final master audit: nine answers (10 November 2026)" +- `D-134` — line 1607, heading: "D-134. Ward arrival and beds batch: how to hold, advocate line, night list and the feature freeze (10 November 2026)" ### `../history/archive/dated-notes/ward-management-decisions.md` (2026-08-18 – 2026-10-24 (mixed, see file)) diff --git a/docs/ward-flow/decisions.md b/docs/ward-flow/decisions.md index fff76d845a..e9863aae25 100644 --- a/docs/ward-flow/decisions.md +++ b/docs/ward-flow/decisions.md @@ -1603,3 +1603,15 @@ is inferred. 8. **Accessibility:** moderate accessibility findings stay non-blocking; serious and critical still block. 9. **New features:** none from the proposed list until #1564 to #1577 close. - **Boundary:** synthetic data only. Changes no signed clinical value or clinical rule, and approves no merge or release. + +## D-134. Ward arrival and beds batch: how to hold, advocate line, night list and the feature freeze (10 November 2026) + +- **Date:** 10 November 2026, about 1:02 AM to 1:08 AM AWST on 11 November (17:02 to 17:08 UTC on 10 November). **Decided by:** Josh, on two decision cards in the Ward Flow project thread "Build ward arrival and bed features", then "Yes to all recommendations please" to the thread's numbered questions. Numbered D-134 because D-128 to D-133 are claimed by PRs #1594, #1598, #1599 and the ED and referral flow batch. +- **Decision:** + 1. **Held split:** items 6 (bed closure review time), 7 (plan staffing for an arrival), 13 (overnight intake window) and 18 (left the ward, waiting for transport) go in one PR that may merge once green. Items 4 (young person on an adult ward) and 15 (interpreter, Aboriginal liaison and advocate lines at arrival) go in a second PR with the `hold` label until Josh signs them off. + 2. **Advocate line (item 15):** "Mental Health Advocacy Service told" shows on the arrival checklist for every patient the app already treats as detained, the same group the existing support and advocacy notification checklist covers, and reuses that record. + 3. **Item 4:** nothing is built until Josh gives the legal duty and the exact notice wording. + 4. **Night list (item 13):** a "Tonight's intake windows" card on the Capacity page, beside "Closed beds past review", in the neutral service-then-name order (D-126). + 5. **Feature freeze:** Josh's instruction to begin the feature batches replaces D-128 item 9 for the 27 items he chose on 10 November. Other features stay frozen until #1564 to #1577 close. + 6. **Design review first:** Josh approves the batch's design review page before its PR is opened (D-74, D-107). +- **Boundary:** synthetic data only. Information and prompts only: nothing ranks or recommends a ward or bed (D-71), and no clinical value changes. Approves no merge or release. From 54bc7ef2ac6ad34167b86fe21360981efdede4a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:15:52 +0000 Subject: [PATCH 02/42] Plan staffing for an arrival on the ward's incoming card (D-117 item 3) The ward answer view now reads the referrals sent to this ward (its scoped inbox, FD-23) and passes them to the card's safety tags, so a referral-backed request can show its flags. A new calm line, "Plan staffing for this arrival", shows when the referral records aggression or absconding. Read through patient-referral-safety.ts (H10); nothing is written to the patient record (R-2026-09-04-A). Backlog item moved to Done recently. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK --- docs/ward-flow/backlog.md | 2 +- .../patients/arrival-staffing-note.tsx | 35 ++++++ .../patients/card-safety-tags.module.css | 21 ++++ .../patients/card-safety-tags.tsx | 7 +- .../patients/patient-referral-safety.ts | 47 ++++++- .../ward-management/ward/ward-answer-view.tsx | 15 ++- tests/ward-arrival-staffing.dom.test.tsx | 117 ++++++++++++++++++ 7 files changed, 230 insertions(+), 14 deletions(-) create mode 100644 src/components/ward-management/patients/arrival-staffing-note.tsx create mode 100644 tests/ward-arrival-staffing.dom.test.tsx diff --git a/docs/ward-flow/backlog.md b/docs/ward-flow/backlog.md index 62f58832f9..692b625110 100644 --- a/docs/ward-flow/backlog.md +++ b/docs/ward-flow/backlog.md @@ -38,7 +38,6 @@ Old lists that may still hold open work. Check each item against source before a Agreed by Josh or required by a decision, with no build or check yet. - Josh adds `Ward Flow hold` as a required check in the main ruleset, so the `hold` label blocks merges (Q384). Not re-checked in GitHub. ([design handover](../design/handover/README.md#q382-to-q389-threads-started-3-november-2026)) -- Ward risk flags on bed requests backed by a full referral: the request card receives no referral data today, so it shows no flags. The ward can still open the full referral (D-79). Separate work (D-117 item 3). ([decisions.md](decisions.md#d-117-care-plans-follow-up-frequent-presenter-count-tribunal-date-and-ward-flags-10-november-2026)) - Clinical values still synthetic (six added since D-63, four more under D-106) need a sourced value and Josh's signature. ([clinical-values.md](clinical-values.md)) - Review the per-client sign-in limits before any real user signs in (D-97 item 6, WF-B008). ([decisions.md](decisions.md#d-97-owner-answers-on-seven-open-items-n21-n25-n26-n29-n40-n41-and-wf-b008-8-november-2026)) - Desk walk, part 2: Patient, movement and referral detail pages, break glass, cover, the phone layout and the shared signed-in server. ([desk walk](evidence/desk-walk-2026-11-08.md)) @@ -61,6 +60,7 @@ Agreed by Josh or required by a decision, with no build or check yet. - Documentation and working-rules audit (D-107, #1555) and clinical values audit (D-106, #1556). - Legal times kept as typed with no countdowns (D-93), and the outage copy stays print or saved file only (D-72); ED long-wait next steps worded (D-108, #1560). - Q383 (check the Railway front-door settings before #1345 merged): not kept as open work; #1345 merged. +- Ward risk flags on bed requests (D-117 item 3): the ward's request card now reads the referrals sent to that ward (its inbox, FD-23), and shows "Plan staffing for this arrival" when the referral records aggression or absconding. Movement and referral only, never the patient record (R-2026-09-04-A). ([decisions.md](decisions.md#d-117-care-plans-follow-up-frequent-presenter-count-tribunal-date-and-ward-flags-10-november-2026)) Merged into `main` in the last two weeks (26 October to 9 November 2026). diff --git a/src/components/ward-management/patients/arrival-staffing-note.tsx b/src/components/ward-management/patients/arrival-staffing-note.tsx new file mode 100644 index 0000000000..a8097ec713 --- /dev/null +++ b/src/components/ward-management/patients/arrival-staffing-note.tsx @@ -0,0 +1,35 @@ +import { Info } from "lucide-react"; +import type { Movement } from "@/components/ward-management/ward-model"; +import { arrivalStaffingPrompt, type CardReferral } from "./patient-referral-safety"; +import styles from "./card-safety-tags.module.css"; + +/** + * Feature 7, "Plan staff for an arrival" (D-117 item 3): one calm line on the receiving ward's + * incoming card when the referral records aggression or absconding, so the nurse in charge can + * arrange staff before the person arrives. Read through `arrivalStaffingPrompt` (H10), from the + * movement's referral only; nothing is written to the patient record (R-2026-09-04-A). Neutral + * words and glyph, like the safety tags beside it: a planning note, not an alarm. + */ +export function ArrivalStaffingNote({ + movement, + referrals, + testId, +}: { + movement: Movement; + referrals: readonly CardReferral[]; + /** Defaults to `ward-answer-arrival-staffing-`. */ + testId?: string; +}) { + const prompt = arrivalStaffingPrompt(movement, referrals); + if (!prompt) return null; + return ( +

+

+ ); +} diff --git a/src/components/ward-management/patients/card-safety-tags.module.css b/src/components/ward-management/patients/card-safety-tags.module.css index fcd66e7043..843489a6bc 100644 --- a/src/components/ward-management/patients/card-safety-tags.module.css +++ b/src/components/ward-management/patients/card-safety-tags.module.css @@ -18,3 +18,24 @@ line-height: var(--wf-lh-12); color: var(--wf-ink-1); } + +/* Feature 7: "Plan staffing for this arrival" on the receiving ward's incoming card. */ +.arrivalNote { + display: flex; + align-items: flex-start; + gap: var(--wf-s-1); + margin: 4px 0 0; + font-size: var(--wf-fs-12); + line-height: var(--wf-lh-12); + color: var(--wf-ink-1); +} + +.arrivalNote strong { + font-weight: 600; +} + +.arrivalGlyph { + flex: none; + margin-top: 1px; + color: var(--wf-ink-2); +} diff --git a/src/components/ward-management/patients/card-safety-tags.tsx b/src/components/ward-management/patients/card-safety-tags.tsx index 3a71f74b90..a60ea4ec84 100644 --- a/src/components/ward-management/patients/card-safety-tags.tsx +++ b/src/components/ward-management/patients/card-safety-tags.tsx @@ -1,8 +1,8 @@ import { Badge } from "@/components/wf"; -import type { Movement, Referral } from "@/components/ward-management/ward-model"; +import type { Movement } from "@/components/ward-management/ward-model"; import type { Patient } from "@/components/ward-management/ward-patients"; import { perthDayLabel } from "@/components/ward-management/ward-clock"; -import { referralCardFlags } from "./patient-referral-safety"; +import { referralCardFlags, type CardReferral } from "./patient-referral-safety"; import { interpreterLanguageRequired } from "./patient-interpreter"; import styles from "./card-safety-tags.module.css"; @@ -20,7 +20,8 @@ export function CardSafetyTags({ testId, }: { movement: Movement; - referrals: readonly Referral[]; + /** The desk's scoped referrals: full records, or a ward's own inbox entries (FD-23). */ + referrals: readonly CardReferral[]; patient: Pick | undefined; /** Without a day zero the referral date cannot be shown, so neither are its flags (H10). */ dayZero: Date | undefined; diff --git a/src/components/ward-management/patients/patient-referral-safety.ts b/src/components/ward-management/patients/patient-referral-safety.ts index 0dff26dbfd..2740f9fbe0 100644 --- a/src/components/ward-management/patients/patient-referral-safety.ts +++ b/src/components/ward-management/patients/patient-referral-safety.ts @@ -16,6 +16,9 @@ import { tentativeDiagnosisPhrase } from "@/components/ward-management/ward-diag import { seededReferralDossier } from "@/components/ward-management/ward-referral-dossier-seed"; import { riskFlagLabel } from "@/components/ward-management/referrals/referral-risk-flags"; +/** The referral fields this file reads: a full referral or a ward's scoped inbox entry (FD-23). */ +export type CardReferral = Pick; + export type ReferralSafetyFacts = /** The movement names a referral this desk cannot see. Show nothing from it. */ | { kind: "not_shared" } @@ -37,7 +40,7 @@ export type ReferralSafetyFacts = */ export function referralSafetyFacts( movement: Movement | undefined, - visibleReferral: Referral | undefined, + visibleReferral: CardReferral | undefined, ): ReferralSafetyFacts { if (!movement) return { kind: "none" }; const diagnosisOnMovement = tentativeDiagnosisPhrase(movement.tentativeDiagnosis ?? null) ?? undefined; @@ -77,10 +80,44 @@ export type ReferralCardFlags = { labels: readonly string[]; raisedAt: number }; * referral the movement names is looked up, so a desk that cannot see it gets nothing. Nothing * also when the referral flagged none. */ -export function referralCardFlags(movement: Movement, referrals: readonly Referral[]): ReferralCardFlags | undefined { - const visible = - movement.referralId === undefined ? undefined : referrals.find((referral) => referral.id === movement.referralId); - const facts = referralSafetyFacts(movement, visible); +export function referralCardFlags( + movement: Movement, + referrals: readonly CardReferral[], +): ReferralCardFlags | undefined { + const facts = referralSafetyFacts(movement, namedReferral(movement, referrals)); if (facts.kind !== "shown" || !facts.riskFlags || facts.riskFlags.length === 0) return undefined; return { labels: facts.riskFlags.map(riskFlagLabel), raisedAt: facts.raisedAt }; } + +function namedReferral(movement: Movement, referrals: readonly CardReferral[]): CardReferral | undefined { + return movement.referralId === undefined + ? undefined + : referrals.find((referral) => referral.id === movement.referralId); +} + +/** The recorded flags that ask the receiving ward to plan staff before the person arrives, in words. */ +const ARRIVAL_STAFFING_FLAGS: Readonly> = { + aggression: "aggression", + absconding: "absent without leave", +}; + +/** "Plan staffing for this arrival", with the recorded flags behind it in words. */ +export type ArrivalStaffingPrompt = { reasons: readonly string[] }; + +/** + * Feature 7 (D-117 item 3): the receiving ward's incoming card asks the nurse in charge to plan + * staff when the referral records aggression or absconding. Read under the same H10 conditions as + * `referralCardFlags`, so a referral this desk cannot see gives nothing. It rides on the movement + * and its referral only, never on the patient record (R-2026-09-04-A). + */ +export function arrivalStaffingPrompt( + movement: Movement, + referrals: readonly CardReferral[], +): ArrivalStaffingPrompt | undefined { + const facts = referralSafetyFacts(movement, namedReferral(movement, referrals)); + if (facts.kind !== "shown" || !facts.riskFlags) return undefined; + const reasons = facts.riskFlags.flatMap((flag) => + Object.hasOwn(ARRIVAL_STAFFING_FLAGS, flag) ? [ARRIVAL_STAFFING_FLAGS[flag]!] : [], + ); + return reasons.length > 0 ? { reasons } : undefined; +} diff --git a/src/components/ward-management/ward/ward-answer-view.tsx b/src/components/ward-management/ward/ward-answer-view.tsx index 107e272205..a11289d509 100644 --- a/src/components/ward-management/ward/ward-answer-view.tsx +++ b/src/components/ward-management/ward/ward-answer-view.tsx @@ -29,6 +29,7 @@ import { ignoreUnavailableActivation } from "@/components/ui-primitives"; import { useRouteRole } from "@/components/ward-management/ward-role-gate"; import { DECLINE_REASON_LABELS } from "@/components/ward-management/ward-referrals"; import { CardSafetyTags } from "@/components/ward-management/patients/card-safety-tags"; +import { ArrivalStaffingNote } from "@/components/ward-management/patients/arrival-staffing-note"; import { useUndoableStep } from "@/components/ward-management/use-undoable-step"; import { HIGH_ACUITY_STAFFING_REFUSAL, OVERRIDE_REASON_REQUIRED } from "@/components/ward-management/ward-flow-reducer"; import { @@ -133,6 +134,7 @@ export function WardAnswerView({ unitId, initialMovementId }: WardAnswerViewProp resolvePatientIdentity, configuration, dayZero, + wardReferralInbox, } = useWardFlowSelect( "movements", "units", @@ -144,10 +146,13 @@ export function WardAnswerView({ unitId, initialMovementId }: WardAnswerViewProp "resolvePatientIdentity", "configuration", "dayZero", + "wardReferralInbox", ); const now = useWardFlowClock(); const raise = useWardRaise("ward"); const unit = units.find((candidate) => candidate.id === unitId); + // D-117 item 3: only the referrals sent to this ward (FD-23) feed the request card's flags. + const wardReferrals = useMemo(() => (unit ? (wardReferralInbox?.(unit.id) ?? []) : []), [wardReferralInbox, unit]); // CS-08 / Notices option A: hold by journey id, not list position; a Notices link opens its named request. const [answerSelection, setAnswerSelection] = useState<{ id?: string; index: number; name?: string }>({ @@ -929,18 +934,18 @@ export function WardAnswerView({ unitId, initialMovementId }: WardAnswerViewProp )} - {/* Referral risk flags (H10) and an interpreter need. FD-23: a ward-only view - holds no referral records, so a referral-backed request shows "not shared" - (nothing); a journey whose referral letter rides on the movement shows the - flags this ward's referral detail already shows. */} + {/* Referral risk flags (H10), an interpreter need and, for aggression or + absconding, "Plan staffing for this arrival" (D-117 item 3). FD-23: only + referrals sent to this ward are read; a letter riding on the movement too. */}
+
{/* SBAR Narrative Sections */} diff --git a/tests/ward-arrival-staffing.dom.test.tsx b/tests/ward-arrival-staffing.dom.test.tsx new file mode 100644 index 0000000000..980553bfee --- /dev/null +++ b/tests/ward-arrival-staffing.dom.test.tsx @@ -0,0 +1,117 @@ +// Feature 7, "Plan staff for an arrival" (D-117 item 3): the receiving ward's incoming card asks the +// nurse in charge to plan staff when the referral records aggression or absconding. Synthetic seed +// data only. +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", async (importOriginal) => ({ + ...(await importOriginal()), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), + useSearchParams: () => new URLSearchParams(window.location.search), +})); + +import { ArrivalStaffingNote } from "@/components/ward-management/patients/arrival-staffing-note"; +import { + arrivalStaffingPrompt, + type CardReferral, +} from "@/components/ward-management/patients/patient-referral-safety"; +import { encodeRecordAddress } from "@/components/ward-management/shell/ward-record-address"; +import { WardFlowProvider } from "@/components/ward-management/ward-flow-provider"; +import type { Movement, Referral } from "@/components/ward-management/ward-model"; +import { seedWardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import { wardReferralInboxEntries } from "@/components/ward-management/referrals/referral-inbox"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; +import { WardScreen } from "@/components/ward-management/ward/ward-screen"; + +function movement(overrides: Partial = {}): Movement { + return { id: "ZZ-SYNTH-1", openedAt: 0, referralId: "RF-SYNTH-1", ...overrides } as Movement; +} + +function referral(riskFlags: string[], id = "RF-SYNTH-1"): CardReferral { + return { id, raisedAt: -60, intake: { riskFlags } } as unknown as CardReferral; +} + +afterEach(() => { + cleanup(); + window.history.replaceState(null, "", "/"); +}); + +describe("arrivalStaffingPrompt", () => { + it("asks for nothing when the referral records neither aggression nor absconding", () => { + expect(arrivalStaffingPrompt(movement(), [referral([])])).toBeUndefined(); + expect(arrivalStaffingPrompt(movement(), [referral(["suicide", "medical", "securityEscort"])])).toBeUndefined(); + }); + + it("asks when the referral records aggression", () => { + expect(arrivalStaffingPrompt(movement(), [referral(["vulnerable", "aggression"])])).toEqual({ + reasons: ["aggression"], + }); + }); + + it("asks when the referral records absconding, in the word list's words", () => { + expect(arrivalStaffingPrompt(movement(), [referral(["absconding"])])).toEqual({ + reasons: ["absent without leave"], + }); + expect(arrivalStaffingPrompt(movement(), [referral(["aggression", "absconding"])])?.reasons).toEqual([ + "aggression", + "absent without leave", + ]); + }); + + it("asks for nothing when the referral is not shared with this desk", () => { + expect(arrivalStaffingPrompt(movement(), [])).toBeUndefined(); + expect(arrivalStaffingPrompt(movement(), [referral(["aggression"], "RF-SYNTH-2")])).toBeUndefined(); + }); + + it("reads a referral from the ward's own inbox (FD-23), and nothing from one sent elsewhere", () => { + const sent = seedWardFlowState().referrals.find((entry) => + entry.destinations.some((arm) => arm.destination.kind === "psychiatric_ward"), + )!; + const arm = sent.destinations.find((entry) => entry.destination.kind === "psychiatric_ward")!; + const unitId = arm.acceptedUnitId ?? (arm.destination as { unitId: string }).unitId; + const flagged = { ...sent, intake: { ...sent.intake, riskFlags: ["absconding"] } } as Referral; + const request = movement({ referralId: sent.id }); + expect(arrivalStaffingPrompt(request, wardReferralInboxEntries([flagged], unitId))?.reasons).toEqual([ + "absent without leave", + ]); + expect(arrivalStaffingPrompt(request, wardReferralInboxEntries([flagged], "not-this-ward"))).toBeUndefined(); + }); + + it("renders one calm line with the reason", () => { + render(); + expect(screen.getByTestId("note")).toHaveTextContent( + "Plan staffing for this arrival · Referral records aggression", + ); + expect(screen.getByTestId("note")).not.toHaveAttribute("role", "alert"); + }); +}); + +function renderAnswer(unitId: string, movementId: string) { + window.history.replaceState( + null, + "", + `/ward-flow/ward/${unitId}/answer?movement=${encodeURIComponent(encodeRecordAddress(movementId))}`, + ); + render( + + + , + ); +} + +describe("ward incoming card", () => { + it("shows the staffing note when the referral records aggression and absconding", async () => { + renderAnswer("bty-adult-secure", "WF-017"); + const card = await screen.findByTestId("ward-incoming-WF-017"); + expect(within(card).getByTestId("ward-answer-arrival-staffing-WF-017")).toHaveTextContent( + "Referral records aggression and absent without leave", + ); + }); + + it("shows no staffing note when the referral records neither flag", async () => { + renderAnswer("bun-adult-open", "WF-022"); + const card = await screen.findByTestId("ward-incoming-WF-022"); + expect(within(card).getByTestId("ward-answer-safety-tags-WF-022")).toHaveTextContent("Suicide or self-harm"); + expect(within(card).queryByText(/Plan staffing for this arrival/u)).toBeNull(); + }); +}); From 17226f35f339dea5f2ead4f646db1548f3f3b50a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:17:04 +0000 Subject: [PATCH 03/42] WIP: overnight intake window (feature item 13) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK --- .../capacity-intake-windows-card.module.css | 59 ++++++ .../capacity/capacity-intake-windows-card.tsx | 56 ++++++ .../capacity/capacity-phone-overview.tsx | 2 + .../capacity/capacity-screen.tsx | 2 + .../capacity/ward-intake-window-demo.ts | 25 +++ .../capacity/ward-intake-window-list.ts | 24 +++ .../capacity/ward-intake-window.ts | 65 +++++++ .../coordinator/shortlist-panel.tsx | 10 ++ .../ward-management/ward-flow-event-roles.ts | 2 + .../ward-management/ward-flow-events.ts | 15 ++ .../ward-flow-intake-window-events.ts | 62 +++++++ .../ward-flow-persistence-classification.ts | 2 + .../ward-flow-reducer-slices.ts | 2 + .../ward-management/ward-flow-reducer.ts | 8 +- .../ward-flow-storage-validation.ts | 3 + .../ward-management/ward-flow-subject-id.ts | 1 + .../ward-management/ward-history.ts | 1 + src/components/ward-management/ward-model.ts | 9 + .../ward-intake-window-control.module.css | 38 ++++ .../ward/ward-intake-window-control.tsx | 155 ++++++++++++++++ .../ward-management/ward/ward-screen.tsx | 3 + tests/helpers/ward-engine-fuzz.ts | 15 ++ tests/ward-event-permissions.test.ts | 2 + tests/ward-event-reachability.test.ts | 2 + tests/ward-intake-window.test.ts | 169 ++++++++++++++++++ tests/ward-referral-model.test.ts | 2 + 26 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 src/components/ward-management/capacity/capacity-intake-windows-card.module.css create mode 100644 src/components/ward-management/capacity/capacity-intake-windows-card.tsx create mode 100644 src/components/ward-management/capacity/ward-intake-window-demo.ts create mode 100644 src/components/ward-management/capacity/ward-intake-window-list.ts create mode 100644 src/components/ward-management/capacity/ward-intake-window.ts create mode 100644 src/components/ward-management/ward-flow-intake-window-events.ts create mode 100644 src/components/ward-management/ward/ward-intake-window-control.module.css create mode 100644 src/components/ward-management/ward/ward-intake-window-control.tsx create mode 100644 tests/ward-intake-window.test.ts diff --git a/src/components/ward-management/capacity/capacity-intake-windows-card.module.css b/src/components/ward-management/capacity/capacity-intake-windows-card.module.css new file mode 100644 index 0000000000..30e3c9600f --- /dev/null +++ b/src/components/ward-management/capacity/capacity-intake-windows-card.module.css @@ -0,0 +1,59 @@ +/* Feature item 13: tonight's intake windows on the Capacity Overview. */ + +.list { + display: grid; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.row { + display: flex; + flex-wrap: wrap; + gap: var(--wf-s-1) var(--wf-s-3); + align-items: center; + justify-content: space-between; + min-height: 40px; + border-bottom: 1px solid var(--wf-line); +} + +.ward { + min-height: 40px; + padding: 0; + font: inherit; + font-size: var(--wf-fs-13); + font-weight: 600; + color: var(--wf-ink-1); + text-align: start; + cursor: pointer; + background: none; + border: 0; +} + +.ward:hover { + text-decoration: underline; +} + +.ward:focus-visible { + outline: 2px solid var(--wf-ink-1); + outline-offset: 2px; +} + +.wardText { + font-size: var(--wf-fs-13); + font-weight: 600; + color: var(--wf-ink-1); +} + +.hours { + font-size: var(--wf-fs-13); + font-variant-numeric: tabular-nums; + color: var(--wf-ink-2); +} + +.muted { + margin: var(--wf-s-2) 0 0; + font-size: var(--wf-fs-12); + color: var(--wf-ink-2); +} diff --git a/src/components/ward-management/capacity/capacity-intake-windows-card.tsx b/src/components/ward-management/capacity/capacity-intake-windows-card.tsx new file mode 100644 index 0000000000..75d32c8a52 --- /dev/null +++ b/src/components/ward-management/capacity/capacity-intake-windows-card.tsx @@ -0,0 +1,56 @@ +import { Card, CardHead } from "@/components/wf"; +import { SpelledCodes } from "@/components/ward-management/ward-abbreviations"; +import { formatInstant, type Instant } from "@/components/ward-management/ward-clock"; +import type { Unit } from "@/components/ward-management/ward-model"; + +import { tonightIntakeWindowList } from "./ward-intake-window-list"; +import styles from "./capacity-intake-windows-card.module.css"; + +/** + * Feature item 13 (10 Nov 2026): "Tonight's intake windows" on the Capacity Overview, so night staff + * see which wards said when they can take an admission before ringing round. Information only: the + * wards are listed by health service then name, never by their hours, and nothing is blocked. + */ +export function CapacityIntakeWindowsCard({ + units, + now, + onWard, +}: { + units: readonly Unit[]; + now: Instant; + /** Opens the ward's panel; absent where the desk opens none. */ + onWard?: (unitId: string) => void; +}) { + const { set, notSetCount } = tonightIntakeWindowList(units, now); + return ( + + + {set.length > 0 ? ( +
    + {set.map(({ unit, intake }) => ( +
  • + {onWard ? ( + + ) : ( + + + + )} + + Admit {formatInstant(intake.from)} to {formatInstant(intake.to)} + +
  • + ))} +
+ ) : null} +

+ {notSetCount === 0 + ? "Every ward has set a window." + : `${notSetCount} ${notSetCount === 1 ? "ward hasn't" : "wards haven't"} set a window.`}{" "} + For information only. Windows clear at 07:00. +

+
+ ); +} diff --git a/src/components/ward-management/capacity/capacity-phone-overview.tsx b/src/components/ward-management/capacity/capacity-phone-overview.tsx index 119d988163..3c1399ab52 100644 --- a/src/components/ward-management/capacity/capacity-phone-overview.tsx +++ b/src/components/ward-management/capacity/capacity-phone-overview.tsx @@ -11,6 +11,7 @@ import type { CapacityModel } from "./capacity-model"; import { gapWords } from "./capacity-range-line"; import { ReadyByRoadTime } from "./capacity-road-time"; import { ShiftChecks } from "./capacity-shift-checks"; +import { CapacityIntakeWindowsCard } from "./capacity-intake-windows-card"; import styles from "./capacity-page.module.css"; /** @@ -119,6 +120,7 @@ export function PhoneOverview({ )} + row.unit)} now={now} onWard={onWard} /> {model.site ? ( // Site bed manager: every site's ready beds by road time, opened in the ward drawer or the // Other sites counts, because another site's ward page is not this desk's to open. diff --git a/src/components/ward-management/capacity/capacity-screen.tsx b/src/components/ward-management/capacity/capacity-screen.tsx index 91b20b9270..9d7587891d 100644 --- a/src/components/ward-management/capacity/capacity-screen.tsx +++ b/src/components/ward-management/capacity/capacity-screen.tsx @@ -38,6 +38,7 @@ import { CapacityHero } from "./capacity-hero"; import { useCapacityModel, type CapacityModel } from "./capacity-model"; import { OtherSitesCounts } from "./capacity-other-sites"; import { PhoneOverview } from "./capacity-phone-overview"; +import { CapacityIntakeWindowsCard } from "./capacity-intake-windows-card"; import { PlannedCounts } from "./capacity-planned-counts"; import { useSiteEdWaiting } from "./capacity-site-ed"; import { heldUpReleases } from "./capacity-held-up"; @@ -329,6 +330,7 @@ export function CapacityScreen({ openOnLoad }: { openOnLoad?: string } = {}) { one card, full width. Josh's fifth notes (8 Nov 2026): the row under it is gone; the shift figures are in the hero and an unchanged count on its ward card. */} {planned} + row.unit)} now={now} onWard={openWard} /> )} (state: T, now: Instant): T { + // Set half an hour ago, but never before this morning's day start. + const at = Math.max(intakeDayStart(now), now - 30); + const units = state.units.map((unit): Unit => { + const demo = DEMO_WINDOWS.find((entry) => entry.unitId === unit.id); + const intake = demo && !unit.intakeWindow ? intakeWindowFromClockText(demo.from, demo.to, now) : undefined; + return intake && demo ? { ...unit, intakeWindow: { ...intake, at, by: demo.by } } : unit; + }); + return { ...state, units }; +} diff --git a/src/components/ward-management/capacity/ward-intake-window-list.ts b/src/components/ward-management/capacity/ward-intake-window-list.ts new file mode 100644 index 0000000000..acf376039f --- /dev/null +++ b/src/components/ward-management/capacity/ward-intake-window-list.ts @@ -0,0 +1,24 @@ +import type { Instant } from "@/components/ward-management/ward-clock"; +import { byServiceThenName } from "@/components/ward-management/ward-derivations-shortlist"; +import type { Unit, WardIntakeWindow } from "@/components/ward-management/ward-model"; + +import { tonightIntakeWindow } from "./ward-intake-window"; + +/** + * Feature item 13: the wards that have set tonight's intake hours, in the one neutral order + * (health service, then ward name; D-71, D-126), and a count of those that have not. Never sorted + * by the hours themselves, so the list ranks nobody. + */ +export function tonightIntakeWindowList( + units: readonly Unit[], + now: Instant, +): { set: { unit: Unit; intake: WardIntakeWindow }[]; notSetCount: number } { + const set: { unit: Unit; intake: WardIntakeWindow }[] = []; + let notSetCount = 0; + for (const unit of [...units].sort(byServiceThenName)) { + const intake = tonightIntakeWindow(unit, now); + if (intake) set.push({ unit, intake }); + else notSetCount += 1; + } + return { set, notSetCount }; +} diff --git a/src/components/ward-management/capacity/ward-intake-window.ts b/src/components/ward-management/capacity/ward-intake-window.ts new file mode 100644 index 0000000000..e6fe491571 --- /dev/null +++ b/src/components/ward-management/capacity/ward-intake-window.ts @@ -0,0 +1,65 @@ +import { + formatInstant, + minuteOfDay, + minuteOfDayFromClockText, + MINUTES_PER_DAY, + type Instant, +} from "@/components/ward-management/ward-clock"; +import type { Unit, WardIntakeWindow } from "@/components/ward-management/ward-model"; +import { REMINDER_MORNING_SHIFT_START_MINUTE } from "@/components/ward-management/ward-operational-defaults"; + +/** + * OVERNIGHT INTAKE WINDOW (feature item 13, 10 Nov 2026): the hours tonight a ward's nurse in + * charge says it can take an admission, so night staff stop ringing wards that cannot take anyone. + * Information only: it never blocks a referral, and never sorts, filters or ranks wards. + * + * It clears itself each morning by being read, not by being deleted: a window counts only when it + * was set since the most recent 07:00 day start. Not the current shift start, because a window set + * at 23:30 sits in the night shift and must still show at 03:00. + */ + +/** The most recent 07:00 day start at or before `now`. */ +export function intakeDayStart(now: Instant): Instant { + const start = REMINDER_MORNING_SHIFT_START_MINUTE; + return now - ((minuteOfDay(now) - start + MINUTES_PER_DAY) % MINUTES_PER_DAY); +} + +/** This ward's window for tonight, or `null` when none was set since this morning's day start. */ +export function tonightIntakeWindow(unit: Pick, now: Instant): WardIntakeWindow | null { + const intake = unit.intakeWindow; + if (!intake) return null; + return intake.at >= intakeDayStart(now) && intake.at <= now ? intake : null; +} + +/** + * Two typed clock times read to instants in the day that started at the latest 07:00 (D-72's typed + * time pattern). A "to" at or before "from" crosses midnight: 21:00 to 02:00 ends the next morning. + * `undefined` for a half-typed time, or a window that starts and ends at the same minute. + */ +export function intakeWindowFromClockText( + fromText: string, + toText: string, + now: Instant, +): { from: Instant; to: Instant } | undefined { + const fromMinute = minuteOfDayFromClockText(fromText); + const toMinute = minuteOfDayFromClockText(toText); + if (fromMinute === undefined || toMinute === undefined || fromMinute === toMinute) return undefined; + const from = + intakeDayStart(now) + ((fromMinute - REMINDER_MORNING_SHIFT_START_MINUTE + MINUTES_PER_DAY) % MINUTES_PER_DAY); + return { from, to: from + ((toMinute - fromMinute + MINUTES_PER_DAY) % MINUTES_PER_DAY) }; +} + +/** Why a window would be refused, or `null`: it starts within today's day and is under a day long. */ +export function intakeWindowProblem(intake: { from: Instant; to: Instant }, now: Instant): string | null { + const { from, to } = intake; + if (![from, to].every((value) => Number.isInteger(value))) return "The times must be whole minutes"; + const dayStart = intakeDayStart(now); + if (from < dayStart || from >= dayStart + MINUTES_PER_DAY) return "The start must be within today's day"; + if (to <= from || to - from >= MINUTES_PER_DAY) return "The end must be after the start, within a day"; + return null; +} + +/** "Tonight: admit 21:00 to 02:00", or "Tonight: not set". */ +export function intakeWindowLine(intake: WardIntakeWindow | null): string { + return intake ? `Tonight: admit ${formatInstant(intake.from)} to ${formatInstant(intake.to)}` : "Tonight: not set"; +} diff --git a/src/components/ward-management/coordinator/shortlist-panel.tsx b/src/components/ward-management/coordinator/shortlist-panel.tsx index e604204cef..0379efd591 100644 --- a/src/components/ward-management/coordinator/shortlist-panel.tsx +++ b/src/components/ward-management/coordinator/shortlist-panel.tsx @@ -1,5 +1,6 @@ "use client"; +import { intakeWindowLine, tonightIntakeWindow } from "@/components/ward-management/capacity/ward-intake-window"; import { securityWord } from "@/components/ward-management/ward-model-vocabulary"; import { HOME_REGION_NOT_RECORDED } from "@/components/ward-management/ward-model"; import { currentLegalDueAt, formProgression, formProgressionPhrase } from "../legal-forms/legal-form-progression"; @@ -2183,6 +2184,15 @@ export function ShortlistPanel({ {activeUnit.intakeConstraints.map((code) => wardIntakeConstraintLabels[code]).join(", ")} ) : null} + {/* Feature item 13: tonight's intake hours, information only. */} + {tonightIntakeWindow(activeUnit, now) ? ( + + {intakeWindowLine(tonightIntakeWindow(activeUnit, now))} + + ) : null} ) : null} {/* The security gate below will read "Met — Secure ward meets an open requirement" for this diff --git a/src/components/ward-management/ward-flow-event-roles.ts b/src/components/ward-management/ward-flow-event-roles.ts index bdd11d91f9..0c4dba59b2 100644 --- a/src/components/ward-management/ward-flow-event-roles.ts +++ b/src/components/ward-management/ward-flow-event-roles.ts @@ -101,6 +101,8 @@ export const EVENT_ROLE: Record // Owner Answer 18, second round, 2026-09-17: the ward's own answer to what is currently // limiting its intake, same role as CONFIRM_CAPACITY above. RECORD_WARD_INTAKE_CONSTRAINTS: ["ward", "coordinator"], + // Feature item 13: tonight's intake hours, the ward for itself (reducer checks) or the coordinator. + SET_INTAKE_WINDOW: ["ward", "coordinator"], RECORD_ESCALATION: ["coordinator"], ADVANCE_CLOCK: ["demo"], RESET_SCENARIO: ["demo"], diff --git a/src/components/ward-management/ward-flow-events.ts b/src/components/ward-management/ward-flow-events.ts index a9965b2885..ee87232e94 100644 --- a/src/components/ward-management/ward-flow-events.ts +++ b/src/components/ward-management/ward-flow-events.ts @@ -597,6 +597,21 @@ export type WardFlowEvent = actingUnitId: string; codes: readonly WardIntakeConstraint[]; } + | { + /** + * Feature item 13 (10 Nov 2026): the hours tonight a ward can take an admission, or `null` to + * clear it. Two typed clock times already read to instants (D-72), no typed text. Information + * only: it never blocks a referral or orders wards. The ward for itself, or the coordinator. + * See ward-flow-intake-window-events.ts. + */ + type: "SET_INTAKE_WINDOW"; + role: WardFlowRole; + now: Instant; + unitId: string; + /** The ward's own claim, refused when it is not `unitId`. Absent for the coordinator. */ + actingUnitId?: string; + intake: { from: Instant; to: Instant } | null; + } | { type: "CONFIRM_CAPACITY"; role: WardFlowRole; diff --git a/src/components/ward-management/ward-flow-intake-window-events.ts b/src/components/ward-management/ward-flow-intake-window-events.ts new file mode 100644 index 0000000000..f0e834ec95 --- /dev/null +++ b/src/components/ward-management/ward-flow-intake-window-events.ts @@ -0,0 +1,62 @@ +import type { AuditDecision } from "./ward-audit"; +import { intakeWindowProblem } from "./capacity/ward-intake-window"; +import type { WardFlowEvent } from "./ward-flow-events"; +import type { findUnit, reject, replaceUnit, WardFlowState } from "./ward-flow-reducer"; +import { WARD_FLOW_ROLE_LABELS } from "./ward-flow-roles"; +import type { Unit } from "./ward-model"; + +/** + * OVERNIGHT INTAKE WINDOW (feature item 13, 10 Nov 2026): set or clear the hours tonight a ward can + * take an admission (`Unit.intakeWindow`). Information only: it moves no counts, blocks no referral + * and orders no list. Nothing deletes it in the morning; `tonightIntakeWindow` stops reading it. + * + * The reducer passes its helpers in, since this file may not import the reducer at run time. + */ +export type IntakeWindowEvent = Extract; + +export type IntakeWindowHelpers = { + findUnit: typeof findUnit; + reject: typeof reject; + replaceUnit: typeof replaceUnit; +}; + +export function reduceIntakeWindowEvent( + state: WardFlowState, + event: IntakeWindowEvent, + decision: AuditDecision, + { findUnit, reject, replaceUnit }: IntakeWindowHelpers, +): WardFlowState { + // Claim, not proof, as RECORD_WARD_INTAKE_CONSTRAINTS: a ward acts only on itself. + if (event.role === "ward" && event.actingUnitId !== event.unitId) { + return reject( + state, + event, + `${event.type} was raised acting as unit ${event.actingUnitId ?? "no named ward"} but names unit ${event.unitId}; a ward sets only its own intake hours`, + ); + } + const unit = findUnit(state, event.unitId); + if (!unit) return reject(state, event, `no unit found for id ${event.unitId}`); + + if (event.intake === null) { + if (!unit.intakeWindow) return reject(state, event, `${unit.name} has no intake hours to clear`); + decision.outcome = "accepted"; + decision.reasonCode = "none"; + const cleared: Unit = { ...unit }; + delete cleared.intakeWindow; + return replaceUnit(state, unit.id, cleared); + } + + const problem = intakeWindowProblem(event.intake, event.now); + if (problem) return reject(state, event, problem); + decision.outcome = "accepted"; + decision.reasonCode = "none"; + return replaceUnit(state, unit.id, { + ...unit, + intakeWindow: { + from: event.intake.from, + to: event.intake.to, + at: event.now, + by: WARD_FLOW_ROLE_LABELS[event.role], + }, + }); +} diff --git a/src/components/ward-management/ward-flow-persistence-classification.ts b/src/components/ward-management/ward-flow-persistence-classification.ts index 8dda079295..7cddadebd2 100644 --- a/src/components/ward-management/ward-flow-persistence-classification.ts +++ b/src/components/ward-management/ward-flow-persistence-classification.ts @@ -288,6 +288,8 @@ const WARD_FLOW_TEXT_SAFE_EVENT_TYPE_TUPLE = [ // the reducer, never a bare `string[]` — the same "array element types reviewed by hand" shape // `REFER_TO_UNITS.unitIds` already has on this list. "RECORD_WARD_INTAKE_CONSTRAINTS", + // Feature item 13 (D-72 pattern): ids and two typed clock times already read to instants. No typed text. + "SET_INTAKE_WINDOW", "REFER_TO_UNITS", // Owner answer 8 (second round, 2026-09-17): `movementId`/`actingUnitId` are ids (reviewed // above) and the caller's own `role` — no human-typed field, the same shape `RELEASE_PULL` diff --git a/src/components/ward-management/ward-flow-reducer-slices.ts b/src/components/ward-management/ward-flow-reducer-slices.ts index 35552a13a4..9fbed2e71d 100644 --- a/src/components/ward-management/ward-flow-reducer-slices.ts +++ b/src/components/ward-management/ward-flow-reducer-slices.ts @@ -7,6 +7,7 @@ import { type DischargeCheckEvent, reduceDischargeCheckEvent } from "./discharge import { reduceBedClosureEvent } from "./ward-flow-bed-closure-events"; import { reduceBedEvent } from "./ward-flow-bed-events"; import { reduceBedMeetingEvent } from "./ward-flow-bed-meeting-events"; +import { reduceIntakeWindowEvent } from "./ward-flow-intake-window-events"; import { reduceWardDiagnosis } from "./ward-flow-diagnosis-events"; import { reduceLoopCheckEvent } from "./ward-flow-loop-check-events"; import { reducePlacementEvent } from "./ward-flow-placement-events"; @@ -58,6 +59,7 @@ const slices = { reduceBroadcastAlertEvent, reduceDischargeDependencyEvent, reduceDischargeFollowThroughEvent, + reduceIntakeWindowEvent, reduceLoopCheckEvent, reducePatientArchive, reducePatientCarePlanEvent, diff --git a/src/components/ward-management/ward-flow-reducer.ts b/src/components/ward-management/ward-flow-reducer.ts index fa99eeb89f..71ef50ba38 100644 --- a/src/components/ward-management/ward-flow-reducer.ts +++ b/src/components/ward-management/ward-flow-reducer.ts @@ -191,6 +191,7 @@ import { type SupportNotificationRecord } from "@/components/ward-management/war import { applyLoopChecksDemoOverlay } from "./ward-loop-checks-demo"; import { applyWaitlistDemoOverlay } from "./ward-demo-waitlist"; import { reduceReviewReadyEvent } from "./ward-flow-review-ready-events"; +import { applyIntakeWindowDemoOverlay } from "./capacity/ward-intake-window-demo"; // S6 example data (3 Nov 2026): no demo list opens empty. A live shared build swaps in a no-op. import { applyExampleListsOverlay } from "@/components/ward-management/ward-demo-example-lists"; import { stepUndoSubjectId, subjectId } from "./ward-flow-subject-id"; @@ -593,7 +594,9 @@ export function seedWardFlowState(scenario: WardScenario = "standard"): WardFlow }; // Closing the loop, package D (24 Oct 2026): a few synthetic marks so each record shows in the demo. const demo = network ? seed : applyLoopChecksDemoOverlay(applyRulingsDemoOverlay(seed, NOW_ANCHOR), NOW_ANCHOR); - return applyExampleListsOverlay(network ? demo : applyWaitlistDemoOverlay(demo, NOW_ANCHOR), NOW_ANCHOR); + // Feature item 13: a few synthetic intake windows set this morning. + const withWindows = network ? demo : applyIntakeWindowDemoOverlay(demo, NOW_ANCHOR); + return applyExampleListsOverlay(network ? withWindows : applyWaitlistDemoOverlay(withWindows, NOW_ANCHOR), NOW_ANCHOR); } /** @@ -1939,6 +1942,9 @@ export function reduceClinicalEvent( case "CLOSE_WARD_TO_ADMISSIONS": case "REOPEN_WARD_TO_ADMISSIONS": return reducerSlices().reduceWardClosureEvent(state, event, decision, { findUnit, reject, replaceUnit }); + // Feature item 13: tonight's intake hours, information only. + case "SET_INTAKE_WINDOW": + return reducerSlices().reduceIntakeWindowEvent(state, event, decision, { findUnit, reject, replaceUnit }); // H-0978 / WS-15: one exact bed closed or reopened (`Unit.closedBeds`). case "SET_BED_CLOSED": return reducerSlices().reduceBedClosureEvent(state, event, decision, { diff --git a/src/components/ward-management/ward-flow-storage-validation.ts b/src/components/ward-management/ward-flow-storage-validation.ts index 4e46ad3650..dca7a8ae75 100644 --- a/src/components/ward-management/ward-flow-storage-validation.ts +++ b/src/components/ward-management/ward-flow-storage-validation.ts @@ -399,6 +399,9 @@ const unit = z }), ), restrictionReviews: z.optional(z.array(restrictionReviewRule)), + // Feature item 13: two typed times read to instants, when and by which desk. Optional, so a state + // saved before intake windows existed still loads. + intakeWindow: z.optional(z.looseObject({ from: finite, to: finite, at: finite, by: text })), // D-72: fixed-list choices, ids and typed times already read to instants. No typed text. restrictivePractice: z.optional( z.looseObject({ diff --git a/src/components/ward-management/ward-flow-subject-id.ts b/src/components/ward-management/ward-flow-subject-id.ts index 347b801d75..0817247649 100644 --- a/src/components/ward-management/ward-flow-subject-id.ts +++ b/src/components/ward-management/ward-flow-subject-id.ts @@ -63,6 +63,7 @@ export function subjectId(event: WardFlowEvent): string { return event.edId ?? event.acceptedUnitId ?? "none"; case "CONFIRM_CAPACITY": case "RECORD_WARD_INTAKE_CONSTRAINTS": + case "SET_INTAKE_WINDOW": case "CLOSE_WARD_TO_ADMISSIONS": case "REOPEN_WARD_TO_ADMISSIONS": case "MARK_WARD_READY_FOR_NEXT": diff --git a/src/components/ward-management/ward-history.ts b/src/components/ward-management/ward-history.ts index f465852437..536a4a700d 100644 --- a/src/components/ward-management/ward-history.ts +++ b/src/components/ward-management/ward-history.ts @@ -139,6 +139,7 @@ export const EVENT_HISTORY_TABLE: Record(null); + const id = useId(); + const { dispatch, rejections } = useWardFlowSelect("dispatch", "rejections"); + // The live clock stamps the step; `now` may be a paused page clock, kept for display only. + const at = useWardFlowClock(); + const { run } = useUndoableStep({ dispatch, rejections, now: at, routeRole: role }); + + const intake = intakeWindowFromClockText(fromText, toText, at); + const problem = + fromText.trim() === "" || toText.trim() === "" + ? "Type both times." + : intake === undefined + ? "Type each time as 21:00 or 2100, and make them different." + : undefined; + + const close = () => { + setOpen(false); + setFromText(""); + setToText(""); + }; + const send = (next: { from: Instant; to: Instant } | null) => { + run( + { + type: "SET_INTAKE_WINDOW", + role, + now: at, + unitId: unit.id, + ...(role === "ward" ? { actingUnitId: unit.id } : {}), + intake: next, + }, + { + success: next + ? `${unit.name}: admit ${formatInstant(next.from)} to ${formatInstant(next.to)} tonight.` + : `${unit.name}: tonight's intake hours cleared.`, + refusalShownInline: true, + onRefused: (why) => setRefused(refusalReasonText(why)), + }, + ); + close(); + }; + + return ( + + {intakeWindowLine(tonight)} + {mayEdit ? ( + <> + {" · "} + + + ) : null} + {refused ? ( + + Not saved. {refused} + + ) : null} + send(null) }] : []), + ]} + primary={{ + label: "Save", + disabledReason: problem, + onAction: () => { + if (problem || !intake) return; + send(intake); + }, + }} + data-testid="ward-intake-window-dialog" + > +
+ + setFromText(event.target.value)} + data-testid="ward-intake-window-from" + /> + + + setToText(event.target.value)} + data-testid="ward-intake-window-to" + /> + +
+
+
+ ); +} diff --git a/src/components/ward-management/ward/ward-screen.tsx b/src/components/ward-management/ward/ward-screen.tsx index 28a213abb7..7c52b29881 100644 --- a/src/components/ward-management/ward/ward-screen.tsx +++ b/src/components/ward-management/ward/ward-screen.tsx @@ -87,6 +87,7 @@ import { CheckingFoot, durMinutes, Menu, type MenuItem, useIsPhone, useOptionalT import { savedAtToast } from "@/components/ward-management/ward-saved-at-toast"; import { useActionToast } from "@/components/ward-management/use-action-toast"; import { useWardUnitQueues } from "./use-ward-unit-queues"; +import { WardIntakeWindowControl } from "./ward-intake-window-control"; import styles from "./ward.module.css"; import { wardAlerts } from "./ward-alert-counts"; import type { WaitingReferral } from "./ward-bed-board"; @@ -1988,6 +1989,8 @@ function WardOverviewScreen({ unitId, presentation = "overview", departurePlanni {" · "} {wardMeta} · {staffedSpecialling} staff on constant observation + {/* Feature item 13: tonight's intake hours, information only. */} + { + const unit = pick(random, state.units); + const from = Math.ceil((currentNow + 1) / 60) * 60; + return { + type: "SET_INTAKE_WINDOW", + unitId: id(unit), + actingUnitId: id(unit), + intake: chance(random, 0.3) ? null : { from, to: from + int(random, 2, 6) * 60 }, + }; + }, + ], // Ward-level confirmations and requests. [ 1, diff --git a/tests/ward-event-permissions.test.ts b/tests/ward-event-permissions.test.ts index 08c68d2ce4..76dc37eaeb 100644 --- a/tests/ward-event-permissions.test.ts +++ b/tests/ward-event-permissions.test.ts @@ -211,6 +211,8 @@ describe("who may raise which event", () => { // Owner Answer 18 (second round, 2026-09-17): the ward's own answer to what is currently // limiting its intake, same role as CONFIRM_CAPACITY above. RECORD_WARD_INTAKE_CONSTRAINTS: ["ward", "coordinator"], + // Feature item 13: tonight's intake hours, the ward for itself or the coordinator. + SET_INTAKE_WINDOW: ["ward", "coordinator"], RECORD_NO_REFERRAL: ["ed", "coordinator"], RECORD_LEAVE_BED: ["ward", "coordinator"], // D-38 (9 Oct 2026): the ward records an absence and its steps; the community team a CTO. diff --git a/tests/ward-event-reachability.test.ts b/tests/ward-event-reachability.test.ts index 0d61087c0a..2b40f7ffe8 100644 --- a/tests/ward-event-reachability.test.ts +++ b/tests/ward-event-reachability.test.ts @@ -48,6 +48,8 @@ const DEFINITION_FILES = new Set([ `${WARD_ROOT}/ward-flow-bed-events.ts`, `${WARD_ROOT}/ward-flow-referral-followup-events.ts`, `${WARD_ROOT}/ward-flow-ward-closure-events.ts`, + // Feature item 13 (10 Nov 2026): SET_INTAKE_WINDOW's case, beside its helpers. + `${WARD_ROOT}/ward-flow-intake-window-events.ts`, `${WARD_ROOT}/ward-flow-bed-meeting-events.ts`, // 21 Oct 2026: declares SEND_RELEASE_SUPPORT_NOTICE and RECORD_ARRIVAL_CHECK beside their cases. `${WARD_ROOT}/ward-flow-release-notice-events.ts`, diff --git a/tests/ward-intake-window.test.ts b/tests/ward-intake-window.test.ts new file mode 100644 index 0000000000..0298665709 --- /dev/null +++ b/tests/ward-intake-window.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; + +import { + intakeDayStart, + intakeWindowFromClockText, + intakeWindowLine, + tonightIntakeWindow, +} from "@/components/ward-management/capacity/ward-intake-window"; +import { tonightIntakeWindowList } from "@/components/ward-management/capacity/ward-intake-window-list"; +import { MINUTES_PER_DAY } from "@/components/ward-management/ward-clock"; +import { seedWardFlowState, wardFlowReducer, type WardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import { isValidStoredWardFlowState } from "@/components/ward-management/ward-flow-storage-validation"; +import type { WardFlowEvent } from "@/components/ward-management/ward-flow-events"; +import type { Unit } from "@/components/ward-management/ward-model"; + +/** Day 0's clock face as an instant: `at(18)` is 18:00 on the demo's first day. */ +const at = (hours: number, minutes = 0, day = 0) => day * MINUTES_PER_DAY + hours * 60 + minutes; + +const windowSetAt = (setAt: number): Pick => ({ + intakeWindow: { ...intakeWindowFromClockText("21:00", "02:00", setAt)!, at: setAt, by: "Nurse in charge" }, +}); + +describe("tonightIntakeWindow", () => { + it("shows a window set at 18:00 until 07:00 the next day", () => { + const unit = windowSetAt(at(18)); + expect(tonightIntakeWindow(unit, at(18))).not.toBeNull(); + expect(tonightIntakeWindow(unit, at(6, 59, 1))).not.toBeNull(); + expect(tonightIntakeWindow(unit, at(7, 0, 1))).toBeNull(); + }); + + it("still shows a window set at 23:30 at 03:00, and it is gone at 07:00", () => { + const unit = windowSetAt(at(23, 30)); + expect(tonightIntakeWindow(unit, at(3, 0, 1))).not.toBeNull(); + expect(tonightIntakeWindow(unit, at(7, 0, 1))).toBeNull(); + }); + + it("reads nothing when no window is set", () => { + expect(tonightIntakeWindow({}, at(20))).toBeNull(); + expect(intakeWindowLine(null)).toBe("Tonight: not set"); + }); +}); + +describe("intakeWindowFromClockText", () => { + it("reads a window across midnight into the next morning", () => { + const intake = intakeWindowFromClockText("21:00", "0200", at(18))!; + expect(intake).toEqual({ from: at(21), to: at(2, 0, 1) }); + expect(intakeWindowLine({ ...intake, at: at(18), by: "Nurse in charge" })).toBe("Tonight: admit 21:00 to 02:00"); + }); + + it("reads a typed time after midnight as tonight, not this morning", () => { + // Set at 01:00 on day 1: the day started at 07:00 on day 0, so 03:00 is day 1's 03:00. + expect(intakeWindowFromClockText("03:00", "05:00", at(1, 0, 1))).toEqual({ from: at(3, 0, 1), to: at(5, 0, 1) }); + expect(intakeDayStart(at(1, 0, 1))).toBe(at(7)); + }); + + it("refuses half-typed times and a window with no length", () => { + expect(intakeWindowFromClockText("21", "02:00", at(18))).toBeUndefined(); + expect(intakeWindowFromClockText("21:00", "", at(18))).toBeUndefined(); + expect(intakeWindowFromClockText("21:00", "21:00", at(18))).toBeUndefined(); + expect(intakeWindowFromClockText("25:00", "02:00", at(18))).toBeUndefined(); + }); +}); + +describe("SET_INTAKE_WINDOW", () => { + const NOW = at(18); + const setup = () => { + const state = seedWardFlowState(); + const unit = state.units.find((candidate) => !candidate.intakeWindow)!; + return { state, unitId: unit.id }; + }; + const send = (state: WardFlowState, event: Partial>) => + wardFlowReducer(state, { + type: "SET_INTAKE_WINDOW", + role: "coordinator", + now: NOW, + unitId: "", + intake: null, + ...event, + } as WardFlowEvent); + const unitOf = (state: WardFlowState, unitId: string) => state.units.find((unit) => unit.id === unitId)!; + + it("sets a window for the ward itself and clears it again", () => { + const { state, unitId } = setup(); + const intake = { from: at(21), to: at(2, 0, 1) }; + const set = send(state, { role: "ward", actingUnitId: unitId, unitId, intake }); + expect(set.rejections).toEqual([]); + expect(unitOf(set, unitId).intakeWindow).toEqual({ ...intake, at: NOW, by: "Nurse in charge" }); + // Information only: no count moves. + expect(unitOf(set, unitId).allocatable).toEqual(unitOf(state, unitId).allocatable); + + const cleared = send(set, { unitId, intake: null }); + expect(cleared.rejections).toEqual([]); + expect(unitOf(cleared, unitId).intakeWindow).toBeUndefined(); + // Nothing left to clear. + expect(send(cleared, { unitId, intake: null }).rejections).toHaveLength(1); + }); + + it("refuses another ward acting, a desk that is not the ward or the coordinator, and invalid times", () => { + const { state, unitId } = setup(); + const other = state.units.find((unit) => unit.id !== unitId)!.id; + const intake = { from: at(21), to: at(23) }; + expect(send(state, { role: "ward", actingUnitId: other, unitId, intake }).rejections).toHaveLength(1); + expect(send(state, { role: "ward", unitId, intake }).rejections).toHaveLength(1); + expect(send(state, { role: "ed", unitId, intake }).rejections).toHaveLength(1); + expect(send(state, { role: "bed_manager", unitId, intake }).rejections).toHaveLength(1); + // End before start, a whole day or more, a start before this morning's 07:00, and part minutes. + expect(send(state, { unitId, intake: { from: at(21), to: at(20) } }).rejections).toHaveLength(1); + expect(send(state, { unitId, intake: { from: at(21), to: at(21, 0, 1) } }).rejections).toHaveLength(1); + expect(send(state, { unitId, intake: { from: at(6), to: at(8) } }).rejections).toHaveLength(1); + expect(send(state, { unitId, intake: { from: at(21, 0.5), to: at(23) } }).rejections).toHaveLength(1); + expect(send(state, { unitId: "no-such-ward", intake }).rejections).toHaveLength(1); + expect(send(state, { unitId, intake }).rejections).toEqual([]); + }); +}); + +describe("tonightIntakeWindowList", () => { + it("keeps the neutral service-then-name order and never sorts by the hours", () => { + const units = seedWardFlowState().units; + const now = at(18); + // Give every ward a window, the earliest start on the ward that sorts last, and check the order. + const withWindows = units.map((unit, index) => ({ + ...unit, + intakeWindow: { + from: at(20) + (units.length - index) * 5, + to: at(23, 30), + at: now - 10, + by: "Nurse in charge", + }, + })); + const { set, notSetCount } = tonightIntakeWindowList(withWindows, now); + expect(notSetCount).toBe(0); + const shuffled = tonightIntakeWindowList([...withWindows].reverse(), now); + expect(shuffled.set.map(({ unit }) => unit.id)).toEqual(set.map(({ unit }) => unit.id)); + const names = set.map(({ unit }) => unit.name); + const byFrom = [...set].sort((a, b) => a.intake.from - b.intake.from).map(({ unit }) => unit.name); + expect(names).not.toEqual(byFrom); + }); + + it("counts the wards with no window tonight, including one set before this morning", () => { + const units = seedWardFlowState().units; + const now = at(8, 0, 1); + const { set, notSetCount } = tonightIntakeWindowList(units, now); + // The seed's windows were set on day 0, so they have cleared by 08:00 on day 1. + expect(set).toEqual([]); + expect(notSetCount).toBe(units.length); + }); + + it("shows the seeded demo windows on the demo clock", () => { + const state = seedWardFlowState(); + const { set } = tonightIntakeWindowList(state.units, at(10, 42)); + expect(set.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe("stored intake windows", () => { + it("loads a saved state with and without an intake window, and refuses a malformed one", () => { + const state = seedWardFlowState(); + expect(isValidStoredWardFlowState(state)).toBe(true); + const withoutAny = { ...state, units: state.units.map(({ intakeWindow: _drop, ...unit }) => unit) }; + expect(isValidStoredWardFlowState(withoutAny)).toBe(true); + const broken = { + ...state, + units: state.units.map((unit, index) => + index === 0 ? { ...unit, intakeWindow: { from: "21:00", to: 120, at: 0, by: "x" } } : unit, + ), + }; + expect(isValidStoredWardFlowState(broken)).toBe(false); + }); +}); diff --git a/tests/ward-referral-model.test.ts b/tests/ward-referral-model.test.ts index d085e6cbaf..bba94d1ca3 100644 --- a/tests/ward-referral-model.test.ts +++ b/tests/ward-referral-model.test.ts @@ -559,6 +559,8 @@ describe("front-door contract — an ED may close to all admissions, never refus // A ward restating what is currently limiting its own intake, from a fixed list. Not a // refusal of any named referral or placement — it carries no subject at all. RECORD_WARD_INTAKE_CONSTRAINTS: false, + // Feature item 13: tonight's intake hours, information only, never a refusal of anyone. + SET_INTAKE_WINDOW: false, // Smart features idea 7: closes a whole ward to everyone, never one named patient. CLOSE_WARD_TO_ADMISSIONS: false, REOPEN_WARD_TO_ADMISSIONS: false, From e9dd7efb57cfaef94b4a47d3aeb0287b35bf862c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:17:13 +0000 Subject: [PATCH 04/42] Add a review time to single bed closures (item 6) A ward closing one bed can choose "Review in" (Not known, or 4 to 48 hours, the whole-ward reopening choices). SET_BED_CLOSED carries an optional reviewAt, validated like a ward's `until`; reopening refuses it. The time only prompts: a closed bed shows "Review 14:00" or "Review overdue", Capacity and the bed meeting agenda list closed beds past review (service then ward name, linking to the ward), and nothing reopens by itself. Ready counts are unchanged by a review time. The standard demo seed closes one empty Maali bed with a review an hour ago. ClosureChoice moves to its own file so both closure panels share it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK --- .../bed-meeting/bed-meeting-agenda-screen.tsx | 42 ++++- .../bed-meeting/bed-meeting-agenda.ts | 29 +++ .../capacity/bed-closure-review.ts | 37 ++++ .../capacity/capacity-closed-beds-review.tsx | 68 +++++++ .../capacity/capacity-phone-overview.tsx | 3 + .../capacity/capacity-screen.tsx | 7 + .../capacity-ward-closures-dialog.tsx | 24 ++- .../capacity/closure-choice.module.css | 18 ++ .../capacity/closure-choice.tsx | 54 ++++++ .../capacity/ward-closure-panel.module.css | 19 -- .../capacity/ward-closure-panel.tsx | 37 +--- .../ward-flow-bed-closure-events.ts | 12 +- .../ward-management/ward-flow-events.ts | 4 +- .../ward-flow-persistence-classification.ts | 3 +- .../ward-flow-storage-validation.ts | 2 + .../ward-management/ward-loop-checks-demo.ts | 44 ++++- src/components/ward-management/ward-model.ts | 15 +- .../ward-management/ward-reanchor.ts | 2 + .../ward/ward-bed-closure-controls.tsx | 32 +++- .../ward-management/ward/ward-bed-tone.ts | 2 + .../ward-management/ward/ward-beds-matrix.tsx | 5 +- .../ward/ward-screen-derivations.ts | 5 + .../ward/ward-screen-overlays.tsx | 26 ++- tests/helpers/ward-engine-fuzz.ts | 6 +- tests/ward-bed-closed-mark-review.test.ts | 174 ++++++++++++++++++ tests/ward-bed-closed-mark.dom.test.tsx | 28 +++ tests/ward-bed-meeting-agenda.dom.test.tsx | 14 ++ tests/ward-board-triage.dom.test.tsx | 8 +- ...d-capacity-closed-beds-review.dom.test.tsx | 38 ++++ .../ward-cross-mode-wiring-audit.dom.test.tsx | 3 +- 30 files changed, 677 insertions(+), 84 deletions(-) create mode 100644 src/components/ward-management/capacity/bed-closure-review.ts create mode 100644 src/components/ward-management/capacity/capacity-closed-beds-review.tsx create mode 100644 src/components/ward-management/capacity/closure-choice.module.css create mode 100644 src/components/ward-management/capacity/closure-choice.tsx create mode 100644 tests/ward-bed-closed-mark-review.test.ts create mode 100644 tests/ward-capacity-closed-beds-review.dom.test.tsx diff --git a/src/components/ward-management/bed-meeting/bed-meeting-agenda-screen.tsx b/src/components/ward-management/bed-meeting/bed-meeting-agenda-screen.tsx index c9ffa5f03c..e59e78019f 100644 --- a/src/components/ward-management/bed-meeting/bed-meeting-agenda-screen.tsx +++ b/src/components/ward-management/bed-meeting/bed-meeting-agenda-screen.tsx @@ -47,6 +47,7 @@ import { HEALTH_SERVICES, type HealthService } from "../ward-model-vocabulary"; import { referralQueueOrder } from "../ward-referrals"; import { healthServiceAcronym, movementBelongsToService, unitHealthService } from "../ward-service-scope"; import { siteByCode } from "../ward-sites"; +import { unitHref } from "../shell/ward-facade"; import { referralBoardHref } from "../ed/ed-bed-search-step"; import { askingForShort, waitWords } from "../referrals/referral-board-parts"; import { WardPrototypeFooter } from "../shell/ward-prototype-footer"; @@ -69,7 +70,13 @@ import { type BedMeetingActionKind, type BedMeetingActionOwner, } from "./bed-meeting-actions"; -import { bedMeetingAgenda, meetingActionSummary, type AgendaItem, type MeetingActionRow } from "./bed-meeting-agenda"; +import { + bedMeetingAgenda, + closedBedsReviewSection, + meetingActionSummary, + type AgendaItem, + type MeetingActionRow, +} from "./bed-meeting-agenda"; import { AgendaReferralsCopy, agendaReferralsText } from "./agenda-referrals-copy"; import { actionsRecordedSince, @@ -237,6 +244,11 @@ function BedMeetingAgenda() { }) : allSections; const fullSummary = meetingActionSummary(scopedMovements, scopedAdmissions, now); + // Item 6 (10 Nov 2026): beds, not people, so only the unfiltered agenda lists them. + const closedReview = closedBedsReviewSection( + service === null ? units : units.filter((unit) => unitHealthService(unit) === service), + now, + ); // Offer every group the control can filter: the agenda lists, the actions from earlier meetings // and the waiting referrals, each person counted once. const groupOptions = agendaGroupOptions( @@ -785,6 +797,34 @@ function BedMeetingAgenda() { ) : null, )} + {closedReview.total > 0 && chips.size === 0 && !grouped ? ( + + {(limit) => + closedReview.items.slice(0, limit).map((item) => ( +
  • +
    +
    + + {item.wardName} · Bed {item.bedNumber} + + + Closed since {formatInstantWithDay(item.closedAt, now)} · review was{" "} + {formatInstantWithDay(item.reviewAt, now)} + +
    + + Open ward + +
    +
  • + )) + } +
    + ) : null} {shows("referrals") ? ( ({ + unitId: unit.id, + wardName: unit.name, + bedNumber: mark.bedNumber, + closedAt: mark.at, + reviewAt: mark.reviewAt!, + })); + return { id: "closed-beds-review", title: "Closed beds past review", total: items.length, items }; +} + /** Every recorded action, where it stands now. Overdue is shown, never escalated. */ export function meetingActionSummary( movements: readonly Movement[], diff --git a/src/components/ward-management/capacity/bed-closure-review.ts b/src/components/ward-management/capacity/bed-closure-review.ts new file mode 100644 index 0000000000..29e7fa88e8 --- /dev/null +++ b/src/components/ward-management/capacity/bed-closure-review.ts @@ -0,0 +1,37 @@ +import { formatInstantWithDay, type Instant } from "../ward-clock"; +import { byServiceThenName } from "../ward-derivations-shortlist"; +import type { Unit } from "../ward-model"; +import { activeClosedBeds } from "../ward-ready-beds"; + +/** + * Item 6 (10 Nov 2026), bed closures with a review time: a ward closing one bed can say when to look + * at it again, so a bed never stays closed by accident. The time only prompts. Reopening always + * needs a person, and a review time changes no bed figure (Ready counts the closed mark either way). + */ +export type ClosedBedMark = NonNullable[number]; + +/** The review time has come on a bed still closed. A prompt for a person, never a reopening. */ +export function bedReviewDue(mark: ClosedBedMark, now: Instant): boolean { + return mark.reopenedAt === undefined && mark.reviewAt !== undefined && now >= mark.reviewAt; +} + +/** "Review 14:00" or "Review overdue" on a closed bed; `null` when no review time was chosen. */ +export function bedReviewLine(mark: ClosedBedMark, now: Instant): string | null { + if (mark.reviewAt === undefined || mark.reopenedAt !== undefined) return null; + return bedReviewDue(mark, now) ? "Review overdue" : `Review ${formatInstantWithDay(mark.reviewAt, now)}`; +} + +export type ClosedBedPastReview = { unit: Unit; mark: ClosedBedMark }; + +/** + * Every bed still closed whose review time has passed, wards in the one neutral order (service, + * then name), beds by number. Nothing is ranked by how late the review is. + */ +export function closedBedsPastReview(units: readonly Unit[], now: Instant): ClosedBedPastReview[] { + return [...units].sort(byServiceThenName).flatMap((unit) => + activeClosedBeds(unit) + .filter((mark) => bedReviewDue(mark, now)) + .sort((a, b) => a.bedNumber - b.bedNumber) + .map((mark) => ({ unit, mark })), + ); +} diff --git a/src/components/ward-management/capacity/capacity-closed-beds-review.tsx b/src/components/ward-management/capacity/capacity-closed-beds-review.tsx new file mode 100644 index 0000000000..a0c777de69 --- /dev/null +++ b/src/components/ward-management/capacity/capacity-closed-beds-review.tsx @@ -0,0 +1,68 @@ +import Link from "next/link"; + +import { buttonClass, Card, CardHead, StatusGlyph } from "@/components/wf"; +import { SpelledCodes } from "@/components/ward-management/ward-abbreviations"; +import { formatInstantWithDay, type Instant } from "@/components/ward-management/ward-clock"; +import type { Unit } from "@/components/ward-management/ward-model"; +import { useCanOpenHref } from "@/components/ward-management/shell/ward-access"; +import { unitHref } from "@/components/ward-management/shell/ward-facade"; + +import { closedBedsPastReview } from "./bed-closure-review"; +import styles from "./capacity-page.module.css"; + +/** + * Item 6 (10 Nov 2026): "Closed beds past review". Every bed still closed whose chosen review time + * has passed, so a bed never stays closed by accident. A prompt only: reopening is the ward's tap on + * its own page, so each row links there. Wards in the neutral service-then-name order. Hidden when + * no closed bed is past its review, as nothing then needs a look. + */ +export function ClosedBedsPastReview({ + units, + now, + executive = false, +}: { + units: readonly Unit[]; + now: Instant; + /** The executive desk opens no ward page, so its rows carry no link. */ + executive?: boolean; +}) { + const canOpen = useCanOpenHref(); + const rows = closedBedsPastReview(units, now); + if (rows.length === 0) return null; + return ( + + +
      + {rows.map(({ unit, mark }) => { + const href = unitHref(unit.id); + return ( +
    • +
      + + · Bed {mark.bedNumber} + + + Closed since {formatInstantWithDay(mark.at, now)} · review was{" "} + {formatInstantWithDay(mark.reviewAt!, now)} + +
      + {!executive && canOpen(href) ? ( + + Open ward + + ) : ( + + Overdue + + )} +
    • + ); + })} +
    +
    + ); +} diff --git a/src/components/ward-management/capacity/capacity-phone-overview.tsx b/src/components/ward-management/capacity/capacity-phone-overview.tsx index 119d988163..c665f8382a 100644 --- a/src/components/ward-management/capacity/capacity-phone-overview.tsx +++ b/src/components/ward-management/capacity/capacity-phone-overview.tsx @@ -11,6 +11,7 @@ import type { CapacityModel } from "./capacity-model"; import { gapWords } from "./capacity-range-line"; import { ReadyByRoadTime } from "./capacity-road-time"; import { ShiftChecks } from "./capacity-shift-checks"; +import { ClosedBedsPastReview } from "./capacity-closed-beds-review"; import styles from "./capacity-page.module.css"; /** @@ -119,6 +120,8 @@ export function PhoneOverview({ )} + {/* Item 6 (10 Nov 2026): beds closed past their review time; hidden when there are none. */} + row.unit)} now={now} executive={executive} /> {model.site ? ( // Site bed manager: every site's ready beds by road time, opened in the ward drawer or the // Other sites counts, because another site's ward page is not this desk's to open. diff --git a/src/components/ward-management/capacity/capacity-screen.tsx b/src/components/ward-management/capacity/capacity-screen.tsx index 91b20b9270..77d67b1e60 100644 --- a/src/components/ward-management/capacity/capacity-screen.tsx +++ b/src/components/ward-management/capacity/capacity-screen.tsx @@ -34,6 +34,7 @@ import { bedMeetingSheet } from "./bed-meeting-derivations"; import { BedForecast } from "./capacity-bed-forecast"; import { BedMapCards, BedMapHighlights, type Highlight } from "./capacity-bed-map-cards"; import { ComingIn } from "./capacity-coming-in"; +import { ClosedBedsPastReview } from "./capacity-closed-beds-review"; import { CapacityHero } from "./capacity-hero"; import { useCapacityModel, type CapacityModel } from "./capacity-model"; import { OtherSitesCounts } from "./capacity-other-sites"; @@ -329,6 +330,12 @@ export function CapacityScreen({ openOnLoad }: { openOnLoad?: string } = {}) { one card, full width. Josh's fifth notes (8 Nov 2026): the row under it is gone; the shift figures are in the hero and an unchanged count on its ward card. */} {planned} + {/* Item 6 (10 Nov 2026): beds closed past their review time; hidden when there are none. */} + row.unit)} + now={mapModel.now} + executive={executive} + /> )} (null); const [reason, setReason] = useState(null); + // Item 6: "Review in", "Not known" by default. The time only prompts; reopening needs a person. + const [reviewAfter, setReviewAfter] = useState(""); // The whole-ward close is the dialog's one primary action, in its foot (stress test, 3 Nov 2026). const [closeWard, setCloseWard] = useState(null); const closed = activeClosedBeds(unit); @@ -56,13 +60,14 @@ export function WardClosuresDialog({ const empty = Array.from({ length: unit.beds }, (_, index) => index + 1).filter( (number) => !taken.has(number) && !closedNumbers.has(number), ); - const setBedClosed = (bedNumber: number, close: boolean, why?: WardClosureReason) => + const setBedClosed = (bedNumber: number, close: boolean, why?: WardClosureReason, reviewAt?: Instant) => raise("SET_BED_CLOSED", { unitId: unit.id, actingUnitId: unit.id, bedNumber, closed: close, ...(close && why ? { reason: why } : {}), + ...(close && reviewAt !== undefined ? { reviewAt } : {}), }); return ( ))} +
    ); } - -/** One closure choice as kit chips; the chosen id rides on `data-value` for the page and its tests. */ -function ClosureChoice({ - label, - testId, - value, - items, - onChange, -}: { - label: string; - testId: string; - value: string; - items: { id: string; label: string }[]; - onChange: (next: string) => void; -}) { - return ( -
    - - - {items.map((item) => ( - onChange(pressed ? item.id : "")} - > - {item.label} - - ))} - -
    - ); -} diff --git a/src/components/ward-management/ward-flow-bed-closure-events.ts b/src/components/ward-management/ward-flow-bed-closure-events.ts index d6b6973026..6c3d496ccc 100644 --- a/src/components/ward-management/ward-flow-bed-closure-events.ts +++ b/src/components/ward-management/ward-flow-bed-closure-events.ts @@ -1,6 +1,6 @@ import type { AuditDecision } from "./ward-audit"; import { bedIsOccupied } from "./ward-admissions"; -import { WARD_CLOSURE_REASON_IDS } from "./capacity/ward-closure"; +import { WARD_CLOSURE_REASON_IDS, WARD_REOPEN_AFTER_HOURS } from "./capacity/ward-closure"; import type { WardFlowEvent } from "./ward-flow-events"; import type { findUnit, heldBedRefusal, reject, replaceUnit, WardFlowState } from "./ward-flow-reducer"; import { WARD_FLOW_ROLE_LABELS } from "./ward-flow-roles"; @@ -73,6 +73,7 @@ export function reduceBedClosureEvent( if (!event.closed) { if (event.reason !== undefined) return reject(state, event, "Reopening a bed takes no reason"); + if (event.reviewAt !== undefined) return reject(state, event, "Reopening a bed takes no review time"); if (!isMarked) return reject(state, event, `Bed ${event.bedNumber} on ${unit.name} is not closed`); decision.outcome = "accepted"; decision.reasonCode = "none"; @@ -94,6 +95,14 @@ export function reduceBedClosureEvent( if (event.reason !== undefined && !WARD_CLOSURE_REASON_IDS.includes(event.reason)) { return reject(state, event, "SET_BED_CLOSED reason must be chosen from WARD_CLOSURE_REASONS"); } + // Item 6: validated as a whole ward's expected reopening (`until`), capped at the longest + // listed choice since the clock can tick between showing the choices and the tap. + if (event.reviewAt !== undefined && !(typeof event.reviewAt === "number" && event.reviewAt > event.now)) { + return reject(state, event, "The review time must be later than now"); + } + if (event.reviewAt !== undefined && event.reviewAt - event.now > Math.max(...WARD_REOPEN_AFTER_HOURS) * 60) { + return reject(state, event, "The review time must be within the listed choices"); + } if (bedsWithAStay(state, unit).has(event.bedNumber)) { return reject(state, event, `Bed ${event.bedNumber} on ${unit.name} has someone in it, so it cannot be closed`); } @@ -113,6 +122,7 @@ export function reduceBedClosureEvent( at: event.now, by: WARD_FLOW_ROLE_LABELS[event.role], ...(event.reason !== undefined ? { reason: event.reason } : {}), + ...(event.reviewAt !== undefined ? { reviewAt: event.reviewAt } : {}), }, ], }; diff --git a/src/components/ward-management/ward-flow-events.ts b/src/components/ward-management/ward-flow-events.ts index a9965b2885..c1dca556fd 100644 --- a/src/components/ward-management/ward-flow-events.ts +++ b/src/components/ward-management/ward-flow-events.ts @@ -816,8 +816,7 @@ export type WardFlowEvent = role: WardFlowRole; now: Instant; unitId: string; - /** The ward's own claim, refused when it is not `unitId`. Absent for the coordinator. */ - actingUnitId?: string; + actingUnitId?: string; // The ward's own claim, refused when it is not `unitId`. Absent for the coordinator. reason: WardClosureReason; until?: Instant; } @@ -839,6 +838,7 @@ export type WardFlowEvent = bedNumber: number; closed: boolean; reason?: WardClosureReason; + reviewAt?: Instant; // Item 6: look again then, a prompt only. Closing only; capped as a ward's `until`. } | { /** diff --git a/src/components/ward-management/ward-flow-persistence-classification.ts b/src/components/ward-management/ward-flow-persistence-classification.ts index 8dda079295..b945c91d5a 100644 --- a/src/components/ward-management/ward-flow-persistence-classification.ts +++ b/src/components/ward-management/ward-flow-persistence-classification.ts @@ -179,7 +179,8 @@ const WARD_FLOW_TEXT_SAFE_EVENT_TYPE_TUPLE = [ "CLEAR_MOVEMENT_URGENT_FLAG", "CLOSE_WARD_TO_ADMISSIONS", "REOPEN_WARD_TO_ADMISSIONS", - // H-0978: ids, a whole bed number, a boolean and a choice from WARD_CLOSURE_REASONS. No typed text. + // H-0978: ids, a whole bed number, a boolean, a choice from WARD_CLOSURE_REASONS and (item 6) a + // review time chosen from WARD_REOPEN_AFTER_HOURS, read to an `Instant`. No typed text. "SET_BED_CLOSED", "RECORD_BED_MEETING_ACTION", "COMPLETE_BED_MEETING_ACTION", diff --git a/src/components/ward-management/ward-flow-storage-validation.ts b/src/components/ward-management/ward-flow-storage-validation.ts index 4e46ad3650..8d9a7e26b1 100644 --- a/src/components/ward-management/ward-flow-storage-validation.ts +++ b/src/components/ward-management/ward-flow-storage-validation.ts @@ -383,6 +383,8 @@ const unit = z reason: z.optional( z.enum(WARD_CLOSURE_REASONS.map((entry) => entry.id) as [WardClosureReason, ...WardClosureReason[]]), ), + // Item 6: when to look at it again, a prompt only. Optional, so older marks still load. + reviewAt: z.optional(finite), // A reopened mark stays as seat history; it reopened no earlier than it closed. reopenedAt: z.optional(finite), }), diff --git a/src/components/ward-management/ward-loop-checks-demo.ts b/src/components/ward-management/ward-loop-checks-demo.ts index eafb7e6893..9140786537 100644 --- a/src/components/ward-management/ward-loop-checks-demo.ts +++ b/src/components/ward-management/ward-loop-checks-demo.ts @@ -1,5 +1,8 @@ +import { bedIsOccupied, type Admission } from "./ward-admissions"; import type { Instant } from "./ward-clock"; import type { Movement, Unit } from "./ward-model"; +import { boundedBedAvailability } from "./ward-ready-beds"; +import { wardStaySeats } from "./ward/ward-bed-layout"; /** * CLOSING THE LOOP, PACKAGE D (24 Oct 2026): synthetic demo marks, so each record is visible in the @@ -11,20 +14,45 @@ import type { Movement, Unit } from "./ward-model"; * - Graylands Adult Secure has an intake limit with a review due later today. * - Graylands Older Adult is closed for staffing with an overdue review, so its transition list * shows what still points at it. + * - Item 6 (10 Nov 2026): Maali (Ward 4.2) has its top empty bed closed for staffing four hours ago + * with a review time an hour ago, so "Closed beds past review" shows on Capacity and the bed + * meeting agenda. It takes one Ready bed, as any closed bed does. */ const CANCELLED_MOVEMENT_ID = "WF-008"; const CONDITIONS_MOVEMENT_ID = "WF-003"; const BED_AFTER_MOVEMENT_ID = "WF-023"; const LIMITED_UNIT_ID = "gry-adult-secure"; const CLOSED_UNIT_ID = "gry-older-adult"; +const CLOSED_BED_UNIT_ID = "fre-adult-open"; const COORDINATOR = "Bed coordinator"; const WARD = "Ward manager"; -export function applyLoopChecksDemoOverlay( - state: T, - now: Instant, -): T { +/** + * The highest bed on the ward with no stay in it and none held, while the ward keeps another Ready + * bed; `undefined` leaves the ward alone, so a changed fixture never breaks the seed. + */ +function demoClosedBed(unit: Unit, movements: readonly Movement[], admissions: readonly Admission[]) { + if (unit.isClosed || (unit.closedBeds ?? []).length > 0 || boundedBedAvailability(unit).available < 2) return; + const seats = wardStaySeats(unit, admissions); + const taken = new Set(); + for (const stay of admissions) { + if (stay.unitId !== unit.id || !bedIsOccupied(stay)) continue; + const bed = stay.bedNumber ?? seats.get(stay.id); + if (bed !== undefined) taken.add(bed); + } + for (const movement of movements) { + if (!movement.closure && movement.acceptedUnitId === unit.id && movement.heldBedNumber !== undefined) { + taken.add(movement.heldBedNumber); + } + } + for (let bed = unit.beds; bed >= 1; bed -= 1) if (!taken.has(bed)) return bed; + return undefined; +} + +export function applyLoopChecksDemoOverlay< + T extends { movements: Movement[]; units: Unit[]; admissions?: readonly Admission[] }, +>(state: T, now: Instant): T { const movements = state.movements.map((movement): Movement => { if (movement.id === CANCELLED_MOVEMENT_ID && movement.closure?.outcome === "did_not_proceed") { const at = movement.closure.at; @@ -109,6 +137,14 @@ export function applyLoopChecksDemoOverlay = new Set([ // WS-15: when a closed bed was reopened. The mark keeps it so a stay seated during the closure // stays put. "reopenedAt", + // Item 6: when to look at a closed bed again, a prompt only. + "reviewAt", ]); /** diff --git a/src/components/ward-management/ward/ward-bed-closure-controls.tsx b/src/components/ward-management/ward/ward-bed-closure-controls.tsx index 543fd5a291..1fccb49d24 100644 --- a/src/components/ward-management/ward/ward-bed-closure-controls.tsx +++ b/src/components/ward-management/ward/ward-bed-closure-controls.tsx @@ -1,7 +1,8 @@ "use client"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Button } from "@/components/wf"; +import { ClosureChoice, REVIEW_AFTER_CHOICES } from "../capacity/closure-choice"; import styles from "./ward-bed-dossier-drawer.module.css"; /** @@ -14,17 +15,23 @@ import styles from "./ward-bed-dossier-drawer.module.css"; * The ward screen decides which taps exist (a ready bed that isn't held may close, a bed closed by * number may reopen) and leaves both out for a desk that may not raise the event. Each is raised from the * tap here and never from an effect or a timer. Kept in its own file so the bed drawer stays small. + * + * Item 6 (10 Nov 2026): closing offers "Review in", "Not known" by default, so the bed is looked at + * again. The time only prompts ("Review 14:00", "Review overdue"); reopening is always this tap. */ export type BedClosureActions = { - /** Set only for a ready bed this desk may close. */ - onCloseBed?: () => void; + /** Set only for a ready bed this desk may close. `reviewAfter` is the "Review in" id ("" for Not known). */ + onCloseBed?: (reviewAfter: string) => void; /** Set only for a bed closed by number that this desk may reopen. */ onReopenBed?: () => void; + /** On a bed closed by number: "Review 14:00" or "Review overdue", when a review time was chosen. */ + reviewLine?: string | null; }; export function BedClosureControls({ onCloseBed, onReopenBed, + reviewLine, size, children, }: BedClosureActions & { @@ -33,9 +40,19 @@ export function BedClosureControls({ /** The phone sheet's role-lock note, which always shows straight after these taps. */ children?: ReactNode; }) { + const [reviewAfter, setReviewAfter] = useState(""); if (!onCloseBed && !onReopenBed) return <>{children}; return ( <> + {onCloseBed ? ( + + ) : null} {onCloseBed ? ( @@ -66,8 +86,8 @@ export function BedClosureControls({

    ) : null} {onReopenBed ? ( -

    - Reopening offers this bed again. +

    + {reviewLine ? `${reviewLine}. ` : ""}Reopening offers this bed again.

    ) : null} {children} diff --git a/src/components/ward-management/ward/ward-bed-tone.ts b/src/components/ward-management/ward/ward-bed-tone.ts index b6a623f1dd..7455e6ccd3 100644 --- a/src/components/ward-management/ward/ward-bed-tone.ts +++ b/src/components/ward-management/ward/ward-bed-tone.ts @@ -52,6 +52,8 @@ export interface BedItem { isPulled?: boolean; isSelected?: boolean; isClosed?: boolean; + /** Item 6: on a bed closed by number with a review time, "Review 14:00" or "Review overdue". */ + closedReview?: string; podId?: string; podLabel?: string; designation?: string; diff --git a/src/components/ward-management/ward/ward-beds-matrix.tsx b/src/components/ward-management/ward/ward-beds-matrix.tsx index 54be005d67..67700c5d23 100644 --- a/src/components/ward-management/ward/ward-beds-matrix.tsx +++ b/src/components/ward-management/ward/ward-beds-matrix.tsx @@ -128,6 +128,7 @@ function areBedCardPropsEqual(prev: WardBedCardProps, next: WardBedCardProps): b p.expectedDays === n.expectedDays && p.departureExpectedAt === n.departureExpectedAt && p.isClosed === n.isClosed && + p.closedReview === n.closedReview && p.isPulled === n.isPulled && p.pastDate === n.pastDate && p.admissionId === n.admissionId && @@ -225,7 +226,9 @@ const WardBedCard = React.memo(function WardBedCard({ bed, isSelected, onSelect who = ( <> Not offered - Closed until it is reopened + + {bed.closedReview ? `Closed until reopened · ${bed.closedReview}` : "Closed until it is reopened"} + ); } else if (isPulled) { diff --git a/src/components/ward-management/ward/ward-screen-derivations.ts b/src/components/ward-management/ward/ward-screen-derivations.ts index 75ab148605..fdb0adee9d 100644 --- a/src/components/ward-management/ward/ward-screen-derivations.ts +++ b/src/components/ward-management/ward/ward-screen-derivations.ts @@ -35,6 +35,7 @@ import { patientAgeYears } from "@/components/ward-management/ward-patients"; import { WARD_LOCKED_BED_SPLITS } from "@/components/ward-management/ward-sites"; import { layoutBedNumbers, wardStaySeats } from "./ward-bed-layout"; import { activeClosedBeds } from "@/components/ward-management/ward-ready-beds"; +import { bedReviewLine } from "../capacity/bed-closure-review"; import { arrivalIsLate } from "./ward-screen-helpers"; import type { Admission } from "@/components/ward-management/ward-admissions"; import type { BedRelease, LeaveBed, Movement, Unit } from "@/components/ward-management/ward-model"; @@ -394,9 +395,12 @@ export function wardBedRoster({ const releaseId = preparingByBed.has(bedNumber) ? undefined : unplacedReleases.shift(); if (releaseId !== undefined) preparingByBed.set(bedNumber, releaseId); } + // Item 6: a bed closed by number carries its review line ("Review 14:00", "Review overdue"). + const closedMarks = new Map(activeClosedBeds(unit).map((mark) => [mark.bedNumber, mark])); const bedsList = rawBeds .map((bed, index) => { const bedNumber = bedNumbers[index]; + const closedMark = bed.status === "closed" ? closedMarks.get(bedNumber) : undefined; const preparingReleaseId = bed.status === "ready" ? preparingByBed.get(bedNumber) : undefined; const designation: "Locked" | "Open" | undefined = isMixed ? bedNumber <= lockedBeds @@ -414,6 +418,7 @@ export function wardBedRoster({ preparing: preparingReleaseId !== undefined, preparingReleaseId, statusText: preparingReleaseId !== undefined ? BED_STATE_LABELS.beingMadeReady : bed.statusText, + closedReview: closedMark ? (bedReviewLine(closedMark, now) ?? undefined) : undefined, }; }) .sort((left, right) => left.bedNumber - right.bedNumber); diff --git a/src/components/ward-management/ward/ward-screen-overlays.tsx b/src/components/ward-management/ward/ward-screen-overlays.tsx index b87014a175..96ce539add 100644 --- a/src/components/ward-management/ward/ward-screen-overlays.tsx +++ b/src/components/ward-management/ward/ward-screen-overlays.tsx @@ -6,6 +6,8 @@ import type { LeavingDestination } from "@/components/ward-management/ward-admissions"; import type { BedReleaseBlocker } from "@/components/ward-management/ward-change-reasons"; import { activeClosedBeds } from "@/components/ward-management/ward-ready-beds"; +import { bedReviewLine } from "../capacity/bed-closure-review"; +import { reviewAtFrom } from "../capacity/closure-choice"; import { admissionCriteriaEligibility } from "@/components/ward-management/ward-flow-reducer"; import { useWardCommandCompletion, wardCommandFailure } from "./use-ward-command-completion"; import { useUndoableStep } from "../use-undoable-step"; @@ -145,9 +147,20 @@ export function WardScreenBedDrawer({ !pathname || routeRole === "ward" || routeRole === "bed_manager" || routeRole === "coordinator"; // Tier 1 item 6 (Josh, 4 Nov 2026): closing or reopening a bed offers Undo on its toast. const bedClosureRole: WardFlowRole = !pathname || routeRole === "ward" ? "ward" : routeRole; - const setBedClosed = (bedNumber: number, closed: boolean) => - runBedStep( - { type: "SET_BED_CLOSED", role: bedClosureRole, now, unitId: unit.id, actingUnitId: unitId, bedNumber, closed }, + const setBedClosed = (bedNumber: number, closed: boolean, reviewAfter = "") => { + // Item 6: "Review in" read to a time at the tap; "Not known" leaves it out. + const reviewAt = closed ? reviewAtFrom(reviewAfter, now) : undefined; + return runBedStep( + { + type: "SET_BED_CLOSED", + role: bedClosureRole, + now, + unitId: unit.id, + actingUnitId: unitId, + bedNumber, + closed, + ...(reviewAt !== undefined ? { reviewAt } : {}), + }, { success: closed ? `Bed ${bedNumber} closed. It is not offered until it is reopened.` @@ -163,11 +176,13 @@ export function WardScreenBedDrawer({ undone: closed ? `Undone. Bed ${bedNumber} is open again.` : `Undone. Bed ${bedNumber} is closed again.`, }, ); + }; return selectedBed !== null ? (() => { const selectedBedItem = bedsList.find((b) => b.bedNumber === selectedBed); const selectedAdmission = admissions.find((admission) => admission.id === selectedBedItem?.admissionId); - const closedByNumber = activeClosedBeds(unit).some((mark) => mark.bedNumber === selectedBed); + const closedMark = activeClosedBeds(unit).find((mark) => mark.bedNumber === selectedBed); + const closedByNumber = closedMark !== undefined; return ( setBedClosed(selectedBed, true) + ? (reviewAfter: string) => setBedClosed(selectedBed, true, reviewAfter) : undefined, onReopenBed: bedClosureAllowed && selectedBedItem?.status === "closed" && closedByNumber ? () => setBedClosed(selectedBed, false) : undefined, + reviewLine: closedMark ? bedReviewLine(closedMark, now) : null, }} bedDrawerRef={bedDrawerRef} onKeyDown={handleBedDrawerKeyDown} diff --git a/tests/helpers/ward-engine-fuzz.ts b/tests/helpers/ward-engine-fuzz.ts index fbe4cbb6ec..cafc9a563c 100644 --- a/tests/helpers/ward-engine-fuzz.ts +++ b/tests/helpers/ward-engine-fuzz.ts @@ -1140,7 +1140,11 @@ const MORE_BUILDERS: readonly (readonly [weight: number, builder: Builder])[] = actingUnitId: id(unit), bedNumber: beds - int(random, 0, Math.min(2, beds - 1)), closed: true, - ...(chance(random, 0.5) ? { reason: pick(random, WARD_CLOSURE_REASON_IDS) } : {}), + // Item 6: a review time rides with a chosen reason, with no extra draw, so the runs aimed + // before it was added stay where they were. + ...(chance(random, 0.5) + ? { reason: pick(random, WARD_CLOSURE_REASON_IDS), reviewAt: currentNow + WARD_REOPEN_AFTER_HOURS[0] * 60 } + : {}), }; }, ], diff --git a/tests/ward-bed-closed-mark-review.test.ts b/tests/ward-bed-closed-mark-review.test.ts new file mode 100644 index 0000000000..17d93b326b --- /dev/null +++ b/tests/ward-bed-closed-mark-review.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import { bedIsOccupied } from "@/components/ward-management/ward-admissions"; +import { bedStates } from "@/components/ward-management/ward-bed-states"; +import { + bedReviewDue, + bedReviewLine, + closedBedsPastReview, +} from "@/components/ward-management/capacity/bed-closure-review"; +import { reviewAtFrom, REVIEW_AFTER_CHOICES } from "@/components/ward-management/capacity/closure-choice"; +import { WARD_REOPEN_AFTER_HOURS } from "@/components/ward-management/capacity/ward-closure"; +import { closedBedsReviewSection } from "@/components/ward-management/bed-meeting/bed-meeting-agenda"; +import { seedWardFlowState, wardFlowReducer, type WardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import { isValidStoredWardFlowState } from "@/components/ward-management/ward-flow-storage-validation"; +import type { Unit } from "@/components/ward-management/ward-model"; +import { boundedBedAvailability } from "@/components/ward-management/ward-ready-beds"; +import { NOW_ANCHOR, siteByCode } from "@/components/ward-management/ward-sites"; +import { wardStaySeats } from "@/components/ward-management/ward/ward-bed-layout"; + +/** + * Item 6 (10 Nov 2026), bed closures with a review time: closing one bed may carry "look at this + * again" (`reviewAt`). It only prompts: nothing reopens by itself, and Ready is the same with or + * without it. Synthetic seeded state only. + */ +const NOW = NOW_ANCHOR; +const MAX_MINUTES = Math.max(...WARD_REOPEN_AFTER_HOURS) * 60; + +function takenBeds(state: WardFlowState, unit: Unit): Set { + const stays = state.admissions.filter((admission) => admission.unitId === unit.id); + const seats = wardStaySeats(unit, stays); + return new Set( + stays.flatMap((stay) => { + const bed = bedIsOccupied(stay) ? (stay.bedNumber ?? seats.get(stay.id)) : undefined; + return bed === undefined ? [] : [bed]; + }), + ); +} + +function setup() { + const state = seedWardFlowState(); + const unit = state.units.find( + (candidate) => + (candidate.closedBeds ?? []).length === 0 && + boundedBedAvailability(candidate).available >= 2 && + takenBeds(state, candidate).size < candidate.beds, + )!; + const taken = takenBeds(state, unit); + const freeBed = [...Array(unit.beds).keys()].map((i) => unit.beds - i).find((bed) => !taken.has(bed))!; + return { state, unit, freeBed }; +} + +const close = (state: WardFlowState, unitId: string, bedNumber: number, extra: { reviewAt?: number } = {}) => + wardFlowReducer(state, { + type: "SET_BED_CLOSED", + role: "ward", + now: NOW, + unitId, + actingUnitId: unitId, + bedNumber, + closed: true, + ...extra, + }); + +const unitOf = (state: WardFlowState, unitId: string) => state.units.find((unit) => unit.id === unitId)!; +const readyOf = (state: WardFlowState, unitId: string) => + bedStates(unitOf(state, unitId), state.admissions, state.bedReleases, state.leaveBeds).ready; + +describe("SET_BED_CLOSED with a review time", () => { + it("records the review time and leaves Ready exactly as a closure without one", () => { + const { state, unit, freeBed } = setup(); + const plain = close(state, unit.id, freeBed); + const reviewed = close(state, unit.id, freeBed, { reviewAt: NOW + 4 * 60 }); + expect(reviewed.rejections).toEqual(state.rejections); + expect(unitOf(reviewed, unit.id).closedBeds).toEqual([ + { bedNumber: freeBed, at: NOW, by: expect.any(String), reviewAt: NOW + 240 }, + ]); + expect(readyOf(reviewed, unit.id)).toBe(readyOf(plain, unit.id)); + expect(boundedBedAvailability(unitOf(reviewed, unit.id))).toEqual(boundedBedAvailability(unitOf(plain, unit.id))); + // Past the review time the bed is still closed and Ready still the same: the time only prompts. + const later = { ...reviewed, now: NOW + 300 }; + expect(readyOf(later, unit.id)).toBe(readyOf(plain, unit.id)); + expect(unitOf(later, unit.id).closedBeds?.[0]?.reopenedAt).toBeUndefined(); + }); + + it("refuses a review time not later than now or beyond the longest choice", () => { + const { state, unit, freeBed } = setup(); + for (const reviewAt of [NOW, NOW - 1, NOW + MAX_MINUTES + 1, Number.NaN]) { + const refused = close(state, unit.id, freeBed, { reviewAt }); + expect(refused.rejections).toHaveLength(state.rejections.length + 1); + expect(unitOf(refused, unit.id).closedBeds).toBeUndefined(); + } + expect(close(state, unit.id, freeBed, { reviewAt: NOW + MAX_MINUTES }).rejections).toEqual(state.rejections); + }); + + it("refuses a review time on a reopening, as it refuses a reason", () => { + const { state, unit, freeBed } = setup(); + const closed = close(state, unit.id, freeBed, { reviewAt: NOW + 60 }); + const refused = wardFlowReducer(closed, { + type: "SET_BED_CLOSED", + role: "ward", + now: NOW, + unitId: unit.id, + actingUnitId: unit.id, + bedNumber: freeBed, + closed: false, + reviewAt: NOW + 60, + }); + expect(refused.rejections.at(-1)?.reason).toMatch(/takes no review time/); + expect(unitOf(refused, unit.id).closedBeds?.[0]?.reopenedAt).toBeUndefined(); + }); +}); + +describe("closed bed review prompts", () => { + const mark = { bedNumber: 3, at: NOW - 60, by: "Ward manager" }; + + it("is due only once the review time has come on a bed still closed", () => { + expect(bedReviewDue(mark, NOW)).toBe(false); + expect(bedReviewLine(mark, NOW)).toBeNull(); + expect(bedReviewDue({ ...mark, reviewAt: NOW + 30 }, NOW)).toBe(false); + expect(bedReviewLine({ ...mark, reviewAt: NOW + 30 }, NOW)).toMatch(/^Review \d\d:\d\d$/); + expect(bedReviewDue({ ...mark, reviewAt: NOW }, NOW)).toBe(true); + expect(bedReviewLine({ ...mark, reviewAt: NOW - 5 }, NOW)).toBe("Review overdue"); + expect(bedReviewDue({ ...mark, reviewAt: NOW - 5, reopenedAt: NOW - 1 }, NOW)).toBe(false); + }); + + it("offers Not known first, which leaves the time out", () => { + expect(REVIEW_AFTER_CHOICES[0]).toEqual({ id: "", label: "Not known" }); + expect(REVIEW_AFTER_CHOICES.map((choice) => choice.id).slice(1)).toEqual(WARD_REOPEN_AFTER_HOURS.map(String)); + expect(reviewAtFrom("", NOW)).toBeUndefined(); + expect(reviewAtFrom("8", NOW)).toBe(NOW + 480); + }); + + it("lists closed beds past review in service then ward-name order, never by lateness", () => { + const units = seedWardFlowState().units.map((unit) => ({ ...unit, closedBeds: [] as Unit["closedBeds"] })); + const [first, second] = [units[units.length - 1]!, units[0]!]; + first.closedBeds = [{ ...mark, reviewAt: NOW - 1 }]; + second.closedBeds = [ + { ...mark, bedNumber: 5, reviewAt: NOW - 600 }, + { ...mark, bedNumber: 2, reviewAt: NOW + 60 }, + ]; + const rows = closedBedsPastReview(units, NOW); + expect(rows.map((row) => row.mark.bedNumber).sort()).toEqual([3, 5]); + const key = (unit: Unit) => `${siteByCode(unit.siteCode)?.service ?? ""}|${unit.name}`; + const keys = rows.map((row) => key(row.unit)); + expect(keys).toEqual([...keys].sort((a, b) => a.localeCompare(b))); + }); +}); + +describe("seeded demo closed bed", () => { + it("has one closed bed past its review on the standard seed, shown on the agenda", () => { + const state = seedWardFlowState(); + const rows = closedBedsPastReview(state.units, NOW); + expect(rows.length).toBeGreaterThan(0); + const section = closedBedsReviewSection(state.units, NOW); + expect(section.total).toBe(rows.length); + expect(section.items[0]).toMatchObject({ unitId: rows[0]!.unit.id, bedNumber: rows[0]!.mark.bedNumber }); + // The seeded bed has nobody in it, and the ward still has a Ready bed. + const unit = rows[0]!.unit; + expect(takenBeds(state, unit).has(rows[0]!.mark.bedNumber)).toBe(false); + expect(boundedBedAvailability(unit).available).toBeGreaterThan(0); + }); + + it("loads a stored state with a review time and an older mark without one", () => { + const state = JSON.parse(JSON.stringify(seedWardFlowState())) as WardFlowState; + const unit = state.units.find((candidate) => (candidate.closedBeds ?? []).length > 0)!; + expect(isValidStoredWardFlowState(state)).toBe(true); + const older = { ...unit.closedBeds![0]! }; + delete older.reviewAt; + unit.closedBeds = [older]; + expect(isValidStoredWardFlowState(state)).toBe(true); + unit.closedBeds = [{ ...older, reviewAt: "soon" as unknown as number }]; + expect(isValidStoredWardFlowState(state)).toBe(false); + }); +}); diff --git a/tests/ward-bed-closed-mark.dom.test.tsx b/tests/ward-bed-closed-mark.dom.test.tsx index 28d98dcbe2..af0a59ca1d 100644 --- a/tests/ward-bed-closed-mark.dom.test.tsx +++ b/tests/ward-bed-closed-mark.dom.test.tsx @@ -74,3 +74,31 @@ describe("closing the exact bed from the bed drawer", () => { expect(bedsIn("ready")).toHaveLength(ready.length); }); }); + +describe("closing a bed with a review time (item 6)", () => { + it("records Review in, shows the review on the closed bed, and leaves Ready as a plain closure", () => { + render( + + + + + + , + ); + const ready = bedsIn("ready"); + const bed = Math.min(...ready); + fireEvent.click(card(bed)); + // Nothing is chosen for the person beyond "Not known", which leaves the time out. + const choice = screen.getByTestId("ward-drawer-close-bed-review"); + expect(choice).toHaveAttribute("data-value", ""); + fireEvent.click(screen.getByRole("button", { name: "In 4 hours" })); + expect(choice).toHaveAttribute("data-value", "4"); + fireEvent.click(screen.getByTestId("ward-drawer-close-bed")); + + const now = flow!.now; + const mark = flow!.units.find((unit) => unit.id === UNIT)!.closedBeds!.at(-1)!; + expect(mark).toMatchObject({ bedNumber: bed, reviewAt: now + 240 }); + expect(bedsIn("ready")).toHaveLength(ready.length - 1); + expect(screen.getByTestId("ward-drawer-reopen-bed-note")).toHaveTextContent(/^Review \d\d:\d\d\. Reopening/); + }); +}); diff --git a/tests/ward-bed-meeting-agenda.dom.test.tsx b/tests/ward-bed-meeting-agenda.dom.test.tsx index f5bc92c2c7..3db37e49f4 100644 --- a/tests/ward-bed-meeting-agenda.dom.test.tsx +++ b/tests/ward-bed-meeting-agenda.dom.test.tsx @@ -315,6 +315,20 @@ describe("bed meeting agenda group filter (B1 extended, 4 Nov 2026)", () => { expect(screen.queryByTestId("bed-meeting-agenda-groups-applied")).toBeNull(); expect(rows()).toBe(before); }); + + it("lists the seeded closed bed past its review, with a link to its ward (item 6)", () => { + render( + + + , + ); + const section = screen.getByTestId("bed-meeting-agenda-closed-beds-review"); + expect(within(section).getByRole("heading", { name: /Closed beds past review/ })).toBeInTheDocument(); + expect(section).toHaveTextContent(/Bed \d+/); + expect(section).toHaveTextContent(/Closed since .* · review was /); + const link = within(section).getAllByRole("link", { name: /^Open .* to review bed \d+$/ })[0]!; + expect(link.getAttribute("href")).toMatch(/^\/ward-flow\/ward\/[a-z-]+$/); + }); }); /** diff --git a/tests/ward-board-triage.dom.test.tsx b/tests/ward-board-triage.dom.test.tsx index fbfdc00ad2..55d201f336 100644 --- a/tests/ward-board-triage.dom.test.tsx +++ b/tests/ward-board-triage.dom.test.tsx @@ -101,7 +101,9 @@ describe("ward board triage bar — the home page's six figures, in the home pag for (const unit of allUnits) { const { unmount } = renderWardBoard(unit.id); - const breakdown = capacityBreakdown(unit, [...releases], [...seededState.leaveBeds], WARD_ADMISSIONS_ANCHOR); + // The seeded unit, as the board reads it: the demo seed closes one bed by number (item 6). + const seeded = seededState.units.find((candidate) => candidate.id === unit.id) ?? unit; + const breakdown = capacityBreakdown(seeded, [...releases], [...seededState.leaveBeds], WARD_ADMISSIONS_ANCHOR); for (const key of FIGURE_KEYS) { const figure = figureFor(key); @@ -139,7 +141,9 @@ describe("ward board triage bar — the toggle changes emphasis and hides nothin for (const unit of allUnits) { const { unmount } = renderWardBoard(unit.id); - const breakdown = capacityBreakdown(unit, [...releases], [...seededState.leaveBeds], WARD_ADMISSIONS_ANCHOR); + // The seeded unit, as the board reads it: the demo seed closes one bed by number (item 6). + const seeded = seededState.units.find((candidate) => candidate.id === unit.id) ?? unit; + const breakdown = capacityBreakdown(seeded, [...releases], [...seededState.leaveBeds], WARD_ADMISSIONS_ANCHOR); const rowsNow = () => screen.queryAllByTestId(/^ward-board-outgoing-derived-/).length; diff --git a/tests/ward-capacity-closed-beds-review.dom.test.tsx b/tests/ward-capacity-closed-beds-review.dom.test.tsx new file mode 100644 index 0000000000..c638d74b26 --- /dev/null +++ b/tests/ward-capacity-closed-beds-review.dom.test.tsx @@ -0,0 +1,38 @@ +import "@testing-library/jest-dom/vitest"; +import { render, screen, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +import { ClosedBedsPastReview } from "@/components/ward-management/capacity/capacity-closed-beds-review"; +import { seedWardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; + +/** Item 6 (10 Nov 2026): Capacity's "Closed beds past review" card. Synthetic seed only. */ +describe("Closed beds past review card", () => { + it("lists the seeded closed bed past review with ward, bed, times and a ward link", () => { + render(); + const card = screen.getByTestId("capacity-closed-beds-review"); + expect(within(card).getByText("Closed beds past review")).toBeInTheDocument(); + expect(card).toHaveTextContent(/Bed \d+/); + expect(card).toHaveTextContent(/Closed since .* · review was /); + }); + + it("is hidden when no closed bed is past its review", () => { + const units = seedWardFlowState().units.map((unit) => ({ ...unit, closedBeds: [] })); + render(); + expect(screen.queryByTestId("capacity-closed-beds-review")).toBeNull(); + }); + + it("carries no ward link for the executive desk", () => { + render(); + expect(within(screen.getByTestId("capacity-closed-beds-review")).queryByRole("link")).toBeNull(); + }); +}); diff --git a/tests/ward-cross-mode-wiring-audit.dom.test.tsx b/tests/ward-cross-mode-wiring-audit.dom.test.tsx index 67c1d8e7c2..7d31525290 100644 --- a/tests/ward-cross-mode-wiring-audit.dom.test.tsx +++ b/tests/ward-cross-mode-wiring-audit.dom.test.tsx @@ -106,7 +106,8 @@ describe("real provider updates all subscribed modes", () => { }); expect(current.rejections.length).toBe(refusals); }; - for (const value of [0, 1, 0, 2, 1]) { + // Ends above one: the seed closes one bed on this ward (item 6), so a confirmed 1 shows 0 Ready. + for (const value of [0, 1, 0, 1, 2]) { confirm(value); const ready = Number(screen.getByTestId("beds-ready").textContent); expect(JSON.parse(screen.getByTestId("forecast").textContent!).readyNow).toBe(ready); From 21df5db27292c31462036fc81c533b9e6e948b5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 17:17:31 +0000 Subject: [PATCH 05/42] Add item 18: Left the ward, waiting for transport A discharge still waiting on transport can be recorded as "Left the ward" on the ward board. It is the ordinary departure (RECORD_LEAVING or RECORD_PATIENT_DISCHARGE with awaitingTransport), so the bed frees once through departAdmission and Ready rises by one on every screen (D-115), with no "Being made ready" step (Josh). The stay keeps waitingForTransport { since, by } and shows on a new "Waiting for transport" list on the board and in Discharges > Recorded until MARK_TRANSPORT_COLLECTED. Collected and the departure both undo through UNDO_DEPARTURE. departAdmission and recordedByLabel move to ward-flow-departure.ts and OverridableWardFlowEvent to ward-flow-overridable-events.ts, unchanged, to keep the reducer and event union under their line caps. Seed: AD-LEFT-01 (synthetic) is waiting for transport. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK --- .../ward-management/board/board.module.css | 12 + .../board/ward-board-transport-wait.tsx | 68 ++++++ .../ward-management/board/ward-board.tsx | 79 +++++- .../discharges/discharge-board-model.ts | 11 + .../discharges/discharge-board.tsx | 6 +- .../ward-management/ward-admissions-seed.ts | 5 + .../ward-management/ward-admissions.ts | 9 + .../ward-management/ward-discharge-records.ts | 3 + .../ward-management/ward-flow-departure.ts | 138 +++++++++++ .../ward-management/ward-flow-event-roles.ts | 2 + .../ward-management/ward-flow-events.ts | 27 +- .../ward-flow-overridable-events.ts | 25 ++ .../ward-flow-persistence-classification.ts | 2 + .../ward-flow-protected-record-events.ts | 7 +- .../ward-management/ward-flow-provider.tsx | 7 +- .../ward-flow-record-events.ts | 2 + .../ward-flow-record-stay-events.ts | 43 +++- .../ward-management/ward-flow-reducer.ts | 137 +---------- .../ward-flow-storage-validation.ts | 8 +- .../ward-management/ward-flow-subject-id.ts | 1 + .../ward-management/ward-history.ts | 1 + .../ward-management/ward-left-ward.ts | 88 +++++++ src/components/ward-management/ward-undo.ts | 3 +- tests/helpers/ward-engine-fuzz.ts | 7 + tests/helpers/ward-legal-figure-sweep.ts | 2 + tests/ward-admission-model.test.ts | 5 + tests/ward-board-left-ward.dom.test.tsx | 82 +++++++ tests/ward-event-permissions.test.ts | 2 + tests/ward-history.test.ts | 3 +- tests/ward-left-ward-transport.test.ts | 231 ++++++++++++++++++ tests/ward-one-number-across-screens.test.ts | 49 ++++ tests/ward-referral-model.test.ts | 2 + 32 files changed, 899 insertions(+), 168 deletions(-) create mode 100644 src/components/ward-management/board/ward-board-transport-wait.tsx create mode 100644 src/components/ward-management/ward-flow-departure.ts create mode 100644 src/components/ward-management/ward-flow-overridable-events.ts create mode 100644 src/components/ward-management/ward-left-ward.ts create mode 100644 tests/ward-board-left-ward.dom.test.tsx create mode 100644 tests/ward-left-ward-transport.test.ts diff --git a/src/components/ward-management/board/board.module.css b/src/components/ward-management/board/board.module.css index f5f8d7f741..55616c0688 100644 --- a/src/components/ward-management/board/board.module.css +++ b/src/components/ward-management/board/board.module.css @@ -6174,3 +6174,15 @@ letter-spacing: 0.06em; text-transform: uppercase; } + +/* Item 18: the "Waiting for transport" list's heading, laid out like the work band's summary but not + a control, so it has no cursor or tap cover. */ +.transportWaitHead { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--ward-space-4) var(--ward-space-12); + padding: 0.625rem 1rem; + border-bottom: 1px solid var(--line); + background: var(--surface-2); +} diff --git a/src/components/ward-management/board/ward-board-transport-wait.tsx b/src/components/ward-management/board/ward-board-transport-wait.tsx new file mode 100644 index 0000000000..29058d0c60 --- /dev/null +++ b/src/components/ward-management/board/ward-board-transport-wait.tsx @@ -0,0 +1,68 @@ +"use client"; + +// Item 18, "Left the ward, waiting for transport": the ward board's compact list of people who have +// left the ward (their bed is already Ready) but are still on site waiting to be collected. Each +// stays here until "Collected", so nobody drops off every list while still in the building. +import { formatInstantWithDay, type Instant } from "@/components/ward-management/ward-clock"; +import type { WaitingForTransportEntry } from "@/components/ward-management/ward-left-ward"; +import styles from "./board.module.css"; + +export function WardBoardTransportWait({ + entries, + nameFor, + now, + onCollected, +}: { + entries: readonly WaitingForTransportEntry[]; + nameFor: (admissionId: string) => string; + now: Instant; + onCollected: (entry: WaitingForTransportEntry, who: string) => void; +}) { + if (entries.length === 0) return null; + return ( +
    +
    +
    +

    + Waiting for transport +

    + + {entries.length === 1 ? "1 person has" : `${entries.length} people have`} left the ward. Their bed is ready. + +
    +
    + {entries.map((entry) => { + const who = nameFor(entry.admissionId); + return ( +
    +
    + {who} + Left the ward +
    + Waiting since {formatInstantWithDay(entry.since, now)}. +
    + +
    +
    + ); + })} +
    +
    +
    + ); +} diff --git a/src/components/ward-management/board/ward-board.tsx b/src/components/ward-management/board/ward-board.tsx index 70354179e6..4398cbf207 100644 --- a/src/components/ward-management/board/ward-board.tsx +++ b/src/components/ward-management/board/ward-board.tsx @@ -68,6 +68,8 @@ import { WardBoardFlowColumn } from "./ward-board-flow-column"; import { WardBoardGridColumn } from "./ward-board-grid-column"; import { LeavingDestinationField, WardBoardDetailPanel } from "./ward-board-detail-panel"; import { WardBoardWorkBand } from "./ward-board-work-band"; +import { WardBoardTransportWait } from "./ward-board-transport-wait"; +import { transportPendingFor, waitingForTransport } from "@/components/ward-management/ward-left-ward"; import { WardBoardPrintSections } from "./ward-board-print-sections"; import { createReadmissionIndex } from "@/components/ward-management/ward-readmission"; import { stayReadmissionCopy } from "@/components/ward-management/ward-readmission-copy"; @@ -180,6 +182,8 @@ export function WardBoard({ identity?: string; item?: ShiftTileItem; admissionId?: string; + /** Item 18: "Left the ward", still on site waiting for transport. */ + awaitingTransport?: boolean; } | null>(null); const dialogTriggerRef = useRef(null); const blockerDialogRef = useRef(null); @@ -572,6 +576,8 @@ export function WardBoard({ const states = bedStates(unit, admissions, [...bedReleases], [...leaveBeds], namedHolds); const incoming = buildIncoming(unit, admissions, movements, now); const outgoing = outgoingToday(unit, bedReleases, outgoingBasis, now); + // Item 18: people who have left this ward, bed already Ready, still waiting to be collected. + const transportWait = waitingForTransport(admissions, unit.id); /* * `sinceYesterday` counts whatever it is given and deliberately cannot use `admissionsForUnit`, * which drops departed admissions and would make `discharged` permanently zero — see its own doc @@ -796,6 +802,12 @@ export function WardBoard({ text: `${isConfirmed ? "Confirmed out" : "Expected out"} today, still here.${blockerPhrase}`, acts: [ { word: "They have left", say: "would release this bed and make it ready" }, + // Item 18: still waiting on transport, so the person can leave the ward and the bed be Ready now. + ...(admissions.some( + (admission) => admission.id === occupant.key && transportPendingFor(admission, liveBedReleases), + ) + ? [{ word: "Left the ward", say: "would free this bed now and list them as waiting for transport" }] + : []), { word: "Record hold-up", tone: "warn", say: "would record what is holding this discharge up" }, ], }); @@ -878,16 +890,20 @@ export function WardBoard({ const admissionId = pendingConfirm.admissionId || pendingConfirm.item?.selectableKey; const leavingDestination = destinationFor(admissionId); if (!admissionId || leavingDestination === "") return; + const awaitingTransport = pendingConfirm.awaitingTransport === true; recordDepartureAndReport( () => recordWardDeparture - ? recordWardDeparture(admissionId, unit.id, leavingDestination) + ? recordWardDeparture(admissionId, unit.id, leavingDestination, "ward", { awaitingTransport }) : raise("RECORD_LEAVING", { admissionId: admissionId, actingUnitId: unit.id, leavingDestination, + ...(awaitingTransport ? { awaitingTransport: true as const } : {}), }), - `Recorded discharge: ${pendingConfirm.who} has left the ward.`, + awaitingTransport + ? `Recorded: ${pendingConfirm.who} has left the ward and is waiting for transport. The bed is ready.` + : `Recorded discharge: ${pendingConfirm.who} has left the ward.`, admissionId, pendingConfirm.who, ); @@ -913,6 +929,18 @@ export function WardBoard({ }; const handleShiftAction = (item: ShiftTileItem, word: string) => { + if (word === "Left the ward") { + // Item 18: always confirmed, because where they went is chosen here and never defaulted. + setPendingConfirm({ + kind: "leaving", + who: item.who || nameFor(item.selectableKey), + identity: identityFor(item.selectableKey), + item, + admissionId: item.selectableKey, + awaitingTransport: true, + }); + return; + } if (word === "They have left") { const leavingDestination = destinationFor(item.selectableKey); const executeLeaving = (leavingDestination: LeavingDestination) => { @@ -1260,6 +1288,27 @@ export function WardBoard({ shiftTiles={shiftTiles} unit={unit} /> + + runDeparture( + () => raise("MARK_TRANSPORT_COLLECTED", { admissionId: entry.admissionId, actingUnitId: unit.id }), + { + success: `Collected: ${who} has gone.`, + undo: (at) => ({ + type: "UNDO_DEPARTURE", + role: "ward", + now: at, + admissionId: entry.admissionId, + actingUnitId: unit.id, + }), + undone: `Undone. ${who} is back on the Waiting for transport list.`, + }, + ) + } + />

    - {pendingConfirm.kind === "leaving" ? "Confirm discharge" : "Confirm emergency department transfer"} + {pendingConfirm.awaitingTransport + ? "Confirm left the ward" + : pendingConfirm.kind === "leaving" + ? "Confirm discharge" + : "Confirm emergency department transfer"}

    diff --git a/src/components/ward-management/discharges/discharge-board-model.ts b/src/components/ward-management/discharges/discharge-board-model.ts index 982789888a..b0a695f3c7 100644 --- a/src/components/ward-management/discharges/discharge-board-model.ts +++ b/src/components/ward-management/discharges/discharge-board-model.ts @@ -1,6 +1,7 @@ import { RELEASE_BANDS, releaseBand, type ReleaseBand } from "@/components/ward-management/ward-bed-availability"; import { dayOf, formatSheetMoment, perthDateLabel, type Instant } from "@/components/ward-management/ward-clock"; import type { DischargeRecord } from "@/components/ward-management/ward-discharge-records"; +import { LEAVING_DESTINATIONS } from "@/components/ward-management/ward-admissions"; import { BED_RELEASES_HELD_UP_ANY_DATE_LABEL } from "@/components/ward-management/ward-derivations"; import type { BedRelease, Unit } from "@/components/ward-management/ward-model"; import { wardLabel } from "@/components/ward-management/ward-absence-labels"; @@ -286,6 +287,16 @@ export function recordedMoment(value: Instant | null, dayZero: Date): string { return value !== null && Number.isFinite(value) ? formatSheetMoment(value, dayZero) : "Not recorded"; } +/** + * Recorded's "Going to" cell: where they went, and, while they wait on site for transport after + * leaving the ward (item 18), since when. The ward board's Collected clears the wait. + */ +export function recordGoingTo(record: DischargeRecord, dayZero: Date): string { + const going = LEAVING_DESTINATIONS.find((item) => item.id === record.leavingDestination)?.label ?? "Not recorded"; + const wait = record.waitingForTransport; + return wait ? `${going}. Waiting for transport since ${recordedMoment(wait.since, dayZero)}.` : going; +} + /** The calendar day alone ("Fri 9 Oct"), for a release already past its date. */ export function recordedDate(value: Instant, dayZero: Date): string { return perthDateLabel(value, dayZero, { weekday: "short", day: "numeric", month: "short" }); diff --git a/src/components/ward-management/discharges/discharge-board.tsx b/src/components/ward-management/discharges/discharge-board.tsx index 6de5a78f94..a33632089a 100644 --- a/src/components/ward-management/discharges/discharge-board.tsx +++ b/src/components/ward-management/discharges/discharge-board.tsx @@ -70,6 +70,7 @@ import { recordedMoment, recordedDate, matchesHighlight, + recordGoingTo, } from "./discharge-board-model"; import { DischargePopulationSwitch, DischargeFilterCard } from "./discharge-board-filters"; import { @@ -1045,10 +1046,7 @@ function DischargeWorkspace({ initialAdmissionId }: { initialAdmissionId?: strin {recordedMoment(record.leftAt, dayZero)} - - {LEAVING_DESTINATIONS.find((item) => item.id === record.leavingDestination)?.label ?? - "Not recorded"} - + {recordGoingTo(record, dayZero)} diff --git a/src/components/ward-management/ward-admissions-seed.ts b/src/components/ward-management/ward-admissions-seed.ts index 0c7a7b01b5..a022785bd5 100644 --- a/src/components/ward-management/ward-admissions-seed.ts +++ b/src/components/ward-management/ward-admissions-seed.ts @@ -454,6 +454,8 @@ type Departure = { * alongside a real `referralId`, and only on `AD-LEFT-01` (`RF-010` → `PT-001`, `wardPatients[0]`, * sex Female, matching this departure's own recorded sex). */ readonly patientId?: PatientId; + /** Item 18: left the ward and still on site waiting for transport, since they left. */ + readonly waitingForTransport?: true; }; function departed(departure: Departure): Admission { @@ -487,6 +489,7 @@ function departed(departure: Departure): Admission { blockReason: null, leavingDestination: departure.destination, leftAt, + ...(departure.waitingForTransport ? { waitingForTransport: { since: leftAt, by: "ward" as const } } : {}), followUp: departure.followUp, }; } @@ -1013,6 +1016,8 @@ const departures: Admission[] = [ referralId: "RF-010", movementId: null, patientId: "PT-010", + // Item 18: the seeded "Waiting for transport" case, so the ward board's list has someone on it. + waitingForTransport: true, }), departed({ id: "AD-LEFT-02", diff --git a/src/components/ward-management/ward-admissions.ts b/src/components/ward-management/ward-admissions.ts index e83efbff53..40e0255035 100644 --- a/src/components/ward-management/ward-admissions.ts +++ b/src/components/ward-management/ward-admissions.ts @@ -750,6 +750,14 @@ export type Admission = { leavingDestination: LeavingDestination | null; /** When they went. `null` until then. */ leftAt: Instant | null; + /** + * Item 18, "Left the ward, waiting for transport": set when the ward records that a discharged + * person has left the ward but is still on site waiting for transport home. The departure itself + * already freed the bed (`departAdmission`), so Ready counts it at once; this only keeps the person + * on the ward's "Waiting for transport" list until "Collected" clears it. A time and a role, never + * a name or typed text. Absent on every other stay and on older saves. + */ + waitingForTransport?: { readonly since: Instant; readonly by: WardFlowRole }; /** * WHETHER ANYBODY ARRANGED FOLLOW-UP, AND WHETHER ANYBODY SAID. * @@ -845,6 +853,7 @@ const ADMISSION_FIELD_PRESENCE: Record = { weekendDischargeReviews: true, leavingDestination: true, leftAt: true, + waitingForTransport: true, followUp: true, careJourney: true, dischargeChecks: true, diff --git a/src/components/ward-management/ward-discharge-records.ts b/src/components/ward-management/ward-discharge-records.ts index 820208b793..fd9a86b4a9 100644 --- a/src/components/ward-management/ward-discharge-records.ts +++ b/src/components/ward-management/ward-discharge-records.ts @@ -43,6 +43,7 @@ export type DischargeRecord = Readonly< | "blockReason" | "leftAt" | "leavingDestination" + | "waitingForTransport" | "followUp" | "careJourney" | "dischargeCriteria" @@ -217,6 +218,8 @@ function project(state: WardFlowState, admission: Admission, actor: WardRecordAc blockReason: admission.blockReason, leftAt: admission.leftAt, leavingDestination: admission.leavingDestination, + // Item 18: left the ward, still on site waiting for transport. A time and a role only. + ...(admission.waitingForTransport ? { waitingForTransport: admission.waitingForTransport } : {}), followUp: admission.followUp, careJourney: admission.careJourney, weekendDischargeReviews: admission.weekendDischargeReviews, diff --git a/src/components/ward-management/ward-flow-departure.ts b/src/components/ward-management/ward-flow-departure.ts new file mode 100644 index 0000000000..e0a87ed7c7 --- /dev/null +++ b/src/components/ward-management/ward-flow-departure.ts @@ -0,0 +1,138 @@ +// The departure arithmetic shared by RECORD_LEAVING and RECORD_PATIENT_DISCHARGE, moved out of +// `ward-flow-reducer.ts` unchanged (line cap, 10 Nov 2026), which re-exports it. +import type { Admission, LeavingDestination } from "./ward-admissions"; +import type { Instant } from "./ward-clock"; +import { adjustSexMix, mixSexOf } from "./ward-eligibility"; +import type { WardFlowState } from "./ward-flow-reducer"; +import { WARD_FLOW_ROLE_LABELS, type WardFlowRole } from "./ward-flow-roles"; +import { replaceAdmission, replaceBedRelease, replaceUnit } from "./ward-flow-state-records"; +import type { BedRelease, Unit } from "./ward-model"; +import { nextBedReleaseId } from "./ward-runtime-ids"; + +/** "NUM " when the ward recorded it, otherwise the role's own label (the coordinator acts too). */ +export function recordedByLabel(role: WardFlowRole, unitName: string): string { + return role === "ward" ? `NUM ${unitName}` : WARD_FLOW_ROLE_LABELS[role]; +} + +/** The established departure arithmetic, shared by the legacy and patient-linked commands. */ +export function departAdmission( + state: WardFlowState, + admission: Admission, + unit: Unit, + now: Instant, + leavingDestination: LeavingDestination, + role: WardFlowRole, + /** Item 18, "Left the ward": the person waits on site for transport. Checked by the caller. */ + awaitingTransport = false, +): WardFlowState { + // Owner decision: a bed release is a named patient's discharge, so pairing is by admission, never + // by ward-wide counts. `releaseAlreadyIncremented` should be unreachable (RELEASE_BED refuses + // while the person is in the bed, and storage validation refuses it); if met, not raising the + // figures again can only under-state free beds. + const liveRelease = state.bedReleases.find((r) => r.admissionId === admission.id && r.state !== "discharged"); + const releaseAlreadyIncremented = state.bedReleases.some( + (r) => r.admissionId === admission.id && r.state === "discharged", + ); + + // Item 11 / WFA-003: `PULL_PATIENT` decrements `allocatableLocked` when the bed taken was locked; + // departure restores it on both branches, since `RELEASE_BED` never touches that field. + // + // S2-3: a departure resolves one over-capacity arrival (this admission's, or the unit's earliest + // pending conflict), so bed figures do not rise above true capacity. + const admittingMovement = state.movements.find((m) => m.admissionId === admission.id); + const matchingConflictIndex = + unit.arrivalCapacityConflicts?.findIndex( + (entry) => + entry.admissionId === admission.id || + (admittingMovement?.id !== undefined && entry.movementId === admittingMovement.id), + ) ?? -1; + const conflictToResolveIndex = + matchingConflictIndex >= 0 ? matchingConflictIndex : (unit.arrivalCapacityConflicts?.length ?? 0) > 0 ? 0 : -1; + const arrivalAwaitingCapacity = conflictToResolveIndex >= 0; + const remainingArrivalConflicts = + conflictToResolveIndex >= 0 && unit.arrivalCapacityConflicts + ? unit.arrivalCapacityConflicts.filter((_, idx) => idx !== conflictToResolveIndex) + : unit.arrivalCapacityConflicts; + const restoreLocked = !arrivalAwaitingCapacity && admission.bedKind === "locked" ? 1 : 0; + // Owner ruling 2026-09-25: occupant counts follow gender (recorded sex for a non-binary person). + // An admission carries only `sex`, so the gender comes from the movement that admitted it. + const leaverSex = mixSexOf(admission.gender ?? admittingMovement?.gender, admission.sex); + // Audit A1-11 (13 Oct 2026): a departure moves the figures but never stamps them as confirmed; + // only the ward's own Confirm numbers (`CONFIRM_CAPACITY`) sets `confirmedAt`. + const updatedUnit: Unit = + releaseAlreadyIncremented || arrivalAwaitingCapacity + ? { + ...unit, + allocatableLocked: Math.min(unit.lockedBeds, unit.allocatableLocked + restoreLocked), + sexMix: adjustSexMix(unit.sexMix, leaverSex, -1), + ...(remainingArrivalConflicts === undefined ? {} : { arrivalCapacityConflicts: remainingArrivalConflicts }), + } + : { + ...unit, + empty: { ...unit.empty, value: Math.min(unit.beds, unit.empty.value + 1) }, + allocatable: { + ...unit.allocatable, + value: Math.min(unit.beds, unit.allocatable.value + 1), + }, + allocatableLocked: Math.min(unit.lockedBeds, unit.allocatableLocked + restoreLocked), + sexMix: adjustSexMix(unit.sexMix, leaverSex, -1), + ...(remainingArrivalConflicts === undefined ? {} : { arrivalCapacityConflicts: remainingArrivalConflicts }), + }; + // Leaving ends any discharge blocker too: the stay is over, so it must not carry a "held up" reason + // into history, the same way its bed release loses its blocker below. + const departed: Admission = { + ...admission, + state: "departed", + leftAt: now, + leavingDestination, + blockReason: null, + dischargeCriteria: null, + // Item 18: kept on the "Waiting for transport" list until collected. The bed itself frees here, + // once, by the same arithmetic as any departure. + ...(awaitingTransport ? { waitingForTransport: { since: now, by: role } } : {}), + }; + const departedState = replaceAdmission(replaceUnit(state, unit.id, updatedUnit), admission.id, departed); + // Owner ruling: a leave bed belongs to one stay, so leaving ends it in the same write. No bed + // figure moves here. + const withPerson: WardFlowState = departedState.leaveBeds.some((bed) => bed.admissionId === admission.id) + ? { ...departedState, leaveBeds: departedState.leaveBeds.filter((bed) => bed.admissionId !== admission.id) } + : departedState; + // A4-24 (owner decision): a vacated bed goes to "Being made ready" (`preparing: true` on the + // discharged release) until one tap of Ready (`SET_BED_PREPARATION`, `preparing: false`). When no + // bed came free here, nothing is marked. + const bedCameFree = !releaseAlreadyIncremented && !arrivalAwaitingCapacity; + // Item 18 (Josh): a bed left by somebody waiting for transport is Ready at once, no cleaning step. + const beingMadeReady = bedCameFree && !awaitingTransport; + if (!liveRelease) { + if (!bedCameFree) return withPerson; + // A departure nobody flagged still vacates a bed: record its release, already discharged, to + // carry "Being made ready". Same id scheme as FLAG_BED_RELEASE. + const vacated: BedRelease = { + id: nextBedReleaseId(withPerson.bedReleases), + unitId: unit.id, + admissionId: admission.id, + state: "discharged", + expectedAt: now, + waitingOn: null, + blocker: null, + blockedBy: null, + preparing: beingMadeReady, + preparationNote: null, + confirmedAt: now, + confirmedBy: recordedByLabel(role, unit.name), + }; + return { ...withPerson, bedReleases: [...withPerson.bedReleases, vacated] }; + } + // Leaving completes this person's release — the same fields RELEASE_BED used to write. A stuck flag + // comes off, because the bed is no longer held up. + return replaceBedRelease(withPerson, liveRelease.id, { + ...liveRelease, + state: "discharged", + waitingOn: null, + blocker: null, + blockedBy: null, + preparing: beingMadeReady, + preparationNote: null, + confirmedAt: now, + }); +} diff --git a/src/components/ward-management/ward-flow-event-roles.ts b/src/components/ward-management/ward-flow-event-roles.ts index bdd11d91f9..20156cd8da 100644 --- a/src/components/ward-management/ward-flow-event-roles.ts +++ b/src/components/ward-management/ward-flow-event-roles.ts @@ -218,6 +218,8 @@ export const EVENT_ROLE: Record // know that somebody walked out of a building, and a coordinator recording a discharge it // cannot observe is the shape this project refuses everywhere else. RECORD_LEAVING: ["ward", "coordinator"], + // Item 18: whoever may record the departure may record the collection that ends the wait. + MARK_TRANSPORT_COLLECTED: ["ward", "coordinator"], // The ward that holds the bed, and nobody else. A ward is the only party that observes one of // its own occupants leaving the building for an emergency department and coming back, and it is // the party still holding the bed while they are gone. The coordinator is excluded on exactly diff --git a/src/components/ward-management/ward-flow-events.ts b/src/components/ward-management/ward-flow-events.ts index a9965b2885..e8671e2f04 100644 --- a/src/components/ward-management/ward-flow-events.ts +++ b/src/components/ward-management/ward-flow-events.ts @@ -26,6 +26,7 @@ import type { import type { UndoBedMeetingActionEvent } from "./ward-bed-meeting-undo-event-type"; import type { Tier1UndoEvent } from "./ward-tier1-undo-event-types"; import type { PatientCollectedEvent, RecordTransportCadEvent } from "./ward-transport-collection-events"; +import type { MarkTransportCollectedEvent } from "./ward-left-ward"; import type { ReferralInformationNeed } from "@/components/ward-management/referrals/referral-information-needs"; import type { ReferralContactOutcome, @@ -170,6 +171,7 @@ export type WardFlowEvent = expectedGeneration: number; expectedRevision: number; leavingDestination: LeavingDestination; + awaitingTransport?: true; // Item 18, "Left the ward": see `ward-left-ward.ts`. } | { type: "UPDATE_EXPECTED_DISCHARGE"; @@ -1774,7 +1776,9 @@ export type WardFlowEvent = * what makes the difference between a bed freed and a bed moved. */ leavingDestination: LeavingDestination; + awaitingTransport?: true; // Item 18, "Left the ward": see `ward-left-ward.ts`. } + | MarkTransportCollectedEvent | { /** * A WARD SENDS ONE OF ITS OWN OCCUPANTS OUT TO AN EMERGENCY DEPARTMENT, AND KEEPS THE BED. @@ -2725,26 +2729,3 @@ export type WardFlowEvent = /** Road or flight — the two repatriation modes this prototype records. Never free text. */ export const REPATRIATION_MODES = ["road", "flight"] as const; export type RepatriationMode = (typeof REPATRIATION_MODES)[number]; - -/** - * ⚠️ THE EVENTS A RECORDED REASON CAN GET PAST — AND THE POINT IS THAT IT IS DERIVED, NOT LISTED. - * - * `Extract` reads the union itself, so this cannot drift from `WardFlowEvent`. The reducer's two - * refusal helpers take THIS rather than `WardFlowEvent & { overrideReason?: OverrideReason }`. - * - * That intersection was the defect: it RE-ADDED the field regardless of what the union member - * declared, so deleting `overrideReason?: OverrideReason` from an event produced no type error - * anywhere. The declaration was load-bearing for CALLERS constructing the event and NOT for the - * reducer reading it — a deletion would have left screens unable to pass a reason while the - * reducer went on reading one that could never arrive. A field with no producer, arriving by - * deletion, invisible to tsc. - * - * ⚠️ AND A STRUCTURAL CHECK WOULD NOT HAVE CAUGHT IT EITHER. `Member extends { overrideReason?: - * OverrideReason }` stays TRUE when the field is deleted, because a type missing an OPTIONAL - * property is still structurally assignable to one that declares it optional. That check would - * compile for ever and report nothing — a check that cannot fail, in the type system. - */ -export type OverridableWardFlowEvent = Extract< - WardFlowEvent, - { type: "REFER_TO_UNITS" | "ACCEPT_IN_PRINCIPLE" | "PULL_PATIENT" | "ACCEPT_REFERRAL" } ->; diff --git a/src/components/ward-management/ward-flow-overridable-events.ts b/src/components/ward-management/ward-flow-overridable-events.ts new file mode 100644 index 0000000000..60a9f17604 --- /dev/null +++ b/src/components/ward-management/ward-flow-overridable-events.ts @@ -0,0 +1,25 @@ +// Moved out of `ward-flow-events.ts` unchanged (line cap, 10 Nov 2026). +import type { WardFlowEvent } from "./ward-flow-events"; + +/** + * ⚠️ THE EVENTS A RECORDED REASON CAN GET PAST — AND THE POINT IS THAT IT IS DERIVED, NOT LISTED. + * + * `Extract` reads the union itself, so this cannot drift from `WardFlowEvent`. The reducer's two + * refusal helpers take THIS rather than `WardFlowEvent & { overrideReason?: OverrideReason }`. + * + * That intersection was the defect: it RE-ADDED the field regardless of what the union member + * declared, so deleting `overrideReason?: OverrideReason` from an event produced no type error + * anywhere. The declaration was load-bearing for CALLERS constructing the event and NOT for the + * reducer reading it — a deletion would have left screens unable to pass a reason while the + * reducer went on reading one that could never arrive. A field with no producer, arriving by + * deletion, invisible to tsc. + * + * ⚠️ AND A STRUCTURAL CHECK WOULD NOT HAVE CAUGHT IT EITHER. `Member extends { overrideReason?: + * OverrideReason }` stays TRUE when the field is deleted, because a type missing an OPTIONAL + * property is still structurally assignable to one that declares it optional. That check would + * compile for ever and report nothing — a check that cannot fail, in the type system. + */ +export type OverridableWardFlowEvent = Extract< + WardFlowEvent, + { type: "REFER_TO_UNITS" | "ACCEPT_IN_PRINCIPLE" | "PULL_PATIENT" | "ACCEPT_REFERRAL" } +>; diff --git a/src/components/ward-management/ward-flow-persistence-classification.ts b/src/components/ward-management/ward-flow-persistence-classification.ts index 8dda079295..ab0d200c77 100644 --- a/src/components/ward-management/ward-flow-persistence-classification.ts +++ b/src/components/ward-management/ward-flow-persistence-classification.ts @@ -265,6 +265,8 @@ const WARD_FLOW_TEXT_SAFE_EVENT_TYPE_TUPLE = [ // #816: a patient id and a closed `PatientArchiveReason`, offered by radio buttons. No typed text. "ARCHIVE_PATIENT", "RECORD_LEAVING", + // Item 18: an admission id, the acting unit id, a time and the caller's role. No typed text. + "MARK_TRANSPORT_COLLECTED", "RECORD_LEFT_DEPARTMENT", "RECORD_LEGAL_FORM_EXPIRY", "RECORD_LEGAL_FORM_RECEIVED", diff --git a/src/components/ward-management/ward-flow-protected-record-events.ts b/src/components/ward-management/ward-flow-protected-record-events.ts index 400bbb506a..bd2dbacb78 100644 --- a/src/components/ward-management/ward-flow-protected-record-events.ts +++ b/src/components/ward-management/ward-flow-protected-record-events.ts @@ -1,4 +1,5 @@ import { pulledBedAvailability } from "./ward-ready-beds"; +import { LEFT_WARD_NEEDS_TRANSPORT, transportPendingFor } from "./ward-left-ward"; import { withoutRecordNumbers } from "./ward-patient-resolver"; import { validCareChange, careChangeRefusal, applyCareChange, emptyCareJourney } from "./ward-care-journey"; import { PROTECTED_REFUSAL_REASONS } from "./ward-protected-refusal-reasons"; @@ -486,9 +487,13 @@ export function reduceProtectedRecordEvent( movementSourceAdmission(state, movement), ); if (returnRefusal) return deny("transition", "denied", returnRefusal); + // Item 18: "Left the ward" only for a discharge still waiting on transport. + const awaitingTransport = event.awaitingTransport === true; + if (awaitingTransport && !transportPendingFor(admission, state.bedReleases)) + return deny("transition", "denied", LEFT_WARD_NEEDS_TRANSPORT); return appendAudit( state, - departAdmission(state, admission, unit, event.now, event.leavingDestination, event.role), + departAdmission(state, admission, unit, event.now, event.leavingDestination, event.role, awaitingTransport), event, { outcome: "accepted", diff --git a/src/components/ward-management/ward-flow-provider.tsx b/src/components/ward-management/ward-flow-provider.tsx index 5821ea95ff..83d2edb89d 100644 --- a/src/components/ward-management/ward-flow-provider.tsx +++ b/src/components/ward-management/ward-flow-provider.tsx @@ -156,6 +156,8 @@ type WardFlowContextValue = closingLoop.ClosingLoopContext & { actingUnitId: string, leavingDestination: LeavingDestination, actingRole?: "ward" | "coordinator", + /** Item 18: "Left the ward", still on site waiting for transport (the bed is Ready at once). */ + options?: { awaitingTransport?: boolean }, ): ReturnType; readDischargeRecords(actor: WardRecordActor, unitId?: string): RecordRead; openDischargeRecord(actor: WardRecordActor, admissionId: string, reason?: OnBehalfReasonId): DischargeOpenHandle; @@ -1207,7 +1209,8 @@ function WardFlowWorld({ const stateBound = useMemo( () => ({ - recordWardDeparture: (admissionId, actingUnitId, leavingDestination, actingRole = "ward") => { + recordWardDeparture: (admissionId, actingUnitId, leavingDestination, actingRole = "ward", options) => { + const awaiting = options?.awaitingTransport === true ? { awaitingTransport: true as const } : {}; const read = selectDischargeRecord( state, actingRole === "coordinator" ? { role: "coordinator" } : { role: "ward", actingUnitId }, @@ -1221,6 +1224,7 @@ function WardFlowWorld({ admissionId, actingUnitId, leavingDestination, + ...awaiting, }); } // A stay that cannot be read, or whose patient link cannot be checked, is not recorded @@ -1243,6 +1247,7 @@ function WardFlowWorld({ patientId: read.value.identity.patient.id, expectedGeneration: state.worldGeneration, expectedRevision: state.dischargeRevisions[admissionId] ?? 0, + ...awaiting, }); }, readDischargeRecords: (actor, unitId) => selectDischargeRecords(shown, actor, unitId), diff --git a/src/components/ward-management/ward-flow-record-events.ts b/src/components/ward-management/ward-flow-record-events.ts index 29f3541cbd..6fb6497d68 100644 --- a/src/components/ward-management/ward-flow-record-events.ts +++ b/src/components/ward-management/ward-flow-record-events.ts @@ -75,6 +75,7 @@ export type RecordedFactEvent = Extract< | "RECORD_LEFT_DEPARTMENT" | "RECORD_EXAMINATION" | "RECORD_LEAVING" + | "MARK_TRANSPORT_COLLECTED" | "RECORD_MOVEMENT_BLOCKER" | "RECORD_AWAY_AT_EMERGENCY_DEPARTMENT" | "RECORD_RETURNED_FROM_EMERGENCY_DEPARTMENT" @@ -1341,6 +1342,7 @@ export function reduceRecordedFactEvent( // A person's stay on the ward: leaving, trips to an emergency department, discharge blockers, // leave, absence, handover, repatriation, clinical contact and support notifications. case "RECORD_LEAVING": + case "MARK_TRANSPORT_COLLECTED": case "RECORD_AWAY_AT_EMERGENCY_DEPARTMENT": case "RECORD_RETURNED_FROM_EMERGENCY_DEPARTMENT": case "RECORD_STAY_DISCHARGE_BLOCKER": diff --git a/src/components/ward-management/ward-flow-record-stay-events.ts b/src/components/ward-management/ward-flow-record-stay-events.ts index aa87e1afb2..1dc6ec99a8 100644 --- a/src/components/ward-management/ward-flow-record-stay-events.ts +++ b/src/components/ward-management/ward-flow-record-stay-events.ts @@ -4,6 +4,7 @@ import { stayLegalStatusIsInvoluntary } from "./ward-discharge-records"; import { openRestrictivePracticeRefusal } from "./restrictive-practice/ward-restrictive-practice"; import { isLeavingDestination } from "./ward-admissions"; +import { LEFT_WARD_NEEDS_TRANSPORT, transportPendingFor } from "./ward-left-ward"; import { type AuditDecision, finiteInstant } from "./ward-audit"; import { recordedCommunityTransition } from "./ward-care-journey"; import type { @@ -46,6 +47,7 @@ type RecordedStayEvent = Extract< { type: | "RECORD_LEAVING" + | "MARK_TRANSPORT_COLLECTED" | "RECORD_AWAY_AT_EMERGENCY_DEPARTMENT" | "RECORD_RETURNED_FROM_EMERGENCY_DEPARTMENT" | "RECORD_STAY_DISCHARGE_BLOCKER" @@ -152,6 +154,10 @@ export function reduceRecordedStayEvent( decision.reasonCode = "invalid-payload"; return reject(state, event, "Departure cannot precede this admission's arrival"); } + // Item 18: "Left the ward" only for a discharge still waiting on transport. + if (event.awaitingTransport === true && !transportPendingFor(admission, state.bedReleases)) { + return reject(state, event, LEFT_WARD_NEEDS_TRANSPORT); + } // Discharge blocked while in transit: a patient cannot be discharged while their transfer // ambulance is actively on the road. @@ -202,7 +208,42 @@ export function reduceRecordedStayEvent( decision.outcome = "accepted"; decision.reasonCode = "none"; - return departAdmission(state, admission, unit, event.now, event.leavingDestination, event.role); + return departAdmission( + state, + admission, + unit, + event.now, + event.leavingDestination, + event.role, + event.awaitingTransport === true, + ); + } + + case "MARK_TRANSPORT_COLLECTED": { + // Item 18: the person who left the ward waiting for transport has gone. No bed figure moves: + // the bed freed when they left the ward. + if (finiteInstant(event.now) === null) { + decision.reasonCode = "invalid-payload"; + return reject(state, event, "Collected needs a finite time"); + } + const admission = findAdmission(state, event.admissionId); + if (!admission) return reject(state, event, `no admission found for id ${event.admissionId}`); + if (event.actingUnitId !== admission.unitId) { + decision.reasonCode = "scope"; + return reject( + state, + event, + `MARK_TRANSPORT_COLLECTED was raised acting as unit ${event.actingUnitId} but admission ${admission.id} belongs to unit ${admission.unitId}`, + ); + } + if (admission.state !== "departed" || admission.waitingForTransport === undefined) { + return reject(state, event, `admission ${admission.id} is not waiting for transport`); + } + decision.outcome = "accepted"; + decision.reasonCode = "none"; + const collected: typeof admission = { ...admission }; + delete collected.waitingForTransport; + return replaceAdmission(state, admission.id, collected); } case "RECORD_AWAY_AT_EMERGENCY_DEPARTMENT": { diff --git a/src/components/ward-management/ward-flow-reducer.ts b/src/components/ward-management/ward-flow-reducer.ts index fa99eeb89f..c57011389e 100644 --- a/src/components/ward-management/ward-flow-reducer.ts +++ b/src/components/ward-management/ward-flow-reducer.ts @@ -63,11 +63,7 @@ import { } from "./ward-discharge-records"; // D-64: these two live beside the discharge records so the Discharges list can use them too. export { admissionCriteriaEligibility, dischargeMovement }; -import { - PLANNED_ADMISSION_CANCEL_REASONS, - PLANNED_ADMISSION_REASONS, - type LeavingDestination, -} from "./ward-admissions"; +import { PLANNED_ADMISSION_CANCEL_REASONS, PLANNED_ADMISSION_REASONS } from "./ward-admissions"; import { lockedBedsFree } from "@/components/ward-management/ward-bed-designation"; import { activeClosedBeds } from "@/components/ward-management/ward-ready-beds"; import type { Instant } from "@/components/ward-management/ward-clock"; @@ -87,8 +83,6 @@ import { import { candidateReason, eligibility, - adjustSexMix, - mixSexOf, referralEligibility, type EligibilityGate, } from "@/components/ward-management/ward-eligibility"; @@ -103,7 +97,6 @@ import { WARD_FLOW_ROLE_LABELS, type RepatriationMode, type WardFlowEvent, - type OverridableWardFlowEvent, type WardFlowRole, } from "@/components/ward-management/ward-flow-events"; @@ -194,7 +187,11 @@ import { reduceReviewReadyEvent } from "./ward-flow-review-ready-events"; // S6 example data (3 Nov 2026): no demo list opens empty. A live shared build swaps in a no-op. import { applyExampleListsOverlay } from "@/components/ward-management/ward-demo-example-lists"; import { stepUndoSubjectId, subjectId } from "./ward-flow-subject-id"; -import { nextBedReleaseId, withFreshIds } from "./ward-runtime-ids"; +import { withFreshIds } from "./ward-runtime-ids"; +// The departure arithmetic lives in its own file (line cap, 10 Nov 2026), re-exported here. +import { departAdmission, recordedByLabel } from "./ward-flow-departure"; +import type { OverridableWardFlowEvent } from "./ward-flow-overridable-events"; +export { departAdmission, recordedByLabel }; import { isPatientCarePlanEvent } from "./ward-patient-care-plan-events"; export { UNLINKED_CARE_REFUSAL } from "./ward-flow-protected-record-events"; @@ -1181,122 +1178,6 @@ export function referralReferrer(referral: Referral): Addressee | undefined { return edId === undefined ? undefined : { role: "ed", placeId: edId }; } -/** The established departure arithmetic, shared by the legacy and patient-linked commands. */ -export function departAdmission( - state: WardFlowState, - admission: Admission, - unit: Unit, - now: Instant, - leavingDestination: LeavingDestination, - role: WardFlowRole, -): WardFlowState { - // Owner decision: a bed release is a named patient's discharge, so pairing is by admission, never - // by ward-wide counts. `releaseAlreadyIncremented` should be unreachable (RELEASE_BED refuses - // while the person is in the bed, and storage validation refuses it); if met, not raising the - // figures again can only under-state free beds. - const liveRelease = state.bedReleases.find((r) => r.admissionId === admission.id && r.state !== "discharged"); - const releaseAlreadyIncremented = state.bedReleases.some( - (r) => r.admissionId === admission.id && r.state === "discharged", - ); - - // Item 11 / WFA-003: `PULL_PATIENT` decrements `allocatableLocked` when the bed taken was locked; - // departure restores it on both branches, since `RELEASE_BED` never touches that field. - // - // S2-3: a departure resolves one over-capacity arrival (this admission's, or the unit's earliest - // pending conflict), so bed figures do not rise above true capacity. - const admittingMovement = state.movements.find((m) => m.admissionId === admission.id); - const matchingConflictIndex = - unit.arrivalCapacityConflicts?.findIndex( - (entry) => - entry.admissionId === admission.id || - (admittingMovement?.id !== undefined && entry.movementId === admittingMovement.id), - ) ?? -1; - const conflictToResolveIndex = - matchingConflictIndex >= 0 ? matchingConflictIndex : (unit.arrivalCapacityConflicts?.length ?? 0) > 0 ? 0 : -1; - const arrivalAwaitingCapacity = conflictToResolveIndex >= 0; - const remainingArrivalConflicts = - conflictToResolveIndex >= 0 && unit.arrivalCapacityConflicts - ? unit.arrivalCapacityConflicts.filter((_, idx) => idx !== conflictToResolveIndex) - : unit.arrivalCapacityConflicts; - const restoreLocked = !arrivalAwaitingCapacity && admission.bedKind === "locked" ? 1 : 0; - // Owner ruling 2026-09-25: occupant counts follow gender (recorded sex for a non-binary person). - // An admission carries only `sex`, so the gender comes from the movement that admitted it. - const leaverSex = mixSexOf(admission.gender ?? admittingMovement?.gender, admission.sex); - // Audit A1-11 (13 Oct 2026): a departure moves the figures but never stamps them as confirmed; - // only the ward's own Confirm numbers (`CONFIRM_CAPACITY`) sets `confirmedAt`. - const updatedUnit: Unit = - releaseAlreadyIncremented || arrivalAwaitingCapacity - ? { - ...unit, - allocatableLocked: Math.min(unit.lockedBeds, unit.allocatableLocked + restoreLocked), - sexMix: adjustSexMix(unit.sexMix, leaverSex, -1), - ...(remainingArrivalConflicts === undefined ? {} : { arrivalCapacityConflicts: remainingArrivalConflicts }), - } - : { - ...unit, - empty: { ...unit.empty, value: Math.min(unit.beds, unit.empty.value + 1) }, - allocatable: { - ...unit.allocatable, - value: Math.min(unit.beds, unit.allocatable.value + 1), - }, - allocatableLocked: Math.min(unit.lockedBeds, unit.allocatableLocked + restoreLocked), - sexMix: adjustSexMix(unit.sexMix, leaverSex, -1), - ...(remainingArrivalConflicts === undefined ? {} : { arrivalCapacityConflicts: remainingArrivalConflicts }), - }; - // Leaving ends any discharge blocker too: the stay is over, so it must not carry a "held up" reason - // into history, the same way its bed release loses its blocker below. - const departed: Admission = { - ...admission, - state: "departed", - leftAt: now, - leavingDestination, - blockReason: null, - dischargeCriteria: null, - }; - const departedState = replaceAdmission(replaceUnit(state, unit.id, updatedUnit), admission.id, departed); - // Owner ruling: a leave bed belongs to one stay, so leaving ends it in the same write. No bed - // figure moves here. - const withPerson: WardFlowState = departedState.leaveBeds.some((bed) => bed.admissionId === admission.id) - ? { ...departedState, leaveBeds: departedState.leaveBeds.filter((bed) => bed.admissionId !== admission.id) } - : departedState; - // A4-24 (owner decision): a vacated bed goes to "Being made ready" (`preparing: true` on the - // discharged release) until one tap of Ready (`SET_BED_PREPARATION`, `preparing: false`). When no - // bed came free here, nothing is marked. - const bedCameFree = !releaseAlreadyIncremented && !arrivalAwaitingCapacity; - if (!liveRelease) { - if (!bedCameFree) return withPerson; - // A departure nobody flagged still vacates a bed: record its release, already discharged, to - // carry "Being made ready". Same id scheme as FLAG_BED_RELEASE. - const vacated: BedRelease = { - id: nextBedReleaseId(withPerson.bedReleases), - unitId: unit.id, - admissionId: admission.id, - state: "discharged", - expectedAt: now, - waitingOn: null, - blocker: null, - blockedBy: null, - preparing: true, - preparationNote: null, - confirmedAt: now, - confirmedBy: recordedByLabel(role, unit.name), - }; - return { ...withPerson, bedReleases: [...withPerson.bedReleases, vacated] }; - } - // Leaving completes this person's release — the same fields RELEASE_BED used to write. A stuck flag - // comes off, because the bed is no longer held up. - return replaceBedRelease(withPerson, liveRelease.id, { - ...liveRelease, - state: "discharged", - waitingOn: null, - blocker: null, - blockedBy: null, - preparing: bedCameFree, - preparationNote: null, - confirmedAt: now, - }); -} - /** * Whether a patient record's free-text legal status is involuntary, for the two discharge guards * below. Deliberately wide: anything starting "Involuntary", plus "Detained awaiting examination". @@ -1353,11 +1234,6 @@ export function dischargeBlockingTransit(state: WardFlowState, admission: Admiss ); } -/** "NUM " when the ward recorded it, otherwise the role's own label (the coordinator acts too). */ -export function recordedByLabel(role: WardFlowRole, unitName: string): string { - return role === "ward" ? `NUM ${unitName}` : WARD_FLOW_ROLE_LABELS[role]; -} - /** Any ward-resource update invalidates a capacity draft, including protected departures. */ function advanceResourceRevisions(before: WardFlowState, after: WardFlowState): WardFlowState { if (after.units === before.units) return after; @@ -1871,6 +1747,7 @@ export function reduceClinicalEvent( case "RECORD_LEFT_DEPARTMENT": case "RECORD_EXAMINATION": case "RECORD_LEAVING": + case "MARK_TRANSPORT_COLLECTED": case "RECORD_MOVEMENT_BLOCKER": case "RECORD_AWAY_AT_EMERGENCY_DEPARTMENT": case "RECORD_RETURNED_FROM_EMERGENCY_DEPARTMENT": diff --git a/src/components/ward-management/ward-flow-storage-validation.ts b/src/components/ward-management/ward-flow-storage-validation.ts index 4e46ad3650..9c28463950 100644 --- a/src/components/ward-management/ward-flow-storage-validation.ts +++ b/src/components/ward-management/ward-flow-storage-validation.ts @@ -607,6 +607,10 @@ const admission = z stayBlockRecords: z.optional(z.custom(isStayBlockRecords)), // absent in older saves. ...dischargeFollowThroughFields, // Features 6, 37, 13 and 14 (9 Nov 2026); absent in older saves. leavingDestination: z.nullable(z.custom(isLeavingDestination)), + // Item 18: left the ward, waiting for transport. Absent in older saves; a time and a known role only. + waitingForTransport: z.optional( + z.strictObject({ since: finite, by: z.enum(Object.keys(WARD_FLOW_ROLE_LABELS) as [string, ...string[]]) }), + ), // A stored barrier is a list value or absent; free text would be typed text restored (finding A2-2). dischargeBarrier: z.optional(z.nullable(z.custom(isDischargeBarrier))), // Item 5: a day's instant and a listed reason, or absent in older saves. @@ -633,7 +637,9 @@ const admission = z z.refine( (stay) => ((stay.clinicallyReadySince ?? null) === null) === ((stay.delayedDischargeReason ?? null) === null), ), - ); + ) + // Item 18: only somebody who has left can be waiting for transport, so the list never shows a bed holder. + .check(z.refine((stay) => stay.waitingForTransport === undefined || stay.state === "departed")); // A destination is one of three kinds, each holding its own facts; any other kind is refused. const referralDestination = z.discriminatedUnion("kind", [ diff --git a/src/components/ward-management/ward-flow-subject-id.ts b/src/components/ward-management/ward-flow-subject-id.ts index 347b801d75..4ac7c00652 100644 --- a/src/components/ward-management/ward-flow-subject-id.ts +++ b/src/components/ward-management/ward-flow-subject-id.ts @@ -84,6 +84,7 @@ export function subjectId(event: WardFlowEvent): string { return event.releaseId; // Admission-scoped: an admission id is not a unit or release id, so these keep their own group. case "RECORD_LEAVING": + case "MARK_TRANSPORT_COLLECTED": case "RECORD_AWAY_AT_EMERGENCY_DEPARTMENT": case "RECORD_RETURNED_FROM_EMERGENCY_DEPARTMENT": case "RECORD_STAY_DISCHARGE_BLOCKER": diff --git a/src/components/ward-management/ward-history.ts b/src/components/ward-management/ward-history.ts index f465852437..1a8d20764e 100644 --- a/src/components/ward-management/ward-history.ts +++ b/src/components/ward-management/ward-history.ts @@ -48,6 +48,7 @@ export const EVENT_HISTORY_TABLE: Record + release.admissionId === admission.id && + release.state !== "discharged" && + (release.waitingOn === AWAITING_TRANSPORT || + release.blocker === AWAITING_TRANSPORT || + (release.dependencies ?? []).some( + (dependency) => dependency.kind === "transport" && dependency.state === "outstanding", + )), + ); +} + +export type WaitingForTransportEntry = { + readonly admissionId: string; + readonly unitId: string; + readonly since: number; +}; + +/** + * Everyone who has left the ward and is still waiting for transport, on one ward or (with no ward) + * everywhere. Oldest wait first: an order by time on the record, never a ranking of anyone. + */ +export function waitingForTransport(admissions: readonly Admission[], unitId?: string): WaitingForTransportEntry[] { + return admissions + .filter( + (admission) => + admission.state === "departed" && + admission.waitingForTransport !== undefined && + (unitId === undefined || admission.unitId === unitId), + ) + .map((admission) => ({ + admissionId: admission.id, + unitId: admission.unitId, + since: admission.waitingForTransport!.since, + })) + .sort((a, b) => a.since - b.since || a.admissionId.localeCompare(b.admissionId)); +} + +/** The refusal both departure paths give when "Left the ward" is asked for without transport pending. */ +export const LEFT_WARD_NEEDS_TRANSPORT = + "Left the ward is for a discharge still waiting on transport. Record the departure with They have left instead"; diff --git a/src/components/ward-management/ward-undo.ts b/src/components/ward-management/ward-undo.ts index 73687b3dcc..0bba96be44 100644 --- a/src/components/ward-management/ward-undo.ts +++ b/src/components/ward-management/ward-undo.ts @@ -100,7 +100,8 @@ export const UNDO_STEP_ACTIONS = { ], UNDO_TRANSPORT_STEP: ["TRANSPORT_ACCEPTED", "TRANSPORT_EN_ROUTE", "PATIENT_COLLECTED", "PATIENT_ARRIVED"], // Audit A1-12 / A4-24 (13 Oct 2026): "Has left" is reversible from its toast, like Move bed. - UNDO_DEPARTURE: ["RECORD_LEAVING", "RECORD_PATIENT_DISCHARGE"], + // Item 18: "Collected" comes back the same way, filed against the same stay. + UNDO_DEPARTURE: ["RECORD_LEAVING", "RECORD_PATIENT_DISCHARGE", "MARK_TRANSPORT_COLLECTED"], // Audit A3-10 (13 Oct 2026): Mark absent starts the police steps on one tap, so it offers Undo. UNDO_ABSENT_WITHOUT_LEAVE: ["RECORD_ABSENT_WITHOUT_LEAVE"], // H-0150 (26 Oct 2026): a bed meeting action marked done by mistake comes back from its toast. diff --git a/tests/helpers/ward-engine-fuzz.ts b/tests/helpers/ward-engine-fuzz.ts index fbe4cbb6ec..62367ebfb7 100644 --- a/tests/helpers/ward-engine-fuzz.ts +++ b/tests/helpers/ward-engine-fuzz.ts @@ -842,6 +842,13 @@ const BUILDERS: readonly (readonly [weight: number, builder: Builder])[] = [ 1, (state, random) => { const stay = stayFor(state, random, (candidate) => candidate.awayAtEmergencyDepartmentSince !== null); + // Item 18: on alternate calls (by the refusal count, never a fresh draw, so the seeded runs keep + // their random stream), or with nobody away at an ED, Collected for the first stay waiting for transport. + const waiting = + stay && state.rejections.length % 2 === 1 + ? undefined + : state.admissions.find((candidate) => candidate.waitingForTransport); + if (waiting) return { type: "MARK_TRANSPORT_COLLECTED", admissionId: waiting.id, actingUnitId: waiting.unitId }; return { type: "RECORD_RETURNED_FROM_EMERGENCY_DEPARTMENT", admissionId: id(stay), diff --git a/tests/helpers/ward-legal-figure-sweep.ts b/tests/helpers/ward-legal-figure-sweep.ts index 91e3119ab5..d974a09cbd 100644 --- a/tests/helpers/ward-legal-figure-sweep.ts +++ b/tests/helpers/ward-legal-figure-sweep.ts @@ -1425,6 +1425,8 @@ function candidatesForMovements( return movementIds.map((movementId) => ({ type, role, now, movementId, blocker: "Awaiting a bed" })); case "RECORD_AWAY_AT_EMERGENCY_DEPARTMENT": case "RECORD_RETURNED_FROM_EMERGENCY_DEPARTMENT": + // Item 18: Collected names the stay and its ward, nothing else. + case "MARK_TRANSPORT_COLLECTED": // One candidate per admission already in `state.admissions`, `actingUnitId` mirroring the // admission's own unit — the same discipline `RECORD_LEAVING` above uses, and for the same // reason: a mismatched acting unit is refused before the reducer body runs, and a refused diff --git a/tests/ward-admission-model.test.ts b/tests/ward-admission-model.test.ts index e5d3404d93..5dcd8ed109 100644 --- a/tests/ward-admission-model.test.ts +++ b/tests/ward-admission-model.test.ts @@ -84,6 +84,8 @@ function anAdmission(overrides: Partial = {}): Admission { weekendDischargeReviews: [], leavingDestination: null, leftAt: null, + // Item 18 (10 Nov 2026), so the built fixture carries every field. + waitingForTransport: { since: DAY_ZERO, by: "ward" }, followUp: null, careJourney: { contacts: [], plan: {}, documents: {}, episodes: [] }, dischargeBarrier: null, @@ -632,6 +634,8 @@ describe("Admission privacy — structural", () => { "weekendDischargeReviews", "leavingDestination", "leftAt", + // Item 18: left the ward, waiting for transport. A time and a role, no free text. + "waitingForTransport", "followUp", "careJourney", // Features 37 and 13 (9 Nov 2026): listed checklist states and a listed reply, each with a time and role. @@ -691,6 +695,7 @@ describe("Admission privacy — structural", () => { weekendDischargeReviews: [], leavingDestination: "discharged-to-the-community", leftAt: DAY_ZERO + 30 * MINUTES_PER_DAY, + waitingForTransport: { since: DAY_ZERO + 30 * MINUTES_PER_DAY, by: "ward" }, // Populated for the same reason the ED timestamp above is: a null here would make the new // field indistinguishable from an absent one, which is exactly what this fixture exists to tell // apart. diff --git a/tests/ward-board-left-ward.dom.test.tsx b/tests/ward-board-left-ward.dom.test.tsx new file mode 100644 index 0000000000..aab733aae1 --- /dev/null +++ b/tests/ward-board-left-ward.dom.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { ToastProvider } from "@/components/wf"; +import { describe, expect, it } from "vitest"; + +import { WardBoard } from "../src/components/ward-management/board/ward-board"; +import { WardFlowProvider } from "../src/components/ward-management/ward-flow-provider"; +import { seedWardFlowState } from "../src/components/ward-management/ward-flow-reducer"; +import { WARD_ADMISSIONS_ANCHOR } from "../src/components/ward-management/ward-admissions-seed"; +import { transportPendingFor, waitingForTransport } from "../src/components/ward-management/ward-left-ward"; + +/* + * Item 18 on the ward board: the seeded person who left the ward waiting for transport is listed + * with a Collected button, and a discharge due today that waits on transport offers "Left the ward". + */ +const NOW = WARD_ADMISSIONS_ANCHOR + 40; + +function renderBoard(unitId: string) { + return render( + + + + + , + ); +} + +describe("ward board: Left the ward, waiting for transport (item 18)", () => { + it("lists the seeded person waiting for transport, and Collected takes them off", () => { + const [entry] = waitingForTransport(seedWardFlowState().admissions); + expect(entry).toBeDefined(); + renderBoard(entry!.unitId); + const list = screen.getByTestId("ward-board-transport-wait"); + expect(within(list).getByRole("heading", { name: "Waiting for transport" })).toBeTruthy(); + const row = within(list).getByTestId(`ward-board-transport-wait-${entry!.admissionId}`); + expect(row.textContent).toContain("Waiting since"); + fireEvent.click(within(row).getByRole("button", { name: /^Collected/ })); + expect(screen.queryByTestId("ward-board-transport-wait")).toBeNull(); + }); + + it("offers Left the ward only on a due discharge still waiting on transport", () => { + const state = seedWardFlowState(); + const units = new Set( + state.admissions + .filter((admission) => transportPendingFor(admission, state.bedReleases)) + .map((admission) => admission.unitId), + ); + let offered = 0; + for (const unitId of units) { + const view = renderBoard(unitId); + offered += screen.queryAllByRole("button", { name: "Left the ward" }).length; + view.unmount(); + } + expect(offered, "a seeded discharge due today waits on transport").toBeGreaterThan(0); + }); + + it("Left the ward asks where they are going, then lists them as waiting for transport", () => { + const state = seedWardFlowState(); + const unitId = [ + ...new Set( + state.admissions + .filter((admission) => transportPendingFor(admission, state.bedReleases)) + .map((admission) => admission.unitId), + ), + ].find((candidate) => { + const view = renderBoard(candidate); + const found = screen.queryAllByRole("button", { name: "Left the ward" }).length > 0; + view.unmount(); + return found; + })!; + renderBoard(unitId); + const before = screen.queryAllByRole("button", { name: /^Collected/ }).length; + fireEvent.click(screen.getAllByRole("button", { name: "Left the ward" })[0]!); + const dialog = screen.getByRole("dialog", { name: "Confirm left the ward" }); + const proceed = within(dialog).getByTestId("ward-board-confirm-proceed") as HTMLButtonElement; + expect(proceed.disabled, "no destination is chosen for the ward").toBe(true); + fireEvent.change(within(dialog).getByTestId("ward-board-confirm-destination"), { + target: { value: "discharged-to-the-community" }, + }); + fireEvent.click(proceed); + expect(screen.getAllByRole("button", { name: /^Collected/ })).toHaveLength(before + 1); + }); +}); diff --git a/tests/ward-event-permissions.test.ts b/tests/ward-event-permissions.test.ts index 08c68d2ce4..af502b771b 100644 --- a/tests/ward-event-permissions.test.ts +++ b/tests/ward-event-permissions.test.ts @@ -226,6 +226,8 @@ describe("who may raise which event", () => { * about that ward's own beds - see `RECORD_LEAVING` in the reducer, which refuses any acting * unit other than the one holding the admission. */ RECORD_LEAVING: ["ward", "coordinator"], + // Item 18: Collected ends a wait for transport; whoever may record the departure may record it. + MARK_TRANSPORT_COLLECTED: ["ward", "coordinator"], /* ADDED 2026-09-01. Two new events, one fact: a ward records that one of its own occupants has * gone out to an emergency department, and that they are back. Ward-only for the reason * `RECORD_LEAVING` directly above is — it is a statement about that ward's own bed, and the diff --git a/tests/ward-history.test.ts b/tests/ward-history.test.ts index 872515552a..eb5b6b0c80 100644 --- a/tests/ward-history.test.ts +++ b/tests/ward-history.test.ts @@ -69,7 +69,8 @@ describe("Ward Flow Read-Only Plain History", () => { // Review tracker and wait list (9 Nov 2026) add REQUEST_PSYCH_REVIEW, RECORD_PSYCH_REVIEW_SEEN // and MARK_WARD_READY_FOR_NEXT. Staff needed (9 Nov 2026) adds REQUEST_WARD_STAFF and // CLOSE_WARD_STAFF_REQUEST. - expect(allEventTypes.length).toBe(193); + // Item 18 (10 Nov 2026) adds MARK_TRANSPORT_COLLECTED. + expect(allEventTypes.length).toBe(194); for (const eventType of allEventTypes) { const config = EVENT_HISTORY_TABLE[eventType]; diff --git a/tests/ward-left-ward-transport.test.ts b/tests/ward-left-ward-transport.test.ts new file mode 100644 index 0000000000..fa04e561b8 --- /dev/null +++ b/tests/ward-left-ward-transport.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; + +import { seedWardFlowState, wardFlowReducer, type WardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import type { WardFlowEvent } from "@/components/ward-management/ward-flow-events"; +import { isValidStoredWardFlowState } from "@/components/ward-management/ward-flow-storage-validation"; +import { transportPendingFor, waitingForTransport } from "@/components/ward-management/ward-left-ward"; +import { boundedBedAvailability } from "@/components/ward-management/ward-ready-beds"; +import { selectDischargeRecords } from "@/components/ward-management/ward-discharge-records"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; +import { recordGoingTo } from "@/components/ward-management/discharges/discharge-board-model"; + +/* + * Item 18, "Left the ward, waiting for transport". The ward records the ordinary departure with + * `awaitingTransport`, so the bed frees once (Ready at once, no "Being made ready"), and the person + * stays on the ward's Waiting for transport list until MARK_TRANSPORT_COLLECTED. + */ +const now = NOW_ANCHOR; +const seed = seedWardFlowState(); +/** A save as browser storage holds it (undefined fields dropped), the shape restoration reads. */ +const saved = (state: WardFlowState) => JSON.parse(JSON.stringify(state)) as WardFlowState; +/** The bed figures alone; a resource revision rises on every write, Undo included. */ +const figures = (state: WardFlowState, unitId: string) => { + const unit = unitOf(state, unitId); + return { empty: unit.empty.value, allocatable: unit.allocatable.value, ...boundedBedAvailability(unit) }; +}; + +/** An occupied, unlinked stay whose discharge waits on transport and can leave on RECORD_LEAVING. */ +function leaver(state: WardFlowState) { + const found = state.admissions.find((admission) => { + if (!transportPendingFor(admission, state.bedReleases)) return false; + const next = wardFlowReducer(state, leave(admission.id, admission.unitId)); + return next.rejections.length === state.rejections.length; + }); + if (!found) throw new Error("the seed has no discharge waiting on transport that can leave"); + return found; +} + +function leave( + admissionId: string, + actingUnitId: string, + role: WardFlowEvent["role"] = "ward", +): Extract { + return { + type: "RECORD_LEAVING", + role, + now, + admissionId, + actingUnitId, + leavingDestination: "discharged-to-the-community", + awaitingTransport: true, + }; +} + +function collect(admissionId: string, actingUnitId: string, role: WardFlowEvent["role"] = "ward"): WardFlowEvent { + return { type: "MARK_TRANSPORT_COLLECTED", role, now: now + 30, admissionId, actingUnitId }; +} + +const unitOf = (state: WardFlowState, unitId: string) => state.units.find((unit) => unit.id === unitId)!; +const stayOf = (state: WardFlowState, id: string) => state.admissions.find((admission) => admission.id === id)!; + +describe("Left the ward, waiting for transport (item 18)", () => { + const person = leaver(seed); + const left = wardFlowReducer(seed, leave(person.id, person.unitId)); + + it("the seed already lists one person waiting for transport, for screenshots", () => { + expect(waitingForTransport(seed.admissions)).toHaveLength(1); + expect(isValidStoredWardFlowState(saved(seed))).toBe(true); + }); + + it("frees the bed once, Ready at once with no cleaning step, and keeps the person listed", () => { + const before = boundedBedAvailability(unitOf(seed, person.unitId)); + const after = boundedBedAvailability(unitOf(left, person.unitId)); + expect(after.effectiveEmpty).toBe(before.effectiveEmpty + 1); + expect(after.available).toBe(before.available + 1); + const stay = stayOf(left, person.id); + expect(stay.state).toBe("departed"); + expect(stay.waitingForTransport).toEqual({ since: now, by: "ward" }); + const release = left.bedReleases.find((candidate) => candidate.admissionId === person.id)!; + expect(release.state).toBe("discharged"); + expect(release.preparing, "no Being made ready step").toBe(false); + expect(waitingForTransport(left.admissions, person.unitId).map((entry) => entry.admissionId)).toContain(person.id); + // The Discharges screen reads it from the same record. + const records = selectDischargeRecords(left, { role: "ward", actingUnitId: person.unitId }, person.unitId); + expect(records.status).toBe("allowed"); + if (records.status === "allowed") + expect( + recordGoingTo( + records.value.find((record) => record.admissionId === person.id)!, + new Date(0), + ), + ).toMatch(/^Discharged to the community\. Waiting for transport since .+\.$/); + }); + + it("an ordinary departure still goes to Being made ready and lists nobody", () => { + const ordinary = wardFlowReducer(seed, { ...leave(person.id, person.unitId), awaitingTransport: undefined }); + expect(stayOf(ordinary, person.id).waitingForTransport).toBeUndefined(); + expect(ordinary.bedReleases.find((release) => release.admissionId === person.id)?.preparing).toBe(true); + }); + + it("is refused for a discharge with no transport outstanding", () => { + const other = seed.admissions.find( + (admission) => admission.state === "occupied" && !transportPendingFor(admission, seed.bedReleases), + )!; + const refused = wardFlowReducer(seed, leave(other.id, other.unitId)); + expect(refused.rejections.length).toBe(seed.rejections.length + 1); + expect(stayOf(refused, other.id).state).toBe("occupied"); + expect(refused.units).toEqual(seed.units); + }); + + it("never frees the bed twice: a second departure of any kind is refused and moves nothing", () => { + const again = wardFlowReducer(left, leave(person.id, person.unitId)); + expect(again.rejections.length).toBe(left.rejections.length + 1); + expect(again.units).toEqual(left.units); + const plain = wardFlowReducer(left, { ...leave(person.id, person.unitId), awaitingTransport: undefined }); + expect(plain.units).toEqual(left.units); + }); + + it("Collected takes them off the list and moves no bed figure", () => { + const collected = wardFlowReducer(left, collect(person.id, person.unitId)); + expect(collected.rejections.length).toBe(left.rejections.length); + expect(stayOf(collected, person.id).waitingForTransport).toBeUndefined(); + expect(waitingForTransport(collected.admissions, person.unitId).map((entry) => entry.admissionId)).not.toContain( + person.id, + ); + expect(collected.units).toEqual(left.units); + // Collecting again is refused rather than silently accepted. + const twice = wardFlowReducer(collected, collect(person.id, person.unitId)); + expect(twice.rejections.length).toBe(collected.rejections.length + 1); + }); + + it("refuses the wrong ward and roles that may not record a departure", () => { + const elsewhere = seed.units.find((unit) => unit.id !== person.unitId)!.id; + for (const event of [ + collect(person.id, elsewhere), + collect(person.id, person.unitId, "ed"), + collect(person.id, person.unitId, "bed_manager"), + leave(person.id, person.unitId, "ed"), + ]) { + const base = event.type === "MARK_TRANSPORT_COLLECTED" ? left : seed; + const refused = wardFlowReducer(base, event); + expect(refused.rejections.length, `${event.type} as ${event.role}`).toBe(base.rejections.length + 1); + expect(refused.admissions).toEqual(base.admissions); + expect(refused.units).toEqual(base.units); + } + // The coordinator may record the collection, as it may record a departure. + const byCoordinator = wardFlowReducer(left, collect(person.id, person.unitId, "coordinator")); + expect(stayOf(byCoordinator, person.id).waitingForTransport).toBeUndefined(); + }); + + it("Undo works like other ward steps: the departure and the collection each come back exactly", () => { + const undoneLeave = wardFlowReducer(left, { + type: "UNDO_DEPARTURE", + role: "ward", + now: now + 1, + admissionId: person.id, + actingUnitId: person.unitId, + }); + expect(undoneLeave.rejections.length).toBe(left.rejections.length); + expect(stayOf(undoneLeave, person.id).state).toBe("occupied"); + expect(stayOf(undoneLeave, person.id).waitingForTransport).toBeUndefined(); + expect(figures(undoneLeave, person.unitId)).toEqual(figures(seed, person.unitId)); + + const collected = wardFlowReducer(left, collect(person.id, person.unitId)); + const undoneCollect = wardFlowReducer(collected, { + type: "UNDO_DEPARTURE", + role: "ward", + now: now + 31, + admissionId: person.id, + actingUnitId: person.unitId, + }); + expect(undoneCollect.rejections.length).toBe(collected.rejections.length); + expect(stayOf(undoneCollect, person.id).waitingForTransport).toEqual({ since: now, by: "ward" }); + expect(figures(undoneCollect, person.unitId)).toEqual(figures(left, person.unitId)); + }); + + it("the linked-patient departure takes the same path", () => { + const linked = seed.admissions.find( + (admission) => admission.patientId !== null && transportPendingFor(admission, seed.bedReleases), + ); + expect(linked, "the seed has a linked stay waiting on transport").toBeDefined(); + if (!linked) return; + const next = wardFlowReducer(seed, { + type: "RECORD_PATIENT_DISCHARGE", + role: "ward", + now, + admissionId: linked.id, + actingUnitId: linked.unitId, + patientId: linked.patientId as `PT-${string}`, + expectedGeneration: seed.worldGeneration, + expectedRevision: seed.dischargeRevisions[linked.id] ?? 0, + leavingDestination: "discharged-to-the-community", + awaitingTransport: true, + }); + expect(stayOf(next, linked.id).state).toBe("departed"); + expect(stayOf(next, linked.id).waitingForTransport).toEqual({ since: now, by: "ward" }); + expect(next.bedReleases.find((release) => release.admissionId === linked.id)?.preparing).toBe(false); + }); + + it("storage keeps the field on a departed stay only, as a time and a known role", () => { + const stored = saved(left); + expect(isValidStoredWardFlowState(stored)).toBe(true); + const withStay = (change: (stay: Record) => Record) => ({ + ...stored, + admissions: stored.admissions.map((stay) => + stay.id === person.id ? change(stay as unknown as Record) : stay, + ), + }); + // An older save without the field still loads. + expect( + isValidStoredWardFlowState( + withStay((stay) => { + const copy = { ...stay }; + delete copy.waitingForTransport; + return copy; + }), + ), + ).toBe(true); + expect(isValidStoredWardFlowState(withStay((stay) => ({ ...stay, state: "occupied" })))).toBe(false); + expect( + isValidStoredWardFlowState(withStay((stay) => ({ ...stay, waitingForTransport: { since: now, by: "nobody" } }))), + ).toBe(false); + expect( + isValidStoredWardFlowState( + withStay((stay) => ({ ...stay, waitingForTransport: { since: now, by: "ward", note: "typed text" } })), + ), + ).toBe(false); + expect( + isValidStoredWardFlowState(withStay((stay) => ({ ...stay, waitingForTransport: { since: NaN, by: "ward" } }))), + ).toBe(false); + }); +}); diff --git a/tests/ward-one-number-across-screens.test.ts b/tests/ward-one-number-across-screens.test.ts index 31add855ae..889d3548d1 100644 --- a/tests/ward-one-number-across-screens.test.ts +++ b/tests/ward-one-number-across-screens.test.ts @@ -23,6 +23,7 @@ import { wardOccupancy } from "@/components/ward-management/ward-occupancy"; import { edPressure } from "@/components/ward-management/ward-pressure"; import { openEdJourneys, waitingInEds } from "@/components/ward-management/ward-shared-figures"; import { NOW_ANCHOR, wardSites } from "@/components/ward-management/ward-sites"; +import { transportPendingFor, waitingForTransport } from "@/components/ward-management/ward-left-ward"; /** * AUDIT 4, ONE NUMBER, ONE ANSWER ACROSS SCREENS (issue #1567). One state, from the seed, and every @@ -429,6 +430,54 @@ describe("one number, one answer across Home, the board, the TV, Statistics and ).toEqual([]); expect(row.inUse, "occupancy ignores the named bed").toBe(unheld.inUse); }); + + /* + * 4. Item 18, "Left the ward, waiting for transport". The bed is Ready at once (Josh: no cleaning + * step), by one, on every screen, because it frees through the ordinary departure (D-115). + */ + it("Beds ready now (item 18): Left the ward raises Ready by one, at once, on every screen", () => { + const candidates = state.admissions.filter((admission) => transportPendingFor(admission, state.bedReleases)); + expect(candidates.length, "the seed has a discharge waiting on transport").toBeGreaterThan(0); + const left = candidates + .map((admission) => + wardFlowReducer(state, { + type: "RECORD_LEAVING", + role: "ward", + now, + admissionId: admission.id, + actingUnitId: admission.unitId, + leavingDestination: "discharged-to-the-community", + awaitingTransport: true, + }), + ) + .find( + (next) => + next.rejections.length === state.rejections.length && networkTvFigures(next, now).bedsReady > tv.bedsReady, + ); + expect(left, "one discharge waiting on transport can leave the ward and free a ready bed").toBeDefined(); + const after = { + home: homeFigures(left!, now), + tv: networkTvFigures(left!, now), + print: printFigures(left!, now), + statistics: statisticsFigures(left!, now), + boards: left!.units.map((unit) => boardFigures(left!, now, unit)), + }; + expect(after.tv.bedsReady, "Ready rose by exactly one").toBe(tv.bedsReady + 1); + expectOneAnswer("beds ready now, after Left the ward", [ + { screen: "Home hero", value: after.home.bedsReady }, + { screen: "ward boards, summed", value: after.boards.reduce((sum, board) => sum + board.readyNow, 0) }, + { + screen: "ward board Ready tiles, summed", + value: after.boards.reduce((sum, board) => sum + board.readyTiles, 0), + }, + { screen: "TV, all services", value: after.tv.bedsReady }, + { screen: "Statistics", value: after.statistics.bedsReady }, + { screen: "printed sheet", value: after.print.bedsReady }, + ]); + // And the person is still listed while on site. + const waiting = waitingForTransport(left!.admissions); + expect(waiting.length).toBe(waitingForTransport(state.admissions).length + 1); + }); }); /* diff --git a/tests/ward-referral-model.test.ts b/tests/ward-referral-model.test.ts index d085e6cbaf..e9c88176ad 100644 --- a/tests/ward-referral-model.test.ts +++ b/tests/ward-referral-model.test.ts @@ -460,6 +460,8 @@ describe("front-door contract — an ED may close to all admissions, never refus RECORD_ADMISSION_FOLLOW_UP: false, RECORD_PATIENT_DISCHARGE: false, RECORD_LEAVING: false, + // Item 18: the end of a wait for transport home. Not a refusal of anyone. + MARK_TRANSPORT_COLLECTED: false, RECORD_LEFT_DEPARTMENT: false, // RB3, item 19: the department recording that a patient no longer needs a bed search (going // home, or needing community follow-up instead). Not a refusal by a RECEIVING unit — no unit From b9cb589e46476d962f38cc3ff2ed363a0b8ea5ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 21:35:51 +0000 Subject: [PATCH 06/42] Overnight intake window: hero placement, sweeps and tests (feature item 13) Shows tonight's intake hours on the ward hero (desktop and phone) from the hero top bar and home section, so ward-screen.tsx stays under its line cap. Adds SET_INTAKE_WINDOW to the legal-figure sweep, the event-count history test and a DOM test. Re-records the storage verdict fixture: the seed now carries three synthetic intake windows, which adds swept paths. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK --- .../capacity/capacity-screen.tsx | 6 +- .../ward-management/ward-flow-reducer.ts | 5 +- .../ward/ward-intake-window-control.tsx | 164 ++++++++---------- .../ward/ward-screen-home-section.tsx | 3 + .../ward/ward-screen-top-bar.tsx | 3 + .../ward-management/ward/ward-screen.tsx | 3 - ...ward-flow-storage-validation-verdicts.json | 2 +- tests/helpers/ward-legal-figure-sweep.ts | 12 ++ tests/ward-history.test.ts | 3 +- tests/ward-intake-window.dom.test.tsx | 61 +++++++ tests/ward-intake-window.test.ts | 11 +- 11 files changed, 173 insertions(+), 100 deletions(-) create mode 100644 tests/ward-intake-window.dom.test.tsx diff --git a/src/components/ward-management/capacity/capacity-screen.tsx b/src/components/ward-management/capacity/capacity-screen.tsx index 9d7587891d..d1b13835bf 100644 --- a/src/components/ward-management/capacity/capacity-screen.tsx +++ b/src/components/ward-management/capacity/capacity-screen.tsx @@ -330,7 +330,11 @@ export function CapacityScreen({ openOnLoad }: { openOnLoad?: string } = {}) { one card, full width. Josh's fifth notes (8 Nov 2026): the row under it is gone; the shift figures are in the hero and an unchanged count on its ward card. */} {planned} - row.unit)} now={now} onWard={openWard} /> + row.unit)} + now={now} + onWard={openWard} + /> )} - {intakeWindowLine(tonight)} - {mayEdit ? ( - <> - {" · "} - - - ) : null} - {refused ? ( - - Not saved. {refused} - - ) : null} - send(null) }] : []), - ]} - primary={{ - label: "Save", - disabledReason: problem, - onAction: () => { - if (problem || !intake) return; - send(intake); - }, - }} - data-testid="ward-intake-window-dialog" - > -
    - - setFromText(event.target.value)} - data-testid="ward-intake-window-from" - /> - - - setToText(event.target.value)} - data-testid="ward-intake-window-to" - /> - -
    -
    -
    + <> + + {intakeWindowLine(tonight)} + {mayEdit ? ( + <> + {" · "} + + + ) : null} + {refused ? ( + + Not saved. {refused} + + ) : null} + send(null) }] : []), + ]} + primary={{ + label: "Save", + disabledReason: problem, + onAction: () => { + if (problem || !intake) return; + send(intake); + }, + }} + data-testid="ward-intake-window-dialog" + > +
    + + setFromText(event.target.value)} + data-testid="ward-intake-window-from" + /> + + + setToText(event.target.value)} + data-testid="ward-intake-window-to" + /> + +
    +
    +
    + ); } diff --git a/src/components/ward-management/ward/ward-screen-home-section.tsx b/src/components/ward-management/ward/ward-screen-home-section.tsx index fa2ceefc38..a65a3d77bc 100644 --- a/src/components/ward-management/ward/ward-screen-home-section.tsx +++ b/src/components/ward-management/ward/ward-screen-home-section.tsx @@ -3,6 +3,7 @@ // The ward screen's Home tab section. Split out of `ward-screen.tsx` (18 October 2026) to keep that // file under the line cap. +import { WardIntakeWindowControl } from "./ward-intake-window-control"; import { Clock, LayoutPanelLeft } from "lucide-react"; import { capacityConfirmer } from "@/components/ward-management/capacity/capacity-confirmer"; import type { FormEvent } from "react"; @@ -247,6 +248,8 @@ export function WardScreenHomeSection({ isPhone ? ( <> {wardHeroFoot} + {/* Feature item 13: tonight's intake hours, information only. */} + {wardHeroFootAside} ) : undefined diff --git a/src/components/ward-management/ward/ward-screen-top-bar.tsx b/src/components/ward-management/ward/ward-screen-top-bar.tsx index 33aef6b7a1..ac818309d7 100644 --- a/src/components/ward-management/ward/ward-screen-top-bar.tsx +++ b/src/components/ward-management/ward/ward-screen-top-bar.tsx @@ -3,6 +3,7 @@ // The ward screen's ward card and top action bar. Split out of `ward-screen.tsx` (18 October 2026) // to keep that file under the line cap. +import { WardIntakeWindowControl } from "./ward-intake-window-control"; import { useEffect, useState } from "react"; import { type WardHeroTopInput, wardHeroTopParts } from "./ward-hero-top-parts"; import { Bed, Check, Presentation, Send, Zap } from "lucide-react"; @@ -341,6 +342,8 @@ export function WardScreenTopBar({ band={wardBand} > {wardHeroMeta} + {/* Feature item 13: tonight's intake hours, information only. */} + {wardHeroMeta ? : null} {staffContact.tag} diff --git a/src/components/ward-management/ward/ward-screen.tsx b/src/components/ward-management/ward/ward-screen.tsx index 7c52b29881..28a213abb7 100644 --- a/src/components/ward-management/ward/ward-screen.tsx +++ b/src/components/ward-management/ward/ward-screen.tsx @@ -87,7 +87,6 @@ import { CheckingFoot, durMinutes, Menu, type MenuItem, useIsPhone, useOptionalT import { savedAtToast } from "@/components/ward-management/ward-saved-at-toast"; import { useActionToast } from "@/components/ward-management/use-action-toast"; import { useWardUnitQueues } from "./use-ward-unit-queues"; -import { WardIntakeWindowControl } from "./ward-intake-window-control"; import styles from "./ward.module.css"; import { wardAlerts } from "./ward-alert-counts"; import type { WaitingReferral } from "./ward-bed-board"; @@ -1989,8 +1988,6 @@ function WardOverviewScreen({ unitId, presentation = "overview", departurePlanni {" · "} {wardMeta} · {staffedSpecialling} staff on constant observation - {/* Feature item 13: tonight's intake hours, information only. */} - unit.id === unitId)!.allocatable.revision ?? 0, })), ); + // Feature item 13: tonight's intake hours, set from the next hour then cleared. Information only. + case "SET_INTAKE_WINDOW": + return unitIds.flatMap((unitId) => + [{ from: now + 60, to: now + 180 }, null].map((intake) => ({ + type, + role, + now, + unitId, + actingUnitId: unitId, + intake, + })), + ); case "RECORD_WARD_INTAKE_CONSTRAINTS": // Same claim-not-proof discipline as CONFIRM_CAPACITY above: `actingUnitId` mirrors // `unitId`. Owner Answer 18 (second round, 2026-09-17) — `codes` may be empty (nothing diff --git a/tests/ward-history.test.ts b/tests/ward-history.test.ts index 872515552a..6b29ab5b3a 100644 --- a/tests/ward-history.test.ts +++ b/tests/ward-history.test.ts @@ -69,7 +69,8 @@ describe("Ward Flow Read-Only Plain History", () => { // Review tracker and wait list (9 Nov 2026) add REQUEST_PSYCH_REVIEW, RECORD_PSYCH_REVIEW_SEEN // and MARK_WARD_READY_FOR_NEXT. Staff needed (9 Nov 2026) adds REQUEST_WARD_STAFF and // CLOSE_WARD_STAFF_REQUEST. - expect(allEventTypes.length).toBe(193); + // Feature item 13 (10 Nov 2026) adds SET_INTAKE_WINDOW. + expect(allEventTypes.length).toBe(194); for (const eventType of allEventTypes) { const config = EVENT_HISTORY_TABLE[eventType]; diff --git a/tests/ward-intake-window.dom.test.tsx b/tests/ward-intake-window.dom.test.tsx new file mode 100644 index 0000000000..67e5674de3 --- /dev/null +++ b/tests/ward-intake-window.dom.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +// Same jsdom-App-Router workaround as the sibling ward-screen dom suites. +vi.mock("next/link", () => ({ + default: ({ children, href, ...rest }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + +import { CapacityIntakeWindowsCard } from "@/components/ward-management/capacity/capacity-intake-windows-card"; +import { WardFlowProvider } from "@/components/ward-management/ward-flow-provider"; +import { seedWardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import { WardScreen } from "@/components/ward-management/ward/ward-screen"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; + +function renderWard(unitId: string) { + render( + + + , + ); +} + +describe("tonight's intake hours on the ward hero (feature item 13)", () => { + it("shows a seeded window", () => { + renderWard("rph-adult-secure"); + expect(screen.getByTestId("ward-intake-window-line").textContent).toBe("Tonight: admit 21:00 to 02:00"); + }); + + it("starts empty, and saves two typed times", () => { + renderWard("rph-older-adult"); + expect(screen.getByTestId("ward-intake-window-line").textContent).toBe("Tonight: not set"); + fireEvent.click(screen.getByTestId("ward-intake-window-edit")); + const from = screen.getByTestId("ward-intake-window-from") as HTMLInputElement; + const to = screen.getByTestId("ward-intake-window-to") as HTMLInputElement; + // Nothing pre-filled. + expect(from.value).toBe(""); + expect(to.value).toBe(""); + fireEvent.change(from, { target: { value: "2200" } }); + fireEvent.change(to, { target: { value: "03:00" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(screen.getByTestId("ward-intake-window-line").textContent).toBe("Tonight: admit 22:00 to 03:00"); + }); +}); + +describe("Tonight's intake windows card", () => { + it("lists the seeded windows and folds the rest into one count line", () => { + const units = seedWardFlowState().units; + render(); + const card = screen.getByTestId("capacity-intake-windows"); + const rows = within(card).getAllByTestId("capacity-intake-window-row"); + expect(rows).toHaveLength(3); + expect(within(card).getByTestId("capacity-intake-windows-not-set").textContent).toContain( + `${units.length - 3} wards haven't set a window.`, + ); + }); +}); diff --git a/tests/ward-intake-window.test.ts b/tests/ward-intake-window.test.ts index 0298665709..b408462436 100644 --- a/tests/ward-intake-window.test.ts +++ b/tests/ward-intake-window.test.ts @@ -86,7 +86,8 @@ describe("SET_INTAKE_WINDOW", () => { expect(set.rejections).toEqual([]); expect(unitOf(set, unitId).intakeWindow).toEqual({ ...intake, at: NOW, by: "Nurse in charge" }); // Information only: no count moves. - expect(unitOf(set, unitId).allocatable).toEqual(unitOf(state, unitId).allocatable); + expect(unitOf(set, unitId).allocatable.value).toBe(unitOf(state, unitId).allocatable.value); + expect(unitOf(set, unitId).empty.value).toBe(unitOf(state, unitId).empty.value); const cleared = send(set, { unitId, intake: null }); expect(cleared.rejections).toEqual([]); @@ -155,15 +156,15 @@ describe("tonightIntakeWindowList", () => { describe("stored intake windows", () => { it("loads a saved state with and without an intake window, and refuses a malformed one", () => { const state = seedWardFlowState(); - expect(isValidStoredWardFlowState(state)).toBe(true); - const withoutAny = { ...state, units: state.units.map(({ intakeWindow: _drop, ...unit }) => unit) }; - expect(isValidStoredWardFlowState(withoutAny)).toBe(true); + expect(isValidStoredWardFlowState(JSON.parse(JSON.stringify(state)))).toBe(true); + const withoutAny = { ...state, units: state.units.map((unit) => ({ ...unit, intakeWindow: undefined })) }; + expect(isValidStoredWardFlowState(JSON.parse(JSON.stringify(withoutAny)))).toBe(true); const broken = { ...state, units: state.units.map((unit, index) => index === 0 ? { ...unit, intakeWindow: { from: "21:00", to: 120, at: 0, by: "x" } } : unit, ), }; - expect(isValidStoredWardFlowState(broken)).toBe(false); + expect(isValidStoredWardFlowState(JSON.parse(JSON.stringify(broken)))).toBe(false); }); }); From 34ca919e6a13c32356f7c27f67847c199dcaaf35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 22:01:17 +0000 Subject: [PATCH 07/42] Fold Left the ward into They have left, and pad the intake windows card The board row keeps its two actions: when transport is still pending, the They have left confirmation asks whether the person is still waiting for it, never defaulted. The intake windows card rows get the same padding as the closed beds card. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxmfmBRcfvStcCSeCwK --- .../ward-management/board/ward-board.tsx | 77 +++++++++++++------ .../capacity-intake-windows-card.module.css | 11 ++- tests/ward-board-left-ward.dom.test.tsx | 46 +++++++---- 3 files changed, 97 insertions(+), 37 deletions(-) diff --git a/src/components/ward-management/board/ward-board.tsx b/src/components/ward-management/board/ward-board.tsx index 4398cbf207..c052714f53 100644 --- a/src/components/ward-management/board/ward-board.tsx +++ b/src/components/ward-management/board/ward-board.tsx @@ -5,7 +5,7 @@ import { useUndoableStep } from "@/components/ward-management/use-undoable-step" import type { WardCommandDispatch, WardCommandOutcome } from "@/components/ward-management/ward-shared-client"; import { WardNotFoundBody } from "@/components/ward-management/shell/ward-not-found"; import { wardScrollBehavior } from "@/components/ward-management/ward-scroll"; -import { useIsPhone, useModalFocus } from "@/components/wf"; +import { Select, useIsPhone, useModalFocus } from "@/components/wf"; import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"; import { @@ -182,7 +182,9 @@ export function WardBoard({ identity?: string; item?: ShiftTileItem; admissionId?: string; - /** Item 18: "Left the ward", still on site waiting for transport. */ + /** Item 18: transport is still pending, so the dialog asks whether they are waiting for it. */ + offerTransport?: boolean; + /** Item 18: left the ward, still on site waiting for transport. Unset until chosen. */ awaitingTransport?: boolean; } | null>(null); const dialogTriggerRef = useRef(null); @@ -802,12 +804,6 @@ export function WardBoard({ text: `${isConfirmed ? "Confirmed out" : "Expected out"} today, still here.${blockerPhrase}`, acts: [ { word: "They have left", say: "would release this bed and make it ready" }, - // Item 18: still waiting on transport, so the person can leave the ward and the bed be Ready now. - ...(admissions.some( - (admission) => admission.id === occupant.key && transportPendingFor(admission, liveBedReleases), - ) - ? [{ word: "Left the ward", say: "would free this bed now and list them as waiting for transport" }] - : []), { word: "Record hold-up", tone: "warn", say: "would record what is holding this discharge up" }, ], }); @@ -890,6 +886,7 @@ export function WardBoard({ const admissionId = pendingConfirm.admissionId || pendingConfirm.item?.selectableKey; const leavingDestination = destinationFor(admissionId); if (!admissionId || leavingDestination === "") return; + if (pendingConfirm.offerTransport && pendingConfirm.awaitingTransport === undefined) return; const awaitingTransport = pendingConfirm.awaitingTransport === true; recordDepartureAndReport( () => @@ -929,19 +926,23 @@ export function WardBoard({ }; const handleShiftAction = (item: ShiftTileItem, word: string) => { - if (word === "Left the ward") { - // Item 18: always confirmed, because where they went is chosen here and never defaulted. - setPendingConfirm({ - kind: "leaving", - who: item.who || nameFor(item.selectableKey), - identity: identityFor(item.selectableKey), - item, - admissionId: item.selectableKey, - awaitingTransport: true, - }); - return; - } if (word === "They have left") { + // Item 18: with transport still pending, the confirmation also asks whether they are waiting + // for it (never defaulted), so one button covers both and the row keeps its two actions. + const offerTransport = admissions.some( + (admission) => admission.id === item.selectableKey && transportPendingFor(admission, liveBedReleases), + ); + if (offerTransport) { + setPendingConfirm({ + kind: "leaving", + who: item.who || nameFor(item.selectableKey), + identity: identityFor(item.selectableKey), + item, + admissionId: item.selectableKey, + offerTransport: true, + }); + return; + } const leavingDestination = destinationFor(item.selectableKey); const executeLeaving = (leavingDestination: LeavingDestination) => { recordDepartureAndReport( @@ -1344,7 +1345,7 @@ export function WardBoard({

    - {pendingConfirm.awaitingTransport + {pendingConfirm.offerTransport ? "Confirm left the ward" : pendingConfirm.kind === "leaving" ? "Confirm discharge" @@ -1413,6 +1414,35 @@ export function WardBoard({ /> ) : null} + {pendingConfirm.offerTransport ? ( + <> + + + + ) : null} +