From eb252afe70e94c2f9f6126c26f556418ac83d689 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:15:53 +0000 Subject: [PATCH 1/7] fix(capacity): count a booked person holding a named bed once in occupancy and forecasts Per-ward expected occupancy counted a named hold in takenNow and the same person's planned admission again, so 24/48/72h rose by 2. The network forecast and the by-kind forecast had the same double count for a named referral hold (and a later-stage journey hold). A shared patientsHoldingANamedBed rule now feeds plannedAdmissionsStillNeedingABed. Approved by Josh, 11 Nov 2026. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../ward-management/capacity/beds-forecast.ts | 4 ++ .../capacity/expected-occupancy.ts | 6 +- .../capacity/planned-admissions-reducer.ts | 12 ++-- .../forecast/forecast-by-kind.ts | 2 + .../ward-management/ward-movement-bed-hold.ts | 27 ++++++++ tests/ward-expected-occupancy.test.ts | 63 ++++++++++++++++++- 6 files changed, 107 insertions(+), 7 deletions(-) diff --git a/src/components/ward-management/capacity/beds-forecast.ts b/src/components/ward-management/capacity/beds-forecast.ts index 4671710bc9..709d0e6bab 100644 --- a/src/components/ward-management/capacity/beds-forecast.ts +++ b/src/components/ward-management/capacity/beds-forecast.ts @@ -15,6 +15,7 @@ import { bedStates, type ReservationReferral } from "@/components/ward-managemen import { bedIsOccupied, type Admission, type PlannedAdmission } from "@/components/ward-management/ward-admissions"; import { dayOf, MINUTES_PER_DAY, type Instant } from "@/components/ward-management/ward-clock"; import { isOpen } from "@/components/ward-management/ward-derivations"; +import { patientsHoldingANamedBed } from "@/components/ward-management/ward-movement-bed-hold"; import type { BedRelease, Movement, MovementStage, Unit } from "@/components/ward-management/ward-model"; import { bedsForecastSources, type BedsForecastSources } from "./beds-forecast-sources"; import { plannedAdmissionsStillNeedingABed } from "./planned-admissions-reducer"; @@ -113,6 +114,8 @@ export function bedsForecast( const reservations = [...movements, ...referrals]; const readyNow = openUnits.reduce((sum, unit) => sum + bedStates(unit, admissions, [], [], reservations).ready, 0); const openUnitIds = new Set(openUnits.map((unit) => unit.id)); + // A booked person who already holds a named bed is out of Ready once; never count the booking too. + const namedBedHolders = patientsHoldingANamedBed(openUnits, admissions, reservations); const unitIds = new Set(units.map((unit) => unit.id)); const admissionsById = new Map(admissions.map((admission) => [admission.id, admission])); const expectedAt = (release: BedRelease) => forecastReleaseAt(release, admissionsById.get(release.admissionId)); @@ -146,6 +149,7 @@ export function bedsForecast( until, waitingMovements, admissions, + namedBedHolders, ).length; const bedsNeeded = waitingForBed + planned; const low = readyNow + confirmed - bedsNeeded; diff --git a/src/components/ward-management/capacity/expected-occupancy.ts b/src/components/ward-management/capacity/expected-occupancy.ts index f9bc878bcd..509b3c3354 100644 --- a/src/components/ward-management/capacity/expected-occupancy.ts +++ b/src/components/ward-management/capacity/expected-occupancy.ts @@ -48,8 +48,10 @@ export function expectedOccupancy( .map((unit): OccupancyRow => { const states = bedStates(unit, admissions, [...releases], [], reservations); const takenNow = states.occupied + states.pulled; - // The forecast for this ward alone, with nobody waiting: waiting people have no ward yet. - const forecast = bedsForecast([unit], [...releases], [...admissions], [], now, planned); + // The forecast for this ward alone, with nobody waiting: waiting people have no ward yet. The + // named holds go in too, so a booking for someone already holding a bed here (in `takenNow`) + // is not added a second time. + const forecast = bedsForecast([unit], [...releases], [...admissions], [], now, planned, reservations); return { unitId: unit.id, name: unit.name, diff --git a/src/components/ward-management/capacity/planned-admissions-reducer.ts b/src/components/ward-management/capacity/planned-admissions-reducer.ts index e982e3431a..48a1621099 100644 --- a/src/components/ward-management/capacity/planned-admissions-reducer.ts +++ b/src/components/ward-management/capacity/planned-admissions-reducer.ts @@ -175,7 +175,8 @@ function onAnOpenJourney(state: WardFlowState, patientId: string): boolean { /** * The bookings still to count as beds needed: booked, on a known ward, due by `until`, and not a - * linked patient already counted through a waiting movement or already holding a pulled/occupied bed. + * linked patient already counted through a waiting movement, already holding a pulled/occupied bed, + * or already holding a numbered bed by name. * Initials-only bookings are always counted: nothing links them to a movement. */ export function plannedAdmissionsStillNeedingABed( @@ -184,10 +185,13 @@ export function plannedAdmissionsStillNeedingABed( until: Instant, waitingMovements: readonly Movement[], admissions: readonly Admission[] = [], + /** People already holding a numbered bed by name (`patientsHoldingANamedBed`): their bed is out of Ready. */ + namedBedHolders: Iterable = [], ): PlannedAdmission[] { - const accountedPatientIds = new Set( - waitingMovements.map((movement) => movement.patientId).filter((id): id is NonNullable => Boolean(id)), - ); + const accountedPatientIds = new Set([ + ...waitingMovements.map((movement) => movement.patientId).filter((id): id is NonNullable => Boolean(id)), + ...namedBedHolders, + ]); // The booking remains on the calendar after an ordinary movement is pulled or arrives. // Its bed has already left Ready; do not count that same person as fresh demand again. for (const admission of admissions) { diff --git a/src/components/ward-management/forecast/forecast-by-kind.ts b/src/components/ward-management/forecast/forecast-by-kind.ts index e5c7a3ef57..57482bff92 100644 --- a/src/components/ward-management/forecast/forecast-by-kind.ts +++ b/src/components/ward-management/forecast/forecast-by-kind.ts @@ -16,6 +16,7 @@ import { bedIsOccupied, type Admission, type PlannedAdmission } from "@/componen import { dayOf, MINUTES_PER_DAY, type Instant } from "@/components/ward-management/ward-clock"; import { numberedBedKind, unitHasOpenBeds } from "@/components/ward-management/ward-bed-designation"; import { isOpen } from "@/components/ward-management/ward-derivations"; +import { patientsHoldingANamedBed } from "@/components/ward-management/ward-movement-bed-hold"; import type { BedRelease, Movement, Unit } from "@/components/ward-management/ward-model"; import { forecastReleaseAt } from "./forecast-release-date"; @@ -125,6 +126,7 @@ export function forecastByKind( until, waitingMovements, admissions, + patientsHoldingANamedBed([...openUnits.values()], admissions, [...movements, ...referrals]), ); const kinds = KIND_ORDER.map((id): KindForecast => { diff --git a/src/components/ward-management/ward-movement-bed-hold.ts b/src/components/ward-management/ward-movement-bed-hold.ts index 5e1adc8bb9..e3d3938e5a 100644 --- a/src/components/ward-management/ward-movement-bed-hold.ts +++ b/src/components/ward-management/ward-movement-bed-hold.ts @@ -127,6 +127,33 @@ export function numberedReservationBeds( return held; } +/** + * The people who already hold a numbered bed by name on one of `units`, by the same rule as + * `numberedReservationBeds`. Their bed is already out of Ready (and in a ward's Pulled count), so a + * booking for the same person must not be counted as fresh demand again (Josh, 11 Nov 2026). + */ +export function patientsHoldingANamedBed( + units: readonly Unit[], + admissions: readonly Admission[], + reservations: readonly (Movement | ReservationReferral)[], +): Set { + const holders = new Set(); + for (const row of reservations) { + if (!row.patientId || holders.has(row.patientId)) continue; + // Only a row carrying a bed number can hold one; the full rule then runs for that row alone. + const numbered = + "stage" in row + ? row.heldBedNumber !== undefined + : ("destinations" in row ? row.destinations : [row]).some((arm) => arm.heldBedNumber !== undefined); + if (!numbered) continue; + const asMovement = "stage" in row ? [row] : []; + const asReferral = "stage" in row ? [] : [row]; + if (units.some((unit) => numberedReservationBeds(unit, admissions, asMovement, asReferral).size > 0)) + holders.add(row.patientId); + } + return holders; +} + /** `numberedReservationBeds` as the gates use it: how many, and how many of each bed kind. */ export function pendingNumberedReservations( unit: Unit, diff --git a/tests/ward-expected-occupancy.test.ts b/tests/ward-expected-occupancy.test.ts index ccdceb5a24..aaf709c88d 100644 --- a/tests/ward-expected-occupancy.test.ts +++ b/tests/ward-expected-occupancy.test.ts @@ -10,7 +10,7 @@ import { bedStates } from "@/components/ward-management/ward-bed-states"; import { MINUTES_PER_DAY } from "@/components/ward-management/ward-clock"; import { bedReleases } from "@/components/ward-management/ward-movements"; import { NOW_ANCHOR, allUnits } from "@/components/ward-management/ward-sites"; -import type { BedRelease, Movement } from "@/components/ward-management/ward-model"; +import type { BedRelease, Movement, Referral } from "@/components/ward-management/ward-model"; const NOW = NOW_ANCHOR; const unit = allUnits()[0]!; @@ -98,6 +98,67 @@ describe("expectedOccupancy", () => { expect(withHold.rows[0]!.horizons[0]!.high).toBe(without.rows[0]!.horizons[0]!.high + 1); }); + it("counts a booked person who already holds a named bed here once, not twice (Josh, 11 Nov 2026)", () => { + const booking = { + id: "PA-OC-HOLD", + unitId: unit.id, + patientId: "PT-OC-HOLD", + state: "booked", + expectedArrivalAt: NOW + 60, + } as unknown as PlannedAdmission; + const journey = { + id: "WF-OC-HOLD-BOOKED", + patientId: "PT-OC-HOLD", + stage: "accepted_awaiting_bed", + acceptedUnitId: unit.id, + heldBedNumber: 1, + } as unknown as Movement; + const referral = { + id: "RF-OC-HOLD-BOOKED", + patientId: "PT-OC-HOLD", + destinations: [ + { state: "accepted", destination: { kind: "psychiatric_ward", unitId: unit.id }, heldBedNumber: 2 }, + ], + } as unknown as Referral; + const empty = expectedOccupancy([unit], [], [] as Admission[], NOW); + for (const hold of [journey, referral]) { + const held = expectedOccupancy([unit], [], [] as Admission[], NOW, [booking], [hold]); + // The hold is in takenNow already; the booking for the same person adds nothing more. + expect(held.rows[0]!.takenNow).toBe(empty.rows[0]!.takenNow + 1); + for (const [index, horizon] of held.rows[0]!.horizons.entries()) { + expect(horizon.likely).toBe(empty.rows[0]!.horizons[index]!.likely + 1); + expect(horizon.high).toBe(empty.rows[0]!.horizons[index]!.high + 1); + } + } + // A booking for someone else still counts. + const other = { ...booking, id: "PA-OC-OTHER", patientId: "PT-OC-OTHER" } as PlannedAdmission; + const both = expectedOccupancy([unit], [], [] as Admission[], NOW, [booking, other], [journey]); + expect(both.rows[0]!.horizons[0]!.likely).toBe(empty.rows[0]!.horizons[0]!.likely + 2); + }); + + it("the network forecast does not count a named referral hold and its booking twice", () => { + const booking = { + id: "PA-NET-HOLD", + unitId: unit.id, + patientId: "PT-NET-HOLD", + state: "booked", + expectedArrivalAt: NOW + 60, + } as unknown as PlannedAdmission; + const referral = { + id: "RF-NET-HOLD", + patientId: "PT-NET-HOLD", + destinations: [ + { state: "accepted", destination: { kind: "psychiatric_ward", unitId: unit.id }, heldBedNumber: 2 }, + ], + } as unknown as Referral; + const without = bedsForecast([unit], [], [], [], NOW, [booking]); + const held = bedsForecast([unit], [], [], [], NOW, [booking], [referral]); + expect(without.horizons[0]!.plannedAdmissions).toBe(1); + expect(held.horizons[0]!.plannedAdmissions).toBe(0); + // One person, one bed: Ready drops by the hold, and the booking no longer takes another. + expect(held.horizons[0]!.readyNow).toBe(without.horizons[0]!.readyNow - 1); + }); + it("says a percentage, or Not recorded with no beds", () => { expect(occupancyPercent(18, 20)).toBe("90%"); expect(occupancyPercent(21, 20)).toBe("105%"); From c985795f72c7cbcd0e6ad628292bd2d1a7fd5d25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:15:53 +0000 Subject: [PATCH 2/7] fix(legal-forms): an ended form's line gives its typed expiry as history legalDeadlineText showed "Form expiry ends 15:42 as typed" under "Ended". A form recorded as ended now reads "Form ended; expiry as typed was 15:42" (or "Form ended" with no time typed). Times stay as typed (D-93). Approved by Josh, 11 Nov 2026. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../legal-forms/legal-forms-derivations.ts | 10 +++++++- .../ward-audit-m17-legal-as-recorded.test.ts | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/components/ward-management/legal-forms/legal-forms-derivations.ts b/src/components/ward-management/legal-forms/legal-forms-derivations.ts index 1ce350f6d7..f3858e2ed9 100644 --- a/src/components/ward-management/legal-forms/legal-forms-derivations.ts +++ b/src/components/ward-management/legal-forms/legal-forms-derivations.ts @@ -13,7 +13,7 @@ import type { LegalForm, Movement } from "@/components/ward-management/ward-mode import type { WardRecordTone } from "@/components/ward-management/ward-record-row"; import { currentLegalDueAt, formProgression, formProgressionPhrase } from "./legal-form-progression"; export { currentLegalDueAt } from "./legal-form-progression"; -import { typedTimePhrase } from "./legal-time-as-typed"; +import { typedClock, typedTimePhrase } from "./legal-time-as-typed"; /** * LEGAL FORMS AND DEADLINES — pure derivations for `LegalFormsScreen`. @@ -181,6 +181,14 @@ export function legalDeadlineText(movement: Movement, now: Instant): string { throw new Error(`legalDeadlineText called on movement ${movement.id}, which carries no legal form`); } const progression = formProgression(movement); + if (progression?.ended) return `${formProgressionPhrase(progression)}; Form ${progression.nextCode} to record`; + // A form a person recorded as ended keeps its typed expiry as history only (Josh, 11 Nov 2026): + // never "Form expiry ends 15:42 as typed" under "Ended", which reads as a live expiry. + if (legalForm.endedBy !== undefined) { + return legalForm.dueAt === undefined + ? "Form ended" + : `Form ended; expiry as typed was ${typedClock(legalForm.dueAt, now)}`; + } if (progression) return `${formProgressionPhrase(progression)}; Form ${progression.nextCode} to record`; if (legalForm.dueAt !== undefined) { // Legal times only as typed (31 Oct 2026): "Form expiry ends 15:42 as typed", never a count. diff --git a/tests/ward-audit-m17-legal-as-recorded.test.ts b/tests/ward-audit-m17-legal-as-recorded.test.ts index 3e5c9f36d0..4196c27ba3 100644 --- a/tests/ward-audit-m17-legal-as-recorded.test.ts +++ b/tests/ward-audit-m17-legal-as-recorded.test.ts @@ -247,6 +247,30 @@ describe("M17: one form, edited, replaced and lapsed, reads the same on every ou expect(outputs.downtimeEnded).toBe(true); }); + // Fixed 11 Nov 2026 (Josh): the Legal forms card and row said "Ended" then "Form expiry ends 15:42 + // as typed", which reads as a live expiry. The ended form's typed time is history, as typed. + it("on the Legal forms page, an ended form's line says it ended and gives the typed expiry as history", () => { + const { second } = journey(); + const ended = apply(second, { + type: "RECORD_LEGAL_FORM_DETAILS", + role: "ed", + now: NOW + 3, + movementId: MOVEMENT_ID, + formCode: "3A", + endedBy: "revoked", + }); + const movement = movementOf(ended); + const dueAt = movement.legalForm!.dueAt!; + for (const now of [NOW + 3, dueAt + 61]) { + const page = legalDeadlineText(movement, now); + expect(page).toBe(`Form ended; expiry as typed was ${typedClock(dueAt, now)}`); + expect(page).not.toContain(typedTimePhrase(dueAt, now)); + expect(page).not.toMatch(COUNT); + } + const noTime = { ...movement, legalForm: { ...movement.legalForm!, dueAt: undefined } } as Movement; + expect(legalDeadlineText(noTime, NOW + 3)).toBe("Form ended"); + }); + it("names the form on the TV wall by its full name (D-93), not the bare code", () => { const { second } = journey(); const outputs = outputsAt(second, NOW + 2); From eda932a0b54d282b25a6cb52e34c79a13015ff45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:15:53 +0000 Subject: [PATCH 3/7] fix(ed): exactly 24 hours counts as a long wait on the ED screen Adds isLongWait (>= LONG_WAIT_MINUTES) and uses it in the ED priority flags and timeline, matching Alerts, TV, bed meeting and Statistics. Approved by Josh, 11 Nov 2026. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../ed/ed-screen-derivations.ts | 3 +- .../ward-management/ed/ed-screen-timeline.tsx | 12 +-- .../ward-operational-defaults.ts | 8 ++ tests/ward-ed-long-wait-boundary.test.ts | 76 +++++++++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 tests/ward-ed-long-wait-boundary.test.ts diff --git a/src/components/ward-management/ed/ed-screen-derivations.ts b/src/components/ward-management/ed/ed-screen-derivations.ts index b2704f34d2..3e6fc9271c 100644 --- a/src/components/ward-management/ed/ed-screen-derivations.ts +++ b/src/components/ward-management/ed/ed-screen-derivations.ts @@ -18,6 +18,7 @@ import { edById } from "@/components/ward-management/ward-sites"; import { ED_REFERRED_NOT_SEEN_MINUTES, ED_SEVERE_PRESSURE_WAIT_MINUTES, + isLongWait, LATE_ARRIVAL_GRACE_MINUTES, LONG_WAIT_MINUTES, LONG_WAIT_TEXT, @@ -474,7 +475,7 @@ export function deriveEdScreen({ onAction: () => setArrivalPlanOpenFor(m.id), }); } - if (m.originEdId === thisEdId && isWaitingInEd(m) && now - m.openedAt > LONG_WAIT_MINUTES) { + if (m.originEdId === thisEdId && isWaitingInEd(m) && isLongWait(now - m.openedAt)) { priorityFlags.push({ tone: "quiet", kind: `Waiting ${LONG_WAIT_TEXT}`, diff --git a/src/components/ward-management/ed/ed-screen-timeline.tsx b/src/components/ward-management/ed/ed-screen-timeline.tsx index faca91835b..a944972102 100644 --- a/src/components/ward-management/ed/ed-screen-timeline.tsx +++ b/src/components/ward-management/ed/ed-screen-timeline.tsx @@ -6,7 +6,7 @@ import { isOpen } from "@/components/ward-management/ward-derivations"; import type { WardFlowContextValue } from "@/components/ward-management/ward-flow-provider"; import { WARD_FLOW_ROLE_LABELS } from "@/components/ward-management/ward-flow-roles"; import { legalFormName } from "@/components/ward-management/ward-legal-forms"; -import { LONG_WAIT_MINUTES, LONG_WAIT_TEXT } from "@/components/ward-management/ward-operational-defaults"; +import { isLongWait, LONG_WAIT_TEXT } from "@/components/ward-management/ward-operational-defaults"; import { DECLINE_REASON_LABELS } from "@/components/ward-management/ward-referrals"; import { resolveSubjectPatient } from "@/components/ward-management/ward-patient-resolver"; import { movementMedicalClearance } from "./ed-screen-model"; @@ -97,25 +97,25 @@ export function edDepartmentTimelineEvents({ movements, }); if (now - m.openedAt <= 24 * 60 || isMovementOpen) { - const isLongWait = now - m.openedAt > LONG_WAIT_MINUTES && isMovementOpen; + const longWait = isLongWait(now - m.openedAt) && isMovementOpen; timelineEvents.push({ id: `arrival-${m.id}-${m.openedAt}`, min: m.openedAt, - tone: isLongWait ? "warn" : "quiet", + tone: longWait ? "warn" : "quiet", category: "arrivals", categoryLabel: "Arrivals & Departures", patientName: patientInfo.displayName, movementId: m.id, badgeText: "Arrived", - badgeTone: isLongWait ? "warn" : "quiet", + badgeTone: longWait ? "warn" : "quiet", primaryText: "Arrived at Emergency Department", - detailText: isLongWait ? `Waiting ${splitDuration(now - m.openedAt)} (${LONG_WAIT_TEXT})` : undefined, + detailText: longWait ? `Waiting ${splitDuration(now - m.openedAt)} (${LONG_WAIT_TEXT})` : undefined, by: null, isOpenMovement: isMovementOpen, what: ( <> {patientInfo.displayName} arrived at the department - {isLongWait && ( + {longWait && ( {" "} — waiting {LONG_WAIT_TEXT} ({splitDuration(now - m.openedAt)}) diff --git a/src/components/ward-management/ward-operational-defaults.ts b/src/components/ward-management/ward-operational-defaults.ts index 9604202821..c8249eae67 100644 --- a/src/components/ward-management/ward-operational-defaults.ts +++ b/src/components/ward-management/ward-operational-defaults.ts @@ -97,6 +97,14 @@ export const ED_SEVERE_PRESSURE_WAIT_MINUTES = CLINICAL_VALUES.edSeverePressure. export const LONG_WAIT_MINUTES = CLINICAL_VALUES.longWait.value; /** The words screens use for it, built from the value: "past 24 hours". */ export const LONG_WAIT_TEXT = `past ${LONG_WAIT_MINUTES / MINUTES_PER_HOUR} hours`; +/** + * Whether a wait counts as long: at or past `LONG_WAIT_MINUTES`, so exactly 24 hours counts. One + * boundary for every screen (Josh, 11 Nov 2026): the ED screen used "past" (>) while Alerts, the + * TV, the bed meeting and Statistics used "at or past" (>=). + */ +export function isLongWait(waitMinutes: number): boolean { + return waitMinutes >= LONG_WAIT_MINUTES; +} /** * M16 (signed by Josh 10 Nov 2026, D-130, as they were typed; synthetic prototype values, not legal diff --git a/tests/ward-ed-long-wait-boundary.test.ts b/tests/ward-ed-long-wait-boundary.test.ts new file mode 100644 index 0000000000..4ef9063190 --- /dev/null +++ b/tests/ward-ed-long-wait-boundary.test.ts @@ -0,0 +1,76 @@ +// C3 (Josh, 11 Nov 2026): one long-wait boundary. Exactly 24 hours counts as a long wait on the ED +// screen, as it already did on Alerts, the TV, the bed meeting and Statistics (`>=`, not `>`). +import { describe, expect, it } from "vitest"; + +import { deriveEdScreen } from "@/components/ward-management/ed/ed-screen-derivations"; +import { isWaitingInEd } from "@/components/ward-management/ward-ed-presence"; +import { seedWardFlowState, type WardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import { isLongWait, LONG_WAIT_MINUTES, LONG_WAIT_TEXT } from "@/components/ward-management/ward-operational-defaults"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; + +const NOW = NOW_ANCHOR; +const noop = () => undefined; + +function board(state: WardFlowState, edId: string) { + return deriveEdScreen({ + movements: state.movements, + units: state.units, + referrals: state.referrals, + registryPatients: state.patients, + configuration: state.configuration, + inboxSnoozes: state.inboxSnoozes, + admissions: state.admissions, + now: NOW, + accessTarget: state.configuration.edAccessTargetMinutes, + thisEdId: edId, + boardFilter: "all", + patientQuery: "", + patientSort: "current", + heroHighlight: null, + selectedPatientId: null, + arrivalPlanOpenFor: undefined, + setSelectedPatientId: noop, + setPhoneSheetFor: noop, + setArrivalPlanOpenFor: noop, + toggleBookTransport: noop, + recordEdStep: noop, + }); +} + +/** A seeded person waiting in an ED, opened `waited` minutes before now. */ +function waitingFor(waited: number) { + const seed = seedWardFlowState(); + const journey = seed.movements.find((movement) => movement.originEdId !== undefined && isWaitingInEd(movement)); + if (!journey?.originEdId) throw new Error("the seed has nobody waiting in an ED"); + const state = { + ...seed, + movements: seed.movements.map((movement) => + movement.id === journey.id ? { ...movement, openedAt: NOW - waited } : movement, + ), + }; + const view = board(state, journey.originEdId); + return { + flagged: view.needsYouItems.some( + (item) => item.key.startsWith(`${journey.id}-`) && item.kind === `Waiting ${LONG_WAIT_TEXT}`, + ), + timelineTone: view.timelineEvents.find((event) => event.id === `arrival-${journey.id}-${NOW - waited}`)?.tone, + }; +} + +describe("long-wait boundary", () => { + it("counts exactly 24 hours as a long wait, and one minute less as not", () => { + expect(LONG_WAIT_MINUTES).toBe(24 * 60); + expect(isLongWait(LONG_WAIT_MINUTES)).toBe(true); + expect(isLongWait(LONG_WAIT_MINUTES - 1)).toBe(false); + expect(isLongWait(LONG_WAIT_MINUTES + 1)).toBe(true); + }); + + it("flags a person on the ED screen and its timeline at exactly 24 hours", () => { + const at = waitingFor(LONG_WAIT_MINUTES); + expect(at.flagged).toBe(true); + expect(at.timelineTone).toBe("warn"); + const before = waitingFor(LONG_WAIT_MINUTES - 1); + expect(before.flagged).toBe(false); + expect(before.timelineTone).toBe("quiet"); + }); +}); From db5b91022d6cd92a1dd4f79b427aeb3cad9a8aa0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:24:12 +0000 Subject: [PATCH 4/7] Raise four phone limits by 0.3 KB for the shared forecast fix The named-bed check in the bed forecast and the shared ED long-wait helper add 0.1 to 0.3 KB gzipped to routes that already load the forecast. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- .../2026-11-11-pr1621-held-clinical-review-fixes.md | 9 +++++++++ script-budget.json | 8 ++++---- 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 docs/script-budget-history/2026-11-11-pr1621-held-clinical-review-fixes.md diff --git a/docs/script-budget-history/2026-11-11-pr1621-held-clinical-review-fixes.md b/docs/script-budget-history/2026-11-11-pr1621-held-clinical-review-fixes.md new file mode 100644 index 0000000000..82fe739803 --- /dev/null +++ b/docs/script-budget-history/2026-11-11-pr1621-held-clinical-review-fixes.md @@ -0,0 +1,9 @@ +# 11 Nov 2026, Held clinical review fixes (PR #1621) + +Changed on top of `main` at `5b08a5e`: Home `/ward-flow` raised 575.4 to 575.7; referrals `/ward-flow/referrals` raised 583.6 to 583.9; ward `/ward-flow/ward/rph-adult-secure` raised 613.8 to 614.1; capacity `/ward-flow/capacity` raised 654.3 to 654.7. Each raise is under 1 KB, within the 5 KB per-screen allowance in HOW-WE-WORK section 6. + +Measured on hosted CI (production build of the PR head `eda932a`, merged with main): Home 575.4 KB, referrals 583.6 KB, ward 613.8 KB, capacity 654.4 KB, each over its limit after rounding. Main alone measured 575.2, 583.5, 613.6 and 654.1. Each new limit is the measurement plus about 0.3. + +Why it changed: the shared bed-forecast code that these routes load grew by 0.1 to 0.3 KB gzipped. Planned admissions now skip a patient who already holds a named bed (`patientsHoldingANamedBed`), and the ED long-wait check moved into one shared `isLongWait` helper (D-145 item 1). + +Trim tried: the forecast is already part of each route's first load and the added code is the fix itself, so lazy loading would save nothing. No other safe trim was found. diff --git a/script-budget.json b/script-budget.json index 1dd84ed777..dda008f195 100644 --- a/script-budget.json +++ b/script-budget.json @@ -1,12 +1,12 @@ { "$comment": "Phone first-load script budgets in KB gzipped, checked by scripts/check-script-budget.mjs after the production build. Set on 16 October 2026 at the measured size plus about 10 KB, and lowered the same day after the rail and search left a phone's first load. Lower a budget when a route shrinks. Raise one only with a reason in the PR. Raise and lowering history: docs/script-budget-history.md up to 4 November 2026, then one file per PR in docs/script-budget-history/ (see its README), not here.", "budgets": { - "/ward-flow": 575.4, - "/ward-flow/referrals": 583.6, + "/ward-flow": 575.7, + "/ward-flow/referrals": 583.9, "/ward-flow/ed": 535.4, - "/ward-flow/ward/rph-adult-secure": 613.8, + "/ward-flow/ward/rph-adult-secure": 614.1, "/ward-flow/movements/WF-001": 609.8, - "/ward-flow/capacity": 654.3, + "/ward-flow/capacity": 654.7, "/ward-flow/ed/arm-ed": 667.4, "/ward-flow/bed-meeting": 592.2, "/ward-flow/statistics": 632.6 From 8ac30c40855b97f29a20c6cb34a68892bdf27ff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 04:38:36 +0000 Subject: [PATCH 5/7] Refresh journey source digests for the planned admissions reducer Booking, change, cancel and convert dispatch is unchanged; only the bed-needed filter gained the named-bed holders, so the reviewed lines moved by four and the file digest changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nkwW4xTv5Ftznofb8N --- docs/ward-flow/journey/MAP-INVENTORY.md | 20 ++++++++-------- .../ward-flow/journey/event-dispositions.json | 24 +++++++++---------- docs/ward-flow/journey/machines.json | 4 ++-- .../journey/ward-journey-explorer.html | 2 +- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/ward-flow/journey/MAP-INVENTORY.md b/docs/ward-flow/journey/MAP-INVENTORY.md index 5bf83b42ed..57c5ae317b 100644 --- a/docs/ward-flow/journey/MAP-INVENTORY.md +++ b/docs/ward-flow/journey/MAP-INVENTORY.md @@ -3,7 +3,7 @@ The route is selective. Its action notes and the additional command models have source review evidence; source digests detect later edits. This does not establish runtime or clinical acceptance. ## 193 CURRENT ENGINE EVENTS -Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e18616914ab6 +Source-contract SHA256: 889a0fe522d43c660c54249c567d047bd7f80b7cd213f1d01a96e85bbd8d5cf6 - RECORD_ADMISSION_CARE — diagram-reviewed; event roles: ward, coordinator, community Diagram action notes reviewed against the recorded source revision. Source digests detect subsequent edits; this is not runtime acceptance. Permission source: src/components/ward-management/ward-flow-event-roles.ts:20 @@ -1710,14 +1710,14 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 effects: Stores a required same-ward authorisation override. No bed count or admission changes until conversion; initials invoke the typed-text persistence boundary. tests: tests/ward-planned-admissions.test.ts tests: tests/ward-audit24-1-planned-booking-authorised.test.ts - Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:299; SHA256 fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3 - Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:263; SHA256 fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3 + Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:303; SHA256 f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603 + Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:267; SHA256 f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603 Reviewed source: src/components/ward-management/ward-flow-events.ts:2431; SHA256 378044cce3b2c98c2c24f4e5bb6ab27f7c361f02a7fa8ab59fa36a77388c84bf Reviewed source: src/components/ward-management/ward-access-state.ts:659; SHA256 924e9e6ab48fddbf423e5cf988cc09b75df3ace0ce4f4465f379b143957e3d07 - CHANGE_PLANNED_ADMISSION — modelled; event roles: coordinator, bed_manager, ward Reviewed command model: Still booked, with revised fields, changedAt and incremented changeCount. Permission source: src/components/ward-management/ward-flow-event-roles.ts:614 - Event/dispatch reference: src/components/ward-management/capacity/planned-admissions-reducer.ts:388 + Event/dispatch reference: src/components/ward-management/capacity/planned-admissions-reducer.ts:392 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:2345 Subject: An existing booked planned admission Before: booked plan with current ward/date/stay/reason/legal status/gender @@ -1735,14 +1735,14 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 effects: Replaces planning fields, records changedAt/count and record-derived linked gender. No bed is held or released. tests: tests/ward-planned-admissions.test.ts tests: tests/ward-audit24-1-planned-booking-authorised.test.ts - Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:383; SHA256 fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3 - Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:263; SHA256 fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3 + Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:387; SHA256 f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603 + Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:267; SHA256 f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603 Reviewed source: src/components/ward-management/ward-flow-events.ts:2488; SHA256 378044cce3b2c98c2c24f4e5bb6ab27f7c361f02a7fa8ab59fa36a77388c84bf Reviewed source: src/components/ward-management/ward-access-state.ts:659; SHA256 924e9e6ab48fddbf423e5cf988cc09b75df3ace0ce4f4465f379b143957e3d07 - CANCEL_PLANNED_ADMISSION — modelled; event roles: coordinator, bed_manager, ward Reviewed command model: cancelled with cancel reason and time. Permission source: src/components/ward-management/ward-flow-event-roles.ts:615 - Event/dispatch reference: src/components/ward-management/capacity/planned-admissions-reducer.ts:464 + Event/dispatch reference: src/components/ward-management/capacity/planned-admissions-reducer.ts:468 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:2350 Subject: One booked planned admission Before: booked @@ -1755,13 +1755,13 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 effects: No capacity refund occurs because booking held no bed. Cancelled plans no longer count as pending arrivals. effects: The step can be taken back by UNDO_RELEASE_STEP within the Undo window, filed against the booking. tests: tests/ward-planned-admissions.test.ts - Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:459; SHA256 fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3 + Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:463; SHA256 f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603 Reviewed source: src/components/ward-management/ward-flow-events.ts:2488; SHA256 378044cce3b2c98c2c24f4e5bb6ab27f7c361f02a7fa8ab59fa36a77388c84bf Reviewed source: src/components/ward-management/ward-access-state.ts:659; SHA256 924e9e6ab48fddbf423e5cf988cc09b75df3ace0ce4f4465f379b143957e3d07 - CONVERT_PLANNED_ADMISSION — modelled; event roles: ward, coordinator Reviewed command model: Plan arrived and linked to a new occupied admission. Permission source: src/components/ward-management/ward-flow-event-roles.ts:616 - Event/dispatch reference: src/components/ward-management/capacity/planned-admissions-reducer.ts:488 + Event/dispatch reference: src/components/ward-management/capacity/planned-admissions-reducer.ts:492 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:743 Subject: A booked plan and its receiving ward Before: booked plan, no current linked bed/journey, ready eligible capacity @@ -1777,7 +1777,7 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 effects: Marks plan arrived with convertedAt/admissionId; does not create a numbered bed assignment or admit merely because its date passed. tests: tests/ward-planned-admissions.test.ts tests: tests/ward-planned-arrival-closed-bed.test.ts - Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:483; SHA256 fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3 + Reviewed source: src/components/ward-management/capacity/planned-admissions-reducer.ts:487; SHA256 f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603 Reviewed source: src/components/ward-management/ward-flow-events.ts:2516; SHA256 378044cce3b2c98c2c24f4e5bb6ab27f7c361f02a7fa8ab59fa36a77388c84bf Reviewed source: src/components/ward-management/ward-access-state.ts:659; SHA256 924e9e6ab48fddbf423e5cf988cc09b75df3ace0ce4f4465f379b143957e3d07 - RECORD_SUPPORT_NOTIFICATION — modelled; event roles: ward, coordinator diff --git a/docs/ward-flow/journey/event-dispositions.json b/docs/ward-flow/journey/event-dispositions.json index 4c76c503ae..20e16f4d87 100644 --- a/docs/ward-flow/journey/event-dispositions.json +++ b/docs/ward-flow/journey/event-dispositions.json @@ -2310,13 +2310,13 @@ "sources": [ { "path": "src/components/ward-management/capacity/planned-admissions-reducer.ts", - "line": 299, - "sha256": "fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3" + "line": 303, + "sha256": "f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603" }, { "path": "src/components/ward-management/capacity/planned-admissions-reducer.ts", - "line": 263, - "sha256": "fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3" + "line": 267, + "sha256": "f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603" }, { "path": "src/components/ward-management/ward-flow-events.ts", @@ -2361,13 +2361,13 @@ "sources": [ { "path": "src/components/ward-management/capacity/planned-admissions-reducer.ts", - "line": 383, - "sha256": "fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3" + "line": 387, + "sha256": "f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603" }, { "path": "src/components/ward-management/capacity/planned-admissions-reducer.ts", - "line": 263, - "sha256": "fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3" + "line": 267, + "sha256": "f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603" }, { "path": "src/components/ward-management/ward-flow-events.ts", @@ -2408,8 +2408,8 @@ "sources": [ { "path": "src/components/ward-management/capacity/planned-admissions-reducer.ts", - "line": 459, - "sha256": "fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3" + "line": 463, + "sha256": "f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603" }, { "path": "src/components/ward-management/ward-flow-events.ts", @@ -2450,8 +2450,8 @@ "sources": [ { "path": "src/components/ward-management/capacity/planned-admissions-reducer.ts", - "line": 483, - "sha256": "fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3" + "line": 487, + "sha256": "f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603" }, { "path": "src/components/ward-management/ward-flow-events.ts", diff --git a/docs/ward-flow/journey/machines.json b/docs/ward-flow/journey/machines.json index 2fcdf924c5..be4e0d839d 100644 --- a/docs/ward-flow/journey/machines.json +++ b/docs/ward-flow/journey/machines.json @@ -1738,8 +1738,8 @@ "sources": [ { "path": "src/components/ward-management/capacity/planned-admissions-reducer.ts", - "line": 584, - "sha256": "fcd8e3b7667a295dc58225efa80ca7dc36863ad203ca4b6873c535c7ae5a0eb3" + "line": 588, + "sha256": "f1c99b223cc7cbc847dcda57bd152e87ad32425c32f5a5a0db9ac7b66c4b1603" }, { "path": "src/components/ward-management/legal-forms/legal-forms-screen.tsx", diff --git a/docs/ward-flow/journey/ward-journey-explorer.html b/docs/ward-flow/journey/ward-journey-explorer.html index 70659ed84a..4e41bebb92 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