From 41af7d5b96d0fcb84de73d2f3a02937efe58143e Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 10 Nov 2026 17:11:23 +0000
Subject: [PATCH 01/30] Hold the first page until the desk is known, so a fresh
device never gets the coordinator's figures
Item 1 of the feature build plan (count leak). The server now holds the page
unless the request says the desk is the whole-network coordinator's (cookie
value 0). A fresh device sees the short loading page first, a change to H-0111
recorded as D-137.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GxPPxnKy5cgHmGa8NdCd2U
---
src/app/ward-flow/layout.tsx | 13 +++++---
.../shell/ward-access-hold.tsx | 3 +-
.../ward-management/shell/ward-desk-early.ts | 26 +++++++++++++---
tests/ward-access-first-paint.dom.test.tsx | 31 ++++++++++++++++++-
4 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/src/app/ward-flow/layout.tsx b/src/app/ward-flow/layout.tsx
index fd84f3c8de..b4853f4ba4 100644
--- a/src/app/ward-flow/layout.tsx
+++ b/src/app/ward-flow/layout.tsx
@@ -53,7 +53,11 @@ import {
import { WardRowDensity } from "@/components/ward-management/settings/row-density";
import { WardGround } from "@/components/ward-management/ward-shell";
import { WardDeskHold, WardDeskPendingClear } from "@/components/ward-management/shell/ward-access-hold";
-import { WARD_DESK_EARLY_SCRIPT, WARD_DESK_HELD_COOKIE } from "@/components/ward-management/shell/ward-desk-early";
+import {
+ WARD_DESK_EARLY_SCRIPT,
+ WARD_DESK_HELD_COOKIE,
+ wardDeskHeldFromCookie,
+} from "@/components/ward-management/shell/ward-desk-early";
import { HeldDeskLoading } from "./loading-status";
import styles from "./ward-flow-layout.module.css";
@@ -150,9 +154,10 @@ export default async function WardFlowMockupLayout({ children }: { children: Rea
// The strict script-src needs the per-request nonce (src/proxy.ts) on our own inline script.
const requestHeaders = await headers();
const nonce = requestHeaders.get("x-nonce") ?? undefined;
- // Desk walk follow-up (9 Nov 2026): this browser's desk is not the whole-network coordinator's, so
- // the server sends a loading page, not the coordinator's page, until the browser knows the desk.
- const deskHeld = (await cookies()).get(WARD_DESK_HELD_COOKIE)?.value === "1";
+ // Desk walk follow-up (9 Nov 2026) and count leak (D-137, 10 Nov 2026): unless this browser said its
+ // desk is the whole-network coordinator's, the server sends a loading page, not the coordinator's
+ // page, until the browser knows the desk. A fresh device is held too.
+ const deskHeld = wardDeskHeldFromCookie((await cookies()).get(WARD_DESK_HELD_COOKIE)?.value);
// A1-17 (audit 13 Oct 2026): draw the phone layout first on a phone, so slow signal does not show
// a different, desktop-shaped page for many seconds before the code loads.
const userAgent = requestHeaders.get("user-agent");
diff --git a/src/components/ward-management/shell/ward-access-hold.tsx b/src/components/ward-management/shell/ward-access-hold.tsx
index 75a2fc4f1c..76d656df36 100644
--- a/src/components/ward-management/shell/ward-access-hold.tsx
+++ b/src/components/ward-management/shell/ward-access-hold.tsx
@@ -33,7 +33,8 @@ export function WardDeskPendingClear() {
* than the whole-network coordinator's), the server sends `placeholder` in place of the page, and
* the browser's first pass keeps it so the two match. The pass after reads the stored desk, so the
* page is first drawn for that desk and the coordinator's figures never reach the page at all.
- * Without the cookie (a fresh device, the coordinator) the server's page stands, as H-0111 decided.
+ * Since D-137 (10 Nov 2026) a fresh device, which sends no cookie, is held as well; only a browser
+ * whose desk is the whole-network coordinator's gets the server's page straight away.
*/
export function WardDeskHold({
held,
diff --git a/src/components/ward-management/shell/ward-desk-early.ts b/src/components/ward-management/shell/ward-desk-early.ts
index 3bd025baee..982ea73aaa 100644
--- a/src/components/ward-management/shell/ward-desk-early.ts
+++ b/src/components/ward-management/shell/ward-desk-early.ts
@@ -13,21 +13,37 @@
* still in the server's page and showed while the page's own part caught up. So a stored desk other
* than the coordinator's also sets `WARD_DESK_HELD_COOKIE`, a bare "1" with no role or place in it.
* When the server sees it, the ward layout sends a neutral loading page in place of the page
- * (`WardDeskHold`), and the browser draws the desk's own page once it knows the desk. Only this tab
- * choosing the coordinator's desk clears it; a fresh tab leaves it, so the safe side wins.
+ * (`WardDeskHold`), and the browser draws the desk's own page once it knows the desk.
+ *
+ * Count leak (item 1, Josh, 10 Nov 2026, D-137): a fresh device sent no cookie, so the server still
+ * drew the coordinator's figures into the first page. The server now holds the page whenever it
+ * cannot be sure of the desk: only the bare "0", set when this browser's desk is the whole-network
+ * coordinator's, lets the server's page stand. A fresh device sees the short loading page first, a
+ * change to H-0111. A fresh tab leaves the cookie as it is, so the safe side wins.
*
* Reads only the role and place kind, never shown or sent. A blocked storage read changes nothing.
* The key must match `WARD_ACCESS_STORAGE_KEY` in `ward-access-store.ts`.
*/
export const WARD_DESK_PENDING_ATTRIBUTE = "data-wf-desk-pending";
-/** Set while this browser's desk is not the whole-network coordinator's. Its value is only ever "1". */
+/**
+ * This browser's desk, as the server may know it: "1" for any desk other than the whole-network
+ * coordinator's, "0" for the coordinator's. It never carries a role or place.
+ */
export const WARD_DESK_HELD_COOKIE = "wardflow-desk-held";
+/** The one value that lets the server draw the coordinator's page (D-137). */
+export const WARD_DESK_WHOLE_NETWORK_VALUE = "0";
+
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`;
+const CLEARED_COOKIE = `${WARD_DESK_HELD_COOKIE}=${WARD_DESK_WHOLE_NETWORK_VALUE};path=/ward-flow;SameSite=Strict`;
+
+/** D-137: the server holds the page unless the request says the desk is the coordinator's. */
+export function wardDeskHeldFromCookie(value: string | undefined): boolean {
+ return value !== WARD_DESK_WHOLE_NETWORK_VALUE;
+}
-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 */}})();`;
+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 cookie stays as it was */}})();`;
/** Keeps the cookie in step with the desk this tab has just stored (`ward-access-store.ts`). */
export function syncWardDeskHeldCookie(wholeNetwork: boolean): void {
diff --git a/tests/ward-access-first-paint.dom.test.tsx b/tests/ward-access-first-paint.dom.test.tsx
index 8d67d28f03..5af2d08c18 100644
--- a/tests/ward-access-first-paint.dom.test.tsx
+++ b/tests/ward-access-first-paint.dom.test.tsx
@@ -7,6 +7,7 @@ import {
WARD_DESK_EARLY_SCRIPT,
WARD_DESK_HELD_COOKIE,
WARD_DESK_PENDING_ATTRIBUTE,
+ wardDeskHeldFromCookie,
} from "@/components/ward-management/shell/ward-desk-early";
import {
parseStoredAccess,
@@ -24,6 +25,7 @@ const bedManager = { role: "bed_manager", scope: { kind: "site", siteCode: "FSH"
const runEarly = () => new Function(WARD_DESK_EARLY_SCRIPT)();
const pending = () => document.documentElement.hasAttribute(WARD_DESK_PENDING_ATTRIBUTE);
const held = () => document.cookie.split("; ").includes(`${WARD_DESK_HELD_COOKIE}=1`);
+const wholeNetwork = () => document.cookie.split("; ").includes(`${WARD_DESK_HELD_COOKIE}=0`);
const clearHeld = () => {
document.cookie = `${WARD_DESK_HELD_COOKIE}=;path=/ward-flow;max-age=0`;
document.cookie = `${WARD_DESK_HELD_COOKIE}=;max-age=0`;
@@ -89,6 +91,7 @@ describe("server page held for a desk other than the coordinator's", () => {
window.sessionStorage.setItem(KEY, JSON.stringify({ role: "coordinator", scope: { kind: "statewide" } }));
runEarly();
expect(held()).toBe(false);
+ expect(wholeNetwork(), "the coordinator's desk says so, so the server may draw its page").toBe(true);
});
it("keeps the held cookie in step with the desk the app stores", () => {
@@ -100,6 +103,7 @@ describe("server page held for a desk other than the coordinator's", () => {
expect(held()).toBe(true);
setWardAccess({ role: "coordinator", scope: { kind: "statewide" } });
expect(held()).toBe(false);
+ expect(wholeNetwork()).toBe(true);
});
it("sends the placeholder, never the page, when held, and draws the page once the desk is known", async () => {
@@ -127,7 +131,7 @@ describe("server page held for a desk other than the coordinator's", () => {
act(() => root?.unmount());
});
- it("keeps the server's page for a fresh device and the coordinator", () => {
+ it("keeps the server's page only for the coordinator", () => {
const html = renderToString(
Loading page
}>
Home
@@ -137,3 +141,28 @@ describe("server page held for a desk other than the coordinator's", () => {
expect(html).not.toContain("Loading page");
});
});
+
+/**
+ * Count leak (item 1, Josh, 10 Nov 2026, D-137): a fresh device sent no cookie, so the server drew
+ * the coordinator's figures into the first page. The server now holds the page unless the request
+ * says the desk is the whole-network coordinator's, which changes H-0111 for a fresh device.
+ */
+describe("D-137 server holds the page when it cannot be sure of the desk", () => {
+ it("holds a fresh device, another desk and any unknown value; lets only the coordinator's stand", () => {
+ expect(wardDeskHeldFromCookie(undefined), "fresh device, no cookie").toBe(true);
+ expect(wardDeskHeldFromCookie("")).toBe(true);
+ expect(wardDeskHeldFromCookie("1")).toBe(true);
+ expect(wardDeskHeldFromCookie("coordinator")).toBe(true);
+ expect(wardDeskHeldFromCookie("0")).toBe(false);
+ });
+
+ it("never draws the coordinator's figures in the first page for a fresh device", () => {
+ const html = renderToString(
+ Loading page}>
+
Network beds ready of 304
+ ,
+ );
+ expect(html).toContain("Loading page");
+ expect(html).not.toContain("304");
+ });
+});
From 8439f1d56a709174bfd4245bfe42233e51b7ca6a Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 10 Nov 2026 17:31:52 +0000
Subject: [PATCH 02/30] Show "Changed 14:32" on ward and bed meeting TV tiles
for 15 minutes
Item 43 of the feature build plan. Each tile's figure is compared with the
last refresh; a change shows a small mark in the existing small text style
for the signed 15 minutes (tvChangeMark, D-137), then it goes. No marks on
first load, none while the screen is frozen, no motion.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GxPPxnKy5cgHmGa8NdCd2U
---
docs/ward-flow/clinical-values.md | 1 +
.../bed-meeting/bed-meeting-tv-top.module.css | 7 ++
.../bed-meeting/bed-meeting-tv-top.tsx | 39 ++++++-
.../bed-meeting/bed-meeting-tv.tsx | 25 ++++-
.../bed-meeting/tv-change-mark.tsx | 52 +++++++++
.../bed-meeting/tv-change-marks.ts | 66 +++++++++++
.../ward-clinical-value-notes.ts | 3 +
.../ward-management/ward-clinical-values.ts | 9 ++
tests/ward-clinical-values.test.ts | 2 +
tests/ward-tv-change-marks.dom.test.tsx | 103 ++++++++++++++++++
tests/ward-tv-change-marks.test.ts | 68 ++++++++++++
11 files changed, 367 insertions(+), 8 deletions(-)
create mode 100644 src/components/ward-management/bed-meeting/tv-change-mark.tsx
create mode 100644 src/components/ward-management/bed-meeting/tv-change-marks.ts
create mode 100644 tests/ward-tv-change-marks.dom.test.tsx
create mode 100644 tests/ward-tv-change-marks.test.ts
diff --git a/docs/ward-flow/clinical-values.md b/docs/ward-flow/clinical-values.md
index a0f23c6318..99bb7b9389 100644
--- a/docs/ward-flow/clinical-values.md
+++ b/docs/ward-flow/clinical-values.md
@@ -79,6 +79,7 @@ governance sign-off before real patient use (D-54).
| Alerts and holds | Forecast alert raised when someone in ED will pass the access target within | 12 hours, default, changeable in Settings | Synthetic value | Forecasting build (Josh, 23 Oct 2026, per the forecast source comments; no decision entry), labelled default; added to the register 10 Nov 2026 (D-122) | None recorded | `forecastEdRiskLead` |
| Alerts and holds | Forecast alert raised when tomorrow's likely free beds dropped since the last look by | 2, default, changeable in Settings | Synthetic value | Forecasting build (Josh, 23 Oct 2026, per the forecast source comments; no decision entry), labelled default; added to the register 10 Nov 2026 (D-122) | None recorded | `forecastWorseAlert` |
| Alerts and holds | Rail shows Handover Due once the shift has this long or less to run | 15 minutes | Synthetic value | Shift ring on the rail, in the 27 Sep 2026 source snapshot with no decision recorded; added to the register 10 Nov 2026 (D-122) | None recorded | `handoverDuePrompt` |
+| Alerts and holds | TV tile shows when its figure changed, for | 15 minutes | Signed for the prototype by Josh (product owner), 2026-11-10 | D-137 (Josh, 10 Nov 2026), plan item 43: TV "Changed HH:MM" mark on the ward and bed meeting TVs | None recorded | `tvChangeMark` |
| Arrivals and answers | Arrival counted as late, past the estimate, after | 1 hour | Signed for the prototype by Josh (product owner), 2026-10-15 | D-22 (26 Sep 2026), labelled default | None recorded | `lateArrivalGrace` |
| Arrivals and answers | Ward that has not answered a referral, first reminder after | 2 hours | Signed for the prototype by Josh (product owner), 2026-10-15 | D-22 (26 Sep 2026), labelled default | None recorded | `silentWardFirstReminder` |
| Arrivals and answers | Ward that has not answered a referral, second reminder after | 4 hours | Signed for the prototype by Josh (product owner), 2026-10-15 | D-22 (26 Sep 2026), labelled default | None recorded | `silentWardSecondReminder` |
diff --git a/src/components/ward-management/bed-meeting/bed-meeting-tv-top.module.css b/src/components/ward-management/bed-meeting/bed-meeting-tv-top.module.css
index c02f6c114b..2019a61bc8 100644
--- a/src/components/ward-management/bed-meeting/bed-meeting-tv-top.module.css
+++ b/src/components/ward-management/bed-meeting/bed-meeting-tv-top.module.css
@@ -126,3 +126,10 @@
margin-block-start: var(--wf-s-2);
}
}
+
+/* Plan item 43 (D-137): "Changed 14:32" under a figure's label, in the hero's small text. No motion. */
+.changed {
+ display: block;
+ font: 400 var(--wf-fs-12) / 16px var(--wf-font);
+ color: var(--wf-hero-ink-3);
+}
diff --git a/src/components/ward-management/bed-meeting/bed-meeting-tv-top.tsx b/src/components/ward-management/bed-meeting/bed-meeting-tv-top.tsx
index 6ace9cf1ff..36871ee401 100644
--- a/src/components/ward-management/bed-meeting/bed-meeting-tv-top.tsx
+++ b/src/components/ward-management/bed-meeting/bed-meeting-tv-top.tsx
@@ -8,6 +8,7 @@ import { LONG_WAIT_TEXT } from "@/components/ward-management/ward-operational-de
import { Hero, HeroAct, HeroFigures, HeroQuickChips, HeroTop, SrOnly, cx, type HeroChipItem } from "@/components/wf";
import pageTop from "../page-top-title.module.css";
import styles from "./bed-meeting-tv-top.module.css";
+import { TvChangeMark, useTvChangeMarks } from "./tv-change-mark";
/**
* Page tops (Josh, 9 Nov 2026): the bed meeting TV's slate top. Kit-size figures (approved: the TV
@@ -25,6 +26,8 @@ export function MeetingTvTop({
asAt,
stale,
chips,
+ now,
+ screen,
}: {
sheet: BedMeetingSheet;
/** "All services" or the service shown; the screen chip names it, the heading reads it. */
@@ -41,11 +44,29 @@ export function MeetingTvTop({
/** TV-06: the red not-updating warning, shown in place of the Live pill. */
stale: ReactNode;
chips: HeroChipItem[];
+ /** The figures' moment, for the change marks (plan item 43, D-137). */
+ now: number;
+ /** Which screen and scenario this is; another one starts with no change marks. */
+ screen: string;
}) {
const { capacity, discharges, ed, delays } = sheet;
// Held up discharges are due too: the panel lists them, so the figure counts them.
const dischargesDue = discharges.heldUp + discharges.confirmed + discharges.expected;
const longest = ed.longestWaits[0];
+ // Plan item 43 (D-137): a figure that changed since the last refresh says when, for a while. A
+ // screen that is not live records no change.
+ const changed = useTvChangeMarks(
+ screen,
+ { ed: ed.waiting, ready: capacity.ready, discharges: dischargesDue, delays: delays.total },
+ now,
+ !live,
+ );
+ const labelOf = (key: string, text: string) => (
+ <>
+ {text}
+
+ >
+ );
return (
}
diff --git a/src/components/ward-management/bed-meeting/bed-meeting-tv.tsx b/src/components/ward-management/bed-meeting/bed-meeting-tv.tsx
index b2e8401c34..d408b9390b 100644
--- a/src/components/ward-management/bed-meeting/bed-meeting-tv.tsx
+++ b/src/components/ward-management/bed-meeting/bed-meeting-tv.tsx
@@ -65,6 +65,7 @@ import { EdClinicalTvView, WardClinicalTvView, WardHandoverTvView } from "./tv-c
import { BoardSwitch, LivePulse, tvBoardOf, tvBoardTitle, type TvBoard } from "./tv-board-switch";
import { EdBedSearchTvView, WardLegalTvView, WardObsLeaveTvView, WardTodayTvView } from "./tv-function-views";
import { MeetingTvTop } from "./bed-meeting-tv-top";
+import { TvChangeMark, useWardTvChangeMarks } from "./tv-change-mark";
import topStyles from "./bed-meeting-tv-top.module.css";
import styles from "./bed-meeting-tv.module.css";
@@ -138,10 +139,12 @@ type TvTileProps = {
meter?: { value: number; max: number };
/** A second line under the detail: when the figure was last confirmed (`bedCountText`), or a note. */
age?: string;
+ /** Plan item 43 (D-137): "Changed 14:32" while the figure's change mark lasts. */
+ changed?: string | null;
testId: string;
};
-function TvTile({ label, value, detail, tone, meter, age, testId }: TvTileProps) {
+function TvTile({ label, value, detail, tone, meter, age, changed, testId }: TvTileProps) {
// Final polish (31 Oct 2026): every tile draws its bar in the same place; an empty whole (nobody
// waiting) draws an empty track rather than leaving the row blank.
const share = meter && meter.max > 0 ? Math.min(100, Math.round((meter.value / meter.max) * 100)) : meter ? 0 : null;
@@ -160,9 +163,11 @@ function TvTile({ label, value, detail, tone, meter, age, testId }: TvTileProps)
)}
{detail}
- {age ? (
+ {age || changed ? (
{age}
+ {age && changed ? " · " : null}
+
) : null}
@@ -175,7 +180,7 @@ function TvTiles({ tiles }: { tiles: TvTileProps[] }) {
className={styles.tiles}
aria-label="Today at a glance"
data-testid="bed-meeting-tv-tiles"
- data-ages={tiles.some((tile) => tile.age) ? "" : undefined}
+ data-ages={tiles.some((tile) => tile.age || tile.changed) ? "" : undefined}
>
{tiles.map((tile) => (
@@ -620,15 +625,20 @@ export function WardTvView({
now,
scenario,
nameOfStay,
+ frozen = false,
}: {
sheet: WardTvSheet;
now: number;
scenario: string;
/** The patient's name for a stay (an admission id); without it the rows give sex and day only. */
nameOfStay?: (admissionId: string) => string;
+ /** Not live: the figures are the last live ones, so no change mark is recorded (plan item 43). */
+ frozen?: boolean;
}) {
const { capacity, incoming, discharges } = sheet;
const going = discharges.heldUp + discharges.confirmed + discharges.expected;
+ const changed = useWardTvChangeMarks(sheet, scenario, now, frozen);
+
const stuck = [
...sheet.heldUp.map((person) => ({ person, note: `Held up: ${person.blockReason ?? ""}`.trim() })),
...sheet.overdue
@@ -647,6 +657,7 @@ export function WardTvView({
tone: capacity.ready === 0 ? "danger" : sheet.bedCount.notConfirmed > 0 ? "warning" : undefined,
meter: { value: capacity.ready, max: capacity.beds },
age: bedCountText(sheet.bedCount, now),
+ changed: changed("ready"),
testId: "ward-tv-ready",
},
{
@@ -656,6 +667,7 @@ export function WardTvView({
tone: sheet.askedCount > 0 ? "warning" : undefined,
// Final polish: still unanswered, of this ward's referrals (asked plus accepted).
meter: { value: sheet.askedCount, max: sheet.askedCount + incoming.total },
+ changed: changed("asked"),
testId: "ward-tv-asked",
},
{
@@ -664,6 +676,7 @@ export function WardTvView({
detail: `Accepted. ${incoming.onTheWay} on the way now`,
tone: incoming.total > capacity.ready ? "warning" : undefined,
meter: { value: incoming.onTheWay, max: incoming.total }, // Final polish: on the way now, of all
+ changed: changed("incoming"),
testId: "ward-tv-incoming",
},
{
@@ -672,6 +685,7 @@ export function WardTvView({
detail: `Today and tomorrow. ${discharges.confirmed} confirmed, ${releasesHeldUpText(discharges.heldUp)}`,
tone: discharges.heldUp > 0 ? "warning" : undefined,
meter: { value: discharges.confirmed, max: going },
+ changed: changed("going"),
testId: "ward-tv-going",
},
{
@@ -680,6 +694,7 @@ export function WardTvView({
detail: `${sheet.noDate} with no date set`,
tone: sheet.overdue.length > 0 ? "warning" : undefined,
meter: { value: sheet.overdue.length, max: sheet.occupied },
+ changed: changed("overdue"),
testId: "ward-tv-overdue",
},
]}
@@ -1317,6 +1332,8 @@ export function BedMeetingTvScreen({
local={shared?.enabled !== true}
asAt={stamp.time}
stale={staleWarning}
+ now={shownNow}
+ screen={`${current}:${scenario}`}
chips={[
{
id: "screen",
@@ -1437,7 +1454,7 @@ export function BedMeetingTvScreen({
{view.kind === "network" ? (
) : view.kind === "ward" ? (
-
+
) : view.kind === "ward-clinical" ? (
(() => {
const footer = ;
diff --git a/src/components/ward-management/bed-meeting/tv-change-mark.tsx b/src/components/ward-management/bed-meeting/tv-change-mark.tsx
new file mode 100644
index 0000000000..703f519664
--- /dev/null
+++ b/src/components/ward-management/bed-meeting/tv-change-mark.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useState } from "react";
+
+import type { Instant } from "@/components/ward-management/ward-clock";
+
+import type { WardTvSheet } from "./tv-derivations";
+import { nextTvChangeMarks, tvChangeMarkText, type TvChangeMarks, type TvTileFigure } from "./tv-change-marks";
+
+/**
+ * TV "changed at" marks (plan item 43, D-137): remembers each tile's figure from the last refresh and
+ * gives the "Changed 14:32" line for a tile that changed. Adjusted during render, as the TV's own
+ * frozen snapshot is, so no effect runs and the mark shows on the same paint as the new figure.
+ */
+export function useTvChangeMarks(
+ screen: string,
+ figures: Readonly>,
+ now: Instant,
+ frozen: boolean,
+): (key: string) => string | null {
+ const [marks, setMarks] = useState(null);
+ const next = nextTvChangeMarks(marks, screen, figures, now, frozen);
+ if (next !== marks) setMarks(next);
+ return (key) => tvChangeMarkText(next, key, now);
+}
+
+/** The ward TV's five tiles, keyed as `WardTvView` asks for them. */
+export function useWardTvChangeMarks(sheet: WardTvSheet, scenario: string, now: Instant, frozen: boolean) {
+ const { capacity, incoming, discharges } = sheet;
+ return useTvChangeMarks(
+ `ward:${sheet.unit.id}:${scenario}`,
+ {
+ ready: capacity.ready,
+ asked: sheet.askedCount,
+ incoming: incoming.total,
+ going: discharges.heldUp + discharges.confirmed + discharges.expected,
+ overdue: sheet.overdue.length,
+ },
+ now,
+ frozen,
+ );
+}
+
+/** The mark itself: plain small text in the caller's own style. Nothing moves or flashes. */
+export function TvChangeMark({ text, testId, className }: { text: string | null; testId: string; className?: string }) {
+ if (text === null) return null;
+ return (
+
+ {text}
+
+ );
+}
diff --git a/src/components/ward-management/bed-meeting/tv-change-marks.ts b/src/components/ward-management/bed-meeting/tv-change-marks.ts
new file mode 100644
index 0000000000..136c116a87
--- /dev/null
+++ b/src/components/ward-management/bed-meeting/tv-change-marks.ts
@@ -0,0 +1,66 @@
+import { CLINICAL_VALUES } from "@/components/ward-management/ward-clinical-values";
+import { formatInstant, type Instant } from "@/components/ward-management/ward-clock";
+
+/**
+ * TV "changed at" marks (plan item 43; Josh, 10 Nov 2026, D-137). On the ward TV and the bed meeting
+ * TV, a tile whose figure changed since the last refresh shows a small "Changed 14:32" for
+ * `TV_CHANGE_MARK_MINUTES`, then the mark goes. No flash, no motion: only a line of the tile's own
+ * small text.
+ *
+ * Pure: the screen keeps the last `TvChangeMarks` and passes it back with each refresh's figures.
+ * - The first sight of a tile has nothing to compare against, so it shows no mark.
+ * - A frozen screen (offline, or the shared refresh down) never records a change: its figures are the
+ * last live ones, so any difference would be invented.
+ * - A different screen (another ward, service or scenario) starts again with no marks.
+ */
+
+/** How long a changed tile shows its mark, in minutes of the app's clock. Signed by Josh, 10 Nov 2026 (D-137). */
+export const TV_CHANGE_MARK_MINUTES = CLINICAL_VALUES.tvChangeMark.value;
+
+export type TvTileFigure = number | string;
+
+export type TvChangeMarks = {
+ /** Which screen these figures belong to; a different screen starts again. */
+ screen: string;
+ tiles: Readonly>;
+};
+
+/**
+ * The marks after one refresh. Returns `previous` itself when nothing changed, so a caller holding it
+ * in state can skip the update.
+ */
+export function nextTvChangeMarks(
+ previous: TvChangeMarks | null,
+ screen: string,
+ figures: Readonly>,
+ now: Instant,
+ frozen: boolean,
+): TvChangeMarks {
+ if (previous !== null && frozen) return previous;
+ const fresh = previous === null || previous.screen !== screen;
+ let changed = fresh;
+ const tiles: Record = {};
+ for (const [key, figure] of Object.entries(figures)) {
+ const before = fresh ? undefined : previous.tiles[key];
+ if (before === undefined) {
+ tiles[key] = { figure, changedAt: null };
+ changed = true;
+ } else if (before.figure !== figure) {
+ tiles[key] = { figure, changedAt: now };
+ changed = true;
+ } else {
+ tiles[key] = before;
+ }
+ }
+ if (!changed && previous !== null && Object.keys(previous.tiles).length !== Object.keys(tiles).length) changed = true;
+ return changed || previous === null ? { screen, tiles } : previous;
+}
+
+/** "Changed 14:32" while a tile's mark lasts, or null. Clears at exactly `TV_CHANGE_MARK_MINUTES`. */
+export function tvChangeMarkText(marks: TvChangeMarks | null, key: string, now: Instant): string | null {
+ const changedAt = marks?.tiles[key]?.changedAt ?? null;
+ if (changedAt === null) return null;
+ const age = now - changedAt;
+ if (age < 0 || age >= TV_CHANGE_MARK_MINUTES) return null;
+ return `Changed ${formatInstant(changedAt)}`;
+}
diff --git a/src/components/ward-management/ward-clinical-value-notes.ts b/src/components/ward-management/ward-clinical-value-notes.ts
index 25aea68862..d082d399c3 100644
--- a/src/components/ward-management/ward-clinical-value-notes.ts
+++ b/src/components/ward-management/ward-clinical-value-notes.ts
@@ -146,6 +146,9 @@ const CLINICAL_VALUE_NOTES = {
source:
"Shift ring on the rail, in the 27 Sep 2026 source snapshot with no decision recorded; added to the register 10 Nov 2026 (D-122)",
},
+ tvChangeMark: {
+ source: 'D-137 (Josh, 10 Nov 2026), plan item 43: TV "Changed HH:MM" mark on the ward and bed meeting TVs',
+ },
lateArrivalGrace: {
source: "D-22 (26 Sep 2026), labelled default",
},
diff --git a/src/components/ward-management/ward-clinical-values.ts b/src/components/ward-management/ward-clinical-values.ts
index d377bff0f4..8ee694f73c 100644
--- a/src/components/ward-management/ward-clinical-values.ts
+++ b/src/components/ward-management/ward-clinical-values.ts
@@ -90,6 +90,8 @@ const SIGNED_BY_JOSH_23_OCT = { status: "signed", signedBy: "Josh (product owner
* and capacity review page).
*/
const SIGNED_BY_JOSH_9_NOV = { status: "signed", signedBy: "Josh (product owner)", signedOn: "2026-11-09" } as const;
+/** Josh signed the TV change mark's length for the prototype on 10 Nov 2026 (D-137). */
+const SIGNED_BY_JOSH_10_NOV = { status: "signed", signedBy: "Josh (product owner)", signedOn: "2026-11-10" } as const;
/**
* The name each observation level is shown under, everywhere Ward Flow shows one (fix plan item 10,
@@ -373,6 +375,13 @@ export const CLINICAL_VALUES = {
value: 15,
unit: "minutes",
},
+ tvChangeMark: {
+ ...SIGNED_BY_JOSH_10_NOV,
+ name: "TV tile shows when its figure changed, for",
+ group: "Alerts and holds",
+ value: 15,
+ unit: "minutes",
+ },
// ---- Arrivals and answers ------------------------------------------------------------------
lateArrivalGrace: {
diff --git a/tests/ward-clinical-values.test.ts b/tests/ward-clinical-values.test.ts
index eea88c6af3..109720b7ff 100644
--- a/tests/ward-clinical-values.test.ts
+++ b/tests/ward-clinical-values.test.ts
@@ -105,6 +105,8 @@ describe("clinical values file", () => {
forecastEdRiskLead: 720,
forecastWorseAlert: 2,
handoverDuePrompt: 15,
+ // D-137 (Josh, 10 Nov 2026): the TV change mark shows for 15 minutes, signed.
+ tvChangeMark: 15,
lateArrivalGrace: 60,
silentWardFirstReminder: 120,
silentWardSecondReminder: 240,
diff --git a/tests/ward-tv-change-marks.dom.test.tsx b/tests/ward-tv-change-marks.dom.test.tsx
new file mode 100644
index 0000000000..702388ebbd
--- /dev/null
+++ b/tests/ward-tv-change-marks.dom.test.tsx
@@ -0,0 +1,103 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next/link", () => ({
+ default: ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => (
+
+ {children}
+
+ ),
+}));
+
+import { WardTvView } from "@/components/ward-management/bed-meeting/bed-meeting-tv";
+import { TV_CHANGE_MARK_MINUTES } from "@/components/ward-management/bed-meeting/tv-change-marks";
+import { wardTvSheet } from "@/components/ward-management/bed-meeting/tv-derivations";
+import { DEFAULT_DUE_SOON_THRESHOLDS, formatInstant } from "@/components/ward-management/ward-clock";
+import { seedWardFlowStateAt } from "@/components/ward-management/ward-flow-reducer";
+import { NOW_ANCHOR } from "@/components/ward-management/ward-sites";
+
+/** Plan item 43 (D-137): the ward TV's tiles say "Changed HH:MM" for 15 minutes after a figure changes. */
+function sheetAt() {
+ const state = seedWardFlowStateAt(0);
+ const unit = state.units[0];
+ return wardTvSheet({
+ units: state.units,
+ movements: state.movements,
+ bedReleases: state.bedReleases,
+ admissions: state.admissions,
+ leaveBeds: state.leaveBeds,
+ now: NOW_ANCHOR,
+ nameOf: () => "",
+ unit,
+ thresholds: DEFAULT_DUE_SOON_THRESHOLDS,
+ });
+}
+
+const realMatchMedia = window.matchMedia;
+afterEach(() => {
+ window.matchMedia = realMatchMedia;
+});
+
+describe("the ward TV's change marks", () => {
+ it("shows none on first load, marks a changed tile, and clears it after 15 minutes", () => {
+ const sheet = sheetAt();
+ const view = render();
+ expect(document.querySelector("[data-tv-change-mark]")).toBeNull();
+
+ const changed = {
+ ...sheet,
+ overdue: [...sheet.overdue, ...sheet.overdue.slice(0, 1)],
+ askedCount: sheet.askedCount + 1,
+ };
+ view.rerender();
+ expect(screen.getByTestId("ward-tv-asked-changed")).toHaveTextContent(`Changed ${formatInstant(NOW_ANCHOR + 2)}`);
+ expect(screen.queryByTestId("ward-tv-ready-changed")).toBeNull();
+ // The bed count's own age line stays beside a mark on the same tile.
+ expect(screen.getByTestId("ward-tv-ready-age")).toHaveTextContent("Confirmed");
+
+ view.rerender(
+ ,
+ );
+ expect(screen.getByTestId("ward-tv-asked-changed")).toBeInTheDocument();
+ view.rerender();
+ expect(screen.queryByTestId("ward-tv-asked-changed")).toBeNull();
+ });
+
+ it("invents no change while the screen is frozen", () => {
+ const sheet = sheetAt();
+ const view = render();
+ view.rerender(
+ ,
+ );
+ expect(document.querySelector("[data-tv-change-mark]")).toBeNull();
+ });
+
+ it("is plain text with reduced motion asked for: no live region, nothing that moves", () => {
+ window.matchMedia = ((query: string) => ({
+ matches: query.includes("prefers-reduced-motion"),
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ })) as typeof window.matchMedia;
+ const sheet = sheetAt();
+ const view = render();
+ view.rerender(
+ ,
+ );
+ const mark = screen.getByTestId("ward-tv-asked-changed");
+ expect(mark.tagName).toBe("SPAN");
+ expect(mark).not.toHaveAttribute("aria-live");
+ expect(mark).not.toHaveAttribute("role");
+ expect(mark.getAttribute("style")).toBeNull();
+ expect(mark).toHaveTextContent(/^Changed \d{2}:\d{2}$/);
+ });
+});
diff --git a/tests/ward-tv-change-marks.test.ts b/tests/ward-tv-change-marks.test.ts
new file mode 100644
index 0000000000..84207dc026
--- /dev/null
+++ b/tests/ward-tv-change-marks.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ TV_CHANGE_MARK_MINUTES,
+ nextTvChangeMarks,
+ tvChangeMarkText,
+} from "@/components/ward-management/bed-meeting/tv-change-marks";
+
+/** Plan item 43 (D-137): a TV tile whose figure changed says when, for 15 minutes, then the mark goes. */
+const AT = 14 * 60 + 32; // 14:32 on the app's clock
+
+describe("TV change marks", () => {
+ it("shows for 15 minutes, as Josh ruled (D-137)", () => {
+ expect(TV_CHANGE_MARK_MINUTES).toBe(15);
+ });
+
+ it("shows nothing on first sight: there is nothing to compare against", () => {
+ const first = nextTvChangeMarks(null, "ward:a", { ready: 3, asked: 1 }, AT, false);
+ expect(tvChangeMarkText(first, "ready", AT)).toBeNull();
+ expect(tvChangeMarkText(first, "asked", AT)).toBeNull();
+ });
+
+ it("marks a changed figure with the time it changed, and leaves an unchanged one alone", () => {
+ const first = nextTvChangeMarks(null, "ward:a", { ready: 3, asked: 1 }, AT - 5, false);
+ const second = nextTvChangeMarks(first, "ward:a", { ready: 2, asked: 1 }, AT, false);
+ expect(tvChangeMarkText(second, "ready", AT)).toBe("Changed 14:32");
+ expect(tvChangeMarkText(second, "asked", AT)).toBeNull();
+ });
+
+ it("keeps the same marks object when nothing changed, so the screen does not re-render for it", () => {
+ const first = nextTvChangeMarks(null, "ward:a", { ready: 3 }, AT, false);
+ expect(nextTvChangeMarks(first, "ward:a", { ready: 3 }, AT + 1, false)).toBe(first);
+ });
+
+ it("stays until just before 15 minutes and clears at exactly 15", () => {
+ const first = nextTvChangeMarks(null, "ward:a", { ready: 3 }, AT - 1, false);
+ const marks = nextTvChangeMarks(first, "ward:a", { ready: 4 }, AT, false);
+ expect(tvChangeMarkText(marks, "ready", AT + TV_CHANGE_MARK_MINUTES - 1)).toBe("Changed 14:32");
+ expect(tvChangeMarkText(marks, "ready", AT + TV_CHANGE_MARK_MINUTES)).toBeNull();
+ expect(tvChangeMarkText(marks, "ready", AT + TV_CHANGE_MARK_MINUTES + 60)).toBeNull();
+ });
+
+ it("restarts the time when the figure changes again", () => {
+ const first = nextTvChangeMarks(null, "ward:a", { ready: 3 }, AT - 10, false);
+ const once = nextTvChangeMarks(first, "ward:a", { ready: 4 }, AT - 10, false);
+ const twice = nextTvChangeMarks(once, "ward:a", { ready: 5 }, AT, false);
+ expect(tvChangeMarkText(twice, "ready", AT + 10)).toBe("Changed 14:32");
+ });
+
+ it("records no change while the screen is frozen", () => {
+ const first = nextTvChangeMarks(null, "ward:a", { ready: 3 }, AT, false);
+ const frozen = nextTvChangeMarks(first, "ward:a", { ready: 9 }, AT + 2, true);
+ expect(frozen).toBe(first);
+ expect(tvChangeMarkText(frozen, "ready", AT + 2)).toBeNull();
+ });
+
+ it("starts again with no marks on another screen or scenario", () => {
+ const first = nextTvChangeMarks(null, "ward:a", { ready: 3 }, AT, false);
+ const other = nextTvChangeMarks(first, "ward:b", { ready: 7 }, AT + 1, false);
+ expect(tvChangeMarkText(other, "ready", AT + 1)).toBeNull();
+ });
+
+ it("formats the time on the app's 24 hour clock, wrapping past midnight", () => {
+ const first = nextTvChangeMarks(null, "s", { ed: 1 }, 1440 + 9 * 60 + 5 - 1, false);
+ const marks = nextTvChangeMarks(first, "s", { ed: 2 }, 1440 + 9 * 60 + 5, false);
+ expect(tvChangeMarkText(marks, "ed", 1440 + 9 * 60 + 5)).toBe("Changed 09:05");
+ });
+});
From ccbb4d5ed349c13b15af7f0f489191cfbd1f28a1 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 10 Nov 2026 17:34:33 +0000
Subject: [PATCH 03/30] Add the coordinator's Night view and its first-night
walk round
Item 29 of the feature build plan. /ward-flow/night shows beds ready now,
ED waits by Step, people on the way, a slot for tonight's intake windows
(Not recorded until item 13 lands) and on call now, each from the same
function the day screens use. A six-step Show me walk round covers it.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GxPPxnKy5cgHmGa8NdCd2U
---
docs/ward-flow/SCREEN-MAP.md | 5 +-
docs/ward-flow/desk-guides/why-cant-i.md | 1 +
public/help/screens/.ward-help-guides.json | 2 +
public/help/screens/night.html | 118 ++++++++
public/help/screens/night.md | 18 ++
scripts/ward-flow/screen-pairs.mjs | 1 +
src/app/ward-flow/night/page.tsx | 13 +
.../ward-management/help/ward-help-content.ts | 22 ++
.../help/ward-help-screen-tours-pages.ts | 43 +++
.../night/night-view-figures.ts | 135 +++++++++
.../night/night-view-screen.tsx | 264 ++++++++++++++++++
.../night/night-view.module.css | 115 ++++++++
.../ward-management/shell/ward-bar-model.ts | 2 +
src/components/ward-management/ward-nav.ts | 5 +
.../ward-management/ward-role-access.ts | 4 +
src/lib/client-error-receiver.ts | 1 +
tests/ward-help-screen-tours.dom.test.tsx | 1 +
tests/ward-landmarks.test.ts | 2 +
tests/ward-nav.test.ts | 5 +-
tests/ward-night-view-tour.dom.test.tsx | 106 +++++++
tests/ward-night-view.test.ts | 108 +++++++
21 files changed, 968 insertions(+), 3 deletions(-)
create mode 100644 public/help/screens/night.html
create mode 100644 public/help/screens/night.md
create mode 100644 src/app/ward-flow/night/page.tsx
create mode 100644 src/components/ward-management/night/night-view-figures.ts
create mode 100644 src/components/ward-management/night/night-view-screen.tsx
create mode 100644 src/components/ward-management/night/night-view.module.css
create mode 100644 tests/ward-night-view-tour.dom.test.tsx
create mode 100644 tests/ward-night-view.test.ts
diff --git a/docs/ward-flow/SCREEN-MAP.md b/docs/ward-flow/SCREEN-MAP.md
index 85bb59a117..d9687622ec 100644
--- a/docs/ward-flow/SCREEN-MAP.md
+++ b/docs/ward-flow/SCREEN-MAP.md
@@ -5,7 +5,7 @@
See also [`site-map.md`](../site-map.md), the full list of app routes.
-**70 mockups · 62 routes · 45 screen folders.**
+**70 mockups · 63 routes · 46 screen folders.**
⚠️ The PAIRING is hand-authored — no rule derives that `command-third-edition.html` is route `/`.
**COMPLETENESS is not**: everything is discovered from disk, so a new or renamed file shows up as
@@ -96,7 +96,7 @@ UNMAPPED rather than disappearing.
## What the checks found
-### ⚠️ ROUTE WITH NO MOCKUP — undrawn, or needs an entry — 27
+### ⚠️ ROUTE WITH NO MOCKUP — undrawn, or needs an entry — 28
- `/activity`
- `/bed-meeting`
@@ -112,6 +112,7 @@ UNMAPPED rather than disappearing.
- `/hub/catchments`
- `/leave`
- `/movements/[movementId]`
+- `/night`
- `/people/[patientId]/triage`
- `/queue`
- `/reports/chronology`
diff --git a/docs/ward-flow/desk-guides/why-cant-i.md b/docs/ward-flow/desk-guides/why-cant-i.md
index 9b7e2d9232..1c616846e3 100644
--- a/docs/ward-flow/desk-guides/why-cant-i.md
+++ b/docs/ward-flow/desk-guides/why-cant-i.md
@@ -38,6 +38,7 @@ Each desk opens only the screens its role allows. If you cannot open a screen, c
| `statistics` | Bed coordinator, Bed manager (counts only), Nurse in charge (counts only), ED clinician (counts only), Community team (counts only), Executive (counts only) |
| `governance` | Bed coordinator, Access admin (own place) |
| `review` | Bed coordinator |
+| `night` | Bed coordinator |
| `alerts` | Bed coordinator, Bed manager (own place), Nurse in charge (own place), ED clinician (own place), Community team (own place), Transport officer (own place) |
| `on-call` | Bed coordinator, Bed manager, Nurse in charge, ED clinician, Community team, Transport officer |
| `seclusion` | Bed coordinator, Bed manager (own place), Nurse in charge (own place) |
diff --git a/public/help/screens/.ward-help-guides.json b/public/help/screens/.ward-help-guides.json
index 5362494dbe..0e73a414a9 100644
--- a/public/help/screens/.ward-help-guides.json
+++ b/public/help/screens/.ward-help-guides.json
@@ -45,6 +45,8 @@
"referral-intake.html": "c8d7e9d8f26938b76bf7c92f849b530426c8493de28ea511f6baa2dcc3eb0168",
"officer.md": "d00a34fd22a5113634ab1feea822db6a607d86de491cc080c98af1058c02c075",
"officer.html": "94fc48cd02bf7235ade1793774a77a711812cdd54e8c7fad9c082f2216e97053",
+ "night.md": "3fccae34d57dc7439366963bbc20db9152e4c9c6b246af4bd49876bf87fdb747",
+ "night.html": "1746cf994d9eaa8f877d7d6d8d7a8ece534b9a2c6ca7287defce106813270843",
"patient.md": "192e060eabf131ef2e4396bbc86fb50e9efb5050318e4a3f99ce715c71bed9da",
"patient.html": "f568436b53a703c3f82364c512c670d2239e6fcddba9cdf7f7df87738e555120",
"add-patient.md": "f7b3d5e0030a1e48cfebfe31af600a3936d024290005bb452f503e384bb6a04f",
diff --git a/public/help/screens/night.html b/public/help/screens/night.html
new file mode 100644
index 0000000000..5f05afe56b
--- /dev/null
+++ b/public/help/screens/night.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+ Night view screen — Ward Flow
+
+
+
+
+
+
Night view
+
+ Ward Flow · The coordinator's overnight page: beds ready, ED waits by Step, who is on the way and on-call
+ lines.
+
+
+
Print on A4 at 100%. Use your browser’s Print command.
+
+
What is on the screen
+
+
+ Tonight at a glance. Beds ready, people waiting in an ED and people on the way. The same figures as Home,
+ Waiting and Movements.
+
+
+ Beds ready now. Each ward with a bed ready, by health service then name, with when its count was confirmed.
+ Never ranked.
+
+
+ ED waits by Step. How many people are at each Step, by time in the department, with the suggested next step.
+ A Step is a prompt, never a breach.
+
+
On the way. Everyone collected and moving to a ward. Open one for their record and arrival.
+
Intake windows. Says Not recorded until wards record their overnight intake windows.
+
+ On call now. The state desk, each service's bed flow and the crisis line, with who answers now. Numbers are
+ synthetic.
+
+
+
+
+
+
+
diff --git a/public/help/screens/night.md b/public/help/screens/night.md
new file mode 100644
index 0000000000..b439425806
--- /dev/null
+++ b/public/help/screens/night.md
@@ -0,0 +1,18 @@
+# Night view
+
+
+
+The coordinator's overnight page: beds ready, ED waits by Step, who is on the way and on-call lines.
+
+## What is on the screen
+
+1. Tonight at a glance. Beds ready, people waiting in an ED and people on the way. The same figures as Home, Waiting and Movements.
+2. Beds ready now. Each ward with a bed ready, by health service then name, with when its count was confirmed. Never ranked.
+3. ED waits by Step. How many people are at each Step, by time in the department, with the suggested next step. A Step is a prompt, never a breach.
+4. On the way. Everyone collected and moving to a ward. Open one for their record and arrival.
+5. Intake windows. Says Not recorded until wards record their overnight intake windows.
+6. On call now. The state desk, each service's bed flow and the crisis line, with who answers now. Numbers are synthetic.
+
+In Ward Flow, open Help and choose Show me around this screen to see each part lit in turn.
+
+_Prototype on synthetic data. Not for real patients._
diff --git a/scripts/ward-flow/screen-pairs.mjs b/scripts/ward-flow/screen-pairs.mjs
index 58f06de4c9..288aa6c3e6 100644
--- a/scripts/ward-flow/screen-pairs.mjs
+++ b/scripts/ward-flow/screen-pairs.mjs
@@ -137,6 +137,7 @@ export const ROUTE_COMPONENTS = [
{ route: "/movements", folder: "movements", react: "movements/movements-screen.tsx" },
{ route: "/movements/[movementId]", folder: "patients", react: "patients/patient-now-screen.tsx" },
{ route: "/network", folder: "network", react: null },
+ { route: "/night", folder: "night", react: "night/night-view-screen.tsx" },
{ route: "/on-call", folder: "on-call", react: "on-call/on-call-screen.tsx" },
{ route: "/out-of-area", folder: "out-of-area", react: "out-of-area/out-of-area-board.tsx" },
{ route: "/people/[patientId]", folder: "patients", react: "patients/patient-now-screen.tsx" },
diff --git a/src/app/ward-flow/night/page.tsx b/src/app/ward-flow/night/page.tsx
new file mode 100644
index 0000000000..ab1732c628
--- /dev/null
+++ b/src/app/ward-flow/night/page.tsx
@@ -0,0 +1,13 @@
+import type { Metadata } from "next";
+
+import { NightViewScreen } from "@/components/ward-management/night/night-view-screen";
+
+export const metadata: Metadata = {
+ title: "Night view — Ward Flow",
+ description:
+ "Synthetic prototype: the coordinator's overnight screen. Beds ready, ED waits by Step, who is on the way, intake windows and on-call lines.",
+};
+
+export default function WardNightViewPage() {
+ return ;
+}
diff --git a/src/components/ward-management/help/ward-help-content.ts b/src/components/ward-management/help/ward-help-content.ts
index 94e05bd7b5..355543e57b 100644
--- a/src/components/ward-management/help/ward-help-content.ts
+++ b/src/components/ward-management/help/ward-help-content.ts
@@ -830,6 +830,28 @@ const PAGE_HELP: readonly PageHelp[] = [
},
],
},
+ {
+ id: "night",
+ path: /^\/ward-flow\/night$/u,
+ title: "Night view",
+ purpose:
+ "The coordinator's overnight page: beds ready, ED waits by Step, who is on the way, intake windows and on-call lines.",
+ actions: [
+ "Read the figures across the top. They are the same as Home, Waiting and Movements.",
+ "Open a ward, a patient or a line from its card. Each card links to its full page.",
+ "Open Help and choose Show me around for a short first-night walk round.",
+ ],
+ questions: [
+ {
+ question: "Why are the wards in this order?",
+ answer: "Wards are listed by health service, then by name. They are never ranked by beds ready.",
+ },
+ {
+ question: "Why do the intake windows say Not recorded?",
+ answer: "Wards do not record overnight intake windows in Ward Flow yet, so no time is shown.",
+ },
+ ],
+ },
{
id: "downtime",
path: /^\/ward-flow\/reports\/downtime$/u,
diff --git a/src/components/ward-management/help/ward-help-screen-tours-pages.ts b/src/components/ward-management/help/ward-help-screen-tours-pages.ts
index c0c877e70c..a87aa3d951 100644
--- a/src/components/ward-management/help/ward-help-screen-tours-pages.ts
+++ b/src/components/ward-management/help/ward-help-screen-tours-pages.ts
@@ -45,6 +45,49 @@ export const named = (label: string, element = "*"): TourTarget => ({
* same as one ward and one ED board, and names the screen a desk must open to see it.
*/
export const MORE_PAGES: readonly Around[] = [
+ {
+ // Feature plan item 29: the first-night walk round of the coordinator's Night view.
+ screen: "night",
+ name: "Night view",
+ keywords: "night overnight first night shift after hours on call steps beds ready",
+ page: {
+ purpose: "The coordinator's overnight page: beds ready, ED waits by Step, who is on the way and on-call lines.",
+ path: /^\/ward-flow\/night$/u,
+ needs: "night",
+ },
+ steps: [
+ {
+ target: [id("night-figures"), HERO],
+ title: "Tonight at a glance",
+ body: "Beds ready, people waiting in an ED and people on the way. The same figures as Home, Waiting and Movements.",
+ },
+ {
+ target: id("night-beds"),
+ title: "Beds ready now",
+ body: "Each ward with a bed ready, by health service then name, with when its count was confirmed. Never ranked.",
+ },
+ {
+ target: id("night-ed-steps"),
+ title: "ED waits by Step",
+ body: "How many people are at each Step, by time in the department, with the suggested next step. A Step is a prompt, never a breach.",
+ },
+ {
+ target: id("night-on-the-way"),
+ title: "On the way",
+ body: "Everyone collected and moving to a ward. Open one for their record and arrival.",
+ },
+ {
+ target: id("night-intake-windows"),
+ title: "Intake windows",
+ body: "Says Not recorded until wards record their overnight intake windows.",
+ },
+ {
+ target: id("night-on-call"),
+ title: "On call now",
+ body: "The state desk, each service's bed flow and the crisis line, with who answers now. Numbers are synthetic.",
+ },
+ ],
+ },
{
screen: "patient",
name: "this patient record",
diff --git a/src/components/ward-management/night/night-view-figures.ts b/src/components/ward-management/night/night-view-figures.ts
new file mode 100644
index 0000000000..c4333fa0af
--- /dev/null
+++ b/src/components/ward-management/night/night-view-figures.ts
@@ -0,0 +1,135 @@
+import { edOpenSummaries } from "@/components/ward-management/ed/ed-home-derivations";
+import {
+ availability,
+ KEY_LINE_IDS,
+ keyLineLabel,
+ numberAt,
+ type Availability,
+ type DirectoryEntry,
+} from "@/components/ward-management/on-call/on-call-directory";
+import type { Admission } from "@/components/ward-management/ward-admissions";
+import { readyBedsNow, totalReadyBedsNow } from "@/components/ward-management/ward-bed-states";
+import type { Instant } from "@/components/ward-management/ward-clock";
+import { edWaitStep, type EdWaitStep } from "@/components/ward-management/ward-ed-wait-steps";
+import type { BedRelease, Movement, Referral, Unit } from "@/components/ward-management/ward-model";
+import { HEALTH_SERVICES } from "@/components/ward-management/ward-model-vocabulary";
+import {
+ ED_ELEVATED_PRESSURE_WAIT_MINUTES,
+ ED_SEVERE_PRESSURE_WAIT_MINUTES,
+ LONG_WAIT_MINUTES,
+ VERY_LONG_WAIT_MINUTES,
+} from "@/components/ward-management/ward-operational-defaults";
+import { queueOrder } from "@/components/ward-management/ward-priority";
+import { unitHealthService } from "@/components/ward-management/ward-service-scope";
+import { minutesInEd } from "@/components/ward-management/ward-shared-figures";
+
+/**
+ * NIGHT VIEW (feature plan item 29). One screen for the coordinator overnight. Every figure here is
+ * read from the derivation the day screens already use; nothing new is counted:
+ *
+ * - Beds ready: `readyBedsNow` per ward, and `totalReadyBedsNow` for the total, the same call
+ * `wardNavCounts` makes for Home's "beds ready" and the rail (D-115: pulled and named beds are off).
+ * - ED waits by Step: the people Waiting counts (`edOpenSummaries`, so the total is `waitingInEds`),
+ * each placed by `edWaitStep` on time in the department, as the ED board's Step chip is.
+ * - On the way: open movements in Home's Moving stage (recorded stage `moving`).
+ * - On call: the On-call page's key lines (`KEY_LINE_IDS`), with who answers now.
+ *
+ * Wards are listed in a fixed order, health service then ward name (D-126), never most ready first.
+ */
+
+export type NightWardBeds = { unit: Unit; ready: number };
+
+export type NightReadyBeds = {
+ /** The same figure as Home's "beds ready" (`wardNavCounts().capacity`). */
+ total: number;
+ /** Wards with at least one bed ready, health service then ward name. */
+ wards: NightWardBeds[];
+ /** Wards with no bed ready now, counted, not listed. */
+ wardsWithNone: number;
+};
+
+function serviceRank(unit: Unit): number {
+ const service = unitHealthService(unit);
+ const at = service ? HEALTH_SERVICES.indexOf(service) : -1;
+ return at === -1 ? HEALTH_SERVICES.length : at;
+}
+
+/** Fixed order (D-126): health service, then ward name A to Z. */
+export function wardsInFixedOrder(rows: readonly T[]): T[] {
+ return [...rows].sort((a, b) => serviceRank(a.unit) - serviceRank(b.unit) || a.unit.name.localeCompare(b.unit.name));
+}
+
+export function nightReadyBeds(
+ units: readonly Unit[],
+ admissions: readonly Admission[],
+ bedReleases: readonly BedRelease[],
+ movements: readonly Movement[],
+ referrals: readonly Referral[],
+): NightReadyBeds {
+ const reservations = [...movements, ...referrals];
+ const rows = units.map((unit) => ({ unit, ready: readyBedsNow(unit, admissions, bedReleases, reservations) }));
+ const wards = wardsInFixedOrder(rows.filter((row) => row.ready > 0));
+ return {
+ total: totalReadyBedsNow(units, admissions, bedReleases, reservations),
+ wards,
+ wardsWithNone: rows.length - wards.length,
+ };
+}
+
+export type NightStepRow = EdWaitStep & { count: number };
+
+export type NightEdWaits = {
+ /** Everyone waiting in an ED: Waiting's title figure (`waitingInEds().total`). */
+ waiting: number;
+ /** Waiting, but under Step 1. */
+ underFirstStep: number;
+ /** Steps 1 to 4, in that fixed order, each with how many are at it now. */
+ steps: NightStepRow[];
+};
+
+/** The four Step thresholds, from the same values `ward-ed-wait-steps.ts` reads. */
+const STEP_STARTS = [
+ ED_ELEVATED_PRESSURE_WAIT_MINUTES,
+ ED_SEVERE_PRESSURE_WAIT_MINUTES,
+ LONG_WAIT_MINUTES,
+ VERY_LONG_WAIT_MINUTES,
+] as const;
+
+export function nightEdWaits(movements: readonly Movement[], now: Instant): NightEdWaits {
+ const waitingNow = edOpenSummaries(movements, now).flatMap((summary) => summary.open);
+ const counts = new Map();
+ let underFirstStep = 0;
+ for (const movement of waitingNow) {
+ const step = edWaitStep(minutesInEd(movement, now));
+ if (step) counts.set(step.step, (counts.get(step.step) ?? 0) + 1);
+ else underFirstStep += 1;
+ }
+ const steps = STEP_STARTS.map((minutes) => edWaitStep(minutes))
+ .filter((step): step is EdWaitStep => step !== undefined)
+ .map((step) => ({ ...step, count: counts.get(step.step) ?? 0 }));
+ return { waiting: waitingNow.length, underFirstStep, steps };
+}
+
+/** Open movements in Home's Moving stage (`flowStageOf`), oldest first as Home lists them. */
+export function nightOnTheWay(movements: readonly Movement[], now: Instant): Movement[] {
+ return queueOrder([...movements], now).filter((movement) => movement.stage === "moving");
+}
+
+export type NightContact = {
+ entry: DirectoryEntry;
+ label: string;
+ now: Availability;
+ number: string | null;
+};
+
+/** The On-call page's key lines, in its own order, with who answers at `minute`. */
+export function nightContacts(entries: readonly DirectoryEntry[], minute: number): NightContact[] {
+ return KEY_LINE_IDS.map((id) => entries.find((entry) => entry.id === id))
+ .filter((entry): entry is DirectoryEntry => entry !== undefined)
+ .map((entry) => ({
+ entry,
+ label: keyLineLabel(entry),
+ now: availability(entry, minute),
+ number: numberAt(entry, minute),
+ }));
+}
diff --git a/src/components/ward-management/night/night-view-screen.tsx b/src/components/ward-management/night/night-view-screen.tsx
new file mode 100644
index 0000000000..83a2634639
--- /dev/null
+++ b/src/components/ward-management/night/night-view-screen.tsx
@@ -0,0 +1,264 @@
+"use client";
+
+import { BedDouble, CalendarClock, Clock, Phone, Truck } from "lucide-react";
+import Link from "next/link";
+import { useMemo } from "react";
+
+import { Card, CardHead, EmptyState, Hero, HeroFigures, HeroTop, LiveChip, StatusGlyph } from "@/components/wf";
+import { buildOnCallDirectory, hhmm } from "@/components/ward-management/on-call/on-call-directory";
+import { movementHref, unitHref } from "@/components/ward-management/shell/ward-facade";
+import { WardPrototypeFooter } from "@/components/ward-management/shell/ward-prototype-footer";
+import { formatInstant, formatInstantWithDay, minuteOfDay } from "@/components/ward-management/ward-clock";
+import { useWardFlowClock, useWardFlowSelect, useWardNetwork } from "@/components/ward-management/ward-flow-provider";
+import { usePatientOf } from "@/components/ward-management/ward-patient-name";
+import { unitHealthService } from "@/components/ward-management/ward-service-scope";
+
+import { nightContacts, nightEdWaits, nightOnTheWay, nightReadyBeds, type NightContact } from "./night-view-figures";
+import styles from "./night-view.module.css";
+
+const CAPACITY_HREF = "/ward-flow/capacity";
+const WAITING_HREF = "/ward-flow/delays";
+const MOVEMENTS_HREF = "/ward-flow/movements";
+const ON_CALL_HREF = "/ward-flow/on-call";
+
+function contactNowText(contact: NightContact): string {
+ const now = contact.now;
+ if (now.kind === "none") return "No line recorded";
+ if (now.kind === "off") return `Closed, opens ${hhmm(now.opens)}`;
+ if (now.allDay) return "Answering, all day";
+ return `Answering until ${hhmm(now.until)}`;
+}
+
+/**
+ * Night view (feature plan item 29): the coordinator's overnight screen. Beds ready, ED waits by
+ * Step, who is on the way, tonight's intake windows and the key on-call lines, on one page. Every
+ * figure comes from the same derivation as the day screens (`night-view-figures.ts`), so the counts
+ * here always match Home, Waiting, Movements and On-call. It shows; it never suggests a ward (D-71).
+ */
+export function NightViewScreen() {
+ const { movements, units, bedReleases, admissions, referrals } = useWardFlowSelect(
+ "movements",
+ "units",
+ "bedReleases",
+ "admissions",
+ "referrals",
+ );
+ const now = useWardFlowClock();
+ const network = useWardNetwork();
+ const patientOf = usePatientOf();
+
+ const beds = useMemo(
+ () => nightReadyBeds(units, admissions, bedReleases, movements, referrals),
+ [units, admissions, bedReleases, movements, referrals],
+ );
+ const waits = useMemo(() => nightEdWaits(movements, now), [movements, now]);
+ const onTheWay = useMemo(() => nightOnTheWay(movements, now), [movements, now]);
+ const directory = useMemo(() => buildOnCallDirectory(network.sites), [network.sites]);
+ const contacts = useMemo(() => nightContacts(directory, minuteOfDay(now)), [directory, now]);
+ const unitName = (id: string | undefined) => units.find((unit) => unit.id === id)?.name;
+ const atStep = waits.waiting - waits.underFirstStep;
+
+ return (
+
+ }
+ >
+
+ }
+ />
+
+
+
+
+
+ {patientOf({ movementId: movement.id }).displayName}
+
+
+ To {unitName(movement.acceptedUnitId) ?? "a ward not recorded"}
+ {eta === undefined ? "" : ` · due ${formatInstantWithDay(eta, now)}`}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ Tonight's intake windows}
+ icon={CalendarClock}
+ iconPlain
+ />
+ {/* Feature plan item 13 (overnight intake windows) is not built yet. Until wards record
+ their windows, this says so rather than showing a time nobody set. */}
+
+ Not recorded. Wards do not record overnight intake windows in Ward Flow yet.
+
+ {view.state === "withheld"
+ ? view.words
+ : view.state === "none"
+ ? kind === "beds"
+ ? "No ward has one now."
+ : "Nobody is counted in this figure now."
+ : "This list does not add up to the figure, so it is not shown. Open the page the figure comes from."}
+
+ )}
+
+ );
+}
+
+/** Phone: the same list in a bottom sheet. */
+export function CountBreakdownSheet({
+ title,
+ onClose,
+ returnFocusRef,
+ ...props
+}: PanelProps & { title: string; onClose: () => void; returnFocusRef: RefObject }) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ward-management/count-breakdown/count-breakdown-rows.ts b/src/components/ward-management/count-breakdown/count-breakdown-rows.ts
new file mode 100644
index 0000000000..8513ef3c08
--- /dev/null
+++ b/src/components/ward-management/count-breakdown/count-breakdown-rows.ts
@@ -0,0 +1,87 @@
+/**
+ * Rows for a count breakdown (feature plan item 5), built from the very list a figure counts. Pure.
+ * Each builder keeps every item it is given, in the order given, so the rows add up to the figure;
+ * nothing here filters, ranks or reorders anybody (D-71).
+ */
+import { durMinutes } from "@/components/wf/format";
+import type { Instant } from "@/components/ward-management/ward-clock";
+import type { BedRelease, LeaveBed, Movement, Unit } from "@/components/ward-management/ward-model";
+import { minutesInEd } from "@/components/ward-management/ward-shared-figures";
+import { edById, siteByCode } from "@/components/ward-management/ward-sites";
+import { movementHref, wardBoardHref } from "@/components/ward-management/shell/ward-facade";
+import { patientHref } from "@/components/ward-management/shell/ward-patient-href";
+
+import type { BreakdownRow } from "./count-breakdown-model";
+
+/** A person's name as a list shows it (D-83: formal name), and their patient id when linked. */
+export type BreakdownPerson = { name: string; patientId?: string };
+
+/** One row per movement, opening the movement's record. */
+export function movementBreakdownRows(
+ movements: readonly Movement[],
+ personOf: (movement: Movement) => BreakdownPerson,
+ now: Instant,
+ canOpen: (href: string) => boolean = () => true,
+): BreakdownRow[] {
+ return movements.map((movement) => {
+ const ed = edById(movement.originEdId);
+ const href = movementHref(movement.id);
+ const parts = [ed?.name, `open ${durMinutes(minutesInEd(movement, now))}`].filter(Boolean);
+ return {
+ id: movement.id,
+ label: personOf(movement).name,
+ detail: parts.join(" · "),
+ href: canOpen(href) ? href : undefined,
+ };
+ });
+}
+
+/** One row per ward holding ready beds, with its count, opening the ward's bed board. */
+export function wardReadyBreakdownRows(
+ wards: readonly { unit: Unit; ready: number }[],
+ canOpen: (href: string) => boolean = () => true,
+): BreakdownRow[] {
+ return wards
+ .filter((ward) => ward.ready > 0)
+ .map(({ unit, ready }) => {
+ const href = wardBoardHref(unit.id);
+ return {
+ id: unit.id,
+ label: unit.name,
+ detail: siteByCode(unit.siteCode)?.name,
+ href: canOpen(href) ? href : undefined,
+ count: ready,
+ };
+ });
+}
+
+/**
+ * One row per stay (an admission, a bed release or a leave bed, by its `admissionId`), naming the
+ * person and opening their page when the stay is linked to one.
+ */
+export function stayBreakdownRows(
+ stays: readonly { key: string; admissionId: string; detail?: string }[],
+ personOf: (admissionId: string) => BreakdownPerson,
+ canOpen: (href: string) => boolean = () => true,
+): BreakdownRow[] {
+ return stays.map((stay) => {
+ const person = personOf(stay.admissionId);
+ const href = person.patientId ? patientHref(person.patientId) : undefined;
+ return {
+ id: stay.key,
+ label: person.name,
+ detail: stay.detail,
+ href: href && canOpen(href) ? href : undefined,
+ };
+ });
+}
+
+/** Bed releases as stays. */
+export function releaseStays(releases: readonly BedRelease[]) {
+ return releases.map((release) => ({ key: release.id, admissionId: release.admissionId }));
+}
+
+/** Leave beds as stays. */
+export function leaveStays(leave: readonly LeaveBed[]) {
+ return leave.map((bed) => ({ key: bed.id, admissionId: bed.admissionId }));
+}
diff --git a/src/components/ward-management/count-breakdown/count-breakdown.module.css b/src/components/ward-management/count-breakdown/count-breakdown.module.css
new file mode 100644
index 0000000000..b4420e9dd2
--- /dev/null
+++ b/src/components/ward-management/count-breakdown/count-breakdown.module.css
@@ -0,0 +1,96 @@
+/* Tap a number to see who is in it (feature plan item 5). Kit tokens only. */
+.popover {
+ width: min(22rem, calc(100vw - 2 * var(--wf-s-4)));
+ max-width: min(22rem, calc(100vw - 2 * var(--wf-s-4)));
+ color: var(--wf-ink-1);
+ text-align: left;
+ white-space: normal;
+}
+
+.body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wf-s-2);
+}
+
+.sentence {
+ margin: 0;
+ font: 600 var(--wf-fs-13) / var(--wf-lh-13) var(--wf-font);
+ color: var(--wf-ink-1);
+}
+
+.loading,
+.note {
+ margin: 0;
+ font: 400 var(--wf-fs-13) / var(--wf-lh-13) var(--wf-font);
+ color: var(--wf-ink-3);
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: var(--wf-s-2);
+ min-height: 40px;
+ padding: var(--wf-s-1) var(--wf-s-2);
+ border-radius: var(--wf-r-md);
+ color: var(--wf-ink-1);
+ text-decoration: none;
+}
+
+a.row:hover {
+ background: var(--wf-surface-2);
+}
+
+a.row:focus-visible {
+ outline: 2px solid var(--wf-focus-ring);
+ outline-offset: 1px;
+}
+
+.row > svg {
+ flex: none;
+ color: var(--wf-ink-3);
+}
+
+.main {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.label {
+ font: 500 var(--wf-fs-14) / var(--wf-lh-14) var(--wf-font);
+}
+
+.detail {
+ font: 400 var(--wf-fs-12) / var(--wf-lh-12) var(--wf-font);
+ color: var(--wf-ink-3);
+}
+
+.count {
+ flex: none;
+ font: 400 var(--wf-fs-12) / var(--wf-lh-12) var(--wf-font);
+ color: var(--wf-ink-2);
+}
+
+.count b {
+ font: 600 var(--wf-fs-14) / var(--wf-lh-14) var(--wf-mono);
+ font-variant-numeric: tabular-nums;
+ color: var(--wf-ink-1);
+}
+
+/* The anchor fills a band cell as the cell's own body did. */
+.cellAnchor {
+ display: flex;
+ flex: 1 1 auto;
+ min-width: 0;
+}
diff --git a/src/components/ward-management/count-breakdown/count-breakdown.tsx b/src/components/ward-management/count-breakdown/count-breakdown.tsx
new file mode 100644
index 0000000000..037e45b0d5
--- /dev/null
+++ b/src/components/ward-management/count-breakdown/count-breakdown.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+/**
+ * TAP A NUMBER TO SEE WHO IS IN IT (feature plan item 5). The figure stays where it is and becomes a
+ * button ("6 open ED movements, show who"). On a wider screen it opens a small pop-up beside the
+ * figure (the kit's `Popover`); on a phone, a bottom sheet (the kit's `Sheet`). The list inside is
+ * fetched on first use, so phone first loads carry only this button.
+ *
+ * The caller hands over the list the figure was counted from (`rows`), never a second list; the
+ * panel checks the rows add up to the figure before it shows any (`breakdownView`). See
+ * `count-breakdown-model.ts` for the scope and small-number rules. Not for the wall TV.
+ */
+import dynamic from "next/dynamic";
+import { useRef, useState, type ReactNode } from "react";
+
+import { Popover, useIsPhone } from "@/components/wf";
+import type { Instant } from "@/components/ward-management/ward-clock";
+
+import {
+ breakdownButtonName,
+ type BreakdownKind,
+ type BreakdownRow,
+ type BreakdownScope,
+} from "./count-breakdown-model";
+import styles from "./count-breakdown.module.css";
+
+const loadPanel = () => import("./count-breakdown-panel");
+const CountBreakdownPanel = dynamic(() => loadPanel().then((m) => m.CountBreakdownPanel), {
+ loading: () =>
Loading the list…
,
+});
+const CountBreakdownSheet = dynamic(() => loadPanel().then((m) => m.CountBreakdownSheet), { loading: () => null });
+
+export type CountBreakdownProps = {
+ /** The figure, exactly as shown. */
+ value: number;
+ /** What it counts, after the number: "open ED movements", "beds ready". */
+ noun: string;
+ kind?: BreakdownKind;
+ scope?: BreakdownScope;
+ /** The minute the figure was counted, from the same `now` the figure used. */
+ countedAt: Instant;
+ /** The figure's own list, built on opening. */
+ rows: () => readonly BreakdownRow[];
+ /** The button's look: the figure's own label or cell class, so the figure looks as before. */
+ className?: string;
+ anchorClassName?: string;
+ testId?: string;
+ children: ReactNode;
+};
+
+export function CountBreakdown({
+ value,
+ noun,
+ kind = "people",
+ scope = "own",
+ countedAt,
+ rows,
+ className,
+ anchorClassName,
+ testId,
+ children,
+}: CountBreakdownProps) {
+ const isPhone = useIsPhone();
+ const [sheetOpen, setSheetOpen] = useState(false);
+ const phoneTriggerRef = useRef(null);
+ const name = breakdownButtonName(value, noun, kind);
+ const panelProps = { value, noun, kind, scope, countedAt, rows };
+ const title = kind === "beds" ? `Where the ${value} ${noun} are` : `Who is in ${value} ${noun}`;
+
+ if (isPhone) {
+ return (
+ <>
+
+ {sheetOpen ? (
+ setSheetOpen(false)}
+ />
+ ) : null}
+ >
+ );
+ }
+
+ return (
+ (
+
+ )}
+ >
+ {() => }
+
+ );
+}
diff --git a/src/components/ward-management/ward-bed-availability.ts b/src/components/ward-management/ward-bed-availability.ts
index 90d4078151..feaafaf1e4 100644
--- a/src/components/ward-management/ward-bed-availability.ts
+++ b/src/components/ward-management/ward-bed-availability.ts
@@ -225,28 +225,37 @@ export function openBedsNow(unit: Unit, releases: BedRelease[]): number {
return Math.max(0, available - bedsPendingPreparation(unit.id, releases));
}
-export function capacityBreakdown(
+export type CapacityBreakdownLists = {
+ confirmedToday: BedRelease[];
+ expectedToday: BedRelease[];
+ blockedToday: BedRelease[];
+ confirmedHeldUpToday: BedRelease[];
+ onLeave: LeaveBed[];
+ excludedBeyondToday: number;
+};
+
+/**
+ * Item 5 (count breakdown): the records behind `capacityBreakdown`'s day figures for one ward. The
+ * figures are these lists' lengths, so a breakdown that lists them always adds up to the figure.
+ */
+export function capacityBreakdownLists(
unit: Unit,
- releases: BedRelease[],
- leave: LeaveBed[],
+ releases: readonly BedRelease[],
+ leave: readonly LeaveBed[],
now: Instant,
-): CapacityBreakdown {
- // Shared with the five-state bed grid so stale counts cannot make this screen disagree.
- const { available: availableNow, held } = boundedBedAvailability(unit);
-
- const unitReleases = releases.filter((release) => release.unitId === unit.id);
-
- let confirmedToday = 0;
- let expectedToday = 0;
- let blockedToday = 0;
- let confirmedHeldUpToday = 0;
- let excludedBeyondToday = 0;
-
- for (const release of unitReleases) {
- if (release.state === "discharged") continue;
- const band = releaseBand(release, now);
- if (band === "beyond-today") {
- excludedBeyondToday += 1;
+): CapacityBreakdownLists {
+ const lists: CapacityBreakdownLists = {
+ confirmedToday: [],
+ expectedToday: [],
+ blockedToday: [],
+ confirmedHeldUpToday: [],
+ onLeave: leave.filter((bed) => bed.unitId === unit.id),
+ excludedBeyondToday: 0,
+ };
+ for (const release of releases) {
+ if (release.unitId !== unit.id || release.state === "discharged") continue;
+ if (releaseBand(release, now) === "beyond-today") {
+ lists.excludedBeyondToday += 1;
continue;
}
// The bed-model rework's whole point (2026-08-28). `state` now carries ONLY how certain the
@@ -256,22 +265,31 @@ export function capacityBreakdown(
// count. A blocked-but-confirmed bed is counted as confirmed here, deliberately, and the
// block is reported alongside rather than instead. Do not re-introduce a `blocker` test into
// this if/else — that is the defect, not a refinement of it.
- if (release.state === "confirmed") confirmedToday += 1;
- else expectedToday += 1;
- if (release.blocker !== null) blockedToday += 1;
- if (release.blocker !== null && release.state === "confirmed") confirmedHeldUpToday += 1;
+ if (release.state === "confirmed") lists.confirmedToday.push(release);
+ else lists.expectedToday.push(release);
+ if (release.blocker !== null) lists.blockedToday.push(release);
+ if (release.blocker !== null && release.state === "confirmed") lists.confirmedHeldUpToday.push(release);
}
+ return lists;
+}
- const onLeave = leave.filter((bed) => bed.unitId === unit.id).length;
-
+export function capacityBreakdown(
+ unit: Unit,
+ releases: BedRelease[],
+ leave: LeaveBed[],
+ now: Instant,
+): CapacityBreakdown {
+ // Shared with the five-state bed grid so stale counts cannot make this screen disagree.
+ const { available: availableNow, held } = boundedBedAvailability(unit);
+ const lists = capacityBreakdownLists(unit, releases, leave, now);
return {
availableNow,
- confirmedToday,
- expectedToday,
- blockedToday,
- confirmedHeldUpToday,
+ confirmedToday: lists.confirmedToday.length,
+ expectedToday: lists.expectedToday.length,
+ blockedToday: lists.blockedToday.length,
+ confirmedHeldUpToday: lists.confirmedHeldUpToday.length,
held,
- onLeave,
- excludedBeyondToday,
+ onLeave: lists.onLeave.length,
+ excludedBeyondToday: lists.excludedBeyondToday,
};
}
diff --git a/src/components/ward-management/ward-bed-states.ts b/src/components/ward-management/ward-bed-states.ts
index 220c218653..5160c78851 100644
--- a/src/components/ward-management/ward-bed-states.ts
+++ b/src/components/ward-management/ward-bed-states.ts
@@ -103,6 +103,21 @@ export function readyBedsNow(
return bedStates(unit, admissions, [...bedReleases], [], referrals).ready;
}
+/**
+ * Item 5 (count breakdown): the wards holding a ready bed now, each with its `readyBedsNow`, in the
+ * order given. `totalReadyBedsNow` is their sum, so a list of these wards always adds up to the figure.
+ */
+export function readyBedsByWard(
+ units: readonly Unit[],
+ admissions: readonly Admission[],
+ bedReleases: readonly BedRelease[],
+ referrals: readonly ReservationReferral[] = [],
+): { unit: Unit; ready: number }[] {
+ return units
+ .map((unit) => ({ unit, ready: readyBedsNow(unit, admissions, bedReleases, referrals) }))
+ .filter((ward) => ward.ready > 0);
+}
+
/** `readyBedsNow` summed over a set of wards. */
export function totalReadyBedsNow(
units: readonly Unit[],
@@ -110,7 +125,7 @@ export function totalReadyBedsNow(
bedReleases: readonly BedRelease[],
referrals: readonly ReservationReferral[] = [],
): number {
- return units.reduce((sum, unit) => sum + readyBedsNow(unit, admissions, bedReleases, referrals), 0);
+ return readyBedsByWard(units, admissions, bedReleases, referrals).reduce((sum, ward) => sum + ward.ready, 0);
}
/** The ruled words, spelled once. */
diff --git a/src/components/ward-management/ward-nav-counts.ts b/src/components/ward-management/ward-nav-counts.ts
index 179e15d03c..ca478aedee 100644
--- a/src/components/ward-management/ward-nav-counts.ts
+++ b/src/components/ward-management/ward-nav-counts.ts
@@ -16,7 +16,7 @@ import type {
import type { WardMode, WardNavId } from "@/components/ward-management/ward-nav";
import { movementBelongsToService } from "@/components/ward-management/ward-service-scope";
import { waitingInEds } from "@/components/ward-management/ward-shared-figures";
-import { totalReadyBedsNow } from "@/components/ward-management/ward-bed-states";
+import { readyBedsByWard } from "@/components/ward-management/ward-bed-states";
import type { Admission } from "@/components/ward-management/ward-admissions";
/**
@@ -69,6 +69,11 @@ export type WardNavCount = {
* screen does not lead with travels here, under its own noun, never in the badge.
*/
need?: { value: number; noun: string };
+ /**
+ * Item 5 (count breakdown): for the ready figure, the wards it counts and each one's ready beds.
+ * `value` is their sum, so a breakdown of the figure always adds up to it.
+ */
+ readyWards?: readonly { unit: Unit; ready: number }[];
};
/**
@@ -140,13 +145,16 @@ function deriveWardNavCounts(input: WardNavCountInput): WardNavCounts {
.filter((group) => SEVERE_CAUSES.includes(group.cause))
.reduce((total, group) => total + group.movements.length, 0);
+ const readyWards = readyBedsByWard(units, admissions ?? [], bedReleases, [...movements, ...referrals]);
+
const counts: WardNavCounts = {
// Audit 6 (Josh, 20 Oct 2026): the one ready figure, which leaves out beds pulled for an incoming patient.
// D-115 (Josh, 9 Nov 2026): and a bed named for an accepted referral, as the TV and print do.
capacity: {
- value: totalReadyBedsNow(units, admissions ?? [], bedReleases, [...movements, ...referrals]),
+ value: readyWards.reduce((sum, ward) => sum + ward.ready, 0),
noun: "beds ready now",
urgent: false,
+ readyWards,
},
movements: { value: open.length, noun: "still open", urgent: false },
// Overall review, 20 Oct 2026: the badge is Waiting's own title figure, in its words. The severe
diff --git a/src/components/ward-management/ward-shared-figures.ts b/src/components/ward-management/ward-shared-figures.ts
index 7a034c5108..6f54f56e67 100644
--- a/src/components/ward-management/ward-shared-figures.ts
+++ b/src/components/ward-management/ward-shared-figures.ts
@@ -41,14 +41,18 @@ export type WaitingFigure = {
byService: ReadonlyMap;
};
-export function waitingInEds(movements: readonly Movement[], now: Instant): WaitingFigure {
+export function waitingInEds(
+ movements: readonly Movement[],
+ now: Instant,
+): WaitingFigure & { people: readonly Movement[] } {
const byService = new Map(HEALTH_SERVICES.map((service) => [service, 0]));
- let total = 0;
+ // Item 5 (count breakdown): the people are the figure; the total is their number, never a second sum.
+ const people: Movement[] = [];
for (const summary of edOpenSummaries(movements, now)) {
- total += summary.waiting;
- byService.set(summary.service, (byService.get(summary.service) ?? 0) + summary.waiting);
+ people.push(...summary.open);
+ byService.set(summary.service, (byService.get(summary.service) ?? 0) + summary.open.length);
}
- return { total, byService };
+ return { total: people.length, byService, people };
}
/**
@@ -83,18 +87,19 @@ export function minutesInEd(movement: Pick, now: Instant):
export function openEdJourneys(
movements: readonly Movement[],
now: Instant,
-): WaitingFigure & { stillInEd: number; inTransit: number } {
+): WaitingFigure & { stillInEd: number; inTransit: number; people: readonly Movement[] } {
const byService = new Map(HEALTH_SERVICES.map((service) => [service, 0]));
- let total = 0;
+ // Item 5 (count breakdown): the journeys are the figure; the total is their number.
+ const people: Movement[] = [];
let stillInEd = 0;
let inTransit = 0;
for (const summary of edJourneySummaries(movements, now)) {
- total += summary.waiting;
+ people.push(...summary.open);
stillInEd += summary.stillInEd;
inTransit += summary.inTransit;
- byService.set(summary.service, (byService.get(summary.service) ?? 0) + summary.waiting);
+ byService.set(summary.service, (byService.get(summary.service) ?? 0) + summary.open.length);
}
- return { total, stillInEd, inTransit, byService };
+ return { total: people.length, stillInEd, inTransit, byService, people };
}
/** "35 waiting", "none waiting": the picker's words, so the noun never leaves the number. */
diff --git a/src/components/wf/hero-top.tsx b/src/components/wf/hero-top.tsx
index 8b3805ae73..e98e878003 100644
--- a/src/components/wf/hero-top.tsx
+++ b/src/components/wf/hero-top.tsx
@@ -23,6 +23,12 @@ import styles from "./hero-top.module.css";
type Press = { href?: string; onClick?: () => void };
+/**
+ * A pressable the page draws itself, in place of the figure's label or the band cell's body: a
+ * count breakdown (feature plan item 5) passes its own button here. The kit gives it the look.
+ */
+export type HeroRenderPress = (press: { className: string; children: ReactNode; testId?: string }) => ReactNode;
+
export type HeroFigureItem = Press & {
id: string;
value: ReactNode;
@@ -38,6 +44,8 @@ export type HeroFigureItem = Press & {
linkTestId?: string;
/** A toggle's state (`aria-pressed`) when the figure has `onClick`. */
pressed?: boolean;
+ /** The page's own button for the label (`HeroRenderPress`). Wins over `href` and `onClick`. */
+ renderPress?: HeroRenderPress;
};
/** A row of figures on the slate: equal columns on a phone (a long label takes the room it needs). */
@@ -76,7 +84,9 @@ function HeroFigure({ item }: { item: HeroFigureItem }) {
{item.value}
{item.unit ? {item.unit} : null}
{" "}
- {item.href ? (
+ {item.renderPress ? (
+ item.renderPress({ className: cx(labelClass, styles.figLink, styles.figButton), children: item.label })
+ ) : item.href ? (
{item.label}
@@ -282,6 +292,8 @@ export type HeroBandCell = Press & {
ariaLabel?: string;
pressed?: boolean;
testId?: string;
+ /** The page's own button for the cell (`HeroRenderPress`). Wins over `href` and `onClick`. */
+ renderPress?: HeroRenderPress;
};
/** True while the list is wider than its box (watched only when `watch` is set). */
@@ -342,7 +354,7 @@ export function HeroBand({ title, aside, asideHref, cells, variant = "inline", c
"use no memo";
const titleId = useId();
const listRef = useRef(null);
- const pressable = cells.some((cell) => cell.href || cell.onClick);
+ const pressable = cells.some((cell) => cell.href || cell.onClick || cell.renderPress);
const scrolls = useScrollsSideways(listRef, !pressable);
return (
@@ -400,7 +412,9 @@ export function HeroBand({ title, aside, asideHref, cells, variant = "inline", c
);
return (
- {cell.href ? (
+ {cell.renderPress ? (
+ cell.renderPress({ className: styles.cellBody, children: body, testId: cell.testId })
+ ) : cell.href ? (
{
+ const panel = await import("@/components/ward-management/count-breakdown/count-breakdown-panel");
+ let calls = 0;
+ return {
+ default: () => {
+ calls += 1;
+ return calls % 2 === 1 ? panel.CountBreakdownPanel : panel.CountBreakdownSheet;
+ },
+ };
+});
+
+import { CountBreakdown } from "@/components/ward-management/count-breakdown/count-breakdown";
+import type { BreakdownRow } from "@/components/ward-management/count-breakdown/count-breakdown-model";
+
+const rows: BreakdownRow[] = [
+ { id: "m-1", label: "SYNTHETIC, Alex", detail: "Example ED", href: "/ward-flow/movements/m-1" },
+ { id: "m-2", label: "SYNTHETIC, Billie", href: "/ward-flow/movements/m-2" },
+ { id: "m-3", label: "Name not recorded" },
+];
+
+/** Feature plan item 5: the figure is a button; opening it lists exactly the figure's people. */
+describe("count breakdown", () => {
+ afterEach(() => cleanup());
+
+ it("opens with one row per person counted, and Escape returns focus to the figure", () => {
+ render(
+ rows} testId="fig">
+ open ED movements
+ ,
+ );
+ const button = screen.getByRole("button", { name: "3 open ED movements, show who" });
+ act(() => {
+ fireEvent.click(button);
+ });
+ expect(screen.getByText("3 open ED movements. Counted 14:32.")).toBeInTheDocument();
+ expect(screen.getAllByTestId("count-breakdown-row")).toHaveLength(3);
+ expect(screen.getByRole("link", { name: /SYNTHETIC, Alex/ })).toHaveAttribute("href", "/ward-flow/movements/m-1");
+ const dialog = screen.getByRole("dialog");
+ act(() => {
+ fireEvent.keyDown(dialog, { key: "Escape" });
+ });
+ expect(screen.queryByTestId("count-breakdown-panel")).not.toBeInTheDocument();
+ expect(button).toHaveFocus();
+ });
+
+ it("shows no list when the rows do not add up to the figure", () => {
+ render(
+ rows}>
+ open ED movements
+ ,
+ );
+ act(() => {
+ fireEvent.click(screen.getByRole("button", { name: /show who/ }));
+ });
+ expect(screen.queryAllByTestId("count-breakdown-row")).toHaveLength(0);
+ expect(screen.getByTestId("count-breakdown-mismatch")).toBeInTheDocument();
+ });
+
+ it("keeps names back under the small number line in an aggregate view", () => {
+ render(
+ rows}>
+ waiting
+ ,
+ );
+ act(() => {
+ fireEvent.click(screen.getByRole("button", { name: /show who/ }));
+ });
+ expect(screen.queryAllByTestId("count-breakdown-row")).toHaveLength(0);
+ expect(screen.getByTestId("count-breakdown-withheld")).toHaveTextContent(/Fewer than/);
+ });
+});
diff --git a/tests/ward-count-breakdown.test.ts b/tests/ward-count-breakdown.test.ts
new file mode 100644
index 0000000000..83e5d85520
--- /dev/null
+++ b/tests/ward-count-breakdown.test.ts
@@ -0,0 +1,179 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+import {
+ breakdownButtonName,
+ breakdownSentence,
+ breakdownTotal,
+ breakdownView,
+ breakdownWithheld,
+} from "../src/components/ward-management/count-breakdown/count-breakdown-model";
+import {
+ leaveStays,
+ movementBreakdownRows,
+ releaseStays,
+ stayBreakdownRows,
+ wardReadyBreakdownRows,
+} from "../src/components/ward-management/count-breakdown/count-breakdown-rows";
+import { buildIncoming } from "../src/components/ward-management/board/ward-board-model";
+import { networkTotals, networkWardRows } from "../src/components/ward-management/capacity/capacity-derivations";
+import { wardPlaceLookup } from "../src/components/ward-management/ward-access-places";
+import { scopeWorkspace } from "../src/components/ward-management/ward-access-scope";
+import { derivedBedReleases } from "../src/components/ward-management/ward-discharge-dates";
+import { capacityBreakdown, capacityBreakdownLists } from "../src/components/ward-management/ward-bed-availability";
+import { readyBedsByWard, totalReadyBedsNow } from "../src/components/ward-management/ward-bed-states";
+import { CLINICAL_VALUES } from "../src/components/ward-management/ward-clinical-values";
+import { seedWardFlowState } from "../src/components/ward-management/ward-flow-reducer";
+import { wardNavCounts } from "../src/components/ward-management/ward-nav-counts";
+import { STATEWIDE_COORDINATOR, type WardAccess } from "../src/components/ward-management/ward-role-access";
+import { openEdJourneys, waitingInEds } from "../src/components/ward-management/ward-shared-figures";
+import { NOW_ANCHOR, wardSites } from "../src/components/ward-management/ward-sites";
+import { DEFAULT_DUE_SOON_THRESHOLDS } from "../src/components/ward-management/ward-clock";
+
+const state = seedWardFlowState();
+const now = NOW_ANCHOR;
+const person = () => ({ name: "Synthetic person" });
+const listOk = (value: number, rows: Parameters[0]) => {
+ expect(breakdownTotal(rows)).toBe(value);
+ expect(breakdownView({ value, kind: "people", scope: "own", rows }).state).not.toBe("mismatch");
+};
+
+/** Feature plan item 5: for every figure that opens a list, the list adds up to the figure. */
+describe("count breakdown: the list equals the figure", () => {
+ it("Home: open ED movements", () => {
+ const figure = openEdJourneys(state.movements, now);
+ expect(figure.total).toBeGreaterThan(0);
+ expect(figure.people.length).toBe(figure.total);
+ listOk(figure.total, movementBreakdownRows(figure.people, person, now));
+ });
+
+ it("Waiting in ED (the badge's figure) is its people", () => {
+ const figure = waitingInEds(state.movements, now);
+ expect(figure.people.length).toBe(figure.total);
+ expect([...figure.byService.values()].reduce((a, b) => a + b, 0)).toBe(figure.total);
+ });
+
+ it("Home and the rail: beds ready now", () => {
+ const counts = wardNavCounts({
+ movements: state.movements,
+ units: state.units,
+ sites: wardSites,
+ referrals: state.referrals,
+ bedReleases: state.bedReleases,
+ leaveBeds: state.leaveBeds,
+ admissions: state.admissions,
+ now,
+ thresholds: DEFAULT_DUE_SOON_THRESHOLDS,
+ });
+ const value = counts.capacity!.value;
+ const rows = wardReadyBreakdownRows(counts.capacity!.readyWards ?? []);
+ expect(breakdownTotal(rows)).toBe(value);
+ expect(value).toBe(
+ totalReadyBedsNow(state.units, state.admissions, state.bedReleases, [...state.movements, ...state.referrals]),
+ );
+ expect(breakdownView({ value, kind: "beds", scope: "own", rows }).state).toBe(value === 0 ? "none" : "list");
+ });
+
+ it("Capacity: beds ready", () => {
+ const rows = networkWardRows(state.units, now, state.bedReleases, state.admissions, state.leaveBeds);
+ const value = networkTotals(rows).ready;
+ expect(breakdownTotal(wardReadyBreakdownRows(rows))).toBe(value);
+ });
+
+ it("bed board: the day figures and On leave, on every ward", () => {
+ const releases = derivedBedReleases([...state.admissions], now);
+ for (const unit of state.units) {
+ const counts = capacityBreakdown(unit, releases, [...state.leaveBeds], now);
+ const lists = capacityBreakdownLists(unit, releases, state.leaveBeds, now);
+ const pairs: [number, ReturnType][] = [
+ [counts.confirmedToday, releaseStays(lists.confirmedToday)],
+ [counts.expectedToday, releaseStays(lists.expectedToday)],
+ [counts.blockedToday, releaseStays(lists.blockedToday)],
+ [counts.onLeave, leaveStays(lists.onLeave)],
+ ];
+ for (const [value, stays] of pairs) listOk(value, stayBreakdownRows(stays, person));
+ }
+ });
+
+ it("bed board: Pulled, not arrived", () => {
+ for (const unit of state.units) {
+ const pulled = buildIncoming(unit, state.admissions, state.movements, now).filter((p) => p.state === "pulled");
+ const rows = stayBreakdownRows(
+ pulled.map((p) => ({ key: p.key, admissionId: p.key })),
+ person,
+ );
+ listOk(pulled.length, rows);
+ }
+ });
+
+ it("refuses to show a list that does not add up", () => {
+ expect(breakdownView({ value: 3, kind: "people", scope: "own", rows: [{ id: "a", label: "A" }] }).state).toBe(
+ "mismatch",
+ );
+ });
+});
+
+describe("count breakdown: scope", () => {
+ const places = wardPlaceLookup(state.units);
+
+ it("a ward desk lists only its own ward's people and beds", () => {
+ const unit = state.units.find((candidate) =>
+ state.admissions.some((admission) => admission.unitId === candidate.id),
+ )!;
+ const desk: WardAccess = { role: "ward", scope: { kind: "ward", unitIds: [unit.id] } };
+ const scoped = scopeWorkspace(state, desk, { now, places });
+ const releases = derivedBedReleases([...scoped.admissions], now);
+ const lists = capacityBreakdownLists(unit, releases, scoped.leaveBeds, now);
+ const own = new Set(state.admissions.filter((a) => a.unitId === unit.id).map((a) => a.id));
+ for (const record of [...lists.confirmedToday, ...lists.expectedToday, ...lists.blockedToday, ...lists.onLeave])
+ expect(own.has(record.admissionId)).toBe(true);
+ const pulled = buildIncoming(unit, scoped.admissions, scoped.movements, now);
+ for (const p of pulled) expect(own.has(p.key)).toBe(true);
+ // Beds are not people: a ward desk sees the network's ready beds as counts (D-115), so a bed
+ // breakdown lists wards and numbers, never a person.
+ for (const row of wardReadyBreakdownRows(readyBedsByWard(scoped.units, scoped.admissions, scoped.bedReleases)))
+ expect(row.count).toBeGreaterThan(0);
+ });
+
+ it("a site desk's Home ED list holds only journeys its own workspace holds", () => {
+ const siteCode = state.units[0].siteCode;
+ const desk: WardAccess = { role: "bed_manager", scope: { kind: "site", siteCode } };
+ const scoped = scopeWorkspace(state, desk, { now, places });
+ const inScope = new Set(scoped.movements.map((movement) => movement.id));
+ const figure = openEdJourneys(scoped.movements, now);
+ for (const movement of figure.people) expect(inScope.has(movement.id)).toBe(true);
+ expect(scopeWorkspace(state, STATEWIDE_COORDINATOR, { now, places }).movements.length).toBeGreaterThanOrEqual(
+ scoped.movements.length,
+ );
+ });
+});
+
+describe("count breakdown: small numbers and words", () => {
+ const line = CLINICAL_VALUES.smallNumberHidden.value;
+
+ it("hides a list of people under the line only in an aggregate view", () => {
+ expect(breakdownWithheld("people", "aggregate", line - 1)).toBe(true);
+ expect(breakdownWithheld("people", "aggregate", line)).toBe(false);
+ expect(breakdownWithheld("people", "aggregate", 0)).toBe(false);
+ expect(breakdownWithheld("people", "own", 1)).toBe(false);
+ expect(breakdownWithheld("beds", "aggregate", 1)).toBe(false);
+ const rows = [{ id: "a", label: "A" }];
+ expect(breakdownView({ value: 1, kind: "people", scope: "aggregate", rows }).state).toBe("withheld");
+ });
+
+ it("names the button and the panel in plain words", () => {
+ expect(breakdownButtonName(6, "waiting in ED", "people")).toBe("6 waiting in ED, show who");
+ expect(breakdownButtonName(4, "beds ready", "beds")).toBe("4 beds ready, show where");
+ expect(breakdownSentence(6, "open ED movements", 14 * 60 + 32)).toBe("6 open ED movements. Counted 14:32.");
+ });
+});
+
+describe("item 5 stays off the wall TV", () => {
+ it("never opens a count breakdown on a TV screen (figures stay plain there)", () => {
+ const dir = join(process.cwd(), "src/components/ward-management/bed-meeting");
+ const tvFiles = readdirSync(dir).filter((name) => /^(bed-meeting-tv|tv-).*\.tsx?$/u.test(name));
+ expect(tvFiles.length).toBeGreaterThan(0);
+ for (const name of tvFiles) expect(readFileSync(join(dir, name), "utf8"), name).not.toMatch(/count-breakdown/u);
+ });
+});
From 73ccc798fff93311d08ac8929e16a197382d9a4c Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 10 Nov 2026 17:37:28 +0000
Subject: [PATCH 05/30] Record D-137: count leak first, TV change mark 15
minutes, QR library
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GxPPxnKy5cgHmGa8NdCd2U
---
docs/ward-flow/OWNER-RULINGS.md | 8 +++++---
docs/ward-flow/decisions.md | 10 ++++++++++
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/docs/ward-flow/OWNER-RULINGS.md b/docs/ward-flow/OWNER-RULINGS.md
index 0ca875566c..60d3d81bd1 100644
--- a/docs/ward-flow/OWNER-RULINGS.md
+++ b/docs/ward-flow/OWNER-RULINGS.md
@@ -9,7 +9,7 @@ Scanned `docs/ward-flow/decisions.md`, `docs/ward-flow/owner-*.md` and `docs/his
One file matches `*owner*` but not `owner-*` and is deliberately excluded: `how-to-write-to-the-owner.md`
— it is instructions for writing TO the owner, not a record of what he ruled.
-**690 rulings/items extracted, across 43 of 44 files.**
+**691 rulings/items extracted, across 43 of 44 files.**
**0 file(s) UNPARSED** — no recognised ruling structure found; listed, not dropped. See below.
**1 file(s) recorded as holding no ruling of their own**, each with its reason. See below.
@@ -32,7 +32,7 @@ line, in the order discovered — confirm which instance you mean before citing
---
-## ID index — 440 distinct IDs
+## ID index — 441 distinct IDs
So an ID like `D-9` or `O-17.1` can be looked up directly, without knowing which file it lives in.
Bare numbered rulings (files that number "1., 2., 3. …" with no owner-issued letter code) are
@@ -299,6 +299,7 @@ safe global key across 30 files.
| `D-126` | `decisions.md`:1566 | D-126. Ranked lists: neutral orders approved (10 November 2026) |
| `D-127` | `decisions.md`:1585 | D-127. Neutral wording for a community treatment order at arrival (10 November 2026) |
| `D-128` | `decisions.md`:1592 | D-128. Final master audit: nine answers (10 November 2026) |
+| `D-137` | `decisions.md`:1607 | D-137. Feature build batch 5: count leak first, TV change mark and QR library (10 November 2026) |
| `D9-1` | `../history/archive/dated-notes/ward-flow-phase-9-decisions.md`:62 | D9-1 — The wait becomes prominent, and its ceiling is removed; it never outranks urgency (SESSION) |
| `D9-2` | `../history/archive/dated-notes/ward-flow-phase-9-decisions.md`:102 | D9-2 — Declaring escalation records it and marks the screens, and changes nothing else (SESSION, and partly blocked) |
| `D9-3` | `../history/archive/dated-notes/ward-flow-phase-9-decisions.md`:126 | D9-3 — The retrospective is one person's journey replayed, plus the ward prediction track record last (SESSION) |
@@ -607,7 +608,7 @@ safe global key across 30 files.
**Title:** Ward Flow — decision log
-128 item(s):
+129 item(s):
- `D-1` — line 157, heading: "D-1. Ward Flow is local only, with no linked repository (Replaced by D-27)"
- `D-2` — line 167, heading: "D-2. Railway is disconnected"
@@ -737,6 +738,7 @@ safe global key across 30 files.
- `D-126` — line 1566, heading: "D-126. Ranked lists: neutral orders approved (10 November 2026)"
- `D-127` — line 1585, heading: "D-127. Neutral wording for a community treatment order at arrival (10 November 2026)"
- `D-128` — line 1592, heading: "D-128. Final master audit: nine answers (10 November 2026)"
+- `D-137` — line 1607, heading: "D-137. Feature build batch 5: count leak first, TV change mark and QR library (10 November 2026)"
### `../history/archive/dated-notes/ward-management-decisions.md` (2026-08-18 – 2026-10-24 (mixed, see file))
diff --git a/docs/ward-flow/decisions.md b/docs/ward-flow/decisions.md
index fff76d845a..a5384b8a9f 100644
--- a/docs/ward-flow/decisions.md
+++ b/docs/ward-flow/decisions.md
@@ -1603,3 +1603,13 @@ is inferred.
8. **Accessibility:** moderate accessibility findings stay non-blocking; serious and critical still block.
9. **New features:** none from the proposed list until #1564 to #1577 close.
- **Boundary:** synthetic data only. Changes no signed clinical value or clinical rule, and approves no merge or release.
+
+## D-137. Feature build batch 5: count leak first, TV change mark and QR library (10 November 2026)
+
+- **Date:** 10 November 2026, 1:05 AM AWST on 11 November (17:05 UTC on 10 November). **Decided by:** Josh, choosing the recommended option on three decision cards in the "Build counts, screens and print" project thread, and writing "Go ahead with all your recommendations and disregard anything you cannot do" in the feature list thread (17:04 UTC) for the build plan's twelve questions. Numbered D-137 because D-129 to D-136 are claimed by other open PRs and batch threads.
+- **Decision:**
+ 1. **Count leak (plan question 1, item 1):** fixed first, before item 5. The server holds the page whenever it cannot be sure of the desk: only a browser that has said its desk is the whole-network coordinator's (cookie value `0`) gets the server's page. This changes H-0111 for a fresh device, which now sees a short loading page before its first page.
+ 2. **TV change mark (plan question 9, item 43):** "Changed 14:32" shows on a ward or bed meeting TV tile for 15 minutes after its figure changes. Signed for the prototype by Josh as `tvChangeMark` in clinical-values.md.
+ 3. **QR code library (plan question 11, item 42):** one small, well-known QR code library may be added as a dependency, loaded only where the desk QR code is drawn.
+- **Note:** D-128 item 9 said no new features until #1564 to #1577 close. This batch builds because Josh chose the 27 items and asked for the batches to start on 10 November 2026; Josh may say otherwise on the PR.
+- **Boundary:** synthetic data only. Design approval for the drawn parts stays with the batch's design review page (D-74). Approves no merge or release.
From ea234ea13fb70307a40b57cb9285049abfbe8b4c Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 10 Nov 2026 21:13:34 +0000
Subject: [PATCH 06/30] Add a plain-language page to print for the patient or
carer
Item 40 of the feature build plan. On an accepted movement, Print for
patient or carer gives one A4 page: the receiving ward, address and visiting
hours (Not recorded in the synthetic data) and what happens on arrival. It
takes only the ward and hospital, never the patient record. Wording is a
draft awaiting consumer, carer and cultural review.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01GxPPxnKy5cgHmGa8NdCd2U
---
.../movements/patient-carer-page-text.ts | 69 +++++++
.../movements/patient-carer-page.module.css | 175 +++++++++++++++++
.../movements/patient-carer-page.tsx | 183 ++++++++++++++++++
.../patients/patient-journey-extras.tsx | 4 +-
tests/ward-patient-carer-page.dom.test.tsx | 112 +++++++++++
5 files changed, 542 insertions(+), 1 deletion(-)
create mode 100644 src/components/ward-management/movements/patient-carer-page-text.ts
create mode 100644 src/components/ward-management/movements/patient-carer-page.module.css
create mode 100644 src/components/ward-management/movements/patient-carer-page.tsx
create mode 100644 tests/ward-patient-carer-page.dom.test.tsx
diff --git a/src/components/ward-management/movements/patient-carer-page-text.ts b/src/components/ward-management/movements/patient-carer-page-text.ts
new file mode 100644
index 0000000000..7a5e8138de
--- /dev/null
+++ b/src/components/ward-management/movements/patient-carer-page-text.ts
@@ -0,0 +1,69 @@
+/*
+ * Patient and carer page (Batch 5, item 40): the fixed words, kept in this one file so they can be
+ * reviewed in one place.
+ *
+ * DRAFT WORDING. Every sentence here is awaiting consumer, carer and cultural review and must not be
+ * used with real people before that review signs it off. The page footer says so on paper too.
+ *
+ * Rules kept (plan item 40): the page names the receiving ward only. It carries no diagnosis, no risk,
+ * no legal form or status, no staff names, no UMRN and no patient name. `patientCarerPageModel`
+ * takes the ward and its hospital only, so nothing about the person can reach the page.
+ *
+ * Ward Flow holds no ward address or visiting hours in its synthetic data, so both print as
+ * "Not recorded" rather than an invented, real-looking address.
+ */
+
+import type { Site, Unit } from "../ward-model";
+
+export const PATIENT_CARER_NOT_RECORDED = "Not recorded";
+
+export const PATIENT_CARER_TEXT = {
+ entryTitle: "Patient or carer page",
+ entrySub: "One page about the receiving ward in plain words. No health or legal details.",
+ button: "Print for patient or carer",
+ dialogTitle: "Page for patient or carer",
+ dialogDescription: "One page about the ward. It has no health or legal details. Ward Flow does not send it anywhere.",
+ printAction: "Print or save PDF",
+ eyebrow: "Information for you and the people who support you",
+ titleLead: "You are going to",
+ addressLabel: "Address",
+ visitingLabel: "Visiting hours",
+ arriveHeading: "What happens when you arrive",
+ arriveSteps: [
+ "Someone from the ward will meet you and tell you their name.",
+ "They will show you around the ward and where you will sleep.",
+ "They will explain how the ward works, including what you can bring.",
+ "You can ask questions at any time. If something is not clear, ask again.",
+ "You can ask for an interpreter.",
+ ],
+ supportHeading: "For family, carers and friends",
+ supportSteps: [
+ "Please phone the ward before you visit to check the visiting hours.",
+ "You can ask the ward how you can stay in touch.",
+ ],
+ draftFooter: "Draft wording: awaiting consumer, carer and cultural review.",
+ prototypeFooter: "Ward Flow prototype. Synthetic data, not for use with real patients.",
+} as const;
+
+export type PatientCarerPage = {
+ wardName: string;
+ hospitalName: string;
+ address: string;
+ visitingHours: string;
+};
+
+/**
+ * The page's variable facts: the ward's name and its hospital, and nothing about the person. Address
+ * and visiting hours are not in Ward Flow's data, so they read "Not recorded".
+ */
+export function patientCarerPageModel(
+ unit: Pick,
+ site: Pick | undefined,
+): PatientCarerPage {
+ return {
+ wardName: unit.name,
+ hospitalName: site?.name ?? PATIENT_CARER_NOT_RECORDED,
+ address: PATIENT_CARER_NOT_RECORDED,
+ visitingHours: PATIENT_CARER_NOT_RECORDED,
+ };
+}
diff --git a/src/components/ward-management/movements/patient-carer-page.module.css b/src/components/ward-management/movements/patient-carer-page.module.css
new file mode 100644
index 0000000000..0c16a242f3
--- /dev/null
+++ b/src/components/ward-management/movements/patient-carer-page.module.css
@@ -0,0 +1,175 @@
+/* Patient and carer page (patient-carer-page.tsx). On screen a paper preview in the dialog that
+ * follows the theme; on paper ink on white, A4, from the shared print isolation. Larger plain type
+ * than staff documents, as it is read by patients and carers. */
+
+/* The entry beside the handover pack, in the same card shape. */
+.entry {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--wf-s-2) var(--wf-s-4);
+ padding: var(--wf-s-3) var(--wf-s-4);
+ border: 1px solid var(--wf-line);
+ border-radius: var(--wf-r-lg);
+ background: var(--wf-surface);
+ box-shadow: var(--wf-e1);
+}
+
+.entryText {
+ flex: 1 1 16rem;
+ min-width: 0;
+}
+
+.entryTitle {
+ margin: 0;
+ color: var(--wf-ink-1);
+ font: 600 var(--wf-fs-15) / 1.3 var(--wf-font);
+}
+
+.entrySub {
+ margin: 2px 0 0;
+ color: var(--wf-ink-2);
+ font: 400 var(--wf-fs-12) / var(--wf-lh-12) var(--wf-font);
+}
+
+.paper {
+ display: grid;
+ gap: var(--wf-s-4);
+ padding: var(--wf-s-4);
+ border: 1px solid var(--wf-line);
+ border-radius: var(--wf-r-lg);
+ background: var(--wf-surface);
+ color: var(--wf-ink-1);
+ font-family: var(--wf-font);
+ font-size: var(--wf-fs-15);
+ line-height: 1.5;
+}
+
+.head {
+ padding-bottom: var(--wf-s-3);
+ border-bottom: 2px solid var(--wf-ink-1);
+}
+
+.eyebrow {
+ margin: 0;
+ color: var(--wf-ink-2);
+ font-size: var(--wf-fs-13);
+ font-weight: 600;
+}
+
+.title {
+ margin: var(--wf-s-1) 0 0;
+ font-size: var(--wf-fs-20);
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.ward {
+ font-weight: 700;
+}
+
+.hospital {
+ margin: var(--wf-s-1) 0 0;
+ font-size: var(--wf-fs-16);
+}
+
+.facts {
+ display: grid;
+ gap: var(--wf-s-2);
+ margin: 0;
+}
+
+.fact {
+ display: grid;
+ grid-template-columns: minmax(8rem, 12rem) minmax(0, 1fr);
+ gap: var(--wf-s-2);
+}
+
+.fact dt {
+ font-weight: 600;
+}
+
+.fact dd {
+ margin: 0;
+}
+
+.section {
+ display: grid;
+ gap: var(--wf-s-2);
+}
+
+.sectionHead {
+ margin: 0;
+ font-size: var(--wf-fs-16);
+ font-weight: 600;
+}
+
+.list {
+ display: grid;
+ gap: var(--wf-s-2);
+ margin: 0;
+ padding-left: 1.25rem;
+}
+
+.footer {
+ display: grid;
+ gap: var(--wf-s-1);
+ padding-top: var(--wf-s-3);
+ border-top: 1px solid var(--wf-line);
+ color: var(--wf-ink-2);
+ font-size: var(--wf-fs-12);
+}
+
+.footer p {
+ margin: 0;
+}
+
+.draft {
+ font-weight: 600;
+}
+
+@media (max-width: 600px) {
+ .fact {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0;
+ }
+}
+
+@media print {
+ .paper {
+ gap: 6mm;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: var(--wf-print-ink);
+ font-size: 13pt;
+ }
+
+ .head {
+ border-bottom-color: var(--wf-print-ink);
+ }
+
+ .eyebrow,
+ .footer {
+ color: var(--wf-print-muted);
+ }
+
+ .title {
+ font-size: 20pt;
+ }
+
+ .hospital,
+ .sectionHead {
+ font-size: 15pt;
+ }
+
+ .footer {
+ border-top-color: var(--wf-print-rule);
+ font-size: 9pt;
+ }
+
+ .section {
+ break-inside: avoid;
+ }
+}
diff --git a/src/components/ward-management/movements/patient-carer-page.tsx b/src/components/ward-management/movements/patient-carer-page.tsx
new file mode 100644
index 0000000000..8804a76163
--- /dev/null
+++ b/src/components/ward-management/movements/patient-carer-page.tsx
@@ -0,0 +1,183 @@
+"use client";
+
+/*
+ * Patient and carer page (Batch 5, item 40): "Print for patient or carer" on an accepted movement.
+ * One A4 page about the receiving ward in plain words. It is printed or saved by a person only;
+ * nothing is sent. Draft wording, awaiting consumer, carer and cultural review (see the text file).
+ *
+ * The paper takes the ward and its hospital only (`patientCarerPageModel`), so no diagnosis, risk,
+ * legal form, staff name, UMRN or patient name can reach it.
+ *
+ * Print isolation is the handover pack's (patient-handover-pack-button.tsx): everything off the
+ * paper's own ancestor line is taken out, so the patient page behind the dialog (name, UMRN, legal
+ * status) can never print with it. The shared `[data-print-output]` rule only hides by visibility,
+ * which the patient page's own print rules override, so it is not used here.
+ */
+
+import { useEffect, useSyncExternalStore } from "react";
+import { Printer } from "lucide-react";
+
+import { Button, Dialog } from "@/components/wf";
+
+import type { Movement, Unit } from "../ward-model";
+import { siteByCode } from "../ward-sites";
+import { PATIENT_CARER_TEXT as T, patientCarerPageModel, type PatientCarerPage } from "./patient-carer-page-text";
+import styles from "./patient-carer-page.module.css";
+
+/** Stages after a ward has accepted and before arrival: the page is useful while the move is ahead. */
+const ACCEPTED_STAGES: ReadonlySet = new Set([
+ "accepted_awaiting_bed",
+ "pulled",
+ "handover_ready",
+ "moving",
+]);
+
+/** The page for this movement, or undefined when no ward has accepted it or it is closed or done. */
+export function patientCarerPageFor(
+ movement: Pick,
+ units: readonly Unit[],
+): PatientCarerPage | undefined {
+ if (movement.closure || !ACCEPTED_STAGES.has(movement.stage) || !movement.acceptedUnitId) return undefined;
+ const unit = units.find((entry) => entry.id === movement.acceptedUnitId);
+ if (!unit) return undefined;
+ return patientCarerPageModel(unit, siteByCode(unit.siteCode));
+}
+
+/** The printed page itself. Fixed words come from `patient-carer-page-text.ts`. */
+export function PatientCarerPaper({ page }: { page: PatientCarerPage }) {
+ return (
+
+
+
+
{T.eyebrow}
+
+ {T.titleLead} {page.wardName}
+
+
{page.hospitalName}
+
+
+
+
{T.addressLabel}
+
{page.address}
+
+
+
{T.visitingLabel}
+
{page.visitingHours}
+
+
+
+
+ {T.arriveHeading}
+
+
+ {T.arriveSteps.map((step) => (
+
{step}
+ ))}
+
+
+
+
+ {T.supportHeading}
+
+
+ {T.supportSteps.map((step) => (
+
{step}
+ ))}
+
+
+
+
+ );
+}
+
+/*
+ * Which movement's page is open, held in this tab's memory only (D-72: nothing on the device). The
+ * patient page lays itself out again at the print width (a tablet-width layout), which remounts this
+ * entry; keeping the open state here, not in the component, keeps the page in the dialog for print.
+ */
+let openMovementId: string | null = null;
+const listeners = new Set<() => void>();
+function setOpenMovement(id: string | null) {
+ openMovementId = id;
+ for (const listener of listeners) listener();
+}
+/** Entries on screen per movement: leaving the page (no entry left a moment later) closes it. */
+const mounted = new Map();
+function useMountedEntry(id: string) {
+ useEffect(() => {
+ mounted.set(id, (mounted.get(id) ?? 0) + 1);
+ return () => {
+ mounted.set(id, (mounted.get(id) ?? 1) - 1);
+ window.setTimeout(() => {
+ if (!mounted.get(id) && openMovementId === id) setOpenMovement(null);
+ }, 1000);
+ };
+ }, [id]);
+}
+function subscribeOpen(listener: () => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+/** The entry on an accepted movement, opening the page with Print. Shows nothing otherwise. */
+export function PatientCarerPageButton({
+ movement,
+ units,
+}: {
+ movement: Pick;
+ units: readonly Unit[];
+}) {
+ const open = useSyncExternalStore(
+ subscribeOpen,
+ () => openMovementId === movement.id,
+ () => false,
+ );
+ useMountedEntry(movement.id);
+ const page = patientCarerPageFor(movement, units);
+ if (!page) return null;
+ return (
+
+
+
+ {T.entryTitle}
+
+
{T.entrySub}
+
+
+
+
+ );
+}
+
+/** A4 portrait, the paper alone. Keyed by the paper's data attribute, as on the handover pack. */
+const PRINT_CSS = `@page { size: A4 portrait; margin: 18mm; }
+@media print {
+ html, body { margin: 0 !important; padding: 0 !important; height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; }
+ body *:not(:has([data-wf-print-carer])):not([data-wf-print-carer]):not([data-wf-print-carer] *) { display: none !important; }
+ body *:has([data-wf-print-carer]) { position: static !important; transform: none !important; overflow: visible !important; height: auto !important; max-height: none !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; border: 0 !important; box-shadow: none !important; contain: none !important; background: none !important; visibility: visible !important; display: block !important; }
+ [data-wf-print-carer], [data-wf-print-carer] * { visibility: visible !important; }
+ [data-wf-print-carer] { display: grid !important; position: static !important; width: 100% !important; color: #000 !important; background: #fff !important; }
+}`;
diff --git a/src/components/ward-management/patients/patient-journey-extras.tsx b/src/components/ward-management/patients/patient-journey-extras.tsx
index 9c7167310a..deb7b3066d 100644
--- a/src/components/ward-management/patients/patient-journey-extras.tsx
+++ b/src/components/ward-management/patients/patient-journey-extras.tsx
@@ -20,6 +20,7 @@ import { recordExtrasFor } from "./patient-record-extras";
import { handoverPackAvailable, type HandoverContact, type HandoverPackInput } from "./patient-handover-pack";
import { PatientHandoverPackButton } from "./patient-handover-pack-button";
import { PatientHoursBreakdown } from "./patient-hours-breakdown";
+import { PatientCarerPageButton } from "../movements/patient-carer-page";
function ageSexWords(patient: Patient | undefined, dayZero: Date, now: Instant): string {
if (!patient) return "";
@@ -46,7 +47,7 @@ export function PatientJourneyExtras({
riskFlags: HandoverPackInput["riskFlags"];
now: Instant;
}) {
- const { referrals, dayZero } = useWardFlowSelect("referrals", "dayZero");
+ const { referrals, dayZero, units } = useWardFlowSelect("referrals", "dayZero", "units");
let pack: HandoverPackInput | undefined;
if (handoverPackAvailable(movement)) {
const edContacts: HandoverContact[] = callRows(
@@ -90,6 +91,7 @@ export function PatientJourneyExtras({
<>
{pack ? : null}
+
>
);
}
diff --git a/tests/ward-patient-carer-page.dom.test.tsx b/tests/ward-patient-carer-page.dom.test.tsx
new file mode 100644
index 0000000000..03c6cc2f7a
--- /dev/null
+++ b/tests/ward-patient-carer-page.dom.test.tsx
@@ -0,0 +1,112 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+
+import {
+ PatientCarerPageButton,
+ PatientCarerPaper,
+ patientCarerPageFor,
+} from "@/components/ward-management/movements/patient-carer-page";
+import {
+ PATIENT_CARER_NOT_RECORDED,
+ PATIENT_CARER_TEXT,
+} from "@/components/ward-management/movements/patient-carer-page-text";
+import { DESK_PERSONAS } from "@/components/ward-management/shell/ward-desk-personas";
+import { seedWardFlowState } from "@/components/ward-management/ward-flow-reducer";
+
+/** Words that would mean a health, risk or legal detail reached the page (synthetic seed only). */
+const BLOCKED_WORDS = [
+ "diagnos",
+ "risk",
+ "umrn",
+ "form ",
+ "mental health act",
+ "involuntary",
+ "voluntary",
+ "detained",
+ "inpatient",
+ "observation",
+ "aggress",
+ "suicid",
+ "self-harm",
+ "abscond",
+ "psychos",
+ "schizo",
+ "bipolar",
+ "dr ",
+];
+
+const state = seedWardFlowState();
+const accepted = state.movements.filter((movement) => patientCarerPageFor(movement, state.units) !== undefined);
+
+function textOf(movement: (typeof accepted)[number]): string {
+ const page = patientCarerPageFor(movement, state.units)!;
+ const html = renderToStaticMarkup();
+ return html
+ .replace(/