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 =