From e6f2b816b10d5e79a1c448ee045400ba8364b841 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 03:59:42 +0000 Subject: [PATCH 01/40] Record D-144: repository review fixes, features and code health approved Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- docs/ward-flow/decisions.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/ward-flow/decisions.md b/docs/ward-flow/decisions.md index 015b6e31a..1ef6a739c 100644 --- a/docs/ward-flow/decisions.md +++ b/docs/ward-flow/decisions.md @@ -156,6 +156,7 @@ Checked 9 November 2026 (D-107 audit). "Partly replaced" means part of the entry | [D-131](#d-131-hard-case-demo-data-allowed-and-the-movement-diagram-follows-the-engine-10-november-2026) | Hard-case demo data allowed, and the movement diagram follows the engine | Active | | [D-132](#d-132-audit-fixes-group-1-docs-and-ci-guards-10-november-2026) | Audit fixes group 1: docs and CI guards; Claude-authored sensitive changes warn | Active | | [D-138](#d-138-phone-alert-urgency-by-kind-10-november-2026) | Phone alert urgency by kind | Active | +| [D-144](#d-144-repository-review-fixes-features-and-code-health-approved-11-november-2026) | Repository review: fixes, features and code health approved | Active | --- @@ -1699,3 +1700,15 @@ is inferred. - **Date:** 10 November 2026 (`2026-11-10`). **Decided by:** Josh, picking item 23 of the audit fix build plan (Ward Flow project thread "Fix audit app and backend issues", audit fix group 2), which counts as his yes to audit decision 20. Numbered D-138 because D-132 to D-137 belong to other threads. D-133 is claimed by open PR #1606. D-134 is claimed by open PR #1608, and D-135 is claimed by open PR #1603, and D-136 is claimed by open PR #1605, and D-137 is claimed by open PR #1607. On 11 November 2026 Josh told every thread to go ahead with its recommendations, which approved the Settings line as drawn in the design review. - **Decision:** act-now phone alerts stay high urgency and buzz again when replaced. Jobs and team alerts go out at normal urgency and replace their earlier alert quietly. Settings shows when this device last received an alert, from the time only (never the alert's words). - **Boundary:** synthetic data only. Changes no signed clinical value or clinical rule, and approves no merge or release. + +## D-144. Repository review: fixes, features and code health approved (11 November 2026) + +- **Date:** 11 November 2026 (`2026-11-11`). **Decided by:** Josh, in the Ward Flow project thread "Review repository for issues and new features": "Go ahead with all these recommendations please" (03:55 UTC), answering the review doc "Ward Flow repository review, 11 November 2026". Numbered D-144 because D-133 to D-143 belong to other threads. D-139 is claimed by open PR #1600, D-140 is claimed by open PR #1606, D-141 is claimed by open PR #1606, D-142 is claimed by open PR #1603, and D-143 is claimed by open PR #1612. +- **Decision:** + 1. **Clinical display fixes, one held PR:** a ward's expected occupancy no longer counts a patient twice when they hold a named bed and also have a booked planned admission to that ward; an ended legal form says it ended and gives the expiry only as it was typed; the ED screen flags a long wait at 24 hours or more, like every other screen. The held PR is shared with the performance thread's triage date fix. + 2. **Non-clinical fixes:** one lapse check for pull holds, staff requests keep the shift the person chose, one reader for typed times, one length-of-stay calculation, a demo code shorter than 12 characters counts as no code, and a demo pass stops working when the code changes. Two lines added to the real-data go/no-go list (record ids never built from UMRNs; a check of the push library). + 3. **Features that finish existing screens** (not covered by the D-128 item 9 pause): Leave page links, print and entry point; Help for pages that had none; print and spreadsheet export on Legal forms and Movements. Triage risk reviews due waits until #1564 to #1577 close and needs its own clinical sign-off. + 4. **Code health:** the file line cap counts code lines, not comments or blank lines; one shared CI setup step; shared helpers for the forked side rail; reasons or fixes for unexplained React hook rule switch-offs; patch-level dependency updates. + 5. **Josh's own action:** turn off Supabase open sign-up before real data. +- **Why:** findings from the 11 November review, each checked against source; the clinical items were display bugs, not changes to signed values. +- **Boundary:** synthetic data only. Changes no signed clinical value; clinical items merge only after Josh checks the held PR. Approves no release. From 7faea5d2f271ad76e1fe6c2359bfdc74e947a4f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:00:49 +0000 Subject: [PATCH 02/40] Count only code lines for the max-lines cap (D-144) Blank lines and comment-only lines no longer count towards the file size cap, so commits stop deleting spacing and safety comments to fit. The per-file caps are re-set in code lines with the headroom each file already had; five files are now under the default and come off the list. The baseline test counts lines with ESLint's own rule, so it matches the lint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- eslint-rules/max-lines-baseline.mjs | 25 ++++++++++-------- eslint.config.mjs | 9 ++++--- tests/max-lines-baseline.test.ts | 40 ++++++++++++++++++++++++----- 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/eslint-rules/max-lines-baseline.mjs b/eslint-rules/max-lines-baseline.mjs index 8e8dcc8a3..22b4deeaa 100644 --- a/eslint-rules/max-lines-baseline.mjs +++ b/eslint-rules/max-lines-baseline.mjs @@ -28,19 +28,22 @@ // 10 Nov 2026 (Tier 7 audit A5-05): the cap now covers the backend's source files too. postgres.mjs // was split by area into workspace-*.mjs; its remaining core (the record cache, reads, saves, // archive and the command path) gets a cap of its size plus about 150 lines. +// 11 Nov 2026 (D-144, Josh approved): the cap counts code lines only. Blank lines and comment-only +// lines no longer count (MAX_LINES_COUNTING), so nobody deletes spacing or safety comments to fit. +// The caps were in raw lines; each is now re-set to the file's code lines plus the same +// headroom it had under raw counting, so no file gains room to grow. ward-model, ward-movements, +// ward-flow-events, ward-patients-seed and postgres.mjs are under the default in code lines and +// came off the list. export const MAX_LINES_DEFAULT = 1500; export const MAX_LINES_HEADROOM = 300; +// ESLint max-lines options shared by eslint.config.mjs and tests/max-lines-baseline.test.ts. +export const MAX_LINES_COUNTING = { skipBlankLines: true, skipComments: true }; export const MAX_LINES_BASELINE = { - "src/components/ward-management/ward-flow-reducer.ts": 2560, - "src/components/ward-management/ward/ward-screen.tsx": 2580, - "src/components/ward-management/ward-model.ts": 1900, - "src/components/ward-management/ward-movements.ts": 2070, - "src/components/ward-management/ward-flow-events.ts": 2750, - "src/components/ward-management/coordinator/shortlist-panel.tsx": 2850, - "src/components/ward-management/movements/movement-workspace-cockpit.tsx": 2150, - "src/components/ward-management/ward/ward-home-tab.tsx": 2150, - "src/components/ward-management/ward-patients-seed.ts": 1800, - "src/components/ward-management/ward/ward-answer-view.tsx": 1750, - "backend/ward-flow/postgres.mjs": 1860, + "src/components/ward-management/ward-flow-reducer.ts": 1854, + "src/components/ward-management/ward/ward-screen.tsx": 2174, + "src/components/ward-management/coordinator/shortlist-panel.tsx": 2256, + "src/components/ward-management/movements/movement-workspace-cockpit.tsx": 2059, + "src/components/ward-management/ward/ward-home-tab.tsx": 1991, + "src/components/ward-management/ward/ward-answer-view.tsx": 1630, }; diff --git a/eslint.config.mjs b/eslint.config.mjs index 458f55917..fb5c718ef 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,7 +7,7 @@ import requireButtonWiring from "./eslint-rules/require-button-wiring.mjs"; import requireZIndexLadder from "./eslint-rules/require-z-index-ladder.mjs"; import restrictSuppressHydrationWarning from "./eslint-rules/restrict-suppress-hydration-warning.mjs"; import noHardcodedHex from "./eslint-rules/no-hardcoded-hex.mjs"; -import { MAX_LINES_BASELINE, MAX_LINES_DEFAULT } from "./eslint-rules/max-lines-baseline.mjs"; +import { MAX_LINES_BASELINE, MAX_LINES_COUNTING, MAX_LINES_DEFAULT } from "./eslint-rules/max-lines-baseline.mjs"; // Shared `local` plugin object. ESLint flat config requires every config block // that references a plugin namespace to point at the *same* object, and the five @@ -179,10 +179,11 @@ const eslintConfig = defineConfig([ }, // File size ratchet. New source files stay under MAX_LINES_DEFAULT lines, and the files that // were already larger cannot grow much past their baseline (eslint-rules/max-lines-baseline.mjs). + // Only code lines count: blank lines and comment-only lines are free (MAX_LINES_COUNTING, D-144). { files: ["src/**/*.{ts,tsx}"], rules: { - "max-lines": ["error", { max: MAX_LINES_DEFAULT }], + "max-lines": ["error", { ...MAX_LINES_COUNTING, max: MAX_LINES_DEFAULT }], }, }, // The backend's source files too (Tier 7 audit A5-05); its tests are left out, as src's are. @@ -190,13 +191,13 @@ const eslintConfig = defineConfig([ files: ["backend/ward-flow/*.mjs"], ignores: ["backend/ward-flow/*.test.mjs", "backend/ward-flow/*.test-support.mjs"], rules: { - "max-lines": ["error", { max: MAX_LINES_DEFAULT }], + "max-lines": ["error", { ...MAX_LINES_COUNTING, max: MAX_LINES_DEFAULT }], }, }, ...Object.entries(MAX_LINES_BASELINE).map(([file, max]) => ({ files: [file], rules: { - "max-lines": ["error", { max }], + "max-lines": ["error", { ...MAX_LINES_COUNTING, max }], }, })), // A lucide-react icon rendered as JSX must be decorative (aria-hidden) or diff --git a/tests/max-lines-baseline.test.ts b/tests/max-lines-baseline.test.ts index 63cd7016e..d99d1c37e 100644 --- a/tests/max-lines-baseline.test.ts +++ b/tests/max-lines-baseline.test.ts @@ -1,11 +1,39 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { MAX_LINES_BASELINE, MAX_LINES_DEFAULT, MAX_LINES_HEADROOM } from "../eslint-rules/max-lines-baseline.mjs"; +import { ESLint } from "eslint"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + MAX_LINES_BASELINE, + MAX_LINES_COUNTING, + MAX_LINES_DEFAULT, + MAX_LINES_HEADROOM, +} from "../eslint-rules/max-lines-baseline.mjs"; -const lineCount = (file: string) => readFileSync(file, "utf8").split("\n").length; +// Count lines the way the lint cap does (code lines only, D-144): run ESLint's own max-lines rule +// with a cap of 0 on each listed file and read the line count from its message. +async function codeLineCounts(files: string[]) { + const eslint = new ESLint({ + cwd: process.cwd(), + cache: false, + ruleFilter: ({ ruleId }) => ruleId === "max-lines", + overrideConfig: { rules: { "max-lines": ["error", { ...MAX_LINES_COUNTING, max: 0 }] } }, + }); + const counts = new Map(); + for (const result of await eslint.lintFiles(files)) { + const message = result.messages.find((m) => m.ruleId === "max-lines"); + const count = Number(message?.message.match(/\((\d+)\)/)?.[1]); + if (!Number.isInteger(count)) throw new Error(`no max-lines count for ${result.filePath}`); + counts.set(files.find((file) => result.filePath.endsWith(file)) ?? result.filePath, count); + } + return counts; +} describe("file size caps follow the files down", () => { const entries = Object.entries(MAX_LINES_BASELINE) as [string, number][]; + let lineCount: (file: string) => number; + + beforeAll(async () => { + const counts = await codeLineCounts(entries.map(([file]) => file)); + lineCount = (file) => counts.get(file) ?? Number.NaN; + }, 60_000); it("keeps an entry only for a file still over the default cap", () => { const backUnder = entries.filter(([file]) => lineCount(file) <= MAX_LINES_DEFAULT).map(([file]) => file); @@ -14,8 +42,8 @@ describe("file size caps follow the files down", () => { it("keeps each cap within the headroom of its file", () => { const loose = entries - .filter(([file, cap]) => cap - lineCount(file) > MAX_LINES_HEADROOM) - .map(([file, cap]) => `${file}: cap ${cap}, file ${lineCount(file)} lines`); + .filter(([file, cap]) => !(cap - lineCount(file) <= MAX_LINES_HEADROOM)) + .map(([file, cap]) => `${file}: cap ${cap}, file ${lineCount(file)} code lines`); expect(loose, "lower these caps in eslint-rules/max-lines-baseline.mjs").toEqual([]); }); }); From c9361c3741d5c2dd1c314ea2ba33a24a183b7b15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:01:09 +0000 Subject: [PATCH 03/40] D-144: drop the shared triage date note (already fixed on main by #1599) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- docs/ward-flow/decisions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ward-flow/decisions.md b/docs/ward-flow/decisions.md index 1ef6a739c..c1c659528 100644 --- a/docs/ward-flow/decisions.md +++ b/docs/ward-flow/decisions.md @@ -1705,7 +1705,7 @@ is inferred. - **Date:** 11 November 2026 (`2026-11-11`). **Decided by:** Josh, in the Ward Flow project thread "Review repository for issues and new features": "Go ahead with all these recommendations please" (03:55 UTC), answering the review doc "Ward Flow repository review, 11 November 2026". Numbered D-144 because D-133 to D-143 belong to other threads. D-139 is claimed by open PR #1600, D-140 is claimed by open PR #1606, D-141 is claimed by open PR #1606, D-142 is claimed by open PR #1603, and D-143 is claimed by open PR #1612. - **Decision:** - 1. **Clinical display fixes, one held PR:** a ward's expected occupancy no longer counts a patient twice when they hold a named bed and also have a booked planned admission to that ward; an ended legal form says it ended and gives the expiry only as it was typed; the ED screen flags a long wait at 24 hours or more, like every other screen. The held PR is shared with the performance thread's triage date fix. + 1. **Clinical display fixes, one held PR:** a ward's expected occupancy no longer counts a patient twice when they hold a named bed and also have a booked planned admission to that ward; an ended legal form says it ended and gives the expiry only as it was typed; the ED screen flags a long wait at 24 hours or more, like every other screen. 2. **Non-clinical fixes:** one lapse check for pull holds, staff requests keep the shift the person chose, one reader for typed times, one length-of-stay calculation, a demo code shorter than 12 characters counts as no code, and a demo pass stops working when the code changes. Two lines added to the real-data go/no-go list (record ids never built from UMRNs; a check of the push library). 3. **Features that finish existing screens** (not covered by the D-128 item 9 pause): Leave page links, print and entry point; Help for pages that had none; print and spreadsheet export on Legal forms and Movements. Triage risk reviews due waits until #1564 to #1577 close and needs its own clinical sign-off. 4. **Code health:** the file line cap counts code lines, not comments or blank lines; one shared CI setup step; shared helpers for the forked side rail; reasons or fixes for unexplained React hook rule switch-offs; patch-level dependency updates. From dd75187d213a7747b2e5d85d818425545e138789 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:02:17 +0000 Subject: [PATCH 04/40] Give every React hook rule switch-off its reason after "--" (M145) The 13 react-hooks/exhaustive-deps switch-offs whose reason sat on the line above now carry it on the directive, naming the dependencies left out. Each effect is deliberately once-only or keyed on a token, so adding the missing dependencies would change when it runs; none is removed. Comments only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../ward-management/ed/use-ed-screen-decline.ts | 4 +--- .../ward-management/front-door/use-front-door-auth.ts | 3 +-- src/components/ward-management/patients/add-patient.tsx | 9 +++------ .../ward-management/referrals/referral-draft-hooks.ts | 3 +-- .../referrals/referral-information-needs-lazy.tsx | 3 +-- .../restrictive-practice/seclusion-forms-view.tsx | 3 +-- src/components/ward-management/use-undoable-step.ts | 3 +-- src/components/ward-management/ward/ward-screen.tsx | 9 +++------ src/components/ward-management/ward/ward-walk-round.tsx | 3 +-- 9 files changed, 13 insertions(+), 27 deletions(-) diff --git a/src/components/ward-management/ed/use-ed-screen-decline.ts b/src/components/ward-management/ed/use-ed-screen-decline.ts index 49876ca24..c7584ffc4 100644 --- a/src/components/ward-management/ed/use-ed-screen-decline.ts +++ b/src/components/ward-management/ed/use-ed-screen-decline.ts @@ -112,9 +112,7 @@ export function useEdInboxDecline({ } pendingDeclineRef.current = undefined; priorRejectionCountRef.current = rejections.length; - // `offerStep` is read at the moment the check runs; the check itself is keyed - // on the dispatch token so it runs once per decline. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- `offerStep` is read when the check runs; the check is keyed on the dispatch token so it runs once per decline. }, [rejections, declineCheckToken]); function toggleDecline(referralId: string) { diff --git a/src/components/ward-management/front-door/use-front-door-auth.ts b/src/components/ward-management/front-door/use-front-door-auth.ts index 532f3b2a3..de50c3172 100644 --- a/src/components/ward-management/front-door/use-front-door-auth.ts +++ b/src/components/ward-management/front-door/use-front-door-auth.ts @@ -128,8 +128,7 @@ export function useFrontDoorAuth(next: string): { phase: Phase; form: WardCreden auth.current?.dispose(); auth.current = null; }; - // `next` is fixed for the page's life: the server read it from the address. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- Once per page: `open` closes over `next`, which is fixed for the page's life (the server read it from the address). }, []); const run = async (step: (adapter: WardBrowserAuthAdapter) => Promise) => { diff --git a/src/components/ward-management/patients/add-patient.tsx b/src/components/ward-management/patients/add-patient.tsx index 6f54c0a5c..fc6961d47 100644 --- a/src/components/ward-management/patients/add-patient.tsx +++ b/src/components/ward-management/patients/add-patient.tsx @@ -312,8 +312,7 @@ export function AddPatientForm() { return input instanceof HTMLInputElement && input.value === ""; }); if (first) document.getElementById(`ward-add-patient-${first}`)?.focus(); - // Only once, on open: later renders never take the cursor away from where someone put it. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only once, on open (`isPhone` as first rendered): later renders never take the cursor away from where someone put it. }, []); const priorSubmissionRef = useRef<{ patients: typeof patients; @@ -525,8 +524,7 @@ export function AddPatientForm() { return; } router.push(patientHref(added.id)); - // findAdded reads `patients`, which is listed. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- `findAdded` reads `patients`, which is listed; `findAdded` and `formSnapshot` are new each render and read this render's form. }, [acceptedSubmission, patients, worldGeneration, router, toast, isDirty]); // Latest values for the outside-view timer, which must not restart on every snapshot. @@ -561,8 +559,7 @@ export function AddPatientForm() { toast?.push({ tone: "success", title: saved, body: note }); }, SHARED_REFRESH_MS); return () => window.clearTimeout(timer); - // clearForm and finishSubmission only set state; the timer starts once per accepted save. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- `clearForm` and `finishSubmission` only set state; the timer starts once per accepted save. }, [acceptedSubmission, worldGeneration, toast]); function handleSubmit(event: FormEvent) { diff --git a/src/components/ward-management/referrals/referral-draft-hooks.ts b/src/components/ward-management/referrals/referral-draft-hooks.ts index a21a5559c..2c58d2d36 100644 --- a/src/components/ward-management/referrals/referral-draft-hooks.ts +++ b/src/components/ward-management/referrals/referral-draft-hooks.ts @@ -171,8 +171,7 @@ export function useReferralDraftAutosave({ keepReferralDraft(current.draft, ownerVersion, current.storageKey); } }, - // `reopened` is fixed for the sheet's life. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- Unmount only: `reopened`, `ownerVersion` and `sessionOwner` are fixed for the sheet's life. [], ); diff --git a/src/components/ward-management/referrals/referral-information-needs-lazy.tsx b/src/components/ward-management/referrals/referral-information-needs-lazy.tsx index cbc611935..14e1616c7 100644 --- a/src/components/ward-management/referrals/referral-information-needs-lazy.tsx +++ b/src/components/ward-management/referrals/referral-information-needs-lazy.tsx @@ -84,7 +84,6 @@ export function useMarkReferralsOpened( if (outcome && outcome.status !== "accepted") forget(); }, forget); } - // `raise` carries the time a referral first appears; a clock tick alone does not re-run this. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- `raise` carries the time a referral first appears; a clock tick alone does not re-run this. }, [enabled, routeRole, actingUnitId, unitId, unopened]); } diff --git a/src/components/ward-management/restrictive-practice/seclusion-forms-view.tsx b/src/components/ward-management/restrictive-practice/seclusion-forms-view.tsx index 535c1a986..56db2bb38 100644 --- a/src/components/ward-management/restrictive-practice/seclusion-forms-view.tsx +++ b/src/components/ward-management/restrictive-practice/seclusion-forms-view.tsx @@ -198,8 +198,7 @@ export function SeclusionFormsView({ const list = roomsRef.current; list?.scrollIntoView?.({ block: "start", behavior: "smooth" }); list?.querySelector("button")?.focus({ preventScroll: true }); - // Only a new ask moves focus; the rooms list changing under it does not. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only a new ask (`ask.n`) moves focus; the rooms list or `onAskSeen` changing under it does not. }, [ask?.n]); // Ward desks cannot open Governance, so the last day's incidents stay readable here. const seesGovernance = ROLE_SCREENS[access.role].governance !== undefined; diff --git a/src/components/ward-management/use-undoable-step.ts b/src/components/ward-management/use-undoable-step.ts index 5638f9251..c8ca9fb08 100644 --- a/src/components/ward-management/use-undoable-step.ts +++ b/src/components/ward-management/use-undoable-step.ts @@ -264,8 +264,7 @@ export function useUndoableStep({ }), }); } - // The token drives this; `sink`, `dispatch` and the callbacks are read as they are now. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- The token drives this; `sink`, `routeRole` and the callbacks are read as they are now. }, [token, rejections]); function submit(event: WardFlowEvent, pending: Pending, record?: () => ReturnType) { diff --git a/src/components/ward-management/ward/ward-screen.tsx b/src/components/ward-management/ward/ward-screen.tsx index 171451b97..7725eb952 100644 --- a/src/components/ward-management/ward/ward-screen.tsx +++ b/src/components/ward-management/ward/ward-screen.tsx @@ -563,8 +563,7 @@ function WardOverviewScreen({ unitId, presentation = "overview", departurePlanni }, }), }); - // `toast`, `routeRole`, `dispatch` and `unitId` are stable for the screen's life; the token drives this. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- `toast`, `routeRole`, `dispatch` and `unitId` are stable for the screen's life and the command helpers are read as they are now; the token drives this. }, [dischargeToken, rejections]); // Board feedback follows this command, including its Undo. @@ -670,8 +669,7 @@ function WardOverviewScreen({ unitId, presentation = "overview", departurePlanni }), }); } - // `toast`, `routeRole` and `dispatch` are stable for the screen's life; the token drives this. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- `toast`, `routeRole` and `dispatch` are stable for the screen's life and the command helpers are read as they are now; the token drives this. }, [boardToken, rejections]); useEffect(() => { @@ -936,8 +934,7 @@ function WardOverviewScreen({ unitId, presentation = "overview", departurePlanni }), }); } - // Read the committed local fallback only after this command's completion. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- Runs once per command completion (the token); `movements` and the helpers are read as committed then, not on every change. }, [rejections, checkToken]); const { pendingBedReleases, unitLeaveBeds, incoming, accepted, heldAfterStop, recentAnswers, withdrawn } = diff --git a/src/components/ward-management/ward/ward-walk-round.tsx b/src/components/ward-management/ward/ward-walk-round.tsx index 4c4f28d40..0384aad52 100644 --- a/src/components/ward-management/ward/ward-walk-round.tsx +++ b/src/components/ward-management/ward/ward-walk-round.tsx @@ -261,8 +261,7 @@ export function WardWalkRound({ } if (was !== undefined) headRef.current?.querySelector("[data-walk-start]")?.focus(); } - // Only a change of bed speaks; `allReviewed` is read as it is at that moment. - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only a change of bed speaks; `allReviewed` is read as it is at that moment. }, [currentNumber]); // Integration QA (3 Nov 2026): Escape closes the card like the close button, and the effect From 2f7fff63741014e872706256b7dd11adba672b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:06:24 +0000 Subject: [PATCH 05/40] Share the side rail's pure helpers across its three copies sentenceStart, viewingDeskLine, formatMinuteOfDay, minutesFrom, computeShiftProgress and the ShiftProgress shape were copied identically into ward-rail.tsx, next-rail-body.tsx and preview-rail-body.tsx (only a doc comment differed). They now live once in shell/ward-rail-helpers.ts. No markup, styles or wording change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../shell-next/next-rail-body.tsx | 131 +----------------- .../shell/ward-rail-helpers.ts | 130 +++++++++++++++++ .../ward-management/shell/ward-rail.tsx | 131 +----------------- .../ward-redesign/shell/preview-rail-body.tsx | 131 +----------------- tests/ward-service-bed-alerts.test.ts | 2 +- tests/ward-shell-third-edition.dom.test.tsx | 2 +- tests/ward-shift-rule-agreement.test.ts | 2 +- 7 files changed, 148 insertions(+), 381 deletions(-) create mode 100644 src/components/ward-management/shell/ward-rail-helpers.ts diff --git a/src/components/ward-management/shell-next/next-rail-body.tsx b/src/components/ward-management/shell-next/next-rail-body.tsx index 2f4e894ac..0ba40a44b 100644 --- a/src/components/ward-management/shell-next/next-rail-body.tsx +++ b/src/components/ward-management/shell-next/next-rail-body.tsx @@ -24,14 +24,11 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from " import { Sheet } from "@/components/wf/sheet"; import { Tooltip } from "@/components/wf/tooltip"; import { OperatorSwitcherModal } from "@/components/ward-management/settings/operator-switcher-modal-lazy"; -import { communityTeamById } from "@/components/ward-management/community/community-derivations"; import { useShellPersonaRole } from "@/components/ward-management/settings/persona-sync"; import { useSettingsDeskProfile } from "@/components/ward-management/settings/settings-desk-profile"; -import { currentDeskId } from "@/components/ward-management/settings/workstation-desks"; import { useNextNavCounts } from "@/components/ward-management/shell-next/next-nav-counts"; import { formatInstant, minuteOfDay } from "@/components/ward-management/ward-clock"; import { requestToolsTool } from "@/components/ward-management/tools/ward-tools-open"; -import { CHROME_ROLE_LABELS, wardChromeRole, type WardChromeRole } from "@/components/ward-management/ward-chrome-role"; import { ACCESS_ADMIN_PERSONA, DESK_PERSONAS, @@ -41,14 +38,14 @@ import { import { useDeskRoleLabel, useEffectiveWardAccess } from "@/components/ward-management/shell/ward-access-store"; import { useWardFlowClock, useWardFlowSelect, useWardNetwork } from "@/components/ward-management/ward-flow-provider"; import type { HealthService } from "@/components/ward-management/ward-model"; -import { edById } from "@/components/ward-management/ward-sites"; import { WARD_CAPACITY_HREF, WARD_REFERRAL_INTAKE_HREF } from "@/components/ward-management/ward-nav"; import { wardNavCountLabel } from "@/components/ward-management/ward-nav-counts"; +import { OPERATIONAL_DEFAULT_LABEL } from "@/components/ward-management/ward-operational-defaults"; import { - HANDOVER_DUE_PROMPT_MINUTES, - OPERATIONAL_DEFAULT_LABEL, -} from "@/components/ward-management/ward-operational-defaults"; -import { shiftAtMinute } from "@/components/ward-management/ward-board-time-features"; + computeShiftProgress, + sentenceStart, + viewingDeskLine, +} from "@/components/ward-management/shell/ward-rail-helpers"; import { Dot, SrOnly, durMinutes, useIsPhone } from "@/components/wf"; import { setGlarePreference, setThemePreference } from "@/lib/theme-client"; import { @@ -209,11 +206,6 @@ function getTabletLandscapeSnapshot() { ); } -/** "at a time limit…" → "At a time limit…", for the closed rail's hover card. */ -function sentenceStart(text: string): string { - return text.length === 0 ? text : text.charAt(0).toUpperCase() + text.slice(1); -} - // UX audit (21 Oct 2026): the same four themes as Settings, Display (settings-15 made Glare a fourth). const APPEARANCE_CHOICES = [ { id: "auto", label: "Auto" }, @@ -236,34 +228,6 @@ function chooseAppearance(next: AppearanceChoice) { announceToWardShell(`Appearance set to ${next}.`); } -/** - * The desk a page belongs to, when it is not the person's own kind of desk: "Viewing RPH ED as Bed - * coordinator". Null on the person's own kind of desk and on every network page. Place names only, - * never a patient. - */ -function viewingDeskLine( - pathname: string, - personaRole: WardChromeRole, - units: readonly { id: string; name: string }[], - personaLabel: string, -): string | null { - const pageRole = wardChromeRole(pathname); - if (pageRole === "coordinator" || pageRole === personaRole) return null; - const deskId = currentDeskId(pathname); - const place = - pageRole === "ed" - ? `${(deskId ? edById(deskId)?.siteCode : undefined) ?? "this"} ED` - : pageRole === "ward" - ? (units.find((unit) => unit.id === deskId)?.name ?? "this ward") - : pageRole === "officer" - ? "the transport desk" - : pageRole === "community" - ? // PR #489 review: the team by name when its page names one; the index has none. - ((deskId ? communityTeamById(deskId)?.name : undefined) ?? "a community team") - : CHROME_ROLE_LABELS[pageRole]; - return `Viewing ${place} as ${personaLabel}`; -} - /** * Retained for the layout's existing call shape. The reconciliation line this used to feed is no * longer mounted in the rail (Josh, 7 October 2026), so the rail takes no props today. @@ -1413,88 +1377,3 @@ export function NextRailBody({ onOpenOperatorModal }: WardRailProps = {}) { ); } - -export interface ShiftProgress { - shiftTitle: string; - /** "Day shift": the pattern's name in sentence case. */ - shiftName: string; - /** "07:00–15:00": clock times, so colons are right here. */ - shiftHours: string; - elapsedHours: number; - elapsedMins: number; - percent: number; - /** Minutes until the shift ends. */ - remainingMinutes: number; - /** True inside the last 15 minutes of the shift. */ - handoverDue: boolean; - countdownStr: string; - dashOffset: number; -} - -/** "07:00" from a minute-of-day figure — used only to word `SHIFT_PATTERN`'s own entries. */ -function formatMinuteOfDay(minute: number): string { - const hours = Math.floor(minute / 60); - const mins = minute % 60; - return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; -} - -/** Minutes from `from` to `to`, wrapping through midnight when `to` is earlier in the day. */ -function minutesFrom(from: number, to: number): number { - return (((to - from) % 1440) + 1440) % 1440; -} - -export function computeShiftProgress(nowInput?: number | Date): ShiftProgress { - const CIRCUMFERENCE = 81.68; - - let currentMinute: number; - if (typeof nowInput === "number") { - // Normalise minute of day 0..1439 - currentMinute = ((nowInput % 1440) + 1440) % 1440; - } else { - const d = nowInput instanceof Date ? nowInput : new Date(); - // AWST is UTC+8 - const awstHours = (d.getUTCHours() + 8) % 24; - const awstMins = d.getUTCMinutes(); - currentMinute = awstHours * 60 + awstMins; - } - - // The shift pattern itself (07:00/15:00/23:00) is a labelled default, not a legal roster rule — - // owner ruling 26 September 2026 (decisions.md D-22) — read from `SHIFT_PATTERN` through `shiftAtMinute` (the rule `currentShift` uses too), never - // hand-typed here, so the one figure and its three titles cannot drift apart. - const shift = shiftAtMinute(currentMinute); - - const shiftHours = `${formatMinuteOfDay(shift.startMinute)}–${formatMinuteOfDay(shift.endMinute)}`; - const shiftTitle = `${shift.name} (${shiftHours})`; - const shiftName = shift.name.charAt(0) + shift.name.slice(1).toLowerCase(); - const shiftDuration = minutesFrom(shift.startMinute, shift.endMinute); - const elapsedTotal = minutesFrom(shift.startMinute, currentMinute); - const remainingTotal = shiftDuration - elapsedTotal; - - const elapsedHours = Math.floor(elapsedTotal / 60); - const elapsedMins = elapsedTotal % 60; - const percent = Math.min(100, Math.max(0, Math.round((elapsedTotal / shiftDuration) * 100))); - const dashOffset = Number((CIRCUMFERENCE * (1 - percent / 100)).toFixed(2)); - const handoverDue = remainingTotal <= HANDOVER_DUE_PROMPT_MINUTES; - - let countdownStr: string; - if (handoverDue) { - countdownStr = "Handover Due"; - } else { - const remainingHours = Math.floor(remainingTotal / 60); - const remainingRemMins = remainingTotal % 60; - countdownStr = remainingHours > 0 ? `${remainingHours}h ${remainingRemMins}m` : `${remainingRemMins}m`; - } - - return { - shiftTitle, - shiftName, - shiftHours, - elapsedHours, - elapsedMins, - percent, - remainingMinutes: remainingTotal, - handoverDue, - countdownStr, - dashOffset, - }; -} diff --git a/src/components/ward-management/shell/ward-rail-helpers.ts b/src/components/ward-management/shell/ward-rail-helpers.ts new file mode 100644 index 000000000..01af9609f --- /dev/null +++ b/src/components/ward-management/shell/ward-rail-helpers.ts @@ -0,0 +1,130 @@ +/** + * Pure helpers shared by the side rail's three copies: the live rail (ward-rail.tsx), the next + * shell's rail (shell-next/next-rail-body.tsx) and the redesign preview's rail + * (ward-redesign/shell/preview-rail-body.tsx). They were copied identically into each; one home + * keeps the shift figures and the desk line from drifting apart. + */ +import { communityTeamById } from "@/components/ward-management/community/community-derivations"; +import { currentDeskId } from "@/components/ward-management/settings/workstation-desks"; +import { shiftAtMinute } from "@/components/ward-management/ward-board-time-features"; +import { CHROME_ROLE_LABELS, wardChromeRole, type WardChromeRole } from "@/components/ward-management/ward-chrome-role"; +import { HANDOVER_DUE_PROMPT_MINUTES } from "@/components/ward-management/ward-operational-defaults"; +import { edById } from "@/components/ward-management/ward-sites"; + +/** "at a time limit…" → "At a time limit…", for the closed rail's hover card. */ +export function sentenceStart(text: string): string { + return text.length === 0 ? text : text.charAt(0).toUpperCase() + text.slice(1); +} + +/** + * The desk a page belongs to, when it is not the person's own kind of desk: "Viewing RPH ED as Bed + * coordinator". Null on the person's own kind of desk and on every network page. Place names only, + * never a patient. + */ +export function viewingDeskLine( + pathname: string, + personaRole: WardChromeRole, + units: readonly { id: string; name: string }[], + personaLabel: string, +): string | null { + const pageRole = wardChromeRole(pathname); + if (pageRole === "coordinator" || pageRole === personaRole) return null; + const deskId = currentDeskId(pathname); + const place = + pageRole === "ed" + ? `${(deskId ? edById(deskId)?.siteCode : undefined) ?? "this"} ED` + : pageRole === "ward" + ? (units.find((unit) => unit.id === deskId)?.name ?? "this ward") + : pageRole === "officer" + ? "the transport desk" + : pageRole === "community" + ? // PR #489 review: the team by name when its page names one; the index has none. + ((deskId ? communityTeamById(deskId)?.name : undefined) ?? "a community team") + : CHROME_ROLE_LABELS[pageRole]; + return `Viewing ${place} as ${personaLabel}`; +} + +export interface ShiftProgress { + shiftTitle: string; + /** "Day shift": the pattern's name in sentence case. */ + shiftName: string; + /** "07:00–15:00": clock times, so colons are right here. */ + shiftHours: string; + elapsedHours: number; + elapsedMins: number; + percent: number; + /** Minutes until the shift ends. */ + remainingMinutes: number; + /** True inside the handover prompt window (`HANDOVER_DUE_PROMPT_MINUTES`) before the shift ends. */ + handoverDue: boolean; + countdownStr: string; + dashOffset: number; +} + +/** "07:00" from a minute-of-day figure — used only to word `SHIFT_PATTERN`'s own entries. */ +function formatMinuteOfDay(minute: number): string { + const hours = Math.floor(minute / 60); + const mins = minute % 60; + return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; +} + +/** Minutes from `from` to `to`, wrapping through midnight when `to` is earlier in the day. */ +function minutesFrom(from: number, to: number): number { + return (((to - from) % 1440) + 1440) % 1440; +} + +export function computeShiftProgress(nowInput?: number | Date): ShiftProgress { + const CIRCUMFERENCE = 81.68; + + let currentMinute: number; + if (typeof nowInput === "number") { + // Normalise minute of day 0..1439 + currentMinute = ((nowInput % 1440) + 1440) % 1440; + } else { + const d = nowInput instanceof Date ? nowInput : new Date(); + // AWST is UTC+8 + const awstHours = (d.getUTCHours() + 8) % 24; + const awstMins = d.getUTCMinutes(); + currentMinute = awstHours * 60 + awstMins; + } + + // The shift pattern itself (07:00/15:00/23:00) is a labelled default, not a legal roster rule — + // owner ruling 26 September 2026 (decisions.md D-22) — read from `SHIFT_PATTERN` through `shiftAtMinute` (the rule `currentShift` uses too), never + // hand-typed here, so the one figure and its three titles cannot drift apart. + const shift = shiftAtMinute(currentMinute); + + const shiftHours = `${formatMinuteOfDay(shift.startMinute)}–${formatMinuteOfDay(shift.endMinute)}`; + const shiftTitle = `${shift.name} (${shiftHours})`; + const shiftName = shift.name.charAt(0) + shift.name.slice(1).toLowerCase(); + const shiftDuration = minutesFrom(shift.startMinute, shift.endMinute); + const elapsedTotal = minutesFrom(shift.startMinute, currentMinute); + const remainingTotal = shiftDuration - elapsedTotal; + + const elapsedHours = Math.floor(elapsedTotal / 60); + const elapsedMins = elapsedTotal % 60; + const percent = Math.min(100, Math.max(0, Math.round((elapsedTotal / shiftDuration) * 100))); + const dashOffset = Number((CIRCUMFERENCE * (1 - percent / 100)).toFixed(2)); + const handoverDue = remainingTotal <= HANDOVER_DUE_PROMPT_MINUTES; + + let countdownStr: string; + if (handoverDue) { + countdownStr = "Handover Due"; + } else { + const remainingHours = Math.floor(remainingTotal / 60); + const remainingRemMins = remainingTotal % 60; + countdownStr = remainingHours > 0 ? `${remainingHours}h ${remainingRemMins}m` : `${remainingRemMins}m`; + } + + return { + shiftTitle, + shiftName, + shiftHours, + elapsedHours, + elapsedMins, + percent, + remainingMinutes: remainingTotal, + handoverDue, + countdownStr, + dashOffset, + }; +} diff --git a/src/components/ward-management/shell/ward-rail.tsx b/src/components/ward-management/shell/ward-rail.tsx index e69183121..fc7783307 100644 --- a/src/components/ward-management/shell/ward-rail.tsx +++ b/src/components/ward-management/shell/ward-rail.tsx @@ -24,25 +24,22 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from " import { Sheet } from "@/components/wf/sheet"; import { Tooltip } from "@/components/wf/tooltip"; import { OperatorSwitcherModal } from "@/components/ward-management/settings/operator-switcher-modal-lazy"; -import { communityTeamById } from "@/components/ward-management/community/community-derivations"; import { useShellPersonaRole } from "@/components/ward-management/settings/persona-sync"; -import { currentDeskId } from "@/components/ward-management/settings/workstation-desks"; import { useWardNavCounts } from "@/components/ward-management/use-ward-nav-counts"; import { formatInstant, minuteOfDay } from "@/components/ward-management/ward-clock"; import { requestToolsTool } from "@/components/ward-management/tools/ward-tools-open"; -import { CHROME_ROLE_LABELS, wardChromeRole, type WardChromeRole } from "@/components/ward-management/ward-chrome-role"; import { ACCESS_ADMIN_PERSONA, DESK_PERSONAS } from "@/components/ward-management/shell/ward-desk-personas"; import { useDeskRoleLabel, useEffectiveWardAccess } from "@/components/ward-management/shell/ward-access-store"; import { useWardFlowClock, useWardFlowSelect, useWardNetwork } from "@/components/ward-management/ward-flow-provider"; import type { HealthService } from "@/components/ward-management/ward-model"; -import { edById } from "@/components/ward-management/ward-sites"; import { WARD_CAPACITY_HREF, WARD_REFERRAL_INTAKE_HREF } from "@/components/ward-management/ward-nav"; import { wardNavCountLabel } from "@/components/ward-management/ward-nav-counts"; +import { OPERATIONAL_DEFAULT_LABEL } from "@/components/ward-management/ward-operational-defaults"; import { - HANDOVER_DUE_PROMPT_MINUTES, - OPERATIONAL_DEFAULT_LABEL, -} from "@/components/ward-management/ward-operational-defaults"; -import { shiftAtMinute } from "@/components/ward-management/ward-board-time-features"; + computeShiftProgress, + sentenceStart, + viewingDeskLine, +} from "@/components/ward-management/shell/ward-rail-helpers"; import { Dot, SrOnly, durMinutes, useIsPhone } from "@/components/wf"; import { applyAppearance, applyGlare } from "./ward-bar"; import { closeWardDrawer, isPhoneViewport, subscribeWardMenu } from "./ward-drawer-bus"; @@ -195,11 +192,6 @@ function getTabletLandscapeSnapshot() { ); } -/** "at a time limit…" → "At a time limit…", for the closed rail's hover card. */ -function sentenceStart(text: string): string { - return text.length === 0 ? text : text.charAt(0).toUpperCase() + text.slice(1); -} - // UX audit (21 Oct 2026): the same four themes as Settings, Display (settings-15 made Glare a fourth). const APPEARANCE_CHOICES = [ { id: "auto", label: "Auto" }, @@ -221,34 +213,6 @@ function chooseAppearance(next: AppearanceChoice) { announceToWardShell(`Appearance set to ${next}.`); } -/** - * The desk a page belongs to, when it is not the person's own kind of desk: "Viewing RPH ED as Bed - * coordinator". Null on the person's own kind of desk and on every network page. Place names only, - * never a patient. - */ -function viewingDeskLine( - pathname: string, - personaRole: WardChromeRole, - units: readonly { id: string; name: string }[], - personaLabel: string, -): string | null { - const pageRole = wardChromeRole(pathname); - if (pageRole === "coordinator" || pageRole === personaRole) return null; - const deskId = currentDeskId(pathname); - const place = - pageRole === "ed" - ? `${(deskId ? edById(deskId)?.siteCode : undefined) ?? "this"} ED` - : pageRole === "ward" - ? (units.find((unit) => unit.id === deskId)?.name ?? "this ward") - : pageRole === "officer" - ? "the transport desk" - : pageRole === "community" - ? // PR #489 review: the team by name when its page names one; the index has none. - ((deskId ? communityTeamById(deskId)?.name : undefined) ?? "a community team") - : CHROME_ROLE_LABELS[pageRole]; - return `Viewing ${place} as ${personaLabel}`; -} - /** * Retained for the layout's existing call shape. The reconciliation line this used to feed is no * longer mounted in the rail (Josh, 7 October 2026), so the rail takes no props today. @@ -1394,88 +1358,3 @@ export function WardRail({ onOpenOperatorModal }: WardRailProps = {}) { ); } - -export interface ShiftProgress { - shiftTitle: string; - /** "Day shift": the pattern's name in sentence case. */ - shiftName: string; - /** "07:00–15:00": clock times, so colons are right here. */ - shiftHours: string; - elapsedHours: number; - elapsedMins: number; - percent: number; - /** Minutes until the shift ends. */ - remainingMinutes: number; - /** True inside the handover prompt window (`HANDOVER_DUE_PROMPT_MINUTES`). */ - handoverDue: boolean; - countdownStr: string; - dashOffset: number; -} - -/** "07:00" from a minute-of-day figure — used only to word `SHIFT_PATTERN`'s own entries. */ -function formatMinuteOfDay(minute: number): string { - const hours = Math.floor(minute / 60); - const mins = minute % 60; - return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; -} - -/** Minutes from `from` to `to`, wrapping through midnight when `to` is earlier in the day. */ -function minutesFrom(from: number, to: number): number { - return (((to - from) % 1440) + 1440) % 1440; -} - -function computeShiftProgress(nowInput?: number | Date): ShiftProgress { - const CIRCUMFERENCE = 81.68; - - let currentMinute: number; - if (typeof nowInput === "number") { - // Normalise minute of day 0..1439 - currentMinute = ((nowInput % 1440) + 1440) % 1440; - } else { - const d = nowInput instanceof Date ? nowInput : new Date(); - // AWST is UTC+8 - const awstHours = (d.getUTCHours() + 8) % 24; - const awstMins = d.getUTCMinutes(); - currentMinute = awstHours * 60 + awstMins; - } - - // The shift pattern itself (07:00/15:00/23:00) is a labelled default, not a legal roster rule — - // owner ruling 26 September 2026 (decisions.md D-22) — read from `SHIFT_PATTERN` through `shiftAtMinute` (the rule `currentShift` uses too), never - // hand-typed here, so the one figure and its three titles cannot drift apart. - const shift = shiftAtMinute(currentMinute); - - const shiftHours = `${formatMinuteOfDay(shift.startMinute)}–${formatMinuteOfDay(shift.endMinute)}`; - const shiftTitle = `${shift.name} (${shiftHours})`; - const shiftName = shift.name.charAt(0) + shift.name.slice(1).toLowerCase(); - const shiftDuration = minutesFrom(shift.startMinute, shift.endMinute); - const elapsedTotal = minutesFrom(shift.startMinute, currentMinute); - const remainingTotal = shiftDuration - elapsedTotal; - - const elapsedHours = Math.floor(elapsedTotal / 60); - const elapsedMins = elapsedTotal % 60; - const percent = Math.min(100, Math.max(0, Math.round((elapsedTotal / shiftDuration) * 100))); - const dashOffset = Number((CIRCUMFERENCE * (1 - percent / 100)).toFixed(2)); - const handoverDue = remainingTotal <= HANDOVER_DUE_PROMPT_MINUTES; - - let countdownStr: string; - if (handoverDue) { - countdownStr = "Handover Due"; - } else { - const remainingHours = Math.floor(remainingTotal / 60); - const remainingRemMins = remainingTotal % 60; - countdownStr = remainingHours > 0 ? `${remainingHours}h ${remainingRemMins}m` : `${remainingRemMins}m`; - } - - return { - shiftTitle, - shiftName, - shiftHours, - elapsedHours, - elapsedMins, - percent, - remainingMinutes: remainingTotal, - handoverDue, - countdownStr, - dashOffset, - }; -} diff --git a/src/components/ward-redesign/shell/preview-rail-body.tsx b/src/components/ward-redesign/shell/preview-rail-body.tsx index 389ab5706..5589619af 100644 --- a/src/components/ward-redesign/shell/preview-rail-body.tsx +++ b/src/components/ward-redesign/shell/preview-rail-body.tsx @@ -26,25 +26,22 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from " import { Sheet } from "@/components/wf/sheet"; import { Tooltip } from "@/components/wf/tooltip"; import { OperatorSwitcherModal } from "@/components/ward-management/settings/operator-switcher-modal-lazy"; -import { communityTeamById } from "@/components/ward-management/community/community-derivations"; import { useShellPersonaRole } from "@/components/ward-management/settings/persona-sync"; -import { currentDeskId } from "@/components/ward-management/settings/workstation-desks"; import { usePreviewNavCounts } from "@/components/ward-redesign/shell/preview-nav-counts"; import { formatInstant, minuteOfDay } from "@/components/ward-management/ward-clock"; import { requestToolsTool } from "@/components/ward-management/tools/ward-tools-open"; -import { CHROME_ROLE_LABELS, wardChromeRole, type WardChromeRole } from "@/components/ward-management/ward-chrome-role"; import { ACCESS_ADMIN_PERSONA, DESK_PERSONAS } from "@/components/ward-management/shell/ward-desk-personas"; import { useDeskRoleLabel, useEffectiveWardAccess } from "@/components/ward-management/shell/ward-access-store"; import { useWardFlowClock, useWardFlowSelect, useWardNetwork } from "@/components/ward-management/ward-flow-provider"; import type { HealthService } from "@/components/ward-management/ward-model"; -import { edById } from "@/components/ward-management/ward-sites"; import { WARD_CAPACITY_HREF } from "@/components/ward-management/ward-nav"; import { wardNavCountLabel } from "@/components/ward-management/ward-nav-counts"; +import { OPERATIONAL_DEFAULT_LABEL } from "@/components/ward-management/ward-operational-defaults"; import { - HANDOVER_DUE_PROMPT_MINUTES, - OPERATIONAL_DEFAULT_LABEL, -} from "@/components/ward-management/ward-operational-defaults"; -import { shiftAtMinute } from "@/components/ward-management/ward-board-time-features"; + computeShiftProgress, + sentenceStart, + viewingDeskLine, +} from "@/components/ward-management/shell/ward-rail-helpers"; import { Dot, SrOnly, durMinutes, useIsPhone } from "@/components/wf"; import { setGlarePreference, setThemePreference } from "@/lib/theme-client"; import { @@ -189,11 +186,6 @@ function getTabletLandscapeSnapshot() { ); } -/** "at a time limit…" → "At a time limit…", for the closed rail's hover card. */ -function sentenceStart(text: string): string { - return text.length === 0 ? text : text.charAt(0).toUpperCase() + text.slice(1); -} - // UX audit (21 Oct 2026): the same four themes as Settings, Display (settings-15 made Glare a fourth). const APPEARANCE_CHOICES = [ { id: "auto", label: "Auto" }, @@ -216,34 +208,6 @@ function chooseAppearance(next: AppearanceChoice) { announceToWardShell(`Appearance set to ${next}.`); } -/** - * The desk a page belongs to, when it is not the person's own kind of desk: "Viewing RPH ED as Bed - * coordinator". Null on the person's own kind of desk and on every network page. Place names only, - * never a patient. - */ -function viewingDeskLine( - pathname: string, - personaRole: WardChromeRole, - units: readonly { id: string; name: string }[], - personaLabel: string, -): string | null { - const pageRole = wardChromeRole(pathname); - if (pageRole === "coordinator" || pageRole === personaRole) return null; - const deskId = currentDeskId(pathname); - const place = - pageRole === "ed" - ? `${(deskId ? edById(deskId)?.siteCode : undefined) ?? "this"} ED` - : pageRole === "ward" - ? (units.find((unit) => unit.id === deskId)?.name ?? "this ward") - : pageRole === "officer" - ? "the transport desk" - : pageRole === "community" - ? // PR #489 review: the team by name when its page names one; the index has none. - ((deskId ? communityTeamById(deskId)?.name : undefined) ?? "a community team") - : CHROME_ROLE_LABELS[pageRole]; - return `Viewing ${place} as ${personaLabel}`; -} - /** * Retained for the layout's existing call shape. The reconciliation line this used to feed is no * longer mounted in the rail (Josh, 7 October 2026), so the rail takes no props today. @@ -1322,88 +1286,3 @@ export function PreviewRailBody({ onOpenOperatorModal }: WardRailProps = {}) { ); } - -export interface ShiftProgress { - shiftTitle: string; - /** "Day shift": the pattern's name in sentence case. */ - shiftName: string; - /** "07:00–15:00": clock times, so colons are right here. */ - shiftHours: string; - elapsedHours: number; - elapsedMins: number; - percent: number; - /** Minutes until the shift ends. */ - remainingMinutes: number; - /** True inside the handover prompt window (`HANDOVER_DUE_PROMPT_MINUTES`) before the shift ends. */ - handoverDue: boolean; - countdownStr: string; - dashOffset: number; -} - -/** "07:00" from a minute-of-day figure — used only to word `SHIFT_PATTERN`'s own entries. */ -function formatMinuteOfDay(minute: number): string { - const hours = Math.floor(minute / 60); - const mins = minute % 60; - return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; -} - -/** Minutes from `from` to `to`, wrapping through midnight when `to` is earlier in the day. */ -function minutesFrom(from: number, to: number): number { - return (((to - from) % 1440) + 1440) % 1440; -} - -export function computeShiftProgress(nowInput?: number | Date): ShiftProgress { - const CIRCUMFERENCE = 81.68; - - let currentMinute: number; - if (typeof nowInput === "number") { - // Normalise minute of day 0..1439 - currentMinute = ((nowInput % 1440) + 1440) % 1440; - } else { - const d = nowInput instanceof Date ? nowInput : new Date(); - // AWST is UTC+8 - const awstHours = (d.getUTCHours() + 8) % 24; - const awstMins = d.getUTCMinutes(); - currentMinute = awstHours * 60 + awstMins; - } - - // The shift pattern itself (07:00/15:00/23:00) is a labelled default, not a legal roster rule — - // owner ruling 26 September 2026 (decisions.md D-22) — read from `SHIFT_PATTERN` through `shiftAtMinute` (the rule `currentShift` uses too), never - // hand-typed here, so the one figure and its three titles cannot drift apart. - const shift = shiftAtMinute(currentMinute); - - const shiftHours = `${formatMinuteOfDay(shift.startMinute)}–${formatMinuteOfDay(shift.endMinute)}`; - const shiftTitle = `${shift.name} (${shiftHours})`; - const shiftName = shift.name.charAt(0) + shift.name.slice(1).toLowerCase(); - const shiftDuration = minutesFrom(shift.startMinute, shift.endMinute); - const elapsedTotal = minutesFrom(shift.startMinute, currentMinute); - const remainingTotal = shiftDuration - elapsedTotal; - - const elapsedHours = Math.floor(elapsedTotal / 60); - const elapsedMins = elapsedTotal % 60; - const percent = Math.min(100, Math.max(0, Math.round((elapsedTotal / shiftDuration) * 100))); - const dashOffset = Number((CIRCUMFERENCE * (1 - percent / 100)).toFixed(2)); - const handoverDue = remainingTotal <= HANDOVER_DUE_PROMPT_MINUTES; - - let countdownStr: string; - if (handoverDue) { - countdownStr = "Handover Due"; - } else { - const remainingHours = Math.floor(remainingTotal / 60); - const remainingRemMins = remainingTotal % 60; - countdownStr = remainingHours > 0 ? `${remainingHours}h ${remainingRemMins}m` : `${remainingRemMins}m`; - } - - return { - shiftTitle, - shiftName, - shiftHours, - elapsedHours, - elapsedMins, - percent, - remainingMinutes: remainingTotal, - handoverDue, - countdownStr, - dashOffset, - }; -} diff --git a/tests/ward-service-bed-alerts.test.ts b/tests/ward-service-bed-alerts.test.ts index bd9cf23c7..8d0610488 100644 --- a/tests/ward-service-bed-alerts.test.ts +++ b/tests/ward-service-bed-alerts.test.ts @@ -5,7 +5,7 @@ import { METRO_HEALTH_SERVICES, occupancyTone, } from "@/components/ward-management/shell/ward-service-bed-alerts"; -import { computeShiftProgress } from "@/components/ward-management/shell-next/next-rail-body"; +import { computeShiftProgress } from "@/components/ward-management/shell/ward-rail-helpers"; import type { BedRelease, Site, Unit } from "@/components/ward-management/ward-model"; import { readyBedsNow } from "@/components/ward-management/ward-bed-states"; import { unitCapacity } from "@/components/ward-management/ward-derivations"; diff --git a/tests/ward-shell-third-edition.dom.test.tsx b/tests/ward-shell-third-edition.dom.test.tsx index 3f41f3ce5..0f1855105 100644 --- a/tests/ward-shell-third-edition.dom.test.tsx +++ b/tests/ward-shell-third-edition.dom.test.tsx @@ -42,7 +42,7 @@ vi.mock("next/navigation", () => ({ import { applyAppearance } from "@/components/ward-management/shell/ward-bar"; import { NextBar as WardBar } from "@/components/ward-management/shell-next/next-bar"; import { WardLiveRegion, resetWardLiveRegionForTests } from "@/components/ward-management/shell/ward-live-region"; -import { computeShiftProgress } from "@/components/ward-management/shell-next/next-rail-body"; +import { computeShiftProgress } from "@/components/ward-management/shell/ward-rail-helpers"; import { NextRailBody as WardRail } from "@/components/ward-management/shell-next/next-rail-body"; // CB-1 (Josh, 6 Nov 2026): two cases stay on the retired shell until Josh decides, because the live // shell has no equivalent. Its Activity trigger reads "Activity" alone, with no "synthetic activity" diff --git a/tests/ward-shift-rule-agreement.test.ts b/tests/ward-shift-rule-agreement.test.ts index f357763c1..787bf9ee6 100644 --- a/tests/ward-shift-rule-agreement.test.ts +++ b/tests/ward-shift-rule-agreement.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { defaultHandoverShift, handoverAt } from "@/components/ward-management/handover/handover-model"; -import { computeShiftProgress } from "@/components/ward-management/shell-next/next-rail-body"; +import { computeShiftProgress } from "@/components/ward-management/shell/ward-rail-helpers"; import { currentShift, shiftAtMinute } from "@/components/ward-management/ward-board-time-features"; import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; From a94c4340746b05c6d6b08423debe4cd879efef95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:07:08 +0000 Subject: [PATCH 06/40] Align the backend's Node range with the root and drop a duplicate script backend/ward-flow engines.node now matches the root (>=24.22.1 <25), with the lockfile's root entry in step. Railway picks Node from RAILPACK_NODE_VERSION, so hosting is unchanged. prune:postgres was an exact copy of prune:receipts:postgres and nothing referred to it. prune:supabase stays: OPERATIONS-RUNBOOK.md still names it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- backend/ward-flow/package-lock.json | 2 +- backend/ward-flow/package.json | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/ward-flow/package-lock.json b/backend/ward-flow/package-lock.json index d19f5bdf3..bd253477e 100644 --- a/backend/ward-flow/package-lock.json +++ b/backend/ward-flow/package-lock.json @@ -16,7 +16,7 @@ "esbuild": "0.28.2" }, "engines": { - "node": ">=22" + "node": ">=24.22.1 <25" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/backend/ward-flow/package.json b/backend/ward-flow/package.json index 5cdd3f51d..be1beb5a7 100644 --- a/backend/ward-flow/package.json +++ b/backend/ward-flow/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=22" + "node": ">=24.22.1 <25" }, "scripts": { "start": "node --env-file-if-exists=.env server.mjs", @@ -13,7 +13,6 @@ "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "migrate:postgres": "node --env-file-if-exists=.env migrate-postgres.mjs", "migrate:supabase": "node --env-file-if-exists=.env migrate-supabase.mjs", - "prune:postgres": "node --env-file-if-exists=.env prune-receipts.mjs postgres", "prune:supabase": "node --env-file-if-exists=.env prune-receipts.mjs supabase", "prune:receipts:postgres": "node --env-file-if-exists=.env prune-receipts.mjs postgres", "prune:receipts:supabase": "node --env-file-if-exists=.env prune-receipts.mjs supabase", From a47ac619846b58d2bf5c28285464224815f5be0d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:07:08 +0000 Subject: [PATCH 07/40] Drop the missing playwright folder from lint:internal Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d011075d0..4b26555cc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "check:coverage-inventory": "node scripts/check-coverage-inventory.mjs", "start": "node scripts/dev-free-port.mjs start", "lint": "node scripts/run-heavy.mjs --npm-script lint:internal", - "lint:internal": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts backend playwright eslint.config.mjs next.config.ts playwright.config.ts vitest.config.mts --max-warnings 0 --no-error-on-unmatched-pattern --cache --cache-location node_modules/.cache/eslint/", + "lint:internal": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts backend eslint.config.mjs next.config.ts playwright.config.ts vitest.config.mts --max-warnings 0 --no-error-on-unmatched-pattern --cache --cache-location node_modules/.cache/eslint/", "lint:changed:internal": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js", "typecheck": "node scripts/run-heavy.mjs --npm-script typecheck:internal", "typecheck:internal": "node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.typecheck.json --noEmit", From 2ecff09d6d51616d8b9aa850dff000f395a595c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:08:56 +0000 Subject: [PATCH 08/40] ci: share the Ward Flow install setup through a composite action Seven ward-flow.yml jobs (backend, unit, browser-build, browser, release-gate, build, coverage) repeated setup-node, the pinned npm provision, the exact node_modules restore and the attested locked install. They now call .github/actions/ward-setup after their checkout, with the step id `install` so every steps.install.outcome condition is unchanged. The build job's main-only save reads the action's cache-hit and cache-primary-key outputs. Same action SHAs, cache key, inputs and permissions; plan and static keep their own setup because they differ. check-contracts.mjs holds the action to the contracts the inline copies had (exact commands, exact key without restore-keys, pinned SHAs, unconditional steps, shell bash) and adds mutation probes for it. The bot-author policy and Dependabot now cover .github/actions/, and the M55 pin test scans local actions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .github/actions/ward-setup/action.yml | 36 ++++ .github/dependabot.yml | 3 +- .github/workflows/ward-flow.yml | 144 +++----------- scripts/ward-ci-public/bot-author-policy.mjs | 8 +- scripts/ward-ci-public/check-contracts.mjs | 198 +++++++++++++++++-- tests/bot-author-policy.test.ts | 11 ++ tests/ci-cache-safety.test.ts | 7 +- tests/ci-remediation-contracts.test.ts | 26 ++- tests/ward-audit-m55-supply-chain.test.ts | 19 +- tests/ward-backend-ci.test.ts | 6 +- 10 files changed, 321 insertions(+), 137 deletions(-) create mode 100644 .github/actions/ward-setup/action.yml diff --git a/.github/actions/ward-setup/action.yml b/.github/actions/ward-setup/action.yml new file mode 100644 index 000000000..c916ddb72 --- /dev/null +++ b/.github/actions/ward-setup/action.yml @@ -0,0 +1,36 @@ +# The shared Node setup for the Ward Flow CI jobs that need installed packages +# (.github/workflows/ward-flow.yml). The job checks out the repository first, so this local action exists. +# Contracts: scripts/ward-ci-public/check-contracts.mjs. Every step is unconditional and pinned by commit. +name: Ward Flow setup +description: Select the pinned Node, provision the pinned npm, then restore and attest the locked install. +outputs: + cache-hit: + description: Whether the exact installed-packages key was restored. + value: ${{ steps.node-modules.outputs.cache-hit }} + cache-primary-key: + description: The exact installed-packages key, for the main-only save in the build job. + value: ${{ steps.node-modules.outputs.cache-primary-key }} +runs: + using: composite + steps: + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version-file: .nvmrc + cache: npm + - name: Install and record the pinned npm toolchain + shell: bash + run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require('./package.json').packageManager")" && node --version && npm --version + # Installed packages come from a cache saved on main only (build job), straight after an attested + # install; install-locked-dependencies.mjs reuses an exact hit only when its post-install stamp matches. + - name: Restore installed packages + id: node-modules + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: node_modules + key: ward-node-modules-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json', 'package-lock.json') }} + - name: Install locked dependencies + id: install + shell: bash + env: + WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }} + run: node scripts/install-locked-dependencies.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 084d4692d..15fb2f180 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -35,8 +35,9 @@ updates: applies-to: security-updates patterns: ["*"] + # The local composite actions under .github/actions/ hold action pins too. - package-ecosystem: github-actions - directory: / + directories: ["/", "/.github/actions/*"] schedule: interval: weekly day: monday diff --git a/.github/workflows/ward-flow.yml b/.github/workflows/ward-flow.yml index 832bfa7e0..bff5a8c82 100644 --- a/.github/workflows/ward-flow.yml +++ b/.github/workflows/ward-flow.yml @@ -308,23 +308,11 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version-file: .nvmrc - cache: npm - - name: Install and record the pinned npm toolchain - run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require('./package.json').packageManager")" && node --version && npm --version - - name: Restore installed packages - id: node-modules - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: node_modules - key: ward-node-modules-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json', 'package-lock.json') }} - - name: Install locked dependencies + # Node, the pinned npm, the exact installed-packages restore and the attested install + # (.github/actions/ward-setup); the checkout above provides the local action. + - name: Set up Node and install locked dependencies id: install - env: - WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }} - run: node scripts/install-locked-dependencies.mjs + uses: ./.github/actions/ward-setup - name: Install locked Ward backend dependencies id: backend-install if: ${{ !cancelled() && steps.install.outcome == 'success' }} @@ -405,23 +393,11 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version-file: .nvmrc - cache: npm - - name: Install and record the pinned npm toolchain - run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require('./package.json').packageManager")" && node --version && npm --version - - name: Restore installed packages - id: node-modules - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: node_modules - key: ward-node-modules-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json', 'package-lock.json') }} - - name: Install locked dependencies + # Node, the pinned npm, the exact installed-packages restore and the attested install + # (.github/actions/ward-setup); the checkout above provides the local action. + - name: Set up Node and install locked dependencies id: install - env: - WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }} - run: node scripts/install-locked-dependencies.mjs + uses: ./.github/actions/ward-setup - name: Select reproducible nightly fuzz window if: ${{ github.event_name == 'schedule' && steps.install.outcome == 'success' }} run: node scripts/ward-flow/engine-fuzz-window.mjs --output "$RUNNER_TEMP/ward-profile/fuzz-window.json" --github-env "$GITHUB_ENV" @@ -503,23 +479,11 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version-file: .nvmrc - cache: npm - - name: Install and record the pinned npm toolchain - run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require('./package.json').packageManager")" && node --version && npm --version - - name: Restore installed packages - id: node-modules - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: node_modules - key: ward-node-modules-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json', 'package-lock.json') }} - - name: Install locked dependencies + # Node, the pinned npm, the exact installed-packages restore and the attested install + # (.github/actions/ward-setup); the checkout above provides the local action. + - name: Set up Node and install locked dependencies id: install - env: - WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }} - run: node scripts/install-locked-dependencies.mjs + uses: ./.github/actions/ward-setup - name: Restore Next build cache id: next-cache if: ${{ !cancelled() && steps.install.outcome == 'success' }} @@ -566,23 +530,11 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version-file: .nvmrc - cache: npm - - name: Install and record the pinned npm toolchain - run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require('./package.json').packageManager")" && node --version && npm --version - - name: Restore installed packages - id: node-modules - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: node_modules - key: ward-node-modules-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json', 'package-lock.json') }} - - name: Install locked dependencies + # Node, the pinned npm, the exact installed-packages restore and the attested install + # (.github/actions/ward-setup); the checkout above provides the local action. + - name: Set up Node and install locked dependencies id: install - env: - WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }} - run: node scripts/install-locked-dependencies.mjs + uses: ./.github/actions/ward-setup - name: Download the verified browser build if: ${{ !cancelled() && needs.browser-build.result == 'success' && steps.install.outcome == 'success' }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 @@ -693,23 +645,11 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version-file: .nvmrc - cache: npm - - name: Install and record the pinned npm toolchain - run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require('./package.json').packageManager")" && node --version && npm --version - - name: Restore installed packages - id: node-modules - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: node_modules - key: ward-node-modules-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json', 'package-lock.json') }} - - name: Install locked dependencies + # Node, the pinned npm, the exact installed-packages restore and the attested install + # (.github/actions/ward-setup); the checkout above provides the local action. + - name: Set up Node and install locked dependencies id: install - env: - WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }} - run: node scripts/install-locked-dependencies.mjs + uses: ./.github/actions/ward-setup - name: Download the verified browser build if: ${{ !cancelled() && needs.browser-build.result == 'success' && steps.install.outcome == 'success' }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 @@ -876,32 +816,20 @@ jobs: - name: Record the measured main if: ${{ github.event_name == 'pull_request' }} run: echo "::notice title=Ward budget main::$(git rev-parse refs/remotes/origin/main)" - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version-file: .nvmrc - cache: npm - - name: Install and record the pinned npm toolchain - run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require('./package.json').packageManager")" && node --version && npm --version - - name: Restore installed packages - id: node-modules - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: node_modules - key: ward-node-modules-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json', 'package-lock.json') }} - - name: Install locked dependencies + # Node, the pinned npm, the exact installed-packages restore and the attested install + # (.github/actions/ward-setup); the checkout above provides the local action. + - name: Set up Node and install locked dependencies id: install - env: - WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }} - run: node scripts/install-locked-dependencies.mjs + uses: ./.github/actions/ward-setup # The clean tree is saved straight after its attested install, from main only, before any step # writes into node_modules; pull requests restore it and install-locked-dependencies.mjs reuses # it only when its post-install stamp still matches the lock and the files. - name: Save installed packages on main - if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.install.outcome == 'success' && steps.node-modules.outputs.cache-hit != 'true' }} + if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.install.outcome == 'success' && steps.install.outputs.cache-hit != 'true' }} uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: node_modules - key: ${{ steps.node-modules.outputs.cache-primary-key }} + key: ${{ steps.install.outputs.cache-primary-key }} - name: Restore Next build cache id: production-cache if: ${{ !cancelled() && steps.install.outcome == 'success' }} @@ -952,23 +880,11 @@ jobs: with: persist-credentials: false fetch-depth: 0 - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version-file: .nvmrc - cache: npm - - name: Install and record the pinned npm toolchain - run: npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require('./package.json').packageManager")" && node --version && npm --version - - name: Restore installed packages - id: node-modules - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: node_modules - key: ward-node-modules-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json', 'package-lock.json') }} - - name: Install locked dependencies + # Node, the pinned npm, the exact installed-packages restore and the attested install + # (.github/actions/ward-setup); the checkout above provides the local action. + - name: Set up Node and install locked dependencies id: install - env: - WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }} - run: node scripts/install-locked-dependencies.mjs + uses: ./.github/actions/ward-setup - name: Download shard coverage blobs if: ${{ !cancelled() && steps.install.outcome == 'success' }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 diff --git a/scripts/ward-ci-public/bot-author-policy.mjs b/scripts/ward-ci-public/bot-author-policy.mjs index 514500b76..8694cf604 100644 --- a/scripts/ward-ci-public/bot-author-policy.mjs +++ b/scripts/ward-ci-public/bot-author-policy.mjs @@ -93,8 +93,10 @@ export const sensitivePathPatterns = [ /^data\/mha-[^/]*$/iu, /^docs\/ward-flow\/governance\//u, /^(?:src|data|docs|backend)\/(?:.*\/)?[^/]*clinic[^/]*(?:\/|$)/iu, - // CI itself: the workflows, the public CI scripts (this file included) and the protected-test list. + // CI itself: the workflows and their local actions, the public CI scripts (this file included) and + // the protected-test list. /^\.github\/workflows\//u, + /^\.github\/actions\//u, /^scripts\/ward-ci-public\//u, /^scripts\/ward-flow\/protected-safety-tests\.json$/u, ]; @@ -191,7 +193,9 @@ export function botSensitiveCommitViolations(commits) { } const pinOnly = isDependabotAuthor(commit.author) ? new Set(commit.actionPinOnlyFiles ?? []) : new Set(); const sensitive = commit.files.filter( - (file) => isSensitivePath(file) && !(pinOnly.has(file) && /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(file)), + (file) => + isSensitivePath(file) && + !(pinOnly.has(file) && /^\.github\/(?:workflows\/[^/]+|actions\/[^/]+\/action)\.ya?ml$/u.test(file)), ); if (sensitive.length > 0) { violations.push( diff --git a/scripts/ward-ci-public/check-contracts.mjs b/scripts/ward-ci-public/check-contracts.mjs index c397733c7..64917a6dd 100644 --- a/scripts/ward-ci-public/check-contracts.mjs +++ b/scripts/ward-ci-public/check-contracts.mjs @@ -8,7 +8,90 @@ const workflow = readFileSync(new URL("../../.github/workflows/ward-flow.yml", i /\r\n/gu, "\n", ); -function requireWorkflow(source) { +// The shared setup action the install jobs use (.github/actions/ward-setup). +const setupAction = readFileSync( + new URL("../../.github/actions/ward-setup/action.yml", import.meta.url), + "utf8", +).replace(/\r\n/gu, "\n"); +function requireWorkflow(source, action) { + // The shared setup the install jobs use: Node, the pinned npm, the exact installed-packages restore and + // the attested install. Its steps are held to the same contracts the inline copies were. + const SETUP_ACTION = "./.github/actions/ward-setup"; + const SETUP_STEP = "Set up Node and install locked dependencies"; + const SETUP_JOBS = ["backend", "unit", "browser-build", "browser", "release-gate", "build", "coverage"]; + const NPM_PROVISION = + 'npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require(\'./package.json\').packageManager")" && node --version && npm --version'; + const CACHE_RESTORE = "actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9"; + const NODE_MODULES_KEY = + "ward-node-modules-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json', 'package-lock.json') }}"; + function requireSetupAction(source, setupNode) { + source = source + .split("\n") + .filter((line) => !/^\s*#/u.test(line)) + .join("\n"); + assert.match(source, /^runs:\n {2}using: composite\n {2}steps:\n/mu); + // The build job's main-only save reads these outputs; anything else would save an unattested key. + assert.match( + source, + /^outputs:\n {2}cache-hit:\n {4}description: [^\n]+\n {4}value: \$\{\{ steps\.node-modules\.outputs\.cache-hit \}\}\n {2}cache-primary-key:\n {4}description: [^\n]+\n {4}value: \$\{\{ steps\.node-modules\.outputs\.cache-primary-key \}\}\n(?=\S)/mu, + ); + for (const pattern of [ + /continue-on-error:/u, + /secrets\./u, + /actions\/cache@/u, + /node-version: /u, + /restore-keys/u, + /^\s*if:/mu, + /working-directory:/u, + /^\s*(?:-\s+)?uses: \.\//mu, + ]) + assert.doesNotMatch(source, pattern, `setup action: ${pattern}`); + for (const match of source.matchAll(/^\s*(?:- )?uses: ([^\n]+)/gmu)) + assert.match(match[1], /^[\w./-]+@[0-9a-f]{40}$/u); + // A composite run step must name its shell; plain bash keeps -e and pipefail. + for (const match of source.matchAll(/^\s*shell: ([^\n]*)$/gmu)) assert.equal(match[1], "bash"); + const entries = source + .split(/^ {4}- /mu) + .slice(1) + .map((text) => { + const fields = new Map(); + for (const match of ` ${text}`.matchAll(/^ {6}([a-z][a-z0-9_-]*): ([^\n]*)(?:\n|$)/gmu)) { + assert.ok(!fields.has(match[1]), `setup action: duplicate step field ${match[1]}`); + fields.set(match[1], match[2]); + } + return { fields, text }; + }); + assert.deepEqual( + entries.map(({ fields }) => fields.get("name") ?? fields.get("uses")), + [ + setupNode, + "Install and record the pinned npm toolchain", + "Restore installed packages", + "Install locked dependencies", + ], + "setup action: select Node, provision npm, restore, then install", + ); + const [runtime, provision, restore, install] = entries; + assert.match(runtime.fields.get("uses"), /^actions\/setup-node@[0-9a-f]{40}$/u); + assert.match(runtime.text, /^ {8}node-version-file: \.nvmrc$/mu); + assert.match(runtime.text, /^ {8}cache: npm$/mu); + assert.equal(provision.fields.get("run"), NPM_PROVISION); + assert.equal(provision.fields.get("shell"), "bash"); + assert.equal(restore.fields.get("uses"), CACHE_RESTORE); + assert.equal(restore.fields.get("id"), "node-modules"); + assert.match(restore.text, /^ {8}path: node_modules$/mu); + assert.ok( + restore.text.includes(` key: ${NODE_MODULES_KEY}\n`), + "setup action: exact installed-packages key", + ); + assert.equal(install.fields.get("id"), "install"); + assert.equal(install.fields.get("run"), "node scripts/install-locked-dependencies.mjs"); + assert.equal(install.fields.get("shell"), "bash"); + assert.deepEqual( + install.text.split("\n").filter((line) => line.startsWith(" WARD_")), + [" WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }}"], + ); + } // Dependency-free, bounded YAML subset: duplicate fields and comments cannot // supply executable evidence. Exact commands prevent shell failure suppression. source = source @@ -81,7 +164,7 @@ function requireWorkflow(source) { ]) assert.doesNotMatch(source, pattern); for (const match of source.matchAll(/^\s*(?:- )?uses: ([^\n]+)/gmu)) - assert.match(match[1], /^[\w./-]+@[0-9a-f]{40}$/u); + if (match[1] !== SETUP_ACTION) assert.match(match[1], /^[\w./-]+@[0-9a-f]{40}$/u); for (const match of source.matchAll(/persist-credentials: ([^\n]+)/gu)) assert.equal(match[1], "false"); assert.equal([...source.matchAll(/run: node scripts\/ward-ci-public\/plan\.mjs/g)].length, 1); exact("plan", "Plan Ward scope", "node scripts/ward-ci-public/plan.mjs"); @@ -135,9 +218,47 @@ function requireWorkflow(source) { ); assert.ok(jobs.get(job)?.includes(` if: ${route}\n`), `${job} job scope`); assert.match(jobs.get(job), /^ {4}needs: (?:plan|\[[^\n]*\bplan\b[^\n]*\])$/mu); - exact(job, "Install locked dependencies", "node scripts/install-locked-dependencies.mjs"); - assert.equal(step(job, "Install locked dependencies").fields.get("id"), "install"); } + // These jobs install through the shared setup action, unconditionally, after their checkout. + assert.deepEqual( + [...jobs.keys()].filter((job) => jobs.get(job).includes(SETUP_ACTION)), + SETUP_JOBS, + "jobs using the shared setup action", + ); + for (const job of SETUP_JOBS) { + const entries = steps(job); + const setup = step(job, SETUP_STEP); + assert.equal(setup.fields.get("uses"), SETUP_ACTION, `${job}: shared setup action`); + assert.equal(setup.fields.get("id"), "install", `${job}: setup outcome is steps.install`); + assert.deepEqual( + [...setup.fields.keys()].sort(), + ["id", "name", "uses"], + `${job}: setup takes no inputs or condition`, + ); + assert.equal( + entries.filter(({ fields }) => fields.get("uses")?.startsWith("actions/checkout@")).length, + 1, + `${job}: one checkout`, + ); + assert.ok( + entries.findIndex(({ fields }) => fields.get("uses")?.startsWith("actions/checkout@")) < + entries.findIndex(({ fields }) => fields.get("name") === SETUP_STEP), + `${job}: checkout before the local setup action`, + ); + assert.doesNotMatch(jobs.get(job), /actions\/setup-node@|Install locked dependencies|steps\.node-modules\./u); + } + // A job reading steps.install has exactly one installer: the shared action or the inline steps. + for (const [job, body] of jobs) { + if (!body.includes("steps.install.")) continue; + const inline = steps(job).filter(({ fields }) => fields.get("name") === "Install locked dependencies").length; + const shared = steps(job).filter(({ fields }) => fields.get("name") === SETUP_STEP).length; + assert.equal(inline + shared, 1, `${job}: one dependency install`); + if (!inline) assert.doesNotMatch(body, /steps\.node-modules\./u, `${job}: restore outputs come from the action`); + } + // The action selects Node with the same pinned setup-node the plan job uses. + const planSetupNode = steps("plan").find(({ fields }) => fields.get("uses")?.startsWith("actions/setup-node@")); + assert.ok(planSetupNode, "plan: selects Node"); + requireSetupAction(action, planSetupNode.fields.get("uses")); assert.equal(step("static", "Install locked dependencies").fields.get("id"), "install"); // Installed packages come from a cache saved on main only, straight after an attested install; the // installer reuses an exact hit only when its own post-install stamp still matches (see @@ -173,9 +294,19 @@ function requireWorkflow(source) { const names = steps("build").map(({ fields }) => fields.get("name")); assert.equal( names.indexOf("Save installed packages on main"), - names.indexOf("Install locked dependencies") + 1, + names.indexOf(SETUP_STEP) + 1, "build: installed packages are saved straight after the attested install", ); + const save = step("build", "Save installed packages on main"); + assert.equal(save.fields.get("uses"), "actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9"); + assert.equal( + save.fields.get("if"), + expression( + "!cancelled() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.install.outcome == 'success' && steps.install.outputs.cache-hit != 'true'", + ), + ); + assert.match(save.text, /^ {10}path: node_modules$/mu); + assert.ok(save.text.includes(` key: ${expression("steps.install.outputs.cache-primary-key")}\n`)); } exact( "static", @@ -651,11 +782,7 @@ function requireWorkflow(source) { } if (body.includes("actions/setup-node@")) { assert.match(body, /node-version-file: \.nvmrc/u); - exact( - job, - "Install and record the pinned npm toolchain", - 'npm install --global --ignore-scripts --no-audit --no-fund "$(node -p "require(\'./package.json\').packageManager")" && node --version && npm --version', - ); + exact(job, "Install and record the pinned npm toolchain", NPM_PROVISION); const entries = steps(job); const provision = entries.findIndex( (entry) => entry.fields.get("name") === "Install and record the pinned npm toolchain", @@ -768,7 +895,7 @@ function requireWorkflow(source) { fi`, ); } -requireWorkflow(workflow); +requireWorkflow(workflow, setupAction); // Exercise the actual aggregate shell across every scope combination and every // failure/cancellation/missing-result branch, including the trusted reuse route. const verdictShell = /^ {8}run: \|\n([\s\S]*)$/mu.exec(workflow.slice(workflow.indexOf(" required:\n")))?.[1]; @@ -969,7 +1096,54 @@ for (const [before, after] of [ ]) { const changed = workflow.replace(before, after); assert.notEqual(changed, workflow, before); - assert.throws(() => requireWorkflow(changed), before); + assert.throws(() => requireWorkflow(changed, setupAction), before); +} +// The same probes against the shared setup action, plus its wiring in the workflow. +for (const [before, after] of [ + ["run: npm install --global --ignore-scripts --no-audit --no-fund", "run: echo npm installation omitted"], + [ + " run: node scripts/install-locked-dependencies.mjs", + " run: node scripts/install-locked-dependencies.mjs || true", + ], + [" id: node-modules\n", " id: unused-node-modules\n"], + [" id: install\n", " id: unused-install\n"], + [" WARD_INSTALL_RESTORED: ${{ steps.node-modules.outputs.cache-hit }}", " WARD_INSTALL_RESTORED: true"], + [ + " path: node_modules\n key: ward-node-modules-", + " path: node_modules\n restore-keys: ward-\n key: ward-node-modules-", + ], + [" key: ward-node-modules-${{ runner.os }}-", " key: ward-node-modules-"], + [" node-version-file: .nvmrc\n", " node-version: 24\n"], + [" cache: npm\n", ""], + [" shell: bash\n run: npm", " shell: bash {0}\n run: npm"], + [" - name: Restore installed packages\n", " - name: Restore installed packages\n if: ${{ false }}\n"], + [ + " uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9", + " uses: actions/cache/restore@v4", + ], + [" value: ${{ steps.node-modules.outputs.cache-primary-key }}", " value: ward-node-modules"], + [" value: ${{ steps.node-modules.outputs.cache-hit }}", " value: 'false'"], +]) { + const changed = setupAction.replace(before, after); + assert.notEqual(changed, setupAction, before); + assert.throws(() => requireWorkflow(workflow, changed), before); +} +for (const [before, after] of [ + [` uses: ./.github/actions/ward-setup\n`, " uses: ./.github/actions/other\n"], + [ + " id: install\n uses: ./.github/actions/ward-setup\n", + " id: setup\n uses: ./.github/actions/ward-setup\n", + ], + [ + ` uses: ./.github/actions/ward-setup\n`, + " if: ${{ false }}\n uses: ./.github/actions/ward-setup\n", + ], + ["steps.install.outputs.cache-hit != 'true'", "true"], + ["key: ${{ steps.install.outputs.cache-primary-key }}", "key: ward-node-modules"], +]) { + const changed = workflow.replace(before, after); + assert.notEqual(changed, workflow, before); + assert.throws(() => requireWorkflow(changed, setupAction), before); } assert.deepEqual(parseNameStatus("M\tdocs/ward-flow/README.md\n"), [{ status: "M", file: "docs/ward-flow/README.md" }]); // A document no unit test reads; tests/ward-ci-scope.test.ts covers documents unit tests do read. diff --git a/tests/bot-author-policy.test.ts b/tests/bot-author-policy.test.ts index 9eef5520a..d7be7b2a7 100644 --- a/tests/bot-author-policy.test.ts +++ b/tests/bot-author-policy.test.ts @@ -99,6 +99,16 @@ describe("bot-author PR policy", () => { expect(botSensitiveCommitViolations([{ sha: "f".repeat(40), author: dependabot, files: [workflow] }])).toHaveLength( 1, ); + // The workflows' local composite actions carry pins too, and get the same exemption. + const setupAction = ".github/actions/ward-setup/action.yml"; + expect( + botSensitiveCommitViolations([ + { sha: "f".repeat(40), author: dependabot, files: [setupAction], actionPinOnlyFiles: [setupAction] }, + ]), + ).toEqual([]); + expect( + botSensitiveCommitViolations([{ sha: "f".repeat(40), author: dependabot, files: [setupAction] }]), + ).toHaveLength(1); // Only Dependabot gets the exemption, and only for workflow files. expect( botSensitiveCommitViolations([ @@ -140,6 +150,7 @@ describe("bot-author PR policy: coding-agent warn tier (A6-F02)", () => { it("protects workflows, public CI scripts and the protected safety tests", () => { for (const file of [ ".github/workflows/ward-flow.yml", + ".github/actions/ward-setup/action.yml", "scripts/ward-ci-public/plan.mjs", "scripts/ward-ci-public/bot-author-policy.mjs", "scripts/ward-flow/protected-safety-tests.json", diff --git a/tests/ci-cache-safety.test.ts b/tests/ci-cache-safety.test.ts index f534af3e3..d5736a72a 100644 --- a/tests/ci-cache-safety.test.ts +++ b/tests/ci-cache-safety.test.ts @@ -6,7 +6,8 @@ import { describe, expect, it } from "vitest"; // baseline and Lighthouse routing) and the three unused composite actions under `.github/actions/` // were removed on 15 October 2026. `ci.yml` does not exist in this repository, so those suites had // been skipped on every run. Ward Flow CI is `.github/workflows/ward-flow.yml`, whose contracts live -// in `scripts/ward-ci-public/check-contracts.mjs`. +// in `scripts/ward-ci-public/check-contracts.mjs`. Its one local action, `.github/actions/ward-setup`, +// was added on 11 November 2026 and is held to the same contracts there. describe("CI cache safety", () => { it("does not add a PR workflow that changes user-owned auto-merge state", () => { @@ -18,5 +19,9 @@ describe("CI cache safety", () => { expect(existsSync(wardCiUrl)).toBe(true); const wardWorkflow = readFileSync(wardCiUrl, "utf8"); expect(wardWorkflow).toContain("cache: npm"); + // The install jobs select Node through the shared setup action, which keeps npm's cache too. + const setupAction = readFileSync(new URL("../.github/actions/ward-setup/action.yml", import.meta.url), "utf8"); + expect(wardWorkflow).toContain("uses: ./.github/actions/ward-setup"); + expect(setupAction).toMatch(/^ {8}cache: npm$/mu); }); }); diff --git a/tests/ci-remediation-contracts.test.ts b/tests/ci-remediation-contracts.test.ts index cc27bfccb..4fc137bbd 100644 --- a/tests/ci-remediation-contracts.test.ts +++ b/tests/ci-remediation-contracts.test.ts @@ -4,14 +4,21 @@ import vm from "node:vm"; import { describe, expect, it } from "vitest"; const workflow = readFileSync(".github/workflows/ward-flow.yml", "utf8"); +// The install jobs' shared setup action, checked together with the workflow. +const setupAction = readFileSync(".github/actions/ward-setup/action.yml", "utf8"); const checker = readFileSync("scripts/ward-ci-public/check-contracts.mjs", "utf8"); -const start = checker.indexOf("function requireWorkflow(source) {"); -const end = checker.indexOf("\nrequireWorkflow(workflow);", start); -const verify = vm.runInNewContext(`(${checker.slice(start, end).trim()})`, { assert }) as (source: string) => void; +const start = checker.indexOf("function requireWorkflow(source, action) {"); +const end = checker.indexOf("\nrequireWorkflow(workflow, setupAction);", start); +const verify = vm.runInNewContext(`(${checker.slice(start, end).trim()})`, { assert }) as ( + source: string, + action: string, +) => void; describe("CI remediation wiring", () => { it("accepts the complete enforcing workflow", () => { - expect(() => verify(workflow)).not.toThrow(); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(() => verify(workflow, setupAction)).not.toThrow(); }); it("plans before allocating the unit and browser runner matrices", () => { @@ -73,6 +80,15 @@ describe("CI remediation wiring", () => { ])("rejects silently disabling %s", (_label, mutate) => { const changed = mutate(workflow); expect(changed).not.toBe(workflow); - expect(() => verify(changed)).toThrow(); + expect(() => verify(changed, setupAction)).toThrow(); + }); + + it("rejects a shared setup action that stops attesting the install", () => { + const changed = setupAction.replace( + "run: node scripts/install-locked-dependencies.mjs", + "run: node scripts/install-locked-dependencies.mjs || true", + ); + expect(changed).not.toBe(setupAction); + expect(() => verify(workflow, changed)).toThrow(); }); }); diff --git a/tests/ward-audit-m55-supply-chain.test.ts b/tests/ward-audit-m55-supply-chain.test.ts index 0c1780c09..aef6a2ecc 100644 --- a/tests/ward-audit-m55-supply-chain.test.ts +++ b/tests/ward-audit-m55-supply-chain.test.ts @@ -10,6 +10,9 @@ const ROOT = path.resolve(__dirname, ".."); const WORKFLOWS = path.join(ROOT, ".github/workflows"); const workflowNames = readdirSync(WORKFLOWS).filter((name) => /\.ya?ml$/.test(name)); const workflow = (name: string) => readFileSync(path.join(WORKFLOWS, name), "utf8"); +// Local composite actions the workflows call (`uses: ./.github/actions/...`) hold pins as well. +const ACTIONS = path.join(ROOT, ".github/actions"); +const actionNames = readdirSync(ACTIONS).map((name) => `${name}/action.yml`); describe("M55 workflows pin actions and declare permissions", () => { it("reads every workflow", () => { @@ -31,6 +34,20 @@ describe("M55 workflows pin actions and declare permissions", () => { expect(unpinned).toEqual([]); }); + it("reads every local action", () => { + expect(actionNames).toContain("ward-setup/action.yml"); + }); + + it.each(actionNames)("local action %s pins every action to a full commit", (name) => { + const refs = readFileSync(path.join(ACTIONS, name), "utf8") + .split("\n") + .map((line) => line.replace(/\s+#.*$/, "")) + .filter((line) => /^\s*(-\s+)?uses:/.test(line)) + .map((line) => line.replace(/^\s*(-\s+)?uses:\s*/, "").trim()); + expect(refs.length).toBeGreaterThan(0); + expect(refs.filter((ref) => !/^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/.test(ref))).toEqual([]); + }); + it.each(workflowNames)("%s declares its permissions at the top", (name) => { expect(workflow(name)).toMatch(/^permissions:/m); expect(workflow(name)).not.toMatch(/^\s*permissions:\s*write-all\b/m); @@ -50,7 +67,7 @@ describe("M55 workflows pin actions and declare permissions", () => { const config = readFileSync(path.join(ROOT, ".github/dependabot.yml"), "utf8"); expect(config).toMatch(/package-ecosystem: npm\s+directory: \/\n/); expect(config).toMatch(/package-ecosystem: npm\s+directory: \/backend\/ward-flow/); - expect(config).toMatch(/package-ecosystem: github-actions/); + expect(config).toMatch(/package-ecosystem: github-actions\s+directories: \["\/", "\/\.github\/actions\/\*"\]\n/); }); }); diff --git a/tests/ward-backend-ci.test.ts b/tests/ward-backend-ci.test.ts index 0fdebf139..5e11439b9 100644 --- a/tests/ward-backend-ci.test.ts +++ b/tests/ward-backend-ci.test.ts @@ -60,7 +60,11 @@ describe("Ward backend CI", () => { "needs.plan.outputs.backend == 'true' && needs.reuse.outputs.backend_verified != 'true'", ); expect(backendJob?.text).toContain("needs.plan.result == 'success'"); - expect(backendJob?.text).toMatch(/^\s*run: node scripts\/install-locked-dependencies\.mjs\s*$/mu); + // The root install runs inside the shared setup action, whose outcome is steps.install. + expect(backendJob?.text).toMatch(/^\s*id: install\n\s*uses: \.\/\.github\/actions\/ward-setup\s*$/mu); + expect(readFileSync(new URL("../.github/actions/ward-setup/action.yml", import.meta.url), "utf8")).toMatch( + /^\s*run: node scripts\/install-locked-dependencies\.mjs\s*$/mu, + ); expect(backendJob?.text).toMatch(/^\s*image:\s*postgres:16@sha256:[0-9a-f]{64}\s*$/mu); expect(backendJob?.text).toMatch(/^\s*POSTGRES_DB:\s*wardflow_test\s*$/mu); expect(backendJob?.text).toMatch(/^\s*ports:\s*\[\s*"127\.0\.0\.1:5432:5432"\s*\]\s*$/mu); From ac53e6b59c4d31a69145911b7aefe0b356c41177 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:10:53 +0000 Subject: [PATCH 09/40] Read pull-hold lapse through the one bed-hold rule Movements and the fixed questions used pullExpiresAt < now, so a pull at its limit read as held there and lapsed everywhere else. Both now call bedHoldLapsed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../movements/movements-screen-helpers.ts | 3 ++- .../ward-management/reports/fixed-questions.ts | 3 ++- tests/ward-fixed-questions.test.ts | 12 ++++++++++++ tests/ward-pull-hold-boundary.test.ts | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/ward-pull-hold-boundary.test.ts diff --git a/src/components/ward-management/movements/movements-screen-helpers.ts b/src/components/ward-management/movements/movements-screen-helpers.ts index 00e4b261f..50a88f2bc 100644 --- a/src/components/ward-management/movements/movements-screen-helpers.ts +++ b/src/components/ward-management/movements/movements-screen-helpers.ts @@ -3,6 +3,7 @@ // of movements-screen.tsx to keep that file under the line cap; movements-screen.tsx re-exports // causeOf and inServiceSuffix. import { type Instant } from "@/components/ward-management/ward-clock"; +import { bedHoldLapsed } from "@/components/ward-management/ward-bed-hold"; import { isOpen, transportLeg, transportNeedState } from "@/components/ward-management/ward-derivations"; import { type DelayCause } from "@/components/ward-management/delays/delays-derivations"; import { edById } from "@/components/ward-management/ward-sites"; @@ -159,7 +160,7 @@ export function causeOf( if (noAccepted && hasReferred) return "awaiting_ward_answer"; - if (movement.stage === "pulled" && movement.pullExpiresAt !== undefined && movement.pullExpiresAt < now) { + if (movement.stage === "pulled" && bedHoldLapsed(movement.pullExpiresAt, now)) { return "bed_pull_expired"; } diff --git a/src/components/ward-management/reports/fixed-questions.ts b/src/components/ward-management/reports/fixed-questions.ts index 6852fd43b..d2a98c172 100644 --- a/src/components/ward-management/reports/fixed-questions.ts +++ b/src/components/ward-management/reports/fixed-questions.ts @@ -6,6 +6,7 @@ // came from so the screen can link to it. Nothing here releases, books or changes anything. import type { Admission } from "@/components/ward-management/ward-admissions"; import { formatInstantWithDay, type Instant } from "@/components/ward-management/ward-clock"; +import { bedHoldLapsed } from "@/components/ward-management/ward-bed-hold"; import { transportNeedState } from "@/components/ward-management/ward-derivations-transport"; import type { BedRelease, Movement, MovementStage, Unit } from "@/components/ward-management/ward-model"; import { originName } from "@/components/ward-management/ward-movement-origin"; @@ -138,7 +139,7 @@ export function fixedQuestionAnswers(input: FixedQuestionInput): FixedAnswer[] { const pastLimit = movements .filter((movement) => open(movement) && movement.stage === "pulled") - .filter((movement) => movement.pullExpiresAt !== undefined && movement.pullExpiresAt < now) + .filter((movement) => bedHoldLapsed(movement.pullExpiresAt, now)) .map((movement) => movementRow( movement, diff --git a/tests/ward-fixed-questions.test.ts b/tests/ward-fixed-questions.test.ts index d2356caff..1ac38bfdf 100644 --- a/tests/ward-fixed-questions.test.ts +++ b/tests/ward-fixed-questions.test.ts @@ -54,6 +54,18 @@ describe("fixedQuestionAnswers", () => { expect(row?.detail).toContain("until a person releases it"); }); + it("counts a pull as past its limit at the limit itself, the same boundary as the bed-hold rule", () => { + const state = seedWardFlowState(); + const pulled = state.movements.find((movement) => movement.stage === "pulled")!; + const at = (pullExpiresAt: number) => + rowsOf( + "pulls_past_limit", + state.movements.map((movement) => (movement.id === pulled.id ? { ...movement, pullExpiresAt } : movement)), + ).map((row) => row.key); + expect(at(NOW_ANCHOR)).toContain(pulled.id); + expect(at(NOW_ANCHOR + 1)).not.toContain(pulled.id); + }); + it("lists open requests with no ward asked, in recorded order", () => { const state = seedWardFlowState(); const keys = rowsOf("requests_no_ward_asked").map((row) => row.key); diff --git a/tests/ward-pull-hold-boundary.test.ts b/tests/ward-pull-hold-boundary.test.ts new file mode 100644 index 000000000..0604483e5 --- /dev/null +++ b/tests/ward-pull-hold-boundary.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { causeOf } from "@/components/ward-management/movements/movements-screen-helpers"; +import { bedHoldLapsed } from "@/components/ward-management/ward-bed-hold"; +import { seedWardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; + +// One bed-hold boundary: Movements reads a pull as lapsed at its limit, exactly as `bedHold` does. +describe("Movements pull-hold boundary", () => { + it("calls a pull expired at the limit itself, the same as the bed-hold rule", () => { + const state = seedWardFlowState(); + const pulled = state.movements.find((movement) => movement.stage === "pulled")!; + const at = { ...pulled, pullExpiresAt: NOW_ANCHOR }; + expect(bedHoldLapsed(NOW_ANCHOR, NOW_ANCHOR)).toBe(true); + expect(causeOf(at, NOW_ANCHOR, new Map())).toBe("bed_pull_expired"); + expect(causeOf({ ...pulled, pullExpiresAt: NOW_ANCHOR + 1 }, NOW_ANCHOR, new Map())).not.toBe("bed_pull_expired"); + }); +}); From 3fc32f77ae3c6c979b7b72f3a2be8bb25e486b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:10:54 +0000 Subject: [PATCH 10/40] Keep the staff request shift the desk chose The shared server stamps its own now, so This shift pressed at 14:59 and saved after 15:00 landed on the next shift. The sheet now sends shiftStartAt; the reducer keeps it only when it is the chosen shift's start within 5 minutes of now, otherwise it reads the shift at now. The shared command schema types it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- backend/ward-flow/command-schemas.ts | 2 + .../staffing/staff-needed-sheet.tsx | 2 + .../staffing/staff-requests.ts | 28 ++++++++++- tests/ward-staff-requests.test.ts | 46 ++++++++++++++++++- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/backend/ward-flow/command-schemas.ts b/backend/ward-flow/command-schemas.ts index 35ca807d4..ba7881523 100644 --- a/backend/ward-flow/command-schemas.ts +++ b/backend/ward-flow/command-schemas.ts @@ -129,6 +129,8 @@ const SCHEMAS: Partial> = { FLAG_RETURN_EARLY: z.looseObject({ admissionId: id, homeHospital: id }), RECORD_MEDICAL_CLEARANCE: z.looseObject({ referralId: id, cleared: z.boolean() }), REQUEST_CAPACITY_REFRESH: z.looseObject({ unitId: id }), + // Staff needed: the shift start the desk chose, when sent. The reducer checks it against now. + REQUEST_WARD_STAFF: z.looseObject({ shiftStartAt: instant.optional() }), // H-0978: one exact bed closed or reopened. The reducer checks the bed and the reason list. SET_BED_CLOSED: z.looseObject({ unitId: id, actingUnitId: id, bedNumber: count, closed: z.boolean() }), CONFIRM_CAPACITY: z.looseObject({ diff --git a/src/components/ward-management/staffing/staff-needed-sheet.tsx b/src/components/ward-management/staffing/staff-needed-sheet.tsx index bf47c8e3c..fd37dcc95 100644 --- a/src/components/ward-management/staffing/staff-needed-sheet.tsx +++ b/src/components/ward-management/staffing/staff-needed-sheet.tsx @@ -15,6 +15,7 @@ import { STAFF_REQUEST_SHIFTS, openStaffRequests, staffCountLabel, + staffRequestShiftStart, staffRequestShiftWord, type StaffRequestCount, type StaffRequestReason, @@ -119,6 +120,7 @@ export function StaffNeededSheet({ unitId, ...(actingUnitId ? { actingUnitId } : {}), shift, + shiftStartAt: staffRequestShiftStart(shift, now), count, reasons, }), diff --git a/src/components/ward-management/staffing/staff-requests.ts b/src/components/ward-management/staffing/staff-requests.ts index 5cccb540e..d1cd75543 100644 --- a/src/components/ward-management/staffing/staff-requests.ts +++ b/src/components/ward-management/staffing/staff-requests.ts @@ -72,6 +72,13 @@ export type StaffRequestEvent = /** Claim, not proof, as `SET_BED_CLOSED`: required of a ward desk, which asks for itself only. */ actingUnitId?: string; shift: StaffRequestShift; + /** + * The start of the shift the desk chose, read on its own clock when it pressed save. The + * shared server stamps its own `now`, so without this a "This shift" pressed at 14:59 and + * saved at 15:00 would land on the 15:00 shift. The reducer keeps it only when it is the + * chosen shift's start within `STAFF_REQUEST_SHIFT_TOLERANCE_MINUTES` of `now`. + */ + shiftStartAt?: number; count: StaffRequestCount; reasons: StaffRequestReason[]; } @@ -125,10 +132,27 @@ export function staffRequestTagText(record: StaffRequestRecord, now: Instant): s return `Staff requested ${formatInstantWithDay(record.updatedAt ?? record.requestedAt, now)} · ${record.count >= 4 ? "4+" : record.count} · ${staffRequestShiftWord(record, now)}`; } -function shiftStartFor(shift: StaffRequestShift, now: Instant): Instant { +/** The start of the shift a "This shift" or "Next shift" choice names at `now`. */ +export function staffRequestShiftStart(shift: StaffRequestShift, now: Instant): Instant { return shift === "this_shift" ? currentShiftStartInstant(now) : shiftEndInstant(now); } +/** How long a save may take to reach the shared board and still keep the shift the desk chose. */ +export const STAFF_REQUEST_SHIFT_TOLERANCE_MINUTES = 5; + +/** + * The shift the request is for: the desk's own `shiftStartAt` when it is what the chosen word + * meant at some moment within the tolerance before `now`, otherwise the shift read at `now`. + * Shifts are hours long, so checking both ends of the window covers every moment in it. + */ +function chosenShiftStart(event: Extract): Instant { + const atNow = staffRequestShiftStart(event.shift, event.now); + const chosen = event.shiftStartAt; + if (typeof chosen !== "number" || !Number.isFinite(chosen)) return atNow; + const pressedAt = event.now - STAFF_REQUEST_SHIFT_TOLERANCE_MINUTES; + return chosen === atNow || chosen === staffRequestShiftStart(event.shift, pressedAt) ? chosen : atNow; +} + export function reduceStaffRequest(state: WardFlowState, event: StaffRequestEvent, reject: Reject): WardFlowState { const records = state.staffRequests ?? []; if (event.role === "ward" && event.actingUnitId !== event.unitId) @@ -147,7 +171,7 @@ export function reduceStaffRequest(state: WardFlowState, event: StaffRequestEven !reasons.every((reason) => STAFF_REQUEST_REASONS.includes(reason)) ) return reject(state, event, "REQUEST_WARD_STAFF needs one or more reasons chosen from the list"); - const shiftStartAt = shiftStartFor(event.shift, event.now); + const shiftStartAt = chosenShiftStart(event); const ordered = STAFF_REQUEST_REASONS.filter((reason) => reasons.includes(reason)); // One open request per ward per shift: asking again updates it rather than adding a second. const existing = records.find( diff --git a/tests/ward-staff-requests.test.ts b/tests/ward-staff-requests.test.ts index f85576fc7..e331ed37a 100644 --- a/tests/ward-staff-requests.test.ts +++ b/tests/ward-staff-requests.test.ts @@ -5,10 +5,13 @@ import { describe, expect, it } from "vitest"; import { openStaffRequestFor, openStaffRequests, + STAFF_REQUEST_SHIFT_TOLERANCE_MINUTES, + staffRequestShiftStart, staffRequestTagText, type StaffRequestEvent, } from "@/components/ward-management/staffing/staff-requests"; -import { shiftEndInstant } from "@/components/ward-management/ward-board-time-features"; +import { currentShiftStartInstant, shiftEndInstant } from "@/components/ward-management/ward-board-time-features"; +import { validSharedCommand } from "../backend/ward-flow/command-schemas"; import { seedWardFlowState, wardFlowReducer, type WardFlowState } from "@/components/ward-management/ward-flow-reducer"; import { isValidStoredWardFlowState } from "@/components/ward-management/ward-flow-storage-validation"; import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; @@ -129,4 +132,45 @@ describe("staff needed requests", () => { expect(openStaffRequestFor(asked.staffRequests, UNIT, end - 1)).toBeDefined(); expect(openStaffRequestFor(asked.staffRequests, UNIT, end)).toBeUndefined(); }); + + it("keeps the shift the desk chose when the shared save lands just after the shift changes", () => { + const boundary = shiftEndInstant(NOW_ANCHOR); + const pressedAt = boundary - 1; + const savedAt = boundary + 1; + const startOf = (state: WardFlowState) => + state.staffRequests!.filter((record) => record.unitId === UNIT).at(-1)!.shiftStartAt; + for (const shift of ["this_shift", "next_shift"] as const) { + const chosen = staffRequestShiftStart(shift, pressedAt); + const kept = wardFlowReducer(seed, ask({ shift, now: savedAt, shiftStartAt: chosen })); + expect(refused(seed, kept)).toBe(false); + expect(startOf(kept)).toBe(chosen); + // Without the desk's choice, the server's later clock reads the next shift (the old bug). + expect(startOf(wardFlowReducer(seed, ask({ shift, now: savedAt })))).toBe(staffRequestShiftStart(shift, savedAt)); + } + expect(staffRequestShiftStart("this_shift", pressedAt)).toBe(currentShiftStartInstant(NOW_ANCHOR)); + }); + + it("falls back to the shift at now when the sent shift start is not the chosen shift's", () => { + const boundary = shiftEndInstant(NOW_ANCHOR); + const savedAt = boundary + STAFF_REQUEST_SHIFT_TOLERANCE_MINUTES + 1; + const startOf = (state: WardFlowState) => + state.staffRequests!.filter((record) => record.unitId === UNIT).at(-1)!.shiftStartAt; + const atNow = staffRequestShiftStart("this_shift", savedAt); + // Pressed too long before the save, a far-off time, the other shift's start and a bad number. + for (const shiftStartAt of [ + staffRequestShiftStart("this_shift", boundary - 1), + NOW_ANCHOR + 10_000, + staffRequestShiftStart("next_shift", savedAt), + Number.NaN, + ]) { + expect(startOf(wardFlowReducer(seed, ask({ now: savedAt, shiftStartAt })))).toBe(atNow); + } + }); + + it("lets the shared command carry the chosen shift start as a number only", () => { + const command = { ...ask(), shiftStartAt: currentShiftStartInstant(NOW_ANCHOR) }; + expect(validSharedCommand(command)).toBe(true); + expect(validSharedCommand(ask())).toBe(true); + expect(validSharedCommand({ ...command, shiftStartAt: "07:00" })).toBe(false); + }); }); From 0bb1ee41629b7049f92dfb36008810b92dc7cd33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:10:54 +0000 Subject: [PATCH 11/40] Parse typed times through minuteOfDayFromClockText Planned admissions and release-day times accepted HH:MM only, while the clock rule elsewhere takes 9:30 and 0930. Both now use the one rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../capacity/planned-admissions-draft.ts | 13 +++++------ .../ward-management/ward/release-day.ts | 23 +++++++------------ tests/ward-planned-admissions-draft.test.ts | 8 +++++-- tests/ward-release-day.test.ts | 6 ++++- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/components/ward-management/capacity/planned-admissions-draft.ts b/src/components/ward-management/capacity/planned-admissions-draft.ts index 705bb42c7..dd070ff0b 100644 --- a/src/components/ward-management/capacity/planned-admissions-draft.ts +++ b/src/components/ward-management/capacity/planned-admissions-draft.ts @@ -10,7 +10,7 @@ import { type PlannedAdmissionReason, } from "../ward-admissions"; import type { OverrideReason } from "../ward-change-reasons"; -import { dayOf, MINUTES_PER_DAY, minuteOfDay, type Instant } from "../ward-clock"; +import { dayOf, MINUTES_PER_DAY, minuteOfDay, minuteOfDayFromClockText, type Instant } from "../ward-clock"; import { requiresAuthorisedDestination } from "../ward-eligibility"; import type { Cohort, LegalStatus, RecordedSex, ReferralGender, Unit } from "../ward-model"; @@ -65,13 +65,12 @@ export function clockInputValue(instant: Instant): string { return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`; } -/** Minutes past midnight for a typed "hh:mm", or null when it is not a clock time. */ +/** + * Minutes past midnight for a typed time, or null when it is not a clock time. Reads the one + * clock-text rule (`minuteOfDayFromClockText`), so "9:05" and "0905" count here as everywhere else. + */ export function parseClockInput(value: string): number | null { - const match = /^(\d{2}):(\d{2})$/.exec(value); - if (!match) return null; - const hours = Number(match[1]); - const minutes = Number(match[2]); - return hours < 24 && minutes < 60 ? hours * 60 + minutes : null; + return minuteOfDayFromClockText(value) ?? null; } /** diff --git a/src/components/ward-management/ward/release-day.ts b/src/components/ward-management/ward/release-day.ts index b7e1adef0..a66c835f4 100644 --- a/src/components/ward-management/ward/release-day.ts +++ b/src/components/ward-management/ward/release-day.ts @@ -1,4 +1,9 @@ -import { MINUTES_PER_DAY, dayOf, type Instant } from "@/components/ward-management/ward-clock"; +import { + MINUTES_PER_DAY, + dayOf, + minuteOfDayFromClockText, + type Instant, +} from "@/components/ward-management/ward-clock"; /** * Task F1 (owner answer 32): a bed-release or leave-bed time is typed with a Today/Tomorrow @@ -10,19 +15,6 @@ export type ReleaseDay = "today" | "tomorrow"; /** Rendered in this fixed order; the chooser is exactly these two options, never free text. */ export const RELEASE_DAYS: readonly ReleaseDay[] = ["today", "tomorrow"]; -/** - * Parses an ``'s `HH:MM` value into a minute-of-day, 0-1439. Malformed or - * empty text refuses rather than guesses — same discipline the function this replaces used. - */ -function parseTimeInputToMinuteOfDay(value: string): number | undefined { - const match = /^(\d{2}):(\d{2})$/.exec(value); - if (!match) return undefined; - const hours = Number(match[1]); - const minutes = Number(match[2]); - if (hours > 23 || minutes > 59) return undefined; - return hours * 60 + minutes; -} - /** * Resolves a typed `HH:MM` plus a Today/Tomorrow choice against the live clock into an `Instant`. * @@ -43,7 +35,8 @@ function parseTimeInputToMinuteOfDay(value: string): number | undefined { * Returns `undefined` for an empty or malformed time, exactly like the function this replaces. */ export function parseReleaseDayInstant(now: Instant, day: ReleaseDay, value: string): Instant | undefined { - const minuteOfDay = parseTimeInputToMinuteOfDay(value); + // The one clock-text rule: "14:00", "9:30" and "0930" all count; malformed or empty refuses. + const minuteOfDay = minuteOfDayFromClockText(value); if (minuteOfDay === undefined) return undefined; const targetDay = dayOf(now) + (day === "tomorrow" ? 1 : 0); return targetDay * MINUTES_PER_DAY + minuteOfDay; diff --git a/tests/ward-planned-admissions-draft.test.ts b/tests/ward-planned-admissions-draft.test.ts index a05d04157..e5cfc7f49 100644 --- a/tests/ward-planned-admissions-draft.test.ts +++ b/tests/ward-planned-admissions-draft.test.ts @@ -40,11 +40,15 @@ describe("applyDraftPatch", () => { }); describe("clock input", () => { - it("round-trips an instant's time of day and refuses what is not hh:mm", () => { + it("round-trips an instant's time of day and refuses what is not a clock time", () => { expect(clockInputValue(MINUTES_PER_DAY + 9 * 60 + 5)).toBe("09:05"); expect(parseClockInput("09:05")).toBe(9 * 60 + 5); expect(parseClockInput("24:00")).toBeNull(); - expect(parseClockInput("9:05")).toBeNull(); + // The one clock-text rule (minuteOfDayFromClockText): "9:05" and "0905" count as 09:05. + expect(parseClockInput("9:05")).toBe(9 * 60 + 5); + expect(parseClockInput("0905")).toBe(9 * 60 + 5); + expect(parseClockInput("9h05")).toBeNull(); + expect(parseClockInput("905")).toBeNull(); expect(parseClockInput("")).toBeNull(); }); }); diff --git a/tests/ward-release-day.test.ts b/tests/ward-release-day.test.ts index aa1b07966..c28203cd2 100644 --- a/tests/ward-release-day.test.ts +++ b/tests/ward-release-day.test.ts @@ -25,6 +25,9 @@ describe("parseReleaseDayInstant", () => { // Naive day-zero parsing would give 840 (0 * 1440 + 840). The clock is on day 1, so a // same-day pick must land on day 1. expect(parseReleaseDayInstant(NOW_DAY_1_AT_10, "today", "14:00")).toBe(1 * MINUTES_PER_DAY + 840); + // The one clock-text rule (minuteOfDayFromClockText): one-digit hours and four digits count too. + expect(parseReleaseDayInstant(NOW_DAY_1_AT_10, "today", "9:30")).toBe(1 * MINUTES_PER_DAY + 570); + expect(parseReleaseDayInstant(NOW_DAY_1_AT_10, "today", "1400")).toBe(1 * MINUTES_PER_DAY + 840); }); it("resolves Tomorrow to the calendar day after the clock's own day", () => { @@ -46,7 +49,8 @@ describe("parseReleaseDayInstant", () => { it("refuses malformed or empty input rather than guessing a value", () => { expect(parseReleaseDayInstant(NOW_DAY_1_AT_10, "today", "")).toBeUndefined(); - expect(parseReleaseDayInstant(NOW_DAY_1_AT_10, "today", "9:00")).toBeUndefined(); + expect(parseReleaseDayInstant(NOW_DAY_1_AT_10, "today", "9h00")).toBeUndefined(); + expect(parseReleaseDayInstant(NOW_DAY_1_AT_10, "today", "143")).toBeUndefined(); expect(parseReleaseDayInstant(NOW_DAY_1_AT_10, "today", "24:00")).toBeUndefined(); expect(parseReleaseDayInstant(NOW_DAY_1_AT_10, "today", "10:60")).toBeUndefined(); }); From e8f5e1e17f857f6c68d5f169fc69acb5a8328e06 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:10:54 +0000 Subject: [PATCH 12/40] Use daysInBed for statistics length of stay Two private stayDays copies dropped a future arrival that daysInBed (and the stay bands) count as 0. Past-arrival figures are unchanged; statistics tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../statistics/executive-statistics-derivations.ts | 8 ++------ .../statistics/statistics-wards-index.tsx | 11 ++--------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/components/ward-management/statistics/executive-statistics-derivations.ts b/src/components/ward-management/statistics/executive-statistics-derivations.ts index 0421d774f..956ff9d3d 100644 --- a/src/components/ward-management/statistics/executive-statistics-derivations.ts +++ b/src/components/ward-management/statistics/executive-statistics-derivations.ts @@ -1,6 +1,6 @@ import type { WardFlowState } from "../ward-flow-reducer"; import { wardStatistics } from "../ward-statistics"; -import { bedIsOccupied, endedInWardTransfer, STAY_BANDS, stayBand } from "../ward-admissions"; +import { bedIsOccupied, daysInBed, endedInWardTransfer, STAY_BANDS, stayBand } from "../ward-admissions"; import { dayOf, MINUTES_PER_DAY, isClockBreached } from "../ward-clock"; import { openBedsNow, bedsPendingPreparation } from "../ward-bed-availability"; import { dischargeDateCoverage } from "./statistics-ward-discharge-dates"; @@ -42,10 +42,6 @@ import { isEdOriginJourney } from "../ward-ed-presence"; import { readyBedsNow } from "../ward-bed-states"; import { lockedBedsFree, openBedsFree } from "../ward-bed-designation"; -function stayDays(arrivedAt: number | null, now: number): number | null { - return arrivedAt === null || arrivedAt > now ? null : Math.floor((now - arrivedAt) / MINUTES_PER_DAY); -} - export type ExecutivePrivateInput = WeeklyReportInput & Pick & { pullHoldMinutes: number; @@ -148,7 +144,7 @@ export function executiveWardSummary(ward: PublicWard, input: ExecutivePrivateIn release.partition(dischargeDateOutcomes.consideredCount, [dischargeDateOutcomes.met, dischargeDateOutcomes.missed]); const stays = currentRows .filter(bedIsOccupied) - .map((row) => stayDays(row.arrivedAt, now)) + .map((row) => daysInBed(row, now)) .filter((value): value is number => value !== null); const longStays = c("longStays", statistics.longStays); const longStayBand = stayBands.find((band) => band.id === "over-3-months"); diff --git a/src/components/ward-management/statistics/statistics-wards-index.tsx b/src/components/ward-management/statistics/statistics-wards-index.tsx index 35757c17a..8fb894d18 100644 --- a/src/components/ward-management/statistics/statistics-wards-index.tsx +++ b/src/components/ward-management/statistics/statistics-wards-index.tsx @@ -26,7 +26,7 @@ import { } from "@/components/wf"; import { wardStatisticsHref } from "@/components/ward-management/shell/ward-facade"; import { BED_ALERT_THRESHOLD_PERCENT } from "@/components/ward-management/shell/ward-service-bed-alerts"; -import { bedIsOccupied } from "@/components/ward-management/ward-admissions"; +import { bedIsOccupied, daysInBed } from "@/components/ward-management/ward-admissions"; import { bedsPendingPreparation } from "@/components/ward-management/ward-bed-availability"; import { BED_STATE_LABELS, bedStates } from "@/components/ward-management/ward-bed-states"; import { @@ -36,7 +36,6 @@ import { OCCUPIED_NOUN, wardBedsInUse, } from "@/components/ward-management/ward-occupancy"; -import { MINUTES_PER_DAY } from "@/components/ward-management/ward-clock"; import { wardServiceOrder } from "@/components/ward-management/ward-derivations"; import type { HealthService } from "@/components/ward-management/ward-model"; import { siteByCode } from "@/components/ward-management/ward-sites"; @@ -101,12 +100,6 @@ export function SortTh({ type WardChip = "over" | "none-ready" | "pending" | "pulled" | "blocked"; -/** Whole days in a bed so far, or null for someone not yet arrived (or a clock wound back). */ -function stayDays(arrivedAt: number | null, now: number): number | null { - if (arrivedAt === null || arrivedAt > now) return null; - return Math.floor((now - arrivedAt) / MINUTES_PER_DAY); -} - const plural = (count: StatisticsCount, one: string, many: string) => (count === 1 ? one : many); /** How much of the list shows before Show more. */ @@ -196,7 +189,7 @@ function OperationalWardsIndex() { ); const stays = current .filter((admission) => bedIsOccupied(admission)) - .map((admission) => stayDays(admission.arrivedAt, now)) + .map((admission) => daysInBed(admission, now)) .filter((days): days is number => days !== null); return { id: unit.id, From 1334b3c3653e81d3994469f3a17a48b088934fee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:10:54 +0000 Subject: [PATCH 13/40] End demo passes when WARD_DEMO_CODE changes A demo pass now carries a keyed fingerprint of the code it was opened with, and the proxy refuses one whose fingerprint no longer matches the code set. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- docs/ward-flow/OPERATIONS-RUNBOOK.md | 2 +- src/lib/front-door-pass.ts | 35 ++++++++++++-- src/proxy.ts | 8 +++- tests/front-door-demo-pass-binding.test.ts | 56 ++++++++++++++++++++++ 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 tests/front-door-demo-pass-binding.test.ts diff --git a/docs/ward-flow/OPERATIONS-RUNBOOK.md b/docs/ward-flow/OPERATIONS-RUNBOOK.md index 8939b2886..94293a289 100644 --- a/docs/ward-flow/OPERATIONS-RUNBOOK.md +++ b/docs/ward-flow/OPERATIONS-RUNBOOK.md @@ -60,7 +60,7 @@ so changing them needs a restart, not a rebuild: With it on, a page without a pass goes to `/sign-in` and an API call gets 401. On the signed-in site the pass lasts until the 12-hour sign-in limit and ends at sign-out. A demo code pass lasts 8 -hours. `/api/health`, the offline page and the alert worker stay open. To switch it off, set +hours, and ends at once when `WARD_DEMO_CODE` changes. `/api/health`, the offline page and the alert worker stay open. To switch it off, set `WARD_FRONT_DOOR=off` and restart. Code: `src/lib/front-door-pass.ts`, `src/proxy.ts`, `src/app/api/session/route.ts`. diff --git a/src/lib/front-door-pass.ts b/src/lib/front-door-pass.ts index 3f07c7920..238319794 100644 --- a/src/lib/front-door-pass.ts +++ b/src/lib/front-door-pass.ts @@ -3,7 +3,8 @@ * switch `WARD_FRONT_DOOR=required` is set, no Ward Flow page or API answers until this browser * holds a signed pass. The pass is an HttpOnly cookie that `POST /api/session` sets after it has * checked a Supabase sign-in with Supabase itself, or a demo code on the public demo. It ends with - * the 12-hour sign-in limit (8 hours for a demo code) and on sign-out. + * the 12-hour sign-in limit (8 hours for a demo code) and on sign-out. A demo pass also ends as + * soon as `WARD_DEMO_CODE` changes. * * The pass only opens the door. It never stands in for the sign-in: the shared workspace still * checks the account's own token on every request, so a pass without a sign-in shows the sign-in @@ -91,6 +92,11 @@ export type FrontDoorPass = { kind: "account" | "demo"; /** The Supabase account id for an account pass. */ subject?: string; + /** + * A demo pass's fingerprint of the demo code it was opened with (`demoCodeFingerprint`), so + * changing `WARD_DEMO_CODE` closes every demo pass at once rather than up to 8 hours later. + */ + code?: string; issuedAt: number; expiresAt: number; }; @@ -128,16 +134,39 @@ function sameText(a: string, b: string): boolean { return difference === 0; } -/** A signed pass value and its cookie lifetime in seconds. */ +/** + * A demo pass's tie to the demo code now set: a keyed hash of the normalised code, so the cookie + * never shows the code itself. Any change to `WARD_DEMO_CODE` changes it. + */ +export async function demoCodeFingerprint(secret: string, env: Env = process.env): Promise { + return (await sign(secret, `demo-code:${normaliseDemoCode(env.WARD_DEMO_CODE ?? "")}`)).slice(0, 22); +} + +/** + * Whether a verified pass still stands against the settings now: an account pass always, a demo + * pass only while `WARD_DEMO_CODE` is the code it was opened with. + */ +export async function passMatchesDemoCode( + secret: string, + pass: FrontDoorPass, + env: Env = process.env, +): Promise { + if (pass.kind !== "demo") return true; + return typeof pass.code === "string" && sameText(pass.code, await demoCodeFingerprint(secret, env)); +} + +/** A signed pass value and its cookie lifetime in seconds. A demo pass carries the code's fingerprint. */ export async function issuePass( secret: string, - pass: Omit & { hours: number; endsBy?: number | null }, + pass: Omit & { hours: number; endsBy?: number | null }, now = Date.now(), + env: Env = process.env, ): Promise<{ value: string; maxAge: number }> { const limit = now + pass.hours * 3_600_000; const expiresAt = pass.endsBy && pass.endsBy < limit ? pass.endsBy : limit; const body: FrontDoorPass = { kind: pass.kind, issuedAt: now, expiresAt }; if (pass.subject) body.subject = pass.subject; + if (pass.kind === "demo") body.code = await demoCodeFingerprint(secret, env); const payload = toBase64Url(encoder.encode(JSON.stringify(body))); return { value: `${payload}.${await sign(secret, payload)}`, diff --git a/src/proxy.ts b/src/proxy.ts index 5d9b3fa0c..67eee6390 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -8,6 +8,7 @@ import { frontDoorRequired, frontDoorSecret, isFrontDoorOpenPath, + passMatchesDemoCode, passOpensSite, safeNextPath, verifyPass, @@ -208,13 +209,16 @@ export async function frontDoorVerdict( ): Promise { const { pathname, search } = request.nextUrl; if (!frontDoorRequired(environment) || isFrontDoorOpenPath(pathname)) return null; - const pass = await verifyPass(frontDoorSecret(environment), request.cookies.get(FRONT_DOOR_PASS_COOKIE)?.value); + const secret = frontDoorSecret(environment); + const pass = await verifyPass(secret, request.cookies.get(FRONT_DOOR_PASS_COOKIE)?.value); // The literal read is inlined at build time, as in POST /api/session; the environment read covers // a runtime value and tests. const shared = (environment.NEXT_PUBLIC_WARD_SHARED_ENABLED ?? (environment === process.env ? process.env.NEXT_PUBLIC_WARD_SHARED_ENABLED : undefined)) === "true"; - if (pass && passOpensSite(pass, shared)) return null; + // A demo pass also ends as soon as WARD_DEMO_CODE changes (it carries the code's fingerprint). + if (secret && pass && passOpensSite(pass, shared) && (await passMatchesDemoCode(secret, pass, environment))) + return null; if (pathname === "/api" || pathname.startsWith("/api/")) return NextResponse.json({ error: "Sign in first.", code: "front_door" }, { status: 401 }); const url = request.nextUrl.clone(); diff --git a/tests/front-door-demo-pass-binding.test.ts b/tests/front-door-demo-pass-binding.test.ts new file mode 100644 index 000000000..dcbf9f24d --- /dev/null +++ b/tests/front-door-demo-pass-binding.test.ts @@ -0,0 +1,56 @@ +import { NextRequest } from "next/server"; +import { describe, expect, it } from "vitest"; + +import { + FRONT_DOOR_PASS_COOKIE, + demoCodeFingerprint, + issuePass, + passMatchesDemoCode, + verifyPass, +} from "../src/lib/front-door-pass"; +import { frontDoorVerdict } from "../src/proxy"; + +// Synthetic secrets and codes only: these values exist for this test file. +const SECRET = "synthetic-binding-secret-0123456789abcdef"; // gitleaks:allow +const CODE = "SYNTHETIC-DEMO-CODE-A"; +const ON = { WARD_FRONT_DOOR: "required", WARD_PASS_SECRET: SECRET, WARD_DEMO_CODE: CODE }; + +function page(path: string, pass: string): NextRequest { + const request = new NextRequest(new URL(path, "https://ward.example.test")); + request.cookies.set(FRONT_DOOR_PASS_COOKIE, pass); + return request; +} + +describe("demo pass bound to the demo code", () => { + it("carries a fingerprint of the code, never the code itself", async () => { + const { value } = await issuePass(SECRET, { kind: "demo", hours: 8 }, Date.now(), ON); + const pass = (await verifyPass(SECRET, value))!; + expect(pass.code).toBe(await demoCodeFingerprint(SECRET, ON)); + expect(Buffer.from(value.split(".")[0]!, "base64url").toString("utf8")).not.toContain("SYNTHETIC"); + // The same code in another form (case, spaces, dashes) is the same code. + expect(await demoCodeFingerprint(SECRET, { WARD_DEMO_CODE: "synthetic demo code a" })).toBe(pass.code); + }); + + it("opens the demo while the code stands and closes it as soon as the code changes", async () => { + const { value } = await issuePass(SECRET, { kind: "demo", hours: 8 }, Date.now(), ON); + expect(await frontDoorVerdict(page("/ward-flow/capacity", value), ON)).toBeNull(); + const changed = { ...ON, WARD_DEMO_CODE: "SYNTHETIC-DEMO-CODE-B" }; + const refused = await frontDoorVerdict(page("/ward-flow/capacity", value), changed); + expect(refused?.status).toBe(307); + expect(new URL(refused!.headers.get("location")!).pathname).toBe("/sign-in"); + expect((await frontDoorVerdict(page("/api/workspace", value), changed))?.status).toBe(401); + const removed = { WARD_FRONT_DOOR: "required", WARD_PASS_SECRET: SECRET }; + expect((await frontDoorVerdict(page("/api/workspace", value), removed))?.status).toBe(401); + }); + + it("refuses a demo pass with no fingerprint and leaves account passes alone", async () => { + const now = Date.now(); + const bare = { kind: "demo" as const, issuedAt: now, expiresAt: now + 60_000 }; + expect(await passMatchesDemoCode(SECRET, bare, ON)).toBe(false); + const account = (await issuePass(SECRET, { kind: "account", subject: "synthetic-id", hours: 12 }, now, ON)).value; + const checked = (await verifyPass(SECRET, account))!; + expect(checked.code).toBeUndefined(); + expect(await passMatchesDemoCode(SECRET, checked, { ...ON, WARD_DEMO_CODE: "OTHER" })).toBe(true); + expect(await frontDoorVerdict(page("/ward-flow", account), { ...ON, WARD_DEMO_CODE: "OTHER" })).toBeNull(); + }); +}); From a473c962ad3960cc6668090525252889f569fc81 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:10:54 +0000 Subject: [PATCH 14/40] Add record-id and web-push rows to the real-data checklist Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- docs/ward-flow/governance/REAL-DATA-GO-NO-GO.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/ward-flow/governance/REAL-DATA-GO-NO-GO.md b/docs/ward-flow/governance/REAL-DATA-GO-NO-GO.md index 7d87d08c8..72deba816 100644 --- a/docs/ward-flow/governance/REAL-DATA-GO-NO-GO.md +++ b/docs/ward-flow/governance/REAL-DATA-GO-NO-GO.md @@ -1,5 +1,7 @@ # Real patient data: go/no-go checklist +_Updated 2026-11-11 - section 1 adds C8 (record ids never from a medical record number) and C9 (web push library check); open sign-up off is already the Supabase row in section 4 (M49); Documentation owns._ + _Updated 2026-11-10 - audit M127: section 0 makes this page the one real-data gate tracker for D-87 and D-111 item 8, with owners and the File A to master-list crosswalk; Documentation owns._ _Updated 2026-11-10 - section 4 rechecked against repository records and the Sentry row corrected (audit item M61); drafts linked under sections 2 and 3; Documentation owns._ @@ -57,6 +59,8 @@ list renumbered them. Use the M number in new work. | C5 | **Sign-in token storage.** The Supabase refresh token sits in sessionStorage, readable by page scripts, because the backend is on another site (H-0044). | `ward-flow:auth::v1` in `ward-auth-session.ts` | Either the token moves to an HttpOnly cookie on a same-site backend, or the PIA accepts the risk with a session time limit and the strict content security policy kept. | | C6 | **Data-mode lock opened on purpose.** Migration 7 limits the database to `prototype`, and the backend refuses any other `WARD_DATA_MODE`. | `migrations/007-prototype-only.sql`, `backend/ward-flow/config.mjs` | A new decision and migration allow `live`, only after sections 2 to 5 are complete. Never by hand in the console. | | C7 | **Logs and error reports.** Sentry is server-side, off unless `SENTRY_DSN` is set, and scrubbed. | `src/lib/sentry-server.ts` (`scrubSentryEvent`) | A test sends an event containing synthetic names and free text and shows none of it leaves the server. Railway and Supabase logs checked for request bodies. | +| C8 | **Record ids never from a medical record number.** Record ids (`WF-`, `PT-`, `RF-`, `AD-`) are made up inside Ward Flow. The record-address code only hides an id in the address bar: it is a fixed XOR with a key in the source, so anyone can reverse it. An id built from a UMRN or other medical record number would put that number in every address. | `encodeRecordAddress` in `src/components/ward-management/shell/ward-record-address.ts` | A written rule and a test show no record id is derived from a UMRN or medical record number, or addresses carry an opaque server-issued key instead. | +| C9 | **Web push library checked.** Alerts are sent with the `web-push` package on the backend. It has had no vendor or security review. | `web-push` in `backend/ward-flow/package.json` | A vendor and security check of the package (maintenance, known issues, what it sends to push services) is recorded, or push is turned off for live workspaces. | Already in place, checked 3 Nov 2026: sign-out, expiry and a refused session clear every Ward Flow key from browser storage (`ward-browser-data.ts`); the service worker caches static files From f828b1e9545cada4bb51538ade3efefe8c702ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:13:29 +0000 Subject: [PATCH 15/40] Point the runbook at prune:receipts:supabase and drop the duplicate prune:supabase script Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- backend/ward-flow/package.json | 1 - docs/ward-flow/OPERATIONS-RUNBOOK.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/ward-flow/package.json b/backend/ward-flow/package.json index be1beb5a7..56488ad99 100644 --- a/backend/ward-flow/package.json +++ b/backend/ward-flow/package.json @@ -13,7 +13,6 @@ "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "migrate:postgres": "node --env-file-if-exists=.env migrate-postgres.mjs", "migrate:supabase": "node --env-file-if-exists=.env migrate-supabase.mjs", - "prune:supabase": "node --env-file-if-exists=.env prune-receipts.mjs supabase", "prune:receipts:postgres": "node --env-file-if-exists=.env prune-receipts.mjs postgres", "prune:receipts:supabase": "node --env-file-if-exists=.env prune-receipts.mjs supabase", "prune:receipts:supabase-pruner": "node --env-file-if-exists=.env prune-receipts.mjs supabase-pruner", diff --git a/docs/ward-flow/OPERATIONS-RUNBOOK.md b/docs/ward-flow/OPERATIONS-RUNBOOK.md index 94293a289..5e9cd9e7b 100644 --- a/docs/ward-flow/OPERATIONS-RUNBOOK.md +++ b/docs/ward-flow/OPERATIONS-RUNBOOK.md @@ -106,7 +106,7 @@ Weekly, or before a demonstration. All read-only. 5. **Receipts.** Command receipts older than 7 days are pruned every Monday at 03:00 Perth by `.github/workflows/prune-receipts.yml`. Until the owner adds the repository secret `WARD_SUPABASE_PRUNE_DATABASE_URL`, the weekly run fails on purpose; check it succeeded. To prune by hand, run - `npm --prefix backend/ward-flow run prune:supabase`. It needs the private administrator + `npm --prefix backend/ward-flow run prune:receipts:supabase`. It needs the private administrator environment, so it is a live change. The audit history is never pruned. 6. **Quiet periods.** Two things stop by themselves when nobody is working on Ward Flow: - GitHub turns off every scheduled workflow after 60 days without repository activity: From f5c0cfa4fec2b982bc0be4d293c8636186cdc580 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:13:59 +0000 Subject: [PATCH 16/40] Leave page: Open ward on overdue and absent rows, Print, and a search entry Overdue and absent rows link to the ward's leave view (#ward-leave-bed-form), where leave is ended. Print uses the browser's own print with a print-only scope line. Leave is now found in Search as a screen, besides Tools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../leave/leave-screen.module.css | 46 +++++++++++++++++++ .../ward-management/leave/leave-screen.tsx | 40 ++++++++++++++-- .../search/ward-smart-search.ts | 8 ++++ .../ward-list-print-button.tsx | 34 ++++++++++++++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 src/components/ward-management/ward-list-print-button.tsx diff --git a/src/components/ward-management/leave/leave-screen.module.css b/src/components/ward-management/leave/leave-screen.module.css index 76077392a..dd9b89382 100644 --- a/src/components/ward-management/leave/leave-screen.module.css +++ b/src/components/ward-management/leave/leave-screen.module.css @@ -7,6 +7,13 @@ padding: var(--wf-s-4); } +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--wf-s-3); +} + .title { margin: 0; font-size: var(--wf-fs-20); @@ -87,6 +94,18 @@ font-variant-numeric: tabular-nums; } +/* Overdue or absent rows: the ward page where the leave is ended. */ +.wardLink { + display: block; + margin-top: 2px; + font-size: var(--wf-fs-12); + color: var(--wf-accent-ink); +} + +.printScope { + display: none; +} + .status { display: inline-flex; align-items: center; @@ -183,6 +202,15 @@ content: " · "; } + .table td.ward .wardLink { + display: inline; + } + + .table td.ward .wardLink::before { + content: " · "; + color: var(--wf-ink-2); + } + .table td.kind, .table td.time { width: auto; @@ -200,3 +228,21 @@ font-family: var(--wf-font); color: var(--wf-ink-3); } + +/* Paper: the whole list in the page's flow, with what it holds and when. */ +@media print { + .main { + padding: 0; + } + + .printScope { + display: block; + margin: 0; + color: CanvasText; + font-size: var(--wf-fs-12); + } + + .scroll { + overflow: visible; + } +} diff --git a/src/components/ward-management/leave/leave-screen.tsx b/src/components/ward-management/leave/leave-screen.tsx index 50faee7d3..fb21e709b 100644 --- a/src/components/ward-management/leave/leave-screen.tsx +++ b/src/components/ward-management/leave/leave-screen.tsx @@ -7,8 +7,11 @@ import { useWardFlowClock, useWardFlowSelect } from "@/components/ward-managemen import { useEffectiveWardAccess } from "@/components/ward-management/shell/ward-access-store"; import { wardPlaceLookup } from "@/components/ward-management/ward-access-places"; import { unitInScope } from "@/components/ward-management/ward-role-access"; -import { formatInstantWithDay, type Instant } from "@/components/ward-management/ward-clock"; +import { formatInstantWithDay, formatSheetMoment, type Instant } from "@/components/ward-management/ward-clock"; import { patientHref } from "@/components/ward-management/shell/ward-patient-href"; +import { unitHref } from "@/components/ward-management/shell/ward-facade"; +import { useCanOpenHref } from "@/components/ward-management/shell/ward-access"; +import { ListPrintButton } from "@/components/ward-management/ward-list-print-button"; import { WardPrototypeFooter } from "@/components/ward-management/shell/ward-prototype-footer"; import { wardNames } from "@/components/ward-management/tools/ward-tool-remind-store"; import { LEAVE_STATUS_LABEL, leaveTrackerRows, type LeaveStatus } from "./leave-tracker"; @@ -35,21 +38,30 @@ function LeaveTime({ instant, now }: { instant: Instant; now: Instant }) { ); } +/** + * The ward page's Leave monitoring, where leave is ended: `#ward-leave-bed-form` opens the ward's + * All discharges view (ward-screen.tsx reads the hash), the same address the ward's own links use. + */ +const leaveOnWardHref = (unitId: string) => `${unitHref(unitId)}#ward-leave-bed-form`; + /** * Feature 23 (Josh, 9 Nov 2026): everyone on leave across the desk's wards, with where their bed is, * the kind of leave, when they left, when they are expected back and the status. Read only; leave is - * recorded and ended from the ward's own page. Synthetic records only. + * recorded and ended from the ward's own page, which an overdue or absent row opens (Open ward). + * Print uses the browser's own print, as the other lists do. Synthetic records only. */ export function LeaveScreen() { - const { leaveBeds, units, admissions, bedReleases, resolvePatientIdentity } = useWardFlowSelect( + const { leaveBeds, units, admissions, bedReleases, resolvePatientIdentity, dayZero } = useWardFlowSelect( "leaveBeds", "units", "admissions", "bedReleases", "resolvePatientIdentity", + "dayZero", ); const now = useWardFlowClock(); const access = useEffectiveWardAccess(); + const canOpenHref = useCanOpenHref(); const [needsActionOnly, setNeedsActionOnly] = useState(false); const rows = useMemo(() => { const places = wardPlaceLookup(units); @@ -68,7 +80,15 @@ export function LeaveScreen() { const onLeave = rows.filter((row) => row.status !== "absent").length; return (
-

Leave

+
+

Leave

+ +
+ {/* On paper only: what the list holds and when, as the other printed lists say. */} +

+ {needsActionOnly ? "Overdue or absent only" : "Everyone on leave"} · As at{" "} + {formatSheetMoment(now, dayZero, { includeYearAndZone: true })} · Identifiers may be included +

{onLeave} on leave · {dueToday}{" "} due back today · {needsAction.length} overdue or absent @@ -120,6 +140,18 @@ export function LeaveScreen() { {row.wardName} {row.bedLabel} + {(row.status === "absent" || row.status === "overdue") && + canOpenHref(unitHref(row.unitId)) ? ( + + Open ward + + ) : null} {row.kind} diff --git a/src/components/ward-management/search/ward-smart-search.ts b/src/components/ward-management/search/ward-smart-search.ts index df8eb3f7a..2b0aeda8e 100644 --- a/src/components/ward-management/search/ward-smart-search.ts +++ b/src/components/ward-management/search/ward-smart-search.ts @@ -140,6 +140,14 @@ export const CORE_SEARCH_VIEWS: readonly CoreSearchView[] = [ keywords: ["legal-forms", "legal forms", "forms", "mha", "mental health act", "mental health act forms"], description: "Recorded forms (demo)", }, + { + // Feature 23: the Leave page, also reached from the Tools sheet's Leave due ("See all leave"). + key: "leave", + title: "Leave", + href: "/ward-flow/leave", + keywords: ["leave", "on leave", "due back", "overdue", "absent", "absent without leave", "awol"], + description: "Everyone on leave and due back", + }, { key: "referrals", title: "Referrals", diff --git a/src/components/ward-management/ward-list-print-button.tsx b/src/components/ward-management/ward-list-print-button.tsx new file mode 100644 index 000000000..0843798e6 --- /dev/null +++ b/src/components/ward-management/ward-list-print-button.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Printer } from "lucide-react"; +import { Button } from "@/components/wf"; + +/** + * A list's Print button: the browser's own print of the page as it shows, as Discharges, the call + * sheet and the chronology already do. The page's print styles drop the controls; names print in + * full, as on screen (D-102 keeps names out of downloaded files only). + */ +export function ListPrintButton({ + variant = "sec", + disabled, + testId, +}: { + variant?: "sec" | "ghost"; + disabled?: boolean; + testId?: string; +}) { + return ( + + ); +} From c77d43603efc4a652fd59aef9e4f3ee93be07402 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:13:59 +0000 Subject: [PATCH 17/40] Help pages for Leave, Today's review, Start a triage and the triage form Kept in their own module so PAGE_HELP takes them in one line. The bed meeting TV keeps none: Help cannot open there (HF-10). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../ward-management/help/ward-help-content.ts | 2 + .../help/ward-help-pages-more.ts | 90 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/components/ward-management/help/ward-help-pages-more.ts diff --git a/src/components/ward-management/help/ward-help-content.ts b/src/components/ward-management/help/ward-help-content.ts index 4b934ff53..15d71d546 100644 --- a/src/components/ward-management/help/ward-help-content.ts +++ b/src/components/ward-management/help/ward-help-content.ts @@ -23,6 +23,7 @@ import { siteDirectory } from "@/components/ward-management/ward-sites"; import type { SiteIdentity } from "@/components/ward-management/ward-sites"; import type { WardTourRole } from "./ward-help-store"; +import { MORE_PAGE_HELP } from "./ward-help-pages-more"; /** The Give button's own words, from the value it grants (D-122: help never types a threshold). */ const GIVE_FOR = `Give for ${formatClinicalFigure(CLINICAL_VALUES.accessGrantDefault.value, CLINICAL_VALUES.accessGrantDefault.unit)}`; @@ -604,6 +605,7 @@ type PageHelp = ScreenHelp & { /** Detail and stand-alone pages. Listed most specific first; the first match wins. */ const PAGE_HELP: readonly PageHelp[] = [ + ...MORE_PAGE_HELP, { id: "ward-answer", path: /^\/ward-flow\/ward\/[^/]+\/answer$/u, diff --git a/src/components/ward-management/help/ward-help-pages-more.ts b/src/components/ward-management/help/ward-help-pages-more.ts new file mode 100644 index 000000000..ca17cb75c --- /dev/null +++ b/src/components/ward-management/help/ward-help-pages-more.ts @@ -0,0 +1,90 @@ +import type { ScreenHelp } from "./ward-help-content"; + +/** + * Help for stand-alone pages that showed "no help page yet" (11 Nov 2026): Leave, Today's review, + * Start a triage and a person's triage form. Kept in its own module so `ward-help-content.ts`'s + * `PAGE_HELP` takes them in one line. Each entry describes the page as its source draws it. + * + * The bed meeting TV (`/ward-flow/bed-meeting`) has none on purpose: it hides the header, so Help + * cannot open there, and its help sits on the screens that open it (HF-10, `TV_QUESTION`). + */ +export const MORE_PAGE_HELP: readonly (ScreenHelp & { id: string; path: RegExp })[] = [ + { + id: "leave", + path: /^\/ward-flow\/leave$/u, + title: "Leave", + purpose: + "Everyone on leave across your wards: their bed, the kind of leave, when they left, when they are expected back and their status.", + actions: [ + "Overdue or absent shows only the people overdue back or absent without leave.", + "Open ward on an overdue or absent row opens that ward's page, where leave is ended.", + "Print makes a paper copy of the list as it shows.", + ], + questions: [ + { + question: "How do I record or end leave?", + answer: + "This page is read only. Start leave from the patient's page with Record a change. End it on the ward's page, under Leave monitoring, with Ended.", + }, + { + question: "When is someone overdue?", + answer: + "Once the expected return recorded with their leave has passed. Absent without leave shows while the ward's absence record stands.", + }, + ], + }, + { + id: "todays-review", + path: /^\/ward-flow\/review$/u, + title: "Today's review", + purpose: + "What to settle before you hand over, and every decline, override, open problem and action the app stopped today.", + actions: [ + "Before you hand over lists overrides to review, a ward's repeated declines and problems still open.", + "Mark reviewed on an override records your role and the time with the governance record.", + "Show narrows Everything today to declines, overrides, problems or actions stopped by the app.", + ], + questions: [ + { + question: "Who can mark an override reviewed?", + answer: "Any coordinator on shift, including the person who made the override.", + }, + { + question: "Where is the full history?", + answer: "Today's review shows today only. Governance holds the full history.", + }, + ], + }, + { + id: "triage-start", + path: /^\/ward-flow\/triage\/new$/u, + title: "Start a triage", + purpose: "Choose who a mental health triage and risk assessment is for.", + actions: [ + "Search by name, UMRN or date of birth, or choose a referral awaiting triage.", + "Filter the referrals by source, and sort them from the menu beside the tabs.", + "Not found? Add patient makes a new record first.", + ], + }, + { + id: "triage", + path: /^\/ward-flow\/people\/[^/]+\/triage$/u, + title: "Triage and risk", + purpose: "One person's triage and risk assessment (SMHMR900 and SMHMR905). It opens blank.", + actions: [ + "Work through the sections in order, from Review details to Formulation and plan.", + "Show what's left lists the answers still needed before signing.", + "Sign creates both records. How to explains the form.", + ], + questions: [ + { + question: "Why can't I sign yet?", + answer: "Every needed answer must be in first. Sign says how many are left, and Show what's left lists them.", + }, + { + question: "Does the form decide the urgency or the risk?", + answer: "No. You choose every clinical answer. Nothing clinical is filled in for you.", + }, + ], + }, +]; From fe1855482694cb7c75c85dd4acf8960332fe9642 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:14:00 +0000 Subject: [PATCH 18/40] Print and spreadsheet export on Legal forms and Movements Reuses ListExportButton and a shared Print button. Exports carry record numbers and UMRNs, never names (D-102); a form's end is exactly as typed with its date, never a countdown (D-93). Desktop only, as on Referrals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../legal-forms/legal-forms-export.ts | 102 ++++++++++++++ .../legal-forms/legal-forms-screen.tsx | 39 ++++- .../movements/movements-export.ts | 100 +++++++++++++ .../movements/movements-list-tools.tsx | 26 ++++ .../movements/movements-screen.tsx | 28 ++-- tests/ward-audit-m124-outbound-marker.test.ts | 2 + tests/ward-legal-movements-export.test.ts | 81 +++++++++++ .../ward-list-print-export-more.dom.test.tsx | 133 ++++++++++++++++++ 8 files changed, 495 insertions(+), 16 deletions(-) create mode 100644 src/components/ward-management/legal-forms/legal-forms-export.ts create mode 100644 src/components/ward-management/movements/movements-export.ts create mode 100644 src/components/ward-management/movements/movements-list-tools.tsx create mode 100644 tests/ward-legal-movements-export.test.ts create mode 100644 tests/ward-list-print-export-more.dom.test.tsx diff --git a/src/components/ward-management/legal-forms/legal-forms-export.ts b/src/components/ward-management/legal-forms/legal-forms-export.ts new file mode 100644 index 000000000..9c95d2111 --- /dev/null +++ b/src/components/ward-management/legal-forms/legal-forms-export.ts @@ -0,0 +1,102 @@ +import { formatSheetMoment, isClockBreached, type Instant } from "@/components/ward-management/ward-clock"; +import { legalFormName } from "@/components/ward-management/ward-legal-forms"; +import { FORM_RECORDED_ENDED, formRecordedEnded } from "@/components/ward-management/ward-legal-form-ended"; +import type { Movement } from "@/components/ward-management/ward-model"; +import { downloadCsv } from "@/components/ward-management/statistics/statistics-csv"; + +import { currentLegalDueAt, formProgression, formProgressionPhrase } from "./legal-form-progression"; + +/** Where and when the file was taken, as `downloadCsv` writes it above the rows. */ +export type ListExportProvenance = NonNullable[2]>; + +/** + * Export (feature 44) for Legal forms and Movements: a form's end exactly as typed, with its date so + * the file reads the same tomorrow ("Ends Tue 11 Nov, 15:42 as typed"). Never a countdown or a time + * worked out from it (D-93, D-70). A form that ended at arrival or the examination, or that a person + * recorded as ended, says so in the words the screens, TV and printouts use (M17, D-125, D-127). + */ +export function legalEndAsTyped( + movement: Pick, + now: Instant, + dayZero: Date, +): string { + if (!movement.legalForm) return "No form recorded"; + const progression = formProgression(movement as Movement); + if (progression) return formProgressionPhrase(progression); + if (formRecordedEnded(movement)) return FORM_RECORDED_ENDED; + const typed = currentLegalDueAt(movement as Movement); + if (typed === undefined) return "No expiry recorded from the form"; + return `${isClockBreached(typed, now) ? "Ended" : "Ends"} ${formatSheetMoment(typed, dayZero)} as typed`; +} + +/** The form by its full register name ("Form 4C (Transfer order)"), never a bare code alone. */ +export function legalFormWords(movement: Pick): string { + return movement.legalForm ? legalFormName(movement.legalForm) : "No form recorded"; +} + +export type LegalFormsExportMovement = Pick< + Movement, + | "id" + | "legalForm" + | "legalStatus" + | "examination" + | "closure" + | "formedAt" + | "legalFormReceivedAt" + | "legalFormReceivedTyped" +>; + +export type LegalFormsExportContext = { + readonly umrnOf: (movement: M) => string; + readonly edName: (movement: M) => string; + readonly wardName: (movement: M) => string | null; + readonly now: Instant; + readonly dayZero: Date; +}; + +export const LEGAL_FORMS_EXPORT_HEADER = [ + "Record", + "UMRN", + "Form", + "ED", + "Receiving ward", + "Referral made", + "Ends, as typed", + "Form received", +] as const; + +/** + * Everyone on a form, in the list's own order: record numbers, places, the form and its recorded + * times. Never a name, initials or a typed note (D-102). + */ +export function legalFormsExportLines( + movements: readonly M[], + ctx: LegalFormsExportContext, +): (string | number)[][] { + const at = (instant: Instant | undefined) => + instant === undefined ? "Not recorded" : formatSheetMoment(instant, ctx.dayZero); + return [ + [...LEGAL_FORMS_EXPORT_HEADER], + ...movements.map((movement) => [ + movement.id, + ctx.umrnOf(movement), + legalFormWords(movement), + ctx.edName(movement), + ctx.wardName(movement) ?? "No receiving ward", + at(movement.formedAt), + legalEndAsTyped(movement, ctx.now, ctx.dayZero), + movement.legalFormReceivedAt === undefined + ? "Not recorded" + : `${at(movement.legalFormReceivedAt)}${movement.legalFormReceivedTyped ? " as typed" : ""}`, + ]), + ]; +} + +/** Builds the rows and hands the browser the file. */ +export function downloadLegalFormsList( + movements: readonly M[], + ctx: LegalFormsExportContext, + provenance: ListExportProvenance, +): void { + downloadCsv("ward-flow-synthetic-legal-forms.csv", legalFormsExportLines(movements, ctx), provenance); +} diff --git a/src/components/ward-management/legal-forms/legal-forms-screen.tsx b/src/components/ward-management/legal-forms/legal-forms-screen.tsx index 2e8a9ef9a..c34019530 100644 --- a/src/components/ward-management/legal-forms/legal-forms-screen.tsx +++ b/src/components/ward-management/legal-forms/legal-forms-screen.tsx @@ -36,7 +36,14 @@ import { useIsPhone, } from "@/components/wf"; import { movementOriginLabel } from "@/components/ward-management/ward-movement-origin"; -import { formatInstant, formatInstantWithDay, type Instant } from "@/components/ward-management/ward-clock"; +import { + formatInstant, + formatInstantWithDay, + formatSheetMoment, + type Instant, +} from "@/components/ward-management/ward-clock"; +import { ListExportButton } from "@/components/ward-management/ward-list-export-button"; +import { ListPrintButton } from "@/components/ward-management/ward-list-print-button"; import { isOpen } from "@/components/ward-management/ward-derivations"; import { useDueSoonThresholds, @@ -538,6 +545,16 @@ function LegalFormsWorkspace({ initialMovementId, recordOnOpen = false }: Worksp if (!formCode) return; return raise("RECORD_LEGAL_FORM_DETAILS", { movementId: target.id, formCode, ...details }); }; + // Export (feature 44): everyone on a form, never names; legal times exactly as typed (D-93, D-102). + const exportList = async () => + (await import("./legal-forms-export")).downloadLegalFormsList( + rows, + { umrnOf, edName: edNameOf, wardName: wardNameOf, now, dayZero }, + { + asAt: formatSheetMoment(now, dayZero, { includeYearAndZone: true }), + scope: "Legal forms; everyone on a form on the list, expiry typed first (highlights do not narrow it)", + }, + ); const addTimeTarget = rows.find((movement) => movement.id === addTimeId) ?? null; const addTimeRefusal = addTimeTarget ? rejections @@ -1085,12 +1102,20 @@ function LegalFormsWorkspace({ initialMovementId, recordOnOpen = false }: Worksp } action={ // Page tops (Josh, 9 Nov 2026): Record a form, Record and tell and Copy for handover are - // the top's chips; the list keeps its own Clear highlight. - highlight ? ( - - ) : undefined + // the top's chips; the list keeps its own Clear highlight, Print and Export. + <> + {highlight ? ( + + ) : null} + {shownView === "history" ? null : ( + <> + + + + )} + } /> diff --git a/src/components/ward-management/movements/movements-export.ts b/src/components/ward-management/movements/movements-export.ts new file mode 100644 index 000000000..ef488bea1 --- /dev/null +++ b/src/components/ward-management/movements/movements-export.ts @@ -0,0 +1,100 @@ +import { formatSheetMoment, type Instant } from "@/components/ward-management/ward-clock"; +import { legalEndAsTyped, legalFormWords } from "@/components/ward-management/legal-forms/legal-forms-export"; +import type { Movement, Unit } from "@/components/ward-management/ward-model"; +import type { Patient } from "@/components/ward-management/ward-patients"; +import { movementOriginLabel } from "@/components/ward-management/ward-movement-origin"; +import { resolveSubjectPatient } from "@/components/ward-management/ward-patient-resolver"; +import { edById, edShortName } from "@/components/ward-management/ward-sites"; +import { stageCopy } from "@/components/ward-management/ward-stage-copy"; +import { downloadCsv } from "@/components/ward-management/statistics/statistics-csv"; + +/** Where and when the file was taken, as `downloadCsv` writes it above the rows. */ +export type ListExportProvenance = NonNullable[2]>; + +export type MovementsExportContext = { + readonly umrnOf: (movement: Movement) => string; + readonly edName: (movement: Movement) => string; + readonly wardName: (movement: Movement) => string; + readonly now: Instant; + readonly dayZero: Date; +}; + +export const MOVEMENTS_EXPORT_HEADER = [ + "Record", + "UMRN", + "Stage", + "ED", + "Receiving ward", + "Opened", + "Urgency tier", + "Legal status", + "Form", + "Form ends, as typed", + "Outcome", + "Closed", +] as const; + +const OUTCOME_WORDS: Record["outcome"], string> = { + arrived: "Arrived", + did_not_proceed: "Did not proceed", +}; + +/** + * Export (feature 44): the Movements worklist as a spreadsheet. Record numbers, places, stages, + * recorded times and labels only: never a name, initials or a typed note (D-102). A form's end is + * exactly as typed, never a countdown (D-93). + */ +export function movementsExportLines( + movements: readonly Movement[], + ctx: MovementsExportContext, +): (string | number)[][] { + const at = (instant: Instant) => formatSheetMoment(instant, ctx.dayZero); + return [ + [...MOVEMENTS_EXPORT_HEADER], + ...movements.map((movement) => [ + movement.id, + ctx.umrnOf(movement), + stageCopy[movement.stage].label, + ctx.edName(movement), + ctx.wardName(movement), + at(movement.openedAt), + movement.urgency, + movement.legalStatus, + legalFormWords(movement), + movement.legalForm ? legalEndAsTyped(movement, ctx.now, ctx.dayZero) : "", + movement.closure ? OUTCOME_WORDS[movement.closure.outcome] : "Open", + movement.closure ? at(movement.closure.at) : "", + ]), + ]; +} + +/** The worklist's Export button: resolves the record numbers and places, then hands over the file. */ +export function exportMovementsList(o: { + readonly movements: readonly Movement[]; + readonly resolved: boolean; + readonly patients: readonly Patient[]; + readonly referrals: Parameters[1]["referrals"]; + readonly units: readonly Unit[]; + readonly now: Instant; + readonly dayZero: Date; +}): void { + const ctx: MovementsExportContext = { + umrnOf: (movement) => resolveSubjectPatient(movement, { patients: o.patients, referrals: o.referrals }).umrn, + edName: (movement) => { + const ed = edById(movement.originEdId); + return ed ? edShortName(ed) : movementOriginLabel(movement, undefined); + }, + wardName: (movement) => + movement.acceptedUnitId + ? (o.units.find((unit) => unit.id === movement.acceptedUnitId)?.name ?? movement.acceptedUnitId) + : "No receiving ward", + now: o.now, + dayZero: o.dayZero, + }; + downloadCsv("ward-flow-synthetic-movements.csv", movementsExportLines(o.movements, ctx), { + asAt: formatSheetMoment(o.now, o.dayZero, { includeYearAndZone: true }), + scope: o.resolved + ? "Movements, Resolved today; every movement closed today in this view's service" + : "Movements; every movement on the worklist in this view's service (highlights and search do not narrow it)", + }); +} diff --git a/src/components/ward-management/movements/movements-list-tools.tsx b/src/components/ward-management/movements/movements-list-tools.tsx new file mode 100644 index 000000000..4e0b9d571 --- /dev/null +++ b/src/components/ward-management/movements/movements-list-tools.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { ListExportButton } from "@/components/ward-management/ward-list-export-button"; +import { ListPrintButton } from "@/components/ward-management/ward-list-print-button"; +import type { exportMovementsList } from "./movements-export"; + +type ExportInput = Parameters[0]; + +/** + * The worklist's Print and Export (feature 44), kept out of `movements-screen.tsx`, which is near its + * line cap. Export loads its builder on the click, so the screen carries none of it up front. + */ +export function MovementsListTools(props: ExportInput & Pick, "disabled">) { + const { disabled, ...input } = props; + return ( + <> + + (await import("./movements-export")).exportMovementsList(input)} + disabled={disabled} + testId="ward-movements-export" + /> + + ); +} diff --git a/src/components/ward-management/movements/movements-screen.tsx b/src/components/ward-management/movements/movements-screen.tsx index 9c93f31f1..2d76b6722 100644 --- a/src/components/ward-management/movements/movements-screen.tsx +++ b/src/components/ward-management/movements/movements-screen.tsx @@ -82,6 +82,7 @@ import flow from "./movement-flow.module.css"; import styles from "./movements.module.css"; import { WardPrototypeFooter } from "@/components/ward-management/shell/ward-prototype-footer"; import { MovementsPhoneNav } from "./movements-phone-nav"; +import { MovementsListTools } from "./movements-list-tools"; import { rememberReturnRow, rowWithAttribute, useReturnedView, useReturnToPlace } from "../use-return-to-place"; import { ShapeOfTheDay } from "./movements-shape-of-day"; import { MovementPeopleSheet, corridorPeople, type MovementPeopleList } from "./movement-people-sheet"; @@ -144,15 +145,17 @@ const worklistRow = (id: string) => rowWithAttribute("data-record-key", id, "li" export function MovementsScreen() { const returned = useReturnedView(MOVEMENTS_ROUTE); const thresholds = useDueSoonThresholds(); - const { movements, units, referrals, patients, dispatch, configuration, setFocusMovementId } = useWardFlowSelect( - "movements", - "units", - "referrals", - "patients", - "dispatch", - "configuration", - "setFocusMovementId", - ); + const { movements, units, referrals, patients, dispatch, configuration, setFocusMovementId, dayZero } = + useWardFlowSelect( + "movements", + "units", + "referrals", + "patients", + "dispatch", + "configuration", + "setFocusMovementId", + "dayZero", + ); const now = useWardFlowClock(); // Task 6 of the audit-wiring plan, 2026-09-16: StageRow's wait-meter bar and its "of N hours" // caption are read against the coordinator-configured ED access target, not a bare 24h literal. @@ -1225,6 +1228,13 @@ export function MovementsScreen() { aria-label="Find movements by name, UMRN or ED" boxClassName={flow.search} /> + {isPhone ? null : ( + + )} {/* Column heads for the rows below. The rows are list items, so these are a visual diff --git a/tests/ward-audit-m124-outbound-marker.test.ts b/tests/ward-audit-m124-outbound-marker.test.ts index 73c22f698..2f6b1555c 100644 --- a/tests/ward-audit-m124-outbound-marker.test.ts +++ b/tests/ward-audit-m124-outbound-marker.test.ts @@ -182,6 +182,8 @@ describe("M124 word read-through on outbound builders (Manual, reviewed hits onl "src/components/ward-management/referrals/referral-export.ts", "src/components/ward-management/search/census-export.ts", "src/components/ward-management/delays/delays-export.ts", + "src/components/ward-management/legal-forms/legal-forms-export.ts", + "src/components/ward-management/movements/movements-export.ts", "src/components/ward-management/reports/patient-chronology.ts", "src/components/ward-management/reports/weekly-report.ts", "src/components/ward-management/ward-clipboard.ts", diff --git a/tests/ward-legal-movements-export.test.ts b/tests/ward-legal-movements-export.test.ts new file mode 100644 index 000000000..15aed74c8 --- /dev/null +++ b/tests/ward-legal-movements-export.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { screenHelpFor } from "@/components/ward-management/help/ward-help-content"; +import { + LEGAL_FORMS_EXPORT_HEADER, + legalEndAsTyped, + legalFormsExportLines, +} from "@/components/ward-management/legal-forms/legal-forms-export"; +import { currentLegalDueAt } from "@/components/ward-management/legal-forms/legal-form-progression"; +import { MOVEMENTS_EXPORT_HEADER, movementsExportLines } from "@/components/ward-management/movements/movements-export"; +import { searchCoreViews } from "@/components/ward-management/search/ward-smart-search"; +import { formatSheetMoment } from "@/components/ward-management/ward-clock"; +import { seedWardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import type { Movement } from "@/components/ward-management/ward-model"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; + +// Feature 44 exports for Legal forms and Movements, the help added for four pages, and the Leave +// page in search. Synthetic seed only. +const state = seedWardFlowState(); +const dayZero = new Date(Date.UTC(2026, 10, 11)); +const NAME = { givenName: "Synthia", familyName: "Testperson" }; +const typed = state.movements.find( + (movement) => currentLegalDueAt(movement) !== undefined && movement.closure === undefined, +)!; +const ctx = { umrnOf: () => "UM000001", edName: () => "Synthetic ED", wardName: () => null, now: NOW_ANCHOR, dayZero }; + +describe("legal end as typed (D-93)", () => { + const dueAt = currentLegalDueAt(typed)!; + it("gives the typed time with its date, never a count", () => { + expect(legalEndAsTyped(typed, dueAt - 75, dayZero)).toBe(`Ends ${formatSheetMoment(dueAt, dayZero)} as typed`); + expect(legalEndAsTyped(typed, dueAt + 75, dayZero)).toBe(`Ended ${formatSheetMoment(dueAt, dayZero)} as typed`); + }); + it("says when nothing was typed or the form was recorded as ended", () => { + const blank = { ...typed, legalForm: { ...typed.legalForm!, dueAt: undefined } } as Movement; + expect(legalEndAsTyped(blank, NOW_ANCHOR, dayZero)).toBe("No expiry recorded from the form"); + const ended = { ...typed, legalForm: { ...typed.legalForm!, endedBy: {} } } as unknown as Movement; + expect(legalEndAsTyped(ended, NOW_ANCHOR, dayZero)).toBe("Recorded as ended"); + }); +}); + +describe("Legal forms and Movements exports (D-102)", () => { + const named = { ...typed, ...NAME } as Movement; + it("has a header row and one row per form, without names", () => { + const lines = legalFormsExportLines([named], ctx); + expect(lines[0]).toEqual([...LEGAL_FORMS_EXPORT_HEADER]); + expect(lines[1]?.[0]).toBe(typed.id); + expect(lines[1]?.[4]).toBe("No receiving ward"); + expect(String(lines[1]?.[2])).toMatch(/^Form /u); + expect(JSON.stringify(lines)).not.toMatch(/Synthia|Testperson/u); + }); + it("lists each movement with its stage and outcome, without names", () => { + const lines = movementsExportLines([named], { ...ctx, wardName: () => "Synthetic ward" }); + expect(lines[0]).toEqual([...MOVEMENTS_EXPORT_HEADER]); + expect(lines[1]?.[0]).toBe(typed.id); + expect(lines[1]?.[10]).toBe("Open"); + expect(String(lines[1]?.[9])).toMatch(/ as typed$/u); + expect(JSON.stringify(lines)).not.toMatch(/Synthia|Testperson/u); + }); +}); + +describe("help for pages that had none", () => { + it.each([ + ["/ward-flow/leave", "leave"], + ["/ward-flow/review", "todays-review"], + ["/ward-flow/triage/new", "triage-start"], + ["/ward-flow/people/PT-001/triage", "triage"], + ])("%s has its own help", (path, id) => { + expect(screenHelpFor(path, [])?.id).toBe(id); + }); + it("keeps the patient page's help on the patient page", () => { + expect(screenHelpFor("/ward-flow/people/PT-001", [])?.id).toBe("patient"); + }); + it("leaves the bed meeting TV without help, since Help cannot open there (HF-10)", () => { + expect(screenHelpFor("/ward-flow/bed-meeting", [])).toBeUndefined(); + }); +}); + +it("finds the Leave page in search", () => { + expect(searchCoreViews("leave").map((result) => result.href)).toContain("/ward-flow/leave"); + expect(searchCoreViews("due back").map((result) => result.href)).toContain("/ward-flow/leave"); +}); diff --git a/tests/ward-list-print-export-more.dom.test.tsx b/tests/ward-list-print-export-more.dom.test.tsx new file mode 100644 index 000000000..3e5a13a4b --- /dev/null +++ b/tests/ward-list-print-export-more.dom.test.tsx @@ -0,0 +1,133 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { LeaveScreen } from "@/components/ward-management/leave/leave-screen"; +import { LegalFormsScreen } from "@/components/ward-management/legal-forms/legal-forms-screen"; +import { MovementsScreen } from "@/components/ward-management/movements/movements-screen"; +import { unitHref } from "@/components/ward-management/shell/ward-facade"; +import { WardFlowOverride, WardFlowProvider, useWardFlow } from "@/components/ward-management/ward-flow-provider"; +import { seedWardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import type { LeaveBed } from "@/components/ward-management/ward-model"; +import { resolveSubjectPatient } from "@/components/ward-management/ward-patient-resolver"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; + +// Leave page (Open ward, Print) and Print and Export on Legal forms and Movements (feature 44). +// Synthetic seed only. Exports carry record numbers and UMRNs, never names (D-102), and legal +// times only as typed (D-93). +const { createObjectURL, revokeObjectURL } = URL; +afterEach(() => { + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = revokeObjectURL; + vi.restoreAllMocks(); +}); + +function wrap(children: ReactNode) { + return render({children}); +} + +/** Clicks Export and returns the downloaded CSV's text. */ +async function exportedCsv(testId: string): Promise { + let file: Blob | undefined; + URL.createObjectURL = (blob: Blob) => { + file = blob; + return "blob:synthetic-export"; + }; + URL.revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + fireEvent.click(screen.getByTestId(testId)); + await waitFor(() => expect(file).toBeDefined(), { timeout: 10_000 }); + return file!.text(); +} + +function expectNoNames(csv: string) { + const state = seedWardFlowState(); + const cells = new Set(csv.split("\r\n").flatMap((line) => line.split(",").map((c) => c.replace(/^"'?|"$/gu, "")))); + for (const movement of state.movements) { + const person = resolveSubjectPatient(movement, state); + if (person.patient) { + expect(csv).not.toContain(person.patient.familyName); + expect(cells).not.toContain(person.patient.givenName); + } + if (person.initials) expect(cells).not.toContain(person.initials); + } +} + +/** A legal time column never carries a count ("2h 05m", "left", "ago", "overdue"). */ +const COUNT = /\b\d+\s*[dhm]\b|\bleft\b|\bago\b|\boverdue\b/u; + +describe("Leave page", () => { + const leave = (id: string, unitId: string, expectedReturn: number, extra: Partial = {}): LeaveBed => ({ + id, + unitId, + admissionId: `adm-${id}`, + expectedReturn, + confirmedAt: 0, + confirmedBy: "Ward nurse", + ...extra, + }); + function WithLeave() { + const flow = useWardFlow(); + const [a, b] = [flow.units[0]!.id, flow.units[1]!.id]; + const leaveBeds = [ + leave("l-over", a, flow.now - 30), + leave("l-away", b, flow.now + 10, { absentWithoutLeave: { since: flow.now - 60, steps: [] } }), + leave("l-later", b, flow.now + 3 * 1440), + ]; + return ( + + + + ); + } + + it("offers Open ward on overdue and absent rows only, opening the ward's leave view", () => { + wrap(); + const over = screen.getByTestId("ward-leave-row-l-over"); + const link = within(over).getByRole("link", { name: /^Open ward/u }); + const seed = seedWardFlowState(); + expect(link.getAttribute("href")).toBe(`${unitHref(seed.units[0]!.id)}#ward-leave-bed-form`); + expect(within(screen.getByTestId("ward-leave-row-l-away")).getByRole("link", { name: /^Open ward/u })).toBeTruthy(); + expect( + within(screen.getByTestId("ward-leave-row-l-later")).queryByRole("link", { name: /^Open ward/u }), + ).toBeNull(); + }); + + it("prints with the browser's own print and says on paper what the list holds", () => { + const print = vi.spyOn(window, "print").mockImplementation(() => {}); + wrap(); + fireEvent.click(screen.getByRole("button", { name: "Print this list" })); + expect(print).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("ward-leave-print-scope").textContent).toMatch(/^Everyone on leave · As at .+ AWST/u); + }); +}); + +describe("Legal forms Print and Export", () => { + it("exports everyone on a form without names, each end exactly as typed", async () => { + wrap(); + expect(screen.getByTestId("ward-legal-print")).toBeTruthy(); + const csv = await exportedCsv("ward-legal-export"); + const lines = csv.split("\r\n"); + expect(lines[0]).toBe('"Data","Synthetic, not clinical records"'); + const header = lines.findIndex((line) => line.startsWith('"Record","UMRN","Form"')); + expect(header).toBeGreaterThan(0); + const rows = lines.slice(header + 1); + expect(rows.length).toBeGreaterThan(0); + const ends = rows.map((line) => line.split('","')[6] ?? ""); + expect(ends.some((cell) => / as typed$/u.test(cell))).toBe(true); + for (const cell of ends) expect(cell).not.toMatch(COUNT); + expectNoNames(csv); + }); +}); + +describe("Movements Print and Export", () => { + it("exports the worklist without names", async () => { + wrap(); + expect(screen.getByTestId("ward-movements-print")).toBeTruthy(); + const csv = await exportedCsv("ward-movements-export"); + const lines = csv.split("\r\n"); + expect(lines.some((line) => line.startsWith('"Record","UMRN","Stage","ED"'))).toBe(true); + expect(lines.length).toBeGreaterThan(8); + expectNoNames(csv); + }); +}); From 5c90753593b0cbbd85aff402ef4f7a68267ffe31 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:15:48 +0000 Subject: [PATCH 19/40] Renumber the review ruling to D-145 (D-144 is claimed by PR #1617) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- docs/ward-flow/decisions.md | 6 +++--- eslint-rules/max-lines-baseline.mjs | 2 +- eslint.config.mjs | 2 +- tests/max-lines-baseline.test.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/ward-flow/decisions.md b/docs/ward-flow/decisions.md index c1c659528..cd347d86e 100644 --- a/docs/ward-flow/decisions.md +++ b/docs/ward-flow/decisions.md @@ -156,7 +156,7 @@ Checked 9 November 2026 (D-107 audit). "Partly replaced" means part of the entry | [D-131](#d-131-hard-case-demo-data-allowed-and-the-movement-diagram-follows-the-engine-10-november-2026) | Hard-case demo data allowed, and the movement diagram follows the engine | Active | | [D-132](#d-132-audit-fixes-group-1-docs-and-ci-guards-10-november-2026) | Audit fixes group 1: docs and CI guards; Claude-authored sensitive changes warn | Active | | [D-138](#d-138-phone-alert-urgency-by-kind-10-november-2026) | Phone alert urgency by kind | Active | -| [D-144](#d-144-repository-review-fixes-features-and-code-health-approved-11-november-2026) | Repository review: fixes, features and code health approved | Active | +| [D-145](#d-145-repository-review-fixes-features-and-code-health-approved-11-november-2026) | Repository review: fixes, features and code health approved | Active | --- @@ -1701,9 +1701,9 @@ is inferred. - **Decision:** act-now phone alerts stay high urgency and buzz again when replaced. Jobs and team alerts go out at normal urgency and replace their earlier alert quietly. Settings shows when this device last received an alert, from the time only (never the alert's words). - **Boundary:** synthetic data only. Changes no signed clinical value or clinical rule, and approves no merge or release. -## D-144. Repository review: fixes, features and code health approved (11 November 2026) +## D-145. Repository review: fixes, features and code health approved (11 November 2026) -- **Date:** 11 November 2026 (`2026-11-11`). **Decided by:** Josh, in the Ward Flow project thread "Review repository for issues and new features": "Go ahead with all these recommendations please" (03:55 UTC), answering the review doc "Ward Flow repository review, 11 November 2026". Numbered D-144 because D-133 to D-143 belong to other threads. D-139 is claimed by open PR #1600, D-140 is claimed by open PR #1606, D-141 is claimed by open PR #1606, D-142 is claimed by open PR #1603, and D-143 is claimed by open PR #1612. +- **Date:** 11 November 2026 (`2026-11-11`). **Decided by:** Josh, in the Ward Flow project thread "Review repository for issues and new features": "Go ahead with all these recommendations please" (03:55 UTC), answering the review doc "Ward Flow repository review, 11 November 2026". Numbered D-145 because D-133 to D-144 belong to other threads. D-139 is claimed by open PR #1600, D-140 is claimed by open PR #1606, D-141 is claimed by open PR #1606, D-142 is claimed by open PR #1603, D-143 is claimed by open PR #1612, and D-144 is claimed by open PR #1617. - **Decision:** 1. **Clinical display fixes, one held PR:** a ward's expected occupancy no longer counts a patient twice when they hold a named bed and also have a booked planned admission to that ward; an ended legal form says it ended and gives the expiry only as it was typed; the ED screen flags a long wait at 24 hours or more, like every other screen. 2. **Non-clinical fixes:** one lapse check for pull holds, staff requests keep the shift the person chose, one reader for typed times, one length-of-stay calculation, a demo code shorter than 12 characters counts as no code, and a demo pass stops working when the code changes. Two lines added to the real-data go/no-go list (record ids never built from UMRNs; a check of the push library). diff --git a/eslint-rules/max-lines-baseline.mjs b/eslint-rules/max-lines-baseline.mjs index 22b4deeaa..e63a22824 100644 --- a/eslint-rules/max-lines-baseline.mjs +++ b/eslint-rules/max-lines-baseline.mjs @@ -28,7 +28,7 @@ // 10 Nov 2026 (Tier 7 audit A5-05): the cap now covers the backend's source files too. postgres.mjs // was split by area into workspace-*.mjs; its remaining core (the record cache, reads, saves, // archive and the command path) gets a cap of its size plus about 150 lines. -// 11 Nov 2026 (D-144, Josh approved): the cap counts code lines only. Blank lines and comment-only +// 11 Nov 2026 (D-145, Josh approved): the cap counts code lines only. Blank lines and comment-only // lines no longer count (MAX_LINES_COUNTING), so nobody deletes spacing or safety comments to fit. // The caps were in raw lines; each is now re-set to the file's code lines plus the same // headroom it had under raw counting, so no file gains room to grow. ward-model, ward-movements, diff --git a/eslint.config.mjs b/eslint.config.mjs index fb5c718ef..0da104b10 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -179,7 +179,7 @@ const eslintConfig = defineConfig([ }, // File size ratchet. New source files stay under MAX_LINES_DEFAULT lines, and the files that // were already larger cannot grow much past their baseline (eslint-rules/max-lines-baseline.mjs). - // Only code lines count: blank lines and comment-only lines are free (MAX_LINES_COUNTING, D-144). + // Only code lines count: blank lines and comment-only lines are free (MAX_LINES_COUNTING, D-145). { files: ["src/**/*.{ts,tsx}"], rules: { diff --git a/tests/max-lines-baseline.test.ts b/tests/max-lines-baseline.test.ts index d99d1c37e..1ea903eda 100644 --- a/tests/max-lines-baseline.test.ts +++ b/tests/max-lines-baseline.test.ts @@ -7,7 +7,7 @@ import { MAX_LINES_HEADROOM, } from "../eslint-rules/max-lines-baseline.mjs"; -// Count lines the way the lint cap does (code lines only, D-144): run ESLint's own max-lines rule +// Count lines the way the lint cap does (code lines only, D-145): run ESLint's own max-lines rule // with a cap of 0 on each listed file and read the line count from its message. async function codeLineCounts(files: string[]) { const eslint = new ESLint({ From 488a1da9aee64015862d0d5e69eec5e9e77a62ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:19:08 +0000 Subject: [PATCH 20/40] deps: Next 16.4.6, Vitest 5.1, lucide-react 1.71 patch/minor updates - next, @next/env, eslint-config-next 16.4.5 -> 16.4.6 (exact pins) - @next/bundle-analyzer ^16.4.0 (locked 16.4.2) -> ^16.4.6 - vitest and @vitest/coverage-v8 ^5.0.4 (locked 5.0.5) -> ^5.1.0 - lucide-react ^1.56.0 (locked 1.58.0) -> ^1.71.0 knip stays at 6.41.0: 6.45.0 reports 15 new unused exports in src, which needs source changes, not a version bump. Playwright, TypeScript 7, jsdom 31 and @types/node 26 are deliberately not updated. Lockfile regenerated with the pinned npm 11.17.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- package-lock.json | 376 +++++++++++++++++++++++++--------------------- package.json | 14 +- 2 files changed, 210 insertions(+), 180 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2bd1931ed..c8322f2bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "dependencies": { "@sentry/core": "^11.8.0", "html-to-image": "1.11.13", - "lucide-react": "^1.56.0", - "next": "16.4.5", + "lucide-react": "^1.71.0", + "next": "16.4.6", "postcss": "^8.5.26", "react": "19.3.1", "react-dom": "19.3.1", @@ -23,8 +23,8 @@ "devDependencies": { "@axe-core/playwright": "~4.14.0", "@babel/parser": "^8.0.7", - "@next/bundle-analyzer": "^16.4.0", - "@next/env": "16.4.5", + "@next/bundle-analyzer": "^16.4.6", + "@next/env": "16.4.6", "@tailwindcss/postcss": "^4.3.3", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", @@ -33,11 +33,11 @@ "@types/node": "^24.19.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "@vitest/coverage-v8": "^5.0.4", + "@vitest/coverage-v8": "^5.1.0", "babel-plugin-react-compiler": "1.0.0", "esbuild": "0.28.2", "eslint": "^10.13.0", - "eslint-config-next": "16.4.5", + "eslint-config-next": "16.4.6", "jsdom": "^30.1.2", "knip": "^6.41.0", "parse5": "8.0.1", @@ -48,7 +48,7 @@ "tailwindcss": "^4.3.1", "tsx": "^4.23.15", "typescript": "^6.0.0", - "vitest": "^5.0.4" + "vitest": "^5.1.0" }, "engines": { "node": ">=24.22.1 <25", @@ -2008,9 +2008,9 @@ } }, "node_modules/@next/bundle-analyzer": { - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.4.2.tgz", - "integrity": "sha512-tpNTC/WZYsmmixg3MZfx5JUuTh+Sgryu4eKhe7leQRcXHabztqtTkPCQM1Bk1VsE0trZ3w7NUCC3Zf7SeIBVzg==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.4.6.tgz", + "integrity": "sha512-Evp2ETZoDcqpg5LZPeJNG51o1Bc5vH+LPSP+BmSkdjeykMCh1fMB/shs3bJVbTOxYp7wC3Ed3HLo0jgX1BdxNA==", "dev": true, "license": "MIT", "dependencies": { @@ -2018,15 +2018,15 @@ } }, "node_modules/@next/env": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.5.tgz", - "integrity": "sha512-JwSPwpdkZL5VMl2jA/q+JI8MZdweAL3xmw5/tb9y/TLtmS0YXZnmYDzSihwk8xTj1OL+WM53gTqaj96yuLp6Xw==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.6.tgz", + "integrity": "sha512-SunBjJ6t+oBtecS3dTjdVuMhUogP5wD/vrGL5EVuQYM1iS4O5ilL51HkiJ3sPjMzRHiCChvr4dbaSJbR7F3lxA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.5.tgz", - "integrity": "sha512-dNmc4y4En/34tWxaBFF2tG/R9GeiRwVPf7v8Az/Pqk8FcT4Uv0ePmrZKvWBL2nFy1QprPgJW+6XjfyV6jbRV3Q==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.6.tgz", + "integrity": "sha512-HdqERVOafOD8JaUSAkwf9aSu/1rsHlkN8C5GcXYBH3C/z1MxBhZFxB5rz/xqMMRiIj7r9tIv8OCLQ9JWUxCvWA==", "dev": true, "license": "MIT", "dependencies": { @@ -2035,9 +2035,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.5.tgz", - "integrity": "sha512-W5TniBzWKASbNNwHZ/S/f1MxQUXU/LdjklVMWA0Ujbfl7SZnUzFls45hO0i/sF7Bx1jJBWp7WWmoQajXtX/O8A==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.6.tgz", + "integrity": "sha512-kJ4VVZkley17xKaJx72QN2IBqjrj7zenCQZaUF0l9ShS/1Mt2kW4+Z5KGG6vgr3L/J562xTOizVyM1iA/u5nlg==", "cpu": [ "arm64" ], @@ -2051,9 +2051,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.5.tgz", - "integrity": "sha512-uWq4qkHapBDS1oFltWS4YxTb9pLHAtLNcpbuK6SmRsebLYZqpBML/j/C0op+50JYGTpZgVL/DWNt/6oWlamrrw==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.6.tgz", + "integrity": "sha512-eIPGZIqC+8JKaeZmvnCANJQF4xWkkHqSnAAajtFxgwDMAOnCgxNvP52SeWZ41PyVwoe+Le/NQuZSaQ8j7YshSA==", "cpu": [ "x64" ], @@ -2067,9 +2067,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.5.tgz", - "integrity": "sha512-FdRcyOx5u5P51mlAqLB9EXdd8jZO5fsdHtk4BAQSkqOywCsbTNN/rmw+jO7pnmjNdAtu2AG46NzbCL2FPDrrgw==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.6.tgz", + "integrity": "sha512-9nI6O8vvczH2D9ZNdMGqb/3NuT15T9ksOZnAmx9/gjF2cOi74+xxyf1Hk96uIMfm14c9y6wHeBq6SH8g6udvcA==", "cpu": [ "arm64" ], @@ -2086,9 +2086,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.5.tgz", - "integrity": "sha512-cyvmmgtRXR8aGVT86wi9Py53IlfyCx7P2fZxuAXAoTummDnhKtUyjrMeXfapqp4qgD+gT5RqyGD/VfA16jEK7Q==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.6.tgz", + "integrity": "sha512-7/qPhTxDp2ScluOJOhLuhWmlA17o1yZ0SIgpISBCpBxXgtnK4+KGywBpFkfLPiU1rnSM1faFGzYkH6i7JdTrqA==", "cpu": [ "arm64" ], @@ -2105,9 +2105,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.5.tgz", - "integrity": "sha512-hPc+Fl+N22DAvqZHq559NmamXboOX5Lu8I5Wl0DezfrhTk1Zz21NQeYoZDmNI/NjTcrdsVxM72Fzn2lI+Q+vAw==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.6.tgz", + "integrity": "sha512-oOpbNa8XOW1qCGd9VvzC4fbkkRxEls7/WI6tz+YHC0xRA7KpV1U7DrAnCPjzVyOk/karzWEepCOcTIDmwK8FwQ==", "cpu": [ "x64" ], @@ -2124,9 +2124,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.5.tgz", - "integrity": "sha512-gbACYE8IP/jlprNTpiWXHb8N9CofOzpnEwH2SrIz1dUbiJf1FbDssDupCAPeP1Taxe8mgG20sGZamJI7KFOdZw==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.6.tgz", + "integrity": "sha512-aM8CZ+OR2EwXaAYw7KGkW2GDmNDsLBIRe1MMRZCJPTdw/xhTD0IMUVO4sf3KqrzuRouGzwwLZn34+gFeVXiUQA==", "cpu": [ "x64" ], @@ -2143,9 +2143,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.5.tgz", - "integrity": "sha512-+8gj++ZMo8H20Xh2ijjNqyFk5LPLw0AC4Fs4Tq/G94rjjQXhOjN4jgSWsWKPFsyY26hfIeemJIwKtMxOsp5GBA==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.6.tgz", + "integrity": "sha512-U0p7/tEtqvHX2crhd841CDwKtp5CFkKXVjOhmfccR3Pvp64sj8KBwRqsvltqLOwVBbj0NtkxwXSDpEHYGbxeXQ==", "cpu": [ "arm64" ], @@ -2159,9 +2159,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.5.tgz", - "integrity": "sha512-qNZj66pYXnaWLiF9R9GUbfBdrCv7kC6TaZfIwB2KH5WPc/8pEM3nVOj8hpQhWJU8S94rlH5MPDcX15+RrgUhiw==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.6.tgz", + "integrity": "sha512-3ZG15/5WIdlTQsIEvc9sPTTSL5XFrlLklIKmfF4fwsDnoAhu6NcBDXsk3mbGxNyo6aVLEGex+fHCv2MahY7a9g==", "cpu": [ "x64" ], @@ -2570,14 +2570,14 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.147.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", - "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "peer": true, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { @@ -2859,9 +2859,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", - "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.18.tgz", + "integrity": "sha512-ubTG+4KlidvioxjOFdsdEa2F6JVGZb8m2ByJUqQ7fYzUeT1cEn194POcsQ10PKH4p//q0YQn4SB3YpSuzcA1Yg==", "cpu": [ "arm" ], @@ -2877,9 +2877,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", - "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.18.tgz", + "integrity": "sha512-2Xa1t/JeFGTZn5TbzgPppcCftF8sCVnTNvrIvoSdcZhoFzAuVy1L3xxQUQ4sfWDtwVzg1u6Yi1R3SlJugRNV2Q==", "cpu": [ "arm64" ], @@ -2895,9 +2895,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", - "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.18.tgz", + "integrity": "sha512-iUzw9HshVvrWci9gVXjxWS7/M/IcLvNo67vu2c/w9zKBz4hIvPRixdUpIW/iCfhldLaq9ER2hecsI9UY5z0/yA==", "cpu": [ "arm64" ], @@ -2913,9 +2913,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", - "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.18.tgz", + "integrity": "sha512-XuDHFzbFEtZl0QGJi92+fmG67tfnp1iZ9VOeDkwasGyTq2VG3/5PnJnvAr+MTzFh/rNScJ3VhN+XpZyyGsN7Jw==", "cpu": [ "x64" ], @@ -2931,9 +2931,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", - "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.18.tgz", + "integrity": "sha512-MM8opLfsLK15oEXDz6iBc0t3ahu+ew9jsAmha+ovZ33hSwYgqo7YmpRITqFTfW7xA71BLEQ3jJdTsaCmhB1c9w==", "cpu": [ "x64" ], @@ -2949,9 +2949,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", - "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.18.tgz", + "integrity": "sha512-J6EoCWiy5RnFQzHrKyHXXevZ9lukQfGpnchX/zG/k/WC2nc9O9XyS1JgA8kI9VxMFggMbCPjpv7LcikriXfehw==", "cpu": [ "arm" ], @@ -2967,13 +2967,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", - "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.18.tgz", + "integrity": "sha512-HIkFuLF7CdDeki+1aCZLLbLq0hk/PHntb+z4/YCTo47JbksTD6jJEELIz92tYROmDmvMrNqUMqBi71BkJLDR+A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2985,13 +2988,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", - "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.18.tgz", + "integrity": "sha512-4oqWItLLYt7IceOnElCct8T2ek5p7PmD7LN04TRJRXGo5f3HtKTgGH6PhYRFK4C2ICkq/LMUU0BVPvvlAKhm8g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3003,13 +3009,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", - "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.18.tgz", + "integrity": "sha512-+AkRly5SrCEgnPhGRfub7pucASYEC2fTsxZg7VCcnozpA2+Wn/gCYy1t7LuJS67/xyJu/7noYBJncDUkbwgw2A==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3021,13 +3030,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", - "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.18.tgz", + "integrity": "sha512-Ttg1r9vVykMnQrE/GwYO9icKtXiuiMfRe/w74xJkeoVuvhRG+WtT6LEMOf58Yl63yd77lhHzO8Y55R8CC3XALA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3039,13 +3051,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", - "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.18.tgz", + "integrity": "sha512-xX1Tt4o5jUMma0vJsXp5eRZA9nOuyigb2dKJMMzZRmzMxLYGE9PaKKSqHPzPvSQgM8ZOEacSTQwDQcwWfh0xmQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3057,13 +3072,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", - "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.18.tgz", + "integrity": "sha512-4VVlGI5K2kociZlW3lSN8VjG3TW5yQIlAcfCdBXMgGjJxZll15Fa4ocRHXHvIVVnwlibCw/NQajJrNDiqqpiiw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3075,9 +3093,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", - "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.18.tgz", + "integrity": "sha512-PApTsgH1jLYuyy3tSCz9De82Gq5qXwi7vImVZhEzTgX1C4150Cp5M2ZfL3pLvbCvbEHL0oJeC6pd6podtv8Y5w==", "cpu": [ "arm64" ], @@ -3093,9 +3111,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", - "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.18.tgz", + "integrity": "sha512-J2XjWjx/SP+4x0H4r6dz3Vzb5WVkg7re40Do9K7HJ3sK2/xcmZrWwakXlSwaMvOkvruLJCD4CL/3wqsIR/thgg==", "cpu": [ "arm64" ], @@ -3111,9 +3129,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", - "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.18.tgz", + "integrity": "sha512-XwRJxRQvkuEtS0hIIt29QrqPJ+JzMJSNYHJw4ae/mc6uEarfRG1ceyg4HmmWiBdL/YCb6g7N5BLbSvmRJRYZHw==", "cpu": [ "x64" ], @@ -3129,9 +3147,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.1.0.tgz", + "integrity": "sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==", "dev": true, "license": "MIT", "peer": true @@ -4110,15 +4128,15 @@ ] }, "node_modules/@vitest/coverage-v8": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.5.tgz", - "integrity": "sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.1.0.tgz", + "integrity": "sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/istanbul-lib-coverage": "^1.0.2", - "@vitest/istanbul-lib-report": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^2.1.0", + "@vitest/istanbul-lib-report": "^2.1.0", "ast-v8-to-istanbul": "^1.0.7", "magicast": "^0.5.5", "obug": "^3.0.0", @@ -4129,8 +4147,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "5.0.5", - "vitest": "5.0.5" + "@vitest/browser": "5.1.0", + "vitest": "5.1.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4139,9 +4157,9 @@ } }, "node_modules/@vitest/istanbul-lib-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", - "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-2.1.0.tgz", + "integrity": "sha512-WOjuS+xcjSIy7ruYBQ+9BUcV7XZQ0FyVcbWbWxW9TRx/kLGx5SQaeFRIFDJxjNplb6YsT7SpKo2zuXXUo91OIQ==", "dev": true, "license": "MIT", "engines": { @@ -4149,27 +4167,27 @@ } }, "node_modules/@vitest/istanbul-lib-report": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", - "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-2.1.0.tgz", + "integrity": "sha512-+ZFta25nWtGCOBG9/IET8w1RO5Oawsd6H8Uf6AJ9EA/StFuKVtKAEJEJ6uRt84OH+T4YkI0ASmIHAhRA+0KVxw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/istanbul-lib-coverage": "1.0.2" + "@vitest/istanbul-lib-coverage": "2.1.0" }, "engines": { "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", - "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.5", + "@vitest/spy": "5.1.0", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, @@ -4190,9 +4208,9 @@ } }, "node_modules/@vitest/mocker/node_modules/magic-string": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", - "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", "dev": true, "license": "MIT", "dependencies": { @@ -4200,9 +4218,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", - "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", "dev": true, "license": "MIT", "funding": { @@ -5350,13 +5368,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.5.tgz", - "integrity": "sha512-mvh39zYLoNjpT5tVmf8xZu7i5lEOb5jN5cW6qD9OpeCp4GEEOeYta0Z2XyKiD0pYdlvm5/Td6Bi890maPRlIjg==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.6.tgz", + "integrity": "sha512-IBGGrVf9E6pL0NGL0hmeicVGeSyv1VEzIWq6IecDqkw+YFByhEtDJS9u1r6s6a1VxK6rssh69KyNFQUgfaacqw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.4.5", + "@next/eslint-plugin-next": "16.4.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -7593,9 +7611,9 @@ } }, "node_modules/lucide-react": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.58.0.tgz", - "integrity": "sha512-hXaFNm6mk6QVtBnDGBn1YAs8j2tGUk0A4s/CpclIcczfxeXiW1rdOLVo6zCsoIih9XXsGvDODQerl749a2n7bA==", + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.71.0.tgz", + "integrity": "sha512-8YtvF+bqT/EMqAXU7kWvYdRu+TV9j4kS1eIS7IbN1j1Z1DgupbrTL0nDIygdqgBDTD7AJPP607VdVgzDTar4zA==", "license": "ISC", "peerDependencies": { "@types/react": "*", @@ -7801,12 +7819,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.4.5.tgz", - "integrity": "sha512-rmdfsWosjTUO+6R0S3KokoIk6Kj0sWDRTe86tZHdqaWH/oXq/udX3/4xb+YQi7aWbyEFoglDU54kaifLCdXbBw==", + "version": "16.4.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.6.tgz", + "integrity": "sha512-QZJiYhaZLJMo2KpkQGQIwvmd79WVsAWeYIU554a8d7zArS3si/IRy3lBNV/rtYhE70oLwdLYhPAXLPx8HwcwtA==", "license": "MIT", "dependencies": { - "@next/env": "16.4.5", + "@next/env": "16.4.6", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -7820,14 +7838,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.4.5", - "@next/swc-darwin-x64": "16.4.5", - "@next/swc-linux-arm64-gnu": "16.4.5", - "@next/swc-linux-arm64-musl": "16.4.5", - "@next/swc-linux-x64-gnu": "16.4.5", - "@next/swc-linux-x64-musl": "16.4.5", - "@next/swc-win32-arm64-msvc": "16.4.5", - "@next/swc-win32-x64-msvc": "16.4.5", + "@next/swc-darwin-arm64": "16.4.6", + "@next/swc-darwin-x64": "16.4.6", + "@next/swc-linux-arm64-gnu": "16.4.6", + "@next/swc-linux-arm64-musl": "16.4.6", + "@next/swc-linux-x64-gnu": "16.4.6", + "@next/swc-linux-x64-musl": "16.4.6", + "@next/swc-win32-arm64-msvc": "16.4.6", + "@next/swc-win32-x64-msvc": "16.4.6", "sharp": "^0.35.5" }, "peerDependencies": { @@ -8587,14 +8605,14 @@ } }, "node_modules/rolldown": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", - "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.18.tgz", + "integrity": "sha512-C1uuT3UR60SShuCaPQFAAZyefJCWZlAKB02BV3BuPE2Rs4fevRQltEKoFO2tV5W9/eJhU5RieV6vJj3euulfNQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@oxc-project/types": "=0.147.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -8604,21 +8622,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.6", - "@rolldown/binding-android-arm64": "1.2.6", - "@rolldown/binding-darwin-arm64": "1.2.6", - "@rolldown/binding-darwin-x64": "1.2.6", - "@rolldown/binding-freebsd-x64": "1.2.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", - "@rolldown/binding-linux-arm64-gnu": "1.2.6", - "@rolldown/binding-linux-arm64-musl": "1.2.6", - "@rolldown/binding-linux-ppc64-gnu": "1.2.6", - "@rolldown/binding-linux-s390x-gnu": "1.2.6", - "@rolldown/binding-linux-x64-gnu": "1.2.6", - "@rolldown/binding-linux-x64-musl": "1.2.6", - "@rolldown/binding-openharmony-arm64": "1.2.6", - "@rolldown/binding-win32-arm64-msvc": "1.2.6", - "@rolldown/binding-win32-x64-msvc": "1.2.6" + "@rolldown/binding-android-arm-eabi": "1.2.18", + "@rolldown/binding-android-arm64": "1.2.18", + "@rolldown/binding-darwin-arm64": "1.2.18", + "@rolldown/binding-darwin-x64": "1.2.18", + "@rolldown/binding-freebsd-x64": "1.2.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.18", + "@rolldown/binding-linux-arm64-gnu": "1.2.18", + "@rolldown/binding-linux-arm64-musl": "1.2.18", + "@rolldown/binding-linux-ppc64-gnu": "1.2.18", + "@rolldown/binding-linux-s390x-gnu": "1.2.18", + "@rolldown/binding-linux-x64-gnu": "1.2.18", + "@rolldown/binding-linux-x64-musl": "1.2.18", + "@rolldown/binding-openharmony-arm64": "1.2.18", + "@rolldown/binding-win32-arm64-msvc": "1.2.18", + "@rolldown/binding-win32-x64-msvc": "1.2.18" } }, "node_modules/run-parallel": { @@ -8943,9 +8961,9 @@ } }, "node_modules/smol-toml": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", - "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -9624,17 +9642,17 @@ } }, "node_modules/vite": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.4.tgz", - "integrity": "sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==", + "version": "8.3.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.8.tgz", + "integrity": "sha512-LzOn3h9rG/0vhLdXrV4yJ4QFA9eEoqCl+Yx+kGJd7aB9w6nfNgVHKSYwLPZUHrz4ZGa+t+kXjV9itvcHDLQouA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.32", + "rolldown": "~1.2.18", "tinyglobby": "^0.2.17" }, "bin": { @@ -9651,7 +9669,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -9851,6 +9869,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9873,6 +9894,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9895,6 +9919,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9917,6 +9944,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9976,14 +10006,14 @@ } }, "node_modules/vitest": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", - "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.5", + "@vitest/mocker": "5.1.0", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -10009,12 +10039,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.5", - "@vitest/browser-preview": "5.0.5", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.5", - "@vitest/coverage-v8": "5.0.5", - "@vitest/ui": "5.0.5", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index d011075d0..d4c4bafc0 100644 --- a/package.json +++ b/package.json @@ -105,8 +105,8 @@ "dependencies": { "@sentry/core": "^11.8.0", "html-to-image": "1.11.13", - "lucide-react": "^1.56.0", - "next": "16.4.5", + "lucide-react": "^1.71.0", + "next": "16.4.6", "postcss": "^8.5.26", "react": "19.3.1", "react-dom": "19.3.1", @@ -131,8 +131,8 @@ "devDependencies": { "@axe-core/playwright": "~4.14.0", "@babel/parser": "^8.0.7", - "@next/bundle-analyzer": "^16.4.0", - "@next/env": "16.4.5", + "@next/bundle-analyzer": "^16.4.6", + "@next/env": "16.4.6", "@tailwindcss/postcss": "^4.3.3", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", @@ -141,11 +141,11 @@ "@types/node": "^24.19.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", - "@vitest/coverage-v8": "^5.0.4", + "@vitest/coverage-v8": "^5.1.0", "babel-plugin-react-compiler": "1.0.0", "esbuild": "0.28.2", "eslint": "^10.13.0", - "eslint-config-next": "16.4.5", + "eslint-config-next": "16.4.6", "jsdom": "^30.1.2", "knip": "^6.41.0", "parse5": "8.0.1", @@ -156,7 +156,7 @@ "tailwindcss": "^4.3.1", "tsx": "^4.23.15", "typescript": "^6.0.0", - "vitest": "^5.0.4" + "vitest": "^5.1.0" }, "allowScripts": { "esbuild@0.28.2": true, From 4b92bf13e6deb240adb663013956f39216c1fe33 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:39:14 +0000 Subject: [PATCH 21/40] Refresh journey source digests and note the staff request shift field Dispatch is unchanged where only comments or helpers moved lines; the staff request disposition now names the optional shiftStartAt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- docs/ward-flow/journey/MAP-INVENTORY.md | 5 +- docs/ward-flow/journey/actions.json | 56 +++++++++---------- .../ward-flow/journey/event-dispositions.json | 11 ++-- docs/ward-flow/journey/machines.json | 2 +- .../journey/ward-journey-explorer.html | 2 +- 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/docs/ward-flow/journey/MAP-INVENTORY.md b/docs/ward-flow/journey/MAP-INVENTORY.md index 5bf83b42e..c90a757c9 100644 --- a/docs/ward-flow/journey/MAP-INVENTORY.md +++ b/docs/ward-flow/journey/MAP-INVENTORY.md @@ -1566,13 +1566,14 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 Scope: Ward, coordinator or site bed manager in EVENT_ROLE; a ward acts only for its own ward (actingUnitId must equal unitId). Executive changes nothing (D-80). Shared authenticated command scope additionally checks the permitted place and stored record; EVENT_ROLE alone is not place authority. payload: unitId; shift this or next; count 1, 2, 3 or 4 or more payload: reasons: one or more distinct items from STAFF_REQUEST_REASONS; actingUnitId for ward callers; role and now + payload: optional shiftStartAt: the start of the shift the person chose; kept only when it is that shift's start at now or 5 minutes earlier, otherwise the shift at now is used guards: Known unit; shift and count from their lists guards: Reject empty, duplicate or unlisted reasons, and a ward naming another ward effects: Appends a record, or updates count, reasons and updatedAt on the open one; never names a patient effects: Does not change beds, placement, movements or any patient stage tests: tests/ward-staff-requests.test.ts Reviewed source: src/components/ward-management/ward-flow-reducer.ts:2375; SHA256 edd08d18e7d96cad77b0a026b29f7acc6b9918b4818010e53b14e08d9c00eb2a - Reviewed source: src/components/ward-management/staffing/staff-requests.ts:132; SHA256 d3e51d13faedefa5314d5f128c59473110a61bae075bd5ddb362788d5ae479f3 + Reviewed source: src/components/ward-management/staffing/staff-requests.ts:156; SHA256 e47b9fadc1f70f84b56bd4e91eb36f5b95f85da6234e10f6251bea10c3f6237a Reviewed source: src/components/ward-management/ward-flow-event-roles.ts:591; SHA256 01913e33459ff2e0c7dfce7de7e0e731cc155400a42b2537e1e6ad7a6c3c83eb - CLOSE_WARD_STAFF_REQUEST — modelled; event roles: ward, coordinator, bed_manager Source-reviewed command model: an open staff request closed as arranged, not available or withdrawn. @@ -1590,7 +1591,7 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 effects: Does not change beds, placement, movements or any patient stage tests: tests/ward-staff-requests.test.ts Reviewed source: src/components/ward-management/ward-flow-reducer.ts:2376; SHA256 edd08d18e7d96cad77b0a026b29f7acc6b9918b4818010e53b14e08d9c00eb2a - Reviewed source: src/components/ward-management/staffing/staff-requests.ts:171; SHA256 d3e51d13faedefa5314d5f128c59473110a61bae075bd5ddb362788d5ae479f3 + Reviewed source: src/components/ward-management/staffing/staff-requests.ts:195; SHA256 e47b9fadc1f70f84b56bd4e91eb36f5b95f85da6234e10f6251bea10c3f6237a Reviewed source: src/components/ward-management/ward-flow-event-roles.ts:592; SHA256 01913e33459ff2e0c7dfce7de7e0e731cc155400a42b2537e1e6ad7a6c3c83eb - RECORD_CLINICAL_CONTACT — diagram-reviewed; event roles: community, coordinator Diagram action notes reviewed against the recorded source revision. Source digests detect subsequent edits; this is not runtime acceptance. diff --git a/docs/ward-flow/journey/actions.json b/docs/ward-flow/journey/actions.json index f0f5ca1e1..cd114df49 100644 --- a/docs/ward-flow/journey/actions.json +++ b/docs/ward-flow/journey/actions.json @@ -172,8 +172,8 @@ }, { "path": "src/components/ward-management/patients/add-patient.tsx", - "line": 614, - "sha256": "fc5752f90ad948491ad836c940e4eb1571aba082c71cee36e4e6083afdf78002" + "line": 611, + "sha256": "e4d277572fc48f16fc5a3746118fc7ab1758d274ed28ae459d9cd1821a2de4f0" }, { "path": "src/components/ward-management/referrals/use-referral-command-completion.ts", @@ -396,8 +396,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1351, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1348, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/referrals/referral-match.tsx", @@ -498,8 +498,8 @@ }, { "path": "src/components/ward-management/ed/use-ed-screen-decline.ts", - "line": 135, - "sha256": "ff7e1679d14d0e040af8cfa6bf07805d7c1bffc9cf8cf7a0d055fedc5f4862fd" + "line": 133, + "sha256": "f32c28822c1a39a28697617ed37d1cada5fa8bbce0c6ec1d2d9454107f60d01f" }, { "path": "src/components/ward-management/referrals/referral-match.tsx", @@ -1039,8 +1039,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1313, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1310, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/ward/ward-answer-view.tsx", @@ -1131,8 +1131,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1404, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1401, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/ward/ward-answer-view.tsx", @@ -1853,8 +1853,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1449, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1446, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/ward/ward-answer-view.tsx", @@ -1968,8 +1968,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1847, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1844, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/patients/patient-transit-operations.tsx", @@ -3385,8 +3385,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1745, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1742, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/tools/ward-tool-feed.tsx", @@ -3691,8 +3691,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1770, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1767, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/board/ward-board.tsx", @@ -3824,8 +3824,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1683, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1680, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/ward-flow-bed-events.ts", @@ -4037,8 +4037,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1702, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1699, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/discharges/discharge-board.tsx", @@ -4170,8 +4170,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1591, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1588, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/ward/ward-answer-view.tsx", @@ -4699,8 +4699,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1099, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1096, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", @@ -6725,8 +6725,8 @@ }, { "path": "src/components/ward-management/ward/ward-screen.tsx", - "line": 1079, - "sha256": "5e374492e10a8cb8387133eb099251ef9e3734eddb7a25466839a627dd441612" + "line": 1076, + "sha256": "217d4e23224a92d608df2fd5ee1581d323380bb7dae9e9b6ad7eaa6614628eb3" }, { "path": "src/components/ward-management/ward-flow-reducer.ts", diff --git a/docs/ward-flow/journey/event-dispositions.json b/docs/ward-flow/journey/event-dispositions.json index 4c76c503a..244c577b4 100644 --- a/docs/ward-flow/journey/event-dispositions.json +++ b/docs/ward-flow/journey/event-dispositions.json @@ -4027,7 +4027,8 @@ "after": "staffRequests holds one open request for the ward and shift with count and ordered reasons", "payload": [ "unitId; shift this or next; count 1, 2, 3 or 4 or more", - "reasons: one or more distinct items from STAFF_REQUEST_REASONS; actingUnitId for ward callers; role and now" + "reasons: one or more distinct items from STAFF_REQUEST_REASONS; actingUnitId for ward callers; role and now", + "optional shiftStartAt: the start of the shift the person chose; kept only when it is that shift's start at now or 5 minutes earlier, otherwise the shift at now is used" ], "scope": "Ward, coordinator or site bed manager in EVENT_ROLE; a ward acts only for its own ward (actingUnitId must equal unitId). Executive changes nothing (D-80). Shared authenticated command scope additionally checks the permitted place and stored record; EVENT_ROLE alone is not place authority.", "guards": [ @@ -4046,8 +4047,8 @@ }, { "path": "src/components/ward-management/staffing/staff-requests.ts", - "line": 132, - "sha256": "d3e51d13faedefa5314d5f128c59473110a61bae075bd5ddb362788d5ae479f3" + "line": 156, + "sha256": "e47b9fadc1f70f84b56bd4e91eb36f5b95f85da6234e10f6251bea10c3f6237a" }, { "path": "src/components/ward-management/ward-flow-event-roles.ts", @@ -4088,8 +4089,8 @@ }, { "path": "src/components/ward-management/staffing/staff-requests.ts", - "line": 171, - "sha256": "d3e51d13faedefa5314d5f128c59473110a61bae075bd5ddb362788d5ae479f3" + "line": 195, + "sha256": "e47b9fadc1f70f84b56bd4e91eb36f5b95f85da6234e10f6251bea10c3f6237a" }, { "path": "src/components/ward-management/ward-flow-event-roles.ts", diff --git a/docs/ward-flow/journey/machines.json b/docs/ward-flow/journey/machines.json index 2fcdf924c..f1f7cf43e 100644 --- a/docs/ward-flow/journey/machines.json +++ b/docs/ward-flow/journey/machines.json @@ -1754,7 +1754,7 @@ { "path": "src/components/ward-management/patients/add-patient.tsx", "line": 87, - "sha256": "fc5752f90ad948491ad836c940e4eb1571aba082c71cee36e4e6083afdf78002" + "sha256": "e4d277572fc48f16fc5a3746118fc7ab1758d274ed28ae459d9cd1821a2de4f0" }, { "path": "src/components/ward-management/patients/patient-now-screen.tsx", diff --git a/docs/ward-flow/journey/ward-journey-explorer.html b/docs/ward-flow/journey/ward-journey-explorer.html index 70659ed84..1ca9c357d 100644 --- a/docs/ward-flow/journey/ward-journey-explorer.html +++ b/docs/ward-flow/journey/ward-journey-explorer.html @@ -1284,7 +1284,7 @@

What to do here