From 4244fbd53efbbf2f78e1f1ebb09f1934473e4983 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:51:56 +0800 Subject: [PATCH 01/51] =?UTF-8?q?Show=20a=20calm=20Saving=E2=80=A6=20pill?= =?UTF-8?q?=20first;=20warn=20only=20after=208=20s,=20offline=20or=20on=20?= =?UTF-8?q?failure=20(live=20audit=20item=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ward-flow/desk-guides/access-admin.md | 3 +- docs/ward-flow/desk-guides/community-team.md | 3 +- .../ward-flow/desk-guides/flow-coordinator.md | 3 +- .../ward-flow/desk-guides/site-bed-manager.md | 3 +- .../desk-guides/transport-officer.md | 3 +- docs/ward-flow/desk-guides/ward.md | 3 +- .../ward-shared-save-status.module.css | 23 +++++ .../ward-shared-save-status.tsx | 96 ++++++++++++++----- .../ward-shared-save-status-bar.dom.test.tsx | 52 +++++++++- 9 files changed, 157 insertions(+), 32 deletions(-) diff --git a/docs/ward-flow/desk-guides/access-admin.md b/docs/ward-flow/desk-guides/access-admin.md index 7f679bcebc..c4138b0dce 100644 --- a/docs/ward-flow/desk-guides/access-admin.md +++ b/docs/ward-flow/desk-guides/access-admin.md @@ -29,7 +29,8 @@ Access is managed in **Settings**. On a phone, the bar at the bottom has **Gover ## Saved, or not - After a change, a short message says it was recorded. -- On a shared board the status line reads "Saving shared changes…", then your desk name again. +- On a shared board a small "Saving…" note shows while a save goes through. If it takes longer + than 8 seconds, or you are offline, the status line warns you not to reload. - If it reads "Connection unavailable — changes are disabled." and shows **Retry**: stay on the page and press **Retry**. Do not reload. Reloading loses the unconfirmed change. - A message saying a change was not recorded means nothing changed. Read the reason and try again. diff --git a/docs/ward-flow/desk-guides/community-team.md b/docs/ward-flow/desk-guides/community-team.md index 6b5e948555..5004b671f9 100644 --- a/docs/ward-flow/desk-guides/community-team.md +++ b/docs/ward-flow/desk-guides/community-team.md @@ -43,7 +43,8 @@ holds **New referral**, and **Activity** is under **Menu**. - After an action, a short message says it was recorded. In the demo it adds "Saved on this device only." - A contact is shown as saved only once the save is confirmed. -- On a shared board the status line reads "Saving shared changes…", then your desk name again. +- On a shared board a small "Saving…" note shows while a save goes through. If it takes longer + than 8 seconds, or you are offline, the status line warns you not to reload. - If it reads "Connection unavailable — changes are disabled." and shows **Retry**: stay on the page and press **Retry**. Do not reload. Reloading loses the unconfirmed action. - A message saying an action was not recorded means nothing changed. Read the reason and try again. diff --git a/docs/ward-flow/desk-guides/flow-coordinator.md b/docs/ward-flow/desk-guides/flow-coordinator.md index b83c57a84e..e4be9e7b6e 100644 --- a/docs/ward-flow/desk-guides/flow-coordinator.md +++ b/docs/ward-flow/desk-guides/flow-coordinator.md @@ -62,7 +62,8 @@ Source: [Home queue order](../../../src/components/ward-management/ward-priority - After an action, a short message says it was recorded. In the demo it adds "Saved on this device only." -- On a shared board the status line reads "Saving shared changes…", then your desk name again. +- On a shared board a small "Saving…" note shows while a save goes through. If it takes longer + than 8 seconds, or you are offline, the status line warns you not to reload. - If it reads "Connection unavailable — changes are disabled." and shows **Retry**: stay on the page and press **Retry**. Do not reload. Reloading loses the unconfirmed action. - A message saying an action was not recorded means nothing changed. Read the reason and try again. diff --git a/docs/ward-flow/desk-guides/site-bed-manager.md b/docs/ward-flow/desk-guides/site-bed-manager.md index fe7f774dc2..e9f22a302c 100644 --- a/docs/ward-flow/desk-guides/site-bed-manager.md +++ b/docs/ward-flow/desk-guides/site-bed-manager.md @@ -38,7 +38,8 @@ are under **Menu**. - After an action, a short message says it was recorded. In the demo it adds "Saved on this device only." -- On a shared board the status line reads "Saving shared changes…", then your desk name again. +- On a shared board a small "Saving…" note shows while a save goes through. If it takes longer + than 8 seconds, or you are offline, the status line warns you not to reload. - If it reads "Connection unavailable — changes are disabled." and shows **Retry**: stay on the page and press **Retry**. Do not reload. Reloading loses the unconfirmed action. - A message saying an action was not recorded means nothing changed. Read the reason and try again. diff --git a/docs/ward-flow/desk-guides/transport-officer.md b/docs/ward-flow/desk-guides/transport-officer.md index ef9d9442a8..2a6c3af24c 100644 --- a/docs/ward-flow/desk-guides/transport-officer.md +++ b/docs/ward-flow/desk-guides/transport-officer.md @@ -34,7 +34,8 @@ You get phone alerts when a job is new or changes. - After each step, a short message says it was recorded, or why it was refused. In the demo it adds "Saved on this device only." -- On a shared board the status line reads "Saving shared changes…", then your desk name again. +- On a shared board a small "Saving…" note shows while a save goes through. If it takes longer + than 8 seconds, or you are offline, the status line warns you not to reload. - If it reads "Connection unavailable — changes are disabled." and shows **Retry**: stay on the page and press **Retry**. Do not reload. Reloading loses the unconfirmed action. - A message saying an action was not recorded means nothing changed. Read the reason and try again. diff --git a/docs/ward-flow/desk-guides/ward.md b/docs/ward-flow/desk-guides/ward.md index 47219e6372..30137f4fad 100644 --- a/docs/ward-flow/desk-guides/ward.md +++ b/docs/ward-flow/desk-guides/ward.md @@ -45,7 +45,8 @@ bed count is not read as a problem with the patient. - After an action, a short message says it was recorded. In the demo it adds "Saved on this device only." -- On a shared board the status line reads "Saving shared changes…", then your desk name again. +- On a shared board a small "Saving…" note shows while a save goes through. If it takes longer + than 8 seconds, or you are offline, the status line warns you not to reload. - If it reads "Connection unavailable — changes are disabled." and shows **Retry**: stay on the page and press **Retry**. Do not reload. Reloading loses the unconfirmed action. - A message saying an action was not recorded means nothing changed. Read the reason and try again. diff --git a/src/components/ward-management/ward-shared-save-status.module.css b/src/components/ward-management/ward-shared-save-status.module.css index 7fe1fe5e9a..f850f84cab 100644 --- a/src/components/ward-management/ward-shared-save-status.module.css +++ b/src/components/ward-management/ward-shared-save-status.module.css @@ -60,6 +60,25 @@ white-space: nowrap; } +/* + * Live audit item 8 (11 Nov 2026): a save in flight shows this small calm pill, not the warning + * strip. It floats, so the page never jumps on every save, and takes no taps. + */ +.pill { + position: fixed; + inset-block-end: var(--wf-s-4); + inset-inline-start: var(--wf-s-4); + z-index: 40; + padding: var(--wf-s-1) var(--wf-s-3); + border: 1px solid var(--wf-line); + border-radius: 999px; + color: var(--wf-ink-2); + background: var(--wf-surface); + font-size: var(--wf-fs-12); + line-height: 1.4; + pointer-events: none; +} + /* * The strip above the app on the shared board, shown only when a lost connection or an unconfirmed * save needs action. One slim row in the shell's own tokens: what is wrong on the left, Retry and @@ -152,6 +171,10 @@ flex: none; } + .pill { + inset-block-end: calc(var(--wf-dock-clear, calc(72px + max(8px, env(safe-area-inset-bottom, 0px)))) + 0.5rem); + } + /* The reload warning must stay readable, so a strip carrying it wraps. */ .bar:has(> p) { flex-wrap: wrap; diff --git a/src/components/ward-management/ward-shared-save-status.tsx b/src/components/ward-management/ward-shared-save-status.tsx index 81979cd452..24f2547317 100644 --- a/src/components/ward-management/ward-shared-save-status.tsx +++ b/src/components/ward-management/ward-shared-save-status.tsx @@ -1,6 +1,14 @@ "use client"; -import { useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode, type RefObject } from "react"; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, + type ReactNode, + type RefObject, +} from "react"; import { createPortal } from "react-dom"; import { clk, useSharedRefreshLate } from "@/components/wf"; import { subscribeToSheetStack, topSheetRoot } from "@/components/wf/sheet-focus"; @@ -17,6 +25,38 @@ function waitingMessage(waiting: number): string { const RELOAD_WARNING = "Not confirmed yet. Do not reload: reloading now would lose this action. Press Retry first."; +/** + * Live audit item 8 (11 Nov 2026): a normal save takes 3 to 4 s, so the full warning flashed on every + * save. A save in flight shows a calm "Saving…" first; the warning comes after this long, when the + * browser is offline, or once the save has failed. + */ +const SAVE_WARNING_AFTER_MS = 8_000; + +const onlineNow = () => typeof navigator === "undefined" || navigator.onLine !== false; +function subscribeOnline(notify: () => void) { + window.addEventListener("online", notify); + window.addEventListener("offline", notify); + return () => { + window.removeEventListener("online", notify); + window.removeEventListener("offline", notify); + }; +} + +/** True once a save has been in flight for `SAVE_WARNING_AFTER_MS`, or at once while offline. */ +function useSaveSlow(saving: boolean): boolean { + const online = useSyncExternalStore(subscribeOnline, onlineNow, () => true); + const [slow, setSlow] = useState(false); + useEffect(() => { + if (!saving) return; + const timer = setTimeout(() => setSlow(true), SAVE_WARNING_AFTER_MS); + return () => { + clearTimeout(timer); + setSlow(false); + }; + }, [saving]); + return saving && (slow || !online); +} + /** Keep recovery inside the top overlay's focus trap, without reopening or closing any record. */ function useRecoveryHost(show: boolean): HTMLElement | null { "use no memo"; @@ -89,26 +129,32 @@ export function WardSharedSaveStatus({ "use no memo"; const save = useWardSharedSave(); const [retrying, setRetrying] = useState(false); + const inFlight = connection.status === "saving"; + const slow = useSaveSlow(inFlight); + // Item 8: a save in flight with no error stays calm until it is slow or the browser is offline. + // A save still waiting after a failure (status back to ready or unavailable) warns at once. + const calmSave = inFlight && !retrying && !slow && !connection.error; // WF-07: one warning node reused in both strip layouts (was duplicated JSX). - const reloadWarning = save?.unconfirmedSave ? ( -

- {RELOAD_WARNING} -

- ) : null; + const reloadWarning = + save?.unconfirmedSave && !calmSave ? ( +

+ {RELOAD_WARNING} +

+ ) : null; const stale = useSharedRefreshLate(connection.enabled, 5 * 60_000, false) && connection.receivedAt > 0; // Tier 1 item 4 (Josh, 4 Nov 2026): a refresh that is late but not yet failed used to show only on // pages with a Live chip. The strip now says when the board last updated, on every screen. const late = useSharedRefreshLate(connection.enabled && connection.status !== "unavailable") && connection.receivedAt > 0; // Inside an open sheet the strip is inert, so a late refresh moves its words and Retry there too. - const showRecovery = connection.status === "saving" || connection.status === "unavailable" || late || stale; + const showRecovery = (inFlight && !calmSave) || connection.status === "unavailable" || late || stale; const host = useRecoveryHost(showRecovery); const saving = connection.status === "saving" || retrying; const recoveryRef = useRef(null); // Josh, 27 Oct 2026 ("it is pointless"): the strip shows only when something needs action, a lost // connection or a save not yet confirmed. Otherwise its words stay a screen-reader status, and // Sign out lives in the rail and the phone menu (`WardAccountActions`). - const problem = connection.status === "unavailable" || !!save?.unconfirmedSave || late || stale; + const problem = connection.status === "unavailable" || (!!save?.unconfirmedSave && !calmSave) || late || stale; const barRef = useRef(null); useBarHeight(barRef, problem); // Retry disappears while the save runs. Park focus on the persistent status, still inside the sheet. @@ -127,21 +173,23 @@ export function WardSharedSaveStatus({ const waiting = connection.waiting ?? 0; const message = retrying ? "Retrying…" - : saving - ? "Saving shared changes… Keep this page open until the save is confirmed." - : connection.status === "unavailable" - ? // Tier 1 item 1: saves waiting while offline go by themselves when the connection returns. - waiting > 0 && !connection.boardCurrent - ? waitingMessage(waiting) - : // Audit 24-3 SM-4: the connection is fine when only a save waits on Retry; its message says so. - connection.boardCurrent - ? "" - : saveProblem("connectionUnavailable") - : stale - ? `Stale. Last updated at ${clk(connection.receivedAt)}. Check updates before acting.` - : late - ? `Not updated since ${clk(connection.receivedAt)}. Still checking every few seconds.` - : `${deskLabel} · shared synthetic workspace`; + : calmSave + ? "Saving…" + : saving + ? "Saving shared changes… Keep this page open until the save is confirmed." + : connection.status === "unavailable" + ? // Tier 1 item 1: saves waiting while offline go by themselves when the connection returns. + waiting > 0 && !connection.boardCurrent + ? waitingMessage(waiting) + : // Audit 24-3 SM-4: the connection is fine when only a save waits on Retry; its message says so. + connection.boardCurrent + ? "" + : saveProblem("connectionUnavailable") + : stale + ? `Stale. Last updated at ${clk(connection.receivedAt)}. Check updates before acting.` + : late + ? `Not updated since ${clk(connection.receivedAt)}. Still checking every few seconds.` + : `${deskLabel} · shared synthetic workspace`; const freshness = connection.status === "unavailable" && connection.receivedAt > 0 ? `${stale ? "Stale. " : ""}Last updated at ${clk(connection.receivedAt)}. Check updates before acting.` @@ -158,7 +206,7 @@ export function WardSharedSaveStatus({
{ publishLiveRefresh(undefined); } }); + +// Live audit item 8 (11 Nov 2026): a normal save shows a calm "Saving…" first. The full reload +// warning comes only after 8 s, while offline, or once the save has failed. +describe("a save in flight", () => { + const saving = (overrides: Partial = {}) => + ({ ...connection("saving"), error: null, ...overrides }) as unknown as WardSharedConnection; + const withSave = (node: React.ReactNode) => ( + {} }}> + {node} + + ); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("shows a calm Saving… pill, then the full warning after 8 s", () => { + vi.useFakeTimers(); + render(withSave()); + const status = screen.getByTestId("ward-shared-status-bar"); + expect(status).toHaveTextContent(/^Saving…$/u); + expect(status.className).toMatch(/pill/u); + expect(screen.queryByTestId("ward-shared-reload-warning")).toBeNull(); + expect(screen.queryByRole("button")).toBeNull(); + act(() => { + vi.advanceTimersByTime(7_999); + }); + expect(screen.queryByTestId("ward-shared-reload-warning")).toBeNull(); + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.getByTestId("ward-shared-reload-warning")).toBeVisible(); + expect(status).toHaveTextContent("Saving shared changes… Keep this page open until the save is confirmed."); + }); + + it("warns at once while the browser is offline", () => { + vi.spyOn(navigator, "onLine", "get").mockReturnValue(false); + render(withSave()); + expect(screen.getByTestId("ward-shared-reload-warning")).toBeVisible(); + }); + + it("warns at once when the save carries an error", () => { + render(withSave()); + expect(screen.getByTestId("ward-shared-reload-warning")).toBeVisible(); + }); +}); From 28e926875c2178842caef96bec70c0cbe08b87cc Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:51:57 +0800 Subject: [PATCH 02/51] Keep the Midland demo team off the signed-in shared workspace (live audit item 6) --- .../ward-management/community/community-screen.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/ward-management/community/community-screen.tsx b/src/components/ward-management/community/community-screen.tsx index 5e3c6a577c..8836c6d858 100644 --- a/src/components/ward-management/community/community-screen.tsx +++ b/src/components/ward-management/community/community-screen.tsx @@ -432,7 +432,7 @@ function useCommunityScreenView({ teamId, admissions, referrals, demonstration = generation: worldGeneration, shared, onAccepted: (source) => { - if (MIDLAND_DEMO_TEAM.test(teamId)) recordMidlandContact("team"); + if (!shared && MIDLAND_DEMO_TEAM.test(teamId)) recordMidlandContact("team"); if (source === "modal") setActiveModalType((current) => (current === "contact" ? null : current)); else setToastMessage("Team contact logged. This does not record patient or referral contact."); }, @@ -448,8 +448,11 @@ function useCommunityScreenView({ teamId, admissions, referrals, demonstration = const hasExplicitProps = Boolean(admissions || referrals); const isDemoMode = demonstration && !hasExplicitProps; // The live Midland page carries the locked mock-up's demo team (Q126, Q132: Midland only). Supplied - // records, and the older demonstration cohort, keep their own lists. - const midlandDemo = !hasExplicitProps && !demonstration && MIDLAND_DEMO_TEAM.test(teamId); + // records, and the older demonstration cohort, keep their own lists. Live audit item 6 (11 Nov + // 2026): the signed-in shared workspace never shows the demo team, so its home lists the shared + // referrals and a contact is saved to the workspace, not kept in this browser. + const sharedWorkspace = Boolean(shared) || process.env.NEXT_PUBLIC_WARD_SHARED_ENABLED === "true"; + const midlandDemo = !hasExplicitProps && !demonstration && !sharedWorkspace && MIDLAND_DEMO_TEAM.test(teamId); const midlandDecisions = useMidlandDecisions(); const { demoTeamReferrals, demoVisibleReferrals, demoVisibleInpatients, demoVisibleEgress, demoVisibleCaseload } = From 0aaa7f55486defa1fd21f1977f642e227a39bfa4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:51:58 +0800 Subject: [PATCH 03/51] Hide the demo Ready to leave checklist on the shared workspace (live audit item 7) --- .../discharges/discharge-ready-to-leave.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/ward-management/discharges/discharge-ready-to-leave.tsx b/src/components/ward-management/discharges/discharge-ready-to-leave.tsx index 2bab425fd5..cd3e1dcd42 100644 --- a/src/components/ward-management/discharges/discharge-ready-to-leave.tsx +++ b/src/components/ward-management/discharges/discharge-ready-to-leave.tsx @@ -1,10 +1,12 @@ "use client"; import { Check } from "lucide-react"; -import { useId } from "react"; +import { useContext, useId } from "react"; import { Frac, StatusGlyph, ToastView, cx, useIsPhone } from "@/components/wf"; +import { SharedSessionContext } from "../shell/ward-shared-session-context"; + import styles from "./discharge-ready-to-leave.module.css"; /** @@ -55,6 +57,7 @@ export function ReadyToLeave({ onDismiss: () => void; }) { const headingId = useId(); + const signedIn = useContext(SharedSessionContext) !== null; // v10 phone: a glyph list (green tick or open circle), not checkbox squares. const isPhone = useIsPhone(); const items: readonly { id: ReadyToLeaveItemId; label: string }[] = onCto @@ -62,6 +65,9 @@ export function ReadyToLeave({ : READY_TO_LEAVE_ITEMS; const done = items.filter((item) => ticked.includes(item.id)).length; const changedLabel = lastChange ? items.find((item) => item.id === lastChange.itemId)?.label : undefined; + // Live audit item 7 (11 Nov 2026): the checklist and its ticks are invented for the demo, so the + // signed-in shared workspace does not show it until a real shared checklist exists. + if (signedIn || process.env.NEXT_PUBLIC_WARD_SHARED_ENABLED === "true") return null; return (
From 1e5424093076ea4ea006a7c8e4047043e3a32876 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:52:00 +0800 Subject: [PATCH 04/51] List a bed's closures and reopenings in its drawer History (live audit item 13) --- .../ward/ward-bed-dossier-drawer.tsx | 14 ++++++++++- tests/ward-bed-closed-mark.dom.test.tsx | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx b/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx index 795debbbe9..f4af1d04cb 100644 --- a/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx +++ b/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx @@ -24,6 +24,7 @@ import { BED_RELEASE_BLOCKERS, type BedReleaseBlocker } from "@/components/ward- import { useWardModalFocus } from "@/components/ward-management/ward-modal-focus"; import { BlockerEditor, useBlockerSave } from "./ward-bed-blocker-editor"; import { BedClosureControls, type BedClosureActions } from "./ward-bed-closure-controls"; +import { wardClosureReasonLabel } from "../capacity/ward-closure"; import { openJourneyFromStay, SentJourneyChanges, @@ -539,8 +540,19 @@ export function WardBedDossierDrawer({ tone: "success", label: `Hold-up cleared: ${cleared.blocker}, by ${roleLabel(cleared.by)}`, }); - timeline.sort((left, right) => right.at - left.at); } + // Live audit item 13 (11 Nov 2026): this bed's own closures (`Unit.closedBeds`), closed and reopened, + // with who and why. They belong to the bed, so they show with or without anyone in it. + for (const mark of unit.closedBeds ?? []) { + if (mark.bedNumber !== selectedBed) continue; + timeline.push({ + at: mark.at, + tone: "warning", + label: `Bed closed${mark.reason ? `: ${wardClosureReasonLabel(mark.reason)}` : ""}, by ${mark.by}`, + }); + if (mark.reopenedAt !== undefined) timeline.push({ at: mark.reopenedAt, tone: "success", label: "Bed reopened" }); + } + timeline.sort((left, right) => right.at - left.at); const openBlockerEditor = () => { const opening = !quickBlockerOpen; diff --git a/tests/ward-bed-closed-mark.dom.test.tsx b/tests/ward-bed-closed-mark.dom.test.tsx index 28d98dcbe2..f541f4f25b 100644 --- a/tests/ward-bed-closed-mark.dom.test.tsx +++ b/tests/ward-bed-closed-mark.dom.test.tsx @@ -73,4 +73,27 @@ describe("closing the exact bed from the bed drawer", () => { expect(card(bed)).toHaveAttribute("data-state", "ready"); expect(bedsIn("ready")).toHaveLength(ready.length); }); + + // Live audit item 13 (11 Nov 2026): the bed's History lists its closure and its reopening. + it("lists the closure and the reopening in the bed's History", () => { + render( + + + + + + , + ); + const bed = Math.min(...bedsIn("ready")); + fireEvent.click(card(bed)); + fireEvent.click(screen.getByTestId("ward-drawer-close-bed")); + fireEvent.click(screen.getByTestId("ward-drawer-reopen-bed")); + const toggle = screen.getByTestId("bed-drawer-history-toggle"); + fireEvent.click(toggle); + const history = document.getElementById(toggle.getAttribute("aria-controls")!)!; + expect(history).toBeVisible(); + expect(history).toHaveTextContent(/Bed closed, by /u); + expect(history).toHaveTextContent("Bed reopened"); + expect(toggle).toHaveTextContent("History2"); + }); }); From 1074a0522c6c9ff64dd0e1cb2d165862524f3e2f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:52:01 +0800 Subject: [PATCH 05/51] Add Leave the demo to the account menu, phone menu and Settings (live audit item 22) --- .../ward-management/front-door/leave-demo.ts | 38 +++++++++++++++++++ .../settings/settings-help-and-demo.tsx | 29 +++++++++++++- .../shell-next/next-account-menu.tsx | 10 ++++- .../shell-next/next-phone-menu-sheet.tsx | 23 ++++++++++- 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 src/components/ward-management/front-door/leave-demo.ts diff --git a/src/components/ward-management/front-door/leave-demo.ts b/src/components/ward-management/front-door/leave-demo.ts new file mode 100644 index 0000000000..27391be485 --- /dev/null +++ b/src/components/ward-management/front-door/leave-demo.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +/** + * Live audit item 22 (11 Nov 2026): the public demo had no way to leave. Only a site with the front + * door on has its readable marker cookie (`wf_door=1`), so "Leave the demo" shows only there, and + * only in the local demo; a signed-in desk has Sign out. Leaving ends the demo the way a sign-out + * ends a session (D-88): this browser's demo state and drafts are cleared, the front door's pass + * ends through the existing `DELETE /api/session`, and the sign-in page opens. + */ +const noChange = () => () => {}; + +function markerPresent(): boolean { + try { + return document.cookie.split(/;\s*/).includes("wf_door=1"); + } catch { + return false; + } +} + +/** True on a front-door site in the browser. False while the page is drawn on the server. */ +export function useCanLeaveDemo(): boolean { + return useSyncExternalStore(noChange, markerPresent, () => false); +} + +export async function leaveDemo(): Promise { + try { + // Loaded on use: the shell does not carry the session-end sweep on every page. + const { endWardFlowBrowserSession } = await import("../ward-browser-data"); + endWardFlowBrowserSession(); + } catch { + // Keep going: the pass below is what keeps this browser out. + } + // Awaited, so the sign-in page never opens while the pass is still good. + await fetch("/api/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {}); + window.location.assign("/sign-in"); +} diff --git a/src/components/ward-management/settings/settings-help-and-demo.tsx b/src/components/ward-management/settings/settings-help-and-demo.tsx index c1a81dd99a..29ac197295 100644 --- a/src/components/ward-management/settings/settings-help-and-demo.tsx +++ b/src/components/ward-management/settings/settings-help-and-demo.tsx @@ -8,10 +8,22 @@ import Link from "next/link"; import { type ChangeEvent, type ReactNode } from "react"; -import { BookOpen, Copy, Database, Download, Info, RotateCcw, ShieldCheck, Upload, Wrench } from "lucide-react"; +import { + BookOpen, + Copy, + Database, + DoorOpen, + Download, + Info, + RotateCcw, + ShieldCheck, + Upload, + Wrench, +} from "lucide-react"; import { digestHref } from "@/components/ward-management/shell/ward-facade"; import { FeatureFlagsCard } from "@/components/ward-management/settings/feature-flags-card"; +import { leaveDemo, useCanLeaveDemo } from "@/components/ward-management/front-door/leave-demo"; import { Button, Card, CardHead, buttonClass } from "@/components/wf"; import styles from "@/components/ward-management/settings/settings.module.css"; @@ -184,6 +196,7 @@ export function SettingsDemoTools({ onCopyDiagnostics?: () => void; about?: AboutProps; }) { + const canLeave = useCanLeaveDemo(); return (
@@ -212,6 +225,20 @@ export function SettingsDemoTools({ {onCopyDiagnostics ? : null} + {/* Live audit item 22 (11 Nov 2026): the public demo's way out. */} + {canLeave ? ( + + + + ) : null}
{about ? ( diff --git a/src/components/ward-management/shell-next/next-account-menu.tsx b/src/components/ward-management/shell-next/next-account-menu.tsx index a1bc82ca6a..77ec77b0be 100644 --- a/src/components/ward-management/shell-next/next-account-menu.tsx +++ b/src/components/ward-management/shell-next/next-account-menu.tsx @@ -1,11 +1,12 @@ "use client"; import { useContext, type RefObject } from "react"; -import { ChevronsUpDown, CircleHelp, LogOut, Repeat, Settings } from "lucide-react"; +import { ChevronsUpDown, CircleHelp, DoorOpen, LogOut, Repeat, Settings } from "lucide-react"; import { useRouter } from "next/navigation"; import { Menu, type MenuItem } from "@/components/wf"; import { useWardHelpRow } from "@/components/ward-management/help/ward-help-link"; +import { leaveDemo, useCanLeaveDemo } from "@/components/ward-management/front-door/leave-demo"; import { SharedSessionContext } from "@/components/ward-management/shell/ward-shared-session-context"; import { settingsHref } from "@/components/ward-management/shell/ward-facade"; import rail from "@/components/ward-management/shell/ward-rail.module.css"; @@ -43,6 +44,7 @@ export function RailAccountMenu({ const session = useContext(SharedSessionContext); const router = useRouter(); const help = useWardHelpRow(); + const canLeaveDemo = useCanLeaveDemo(); const act = (run: () => void) => () => { onLeave?.(); run(); @@ -73,6 +75,12 @@ export function RailAccountMenu({ { kind: "separator", id: "sep" }, { id: "sign-out", label: "Sign out", icon: LogOut, onSelect: act(() => session.signOut()) }, ); + } else if (canLeaveDemo) { + // Live audit item 22: the public demo's way out (`front-door/leave-demo.ts`). + items.push( + { kind: "separator", id: "sep" }, + { id: "leave-demo", label: "Leave the demo", icon: DoorOpen, onSelect: act(() => void leaveDemo()) }, + ); } const sheet = variant === "sheet"; return ( diff --git a/src/components/ward-management/shell-next/next-phone-menu-sheet.tsx b/src/components/ward-management/shell-next/next-phone-menu-sheet.tsx index 936caeb099..1f1cd8818c 100644 --- a/src/components/ward-management/shell-next/next-phone-menu-sheet.tsx +++ b/src/components/ward-management/shell-next/next-phone-menu-sheet.tsx @@ -25,6 +25,7 @@ import { ClipboardList, Inbox, Clock3, + DoorOpen, LogOut, PhoneOutgoing, Plus, @@ -47,6 +48,7 @@ import { preloadWhenIdle } from "@/components/ward-management/preload-when-idle" import { OperatorSwitcherModal } from "@/components/ward-management/settings/operator-switcher-modal-lazy"; import { useWardDataMode } from "@/components/ward-management/ward-shared-access"; +import { leaveDemo, useCanLeaveDemo } from "@/components/ward-management/front-door/leave-demo"; import { SharedSessionContext } from "@/components/ward-management/shell/ward-shared-session-context"; import { settingsHref, unitHref } from "@/components/ward-management/shell/ward-facade"; import { announceToWardShell } from "@/components/ward-management/shell/ward-live-region"; @@ -364,10 +366,27 @@ function DataModeRow({ onClose }: { onClose: () => void }) { ); } -/** Sign out of the shared board, with the account it signs out of. Nothing in the local demo. */ +/** + * Sign out of the shared board, with the account it signs out of. In the local demo on a front-door + * site, Leave the demo instead (live audit item 22); nothing on the open local demo. + */ function SignOutRow() { const session = useContext(SharedSessionContext); - if (!session) return null; + const canLeaveDemo = useCanLeaveDemo(); + if (!session) { + return canLeaveDemo ? ( +
  • + +
  • + ) : null; + } return (
  • - + {/* D-150: the signed-in site runs on real Perth time, so its clock is not advanced. */} + {shared?.enabled ? null : ( + <> + + + + )}
  • {oneList ? null : ( diff --git a/src/components/ward-management/use-undoable-step.ts b/src/components/ward-management/use-undoable-step.ts index 5638f92511..fc9ade0482 100644 --- a/src/components/ward-management/use-undoable-step.ts +++ b/src/components/ward-management/use-undoable-step.ts @@ -57,6 +57,26 @@ type Pending = { * toast when the page that raised the toast has gone, because the toast outlives the page. */ sayAfterLeaving?: boolean; + /** See `UndoableStep.choices`. */ + choices?: UndoStepChoices; + /** Runs once this step is accepted, in place of its toast: a choice's undo before its redo. */ + then?: () => void; +}; + +/** + * D-148: an optional choice on the success toast, such as a bed closure's reason. Choosing undoes + * the step and records it again with the choice, so the step itself stays one tap and the History + * shows both. The new step offers Undo as the first did. + */ +export type UndoStepChoices = { + label: string; + items: readonly { id: string; label: string }[]; + /** The step again with the choice, built when it is chosen. */ + redo: (id: string, now: Instant) => WardFlowEvent; + /** The toast's title once the step with the choice is accepted. */ + success: (id: string) => string; + /** Said if the page has gone when the undo lands, so the step is not redone. */ + notRedone: string; }; type RecordWatch = { undoType: string; subjectId: string; priorSeq?: number; seq?: number }; @@ -87,6 +107,8 @@ export type UndoableStep = { * (Josh, 26 Sept 2026), because a shared-board refusal never names the patient (P058). */ who?: string; + /** D-148: an optional follow-up choice on the success toast. */ + choices?: UndoStepChoices; }; /** The refusal's words, led by the person's name when the words do not already name them. */ @@ -187,6 +209,10 @@ export function useUndoableStep({ else onMessage?.(said, "warning"); continue; } + if (pending.then) { + pending.then(); + continue; + } const build = pending.undo; if (!sink) { onMessage?.(pending.success, "success"); @@ -231,36 +257,9 @@ export function useUndoableStep({ body: `Undo within ${UNDO_WINDOW_MINUTES} min.`, undo: { durationMs: UNDO_TOAST_MS, - onUndo: () => { - if (pending.outcome?.isCurrent && !pending.outcome.isCurrent()) { - sink.push({ - tone: "warning", - title: - "Undo is unavailable because the workspace or working role changed. Review the current board.", - }); - return; - } - if (pending.outcome?.canUndo && !pending.outcome.canUndo()) { - sink.push({ - tone: "warning", - title: - "Undo is unavailable until the shared board is ready. Wait for it to reconnect, then check the board.", - }); - return false; - } - const undoPending = { - prior: rejectionCountRef.current, - success: pending.undone ?? "Undone. Back as it was.", - sayAfterLeaving: true, - }; - const undoEvent = { - ...build(nowRef.current), - ...(watch.seq === undefined ? {} : { recordSeq: watch.seq }), - } as WardFlowEvent; - submit(undoEvent, undoPending); - return undefined; - }, + onUndo: () => takeBack(pending, build, watch, { success: pending.undone ?? "Undone. Back as it was." }), }, + ...(pending.choices ? { choices: toastChoices(pending, pending.choices, build, watch) } : {}), }), }); } @@ -268,6 +267,67 @@ export function useUndoableStep({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [token, rejections]); + /** The toast's Undo, or a choice's undo: fenced as one, then sent with the record it was offered for. */ + function takeBack( + pending: Pending, + build: (now: Instant) => WardFlowEvent, + watch: RecordWatch, + next: Pick, + ): void | false { + if (!sink) return undefined; + if (pending.outcome?.isCurrent && !pending.outcome.isCurrent()) { + sink.push({ + tone: "warning", + title: "Undo is unavailable because the workspace or working role changed. Review the current board.", + }); + return undefined; + } + if (pending.outcome?.canUndo && !pending.outcome.canUndo()) { + sink.push({ + tone: "warning", + title: "Undo is unavailable until the shared board is ready. Wait for it to reconnect, then check the board.", + }); + return false; + } + const undoEvent = { + ...build(nowRef.current), + ...(watch.seq === undefined ? {} : { recordSeq: watch.seq }), + } as WardFlowEvent; + submit(undoEvent, { prior: rejectionCountRef.current, sayAfterLeaving: true, ...next }); + return undefined; + } + + /** D-148: the toast's choices. Choosing undoes the step, then records it again with the choice. */ + function toastChoices( + pending: Pending, + choices: UndoStepChoices, + build: (now: Instant) => WardFlowEvent, + watch: RecordWatch, + ) { + return { + label: choices.label, + items: choices.items, + onChoose: (id: string) => { + takeBack(pending, build, watch, { + success: choices.notRedone, + then: () => + runStep(choices.redo(id, nowRef.current), { + success: choices.success(id), + undo: pending.undo, + undone: pending.undone, + who: pending.who, + }), + }); + }, + }; + } + + function runStep(event: WardFlowEvent, step: UndoableStep) { + const offered = step.undo?.(nowRef.current); + const priorSeq = offered ? undoRecordSeqFor(undoRecords, offered.type, stepUndoSubjectId(offered)) : undefined; + submit(event, { prior: rejectionCountRef.current, ...step, priorSeq }); + } + function submit(event: WardFlowEvent, pending: Pending, record?: () => ReturnType) { let result: ReturnType; try { @@ -344,11 +404,7 @@ export function useUndoableStep({ return { /** Dispatches `event` now, then offers Undo once the engine has accepted it. */ - run(event: WardFlowEvent, step: UndoableStep) { - const offered = step.undo?.(nowRef.current); - const priorSeq = offered ? undoRecordSeqFor(undoRecords, offered.type, stepUndoSubjectId(offered)) : undefined; - submit(event, { prior: rejectionCountRef.current, ...step, priorSeq }); - }, + run: runStep, /** Records through a provider helper while keeping the command's completion and Undo guard. */ runRecorded(record: () => ReturnType, step: UndoableStep) { const offered = step.undo?.(nowRef.current); diff --git a/src/components/ward-management/ward-global-search-commands.tsx b/src/components/ward-management/ward-global-search-commands.tsx index feed6bb194..f1ad8887fa 100644 --- a/src/components/ward-management/ward-global-search-commands.tsx +++ b/src/components/ward-management/ward-global-search-commands.tsx @@ -25,7 +25,8 @@ export function WardSearchCommands({ resultsCurrent: boolean; canRunAction: NonNullable; canOpenHref: NonNullable; - onAdvanceClock: () => void; + /** Absent in shared mode (D-150): the signed-in site runs on real Perth time. */ + onAdvanceClock?: () => void; onSwitchDesk: (role: DeskRole, href: string) => void; onHandover: () => void; shortcutsExpanded: boolean; @@ -50,7 +51,7 @@ export function WardSearchCommands({

    ) : null}
    - {canRunAction("advance-clock") ? ( + {canRunAction("advance-clock") && onAdvanceClock ? (
    diff --git a/src/components/ward-management/ward/ward-screen-overlays.tsx b/src/components/ward-management/ward/ward-screen-overlays.tsx index b87014a175..b108d2ade8 100644 --- a/src/components/ward-management/ward/ward-screen-overlays.tsx +++ b/src/components/ward-management/ward/ward-screen-overlays.tsx @@ -9,6 +9,7 @@ import { activeClosedBeds } from "@/components/ward-management/ward-ready-beds"; import { admissionCriteriaEligibility } from "@/components/ward-management/ward-flow-reducer"; import { useWardCommandCompletion, wardCommandFailure } from "./use-ward-command-completion"; import { useUndoableStep } from "../use-undoable-step"; +import { bedClosureReasonChoices } from "./ward-bed-closure-reason"; import type { WardFlowRole } from "@/components/ward-management/ward-flow-roles"; import { WardBedDossierDrawer } from "./ward-screen-lazy"; import { wardSafeRejectionReason } from "./ward-rejection-text"; @@ -161,6 +162,17 @@ export function WardScreenBedDrawer({ ...(bedClosureRole === "ward" ? { actingUnitId: unitId } : {}), }), undone: closed ? `Undone. Bed ${bedNumber} is open again.` : `Undone. Bed ${bedNumber} is closed again.`, + // D-148 (Josh, 11 Nov 2026): closing stays one tap; its toast offers an optional reason. + ...(closed + ? { + choices: bedClosureReasonChoices({ + role: bedClosureRole, + unitId: unit.id, + actingUnitId: unitId, + bedNumber, + }), + } + : {}), }, ); return selectedBed !== null diff --git a/src/components/ward-redesign/pop-demo/demo-panel.tsx b/src/components/ward-redesign/pop-demo/demo-panel.tsx index beba5deb31..428e01b6fa 100644 --- a/src/components/ward-redesign/pop-demo/demo-panel.tsx +++ b/src/components/ward-redesign/pop-demo/demo-panel.tsx @@ -226,22 +226,27 @@ function OperationalDemoPanel({ oneList = false }: { oneList?: boolean }) { Demo clock {formatInstant(now)} - - + {/* D-150: the signed-in site runs on real Perth time, so its clock is not advanced. */} + {shared?.enabled ? null : ( + <> + + + + )}
    diff --git a/src/components/ward-redesign/pop-search/global-search.tsx b/src/components/ward-redesign/pop-search/global-search.tsx index f5a7d09fc3..855bc33d94 100644 --- a/src/components/ward-redesign/pop-search/global-search.tsx +++ b/src/components/ward-redesign/pop-search/global-search.tsx @@ -1158,11 +1158,15 @@ export function GlobalSearch({ canOpenHref={canOpenHref} shortcutsExpanded={shortcutsExpanded} onToggleShortcuts={() => setShowShortcuts((previous) => !previous)} - onAdvanceClock={() => { - if (wardFlow) - wardFlow.dispatch({ type: "ADVANCE_CLOCK", role: "demo", now: now ?? wardFlow.now, minutes: 15 }); - close(); - }} + onAdvanceClock={ + wardFlow?.shared?.enabled + ? undefined + : () => { + if (wardFlow) + wardFlow.dispatch({ type: "ADVANCE_CLOCK", role: "demo", now: now ?? wardFlow.now, minutes: 15 }); + close(); + } + } onSwitchDesk={(role, href) => { if (!confirmLeave()) return; setDeskRole(role, href); diff --git a/src/components/wf/toast-provider.tsx b/src/components/wf/toast-provider.tsx index 43251752a7..3c266d70fe 100644 --- a/src/components/wf/toast-provider.tsx +++ b/src/components/wf/toast-provider.tsx @@ -15,7 +15,7 @@ import { canRestoreFocusTo } from "./sheet-focus"; import { ConfirmHost } from "./confirm-dialog"; import { OverlayPortal } from "./overlay-root"; import overlayStyles from "./overlay.module.css"; -import { type ToastNextStep, type ToastUndo, ToastView } from "./toast"; +import { type ToastChoices, type ToastNextStep, type ToastUndo, ToastView } from "./toast"; import { useToastJumpKey } from "./toast-keyboard"; import { timedToastMs } from "./toast-timing"; @@ -41,6 +41,8 @@ export type Toast = { * closes itself, so `duration` defaults to 0 here too. */ undo?: ToastUndo; + /** An optional follow-up choice, such as a reason (D-148); choosing closes the toast. */ + choices?: ToastChoices; /** Mono meta on the right, such as "1s ago". */ meta?: string; /** @@ -376,6 +378,17 @@ function ToastCard({ : undefined } nextStep={toast.nextStep} + choices={ + toast.choices + ? { + ...toast.choices, + onChoose: (id) => { + toast.choices?.onChoose(id); + onDismiss(toast.id); + }, + } + : undefined + } undo={ toast.undo ? { diff --git a/src/components/wf/toast.tsx b/src/components/wf/toast.tsx index 6517c82756..26061a1639 100644 --- a/src/components/wf/toast.tsx +++ b/src/components/wf/toast.tsx @@ -2,7 +2,8 @@ import { X } from "lucide-react"; import { type CSSProperties, type HTMLAttributes, type ReactNode, useEffect, useRef, useState } from "react"; -import { Button } from "./button"; +import { Button, buttonClass } from "./button"; +import { Menu } from "./menu"; import { cx } from "./cx"; import styles from "./overlay.module.css"; import { StatusGlyph, type WfTone } from "./status-glyph"; @@ -37,6 +38,17 @@ type ToastTimeout = { restartKey?: unknown; }; +/** + * An optional follow-up choice on the toast, such as a reason for the step just taken (D-148). A + * kit menu of the choices opens from one button; choosing closes the toast. + */ +export type ToastChoices = { + /** The menu's accessible name and the button's words. */ + label: string; + items: readonly { id: string; label: string }[]; + onChoose: (id: string) => void; +}; + export type ToastNextStep = { label: string; href?: string; @@ -56,6 +68,8 @@ export type ToastViewProps = { nextStep?: ToastNextStep; /** Undo with a time window and its shrinking line. */ undo?: ToastUndo; + /** An optional follow-up choice, shown before Undo (D-148). */ + choices?: ToastChoices; /** Closes the toast after a pausable time. Ignored when `undo` is set: the window is the clock. */ timeout?: ToastTimeout; /** @@ -184,6 +198,7 @@ export function ToastView({ action, nextStep, undo, + choices, timeout, leaving = false, onClose, @@ -277,6 +292,27 @@ export function ToastView({ ) ) : null} + {choices && (!undo || phase === "running") ? ( + ({ + id: item.id, + label: item.label, + onSelect: () => choices.onChoose(item.id), + }))} + trigger={(props) => ( + + )} + /> + ) : null} {undo ? : null} {onClose ? ( + ); +} + +describe("bed closure reason on the toast (D-148)", () => { + it("closes on one tap, then a chosen reason undoes and closes again with it", async () => { + const dispatch = vi + .fn<(event: WardFlowEvent) => Promise>() + .mockResolvedValueOnce({ commandId: "close", status: "accepted", undoStep: stepRecord(3) }) + .mockResolvedValueOnce({ commandId: "undo", status: "accepted" }) + .mockResolvedValueOnce({ commandId: "again", status: "accepted", undoStep: stepRecord(5) }); + render( + + + , + ); + await act(async () => { + fireEvent.click(screen.getByText("Close this bed")); + }); + // One tap saved it: no confirm step, and no reason was asked for. + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0]).not.toHaveProperty("reason"); + const toast = screen.getByTestId("toast"); + expect(within(toast).getByRole("button", { name: "Undo" })).toBeInTheDocument(); + await act(async () => { + fireEvent.click(within(toast).getByTestId("toast-choices")); + }); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: "Staffing" })); + }); + expect(dispatch.mock.calls[1][0]).toMatchObject({ type: "UNDO_BED_STEP", bedNumber: 4, recordSeq: 3 }); + expect(dispatch.mock.calls[2][0]).toMatchObject({ + type: "SET_BED_CLOSED", + bedNumber: 4, + closed: true, + reason: "staffing", + }); + const again = screen.getByTestId("toast"); + expect(again).toHaveTextContent("Bed 4 closed: Staffing."); + // The step with its reason offers Undo as the first did, and no second reason picker. + expect(within(again).getByRole("button", { name: "Undo" })).toBeInTheDocument(); + expect(within(again).queryByTestId("toast-choices")).toBeNull(); + }); + + it("does not close again when the undo is refused", async () => { + const dispatch = vi + .fn<(event: WardFlowEvent) => Promise>() + .mockResolvedValueOnce({ commandId: "close", status: "accepted", undoStep: stepRecord(3) }) + .mockResolvedValueOnce({ commandId: "undo", status: "refused", message: "This has changed since" }); + render( + + + , + ); + await act(async () => { + fireEvent.click(screen.getByText("Close this bed")); + }); + await act(async () => { + fireEvent.click(within(screen.getByTestId("toast")).getByTestId("toast-choices")); + }); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: "Infection outbreak" })); + }); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(screen.getByTestId("toast")).toHaveTextContent("Not recorded: This has changed since"); + }); +}); + +// D-151: the beds a waiting save will change, for the tile's "Waiting to save" marker. +describe("waiting bed keys (D-151)", () => { + it("names each bed a waiting save changes, once, and nothing else", () => { + const bed = { type: "SET_BED_CLOSED", role: "ward", now: 1, ...close, closed: true } as WardFlowEvent; + const other = { + type: "REQUEST_CAPACITY_REFRESH", + role: "coordinator", + now: 1, + unitId: "ward-test", + } as WardFlowEvent; + expect(waitingBedKeys([null, { event: bed }, { event: bed }, { event: other }])).toEqual(["ward-test:4"]); + expect(waitingBedKeys([])).toEqual([]); + }); +}); diff --git a/tests/ward-patient-archive.test.ts b/tests/ward-patient-archive.test.ts index fc28961c44..2f33ea1133 100644 --- a/tests/ward-patient-archive.test.ts +++ b/tests/ward-patient-archive.test.ts @@ -5,6 +5,10 @@ import { seedWardFlowState, wardFlowReducer, type WardFlowState } from "@/compon import type { WardFlowEvent } from "@/components/ward-management/ward-flow-events"; import { GENERAL_OBSERVATION } from "@/components/ward-management/ward-observation"; import { validSharedCommand } from "../backend/ward-flow/command-schemas"; +import { patientArchiveProblem } from "@/components/ward-management/ward-patient-archive"; +import { UNDO_WINDOW_MINUTES } from "@/components/ward-management/ward-operational-defaults"; +import { isValidSharedWardFlowState } from "@/components/ward-management/ward-shared-state-validation"; +import { FIXTURE_HISTORY } from "./helpers/ward-referral-history"; const now = 642; type WithoutNow = T extends unknown ? Omit : never; @@ -152,3 +156,79 @@ describe("ARCHIVE_PATIENT", () => { }); }); }); + +// D-149 (Josh, 11 Nov 2026): a record whose only links are withdrawn or cancelled front-door +// referrals, audit rows and spent Undo records can be archived; anything else still keeps it. +describe("ARCHIVE_PATIENT after a withdrawn referral (D-149)", () => { + function referred() { + const world = withNewPatient(); + world.send({ + type: "RECEIVE_REFERRAL", + role: "ed", + ageBand: "Adult", + patientId: world.patient.id, + destinations: [ + { + kind: "psychiatric_ward", + unitId: "dn-arm-acute", + sex: "Male", + secureBedNeeded: false, + involuntaryBedNeeded: false, + highAcuityNursingNeeded: false, + }, + ], + homeRegion: "Perth Metropolitan", + suburb: { kind: "named", name: "Perth" }, + source: "ed_medical", + originSiteCode: "RPH", + urgency: 3, + transportNeeded: false, + ...FIXTURE_HISTORY, + } as never); + const referral = world.state.referrals.find((entry) => entry.patientId === world.patient.id); + expect(referral, "the referral was refused, so this suite would assert nothing").toBeDefined(); + return { ...world, referralId: referral!.id }; + } + const later = (minutes: number) => now + minutes; + + it("refuses while the referral is still open, and says why", () => { + const world = referred(); + expect(patientArchiveProblem(world.state, world.patient.id, later(60))).toMatch(/open or answered referral/); + world.send({ type: "ARCHIVE_PATIENT", role: "coordinator", patientId: world.patient.id, reason: "test" }); + expect(world.state.patients.some((patient) => patient.id === world.patient.id)).toBe(true); + }); + + it("archives once the referrer withdrew it and the Undo window has passed", () => { + const world = referred(); + world.send({ type: "RECORD_REFERRER_WITHDRAWAL", role: "coordinator", referralId: world.referralId } as never); + // Inside the Undo window the withdrawal could still be taken back, so the record stays. + expect(patientArchiveProblem(world.state, world.patient.id, now)).toMatch(/can still be undone/); + const after = wardFlowReducer(world.state, { + type: "ARCHIVE_PATIENT", + role: "coordinator", + now: later(UNDO_WINDOW_MINUTES + 1), + patientId: world.patient.id, + reason: "test", + }); + expect(after.patients.some((patient) => patient.id === world.patient.id)).toBe(false); + // The withdrawn referral stays, and the archived state is still one the server accepts. + expect(after.referrals.some((entry) => entry.id === world.referralId)).toBe(true); + expect(isValidSharedWardFlowState(after)).toBe(true); + }); + + it("still refuses a referral a ward answered", () => { + const world = referred(); + const answered = { + ...world.state, + referrals: world.state.referrals.map((entry) => + entry.id === world.referralId + ? { + ...entry, + destinations: entry.destinations.map((addressing) => ({ ...addressing, state: "declined" as const })), + } + : entry, + ), + }; + expect(patientArchiveProblem(answered, world.patient.id, later(60))).toMatch(/care recorded against them/); + }); +}); diff --git a/tests/ward-shared-world-clock.test.ts b/tests/ward-shared-world-clock.test.ts new file mode 100644 index 0000000000..150bc97340 --- /dev/null +++ b/tests/ward-shared-world-clock.test.ts @@ -0,0 +1,49 @@ +/** @vitest-environment node */ +// D-150 (Josh, 11 Nov 2026): the signed-in site runs on real Perth time. The shared world's clock is +// the real minutes since its day zero, not the scripted 10:42 the public demo starts from. +import { describe, expect, it } from "vitest"; +import { seedWorld, worldNow } from "../backend/ward-flow/engine"; +import { demoDayZero } from "@/components/ward-management/ward-clock"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; +import { seedWardFlowState } from "@/components/ward-management/ward-flow-reducer"; + +/** A real moment at Perth hour:minute on 11 Nov 2026 (AWST is UTC+8). */ +const perth = (hour: number, minute: number) => new Date(Date.UTC(2026, 10, 11, hour - 8, minute)); + +describe("shared world clock (D-150)", () => { + it("reads the real Perth time for a new world, whatever hour it was made", () => { + for (const [hour, minute] of [ + [9, 54], + [10, 42], + [17, 5], + ] as const) { + const at = perth(hour, minute); + expect(worldNow(seedWorld(at), at)).toBe(hour * 60 + minute); + } + }); + + it("keeps an existing world on its stored day zero and counts real minutes from it", () => { + const madeAt = perth(9, 54); + const world = seedWorld(madeAt); + // The live audit's case: a world made at 9:54 used to read 10:42 at once, 48 minutes fast. + const later = new Date(madeAt.getTime() + 25 * 24 * 60 * 60_000 + 129 * 60_000); + expect(worldNow(world, later)).toBe(25 * 1440 + 12 * 60 + 3); + expect(world.dayZero).toBe(demoDayZero(madeAt).toISOString()); + }); + + it("re-anchors the seed onto the real time, keeping every relative offset", () => { + const at = perth(17, 5); + const world = seedWorld(at); + const shift = 17 * 60 + 5 - NOW_ANCHOR; + const authored = seedWardFlowState(); + const firstAuthored = authored.movements.find((movement) => typeof movement.openedAt === "number")!; + const firstSeeded = world.state.movements.find((movement) => movement.id === firstAuthored.id)!; + expect(firstSeeded.openedAt).toBe(firstAuthored.openedAt + shift); + }); + + it("still adds a clock offset already in a stored world", () => { + const at = perth(12, 0); + const world = seedWorld(at); + expect(worldNow({ ...world, state: { ...world.state, clockOffsetMinutes: 15 } }, at)).toBe(12 * 60 + 15); + }); +}); From 37668c2b3e1c416ba3f688ee430f695fcc1e494d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:59:53 +0800 Subject: [PATCH 09/51] Item 8/6/13 follow-up: keep the warning through a retry after a failure, read shared mode from the session, show History on an empty bed with closures --- .../community/community-screen.tsx | 10 +++++++--- .../ward-shared-save-status.tsx | 11 ++++++++--- .../ward/ward-bed-dossier-drawer.tsx | 18 ++++++++++++++++++ .../ward/ward-bed-dossier-phone.tsx | 15 +++++++++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/components/ward-management/community/community-screen.tsx b/src/components/ward-management/community/community-screen.tsx index 8836c6d858..b3a7096efa 100644 --- a/src/components/ward-management/community/community-screen.tsx +++ b/src/components/ward-management/community/community-screen.tsx @@ -6,7 +6,8 @@ import { useEffectiveWardAccess } from "@/components/ward-management/shell/ward- import { wardScrollBehavior } from "@/components/ward-management/ward-scroll"; import { currentCareContactCompleted } from "../ward-care-journey"; import type { DischargeOpenHandle } from "../ward-discharge-records"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { SharedSessionContext } from "@/components/ward-management/shell/ward-shared-session-context"; import { Segmented, useIsPhone, useOptionalToast } from "@/components/wf"; import { usePrintableDisclosures } from "@/components/ward-management/use-printable-disclosures"; import { useActionToast } from "@/components/ward-management/use-action-toast"; @@ -310,6 +311,10 @@ function useCommunityScreenView({ teamId, admissions, referrals, demonstration = "worldGeneration", ); const liveReferrals = useCommunityScopedReferrals(); + // Live audit item 6 (11 Nov 2026): the signed-in shared workspace (a shared build, or a signed-in + // session) never shows the Midland demo team. + const sharedWorkspace = + useContext(SharedSessionContext) !== null || process.env.NEXT_PUBLIC_WARD_SHARED_ENABLED === "true"; const sentByAnyTeam = useCommunitySentReferrals(); const now = useWardFlowClock(); const raise = useWardRaise("community"); @@ -432,7 +437,7 @@ function useCommunityScreenView({ teamId, admissions, referrals, demonstration = generation: worldGeneration, shared, onAccepted: (source) => { - if (!shared && MIDLAND_DEMO_TEAM.test(teamId)) recordMidlandContact("team"); + if (!sharedWorkspace && MIDLAND_DEMO_TEAM.test(teamId)) recordMidlandContact("team"); if (source === "modal") setActiveModalType((current) => (current === "contact" ? null : current)); else setToastMessage("Team contact logged. This does not record patient or referral contact."); }, @@ -451,7 +456,6 @@ function useCommunityScreenView({ teamId, admissions, referrals, demonstration = // records, and the older demonstration cohort, keep their own lists. Live audit item 6 (11 Nov // 2026): the signed-in shared workspace never shows the demo team, so its home lists the shared // referrals and a contact is saved to the workspace, not kept in this browser. - const sharedWorkspace = Boolean(shared) || process.env.NEXT_PUBLIC_WARD_SHARED_ENABLED === "true"; const midlandDemo = !hasExplicitProps && !demonstration && !sharedWorkspace && MIDLAND_DEMO_TEAM.test(teamId); const midlandDecisions = useMidlandDecisions(); diff --git a/src/components/ward-management/ward-shared-save-status.tsx b/src/components/ward-management/ward-shared-save-status.tsx index 24f2547317..74d2b4c495 100644 --- a/src/components/ward-management/ward-shared-save-status.tsx +++ b/src/components/ward-management/ward-shared-save-status.tsx @@ -131,9 +131,14 @@ export function WardSharedSaveStatus({ const [retrying, setRetrying] = useState(false); const inFlight = connection.status === "saving"; const slow = useSaveSlow(inFlight); - // Item 8: a save in flight with no error stays calm until it is slow or the browser is offline. - // A save still waiting after a failure (status back to ready or unavailable) warns at once. - const calmSave = inFlight && !retrying && !slow && !connection.error; + // Item 8: a save once left waiting (failed, or held while offline) keeps the full warning through + // its retry, until nothing is left unconfirmed. + const [failedBefore, setFailedBefore] = useState(false); + const waitingAfterFailure = !!save?.unconfirmedSave && !inFlight; + if (waitingAfterFailure && !failedBefore) setFailedBefore(true); + if (!save?.unconfirmedSave && !inFlight && failedBefore) setFailedBefore(false); + // A save in flight with no error stays calm until it is slow or the browser is offline. + const calmSave = inFlight && !retrying && !slow && !failedBefore && !connection.error; // WF-07: one warning node reused in both strip layouts (was duplicated JSX). const reloadWarning = save?.unconfirmedSave && !calmSave ? ( diff --git a/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx b/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx index f4af1d04cb..11b46958f6 100644 --- a/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx +++ b/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx @@ -1048,6 +1048,24 @@ export function WardBedDossierDrawer({ {readyNote} + {/* Live audit item 13: an empty bed's History holds its closures, so it shows when there are any. */} + {timeline.length > 0 ? ( + <> + + {history} + + ) : null} ) : ( <> diff --git a/src/components/ward-management/ward/ward-bed-dossier-phone.tsx b/src/components/ward-management/ward/ward-bed-dossier-phone.tsx index ee4e20c5b9..8b6f858287 100644 --- a/src/components/ward-management/ward/ward-bed-dossier-phone.tsx +++ b/src/components/ward-management/ward/ward-bed-dossier-phone.tsx @@ -230,6 +230,21 @@ export function PhoneBedSheet({ {bedItem?.designation ? {bedItem.designation} : null} {readyNote} + {/* Live audit item 13: an empty bed's closures and reopenings, when it has any. */} + {timeline.length > 0 ? ( + <> +

    History

    +
      + {timeline.map((event) => ( +
    • + + {formatInstantWithDay(event.at, now)} + {event.label} +
    • + ))} +
    + + ) : null} ) : ( <> From 1b56666dfe4bc97a9d33ee358b42666125e8090a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:02:13 +0800 Subject: [PATCH 10/51] Guard: list the save warning pause as screen timing, not an invented figure (item 8) --- tests/ward-no-invented-screen-figures.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ward-no-invented-screen-figures.test.ts b/tests/ward-no-invented-screen-figures.test.ts index cea5ec2351..1067643dbf 100644 --- a/tests/ward-no-invented-screen-figures.test.ts +++ b/tests/ward-no-invented-screen-figures.test.ts @@ -156,6 +156,12 @@ const CONSTANT_EXCLUSIONS: ReadonlyArray<{ id: string; why: string; file?: RegEx file: /^ward-role-alerts\.ts$/, name: /^ROLE_ALERT_WINDOW_MINUTES$/, }, + { + id: "save-warning-pause", + why: "Live audit item 8 (11 Nov 2026): how long a save in flight shows the calm Saving… pill before the reload warning; screen timing, never shown, not a clinical limit.", + file: /^ward-shared-save-status\.tsx$/, + name: /^SAVE_WARNING_AFTER_MS$/, + }, { id: "typing-pause", why: "The referral draft's autosave pause (9 Oct 2026): how long typing rests before tab memory is updated; never shown, not a limit.", From b1f24e274b19541d7c4df534a80696f126fcb70e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:14:08 +0800 Subject: [PATCH 11/51] Leave the demo: replace the page with sign-in, so Back does not return to the demo --- src/components/ward-management/front-door/leave-demo.ts | 4 +++- tests/ward-next-account-menu.dom.test.tsx | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/ward-management/front-door/leave-demo.ts b/src/components/ward-management/front-door/leave-demo.ts index 27391be485..b4b908da3b 100644 --- a/src/components/ward-management/front-door/leave-demo.ts +++ b/src/components/ward-management/front-door/leave-demo.ts @@ -34,5 +34,7 @@ export async function leaveDemo(): Promise { } // Awaited, so the sign-in page never opens while the pass is still good. await fetch("/api/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {}); - window.location.assign("/sign-in"); + // A full load, not a client navigation: the sign-in page must be drawn fresh, and Back must not + // return to the demo. + window.location.replace("/sign-in"); } diff --git a/tests/ward-next-account-menu.dom.test.tsx b/tests/ward-next-account-menu.dom.test.tsx index 614510d946..129d32144c 100644 --- a/tests/ward-next-account-menu.dom.test.tsx +++ b/tests/ward-next-account-menu.dom.test.tsx @@ -138,8 +138,8 @@ describe("rail account menu (DK1)", () => { document.cookie = "wf_door=1; path=/"; const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); vi.stubGlobal("fetch", fetchMock); - const assign = vi.fn(); - vi.spyOn(window, "location", "get").mockReturnValue({ ...window.location, assign } as Location); + const replace = vi.fn(); + vi.spyOn(window, "location", "get").mockReturnValue({ ...window.location, replace } as Location); try { renderRail(); const menu = openAccountMenu(); @@ -150,7 +150,7 @@ describe("rail account menu (DK1)", () => { "Leave the demo", ]); fireEvent.click(within(menu).getByRole("menuitem", { name: "Leave the demo" })); - await vi.waitFor(() => expect(assign).toHaveBeenCalledWith("/sign-in")); + await vi.waitFor(() => expect(replace).toHaveBeenCalledWith("/sign-in")); expect(fetchMock).toHaveBeenCalledWith("/api/session", expect.objectContaining({ method: "DELETE" })); } finally { document.cookie = "wf_door=; path=/; max-age=0"; From 352c00adcc9c35a39946d13f93eb809aad3842dd Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:14:19 +0800 Subject: [PATCH 12/51] D-149: unlink withdrawn referrals on archive so the stored workspace stays valid; fix archive and toast tests --- docs/ward-flow/decisions.md | 2 +- .../ward-patient-archive-reducer.ts | 10 +++- .../ward-bed-close-reason-toast.dom.test.tsx | 2 +- tests/ward-patient-archive.test.ts | 55 +++++++++++++++---- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/docs/ward-flow/decisions.md b/docs/ward-flow/decisions.md index 79ead95c9e..08b1602df5 100644 --- a/docs/ward-flow/decisions.md +++ b/docs/ward-flow/decisions.md @@ -1743,7 +1743,7 @@ is inferred. - **Date:** 11 November 2026. **Decided by:** Josh, approving decision item 9 of the live audit fix plan, as for D-148. This gives the 26 October rule (#816: "archive only, coordinator only, for records added in error") its D-number. - **Decision:** 1. Archiving stays coordinator only, for a record added in error, and still never destroys anything. - 2. A record can now be archived when the only things naming it are front-door referrals that nobody answered (every destination withdrawn by the referrer or cancelled), audit rows, and Undo records older than the Undo window. Any journey, stay, form, bed record, answered or open referral, or fact recorded on the person still keeps the record. + 2. A record can now be archived when the only things naming it are front-door referrals that nobody answered (every destination withdrawn by the referrer or cancelled), audit rows, and Undo records older than the Undo window. Any journey, stay, form, bed record, answered or open referral, or fact recorded on the person still keeps the record. The withdrawn referral stays in the history but no longer links to the archived record. 3. The coordinator's "Record added in error" card no longer hides when archive is not available. It says why in one line. - **Why:** the live audit's test patient could never be archived because a withdrawn referral and record-view audit rows named it, so test and mistaken records stayed on the board for good. - **Boundary:** synthetic data only. Archiving the live audit's test patient and the old TEST-Helper4 records is a live write that waits for Josh's yes after release. diff --git a/src/components/ward-management/ward-patient-archive-reducer.ts b/src/components/ward-management/ward-patient-archive-reducer.ts index ee09520530..cf70e0df79 100644 --- a/src/components/ward-management/ward-patient-archive-reducer.ts +++ b/src/components/ward-management/ward-patient-archive-reducer.ts @@ -21,5 +21,13 @@ export function reducePatientArchive( // patient sequence is kept, so no later record reuses the id. decision.outcome = "accepted"; decision.reasonCode = "none"; - return { ...state, patients: state.patients.filter((patient) => patient.id !== patientId) }; + // D-149: the only referrals still naming the record are ones nobody answered. They stay, but no + // longer point at a record that has left the lists, so the stored workspace still checks out. + const referrals = state.referrals.map((referral) => { + if (referral.patientId !== patientId) return referral; + const { patientId: _archived, ...unlinked } = referral; + void _archived; + return unlinked; + }); + return { ...state, patients: state.patients.filter((patient) => patient.id !== patientId), referrals }; } diff --git a/tests/ward-bed-close-reason-toast.dom.test.tsx b/tests/ward-bed-close-reason-toast.dom.test.tsx index 3cfc9cea15..a29c563aab 100644 --- a/tests/ward-bed-close-reason-toast.dom.test.tsx +++ b/tests/ward-bed-close-reason-toast.dom.test.tsx @@ -107,7 +107,7 @@ describe("bed closure reason on the toast (D-148)", () => { // D-151: the beds a waiting save will change, for the tile's "Waiting to save" marker. describe("waiting bed keys (D-151)", () => { it("names each bed a waiting save changes, once, and nothing else", () => { - const bed = { type: "SET_BED_CLOSED", role: "ward", now: 1, ...close, closed: true } as WardFlowEvent; + const bed = { type: "SET_BED_CLOSED", now: 1, ...close, closed: true } as WardFlowEvent; const other = { type: "REQUEST_CAPACITY_REFRESH", role: "coordinator", diff --git a/tests/ward-patient-archive.test.ts b/tests/ward-patient-archive.test.ts index 2f33ea1133..84ef27068c 100644 --- a/tests/ward-patient-archive.test.ts +++ b/tests/ward-patient-archive.test.ts @@ -170,7 +170,6 @@ describe("ARCHIVE_PATIENT after a withdrawn referral (D-149)", () => { destinations: [ { kind: "psychiatric_ward", - unitId: "dn-arm-acute", sex: "Male", secureBedNeeded: false, involuntaryBedNeeded: false, @@ -186,8 +185,12 @@ describe("ARCHIVE_PATIENT after a withdrawn referral (D-149)", () => { ...FIXTURE_HISTORY, } as never); const referral = world.state.referrals.find((entry) => entry.patientId === world.patient.id); - expect(referral, "the referral was refused, so this suite would assert nothing").toBeDefined(); - return { ...world, referralId: referral!.id }; + expect( + referral, + `the referral was refused (${world.state.rejections.at(-1)?.reason}), so this suite would assert nothing`, + ).toBeDefined(); + // Not a spread: `state` is a getter, and a spread would freeze it at this moment. + return Object.assign(world, { referralId: referral!.id }); } const later = (minutes: number) => now + minutes; @@ -198,12 +201,47 @@ describe("ARCHIVE_PATIENT after a withdrawn referral (D-149)", () => { expect(world.state.patients.some((patient) => patient.id === world.patient.id)).toBe(true); }); - it("archives once the referrer withdrew it and the Undo window has passed", () => { + it("archives once the referrer withdrew it", () => { const world = referred(); world.send({ type: "RECORD_REFERRER_WITHDRAWAL", role: "coordinator", referralId: world.referralId } as never); - // Inside the Undo window the withdrawal could still be taken back, so the record stays. - expect(patientArchiveProblem(world.state, world.patient.id, now)).toMatch(/can still be undone/); - const after = wardFlowReducer(world.state, { + const withdrawn = world.state.referrals.find((entry) => entry.id === world.referralId)!; + expect( + withdrawn.destinations.every((addressing) => addressing.withdrawnAt !== undefined), + `the withdrawal was refused (${world.state.rejections.at(-1)?.reason})`, + ).toBe(true); + // A referrer's withdrawal is not an Undo step, so nothing is left that could bring it back. + expect(patientArchiveProblem(world.state, world.patient.id, now)).toBeUndefined(); + world.send({ type: "ARCHIVE_PATIENT", role: "coordinator", patientId: world.patient.id, reason: "test" }); + expect(world.state.patients.some((patient) => patient.id === world.patient.id)).toBe(false); + // The withdrawn referral stays, no longer linked to the archived record, and the archived + // state is still one the server accepts. + const kept = world.state.referrals.find((entry) => entry.id === world.referralId); + expect(kept).toBeDefined(); + expect(kept!.patientId).toBeUndefined(); + expect(isValidSharedWardFlowState(JSON.parse(JSON.stringify(world.state)))).toBe(true); + }); + + it("waits out the Undo window while a step naming the record could still be taken back", () => { + const world = referred(); + world.send({ type: "RECORD_REFERRER_WITHDRAWAL", role: "coordinator", referralId: world.referralId } as never); + const withUndo = { + ...world.state, + undoRecords: [ + ...(world.state.undoRecords ?? []), + { kind: "step", undoType: "UNDO_BED_STEP", subjectId: world.patient.id, seq: 1, at: now }, + ], + } as unknown as WardFlowState; + expect(patientArchiveProblem(withUndo, world.patient.id, now)).toMatch(/can still be undone/); + expect(patientArchiveProblem(withUndo, world.patient.id, later(UNDO_WINDOW_MINUTES))).toBeUndefined(); + const inside = wardFlowReducer(withUndo, { + type: "ARCHIVE_PATIENT", + role: "coordinator", + now, + patientId: world.patient.id, + reason: "test", + }); + expect(inside.patients.some((patient) => patient.id === world.patient.id)).toBe(true); + const after = wardFlowReducer(withUndo, { type: "ARCHIVE_PATIENT", role: "coordinator", now: later(UNDO_WINDOW_MINUTES + 1), @@ -211,9 +249,6 @@ describe("ARCHIVE_PATIENT after a withdrawn referral (D-149)", () => { reason: "test", }); expect(after.patients.some((patient) => patient.id === world.patient.id)).toBe(false); - // The withdrawn referral stays, and the archived state is still one the server accepts. - expect(after.referrals.some((entry) => entry.id === world.referralId)).toBe(true); - expect(isValidSharedWardFlowState(after)).toBe(true); }); it("still refuses a referral a ward answered", () => { From 2387696a4b75121a8f0abe46af71faf603c05d20 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:24:47 +0800 Subject: [PATCH 13/51] Trim to fit the phone budgets: share the History toggle and list, reuse the sign-out icon, drop the online listener --- .../shell-next/next-account-menu.tsx | 4 +- .../shell-next/next-phone-menu-sheet.tsx | 3 +- .../ward-shared-save-status.tsx | 79 ++----------------- .../ward/ward-bed-dossier-drawer.tsx | 40 ++++------ .../ward/ward-bed-dossier-phone.tsx | 31 +++----- 5 files changed, 37 insertions(+), 120 deletions(-) diff --git a/src/components/ward-management/shell-next/next-account-menu.tsx b/src/components/ward-management/shell-next/next-account-menu.tsx index 77ec77b0be..1714bc86aa 100644 --- a/src/components/ward-management/shell-next/next-account-menu.tsx +++ b/src/components/ward-management/shell-next/next-account-menu.tsx @@ -1,7 +1,7 @@ "use client"; import { useContext, type RefObject } from "react"; -import { ChevronsUpDown, CircleHelp, DoorOpen, LogOut, Repeat, Settings } from "lucide-react"; +import { ChevronsUpDown, CircleHelp, LogOut, Repeat, Settings } from "lucide-react"; import { useRouter } from "next/navigation"; import { Menu, type MenuItem } from "@/components/wf"; @@ -79,7 +79,7 @@ export function RailAccountMenu({ // Live audit item 22: the public demo's way out (`front-door/leave-demo.ts`). items.push( { kind: "separator", id: "sep" }, - { id: "leave-demo", label: "Leave the demo", icon: DoorOpen, onSelect: act(() => void leaveDemo()) }, + { id: "leave-demo", label: "Leave the demo", icon: LogOut, onSelect: act(() => void leaveDemo()) }, ); } const sheet = variant === "sheet"; diff --git a/src/components/ward-management/shell-next/next-phone-menu-sheet.tsx b/src/components/ward-management/shell-next/next-phone-menu-sheet.tsx index 1f1cd8818c..e62b7a2d41 100644 --- a/src/components/ward-management/shell-next/next-phone-menu-sheet.tsx +++ b/src/components/ward-management/shell-next/next-phone-menu-sheet.tsx @@ -25,7 +25,6 @@ import { ClipboardList, Inbox, Clock3, - DoorOpen, LogOut, PhoneOutgoing, Plus, @@ -382,7 +381,7 @@ function SignOutRow() { data-testid="ward-phone-menu-leave-demo" onClick={() => void leaveDemo()} > - + ) : null; diff --git a/src/components/ward-management/ward-shared-save-status.tsx b/src/components/ward-management/ward-shared-save-status.tsx index 74d2b4c495..208fdf4bb6 100644 --- a/src/components/ward-management/ward-shared-save-status.tsx +++ b/src/components/ward-management/ward-shared-save-status.tsx @@ -32,19 +32,11 @@ const RELOAD_WARNING = "Not confirmed yet. Do not reload: reloading now would lo */ const SAVE_WARNING_AFTER_MS = 8_000; -const onlineNow = () => typeof navigator === "undefined" || navigator.onLine !== false; -function subscribeOnline(notify: () => void) { - window.addEventListener("online", notify); - window.addEventListener("offline", notify); - return () => { - window.removeEventListener("online", notify); - window.removeEventListener("offline", notify); - }; -} - -/** True once a save has been in flight for `SAVE_WARNING_AFTER_MS`, or at once while offline. */ +/** + * True once a save has been in flight for `SAVE_WARNING_AFTER_MS`, or at once while offline. A save + * sent offline fails within moments, so the failure keeps the warning from then on. + */ function useSaveSlow(saving: boolean): boolean { - const online = useSyncExternalStore(subscribeOnline, onlineNow, () => true); const [slow, setSlow] = useState(false); useEffect(() => { if (!saving) return; @@ -54,68 +46,7 @@ function useSaveSlow(saving: boolean): boolean { setSlow(false); }; }, [saving]); - return saving && (slow || !online); -} - -/** Keep recovery inside the top overlay's focus trap, without reopening or closing any record. */ -function useRecoveryHost(show: boolean): HTMLElement | null { - "use no memo"; - const root = useSyncExternalStore(subscribeToSheetStack, topSheetRoot, () => null); - const [host, setHost] = useState(null); - const focusedInHost = useRef(false); - useLayoutEffect(() => { - if (!show || !root) { - return; - } - const panel = root.matches('[role="dialog"]') ? root : root.querySelector('[role="dialog"]'); - // Inline modals may already sit inside the disabled workspace. Recovery there would inherit - // inert too; leave the global control available instead. - if (!panel || panel.closest("[inert]")) { - return; - } - const container = panel.querySelector("[data-phone-sheet-body]") ?? panel; - const element = document.createElement("div"); - element.dataset.wardSharedRecoveryHost = ""; - element.className = styles.host; - container.prepend(element); - focusedInHost.current = false; - const rememberFocus = (event: FocusEvent) => { - focusedInHost.current = event.target instanceof Node && element.contains(event.target); - }; - document.addEventListener("focusin", rememberFocus); - setHost(element); - return () => { - const hadFocus = - element.contains(document.activeElement) || (focusedInHost.current && document.activeElement === document.body); - document.removeEventListener("focusin", rememberFocus); - element.remove(); - // A completed retry removes its control. Keep focus in this same record, while a closing - // or stacked overlay continues to own its normal focus return. - if (hadFocus && panel.isConnected && root === topSheetRoot()) panel.focus({ preventScroll: true }); - }; - }, [root, show]); - return show && root?.contains(host) ? host : null; -} - -/** - * Publish this strip's height as --wf-data-bar-height, so the shell (sized to the window less that - * variable) shortens to fit rather than pushing its bottom edge, the rail's account card, off screen. - */ -function useBarHeight(ref: RefObject, shown: boolean) { - useLayoutEffect(() => { - const bar = ref.current; - if (!bar || !shown) return; - const root = document.documentElement; - const publish = () => - root.style.setProperty("--wf-data-bar-height", `${Math.ceil(bar.getBoundingClientRect().height)}px`); - publish(); - const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(publish); - observer?.observe(bar); - return () => { - observer?.disconnect(); - root.style.removeProperty("--wf-data-bar-height"); - }; - }, [ref, shown]); + return saving && (slow || (typeof navigator !== "undefined" && navigator.onLine === false)); } export function WardSharedSaveStatus({ diff --git a/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx b/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx index 11b46958f6..854821eb19 100644 --- a/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx +++ b/src/components/ward-management/ward/ward-bed-dossier-drawer.tsx @@ -881,6 +881,20 @@ export function WardBedDossierDrawer({ ); + const historyToggle = ( + + ); const history = (
    0 ? ( <> - + {historyToggle} {history} ) : null} @@ -1097,18 +1100,7 @@ export function WardBedDossierDrawer({ ); })} - + {historyToggle} {panel( "now", diff --git a/src/components/ward-management/ward/ward-bed-dossier-phone.tsx b/src/components/ward-management/ward/ward-bed-dossier-phone.tsx index 8b6f858287..97075852ce 100644 --- a/src/components/ward-management/ward/ward-bed-dossier-phone.tsx +++ b/src/components/ward-management/ward/ward-bed-dossier-phone.tsx @@ -160,6 +160,17 @@ export function PhoneBedSheet({ .filter(Boolean) .join(" · "); const admissionId = bedItem?.admissionId; + const timelineList = ( +
      + {timeline.map((event) => ( +
    • + + {formatInstantWithDay(event.at, now)} + {event.label} +
    • + ))} +
    + ); return ( <>
    0 ? ( <>

    History

    -
      - {timeline.map((event) => ( -
    • - - {formatInstantWithDay(event.at, now)} - {event.label} -
    • - ))} -
    + {timelineList} ) : null} @@ -309,15 +312,7 @@ export function PhoneBedSheet({ ) : null}

    History

    {timeline.length > 0 ? ( -
      - {timeline.map((event) => ( -
    • - - {formatInstantWithDay(event.at, now)} - {event.label} -
    • - ))} -
    + timelineList ) : (

    Nothing recorded for this stay yet.

    )} From 9256f3bf0c11c64890d84db8bdd0dabdbd2de5d3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:26:23 +0800 Subject: [PATCH 14/51] D-150: keep global search within its line cap --- .../ward-management/ward-global-search.tsx | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/components/ward-management/ward-global-search.tsx b/src/components/ward-management/ward-global-search.tsx index d6b4be99b7..7845877b16 100644 --- a/src/components/ward-management/ward-global-search.tsx +++ b/src/components/ward-management/ward-global-search.tsx @@ -797,6 +797,11 @@ export function WardGlobalSearch({ setActiveIndex(-1); setHoverIndex(-1); } + // D-150: the scripted clock is the public demo's; the signed-in site runs on real Perth time. + const advanceClock = () => { + if (wardFlow) wardFlow.dispatch({ type: "ADVANCE_CLOCK", role: "demo", now: now ?? wardFlow.now, minutes: 15 }); + close(); + }; useEffect(() => { if (!open) return; @@ -1270,20 +1275,7 @@ export function WardGlobalSearch({ onToggleShortcuts={() => setShowShortcuts((previous) => !previous)} savedHere={myViews.savedHere} onToggleSaveView={myViews.toggleHere} - onAdvanceClock={ - wardFlow?.shared?.enabled - ? undefined - : () => { - if (wardFlow) - wardFlow.dispatch({ - type: "ADVANCE_CLOCK", - role: "demo", - now: now ?? wardFlow.now, - minutes: 15, - }); - close(); - } - } + onAdvanceClock={wardFlow?.shared?.enabled ? undefined : advanceClock} onSwitchDesk={(role, href) => { navigateWithDirtyGuard(() => { if (deskSwitch.shared) { From 5a60aa88c30e0ee007f89055490ce7d8d2051c8f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:28:30 +0800 Subject: [PATCH 15/51] Restore the recovery host and bar height hooks dropped by the previous trim --- .../ward-shared-save-status.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/components/ward-management/ward-shared-save-status.tsx b/src/components/ward-management/ward-shared-save-status.tsx index 208fdf4bb6..0be43413d2 100644 --- a/src/components/ward-management/ward-shared-save-status.tsx +++ b/src/components/ward-management/ward-shared-save-status.tsx @@ -49,6 +49,67 @@ function useSaveSlow(saving: boolean): boolean { return saving && (slow || (typeof navigator !== "undefined" && navigator.onLine === false)); } +/** Keep recovery inside the top overlay's focus trap, without reopening or closing any record. */ +function useRecoveryHost(show: boolean): HTMLElement | null { + "use no memo"; + const root = useSyncExternalStore(subscribeToSheetStack, topSheetRoot, () => null); + const [host, setHost] = useState(null); + const focusedInHost = useRef(false); + useLayoutEffect(() => { + if (!show || !root) { + return; + } + const panel = root.matches('[role="dialog"]') ? root : root.querySelector('[role="dialog"]'); + // Inline modals may already sit inside the disabled workspace. Recovery there would inherit + // inert too; leave the global control available instead. + if (!panel || panel.closest("[inert]")) { + return; + } + const container = panel.querySelector("[data-phone-sheet-body]") ?? panel; + const element = document.createElement("div"); + element.dataset.wardSharedRecoveryHost = ""; + element.className = styles.host; + container.prepend(element); + focusedInHost.current = false; + const rememberFocus = (event: FocusEvent) => { + focusedInHost.current = event.target instanceof Node && element.contains(event.target); + }; + document.addEventListener("focusin", rememberFocus); + setHost(element); + return () => { + const hadFocus = + element.contains(document.activeElement) || (focusedInHost.current && document.activeElement === document.body); + document.removeEventListener("focusin", rememberFocus); + element.remove(); + // A completed retry removes its control. Keep focus in this same record, while a closing + // or stacked overlay continues to own its normal focus return. + if (hadFocus && panel.isConnected && root === topSheetRoot()) panel.focus({ preventScroll: true }); + }; + }, [root, show]); + return show && root?.contains(host) ? host : null; +} + +/** + * Publish this strip's height as --wf-data-bar-height, so the shell (sized to the window less that + * variable) shortens to fit rather than pushing its bottom edge, the rail's account card, off screen. + */ +function useBarHeight(ref: RefObject, shown: boolean) { + useLayoutEffect(() => { + const bar = ref.current; + if (!bar || !shown) return; + const root = document.documentElement; + const publish = () => + root.style.setProperty("--wf-data-bar-height", `${Math.ceil(bar.getBoundingClientRect().height)}px`); + publish(); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(publish); + observer?.observe(bar); + return () => { + observer?.disconnect(); + root.style.removeProperty("--wf-data-bar-height"); + }; + }, [ref, shown]); +} + export function WardSharedSaveStatus({ connection, deskLabel, From 8366206345837343470453026de4630fc0163ecf Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:40:11 +0800 Subject: [PATCH 16/51] Live audit hardening: token wait deadline, security.txt, backend headers, Secure cookies, sign-out cookies, TV log role, zod jitless (items 14, 15, 17, 18, 19, 21, 23) - 14: the shared client's sign-in token wait (untilAborted) gets its own 15 s deadline, then the normal "not refreshed" retry. - 15: /.well-known/security.txt (RFC 9116), open past the front door. - 17: backend replies add Referrer-Policy, Permissions-Policy and Cross-Origin-Resource-Policy. - 18: the theme start-up script's cookie write is Secure over https. - 19: sign-out removes the wf_door marker (DELETE /api/session) and the desk cookie; desk cookie writes are Secure over https. The tab remembers the marker so an in-app re-sign-in still renews. - 21: the backend logs a read as "tv" only for roles that open the TV board (TV_BOARD_ROLES). - 23: zod jitless in the executive statistics contract, so no `new Function` probe trips the CSP. --- backend/ward-flow/security-headers.test.mjs | 29 ++++ backend/ward-flow/server.mjs | 7 +- backend/ward-flow/shared-http.mjs | 8 +- backend/ward-flow/shared-http.test.mjs | 29 ++++ public/.well-known/security.txt | 5 + src/app/api/session/route.ts | 2 + .../front-door/front-door-pass-client.ts | 13 +- .../ward-management/shell/ward-desk-early.ts | 13 +- .../executive-statistics-contract.ts | 5 + .../ward-management/ward-browser-data.ts | 2 + .../ward-shared-client-helpers.ts | 34 ++--- src/lib/front-door-pass.ts | 2 + src/lib/theme.ts | 5 +- tests/front-door-pass.test.ts | 20 ++- tests/ward-live-audit-hardening.test.ts | 125 ++++++++++++++++++ 15 files changed, 270 insertions(+), 29 deletions(-) create mode 100644 backend/ward-flow/security-headers.test.mjs create mode 100644 public/.well-known/security.txt create mode 100644 tests/ward-live-audit-hardening.test.ts diff --git a/backend/ward-flow/security-headers.test.mjs b/backend/ward-flow/security-headers.test.mjs new file mode 100644 index 0000000000..92e5199c4e --- /dev/null +++ b/backend/ward-flow/security-headers.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { request as httpRequest } from "node:http"; +import { test } from "node:test"; +import { listen } from "./server.mjs"; + +test("live audit item 17: every backend reply sends no referrer, offers no feature and refuses no-cors embedding", async () => { + const server = listen(async () => Response.json({ ok: true }), { port: 0, host: "127.0.0.1" }); + try { + await new Promise((resolve) => server.once("listening", resolve)); + const port = /** @type {import("node:net").AddressInfo} */ (server.address()).port; + for (const method of ["GET", "DELETE"]) { + const headers = await new Promise((resolve, reject) => { + const req = httpRequest({ host: "127.0.0.1", port, path: "/healthz", method }, (res) => { + res.resume(); + resolve(res.headers); + }); + req.on("error", reject); + req.end(); + }); + assert.equal(headers["referrer-policy"], "no-referrer", method); + assert.equal(headers["permissions-policy"], "camera=(), microphone=(), geolocation=(), payment=()", method); + assert.equal(headers["cross-origin-resource-policy"], "same-origin", method); + // The existing P174 pair is still there. + assert.equal(headers["content-security-policy"], "default-src 'none'; frame-ancestors 'none'", method); + } + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/backend/ward-flow/server.mjs b/backend/ward-flow/server.mjs index 781743b94e..69a415c3e4 100644 --- a/backend/ward-flow/server.mjs +++ b/backend/ward-flow/server.mjs @@ -18,11 +18,16 @@ const gzipAsync = promisify(gzip); const BODY_LIMIT = 1_048_576; /** * Audit P174: every backend reply is JSON, never a page, so it may not be framed or load anything, - * and browsers keep to HTTPS for a year. + * and browsers keep to HTTPS for a year. Live audit item 17 (hardening only): no referrer leaves a + * reply, no browser feature is offered, and no other site may embed a reply without CORS (the + * site's own CORS reads are unaffected, as CORP applies only to no-cors loads). */ export const BACKEND_SECURITY_HEADERS = Object.freeze({ "strict-transport-security": "max-age=31536000; includeSubDomains", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "referrer-policy": "no-referrer", + "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=()", + "cross-origin-resource-policy": "same-origin", }); /** Audit P177: the uptime monitor sends this header to see the database answer on `/healthz`. */ export const UPTIME_TOKEN_HEADER = "x-ward-flow-uptime-token"; diff --git a/backend/ward-flow/shared-http.mjs b/backend/ward-flow/shared-http.mjs index 60a40b6173..1feed3ed0f 100644 --- a/backend/ward-flow/shared-http.mjs +++ b/backend/ward-flow/shared-http.mjs @@ -53,6 +53,12 @@ const STEP_UP_PATHS = new Set([ const WORKING_AS = "x-ward-working-as"; /** "tv" when the desk is showing the bed meeting TV board, so the access log can say so (H-0731). */ const SCREEN = "x-ward-screen"; +/** + * Live audit item 21: the roles whose desk opens the bed meeting TV board (`ROLE_SCREENS` has + * "bed-meeting" for these only; `tests/ward-live-audit-hardening.test.ts` keeps them in step). Any + * other desk's "tv" header is logged as the list it really is. + */ +export const TV_BOARD_ROLES = new Set(["coordinator", "bed_manager", "ward"]); /** Messages the pg pool and the workspace store use when the database, not the code, failed. */ const STORAGE_MESSAGES = /^(?:Connection terminated|timeout exceeded when trying to connect|Client has encountered a connection error|Cannot use a pool after calling end|Shared schema unavailable|Workspace data mode mismatch|Stored workspace is incompatible)/; @@ -486,7 +492,7 @@ export function createSharedHandler({ // unchanged poll sends none, so it is not logged, except on the TV board: a desk that moves // to the TV keeps showing the records it already holds, so the TV logs its own polls too // (the store logs at most once a window). A failed log write fails the read. - const tvScreen = request.headers.get(SCREEN) === "tv"; + const tvScreen = request.headers.get(SCREEN) === "tv" && TV_BOARD_ROLES.has(view.access.role); const logListRead = async () => { if (view.access.role !== "executive" && store.logList) await store.logList(view, tvScreen ? "tv" : "list"); }; diff --git a/backend/ward-flow/shared-http.test.mjs b/backend/ward-flow/shared-http.test.mjs index 35debd4f2b..aca9d95433 100644 --- a/backend/ward-flow/shared-http.test.mjs +++ b/backend/ward-flow/shared-http.test.mjs @@ -1072,6 +1072,35 @@ test("the server logs each full read of a desk's list or TV board, and the TV's assert.equal((await failing(request())).status, 503); }); +test("live audit item 21: a desk whose role cannot open the TV board is logged as its list", async () => { + const logged = []; + const edView = { ...wardView, access: { role: "ed", scope: { kind: "ed", edIds: ["ed-a"] } } }; + const { handler } = setup(other, "prototype", { + viewer: async () => edView, + logList: async (view, surface) => { + logged.push([view.access.role, surface]); + return true; + }, + }); + const tv = request(); + tv.headers.set("x-ward-screen", "tv"); + assert.equal((await handler(tv)).status, 200); + const { handler: ward } = setup(other, "prototype", { + viewer: async () => wardView, + logList: async (view, surface) => { + logged.push([view.access.role, surface]); + return true; + }, + }); + const wardTv = request(); + wardTv.headers.set("x-ward-screen", "tv"); + assert.equal((await ward(wardTv)).status, 200); + assert.deepEqual(logged, [ + ["ed", "list"], + ["ward", "tv"], + ]); +}); + test("a bootstrap coordinator past its end date has no role (H-0057)", async () => { const handler = createHandler({ config: { diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000000..6d52f49ecc --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,5 @@ +# Ward Flow is a synthetic prototype. See the security policy before testing anything live. +Contact: https://github.com/BigSimmo/Ward-Flow/security/advisories/new +Expires: 2027-11-11T00:00:00.000Z +Policy: https://github.com/BigSimmo/Ward-Flow/blob/main/SECURITY.md +Preferred-Languages: en diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts index 3eb2cfddb5..441a789992 100644 --- a/src/app/api/session/route.ts +++ b/src/app/api/session/route.ts @@ -148,5 +148,7 @@ export function DELETE(request: NextRequest) { "Set-Cookie", `${FRONT_DOOR_PASS_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly${secure}`, ); + // Live audit item 19: the readable marker goes with the pass, so nothing stays after sign-out. + response.headers.append("Set-Cookie", `${FRONT_DOOR_MARKER_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`); return response; } diff --git a/src/components/ward-management/front-door/front-door-pass-client.ts b/src/components/ward-management/front-door/front-door-pass-client.ts index 28bd09909a..2ba444622f 100644 --- a/src/components/ward-management/front-door/front-door-pass-client.ts +++ b/src/components/ward-management/front-door/front-door-pass-client.ts @@ -7,12 +7,20 @@ const MARKER = "wf_door=1"; +/** + * Live audit item 19: sign-out removes the marker, so this tab remembers having seen it. A sign-in + * in the app after an idle sign-out then still renews the pass. + */ +let markerSeen = false; + function frontDoorOn(): boolean { + if (markerSeen) return true; try { - return document.cookie.split(/;\s*/).includes(MARKER); + markerSeen = document.cookie.split(/;\s*/).includes(MARKER); } catch { - return false; + // Cookies blocked: no marker to read. } + return markerSeen; } /** @@ -66,6 +74,7 @@ export async function renewFrontDoorPass(token: () => Promise): Promise< export function endFrontDoorPass(): void { if (!frontDoorOn()) return; try { + // The server's reply removes the marker with the pass (live audit item 19). void fetch("/api/session", { method: "DELETE", credentials: "same-origin", keepalive: true }).catch(() => {}); } catch { // No fetch here: the pass still ends at its own time. diff --git a/src/components/ward-management/shell/ward-desk-early.ts b/src/components/ward-management/shell/ward-desk-early.ts index 3bd025baee..a1a17dc547 100644 --- a/src/components/ward-management/shell/ward-desk-early.ts +++ b/src/components/ward-management/shell/ward-desk-early.ts @@ -27,12 +27,17 @@ export const WARD_DESK_HELD_COOKIE = "wardflow-desk-held"; const HELD_COOKIE = `${WARD_DESK_HELD_COOKIE}=1;path=/ward-flow;SameSite=Strict`; const CLEARED_COOKIE = `${WARD_DESK_HELD_COOKIE}=;path=/ward-flow;SameSite=Strict;max-age=0`; -export const WARD_DESK_EARLY_SCRIPT = `(function(){try{var d=JSON.parse(sessionStorage.getItem("wardflow:access-desk-v1")||"null");if(d&&typeof d==="object"){if(d.role==="coordinator"&&d.scope&&d.scope.kind==="statewide"){document.cookie="${CLEARED_COOKIE}";}else{document.documentElement.setAttribute("${WARD_DESK_PENDING_ATTRIBUTE}","");document.cookie="${HELD_COOKIE}";}}}catch(e){/* storage blocked - the server's page stands */}})();`; +/** Live audit item 19: every write of the desk cookie is Secure over https. */ +export const WARD_DESK_EARLY_SCRIPT = `(function(){try{var d=JSON.parse(sessionStorage.getItem("wardflow:access-desk-v1")||"null"),s=location.protocol==="https:"?";Secure":"";if(d&&typeof d==="object"){if(d.role==="coordinator"&&d.scope&&d.scope.kind==="statewide"){document.cookie="${CLEARED_COOKIE}"+s;}else{document.documentElement.setAttribute("${WARD_DESK_PENDING_ATTRIBUTE}","");document.cookie="${HELD_COOKIE}"+s;}}}catch(e){/* storage blocked - the server's page stands */}})();`; -/** Keeps the cookie in step with the desk this tab has just stored (`ward-access-store.ts`). */ -export function syncWardDeskHeldCookie(wholeNetwork: boolean): void { +/** + * Keeps the cookie in step with the desk this tab has just stored (`ward-access-store.ts`). Sign-out + * passes `null`, which removes it, so nothing of the desk stays readable (live audit item 19). + */ +export function syncWardDeskHeldCookie(wholeNetwork: boolean | null): void { try { - document.cookie = wholeNetwork ? CLEARED_COOKIE : HELD_COOKIE; + document.cookie = + (wholeNetwork === false ? HELD_COOKIE : CLEARED_COOKIE) + (location.protocol === "https:" ? ";Secure" : ""); } catch { // Cookies blocked: the first-load hide above still applies. } diff --git a/src/components/ward-management/statistics/executive-statistics-contract.ts b/src/components/ward-management/statistics/executive-statistics-contract.ts index 3ae36f6b2b..6fcc99e476 100644 --- a/src/components/ward-management/statistics/executive-statistics-contract.ts +++ b/src/components/ward-management/statistics/executive-statistics-contract.ts @@ -13,6 +13,11 @@ import { ED_WAIT_BANDS } from "./statistics-ed-waits"; import { reportWeek } from "../reports/weekly-report"; import { validateConfiguration } from "../ward-configuration"; +// Live audit item 23: before the first schema is built, so zod never probes `new Function`, which +// the production CSP refuses and reports on every executive page. This is Ward Flow's only full-zod +// schema in the browser (the others use zod/mini, which compiles nothing). +z.config({ jitless: true }); + /** Foundation only. No server or screen adopts this payload until its full reporting contract is ready. */ const count = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); const releasedCount = z.discriminatedUnion("kind", [ diff --git a/src/components/ward-management/ward-browser-data.ts b/src/components/ward-management/ward-browser-data.ts index 63686e6496..f2e6bf244b 100644 --- a/src/components/ward-management/ward-browser-data.ts +++ b/src/components/ward-management/ward-browser-data.ts @@ -1,5 +1,6 @@ import { endFrontDoorPass } from "@/components/ward-management/front-door/front-door-pass-client"; import { clearSessionDrafts } from "./ward-session-drafts"; +import { syncWardDeskHeldCookie } from "./shell/ward-desk-early"; /** * Ward Flow's browser storage, cleared when a signed-in session ends (overall review 20 Oct 2026, @@ -163,6 +164,7 @@ export function endWardFlowBrowserSession(): void { } } clearWardFlowBrowserData(); + syncWardDeskHeldCookie(null); // The front door's pass ends with the sign-in, so the next person at this browser starts outside. endFrontDoorPass(); } diff --git a/src/components/ward-management/ward-shared-client-helpers.ts b/src/components/ward-management/ward-shared-client-helpers.ts index 32a2a074b0..3fa1d4d16f 100644 --- a/src/components/ward-management/ward-shared-client-helpers.ts +++ b/src/components/ward-management/ward-shared-client-helpers.ts @@ -28,27 +28,29 @@ export function anySignal(signals: readonly AbortSignal[]): AbortSignal { return controller.signal; } -/** Settles with `promise`, or rejects with an AbortError as soon as `signal` aborts. */ -export function untilAborted(promise: Promise, signal: AbortSignal | undefined): Promise { - if (!signal) return promise; - if (signal.aborted) { +/** + * Settles with `promise`, or rejects with an AbortError as soon as `signal` aborts. Only the shared + * client's sign-in token wait uses it, so it also gives that wait its own deadline (15 s, as long as + * a request may take): a token step that never answers ends in the normal "not refreshed" retry + * instead of an endless spinner (live audit item 14). + */ +export function untilAborted(promise: Promise, signal: AbortSignal | undefined, ms = 15_000): Promise { + if (signal?.aborted) { // Cancellation settles the caller, but the token request can still reject later. void promise.catch(() => {}); return Promise.reject(new DOMException("The poll was stopped", "AbortError")); } + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; return new Promise((resolve, reject) => { - const stop = () => reject(new DOMException("The poll was stopped", "AbortError")); - signal.addEventListener("abort", stop, { once: true }); - promise.then( - (value) => { - signal.removeEventListener("abort", stop); - resolve(value); - }, - (error: unknown) => { - signal.removeEventListener("abort", stop); - reject(error); - }, - ); + onAbort = () => reject(new DOMException("The poll was stopped", "AbortError")); + signal?.addEventListener("abort", onAbort, { once: true }); + timer = setTimeout(() => reject(new Error("Sign-in check timed out")), ms); + // A late answer or failure after either stop is observed here and dropped. + promise.then(resolve, reject); + }).finally(() => { + clearTimeout(timer); + if (onAbort) signal?.removeEventListener("abort", onAbort); }); } diff --git a/src/lib/front-door-pass.ts b/src/lib/front-door-pass.ts index 3f07c79205..c08a8a38ee 100644 --- a/src/lib/front-door-pass.ts +++ b/src/lib/front-door-pass.ts @@ -59,6 +59,8 @@ const OPEN_PATHS = new Set([ "/api/health", "/api/local-project-id", "/robots.txt", + // Live audit item 15: the security contact (RFC 9116) is readable before sign-in. + "/.well-known/security.txt", "/opengraph-image", "/ward-flow-offline.html", "/ward-flow-push-sw.js", diff --git a/src/lib/theme.ts b/src/lib/theme.ts index d8e08cdd9a..5f7f22895c 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -47,9 +47,10 @@ export const LEGACY_WARD_APPEARANCE_KEY = "ward-flow-appearance"; * Every catch swallows deliberately, and each says so inline. This runs before React mounts, so * there is no logger or toast to report to, and every failure here means the same thing: no * explicit pin was readable, so the next fallback applies. Keep the inline notes to one short - * clause — this string ships in every page's ``. + * clause — this string ships in every page's ``. Its cookie write is Secure over https, as in + * `./theme-client` (live audit item 18). */ -export const THEME_BOOTSTRAP_SCRIPT = `(function(){var K="${THEME_STORAGE_KEY}",L="${LEGACY_WARD_APPEARANCE_KEY}",t=null;try{t=localStorage.getItem(K);var w=localStorage.getItem(L);if(t!=="light"&&t!=="dark"&&(w==="light"||w==="dark")){t=w;localStorage.setItem(K,w);try{document.cookie="${THEME_COOKIE_NAME}="+w+"; path=/; max-age=31536000; SameSite=Lax";}catch(e){/* cookie blocked - storage still holds the pin */}}if(w!==null)localStorage.removeItem(L);}catch(e){/* storage blocked (private/partitioned) - fall through to the cookie, then the OS preference */}if(t!=="light"&&t!=="dark"){t=null;try{var m=document.cookie.match(/(?:^|; )${THEME_COOKIE_NAME}=(light|dark)(?:;|$)/);if(m)t=m[1];}catch(e){/* cookie access blocked (sandboxed frame) - the OS preference below applies */}}var r=document.documentElement,q=null;try{q=window.matchMedia("(prefers-color-scheme: dark)");}catch(e){/* no matchMedia - treat the OS as light */}function paint(d){r.classList.toggle("dark",d);var c=d?"${APP_THEME_COLORS.dark}":"${APP_THEME_COLORS.light}";document.querySelectorAll('meta[name="theme-color"]').forEach(function(x){x.setAttribute("content",c);});}if(r.setAttribute){if(t)r.setAttribute("data-theme",t);else r.removeAttribute("data-theme");}paint(t?t==="dark":!!(q&&q.matches));try{q.addEventListener("change",function(){if(!r.getAttribute("data-theme"))paint(q.matches);});}catch(e){/* old browser - Auto follows the OS on the next load */}})();`; +export const THEME_BOOTSTRAP_SCRIPT = `(function(){var K="${THEME_STORAGE_KEY}",L="${LEGACY_WARD_APPEARANCE_KEY}",t=null;try{t=localStorage.getItem(K);var w=localStorage.getItem(L);if(t!=="light"&&t!=="dark"&&(w==="light"||w==="dark")){t=w;localStorage.setItem(K,w);try{document.cookie="${THEME_COOKIE_NAME}="+w+"; path=/; max-age=31536000; SameSite=Lax"+(location.protocol==="https:"?"; Secure":"");}catch(e){/* cookie blocked - storage still holds the pin */}}if(w!==null)localStorage.removeItem(L);}catch(e){/* storage blocked (private/partitioned) - fall through to the cookie, then the OS preference */}if(t!=="light"&&t!=="dark"){t=null;try{var m=document.cookie.match(/(?:^|; )${THEME_COOKIE_NAME}=(light|dark)(?:;|$)/);if(m)t=m[1];}catch(e){/* cookie access blocked (sandboxed frame) - the OS preference below applies */}}var r=document.documentElement,q=null;try{q=window.matchMedia("(prefers-color-scheme: dark)");}catch(e){/* no matchMedia - treat the OS as light */}function paint(d){r.classList.toggle("dark",d);var c=d?"${APP_THEME_COLORS.dark}":"${APP_THEME_COLORS.light}";document.querySelectorAll('meta[name="theme-color"]').forEach(function(x){x.setAttribute("content",c);});}if(r.setAttribute){if(t)r.setAttribute("data-theme",t);else r.removeAttribute("data-theme");}paint(t?t==="dark":!!(q&&q.matches));try{q.addEventListener("change",function(){if(!r.getAttribute("data-theme"))paint(q.matches);});}catch(e){/* old browser - Auto follows the OS on the next load */}})();`; /** * Glare mode: a separate on/off preference for ward PCs by bright windows. On sets diff --git a/tests/front-door-pass.test.ts b/tests/front-door-pass.test.ts index de3ad6c3db..3a85c75e49 100644 --- a/tests/front-door-pass.test.ts +++ b/tests/front-door-pass.test.ts @@ -79,9 +79,18 @@ describe("front door switch and secret", () => { "/api/health", "/ward-flow-offline.html", "/ward-flow-push-sw.js", + "/.well-known/security.txt", ]) expect(isFrontDoorOpenPath(path)).toBe(true); - for (const path of ["/", "/ward-flow", "/api/workspace", "/sign-in/extra", "/ward-flow-sign-in/extra", "/mockups"]) + for (const path of [ + "/", + "/ward-flow", + "/api/workspace", + "/sign-in/extra", + "/ward-flow-sign-in/extra", + "/mockups", + "/.well-known/other.txt", + ]) expect(isFrontDoorOpenPath(path)).toBe(false); }); }); @@ -502,12 +511,17 @@ describe("DELETE /api/session", () => { it("clears the pass", () => { const response = DELETE(new NextRequest(new URL("https://ward.example.test/api/session"), { method: "DELETE" })); expect(response.status).toBe(204); - expect(response.headers.get("set-cookie")).toMatch(/^wf_pass=; Path=\/; Max-Age=0;.*HttpOnly; Secure$/); + const [pass, marker] = response.headers.getSetCookie(); + expect(pass).toMatch(/^wf_pass=; Path=\/; Max-Age=0;.*HttpOnly; Secure$/); + // Live audit item 19: the readable marker goes too, so nothing stays after sign-out. + expect(marker).toBe("wf_door=; Path=/; Max-Age=0; SameSite=Lax; Secure"); }); it("marks the cookie Secure in production even when the app sees plain http behind the edge", () => { vi.stubEnv("NODE_ENV", "production"); const response = DELETE(new NextRequest(new URL("http://ward.example.test/api/session"), { method: "DELETE" })); - expect(response.headers.get("set-cookie")).toMatch(/HttpOnly; Secure$/); + const [pass, marker] = response.headers.getSetCookie(); + expect(pass).toMatch(/HttpOnly; Secure$/); + expect(marker).toMatch(/; Secure$/); }); }); diff --git a/tests/ward-live-audit-hardening.test.ts b/tests/ward-live-audit-hardening.test.ts new file mode 100644 index 0000000000..a7a4bad139 --- /dev/null +++ b/tests/ward-live-audit-hardening.test.ts @@ -0,0 +1,125 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ROLE_SCREENS, WARD_ACCESS_ROLES } from "@/components/ward-management/ward-role-access"; +import { untilAborted } from "@/components/ward-management/ward-shared-client-helpers"; +import { THEME_BOOTSTRAP_SCRIPT, LEGACY_WARD_APPEARANCE_KEY, THEME_COOKIE_NAME } from "@/lib/theme"; + +const root = join(__dirname, ".."); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("live audit item 14: the sign-in token wait has its own deadline", () => { + it("rejects with a plain error once the wait passes, so the normal retry shows", async () => { + vi.useFakeTimers(); + const stuck = untilAborted(new Promise(() => {}), undefined); + const settled = expect(stuck).rejects.toThrow("Sign-in check timed out"); + await vi.advanceTimersByTimeAsync(15_000); + await settled; + }); + + it("passes an answer or a failure straight through, and still stops with the poll", async () => { + await expect(untilAborted(Promise.resolve("token"), undefined, 50)).resolves.toBe("token"); + await expect(untilAborted(Promise.reject(new Error("refused")), undefined, 50)).rejects.toThrow("refused"); + const poll = new AbortController(); + const stopped = untilAborted(new Promise(() => {}), poll.signal, 50); + poll.abort(); + await expect(stopped).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +describe("live audit item 21: the access log says TV board only for a desk that opens it", () => { + it("keeps the server's TV roles in step with the roles that open the bed meeting screen", () => { + const source = readFileSync(join(root, "backend/ward-flow/shared-http.mjs"), "utf8"); + const match = /export const TV_BOARD_ROLES = new Set\(\[([^\]]*)\]\)/u.exec(source); + expect(match).not.toBeNull(); + const server = [...match![1].matchAll(/"([a-z_]+)"/gu)].map((entry) => entry[1]).sort(); + const opens = WARD_ACCESS_ROLES.filter((role) => ROLE_SCREENS[role]["bed-meeting"] !== undefined).sort(); + expect(server).toEqual(opens); + }); +}); + +describe("live audit item 15: security.txt", () => { + it("names a contact and a future expiry (RFC 9116)", () => { + const text = readFileSync(join(root, "public/.well-known/security.txt"), "utf8"); + expect(text).toMatch(/^Contact: https:\/\/github\.com\/BigSimmo\/Ward-Flow\/security\/advisories\/new$/mu); + const expires = /^Expires: (\S+)$/mu.exec(text)?.[1]; + expect(Number.isFinite(Date.parse(expires ?? ""))).toBe(true); + }); +}); + +describe("live audit item 18: the theme start-up script's cookie is Secure over https", () => { + function migrate(protocol: string) { + vi.stubGlobal("location", { protocol }); + const store: Record = { [LEGACY_WARD_APPEARANCE_KEY]: "dark" }; + const page = { + cookie: "", + documentElement: { + classList: { toggle() {} }, + setAttribute() {}, + removeAttribute() {}, + getAttribute: () => null, + }, + querySelectorAll: () => [], + }; + new Function("localStorage", "window", "document", THEME_BOOTSTRAP_SCRIPT)( + { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => (store[key] = value), + removeItem: (key: string) => delete store[key], + }, + { matchMedia: () => ({ matches: false, addEventListener() {} }) }, + page, + ); + return page.cookie; + } + + it("adds Secure on https and leaves it off on plain http", () => { + expect(migrate("https:")).toBe(`${THEME_COOKIE_NAME}=dark; path=/; max-age=31536000; SameSite=Lax; Secure`); + expect(migrate("http:")).toBe(`${THEME_COOKIE_NAME}=dark; path=/; max-age=31536000; SameSite=Lax`); + }); +}); + +describe("live audit item 19: sign-out leaves no readable Ward Flow cookie", () => { + it("clears the desk cookie, Secure over https", async () => { + const writes: string[] = []; + vi.stubGlobal("location", { protocol: "https:" }); + vi.stubGlobal("document", { + set cookie(value: string) { + writes.push(value); + }, + get cookie() { + return ""; + }, + }); + const { syncWardDeskHeldCookie } = await import("@/components/ward-management/shell/ward-desk-early"); + syncWardDeskHeldCookie(false); + syncWardDeskHeldCookie(null); + expect(writes).toEqual([ + "wardflow-desk-held=1;path=/ward-flow;SameSite=Strict;Secure", + "wardflow-desk-held=;path=/ward-flow;SameSite=Strict;max-age=0;Secure", + ]); + }); + + it("the sign-out sweep clears the desk cookie", () => { + const source = readFileSync(join(root, "src/components/ward-management/ward-browser-data.ts"), "utf8"); + expect(source).toMatch(/clearWardFlowBrowserData\(\);\n\s*syncWardDeskHeldCookie\(null\);/u); + }); +}); + +describe("live audit item 23: zod builds no code in the browser", () => { + it("sets jitless before the executive statistics contract's first schema", () => { + const source = readFileSync( + join(root, "src/components/ward-management/statistics/executive-statistics-contract.ts"), + "utf8", + ); + const jitless = source.indexOf("z.config({ jitless: true });"); + expect(jitless).toBeGreaterThan(-1); + expect(jitless).toBeLessThan(source.indexOf("z.number()")); + }); +}); From e60937e3a5799a84abbe6b2bbaeca9b55b1a4ff1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:00:13 +0800 Subject: [PATCH 17/51] Keep TV_BOARD_ROLES module-private so knip sees no new unused export (item 21) --- backend/ward-flow/shared-http.mjs | 2 +- tests/ward-live-audit-hardening.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/ward-flow/shared-http.mjs b/backend/ward-flow/shared-http.mjs index 1feed3ed0f..e103c7f127 100644 --- a/backend/ward-flow/shared-http.mjs +++ b/backend/ward-flow/shared-http.mjs @@ -58,7 +58,7 @@ const SCREEN = "x-ward-screen"; * "bed-meeting" for these only; `tests/ward-live-audit-hardening.test.ts` keeps them in step). Any * other desk's "tv" header is logged as the list it really is. */ -export const TV_BOARD_ROLES = new Set(["coordinator", "bed_manager", "ward"]); +const TV_BOARD_ROLES = new Set(["coordinator", "bed_manager", "ward"]); /** Messages the pg pool and the workspace store use when the database, not the code, failed. */ const STORAGE_MESSAGES = /^(?:Connection terminated|timeout exceeded when trying to connect|Client has encountered a connection error|Cannot use a pool after calling end|Shared schema unavailable|Workspace data mode mismatch|Stored workspace is incompatible)/; diff --git a/tests/ward-live-audit-hardening.test.ts b/tests/ward-live-audit-hardening.test.ts index a7a4bad139..22c8ecd402 100644 --- a/tests/ward-live-audit-hardening.test.ts +++ b/tests/ward-live-audit-hardening.test.ts @@ -36,7 +36,7 @@ describe("live audit item 14: the sign-in token wait has its own deadline", () = describe("live audit item 21: the access log says TV board only for a desk that opens it", () => { it("keeps the server's TV roles in step with the roles that open the bed meeting screen", () => { const source = readFileSync(join(root, "backend/ward-flow/shared-http.mjs"), "utf8"); - const match = /export const TV_BOARD_ROLES = new Set\(\[([^\]]*)\]\)/u.exec(source); + const match = /const TV_BOARD_ROLES = new Set\(\[([^\]]*)\]\)/u.exec(source); expect(match).not.toBeNull(); const server = [...match![1].matchAll(/"([a-z_]+)"/gu)].map((entry) => entry[1]).sort(); const opens = WARD_ACCESS_ROLES.filter((role) => ROLE_SCREENS[role]["bed-meeting"] !== undefined).sort(); From 1e6d5483c8a446201039f1cae58ce4e887fe4c7b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:16:13 +0800 Subject: [PATCH 18/51] Combined live audit PR: Leave the demo ends the pass through the front door's own call; archive panel test mocks the board clock --- .../front-door/front-door-pass-client.ts | 24 +++++++++++++++---- .../ward-management/front-door/leave-demo.ts | 7 ++++-- .../ward-management/ward-browser-data.ts | 2 +- .../ward-runtime-archive-outcome.dom.test.tsx | 5 +++- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/components/ward-management/front-door/front-door-pass-client.ts b/src/components/ward-management/front-door/front-door-pass-client.ts index 2ba444622f..b93034adeb 100644 --- a/src/components/ward-management/front-door/front-door-pass-client.ts +++ b/src/components/ward-management/front-door/front-door-pass-client.ts @@ -70,13 +70,29 @@ export async function renewFrontDoorPass(token: () => Promise): Promise< } } -/** At sign-out, an idle sign-out or the sign-in limit, end the pass too. Survives the page closing. */ -export function endFrontDoorPass(): void { - if (!frontDoorOn()) return; +/** The pass ending now, so a second caller in the same moment waits on it rather than asking again. */ +let ending: Promise | null = null; + +/** + * At sign-out, an idle sign-out or the sign-in limit, end the pass too. Survives the page closing. + * Resolves once the server has answered, so Leave the demo can wait before opening the sign-in page. + */ +export function endFrontDoorPass(): Promise { + if (!frontDoorOn()) return Promise.resolve(); + if (ending) return ending; try { // The server's reply removes the marker with the pass (live audit item 19). - void fetch("/api/session", { method: "DELETE", credentials: "same-origin", keepalive: true }).catch(() => {}); + ending = fetch("/api/session", { method: "DELETE", credentials: "same-origin", keepalive: true }) + .then( + () => {}, + () => {}, + ) + .finally(() => { + ending = null; + }); + return ending; } catch { // No fetch here: the pass still ends at its own time. + return Promise.resolve(); } } diff --git a/src/components/ward-management/front-door/leave-demo.ts b/src/components/ward-management/front-door/leave-demo.ts index b4b908da3b..d1a43e1375 100644 --- a/src/components/ward-management/front-door/leave-demo.ts +++ b/src/components/ward-management/front-door/leave-demo.ts @@ -2,6 +2,8 @@ import { useSyncExternalStore } from "react"; +import { endFrontDoorPass } from "./front-door-pass-client"; + /** * Live audit item 22 (11 Nov 2026): the public demo had no way to leave. Only a site with the front * door on has its readable marker cookie (`wf_door=1`), so "Leave the demo" shows only there, and @@ -32,8 +34,9 @@ export async function leaveDemo(): Promise { } catch { // Keep going: the pass below is what keeps this browser out. } - // Awaited, so the sign-in page never opens while the pass is still good. - await fetch("/api/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {}); + // The front door's own call, awaited (the sweep above started it), so the sign-in page never opens + // while the pass is still good. + await endFrontDoorPass(); // A full load, not a client navigation: the sign-in page must be drawn fresh, and Back must not // return to the demo. window.location.replace("/sign-in"); diff --git a/src/components/ward-management/ward-browser-data.ts b/src/components/ward-management/ward-browser-data.ts index f2e6bf244b..0f3d5927e5 100644 --- a/src/components/ward-management/ward-browser-data.ts +++ b/src/components/ward-management/ward-browser-data.ts @@ -166,7 +166,7 @@ export function endWardFlowBrowserSession(): void { clearWardFlowBrowserData(); syncWardDeskHeldCookie(null); // The front door's pass ends with the sign-in, so the next person at this browser starts outside. - endFrontDoorPass(); + void endFrontDoorPass(); } /** diff --git a/tests/ward-runtime-archive-outcome.dom.test.tsx b/tests/ward-runtime-archive-outcome.dom.test.tsx index 8d4a403186..ecf398d3b5 100644 --- a/tests/ward-runtime-archive-outcome.dom.test.tsx +++ b/tests/ward-runtime-archive-outcome.dom.test.tsx @@ -5,7 +5,10 @@ import type { WardCommandOutcome } from "@/components/ward-management/ward-share const mocks = vi.hoisted(() => ({ raise: vi.fn(), replace: vi.fn() })); vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.replace }) })); vi.mock("@/components/ward-management/ward-flow-raise", () => ({ useWardRaise: () => mocks.raise })); -vi.mock("@/components/ward-management/ward-flow-provider", () => ({ useWardFlowSelect: () => ({}) })); +vi.mock("@/components/ward-management/ward-flow-provider", () => ({ + useWardFlowSelect: () => ({}), + useWardFlowClock: () => 0, +})); vi.mock("@/components/ward-management/ward-role-gate", () => ({ useRoleGate: () => () => ({ allowed: true }), useRouteRole: () => "coordinator", From 94e026438683a3e4f3b6617b3fcf8c2200239f17 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:22:38 +0800 Subject: [PATCH 19/51] Keep the toast's reason menu off every first load: load it only when a toast offers choices (phone script budgets) --- src/components/wf/toast-choices.tsx | 35 +++++++++++++++++++ src/components/wf/toast.tsx | 35 +++++++++++-------- .../ward-bed-close-reason-toast.dom.test.tsx | 7 ++-- 3 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 src/components/wf/toast-choices.tsx diff --git a/src/components/wf/toast-choices.tsx b/src/components/wf/toast-choices.tsx new file mode 100644 index 0000000000..104d0abd68 --- /dev/null +++ b/src/components/wf/toast-choices.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { buttonClass } from "./button"; +import { cx } from "./cx"; +import { Menu } from "./menu"; +import styles from "./overlay.module.css"; +import type { ToastChoices } from "./toast"; + +/** + * The toast's follow-up choice menu (D-148). Its own file, loaded only when a toast offers choices, + * so the kit menu stays off the first load of every page that shows toasts. + */ +export default function ToastChoicesMenu({ choices }: { choices: ToastChoices }) { + return ( + ({ + id: item.id, + label: item.label, + onSelect: () => choices.onChoose(item.id), + }))} + trigger={(props) => ( + + )} + /> + ); +} diff --git a/src/components/wf/toast.tsx b/src/components/wf/toast.tsx index 26061a1639..26327efe85 100644 --- a/src/components/wf/toast.tsx +++ b/src/components/wf/toast.tsx @@ -1,9 +1,17 @@ "use client"; import { X } from "lucide-react"; -import { type CSSProperties, type HTMLAttributes, type ReactNode, useEffect, useRef, useState } from "react"; +import { + type CSSProperties, + type HTMLAttributes, + type ReactNode, + Suspense, + lazy, + useEffect, + useRef, + useState, +} from "react"; import { Button, buttonClass } from "./button"; -import { Menu } from "./menu"; import { cx } from "./cx"; import styles from "./overlay.module.css"; import { StatusGlyph, type WfTone } from "./status-glyph"; @@ -49,6 +57,9 @@ export type ToastChoices = { onChoose: (id: string) => void; }; +/** Loaded only when a toast offers choices (D-148), so the kit menu is off every first load. */ +const ToastChoicesMenu = lazy(() => import("./toast-choices")); + export type ToastNextStep = { label: string; href?: string; @@ -293,25 +304,19 @@ export function ToastView({ ) ) : null} {choices && (!undo || phase === "running") ? ( - ({ - id: item.id, - label: item.label, - onSelect: () => choices.onChoose(item.id), - }))} - trigger={(props) => ( + {choices.label} - )} - /> + } + > + + ) : null} {undo ? : null} {onClose ? ( diff --git a/tests/ward-bed-close-reason-toast.dom.test.tsx b/tests/ward-bed-close-reason-toast.dom.test.tsx index a29c563aab..ab011267aa 100644 --- a/tests/ward-bed-close-reason-toast.dom.test.tsx +++ b/tests/ward-bed-close-reason-toast.dom.test.tsx @@ -60,8 +60,10 @@ describe("bed closure reason on the toast (D-148)", () => { expect(dispatch.mock.calls[0][0]).not.toHaveProperty("reason"); const toast = screen.getByTestId("toast"); expect(within(toast).getByRole("button", { name: "Undo" })).toBeInTheDocument(); + // The choice menu is loaded on use. + const choose = await within(toast).findByTestId("toast-choices"); await act(async () => { - fireEvent.click(within(toast).getByTestId("toast-choices")); + fireEvent.click(choose); }); await act(async () => { fireEvent.click(screen.getByRole("menuitem", { name: "Staffing" })); @@ -93,8 +95,9 @@ describe("bed closure reason on the toast (D-148)", () => { await act(async () => { fireEvent.click(screen.getByText("Close this bed")); }); + const choose = await within(screen.getByTestId("toast")).findByTestId("toast-choices"); await act(async () => { - fireEvent.click(within(screen.getByTestId("toast")).getByTestId("toast-choices")); + fireEvent.click(choose); }); await act(async () => { fireEvent.click(screen.getByRole("menuitem", { name: "Infection outbreak" })); From d5a8fd86a8cdd634487e80d830b65a852e429163 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:25:48 +0800 Subject: [PATCH 20/51] Leave the demo loads the front door's pass call on use, so the shell does not carry it --- src/components/ward-management/front-door/leave-demo.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/ward-management/front-door/leave-demo.ts b/src/components/ward-management/front-door/leave-demo.ts index d1a43e1375..41f7fec27a 100644 --- a/src/components/ward-management/front-door/leave-demo.ts +++ b/src/components/ward-management/front-door/leave-demo.ts @@ -2,8 +2,6 @@ import { useSyncExternalStore } from "react"; -import { endFrontDoorPass } from "./front-door-pass-client"; - /** * Live audit item 22 (11 Nov 2026): the public demo had no way to leave. Only a site with the front * door on has its readable marker cookie (`wf_door=1`), so "Leave the demo" shows only there, and @@ -35,8 +33,11 @@ export async function leaveDemo(): Promise { // Keep going: the pass below is what keeps this browser out. } // The front door's own call, awaited (the sweep above started it), so the sign-in page never opens - // while the pass is still good. - await endFrontDoorPass(); + // while the pass is still good. Loaded on use too, like the sweep. + await import("./front-door-pass-client").then( + ({ endFrontDoorPass }) => endFrontDoorPass(), + () => {}, + ); // A full load, not a client navigation: the sign-in page must be drawn fresh, and Back must not // return to the demo. window.location.replace("/sign-in"); From bbd3a4c49e67a9c4883311e3b521fa8faf56832f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:30:05 +0800 Subject: [PATCH 21/51] D-148: say the audit trail keeps both steps; the bed's History shows the closure with its reason --- docs/ward-flow/decisions.md | 2 +- src/components/ward-management/use-undoable-step.ts | 4 ++-- .../ward-management/ward/ward-bed-closure-reason.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/ward-flow/decisions.md b/docs/ward-flow/decisions.md index 08b1602df5..962efcdc20 100644 --- a/docs/ward-flow/decisions.md +++ b/docs/ward-flow/decisions.md @@ -1734,7 +1734,7 @@ is inferred. ## D-148. Closing a bed stays one tap, and its toast offers a reason (11 November 2026) - **Date:** 11 November 2026. **Decided by:** Josh, approving every recommendation in the "Ward Flow live audit: verified findings and fix plan" (decision item 4) after fact-checking: "Go ahead with all recommendations after fact checking please". Numbered D-148 because D-145 is claimed by open PR #1623. D-146 is claimed by open PR #1624. D-147 is claimed by open PR #1615. -- **Decision:** "Close this bed" in the bed drawer still saves on one tap with Undo (the 26 October fast-workflow choice). No confirm step is added. Its toast now offers "Add a reason", a kit menu of the existing ward closure reasons (`WARD_CLOSURE_REASONS`). Choosing one undoes the close and closes the bed again with that reason, so the reason is chosen, never typed, and the History shows both steps. +- **Decision:** "Close this bed" in the bed drawer still saves on one tap with Undo (the 26 October fast-workflow choice). No confirm step is added. Its toast now offers "Add a reason", a kit menu of the existing ward closure reasons (`WARD_CLOSURE_REASONS`). Choosing one undoes the close and closes the bed again with that reason, so the reason is chosen, never typed, and the audit trail keeps both steps (the bed's History shows the closure with its reason). - **Why:** the live audit found closures with no reason; a reason helps the bed meeting without slowing the close. - **Boundary:** synthetic data only. No engine rule changes: `SET_BED_CLOSED` already took an optional reason. diff --git a/src/components/ward-management/use-undoable-step.ts b/src/components/ward-management/use-undoable-step.ts index fc9ade0482..1034e53104 100644 --- a/src/components/ward-management/use-undoable-step.ts +++ b/src/components/ward-management/use-undoable-step.ts @@ -65,8 +65,8 @@ type Pending = { /** * D-148: an optional choice on the success toast, such as a bed closure's reason. Choosing undoes - * the step and records it again with the choice, so the step itself stays one tap and the History - * shows both. The new step offers Undo as the first did. + * the step and records it again with the choice, so the step itself stays one tap and the audit + * trail keeps both. The new step offers Undo as the first did. */ export type UndoStepChoices = { label: string; diff --git a/src/components/ward-management/ward/ward-bed-closure-reason.ts b/src/components/ward-management/ward/ward-bed-closure-reason.ts index 10fb13f990..cabf887519 100644 --- a/src/components/ward-management/ward/ward-bed-closure-reason.ts +++ b/src/components/ward-management/ward/ward-bed-closure-reason.ts @@ -7,8 +7,8 @@ type BedClose = Extract; /** * D-148 (Josh, 11 Nov 2026): "Close this bed" stays one tap with Undo, and its toast offers an * optional reason from the same list a ward closure uses (`WARD_CLOSURE_REASONS`). Choosing one - * undoes the close and closes the bed again with the reason, so the History shows both steps and - * nothing typed is ever stored. No confirm step is added. + * undoes the close and closes the bed again with the reason, so the audit trail keeps both steps, + * the bed's History shows the closure with its reason, and nothing typed is ever stored. No confirm step is added. */ export function bedClosureReasonChoices(close: Omit): UndoStepChoices { const label = (id: string) => wardClosureReasonLabel(id as WardClosureReason); From e664ef8e1188a19b06bed19d20a23b247f6a69be Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:48:16 +0800 Subject: [PATCH 22/51] Journey review records for the live audit fixes (D-149 archive guard, D-150 clock controls, moved citations); doc facts to D-151 --- docs/FACTS.md | 4 +- docs/ward-flow/journey/MAP-INVENTORY.md | 13 ++-- docs/ward-flow/journey/README.md | 7 +++ docs/ward-flow/journey/actions.json | 60 +++++++++---------- .../ward-flow/journey/event-dispositions.json | 17 +++--- .../journey/ward-journey-explorer.html | 2 +- 6 files changed, 56 insertions(+), 47 deletions(-) diff --git a/docs/FACTS.md b/docs/FACTS.md index 0650b8287a..91d89deb0d 100644 --- a/docs/FACTS.md +++ b/docs/FACTS.md @@ -13,8 +13,8 @@ green. Quote a fact instead of restating it, and it updates itself when the sour | `migration-count` | 17 | Numbered database migrations | `backend/ward-flow/migrations/` | | `ward-route-count` | 60 | `page.tsx` routes under `/ward-flow` | `src/app/ward-flow/` | | `app-route-count` | 64 | Pages and route handlers: the Ward Flow routes plus sign-in and the `/mockups` digest | `src/app/ward-flow/, src/app/ward-flow-sign-in/, src/app/mockups/` | -| `latest-decision` | D-144 | Newest entry in the decision log | `docs/ward-flow/decisions.md` | -| `next-decision` | D-145 | Number to use for the next decision | `docs/ward-flow/decisions.md` | +| `latest-decision` | D-151 | Newest entry in the decision log | `docs/ward-flow/decisions.md` | +| `next-decision` | D-152 | Number to use for the next decision | `docs/ward-flow/decisions.md` | | `node-engine` | >=24.22.1 <25 | Supported Node.js range | `package.json` | | `backend-env-vars` | 33 names, [listed below](#backend-env-vars) | Environment variables the shared backend reads | `backend/ward-flow/*.mjs` | diff --git a/docs/ward-flow/journey/MAP-INVENTORY.md b/docs/ward-flow/journey/MAP-INVENTORY.md index 0094555bb7..4492306132 100644 --- a/docs/ward-flow/journey/MAP-INVENTORY.md +++ b/docs/ward-flow/journey/MAP-INVENTORY.md @@ -984,25 +984,26 @@ Source-contract SHA256: 0873c62bf7c72255c924e82c939d36e74594ee43c51831c4d4ba4063 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:1792 Event/dispatch reference: src/components/ward-management/ward-patient-registration-reducer.ts:39 - ARCHIVE_PATIENT — modelled; event roles: coordinator - Source-reviewed command model: Patient removed from active patients list; audit event and sequence retained. + Source-reviewed command model: Patient removed from active patients list (withdrawn-only referrals unlinked, D-149); audit event and sequence retained. Permission source: src/components/ward-management/ward-flow-event-roles.ts:397 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:1794 Event/dispatch reference: src/components/ward-management/ward-patient-archive-reducer.ts:10 Subject: An erroneous standalone patient registration - Before: Patient has only registration fields and no care records/references elsewhere + Before: Patient has only registration fields, and nothing else names it except withdrawn or cancelled front-door referrals, audit rows, or Undo records older than the Undo window (D-149) After: Patient removed from active patients list; audit event and sequence retained Scope: Coordinator only, with shared record scope applied. Shared authenticated command scope additionally checks the permitted place and stored record; EVENT_ROLE alone is not place authority. payload: patientId; reason from PATIENT_ARCHIVE_REASONS (duplicate, test, wrong_person); role and now guards: Patient must exist; listed archive reason - guards: Registration facts include the optional typed details (preferred name, address, GP, catchment team, interpreter language, Indigenous status, and the ADMIN_DETAIL_FIELDS administrative details); refuse any non-registration fact or any state/audit care reference; prior archive-attempt audit entries do not block + guards: Registration facts include the optional typed details (preferred name, address, GP, catchment team, interpreter language, Indigenous status, and the ADMIN_DETAIL_FIELDS administrative details); refuse any non-registration fact, any open or answered referral, or any other state reference. D-149 (Josh, 11 Nov 2026): a front-door referral whose every destination was withdrawn by the referrer or cancelled does not block, audit rows do not block, and an Undo record blocks only while it is inside the Undo window (measured from the event's now). Each refusal names its reason, and the coordinator's card shows it instead of hiding effects: Filters patient from patients; keeps sequence so id is not reused + effects: D-149: a withdrawn or cancelled referral that named the record stays in the referrals list with its patientId removed, so the stored workspace still validates effects: Reducer audit wrapper retains the archive event/reason; no care record is removed effects: The step can be taken back by UNDO_PATIENT_RECORD within the Undo window. tests: tests/ward-patient-archive.test.ts - Reviewed source: src/components/ward-management/ward-patient-archive-reducer.ts:10; SHA256 bfd91bd2e7955ee0c776cf1c8e5fb3ecb1dd76d2cd0d2c9a71a9789270b95898 + Reviewed source: src/components/ward-management/ward-patient-archive-reducer.ts:10; SHA256 fbe91ef4b0daad593da3144d30e40c65803851a4f31337bf61bd3004038c1900 Reviewed source: src/components/ward-management/ward-flow-event-roles.ts:397; SHA256 f9c4dd2aa18c14b7b98ab03b46946cf70c309a6281278d4eda668faa4011d423 - Reviewed source: src/components/ward-management/ward-patient-archive.ts:30; SHA256 2738c8d1a80a5d8fc418865719058ee2603141bb9493d55954cae83742455eb3 - Reviewed source: src/components/ward-management/ward-patient-archive.ts:44; SHA256 2738c8d1a80a5d8fc418865719058ee2603141bb9493d55954cae83742455eb3 + Reviewed source: src/components/ward-management/ward-patient-archive.ts:32; SHA256 9677f94adeb0eedb1a1b2b9e8f0c3f4bc36236e91f95f97f5d9198f56f8d1324 + Reviewed source: src/components/ward-management/ward-patient-archive.ts:54; SHA256 9677f94adeb0eedb1a1b2b9e8f0c3f4bc36236e91f95f97f5d9198f56f8d1324 Reviewed source: src/components/ward-management/ward-access-state.ts:668; SHA256 aff9d668ab52da5a8f185ac0169ee45d478cffb4aad491addc9809f5a352f2b5 - ACCEPT_REFERRAL — diagram-reviewed; event roles: ward, coordinator, ed, community Diagram action notes reviewed against the recorded source revision. Source digests detect subsequent edits; this is not runtime acceptance. diff --git a/docs/ward-flow/journey/README.md b/docs/ward-flow/journey/README.md index 2c98db27df..e0cac7c8bb 100644 --- a/docs/ward-flow/journey/README.md +++ b/docs/ward-flow/journey/README.md @@ -279,3 +279,10 @@ Source digests were re-reviewed file by file before being re-recorded: - `governance-registers.tsx`: Governance's review list also shows each cover given (M58); `REVIEW_AUDIT_EVENT` moved from line 543 to 546, unchanged. After `main` (D-121 to D-127) was merged into this branch (10 Nov 2026), `ward/ward-screen.tsx` and `ward-tasks-drawer.tsx` hold both sides' edits. Each cited line was found again by its text, unchanged in role, payload and guard: the twelve `ward-screen.tsx` citations moved down by three to seven lines, and `RECORD_ESCALATION` in `ward-tasks-drawer.tsx` moved to line 673 (one comment line added beside the D-125 sign-out wait). Only lines and digests were re-recorded. This thread's rulings were then renumbered D-129 and D-130, a comment-only change in the cited files, so those digests (in `actions.json`, `event-dispositions.json` and `machines.json`) were re-recorded with no line moved. + +Live audit fixes (#1626, 11 Nov 2026): source digests for three files were re-reviewed. Each cited line was found again by its text, and its role, payload and guard are unchanged. +- `community/community-screen.tsx`: the signed-in shared workspace no longer shows the Midland demo team (item 6), and a team contact there is not kept in the browser. The `ACCEPT_REFERRAL`, `DECLINE_REFERRAL` and `MARK_NOTICE_READ` dispatches each moved down seven lines. +- `officer/officer-screen.tsx`: the handover copy toast says "1 open job" (item 25), which adds one import line. The four transport events each moved down one line. +- `ward/ward-screen-overlays.tsx`: closing a bed offers an optional reason on its toast (D-148). `SET_BED_CLOSED` is unchanged. Choosing a reason undoes the close and sends it again with that reason. `RECORD_AWAY_AT_EMERGENCY_DEPARTMENT`, `RECORD_RETURNED_FROM_EMERGENCY_DEPARTMENT`, `SET_BED_PREPARATION` and the discharge-barrier dispatch each moved down twelve lines. +- `ward-global-search.tsx`, `tools/ward-demo-panel.tsx`: `ADVANCE_CLOCK` is unchanged, but D-150 hides its controls in shared mode, because the signed-in site runs on real Perth time. The action's screen note says so. `RESET_SCENARIO` and `SET_SCENARIO` are unchanged. +- `ward-patient-archive.ts`, `ward-patient-archive-reducer.ts`: D-149 changed `ARCHIVE_PATIENT`'s guard. A withdrawn or cancelled front-door referral, audit rows and expired Undo records no longer block, and the withdrawn referral is unlinked from the archived record. The command model's before, guards, effects and the `patientArchiveProblem` citation (line 44 to 54) are updated. diff --git a/docs/ward-flow/journey/actions.json b/docs/ward-flow/journey/actions.json index 194b08b210..56357fea81 100644 --- a/docs/ward-flow/journey/actions.json +++ b/docs/ward-flow/journey/actions.json @@ -391,8 +391,8 @@ }, { "path": "src/components/ward-management/community/community-screen.tsx", - "line": 589, - "sha256": "4cce4397ba7d06901141717dd89e58cf12315dc45e90f35604b527e6cebf5671" + "line": 596, + "sha256": "9272b5c9b4fdddfed3d951a84b86148a036bf7f38af89ee287c7375874288667" }, { "path": "src/components/ward-management/ward/ward-screen.tsx", @@ -493,8 +493,8 @@ }, { "path": "src/components/ward-management/community/community-screen.tsx", - "line": 529, - "sha256": "4cce4397ba7d06901141717dd89e58cf12315dc45e90f35604b527e6cebf5671" + "line": 536, + "sha256": "9272b5c9b4fdddfed3d951a84b86148a036bf7f38af89ee287c7375874288667" }, { "path": "src/components/ward-management/ed/use-ed-screen-decline.ts", @@ -2388,8 +2388,8 @@ }, { "path": "src/components/ward-management/officer/officer-screen.tsx", - "line": 521, - "sha256": "da86cd3b9a7e8018baf1a5abb7477e66bdb918fd911ad8f43779f95de7b68027" + "line": 522, + "sha256": "adc8d3dcda4dd8012363494a18a22910aecaf0a41698dfe2675eeac869e24dd8" }, { "path": "src/components/ward-management/patients/patient-transit-operations.tsx", @@ -2457,8 +2457,8 @@ }, { "path": "src/components/ward-management/officer/officer-screen.tsx", - "line": 528, - "sha256": "da86cd3b9a7e8018baf1a5abb7477e66bdb918fd911ad8f43779f95de7b68027" + "line": 529, + "sha256": "adc8d3dcda4dd8012363494a18a22910aecaf0a41698dfe2675eeac869e24dd8" }, { "path": "src/components/ward-management/patients/patient-transit-operations.tsx", @@ -2534,8 +2534,8 @@ }, { "path": "src/components/ward-management/officer/officer-screen.tsx", - "line": 544, - "sha256": "da86cd3b9a7e8018baf1a5abb7477e66bdb918fd911ad8f43779f95de7b68027" + "line": 545, + "sha256": "adc8d3dcda4dd8012363494a18a22910aecaf0a41698dfe2675eeac869e24dd8" }, { "path": "src/components/ward-management/patients/patient-transit-operations.tsx", @@ -2611,8 +2611,8 @@ }, { "path": "src/components/ward-management/officer/officer-screen.tsx", - "line": 563, - "sha256": "da86cd3b9a7e8018baf1a5abb7477e66bdb918fd911ad8f43779f95de7b68027" + "line": 564, + "sha256": "adc8d3dcda4dd8012363494a18a22910aecaf0a41698dfe2675eeac869e24dd8" }, { "path": "src/components/ward-management/tools/ward-tool-feed.tsx", @@ -3469,8 +3469,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen-overlays.tsx", - "line": 273, - "sha256": "27f313a7a06534fcdb3003ce8e31074b16648e74280f57980b2e051d81a16dd8" + "line": 285, + "sha256": "de7e57415a81f53d78e4f263494daddab2ab8bc316f6a7893b5762276dc39ccf" }, { "path": "src/components/ward-management/board/ward-board-detail-panel.tsx", @@ -3548,8 +3548,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen-overlays.tsx", - "line": 284, - "sha256": "27f313a7a06534fcdb3003ce8e31074b16648e74280f57980b2e051d81a16dd8" + "line": 296, + "sha256": "de7e57415a81f53d78e4f263494daddab2ab8bc316f6a7893b5762276dc39ccf" }, { "path": "src/components/ward-management/board/ward-board-detail-panel.tsx", @@ -4106,8 +4106,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen-overlays.tsx", - "line": 296, - "sha256": "27f313a7a06534fcdb3003ce8e31074b16648e74280f57980b2e051d81a16dd8" + "line": 308, + "sha256": "de7e57415a81f53d78e4f263494daddab2ab8bc316f6a7893b5762276dc39ccf" }, { "path": "src/components/ward-management/ward-flow-bed-events.ts", @@ -4694,8 +4694,8 @@ }, { "path": "src/components/ward-management/community/community-screen.tsx", - "line": 832, - "sha256": "4cce4397ba7d06901141717dd89e58cf12315dc45e90f35604b527e6cebf5671" + "line": 839, + "sha256": "9272b5c9b4fdddfed3d951a84b86148a036bf7f38af89ee287c7375874288667" }, { "path": "src/components/ward-management/ward/ward-screen.tsx", @@ -4953,10 +4953,10 @@ ], "screen": { "screen": "ward-global-search.tsx; tools/ward-demo-panel.tsx", - "control": "Global Search offers Advance demo clock (+15m), dispatching minutes=15 under role demo; the Demo panel also offers +15 min and +1 hour. Imperative search navigation and desk switching first consult the composed unsaved-work guard." + "control": "Global Search offers Advance demo clock (+15m), dispatching minutes=15 under role demo; the Demo panel also offers +15 min and +1 hour. D-150 (11 Nov 2026): the signed-in shared site runs on real Perth time, so both controls are hidden in shared mode and remain on the public demo only. Imperative search navigation and desk switching first consult the composed unsaved-work guard." }, "review": { - "reviewedOn": "2026-11-05", + "reviewedOn": "2026-11-11", "sources": [ { "path": "src/components/ward-management/ward-flow-event-roles.ts", @@ -4970,13 +4970,13 @@ }, { "path": "src/components/ward-management/ward-global-search.tsx", - "line": 1275, - "sha256": "b7c651d7a0a3110b10e5d46aeb7ab769359393240cf55bb4024fc570504185ac" + "line": 802, + "sha256": "da7ec0be180ef0f2d0009261ed09aa538703e8153afb3ef84b292aac44d21c73" }, { "path": "src/components/ward-management/tools/ward-demo-panel.tsx", - "line": 268, - "sha256": "002918ecd56f5d0f951247f9cbc1b96adccea94e8d024b3a82cb0ed46b6dbebc" + "line": 271, + "sha256": "ba6e115ccc6fb129eb4b2aeb72e975e106930bdd55c7e6cc5cdc17a0d8bb685f" }, { "path": "src/components/ward-management/ward-flow-provider.tsx", @@ -5033,7 +5033,7 @@ { "path": "src/components/ward-management/tools/ward-demo-panel.tsx", "line": 197, - "sha256": "002918ecd56f5d0f951247f9cbc1b96adccea94e8d024b3a82cb0ed46b6dbebc" + "sha256": "ba6e115ccc6fb129eb4b2aeb72e975e106930bdd55c7e6cc5cdc17a0d8bb685f" } ] } @@ -5084,7 +5084,7 @@ { "path": "src/components/ward-management/tools/ward-demo-panel.tsx", "line": 197, - "sha256": "002918ecd56f5d0f951247f9cbc1b96adccea94e8d024b3a82cb0ed46b6dbebc" + "sha256": "ba6e115ccc6fb129eb4b2aeb72e975e106930bdd55c7e6cc5cdc17a0d8bb685f" }, { "path": "src/components/ward-management/ward-flow-reducer.ts", @@ -5700,8 +5700,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen-overlays.tsx", - "line": 329, - "sha256": "27f313a7a06534fcdb3003ce8e31074b16648e74280f57980b2e051d81a16dd8" + "line": 341, + "sha256": "de7e57415a81f53d78e4f263494daddab2ab8bc316f6a7893b5762276dc39ccf" }, { "path": "src/components/ward-management/board/ward-board-detail-panel.tsx", diff --git a/docs/ward-flow/journey/event-dispositions.json b/docs/ward-flow/journey/event-dispositions.json index 7e9b0cee35..a8069315b7 100644 --- a/docs/ward-flow/journey/event-dispositions.json +++ b/docs/ward-flow/journey/event-dispositions.json @@ -1444,20 +1444,21 @@ { "event": "ARCHIVE_PATIENT", "status": "modelled", - "reason": "Source-reviewed command model: Patient removed from active patients list; audit event and sequence retained.", + "reason": "Source-reviewed command model: Patient removed from active patients list (withdrawn-only referrals unlinked, D-149); audit event and sequence retained.", "source": "src/components/ward-management/ward-patient-archive-reducer.ts", "model": { "subject": "An erroneous standalone patient registration", - "before": "Patient has only registration fields and no care records/references elsewhere", + "before": "Patient has only registration fields, and nothing else names it except withdrawn or cancelled front-door referrals, audit rows, or Undo records older than the Undo window (D-149)", "after": "Patient removed from active patients list; audit event and sequence retained", "payload": ["patientId; reason from PATIENT_ARCHIVE_REASONS (duplicate, test, wrong_person); role and now"], "scope": "Coordinator only, with shared record scope applied. Shared authenticated command scope additionally checks the permitted place and stored record; EVENT_ROLE alone is not place authority.", "guards": [ "Patient must exist; listed archive reason", - "Registration facts include the optional typed details (preferred name, address, GP, catchment team, interpreter language, Indigenous status, and the ADMIN_DETAIL_FIELDS administrative details); refuse any non-registration fact or any state/audit care reference; prior archive-attempt audit entries do not block" + "Registration facts include the optional typed details (preferred name, address, GP, catchment team, interpreter language, Indigenous status, and the ADMIN_DETAIL_FIELDS administrative details); refuse any non-registration fact, any open or answered referral, or any other state reference. D-149 (Josh, 11 Nov 2026): a front-door referral whose every destination was withdrawn by the referrer or cancelled does not block, audit rows do not block, and an Undo record blocks only while it is inside the Undo window (measured from the event's now). Each refusal names its reason, and the coordinator's card shows it instead of hiding" ], "effects": [ "Filters patient from patients; keeps sequence so id is not reused", + "D-149: a withdrawn or cancelled referral that named the record stays in the referrals list with its patientId removed, so the stored workspace still validates", "Reducer audit wrapper retains the archive event/reason; no care record is removed", "The step can be taken back by UNDO_PATIENT_RECORD within the Undo window." ], @@ -1465,7 +1466,7 @@ { "path": "src/components/ward-management/ward-patient-archive-reducer.ts", "line": 10, - "sha256": "bfd91bd2e7955ee0c776cf1c8e5fb3ecb1dd76d2cd0d2c9a71a9789270b95898" + "sha256": "fbe91ef4b0daad593da3144d30e40c65803851a4f31337bf61bd3004038c1900" }, { "path": "src/components/ward-management/ward-flow-event-roles.ts", @@ -1474,13 +1475,13 @@ }, { "path": "src/components/ward-management/ward-patient-archive.ts", - "line": 30, - "sha256": "2738c8d1a80a5d8fc418865719058ee2603141bb9493d55954cae83742455eb3" + "line": 32, + "sha256": "9677f94adeb0eedb1a1b2b9e8f0c3f4bc36236e91f95f97f5d9198f56f8d1324" }, { "path": "src/components/ward-management/ward-patient-archive.ts", - "line": 44, - "sha256": "2738c8d1a80a5d8fc418865719058ee2603141bb9493d55954cae83742455eb3" + "line": 54, + "sha256": "9677f94adeb0eedb1a1b2b9e8f0c3f4bc36236e91f95f97f5d9198f56f8d1324" }, { "path": "src/components/ward-management/ward-access-state.ts", diff --git a/docs/ward-flow/journey/ward-journey-explorer.html b/docs/ward-flow/journey/ward-journey-explorer.html index f2f6d8a0de..5df2c05cc4 100644 --- a/docs/ward-flow/journey/ward-journey-explorer.html +++ b/docs/ward-flow/journey/ward-journey-explorer.html @@ -1284,7 +1284,7 @@

    What to do here