Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/FACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ green. Quote a fact instead of restating it, and it updates itself when the sour
| `migration-count` | 18 | Numbered database migrations | `backend/ward-flow/migrations/` |
| `ward-route-count` | 61 | `page.tsx` routes under `/ward-flow` | `src/app/ward-flow/` |
| `app-route-count` | 65 | Pages and route handlers: the Ward Flow routes plus sign-in and the `/mockups` digest | `src/app/ward-flow/, src/app/ward-flow-sign-in/, src/app/mockups/` |
| `latest-decision` | D-156 | Newest entry in the decision log | `docs/ward-flow/decisions.md` |
| `next-decision` | D-157 | Number to use for the next decision | `docs/ward-flow/decisions.md` |
| `latest-decision` | D-158 | Newest entry in the decision log | `docs/ward-flow/decisions.md` |
| `next-decision` | D-159 | Number to use for the next decision | `docs/ward-flow/decisions.md` |
| `node-engine` | >=24.22.1 <25 | Supported Node.js range | `package.json` |
| `backend-env-vars` | 33 names, [listed below](#backend-env-vars) | Environment variables the shared backend reads | `backend/ward-flow/*.mjs` |

Expand Down
636 changes: 320 additions & 316 deletions docs/ward-flow/OWNER-RULINGS.md

Large diffs are not rendered by default.

32 changes: 23 additions & 9 deletions docs/ward-flow/decisions.md

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions docs/ward-flow/journey/MAP-INVENTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1692,18 +1692,19 @@ Source-contract SHA256: 7cfda9a71c556b4d10a573977f0ef64456a4f9bb9890c7a236952cd7
Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:2266
Subject: A ward's staff request for this shift or next shift
Before: Known ward; no open request for that ward and shift, or one to update
After: staffRequests holds one open request for the ward and shift with count and ordered reasons
After: staffRequests holds one open request for the ward and shift with count and ordered reasons, or the event is refused when This shift has already ended
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
guards: Reject a This-shift request whose chosen shift has already ended (D-158); a Next-shift request that lands just after the shift changes is still kept
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:2268; SHA256 ff62a01c308a19a3cd2f6ea39a1502194855812ad15c7599a441bc8c9e32c4f6
Reviewed source: src/components/ward-management/staffing/staff-requests.ts:156; SHA256 e47b9fadc1f70f84b56bd4e91eb36f5b95f85da6234e10f6251bea10c3f6237a
Reviewed source: src/components/ward-management/staffing/staff-requests.ts:156; SHA256 61771664a49e5dd78341e5a6f34b9ad61b99896c789768c878c93b3bb5ff21ad
Reviewed source: src/components/ward-management/ward-flow-event-roles.ts:605; SHA256 808d614bd517bb11c0ef517ce5632dd3caba8a71758476401f35da82fe26afda
- 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.
Expand All @@ -1721,7 +1722,7 @@ Source-contract SHA256: 7cfda9a71c556b4d10a573977f0ef64456a4f9bb9890c7a236952cd7
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:2269; SHA256 ff62a01c308a19a3cd2f6ea39a1502194855812ad15c7599a441bc8c9e32c4f6
Reviewed source: src/components/ward-management/staffing/staff-requests.ts:195; SHA256 e47b9fadc1f70f84b56bd4e91eb36f5b95f85da6234e10f6251bea10c3f6237a
Reviewed source: src/components/ward-management/staffing/staff-requests.ts:200; SHA256 61771664a49e5dd78341e5a6f34b9ad61b99896c789768c878c93b3bb5ff21ad
Reviewed source: src/components/ward-management/ward-flow-event-roles.ts:615; SHA256 808d614bd517bb11c0ef517ce5632dd3caba8a71758476401f35da82fe26afda
- SET_SITE_FEATURE_SWITCH — modelled; event roles: coordinator
Source-reviewed command model: one listed feature switched off, or back on, at one site (Batch 4 item 20, D-135; access admin by D-142).
Expand Down
12 changes: 6 additions & 6 deletions docs/ward-flow/journey/actions.json
Original file line number Diff line number Diff line change
Expand Up @@ -3093,8 +3093,8 @@
},
{
"path": "src/components/ward-management/legal-forms/legal-forms-screen.tsx",
"line": 365,
"sha256": "21e9f1961cac15e3deac106f62f32fd6f5b9dccd51020fd7827ebb52043b9d19"
"line": 376,
"sha256": "06b9403c02ff16aace7df7690c0ca9f195bf1a674e79aaa9f0274b332fcc06b1"
},
{
"path": "src/components/ward-management/ward-flow-record-events.ts",
Expand Down Expand Up @@ -3239,8 +3239,8 @@
},
{
"path": "src/components/ward-management/legal-forms/legal-forms-screen.tsx",
"line": 1303,
"sha256": "21e9f1961cac15e3deac106f62f32fd6f5b9dccd51020fd7827ebb52043b9d19"
"line": 1321,
"sha256": "06b9403c02ff16aace7df7690c0ca9f195bf1a674e79aaa9f0274b332fcc06b1"
},
{
"path": "src/components/ward-management/ward-flow-record-legal-events.ts",
Expand Down Expand Up @@ -6238,8 +6238,8 @@
},
{
"path": "src/components/ward-management/legal-forms/legal-forms-screen.tsx",
"line": 531,
"sha256": "21e9f1961cac15e3deac106f62f32fd6f5b9dccd51020fd7827ebb52043b9d19"
"line": 542,
"sha256": "06b9403c02ff16aace7df7690c0ca9f195bf1a674e79aaa9f0274b332fcc06b1"
},
{
"path": "src/components/ward-management/ward-flow-record-legal-events.ts",
Expand Down
11 changes: 6 additions & 5 deletions docs/ward-flow/journey/event-dispositions.json
Original file line number Diff line number Diff line change
Expand Up @@ -4178,7 +4178,7 @@
"model": {
"subject": "A ward's staff request for this shift or next shift",
"before": "Known ward; no open request for that ward and shift, or one to update",
"after": "staffRequests holds one open request for the ward and shift with count and ordered reasons",
"after": "staffRequests holds one open request for the ward and shift with count and ordered reasons, or the event is refused when This shift has already ended",
"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",
Expand All @@ -4187,7 +4187,8 @@
"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": [
"Known unit; shift and count from their lists",
"Reject empty, duplicate or unlisted reasons, and a ward naming another ward"
"Reject empty, duplicate or unlisted reasons, and a ward naming another ward",
"Reject a This-shift request whose chosen shift has already ended (D-158); a Next-shift request that lands just after the shift changes is still kept"
],
"effects": [
"Appends a record, or updates count, reasons and updatedAt on the open one; never names a patient",
Expand All @@ -4202,7 +4203,7 @@
{
"path": "src/components/ward-management/staffing/staff-requests.ts",
"line": 156,
"sha256": "e47b9fadc1f70f84b56bd4e91eb36f5b95f85da6234e10f6251bea10c3f6237a"
"sha256": "61771664a49e5dd78341e5a6f34b9ad61b99896c789768c878c93b3bb5ff21ad"
},
{
"path": "src/components/ward-management/ward-flow-event-roles.ts",
Expand Down Expand Up @@ -4243,8 +4244,8 @@
},
{
"path": "src/components/ward-management/staffing/staff-requests.ts",
"line": 195,
"sha256": "e47b9fadc1f70f84b56bd4e91eb36f5b95f85da6234e10f6251bea10c3f6237a"
"line": 200,
"sha256": "61771664a49e5dd78341e5a6f34b9ad61b99896c789768c878c93b3bb5ff21ad"
},
{
"path": "src/components/ward-management/ward-flow-event-roles.ts",
Expand Down
2 changes: 1 addition & 1 deletion docs/ward-flow/journey/machines.json
Original file line number Diff line number Diff line change
Expand Up @@ -1744,7 +1744,7 @@
{
"path": "src/components/ward-management/legal-forms/legal-forms-screen.tsx",
"line": 1,
"sha256": "21e9f1961cac15e3deac106f62f32fd6f5b9dccd51020fd7827ebb52043b9d19"
"sha256": "06b9403c02ff16aace7df7690c0ca9f195bf1a674e79aaa9f0274b332fcc06b1"
},
{
"path": "src/components/ward-management/legal-forms/legal-forms-sheets.tsx",
Expand Down
2 changes: 1 addition & 1 deletion docs/ward-flow/journey/ward-journey-explorer.html

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export function admittedOnAForm(movements: readonly Movement[], admissions: read
.sort((a, b) => (a.closure?.at ?? a.openedAt) - (b.closure?.at ?? b.openedAt));
}

/**
* Everyone on a form on the list (D-157): the open-movement table rows, then the same
* admitted-on-a-form journeys the screen shows, with no duplicates.
*/
export function legalFormsListPopulation<M extends { id: string }>(rows: readonly M[], admitted: readonly M[]): M[] {
const seen = new Set(rows.map((row) => row.id));
return [...rows, ...admitted.filter((movement) => !seen.has(movement.id))];
}

/** What the record holds now: the form still to record, or the form recorded since arrival. */
export function admittedFormLine(movement: Movement): string {
const progression = formProgression(movement);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ import {
legalFormGroupRows,
isLegalDeadlineBreached,
} from "./legal-forms-derivations";
import { admittedOnAForm, LegalFormsAdmitted, WARD_RECORDS_ADMITTED } from "./legal-forms-admitted";
import {
admittedOnAForm,
legalFormsListPopulation,
LegalFormsAdmitted,
WARD_RECORDS_ADMITTED,
} from "./legal-forms-admitted";
import { FocusPanel, typedReceipt } from "./legal-forms-panel";
import { AddTimeDialog } from "./legal-forms-add-time";
import { ChecklistView, FormRow, GAP_LABEL, PhoneFormRow, ROW_COLUMNS } from "./legal-form-rows";
Expand Down Expand Up @@ -254,6 +259,9 @@ function LegalFormsWorkspace({ initialMovementId, recordOnOpen = false }: Worksp
const patientOf = (movement: Movement) => resolveSubjectPatient(movement, { patients, referrals });
const noDeadline = legalFormGroupRows(movements, now, "no-deadline");
const rows = [...withDeadline, ...noDeadline];
// D-157: Print, Export and their disabled state use the same people as the on-screen list,
// including "Admitted on a form" (same `admitted` set the admitted group shows).
const listPopulation = legalFormsListPopulation(rows, admitted);
const withoutForm = openMovements.length - rows.length;
const breakdown = legalFormBreakdown(rows, now);
const reminders = legalExpiryReminderSummary(movements, now, thresholds);
Expand Down Expand Up @@ -337,6 +345,9 @@ function LegalFormsWorkspace({ initialMovementId, recordOnOpen = false }: Worksp
// FS3: only the desks that may open seclusion (D-72: ward, site bed manager, coordinator) see the view.
const seesSeclusion = ROLE_SCREENS[access.role].seclusion !== undefined;
const shownView: View = view === "seclusion" && !seesSeclusion ? "clocks" : view;
// D-157: Checklist Print is window.print() of this view, so Admitted on a form stays on
// Checklist as well as On a form (clocks), and the printed page includes those people.
const showAdmittedOnAForm = shownView === "clocks" || shownView === "checklist";
// Page tops (10 Nov 2026): the bar names the Seclusion tab, as the old Seclusion page did.
const pathname = usePathname() ?? "";
useWardBarPageTitle(pathname, shownView === "seclusion" ? "Seclusion" : undefined);
Expand Down Expand Up @@ -545,14 +556,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).
// Export (feature 44, D-157): everyone on a form on the list, including admitted on a form;
// never names; legal times exactly as typed (D-93, D-102).
const exportList = async () =>
(await import("./legal-forms-export")).downloadLegalFormsList(
rows,
listPopulation,
{ 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)",
scope:
"Legal forms; everyone on a form on the list, including admitted on a form, expiry typed first (highlights do not narrow it)",
},
);
const addTimeTarget = rows.find((movement) => movement.id === addTimeId) ?? null;
Expand Down Expand Up @@ -1047,7 +1060,7 @@ function LegalFormsWorkspace({ initialMovementId, recordOnOpen = false }: Worksp
onSelect={select}
/>
) : null}
{shownView === "clocks" ? admittedList : null}
{showAdmittedOnAForm ? admittedList : null}
{shownView === "history" ? historyList : null}
{/* FS1: as on desktop, the full notice closes the list it qualifies, so the phone reaches
the people on a form first. */}
Expand Down Expand Up @@ -1112,8 +1125,12 @@ function LegalFormsWorkspace({ initialMovementId, recordOnOpen = false }: Worksp
) : null}
{shownView === "history" ? null : (
<>
<ListPrintButton disabled={rows.length === 0} testId="ward-legal-print" />
<ListExportButton load={exportList} disabled={rows.length === 0} testId="ward-legal-export" />
<ListPrintButton disabled={listPopulation.length === 0} testId="ward-legal-print" />
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
<ListExportButton
load={exportList}
disabled={listPopulation.length === 0}
testId="ward-legal-export"
/>
</>
)}
</>
Expand Down Expand Up @@ -1197,7 +1214,7 @@ function LegalFormsWorkspace({ initialMovementId, recordOnOpen = false }: Worksp
/>
) : null}

{shownView === "clocks" ? admittedList : null}
{showAdmittedOnAForm ? admittedList : null}
{shownView === "history" ? historyList : null}
{/* The full notice closes the list it qualifies, not a loose line under the hero. */}
<div className={styles.listFoot}>
Expand Down
5 changes: 5 additions & 0 deletions src/components/ward-management/staffing/staff-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ export function reduceStaffRequest(state: WardFlowState, event: StaffRequestEven
)
return reject(state, event, "REQUEST_WARD_STAFF needs one or more reasons chosen from the list");
const shiftStartAt = chosenShiftStart(event);
// D-158: a "This shift" pressed just before the shift ends, and saved just after, would
// otherwise keep the ended shift — accepted, immediately not open, and unable to be closed.
// "Next shift" across the same boundary stays valid and is kept.
if (event.shift === "this_shift" && event.now >= staffRequestEndsAt({ shiftStartAt }))
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
return reject(state, event, "This shift has already ended. Choose this shift or next shift again");
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(
Expand Down
36 changes: 35 additions & 1 deletion tests/ward-legal-form-admitted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
import type { WardFlowRole } from "../src/components/ward-management/ward-flow-events";
import type { WardAccess } from "../src/components/ward-management/ward-role-access";
import { NOW_ANCHOR } from "../src/components/ward-management/ward-sites";
import { admittedFormLine, admittedOnAForm } from "../src/components/ward-management/legal-forms/legal-forms-admitted";
import {
admittedFormLine,
admittedOnAForm,
legalFormsListPopulation,
} from "../src/components/ward-management/legal-forms/legal-forms-admitted";
import { isOpen } from "../src/components/ward-management/ward-derivations";

/**
* Walk-through WT-03 (26 Oct 2026): a person who arrived on a Form 1A still showed Form 1A on the
Expand Down Expand Up @@ -120,3 +125,32 @@ describe("the ward records the next form for a person admitted on a form (WT-03)
expect(commandScopeRefusal(state, other, command, options)).toMatch(/outside your role's place/);
});
});

describe("legal forms list population (D-157)", () => {
it("puts admitted-on-a-form journeys after the open-movement rows, without duplicates", () => {
const { state, movementId } = arrivedOnA1A();
const rows = state.movements.filter((movement) => isOpen(movement) && movement.legalForm !== undefined);
const admitted = admittedOnAForm(state.movements, state.admissions);
expect(admitted.map((movement) => movement.id)).toContain(movementId);
expect(rows.map((movement) => movement.id)).not.toContain(movementId);
const population = legalFormsListPopulation(rows, admitted);
expect(population.map((movement) => movement.id)).toEqual([
...rows.map((movement) => movement.id),
...admitted.map((movement) => movement.id),
]);
expect(new Set(population.map((movement) => movement.id)).size).toBe(population.length);
// A journey already in the open rows is kept once, in the rows' place.
expect(legalFormsListPopulation(admitted, admitted).map((movement) => movement.id)).toEqual(
admitted.map((movement) => movement.id),
);
});

it("is empty only when both the open-form rows and the admitted group are empty", () => {
expect(legalFormsListPopulation([], [])).toEqual([]);
const { state, movementId } = arrivedOnA1A();
const admitted = admittedOnAForm(state.movements, state.admissions);
const onlyAdmitted = legalFormsListPopulation([], admitted);
expect(onlyAdmitted.map((movement) => movement.id)).toContain(movementId);
expect(onlyAdmitted).toHaveLength(admitted.length);
});
});
Loading