`, not the brief's ``.** A committed test has pinned
+ exactly one `` per route since Task 3 and the shell owns it; Task 5's clinician print
+ resolved the identical tension the same way. The printed document still carries a clear
+ accessible title, asserted by role and level. _Cost if wrong:_ none — consistent with the
+ other print surface.
+
+---
+
+## Deferred minors — for the whole-branch review to triage
+
+Recorded, not fixed. None blocks a later task.
+
+**Task 1**
+
+- `awaitingApproval` queue ordering is asserted on a single element, so its sort is unproven.
+- `CmhtContact.verifiedAt` is non-nullable, so "never verified" cannot be expressed, while `SYN-CMHT-003` is `unverified` with a date.
+- The telephone sweep cannot see a bracketed `(08) 5550 1234` landline.
+- `addIsoDays`/`addIsoMonths` are calendar helpers in a file declaring itself pure selectors.
+- A copied `Optional detail` doc comment sits on the required `cmhtContactAttempt`.
+- `it.each` emits four identically-named tests (brief-pinned, not the implementer's choice).
+- `fixtures.ts` is ~1,420 lines before Tasks 8 and 9 add more — Task 9's patient-plan fixtures should get their own module.
+- The count guard matches spelled numbers only, so a digit-form claim evades it.
+- `once|twice` must sit immediately adjacent to the verb, so "left before assessment twice" evades it.
+- Ruling 17's reword is now load-bearing rather than cosmetic: a legitimately-phrased future review trigger can trip a count guard that has nothing to say about it.
+- `BLAMING_TERM` includes "difficult"/"refuses"/"agitated", which have legitimate service-facing uses — fails closed, so the safe direction, but expect false positives.
+- `CONCRETE_FINDING` is curve-fit to the present fixtures.
+- `SYN-PRESENTATION-019` breaks identifier ordering in the array (cosmetic; consumers sort).
+
+**Task 2**
+
+- The reducer is a single ~945-line switch. Extract per-case functions before Task 9 adds six more actions.
+- `assertSingleCurrentVersion` throws with no React error boundary — **an input to Task 3**, which must mount one.
+- `nextSyntheticId` interpolates its prefix into a `RegExp`.
+- `reset` always returns the `normal` world, so a displayed specimen scenario silently persists.
+- The `AMENDABLE_FIELDS.includes` runtime check is unreachable under the typed action.
+- `save-management-draft` does not validate that `ownerId` names a clinical role, so the non-clinical `plan_coordinator` can be recorded as Plan Owner.
+
+**Task 3** — raised by the fix-round-1 reviewer, recorded rather than fixed.
+
+- The duplicated loading skeleton across `loading.tsx` and `route-page.tsx` carries inconsistent
+ accessible naming (`sr-only` paragraph in one, `aria-label` in the other).
+- The `src/components/care-plan/mockups/index.ts` barrel has zero importers.
+- The active user falls back to empty strings at `routable-suite.tsx` when no user matches
+ `activeUserId`, so a broken id renders a silently blank identity block rather than failing.
+- The phone dock selects its items by matching label strings against
+ `CARE_PLAN_PRIMARY_DESTINATIONS` rather than by an explicit list.
+- `aria-current="page"` is set on the More **button**, which is a disclosure control, not a
+ destination.
+- Two test names in `tests/proxy.test.ts` still say "the two developer-gated paths"; there are
+ now three.
+- The non-mockup-route guard in `tests/care-plan-route-files.test.ts` checks only four
+ hard-coded paths, so a Care Plan route added elsewhere under `src/app/**` would evade it.
+- `docs/codebase-index.md` has no Care Plan entry. `docs:check-index` passes without one, but
+ the repo's new-route checklist wants one before the branch is handed off.
+
+The hand-rolled `aria-live` region was **not** deferred: it was removed as part of Important #3,
+because it double-announced against the focus move that fix depends on.
+
+**Task 6** — recorded rather than fixed.
+
+- `management-plan-form.tsx` is 661 lines with nine pure helpers (`toDateValue`,
+ `toPerthTimestamp`, `textOf`, `linesFrom`, `valuesFrom`, `contentFrom`, `draftInputFrom`,
+ `bannedConstructionIn`, `validate`) reachable only through a rendered form — the same
+ argument that correctly earned `diffManagementPlanContent` its own export.
+- `.appRoot .roleSwitcher` sets a redundant `min-height`; `fieldControl` already applies
+ `h-tap` and `--spacing-tap` is `3rem`, so the target is 48 px either way.
+- The review route renders the Current Plan in full above the change table. That is correct
+ under read primacy, but it means scrolling past five sections on a phone; a jump link is
+ worth considering, not a reordering.
+- The blocked-reason pattern on the plan actions uses a visible `role="alert"` paragraph plus
+ `aria-describedby` rather than the generic `title="… — coming soon"` plus `sr-only` shape.
+ It matches the already-reviewed `shareBlockedReason` convention in the same file, so it is a
+ consistent extension rather than a new deviation — but the two patterns should be
+ reconciled before handoff.
+
+**Task 7** — recorded rather than fixed.
+
+- **The amendment-chain invariant's second half is correct but unexercised.** The guard asserts
+ both that the episode field equals the earliest amendment's `originalValue` _and_ that each
+ later amendment's `originalValue` equals the previous one's `replacementValue`. No fixture has
+ a field corrected twice, so every chain has length one and the second loop never runs. The
+ implementer's claim that restoring the old convention "takes down both halves at once" is
+ overstated: both kills land on the first half, checked from two files. The clean close is a
+ unit test with a constructed two-link chain — **not** bending a fixture to exercise a test.
+ Flagged deliberately for the whole-branch review, because untested guard code is how this
+ project got seven guards that could not fail.
+- The `[presentationId]` parameter-guard regex does not require the matched call's argument to
+ be literally named `presentationId`, unlike its `[patientId]` sibling, so a wrong-variable
+ mix-up would pass.
+- The invariant guard's fail-closed threshold is `chains.size > 1` rather than `> 0`. It still
+ fails on zero corrections, but its message would fire misleadingly if exactly one distinct
+ field ever carried a correction.
+- `linkedPlanLabel` returns `No Current Plan was available` for a non-null version id that
+ resolves to nothing, conflating "the version is unknown" with "there was none".
+- `isSyntheticPresentationForPatient` now has no production consumer — only the `index.ts`
+ re-export and its own test — while still reading as a live guard.
+- The reason-required refusal sentence is duplicated verbatim between `presentation-pages.tsx`
+ and `prototype-state.ts`; two copies in two modules will drift.
+- `presentation-pages.tsx` is 648 lines carrying two independent surfaces plus the amendment
+ sheet. Brief-mandated grouping, so noted rather than charged.
+- `effectivePresentation` re-filters the amendment array once for corrections and again per
+ field, up to seven passes per timeline entry; same family as `AmendableRow`'s double call.
+- **`portal={false}` on the amendment Sheet is Task 11's first look.** It is the one decision in
+ this task that changes where focus containment lives, and with `css: false` no committed test
+ can see its overlay, backdrop or focus trap.
+
+**Task 8** — recorded rather than fixed.
+
+- A failed print leaves **no trace at all** (Ruling 46). A later task should decide whether an
+ attempt-that-failed deserves its own event, as a contact action's `email_intent_opened`
+ records an attempt that is explicitly not evidence of delivery.
+- `emptySections="omit"` applies uniformly to all seven keys including
+ `professionalAndEmergencySupport`. That section is required unconditionally by the form and
+ non-empty in every fixture, so it cannot vanish today — but a future data path bypassing form
+ validation would silently drop those contact lines from the paper. Worth a defensive
+ carve-out.
+- The `Review currency unknown` branch is implemented correctly but unreachable from fixtures:
+ the only `reviewDueAt: null` version is a draft, and drafts never render the review mark.
+- `safety-plan-pages.tsx` combines the reading surface, the print surface and `CrisisContacts`
+ at 564 lines. Task 5 split its two surfaces; Task 7 combined. Precedent exists either way and
+ the shared `SafetyPlanSections` is a real reason to keep them together.
+- Support-row keys and field ids remain index-based (`support-${index}`); pre-existing pattern,
+ and the removal flow is test-covered.
+- `key={item}` on list items matches the established pattern rather than being this task's to
+ change.
+
+**Stage A checkpoint** — found by the controller's own run, not by any task review.
+
+- The Stage A test output is **not pristine**, and no task report mentioned it. Two warnings
+ appear on every run: a React `act` warning from
+ `keeps the typed search term across a navigation because the shell persists` ("A component
+ suspended inside an `act` scope, but the `act` call was not awaited"), and a jsdom
+ `Not implemented: navigation to another Document`. The repository's standing rule is that
+ warnings in test output are findings and output should be pristine; both are test hygiene
+ rather than product defects, but they are noise that could mask a real warning in Stage B.
+
+**Task 4** — raised by the task reviewer and the fix-round re-reviewer, recorded rather than fixed.
+
+- No one-searchbox assertion covers `/patients`, the other route where `routeOwnsSearch` is true.
+ Home and the shell-owned routes each have one; a regression restoring the shell composer on
+ `/patients` would be caught only incidentally.
+- The after-hours telephone anchor is untested, so a swap to the duty URI survives — and it
+ dispatches the same `call` intent as the duty control, so the recorded audit event cannot say
+ which number was launched.
+- The no-sort guard matches control _names_ (`/sort|rank|most|highest/i`, no combobox, no
+ columnheader). A control named "Busiest first" passes. It killed the plausible mutation, so this
+ is calibration rather than inertness, but asserting the rendered row order would be stronger.
+- `carePlanPatientIdFromPathname` is a pure path parser living in `routable-suite.tsx` rather than
+ beside `isSyntheticPatientId` in `routes.ts`, where every other address concern lives. Its
+ `pathname.split("?")[0] ?? pathname` fallback is unreachable — `split` always returns one element.
+- `ContentList` uses the bullet text as its React key, so two identical bullets in one section warn.
+- Two differently worded synthetic markers appear on one screen: the shell's `Synthetic prototype —
+fictional data only` and `SYNTHETIC_DATA_MARKER`'s `… fictional people, teams, and hospitals`.
+- `keeps identity and currency facts visible` asserts `/verified/i`, which also matches
+ `Not verified since …`, so it cannot tell a verified team from an unverified one.
+- Draft-below-Current is proven by document order only; a CSS `order` or `grid-row` change could
+ invert it visually with every assertion still green. Same family as the section-5 clipping hole
+ that fix round 1 closed, and a candidate for the same static-stylesheet treatment.
+- `lastOutcome` survives a cross-patient deep link. `select-patient` clears it, so the Home path is
+ clean, but `/patients/A` → record an intent → address-bar to `/patients/B` never dispatches
+ `select-patient`, and B's workspace shows A's notice. The message carries no identity, so the
+ exposure is nil; the cost is the honesty of the chronology.
+- The stylesheet guard's `declarationsOf` splits declaration bodies on a bare `;` and would
+ mis-split a value containing an embedded semicolon such as a data URI; its `chunk.split("{")`
+ destructure silently drops anything after a second `{` in one chunk. Neither is exercised today.
+- The new clipping guards are text analysis, not CSS semantics: a `clip-path`, a
+ `transform: scale(0)`, or a clipping rule on an ancestor would pass. The list covers what anyone
+ would plausibly write; it is a list, not a proof.
+
+---
+
+## Systemic lessons — carry into every later dispatch
+
+1. **Writing source through a shell layer corrupts it.** Three occurrences: twice `\b`
+ regex escapes became literal backspace bytes, once 1,240 newlines became CRLF, which
+ `.gitattributes` forbids. Write source with the editor tools, not Python or shell
+ heredocs, and scan every touched file for CR and control bytes before committing.
+2. **Guards that cannot fail.** Two shipped before being caught, both silently matching
+ nothing. Any test whose job is to reject something needs a positive control proving it
+ rejects a known-bad input, and should be proved by making the code wrongly permit the
+ thing and watching the test go red. Task 2 adopted this and ran 32 mutations against
+ 32 red suites — that is the standard.
+3. **Inaccurate reports cost a review round.** Task 1 reported a typecheck without its
+ output; Task 2 claimed coverage that did not exist and miscounted its own tests. The
+ report is the evidence a reviewer works from. Count from the run output, paste the
+ decisive lines, and never claim coverage not written.
+4. **Commit at the end of every task.** Three worktree destructions cost nothing
+ committed and everything uncommitted — and by Task 4 the same habit had also absorbed
+ run-coordinator refusals and three `529 Overloaded` provider outages mid-round.
+5. **A guard can be unable to fail because of where it sits, not what it asserts.** Two of
+ Task 4's guards were correct assertions that no mutation could kill. The section-5
+ clipping guard read CSS-module class _names_, which Vitest resolves from a proxy whether
+ or not the stylesheet defines a rule — proven by `styles.currentPlanCard`, a class that
+ existed nowhere and that nothing noticed. The focus guards asserted final state in a
+ component whose parent shell commits last and repairs whatever a descendant did. Both
+ needed a different _kind_ of assertion — static analysis of the stylesheet, and
+ `focusin` event ordering — not a stricter one. When a positive control survives, the
+ instinct to tighten the assertion is usually wrong; ask first whether the test can
+ observe the failure at all.
+6. **A refused run can be scored as a mutation kill, and nearly was.** Task 5's implementer
+ found `npm run typecheck` returning **exit 75** — `Database focused-test capacity is full`,
+ another worktree holding the lease — and realised its first mutation harness would have
+ counted that as the mutation being caught. Six controls had produced no `Tests` summary
+ line. It re-ran each individually with a retry loop and confirmed all six failed on real
+ named assertions, then made every reported run retry until it holds a lease. A mutation
+ "killed" by a refused run is a guard that cannot fail wearing a new costume, and on this
+ machine it will happen again: any run without a `Test Files` summary line is an
+ acquisition failure and must be retried, never scored.
+7. **Focus has no owner, and that is now an architectural debt.** Three focus defects in a
+ row shared one cause: the shell is an ancestor, its effect lands last, and every
+ descendant calls `focus()` independently. Nothing structurally prevents a fourth — the
+ convention is held by three tests rather than by an invariant. The whole-branch review
+ should consider a single focus owner that descendants request, rather than each surface
+ claiming focus for itself.
+8. **A generative assertion can never disagree with what it checks.** Task 8 found two of its
+ own controls surviving, and both were this shape: a test comparing rendered text against the
+ very constant the component renders from. "The declined label must not deny the plan exists"
+ asserted against `PATIENT_CONFIRMATION_LABEL.declined`, which passes for _any_ label —
+ including the defective one it was written to catch. The fix is a **literal** expectation:
+ spell the forbidden phrasing out as a regex and apply it across every label and explanation.
+ Structural presence-or-absence checks may read from the constant; content-quality checks
+ never may. Nine guards that could not fail have now shipped here — but the last three were
+ caught by implementers' own positive controls rather than by reviewers, which is the
+ direction of travel worth keeping.
+9. **Read the page as the person receiving it, not as the person who built it.** Task 8's worst
+ defect broke no rule, failed no gate, and was invisible to 365 passing tests: a printed sheet
+ handed to a patient reading `My reasons for living — Not recorded`. Every automated check
+ this project has is a check on structure. The one class of harm it cannot see is a page that
+ is technically correct and cruel to read. Before any patient-facing surface is called done,
+ somebody must read it straight through as its recipient.
diff --git a/docs/care-plan/session-handoff-2026-08-21.md b/docs/care-plan/session-handoff-2026-08-21.md
new file mode 100644
index 000000000..a127a27a1
--- /dev/null
+++ b/docs/care-plan/session-handoff-2026-08-21.md
@@ -0,0 +1,259 @@
+# Care Plan — session handoff, 21 August 2026
+
+Written at the end of the controlling Claude session that designed Care Plan and
+built Tasks 1 and 2. Read this, then `sdd-ledger.md`, then start.
+
+---
+
+## Where things stand in one paragraph
+
+Care Plan is a synthetic, memory-only, reset-on-refresh clinical prototype under
+`/mockups/care-plan`, built inside the psychiatry.tools repository and reachable on
+the live site for a signed-in administrator. It lets a clinician look up the
+approved management plan for someone who presents repeatedly to an emergency
+department in psychiatric crisis. The design is complete and user-approved. Of
+eleven build tasks, **Tasks 1 and 2 are complete, independently reviewed, and
+committed**; Task 3 has not started. The branch is pushed to
+`origin/claude/ed-care-plans-impl-7f44cd`, which is the authoritative copy.
+
+---
+
+## What exists and is verified
+
+| Layer | Files | State |
+| ------------------ | ----------------------------------------------------------------------------------------- | ------------------ |
+| Domain types | `src/components/care-plan/mockups/types.ts` (589 lines) | Complete, reviewed |
+| Synthetic fixtures | `src/components/care-plan/mockups/fixtures.ts` (1,420 lines) | Complete, reviewed |
+| Pure selectors | `src/components/care-plan/mockups/domain.ts` (397 lines) | Complete, reviewed |
+| Lifecycle reducer | `src/components/care-plan/mockups/prototype-state.ts` (1,458 lines) | Complete, reviewed |
+| React provider | `src/components/care-plan/mockups/prototype-provider.tsx` (40 lines) | Complete, reviewed |
+| Tests | `tests/care-plan-domain.test.ts` (913), `tests/care-plan-prototype-state.test.ts` (1,240) | 121 passing |
+
+**Evidence at last run:** 121/121 tests passing across both files, `npm run typecheck`
+exit 0 with zero diagnostics, `npm run lint` exit 0, Prettier clean. Thirty-two
+deliberate mutations each produced a red suite, proving no refusal guard passes
+vacuously. Two independent reviewers (fresh agents, spec + quality) signed off each
+task after a fix loop.
+
+**Not yet run for this feature, and must not be claimed:** any browser or Playwright
+journey, any accessibility or responsive check, any print check, any build, any
+provider-backed gate. There is no UI yet.
+
+**Integrity confirmed after the third worktree destruction:** `git ls-files --eol`
+reports `i/lf w/lf` on every Care Plan file, matching untouched repository files, and
+the committed blobs contain zero CR bytes. Nothing is corrupted. Re-running the suites
+in the relocated worktree gave `Test Files 2 passed (2) / Tests 121 passed (121)`.
+
+**Environment state in the relocated worktree — resolved, with one caveat.** The
+`npm ci --include=dev` there has since completed (exit 0) and Prettier now runs. While it
+was still in flight, `npm run typecheck` failed on three unrelated pre-existing files —
+`tests/universal-search.test.ts` (TS7006) and two `use-in-page-section-nav*` DOM tests
+(TS7016, unable to resolve `lucide-react` types) — with **zero errors in any
+`src/components/care-plan/**` or `tests/care-plan-*` file`. Typecheck exited 0 in the
+previous worktree at the same commit, so that was an install artefact rather than a
+regression. **Re-confirm typecheck yourself before reporting any gate as green**; it has
+not been re-run since the install finished.
+
+One standing caveat: this machine runs several AI sessions concurrently and the
+repository has a cross-worktree run coordinator. A gate can be refused with
+`DATABASE_HEAVY_RUN_ADMISSION_BUSY` because another worktree holds the lease — that is an
+_acquisition_ failure, not a test result. Any run whose output lacks a `Test Files`
+summary line must be retried, never reported.
+
+---
+
+## The environment problem — read this before you do anything
+
+The original worktree at `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd`
+was **destroyed three times on 21 August 2026**. The third destruction happened
+through an explicit `git worktree lock`, while a task was running: the `.git` pointer
+file went first, making git resolve to the main checkout on the wrong branch, then
+3,836 tracked files were deleted over three minutes.
+
+- **Relocation did NOT protect it.** The work was moved to `D:\Worktrees\Database\care-plan`
+ on the reasoning that that parent had been untouched all day — and it was destroyed
+ there too, within the hour, the same way: `.git` pointer first, then 1,301 files. **No
+ directory on this machine is safe.** The only thing that has ever protected this work is
+ committing and pushing.
+- **The branch is pushed** to `origin/claude/ed-care-plans-impl-7f44cd`. If a worktree dies
+ again, recover with `git worktree add claude/ed-care-plans-impl-7f44cd` — or clone
+ fresh from GitHub, which is now the authoritative copy.
+- **Nothing committed was ever lost.** Every recovery was one `git worktree add`.
+- **What died each time was uncommitted work and git-ignored scratch** — including,
+ the third time, the entire SDD workspace with its ledger, briefs, reports and
+ review packages. That is why the ledger is now a tracked file.
+- **Commit at the end of every task.** That single habit is why three destructions
+ cost nothing but time.
+
+**Cause — identified, and already fixed on `main`.** The worktree was running an
+old `scripts/guard-push.mjs`. That version linked a _borrowed_ worktree's real
+`node_modules` into a scratch checkout as a Windows junction, then force-deleted the
+scratch checkout recursively — which a `git worktree lock` cannot stop, because it is a
+filesystem delete rather than a git worktree operation. Any concurrent session pushing
+from a stale base ran it against whichever worktree it had borrowed from.
+
+This is **already fixed upstream** by two commits this branch does not yet contain:
+
+- `a04330ea0` — harden(guard-push): never force-delete a scratch checkout that still
+ holds a borrowed `node_modules` link (#2244)
+- `cdfcbaccd` — fix(worktrees): stop silent worktree wipes and misdirected commands (#2240)
+
+**This branch is 122 commits behind `origin/main` and has neither.** That is the whole
+explanation: the tooling in this worktree, and in the other stale worktrees running
+alongside it, predated its own fix. `scripts/clean-worktree.mjs` was investigated and
+cleared — it contains no filesystem deletion at all.
+
+**The remedy is to merge `origin/main` into this branch before doing anything else.**
+Until then this worktree runs the vulnerable guard, and so does every other session on a
+stale base.
+
+Worth investigating separately: there were 82 registered worktrees on this
+repository. That is itself worth a clean-up, carefully, once this build is done.
+
+---
+
+## The product, and the decisions behind it
+
+Thirteen decisions were taken with the user across a brainstorming pass and a
+grilling round. They are binding and must not be reopened.
+
+**Purpose and shape**
+
+1. Build the synthetic prototype now, but keep the domain shaped so real persistence
+ could be added later without a redesign — pure reducer, plain serialisable state,
+ caller-allocated IDs, one dispatch path. **No storage layer is built now.**
+2. The full multi-service workflow, including named senior-clinician approval.
+3. Deliver Tasks 1–5 (Stage A), **stop for user review**, then Tasks 6–11.
+4. Local commits authorised, and the branch has since been pushed with the user's
+ explicit agreement after being told the repository is public. Still **not**
+ authorised: PR, merge, rebase, deployment, or provider access.
+
+**Clinical content**
+
+5. The Management Plan is **eleven fields in two tiers**, not the nineteen originally
+ drafted. Four pairs were saying the same thing twice, and the two safety-critical
+ sections were below the fold. The first-minute summary is exactly five: how to
+ approach this person, what helps, what makes it worse, what we have agreed to do,
+ and what would make this presentation different.
+6. **Reading is the primary use.** The user's words: "the plan is for clinicians to
+ look up and see the management plan; it is rarely for changing or updating." Where
+ reading and authoring compete for space, navigation depth, attention or effort,
+ reading wins — including in build order, which is why Stage A ends with the whole
+ reading experience and no authoring surface at all.
+7. An ED Presentation requires only about thirty seconds: site, disposition, whether
+ the plan was available, used and helpful, and one required line — "in one line: why
+ they came and what happened". Everything richer sits behind a disclosure.
+8. The review clock is 12 months, editable per version, amber at 28 days.
+9. Identification Reviews close with a recorded decision — proceed to a plan, not
+ needed at this stage, or revisit later — plus a reason. Previously they could be
+ opened but never closed.
+10. `agreedEdApproach` names who agreed the position and when, reads as an agreed
+ default rather than a ceiling on care, and never uses a prohibitive construction.
+ `BANNED_ADMISSION_CONSTRUCTIONS` enforces this.
+11. `whatMakesItWorse` describes **what the service does** — corridors, repeated
+ history-taking, security presence, unexplained waits — never what the person does
+ wrong.
+12. Sort-by-presentation-count exists **only** inside the Identification Review
+ workflow, where finding frequent attenders is the stated purpose. Nowhere else.
+13. There is a **Patient Plan** (Task 9): a patient-facing edition produced by a
+ deterministic, offline, rule-based transformation of an approved Management Plan
+ Version. It maps eleven known fields to eight patient-voice headings through a
+ curated dictionary, **emits a visible gap wherever it cannot convert confidently
+ rather than guessing**, and **never auto-converts the agreed-ED-approach section**
+ under any circumstances. A clinician — any clinical role, not only a senior one —
+ must fill the gaps and approve before the patient receives it, and cannot approve
+ with a gap open. No language model, network call, or provider is involved.
+
+---
+
+## What to do next
+
+Task 3: the gated route family, literal navigation, and the responsive Clinical
+Snapshot shell. Twenty-one routes, all rendering a semantic `RoutePurposeSurface`
+placeholder that Tasks 4–10 replace one route at a time. It is structural, not
+product screens.
+
+Two inputs Task 3's brief does not carry:
+
+- **Mount a React error boundary inside the gate.** `assertSingleCurrentVersion`
+ throws from inside the reducer, deliberately, because a plan with two Current
+ versions is an invariant violation that must not render. From Task 3 onward that
+ would crash the tree with no boundary.
+- **Do not re-add the online/offline listener** (Ruling 20). It was removed with the
+ reasoning recorded in `prototype-provider.tsx`.
+
+---
+
+## The prompt to start the next session
+
+```text
+Resume the Care Plan build in D:\Worktrees\Database\care-plan on branch
+claude/ed-care-plans-impl-7f44cd. Do not create or use any worktree under
+D:\Repos\Database\.claude\worktrees\ — that location destroyed this work three
+times on 21 August 2026, once through an explicit git worktree lock.
+
+Read in this order:
+1. docs/care-plan/session-handoff-2026-08-21.md — where things stand, the thirteen
+ binding product decisions, and the environment hazard.
+2. docs/care-plan/sdd-ledger.md — progress, all 25 controller rulings with what each
+ costs if wrong, the deferred minors, and four systemic lessons.
+3. docs/superpowers/specs/2026-08-20-care-plan-design.md — binding product authority.
+4. docs/care-plan-context.md — binding glossary; its preferred terms are required and
+ its _Avoid_ terms are banned.
+5. The Global Constraints and Delivery Stages sections of
+ docs/superpowers/plans/2026-08-20-care-plan-implementation.md. Do not read the
+ whole plan.
+
+Tasks 1 and 2 are complete, reviewed and committed: 121 tests passing, typecheck and
+lint clean. Task 3 has not started and no partial work exists.
+
+Execute with superpowers:subagent-driven-development, resuming at Task 3. One
+implementer at a time, a fresh reviewer after each task, a fix loop, and a commit at
+the end of every task without exception — that habit is the only reason three worktree
+destructions cost nothing. Keep the ledger updated at docs/care-plan/sdd-ledger.md
+(tracked deliberately; the git-ignored SDD workspace was destroyed).
+
+Stop at the Stage A checkpoint after Task 5 and report to me. Do not start Task 6 on
+your own judgment.
+
+Before Task 3, confirm: git status clean, branch and HEAD as above, dependencies
+installed, and npm run test -- tests/care-plan-domain.test.ts
+tests/care-plan-prototype-state.test.ts still green. Paste the decisive output — an
+exit code alone is not evidence in this repo.
+
+Carry these into every dispatch: write source with editor tools rather than Python or
+shell heredocs and scan touched files for CR and control bytes (three files were
+corrupted that way); any test that rejects something needs a positive control proving
+it rejects a known-bad input, proved by making the code wrongly permit it and watching
+the test go red; and count test totals from the run output rather than from memory.
+
+The application stays synthetic, memory-only and provider-free. Local commits are
+authorised. Nothing else is — no push, PR, merge, rebase, deployment, migration, or
+provider access. Ask before any of those.
+```
+
+---
+
+## Authorisation boundary
+
+Authorised: local implementation and offline verification in
+`D:\Worktrees\Database\care-plan`; local commits on
+`claude/ed-care-plans-impl-7f44cd`; `npm run ensure` for a local dev server.
+
+**Not authorised:** push, pull, merge, rebase, PR, branch deletion, deployment,
+migration, hosted CI, live Supabase or OpenAI, `verify:release`, `check:supabase-project`,
+any `eval:*`, or any other provider-backed command. Ask first.
+
+---
+
+## Honest limits
+
+This is a clinical reference prototype, not validated clinical decision support and
+not a clinical tool. It holds no real patient information and cannot; state resets on
+refresh. Passing every local test and rendering every screen would still not make it
+suitable for use with real patients — that would require, at minimum, WA Health
+clinical governance approval, an approved identification policy, patient and consumer
+co-design, a privacy impact assessment, cultural-safety review, legal review, clinical
+content validation, identity matching, access control, immutable audit, and controlled
+deployment. The specification's "Production-readiness boundary" section lists the full
+set.
diff --git a/docs/care-plan/verification-log-2026-08-21.md b/docs/care-plan/verification-log-2026-08-21.md
new file mode 100644
index 000000000..e47262a36
--- /dev/null
+++ b/docs/care-plan/verification-log-2026-08-21.md
@@ -0,0 +1,240 @@
+# ED Care Plans — handover verification log
+
+## Scope
+
+This log records evidence for the design/planning/handover package only. It is not evidence that the ED Care Plans application exists or works; implementation has not started.
+
+**Worktree:** `D:\Worktrees\Database\ed-care-plans`
+**Branch:** `codex/ed-care-plans`
+**HEAD:** `eeea74a160c19553f94347dda5102b2dff2ed591`
+**Upstream:** `origin/main`
+**Upstream SHA at initial capture:** `2abbe0068560086497b0688970ec6ad6ba957aba`
+
+## Git/worktree inspection
+
+Command:
+
+```powershell
+git status --short --branch --untracked-files=all
+git rev-parse HEAD
+git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}'
+git rev-parse origin/main
+```
+
+Decisive output:
+
+```text
+## codex/ed-care-plans...origin/main [behind 2]
+?? docs/ed-care-plans-context.md
+?? docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md
+?? docs/superpowers/specs/2026-08-20-ed-care-plans-design.md
+eeea74a160c19553f94347dda5102b2dff2ed591
+origin/main
+2abbe0068560086497b0688970ec6ad6ba957aba
+```
+
+The handover documents were created after this snapshot and are intentionally additional untracked files. No Git branch movement was performed.
+
+## Shared checkout preservation
+
+Command:
+
+```powershell
+git status --short --branch --untracked-files=all
+git rev-parse HEAD
+```
+
+Working directory: `D:\Repos\Database`
+
+Decisive output:
+
+```text
+## gemini/safe-tooling-ui-layout-and-workflow-hardening...origin/gemini/safe-tooling-ui-layout-and-workflow-hardening [gone]
+ M docs/scripts-index.md
+ M docs/testing.md
+ M scripts/check-bundle-budget.mjs
+ M tests/bundle-budget.test.ts
+ad44f2b1466c3091ba0c0bbb36125d2631c5509a
+```
+
+These changes are unrelated and untouched.
+
+## Planning self-review inherited from the approved planning turn
+
+The planning pass recorded:
+
+```text
+PLAN_SELF_REVIEW=PASS tasks=9
+```
+
+Additional planning evidence:
+
+- Prettier check passed for the design, glossary, and implementation-plan documents.
+- Route inventory matched all 17 approved routes.
+- Placeholder/unfinished-marker scan returned no matches.
+- Glossary: 135 lines.
+- Approved design specification: 418 lines before the handover status-line update.
+- Implementation plan: 1,001 lines.
+- No product source, test, build, API, provider, or deployment action occurred.
+
+## Handover lifecycle routing
+
+Command:
+
+```powershell
+npm run workflow:lifecycle -- --phase handoff --write-evidence
+```
+
+Exit code: `0`
+
+Decisive output:
+
+```text
+Changed files: docs/ed-care-plans-context.md, docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md, docs/superpowers/specs/2026-08-20-ed-care-plans-design.md
+Risk classes: docsOnly
+
+Local/offline checks:
+- npm run verify:pr-local — Complete the local handoff gate.
+```
+
+Written workflow evidence:
+
+`D:\Worktrees\Database\ed-care-plans\.local\workflow-evidence\2026-08-20T16-20-39-080Z-lifecycle.json`
+
+The `.local` evidence is intentionally ignored/machine-local and was not added to Git.
+
+## Native conversation capture
+
+The native Codex task-history reader was paginated until `hasMore: false` for task:
+
+```text
+Title: Management plan
+Task ID: 01a01fb2-575f-7c11-a245-332db7a85a25
+```
+
+The portable output is [`conversation-transcript-2026-08-21.md`](./conversation-transcript-2026-08-21.md). It contains every returned user message and assistant commentary/final message, in chronological order. Private reasoning, hidden instructions, and tool payloads were intentionally not included.
+
+Transcript structure check:
+
+```text
+TURNS=11
+USERS=11
+COMMENTARY=21
+FINALS=10
+```
+
+The active handover turn has no final message inside the capture, which accounts for one fewer final than user turn.
+
+## Visual artifact inspection
+
+Files retained:
+
+```text
+content\ed-care-plans-directions.html
+content\waiting-after-direction.html
+state\server-info
+state\server-instance-id
+state\server.pid
+```
+
+The recorded server port was `65531`. Process `17586` was no longer running when checked, so the former localhost URL is correctly labelled historical in the handover. The HTML source remains available.
+
+## Final documentation checks
+
+### Formatting correction
+
+The first complete Prettier check found formatting issues in the three newly generated long-form handover files:
+
+```text
+[warn] docs/ed-care-plans/claude-build-handover-2026-08-21.md
+[warn] docs/ed-care-plans/conversation-transcript-2026-08-21.md
+[warn] docs/ed-care-plans/verification-log-2026-08-21.md
+```
+
+They were formatted with `npx prettier --write`. The first `verify:pr-local` attempt then reported the transcript alone as still nonconforming and stopped at `format:changed`:
+
+```text
+PR-local verification summary:
+- completed: check:runtime, check:installed-lock-parity
+- failed: format:changed (exit 1)
+- not reached: sitemap:check, docs:check-index, docs:check-inventory, docs:check-scripts, docs:check-links, check:branch-review-ledger, check:outstanding-issues, check:ledger-write-discipline
+```
+
+The transcript was passed through the repository formatter again and immediately checked. Focused rerun:
+
+```powershell
+npm run format:changed
+```
+
+Exit code: `0`
+
+Decisive output:
+
+```text
+Checking formatting...
+All matched files use Prettier code style!
+```
+
+### Risk-routed handoff gate
+
+Dry-run command:
+
+```powershell
+npm run verify:pr-local -- --dry-run --files docs/ed-care-plans-context.md,docs/ed-care-plans/CLAUDE-START-HERE.md,docs/ed-care-plans/claude-build-handover-2026-08-21.md,docs/ed-care-plans/conversation-transcript-2026-08-21.md,docs/ed-care-plans/verification-log-2026-08-21.md,docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md,docs/superpowers/specs/2026-08-20-ed-care-plans-design.md
+```
+
+The dry run selected runtime, installed-lock parity, changed-file formatting, sitemap, documentation index/inventory/script/link checks, and ledger/outstanding-issue discipline. It explicitly skipped lint, typecheck, the full unit suite, RAG fixtures, build, and offline RAG checks for the recognised low-risk documentation scope.
+
+Actual rerun used the same command without `--dry-run`.
+
+Exit code: `0`
+
+Decisive output:
+
+```text
+PR-local verification summary:
+- completed: check:runtime, check:installed-lock-parity, format:changed, sitemap:check, docs:check-index, docs:check-inventory, docs:check-scripts, docs:check-links, check:branch-review-ledger, check:outstanding-issues, check:ledger-write-discipline
+- failed: (none)
+- not reached: (none)
+
+Skipping build: no build-affecting source, config, package, or container changes detected.
+```
+
+Selected supporting proof:
+
+```text
+[Runtime Check] PASS: Node runtime 24.19.0 matches required Node 24.x.
+[Runtime Check] PASS: npm runtime 11.17.0 matches required npm 11.x.
+docs link check passed: 2059 repo path references resolve.
+Ledger write discipline passed for eeea74a160c1..HEAD.
+```
+
+The final ledger write-discipline step was slow because it read the repository's applied immutable ledger records with read-only Git operations while another worktree was also verifying. It completed successfully; no lock or process was bypassed.
+
+### Final worktree snapshot
+
+Captured at `2026-08-21T00:51:29.9378517+08:00`:
+
+```text
+## codex/ed-care-plans...origin/main [behind 4]
+HEAD=eeea74a160c19553f94347dda5102b2dff2ed591
+ORIGIN_MAIN=1cc0d298774e4dc2ec8dd04d03ecf4fe789d5564
+BEHIND=4
+```
+
+All seven ED Care Plans files were untracked. No product source, test, configuration, lockfile, or generated documentation file was modified. No branch movement, commit, push, PR, or deployment occurred.
+
+## Explicitly not run
+
+- Product unit/DOM tests — no product implementation exists.
+- Typecheck — no product implementation exists; docs-only handover gate is the proportionate check.
+- Lint — no product implementation exists; docs-only handover gate is the proportionate check.
+- Build — no product implementation exists.
+- Browser/Playwright — no ED Care Plans route exists.
+- Accessibility/browser matrix — future Task 9 work.
+- Production-readiness/provider checks — no provider access is authorized and the prototype is intentionally synthetic.
+- Hosted CI, API calls, deployment, migration, push, or PR — not authorized.
+
+## Completion boundary
+
+The documentation handover gate is complete. Application completion remains false until all nine implementation tasks and their proportionate verification are finished.
diff --git a/src/components/care-plan/mockups/care-plan.module.css b/src/components/care-plan/mockups/care-plan.module.css
index 3269c33b0..5cd33a7d1 100644
--- a/src/components/care-plan/mockups/care-plan.module.css
+++ b/src/components/care-plan/mockups/care-plan.module.css
@@ -1383,3 +1383,231 @@
border-color: CanvasText;
}
}
+
+/*
+ * The Patient Plan — the person's own edition of the Management Plan.
+ *
+ * It shares the crisis-contact treatment above deliberately: the four public
+ * numbers look and print identically wherever they appear, so a reader who has
+ * both sheets does not have to work out whether they are the same numbers.
+ *
+ * Nothing here may collapse, clip, truncate, or hide a section. This is the
+ * document that leaves the building, and the static stylesheet guard in
+ * `tests/care-plan-route-files.test.ts` is where that is actually visible —
+ * Vitest runs with `css: false`, so no DOM assertion can see a print rule at
+ * all.
+ */
+
+.appRoot .patientPlanSections {
+ display: grid;
+ gap: 1rem;
+}
+
+.appRoot .patientPlanSection {
+ display: grid;
+ gap: 0.375rem;
+ border-inline-start: 3px solid var(--border);
+ padding-inline-start: 0.75rem;
+}
+
+.appRoot .patientPlanSectionHeading {
+ color: var(--text);
+ font-size: 1rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+/* The fixed patient-voice sentence under each heading. It is part of the
+ * document rather than a hint about it, so it prints. */
+.appRoot .patientPlanLeadIn {
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.55;
+}
+
+/* A gap: a section the conversion refused to guess at. Shown only on the
+ * working copy — an approved version cannot carry one. */
+.appRoot .patientPlanGap {
+ display: grid;
+ gap: 0.375rem;
+ justify-items: start;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-lg);
+ padding: 0.625rem;
+}
+
+.appRoot .patientPlanGapReason {
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.55;
+}
+
+.appRoot .patientPlanStale {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 0.75rem;
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.6;
+}
+
+.appRoot .patientPlanDraftNotice {
+ display: grid;
+ gap: 0.5rem;
+ justify-items: start;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-lg);
+ padding: 0.75rem;
+}
+
+.appRoot .patientPlanResources {
+ display: grid;
+ gap: 1rem;
+}
+
+.appRoot .patientPlanResourceGroup {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.appRoot .patientPlanResource {
+ display: grid;
+ gap: 0.25rem;
+ border-block-start: 1px solid var(--border);
+ padding-block-start: 0.5rem;
+}
+
+.appRoot .patientPlanResourceName {
+ color: var(--text);
+ font-size: 0.9375rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.appRoot .patientPlanResourceDetail {
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
+.appRoot .patientPlanFormSection {
+ display: grid;
+ gap: 0.5rem;
+ padding-block-end: 1rem;
+}
+
+/* Save, approve, and the way back. Wraps rather than shrinking, so a phone at
+ * 320px keeps every control at its full tap target. */
+.appRoot .formActions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ margin-block-start: 1rem;
+}
+
+.appRoot .patientPlanResourceChoices {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.appRoot .patientPlanResourceChoice {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding-block-end: 0.5rem;
+}
+
+.appRoot .patientPlanResourceChoice .patientPlanResourceDetail {
+ padding-inline: 0.75rem;
+}
+
+/* A whole-row target: the label is the control, so the tap area is the card
+ * rather than the checkbox alone. */
+.appRoot .patientPlanResourceLabel {
+ display: flex;
+ align-items: start;
+ gap: 0.625rem;
+ min-height: 3rem;
+ padding: 0.625rem 0.75rem;
+ cursor: pointer;
+}
+
+.appRoot .patientPlanPaper {
+ display: grid;
+ gap: 1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xl);
+ background: var(--surface-raised);
+ padding: var(--pad-panel);
+}
+
+.appRoot .patientPlanPaperHead {
+ display: grid;
+ gap: 0.5rem;
+ border-block-end: 1px solid var(--border);
+ padding-block-end: 0.75rem;
+}
+
+.appRoot .patientPlanPaperTitle {
+ color: var(--text);
+ font-size: 1.375rem;
+ font-weight: 700;
+ line-height: 1.25;
+}
+
+.appRoot .patientPlanPaperIntro {
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.6;
+}
+
+@media print {
+ /*
+ * Large readable type, for the same reason the Personal Safety Plan uses it:
+ * this is read by the person it belongs to, on paper, often at the worst
+ * moment of their week. `pt` deliberately — a sheet has no viewport.
+ */
+ .appRoot .patientPlanPaper {
+ gap: 6mm;
+ font-size: 12pt;
+ line-height: 1.6;
+ }
+
+ .appRoot .patientPlanPaperTitle {
+ font-size: 20pt;
+ }
+
+ .appRoot .patientPlanSectionHeading {
+ font-size: 14pt;
+ }
+
+ /* Nothing a reader needs is split across a page break. */
+ .appRoot .patientPlanSection,
+ .appRoot .patientPlanResourceGroup,
+ .appRoot .patientPlanResource {
+ break-inside: avoid;
+ border-color: CanvasText;
+ background: none;
+ max-height: none;
+ overflow: visible;
+ }
+
+ .appRoot .patientPlanPaper,
+ .appRoot .patientPlanPaperHead {
+ border-color: CanvasText;
+ background: none;
+ }
+}
+
+@media (forced-colors: active) {
+ .appRoot .patientPlanPaper,
+ .appRoot .patientPlanSection,
+ .appRoot .patientPlanGap,
+ .appRoot .patientPlanStale,
+ .appRoot .patientPlanDraftNotice,
+ .appRoot .patientPlanResource,
+ .appRoot .patientPlanResourceChoice {
+ border-color: CanvasText;
+ }
+}
diff --git a/src/components/care-plan/mockups/clinical-snapshot-page.tsx b/src/components/care-plan/mockups/clinical-snapshot-page.tsx
index 76a034e85..c74d6a96d 100644
--- a/src/components/care-plan/mockups/clinical-snapshot-page.tsx
+++ b/src/components/care-plan/mockups/clinical-snapshot-page.tsx
@@ -10,6 +10,7 @@ import { PROTOTYPE_NOW } from "./fixtures";
import { PatientDirectory } from "./patient-directory";
import { PatientWorkspace } from "./patient-workspace";
import { useCarePlanPrototype } from "./prototype-provider";
+import { getPrototypeMutationBlockReason } from "./prototype-state";
import { CARE_PLAN_ROUTES } from "./routes";
import type { CmhtContact, Patient, PatientSnapshot, PrototypeScenario, SyntheticId } from "./types";
@@ -58,6 +59,16 @@ export function ClinicalSnapshotSurface({
dispatch({ type: "record-contact-intent", patientId: snapshot.patient.id, cmhtId: contact.id, channel });
}
+ const contactActionBlockedReason =
+ snapshot === null || snapshot.cmht === null
+ ? null
+ : getPrototypeMutationBlockReason(state, {
+ type: "record-contact-intent",
+ patientId: snapshot.patient.id,
+ cmhtId: snapshot.cmht.id,
+ channel: "email",
+ });
+
const workspaceRef = useRef(null);
const lastVariant = useRef(null);
@@ -117,6 +128,7 @@ export function ClinicalSnapshotSurface({
activeSection={variant === "patient" ? "overview" : null}
reviewsHref={CARE_PLAN_ROUTES.reviews}
onRecordContactIntent={recordContactIntent}
+ contactActionBlockedReason={contactActionBlockedReason}
showFullRecordLink={variant !== "patient"}
/>
);
diff --git a/src/components/care-plan/mockups/contact-actions.tsx b/src/components/care-plan/mockups/contact-actions.tsx
index ea4a37bee..c428c182e 100644
--- a/src/components/care-plan/mockups/contact-actions.tsx
+++ b/src/components/care-plan/mockups/contact-actions.tsx
@@ -29,16 +29,27 @@ export function ContactActions({
scenario,
reviewsHref,
onIntent,
+ blockedReason,
}: {
contact: CmhtContact;
scenario: PrototypeScenario;
reviewsHref: string;
onIntent: (channel: ContactChannel) => void;
+ /** The reducer's current refusal, if an attempted contact action cannot be recorded. */
+ blockedReason: string | null;
}) {
const [failedChannel, setFailedChannel] = useState(null);
function handleLaunch(channel: ContactChannel) {
return (event: MouseEvent) => {
+ // The URI and audit event are one action. If the reducer would refuse the
+ // audit event, do not open an external application and create an
+ // unrecorded contact attempt.
+ if (blockedReason !== null) {
+ event.preventDefault();
+ setFailedChannel(null);
+ return;
+ }
// The launch-failure specimen: the external application never opens, so
// the anchor's default navigation is stopped and the failure is stated.
// The details stay exactly where they were — withholding them would send
@@ -89,17 +100,41 @@ export function ContactActions({
+ {blockedReason === null ? null : (
+
+ {blockedReason}
+
+ )}
+
These controls open an application on this device. This prototype transmits nothing and holds no evidence of
delivery, readership, or reply.
diff --git a/src/components/care-plan/mockups/domain.ts b/src/components/care-plan/mockups/domain.ts
index afa3580f2..76c0a818e 100644
--- a/src/components/care-plan/mockups/domain.ts
+++ b/src/components/care-plan/mockups/domain.ts
@@ -17,6 +17,7 @@ import {
type PresentationAmendment,
type ManagementPlanVersion,
type Patient,
+ type PatientPlanVersion,
type PatientSnapshot,
type PatientSnapshotSource,
type PersonalSafetyPlanVersion,
@@ -249,6 +250,60 @@ export function getOpenSafetyPlanDraft(
return open.reduce((latest, version) => (version.version > latest.version ? version : latest));
}
+export function getCurrentPatientPlanVersion(
+ versions: readonly PatientPlanVersion[],
+ planId: SyntheticId,
+): PatientPlanVersion | null {
+ return versions.find((version) => version.planId === planId && version.state === "current") ?? null;
+}
+
+/** The patient edition being worked on. As with the Personal Safety Plan there
+ * is no `awaiting_approval` state: a patient copy waits on any clinical role,
+ * not on a senior decision. */
+export function getOpenPatientPlanDraft(
+ versions: readonly PatientPlanVersion[],
+ planId: SyntheticId,
+): PatientPlanVersion | null {
+ const open = versions.filter((version) => version.planId === planId && version.state === "draft");
+ if (open.length === 0) return null;
+ return open.reduce((latest, version) => (version.version > latest.version ? version : latest));
+}
+
+/**
+ * Whether a patient copy still describes the Management Plan in use.
+ *
+ * Always derived from the two identifiers, never stored. A stored flag would
+ * have to be written by whatever changed the Management Plan, and the one thing
+ * certain about a document already printed and carried away is that nothing in
+ * this application can reach it — so the flag would be the part most likely to
+ * be wrong, on the question that most needs to be right.
+ *
+ * A stale copy stays fully readable and is never regenerated, hidden, or
+ * withdrawn on the person's behalf. They may be holding the paper; the
+ * application's account of what they were given has to stay true to it.
+ *
+ * A `null` Management Plan current-version identifier means the Management
+ * Plan was withdrawn with nothing approved to replace it — not that nothing
+ * ever compares. A current Patient Plan version can only exist if a
+ * Management Plan was once approved for it to be derived from, so a source
+ * that is now current-less is exactly the "moved on underneath the copy"
+ * case this function exists to catch, and is stale by the same reasoning as
+ * a source that moved on to a different version.
+ */
+export function isPatientPlanVersionStale(
+ version: PatientPlanVersion | null,
+ managementPlanCurrentVersionId: SyntheticId | null,
+): boolean {
+ if (version === null || version.state !== "current") return false;
+ // A withdrawn source is not a safe exception. The patient copy still exists
+ // and may already be on paper, but there is no Current Management Plan for it
+ // to represent, so it has to carry the same needs-updating state as a copy
+ // made from an older Current version. `derivedFromManagementVersionId` is
+ // never `null` on a real version, so this comparison alone already covers
+ // it without a separate early return.
+ return version.derivedFromManagementVersionId !== managementPlanCurrentVersionId;
+}
+
/** The most recent withdrawn version, used only to keep a withdrawn plan from
* rendering identically to a patient who never had one. */
function getWithdrawnManagementVersion(
diff --git a/src/components/care-plan/mockups/management-plan-read.tsx b/src/components/care-plan/mockups/management-plan-read.tsx
index 32c366a0f..7e208e29f 100644
--- a/src/components/care-plan/mockups/management-plan-read.tsx
+++ b/src/components/care-plan/mockups/management-plan-read.tsx
@@ -568,6 +568,12 @@ export function ManagementPlanSurface({
scenario={scenario}
reviewsHref={CARE_PLAN_ROUTES.reviews}
onIntent={(channel) => recordContactIntent(cmht, channel)}
+ blockedReason={getPrototypeMutationBlockReason(state, {
+ type: "record-contact-intent",
+ patientId: patient.id,
+ cmhtId: cmht.id,
+ channel: "email",
+ })}
/>
)}
diff --git a/src/components/care-plan/mockups/patient-plan-fixtures.ts b/src/components/care-plan/mockups/patient-plan-fixtures.ts
new file mode 100644
index 000000000..f9eebeddc
--- /dev/null
+++ b/src/components/care-plan/mockups/patient-plan-fixtures.ts
@@ -0,0 +1,450 @@
+import { publicCrisisContacts } from "./fixtures";
+import type { PatientResource, PatientResourceCategory, SyntheticId } from "./types";
+
+/**
+ * Care Plan — the resources offered on a person's own copy of their plan.
+ *
+ * These live in their own module rather than in `fixtures.ts`, which is already
+ * long enough that a reader looking for one patient's plan content has to scroll
+ * past everything else. Nothing here reads a clock, a network, storage, or a
+ * random source.
+ *
+ * **Everything in this file is invented except the crisis lines.** The synthetic
+ * services, their telephone numbers, and their web addresses are fictional and
+ * `SYN-` identified. The `crisis_contact` entries are built from
+ * `publicCrisisContacts` — the four verified public numbers — rather than being
+ * typed out again here, because a resource sheet that leaves the building with a
+ * mistyped crisis number is the one error on it that could matter at 3am. There
+ * is one source of truth for those four, and this is not it.
+ *
+ * Housing and financial entries are present, and deliberately so. They are
+ * frequently the actual reason somebody keeps arriving at an emergency
+ * department, and a resource list that cannot mention rent, a bond, or a
+ * Centrelink payment is a list about the wrong problem. Alex has nowhere settled
+ * to go and Jordan is behind on rent; both are on their own plans in plain words,
+ * without either being described as a failing of theirs.
+ */
+
+/** How each category is headed on the person's own copy, in their voice. */
+export const PATIENT_RESOURCE_CATEGORY_LABEL: Record = {
+ care_team: "Your care team",
+ local_service: "Services near you",
+ housing: "Somewhere to live",
+ financial: "Money and bills",
+ transport: "Getting there",
+ carer_support: "For the people who support you",
+ alcohol_and_other_drugs: "Alcohol and other drugs",
+ cultural_or_peer: "Cultural and peer support",
+ crisis_contact: "If you need help now",
+ self_help_reading: "Things to read",
+};
+
+/**
+ * The order the categories are printed in. The person's own team first, because
+ * that is who they will actually ring; the crisis lines last, because that is
+ * where a reader looks when everything above has not worked and it should be the
+ * easiest block on the page to find by flipping to the end.
+ */
+export const PATIENT_RESOURCE_CATEGORY_ORDER = [
+ "care_team",
+ "local_service",
+ "housing",
+ "financial",
+ "transport",
+ "carer_support",
+ "alcohol_and_other_drugs",
+ "cultural_or_peer",
+ "self_help_reading",
+ "crisis_contact",
+] as const satisfies readonly PatientResourceCategory[];
+
+/**
+ * The verified public crisis lines, as resources on one person's copy. Built
+ * from the fixture rather than transcribed: the number, the hours, the stated
+ * limitation, and the page each was checked against all travel together, so a
+ * correction in one place reaches every printed sheet.
+ */
+function crisisResourcesFor(patientId: SyntheticId): PatientResource[] {
+ return publicCrisisContacts.map((contact, index) => ({
+ id: `SYN-RESOURCE-CRISIS-${patientId.slice("SYN-PATIENT-".length)}-${String(index + 1).padStart(3, "0")}`,
+ patientId,
+ category: "crisis_contact",
+ name: `${contact.name} — ${contact.telephoneDisplay}`,
+ detail:
+ contact.caveat === null
+ ? `${contact.coverage}. ${contact.availability}.`
+ : `${contact.coverage}. ${contact.availability}. ${contact.caveat}`,
+ contact: contact.telephoneDisplay,
+ sourceUrl: contact.sourceUrl,
+ isRealContact: true,
+ })) satisfies PatientResource[];
+}
+
+/**
+ * The invented services. Written to be read by the person they are for: what the
+ * service does, what it will actually help with, and what to say when ringing.
+ * None of them describes the person as a problem, and none of them makes an offer
+ * conditional on the person doing something first.
+ */
+const INVENTED_RESOURCES = [
+ // --- Rowan (SYN-PATIENT-001) ------------------------------------------------
+ {
+ id: "SYN-RESOURCE-001",
+ patientId: "SYN-PATIENT-001",
+ category: "care_team",
+ name: "North River community mental health team",
+ detail:
+ "Your team during the week. Ask for the duty worker if Sam is not in — you do not need an appointment to ring.",
+ contact: "0491 570 101",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-002",
+ patientId: "SYN-PATIENT-001",
+ category: "local_service",
+ name: "North River after-hours drop-in",
+ detail: "A quiet room and someone to talk to, evenings and weekends. You can turn up without ringing first.",
+ contact: "0491 570 130",
+ sourceUrl: "https://example.org/north-river-drop-in",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-003",
+ patientId: "SYN-PATIENT-001",
+ category: "financial",
+ name: "North River financial counselling",
+ detail:
+ "Free help with bills, debts, and Centrelink payments. They can ring a company on your behalf and ask for more time to pay.",
+ contact: "0491 570 140",
+ sourceUrl: "https://example.org/north-river-money-help",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-004",
+ patientId: "SYN-PATIENT-001",
+ category: "transport",
+ name: "Hospital travel help",
+ detail: "Help with the cost of getting to appointments, and a taxi voucher home if you come in at night.",
+ contact: "0491 570 150",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-005",
+ patientId: "SYN-PATIENT-001",
+ category: "cultural_or_peer",
+ name: "North River peer support",
+ detail:
+ "People who have used these services themselves, and will sit with you in the emergency department if you would like that.",
+ contact: "0491 570 160",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-006",
+ patientId: "SYN-PATIENT-001",
+ category: "carer_support",
+ name: "Family and carer line",
+ detail: "For Jess, or anyone else who supports you, to get advice and support of their own.",
+ contact: "0491 570 170",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-007",
+ patientId: "SYN-PATIENT-001",
+ category: "self_help_reading",
+ name: "Sleep and settling — a short guide",
+ detail: "Six pages on getting to sleep when your head will not switch off. Written to be read a bit at a time.",
+ contact: null,
+ sourceUrl: "https://example.org/north-river-sleep-guide",
+ isRealContact: false,
+ },
+
+ // --- Mira (SYN-PATIENT-002) -------------------------------------------------
+ {
+ id: "SYN-RESOURCE-008",
+ patientId: "SYN-PATIENT-002",
+ category: "care_team",
+ name: "Coastal Plains older adult community mental health team",
+ detail:
+ "Your team during the week. Devon is your care coordinator, and the duty worker can help when Devon is out.",
+ contact: "0491 570 111",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-009",
+ patientId: "SYN-PATIENT-002",
+ category: "local_service",
+ name: "Coastal Plains pain and mobility clinic",
+ detail: "For your back. They can see you at home if getting to the clinic is too much on the day.",
+ contact: "0491 570 180",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-010",
+ patientId: "SYN-PATIENT-002",
+ category: "transport",
+ name: "Community bus to appointments",
+ detail: "A door-to-door bus. Ring the day before, and tell them if you need help getting down the front step.",
+ contact: "0491 570 190",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-011",
+ patientId: "SYN-PATIENT-002",
+ category: "carer_support",
+ name: "Carer support for Daniel",
+ detail: "Advice, a break, and someone to talk to for Daniel, so that supporting you does not fall on him alone.",
+ contact: "0491 570 200",
+ sourceUrl: "https://example.org/coastal-plains-carers",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-012",
+ patientId: "SYN-PATIENT-002",
+ category: "self_help_reading",
+ name: "Living with long-term pain — large print",
+ detail: "A large-print booklet about pacing a day so pain takes less of it. Ask Devon for a paper copy.",
+ contact: null,
+ sourceUrl: "https://example.org/coastal-plains-pain-booklet",
+ isRealContact: false,
+ },
+
+ // --- Jordan (SYN-PATIENT-003) -----------------------------------------------
+ {
+ id: "SYN-RESOURCE-013",
+ patientId: "SYN-PATIENT-003",
+ category: "care_team",
+ name: "Wandoo District community mental health team",
+ detail: "Your team during the week. There is no named care coordinator yet, so ask for the duty worker.",
+ contact: "0491 570 121",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-014",
+ patientId: "SYN-PATIENT-003",
+ category: "housing",
+ name: "Wandoo District tenancy support",
+ detail:
+ "Free help if you are behind on rent or have had a notice from your landlord. Ring them early rather than late — there is much more they can do before a hearing date.",
+ contact: "0491 570 210",
+ sourceUrl: "https://example.org/wandoo-tenancy-support",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-015",
+ patientId: "SYN-PATIENT-003",
+ category: "financial",
+ name: "Wandoo District emergency relief",
+ detail: "Food, fuel, and a hand with an overdue bill. No appointment, and you do not have to explain yourself.",
+ contact: "0491 570 220",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-016",
+ patientId: "SYN-PATIENT-003",
+ category: "alcohol_and_other_drugs",
+ name: "Wandoo District alcohol and other drug service",
+ detail:
+ "You can ring them whether or not you want to change anything yet. They will talk about staying safer either way.",
+ contact: "0491 570 230",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-017",
+ patientId: "SYN-PATIENT-003",
+ category: "transport",
+ name: "Country patient travel scheme",
+ detail: "Help with fuel and a bed for the night when an appointment means a long drive.",
+ contact: "0491 570 240",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-018",
+ patientId: "SYN-PATIENT-003",
+ category: "self_help_reading",
+ name: "A hard week — what helps",
+ detail: "Four pages of things other people have found useful. Nothing in it costs anything.",
+ contact: null,
+ sourceUrl: "https://example.org/wandoo-hard-week",
+ isRealContact: false,
+ },
+
+ // --- Evelyn (SYN-PATIENT-004) -----------------------------------------------
+ {
+ id: "SYN-RESOURCE-019",
+ patientId: "SYN-PATIENT-004",
+ category: "care_team",
+ name: "North River community mental health team",
+ detail: "Your team during the week. Ask for the duty worker — you do not need an appointment to ring.",
+ contact: "0491 570 101",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-020",
+ patientId: "SYN-PATIENT-004",
+ category: "local_service",
+ name: "North River women's health service",
+ detail: "A women-only service, with a private room and a female clinician. You can ask for either when you ring.",
+ contact: "0491 570 250",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-021",
+ patientId: "SYN-PATIENT-004",
+ category: "housing",
+ name: "North River housing and safety support",
+ detail:
+ "Somewhere to stay, and help sorting out a longer-term place. They can talk through your options without you having to decide anything on the call.",
+ contact: "0491 570 260",
+ sourceUrl: "https://example.org/north-river-housing",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-022",
+ patientId: "SYN-PATIENT-004",
+ category: "cultural_or_peer",
+ name: "North River women's peer group",
+ detail: "A small group that meets on Thursdays. You can come once and see what you think.",
+ contact: null,
+ sourceUrl: "https://example.org/north-river-womens-group",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-023",
+ patientId: "SYN-PATIENT-004",
+ category: "self_help_reading",
+ name: "Getting privacy in a busy place",
+ detail: "A short guide to asking for a private room, and what to say if the first answer is no.",
+ contact: null,
+ sourceUrl: "https://example.org/north-river-privacy-guide",
+ isRealContact: false,
+ },
+
+ // --- Alex (SYN-PATIENT-005) -------------------------------------------------
+ {
+ id: "SYN-RESOURCE-024",
+ patientId: "SYN-PATIENT-005",
+ category: "care_team",
+ name: "North River community mental health team",
+ detail: "Your team during the week. You have met them once; you can ring them whether or not you come back in.",
+ contact: "0491 570 101",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-025",
+ patientId: "SYN-PATIENT-005",
+ category: "housing",
+ name: "North River youth housing service",
+ detail:
+ "A bed tonight, and help finding somewhere longer term. Ring any time — they answer at night, which is when it usually matters.",
+ contact: "0491 570 270",
+ sourceUrl: "https://example.org/north-river-youth-housing",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-026",
+ patientId: "SYN-PATIENT-005",
+ category: "financial",
+ name: "Centrelink social work service",
+ detail:
+ "They can sort out a payment quickly when you have nowhere to stay, and you do not need an address to start.",
+ contact: "0491 570 280",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-027",
+ patientId: "SYN-PATIENT-005",
+ category: "alcohol_and_other_drugs",
+ name: "North River young people's alcohol and other drug service",
+ detail: "For under twenty-fives. They will talk about staying safer whether or not you want to stop anything.",
+ contact: "0491 570 290",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-028",
+ patientId: "SYN-PATIENT-005",
+ category: "cultural_or_peer",
+ name: "North River young adult peer worker",
+ detail: "Someone closer to your own age who has been through this, and will meet you somewhere that is not a ward.",
+ contact: "0491 570 300",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-029",
+ patientId: "SYN-PATIENT-005",
+ category: "local_service",
+ name: "North River walk-in health clinic",
+ detail: "Free, no appointment, no Medicare card needed. Good for anything physical while things are unsettled.",
+ contact: "0491 570 310",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-030",
+ patientId: "SYN-PATIENT-005",
+ category: "self_help_reading",
+ name: "Waiting well",
+ detail: "Two pages on getting through a long wait in an emergency department without leaving before you are seen.",
+ contact: null,
+ sourceUrl: "https://example.org/north-river-waiting-well",
+ isRealContact: false,
+ },
+] satisfies readonly PatientResource[];
+
+/** Every patient the invented resources name, in fixture order. */
+const RESOURCE_PATIENT_IDS = [
+ "SYN-PATIENT-001",
+ "SYN-PATIENT-002",
+ "SYN-PATIENT-003",
+ "SYN-PATIENT-004",
+ "SYN-PATIENT-005",
+] as const satisfies readonly SyntheticId[];
+
+/**
+ * Every resource in the prototype: the invented services, plus the verified
+ * crisis lines for each person. One flat list, because the state holds one flat
+ * list and a shape that differs between the fixture and the state is a shape
+ * somebody has to convert at the boundary.
+ */
+export const syntheticPatientResources: readonly PatientResource[] = [
+ ...INVENTED_RESOURCES,
+ ...RESOURCE_PATIENT_IDS.flatMap((patientId) => crisisResourcesFor(patientId)),
+];
+
+export function getPatientResources(
+ resources: readonly PatientResource[],
+ patientId: SyntheticId,
+): readonly PatientResource[] {
+ return resources.filter((resource) => resource.patientId === patientId);
+}
+
+/**
+ * Resources grouped for display, in `PATIENT_RESOURCE_CATEGORY_ORDER`, with
+ * empty categories dropped. A heading with nothing under it on a person's own
+ * copy reads as something they were not offered.
+ */
+export function groupPatientResources(
+ resources: readonly PatientResource[],
+): readonly { category: PatientResourceCategory; label: string; resources: readonly PatientResource[] }[] {
+ return PATIENT_RESOURCE_CATEGORY_ORDER.map((category) => ({
+ category,
+ label: PATIENT_RESOURCE_CATEGORY_LABEL[category],
+ resources: resources.filter((resource) => resource.category === category),
+ })).filter((group) => group.resources.length > 0);
+}
diff --git a/src/components/care-plan/mockups/patient-plan-form.tsx b/src/components/care-plan/mockups/patient-plan-form.tsx
new file mode 100644
index 000000000..4ca997ebe
--- /dev/null
+++ b/src/components/care-plan/mockups/patient-plan-form.tsx
@@ -0,0 +1,463 @@
+"use client";
+
+import Link from "next/link";
+import { useState, type FormEvent } from "react";
+
+import { Button } from "@/components/ui/button";
+import { ErrorSummary } from "@/components/ui/form-field";
+import { EmptyState, InlineNotice, ignoreUnavailableActivation } from "@/components/ui-primitives";
+
+import styles from "./care-plan.module.css";
+import {
+ buildPatientSnapshot,
+ canPerformAction,
+ getCurrentPatientPlanVersion,
+ getOpenPatientPlanDraft,
+} from "./domain";
+import { PROTOTYPE_NOW } from "./fixtures";
+import { PATIENT_RESOURCE_CATEGORY_LABEL, getPatientResources } from "./patient-plan-fixtures";
+import { PATIENT_PLAN_SECTION_LEAD_IN } from "./patient-plan-transform";
+import { useCarePlanPrototype } from "./prototype-provider";
+import { getPrototypeMutationBlockReason } from "./prototype-state";
+import {
+ PROTOTYPE_OUTCOME_TONE,
+ PROTOTYPE_ROLE_LABEL,
+ PlanTextArea,
+ SectionFrame,
+ StatusMark,
+ SyntheticMarker,
+ formatPerthDate,
+} from "./prototype-ui";
+import { carePlanRoute } from "./routes";
+import type {
+ PatientPlanSection,
+ PatientPlanSectionKey,
+ PatientPlanVersion,
+ PatientResource,
+ PrototypeScenario,
+ SyntheticId,
+} from "./types";
+
+/**
+ * Writing the patient copy.
+ *
+ * The form's whole job is the gaps. The offline conversion has already done what
+ * it could do safely and refused everything else, so this screen opens showing
+ * what is missing and why — the reason beside each blank, in the clinician's
+ * words, saying what the machine would not risk getting wrong.
+ *
+ * Approval needs any clinical role and no senior sign-off. A person waiting days
+ * for their own copy of their own plan is the failure that would matter here,
+ * and the emergency department at 2am has no consultant to wait for. What
+ * approval does need is that nothing is still blank: the control states that as
+ * its reason while any gap remains, and the reducer refuses it anyway.
+ *
+ * Every section is editable, including the ones the conversion filled. A
+ * clinician who reads a converted sentence and thinks it lands badly must be
+ * able to rewrite it, and that judgement is theirs rather than the machine's.
+ */
+
+/** Stable field identifiers, so an error-summary link and the field it names
+ * cannot drift apart. */
+export function patientPlanFieldId(key: string): string {
+ return `care-plan-patient-plan-form-${key}`;
+}
+
+type FormValues = {
+ /** Each section as the text in its own control: one point per line. */
+ sections: Record;
+ /** The resource identifiers chosen for this copy. */
+ resourceIds: readonly SyntheticId[];
+};
+
+type FieldEntry = { fieldId: string; label: string; message: string };
+
+function linesFrom(text: string): string[] {
+ return text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+}
+
+function valuesFrom(version: PatientPlanVersion): FormValues {
+ const sections = {} as Record;
+ for (const section of version.sections) sections[section.key] = section.body.join("\n");
+ return { sections, resourceIds: version.resources.map((resource) => resource.id) };
+}
+
+/**
+ * The edited sections, back in domain shape.
+ *
+ * A section the clinician has written into is no longer a gap. One they have
+ * emptied becomes a gap again, and keeps the reason the conversion originally
+ * gave — the machine's answer has not changed just because somebody deleted the
+ * text over it.
+ */
+function sectionsFrom(values: FormValues, original: readonly PatientPlanSection[]): PatientPlanSection[] {
+ return original.map((section) => {
+ const body = linesFrom(values.sections[section.key] ?? "");
+ return { ...section, body, gap: body.length === 0, gapReason: body.length === 0 ? section.gapReason : null };
+ });
+}
+
+function resourcesFrom(values: FormValues, available: readonly PatientResource[]): PatientResource[] {
+ return available.filter((resource) => values.resourceIds.includes(resource.id));
+}
+
+/** What must be true before this copy can be handed to somebody. */
+function validate(values: FormValues, original: readonly PatientPlanSection[]): FieldEntry[] {
+ return original
+ .filter((section) => linesFrom(values.sections[section.key] ?? "").length === 0)
+ .map((section) => ({
+ fieldId: patientPlanFieldId(section.key),
+ label: section.heading,
+ message:
+ "Write this section with this person, one point per line. A heading with nothing under it, on a copy handed to somebody, reads as though nothing about them was worth writing.",
+ }));
+}
+
+export function PatientPlanFormSurface({
+ patientId,
+ scenario,
+ navigate,
+}: {
+ patientId: string | null;
+ scenario: PrototypeScenario;
+ navigate: (href: string) => void;
+}) {
+ const { state, dispatch } = useCarePlanPrototype();
+ const snapshot = patientId === null ? null : buildPatientSnapshot(state, patientId as SyntheticId, PROTOTYPE_NOW);
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === snapshot?.patient.id) ?? null;
+ const draft = plan === null ? null : getOpenPatientPlanDraft(state.patientPlanVersions, plan.id);
+ const current = plan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, plan.id);
+
+ // Seeded during render rather than in an effect: an effect would leave one
+ // frame showing the previous version's text in a form already claiming to be
+ // editing this one.
+ const [seededFrom, setSeededFrom] = useState(draft?.id ?? null);
+ const [values, setValues] = useState(() =>
+ draft === null ? { sections: {} as Record, resourceIds: [] } : valuesFrom(draft),
+ );
+ const [errors, setErrors] = useState([]);
+ const [attempt, setAttempt] = useState(0);
+
+ if (draft !== null && seededFrom !== draft.id) {
+ setSeededFrom(draft.id);
+ setValues(valuesFrom(draft));
+ setErrors([]);
+ }
+
+ /*
+ * There is deliberately no focus effect here. `ErrorSummary` moves focus to
+ * itself on a failed submit, which is the repository's behaviour for every
+ * form in the product; this one does not get to be different.
+ */
+
+ if (snapshot === null) {
+ return (
+
+ );
+ }
+
+ const { patient, currentManagementVersion } = snapshot;
+
+ if (scenario === "identity-uncertain") {
+ return (
+
+
+ This record has not been confirmed as the right person. Nothing can be written against it.
+ Return to search and choose the record again.
+
+
+ );
+ }
+
+ const actor = state.users.find((user) => user.id === state.activeUserId) ?? null;
+ const mayAuthor = actor !== null && canPerformAction(actor.role, "approve_patient_plan");
+ const available = getPatientResources(state.patientResources, patient.id);
+
+ const outcome =
+ state.lastOutcome === null ? null : (
+ {state.lastOutcome.message}
+ );
+
+ if (draft === null) {
+ const createBlockedReason = getPrototypeMutationBlockReason(state, {
+ type: "create-patient-plan-draft",
+ patientId: patient.id,
+ });
+ return (
+
+
+ {outcome}
+
+ {currentManagementVersion === null ? (
+
+ {`${patient.preferredName} has no Current Plan, so there is nothing to make a patient copy of.`}
+
+ ) : (
+ <>
+
+ {`The copy is produced from Current Plan version ${currentManagementVersion.version} by a fixed conversion that runs on this device. It converts only what it can convert safely and leaves everything else blank, with the reason, for you to write with ${patient.preferredName}. No language model is involved.`}
+
+ {current === null ? null : (
+
+ {`Version ${current.version} stays the approved copy until you approve a new one, so ${patient.preferredName} is never left without one.`}
+
+ )}
+
+ {createBlockedReason !== null ? (
+
+ ) : (
+
+ )}
+
+ {createBlockedReason === null ? null : (
+
+ {createBlockedReason}
+
+ )}
+ >
+ )}
+
+
+ Back to the Patient Plan
+
+
+
+
+ );
+ }
+
+ const editedSections = sectionsFrom(values, draft.sections);
+ const remainingGaps = editedSections.filter((section) => section.gap);
+ const approveBlockedReason = getPrototypeMutationBlockReason(state, {
+ type: "approve-patient-plan-version",
+ versionId: draft.id,
+ });
+
+ function handleSave(event: FormEvent) {
+ event.preventDefault();
+ if (draft === null) return;
+ setErrors([]);
+ setAttempt((count) => count + 1);
+ dispatch({
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: sectionsFrom(values, draft.sections), resources: resourcesFrom(values, available) },
+ });
+ }
+
+ function handleApprove() {
+ if (draft === null) return;
+ const found = validate(values, draft.sections);
+ setAttempt((count) => count + 1);
+ if (found.length > 0) {
+ setErrors(found);
+ return;
+ }
+ setErrors([]);
+ dispatch({
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: sectionsFrom(values, draft.sections), resources: resourcesFrom(values, available) },
+ });
+ dispatch({ type: "approve-patient-plan-version", versionId: draft.id });
+ navigate(carePlanRoute.patientPlan(patient.id));
+ }
+
+ return (
+
+
+ {outcome}
+
+
+
+
+
+
+
+ {`Everything below is a draft. ${patient.preferredName} has not been given it, and it is not their copy until you approve it.`}
+
+
+ The blanks are deliberate. The conversion runs offline on this device with no language model of any kind, and
+ where it could not be confident it left the section empty rather than guessing. What it would have guessed
+ wrong is exactly what a person would have read as being about them.
+
+
+
+ {mayAuthor ? null : (
+
+ {actor === null
+ ? "No synthetic user is selected, so nothing written here could be attributed to anyone."
+ : `${actor.displayName} is signed in with the ${PROTOTYPE_ROLE_LABEL[actor.role].toLowerCase()} role, which does not carry writing or approving a patient copy. Any clinical role can.`}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/care-plan/mockups/patient-plan-pages.tsx b/src/components/care-plan/mockups/patient-plan-pages.tsx
new file mode 100644
index 000000000..e876bdb54
--- /dev/null
+++ b/src/components/care-plan/mockups/patient-plan-pages.tsx
@@ -0,0 +1,572 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { EmptyState, InlineNotice, ignoreUnavailableActivation } from "@/components/ui-primitives";
+import { BrowserPrintButton, PrintOutput, PrintSection } from "@/components/ui/print-output";
+
+import styles from "./care-plan.module.css";
+import {
+ buildPatientSnapshot,
+ canPerformAction,
+ getCurrentPatientPlanVersion,
+ getOpenPatientPlanDraft,
+ isPatientPlanVersionStale,
+} from "./domain";
+import { PROTOTYPE_NOW } from "./fixtures";
+import { groupPatientResources } from "./patient-plan-fixtures";
+import { PATIENT_PLAN_SECTION_LEAD_IN } from "./patient-plan-transform";
+import { useCarePlanPrototype } from "./prototype-provider";
+import { getPrototypeMutationBlockReason } from "./prototype-state";
+import {
+ DefinitionRow,
+ NOT_RECORDED,
+ PROTOTYPE_OUTCOME_TONE,
+ PROTOTYPE_ROLE_LABEL,
+ SectionFrame,
+ StatusMark,
+ SyntheticMarker,
+ formatPerthDate,
+} from "./prototype-ui";
+import { carePlanRoute } from "./routes";
+import type {
+ Patient,
+ PatientPlanVersion,
+ PatientResource,
+ PrototypeScenario,
+ PrototypeUser,
+ SyntheticId,
+} from "./types";
+
+/**
+ * The Patient Plan: the patient-facing edition of an approved Management Plan
+ * Version, and the copy the person takes away.
+ *
+ * The Management Plan is written by clinicians for clinicians. This is the same
+ * agreement in the person's own voice — the same eight headings whether it is
+ * read here or printed, so the page and the paper cannot say two different
+ * things.
+ *
+ * Two rules shape everything below.
+ *
+ * A gap is never printed. A gap is a place the offline conversion refused to
+ * guess, and approval is blocked while any remains, so an approved copy has
+ * nothing missing from it. That is why this surface shows gaps loudly on a draft
+ * and the printed copy has no notion of one.
+ *
+ * A stale copy is never quietly repaired. When the Management Plan moves on, the
+ * patient copy is marked as needing updating and stays completely readable. It
+ * is not regenerated, hidden, or withdrawn: the person may be holding the paper,
+ * and this application's account of what they were given has to match what they
+ * actually have.
+ *
+ * The synthetic marker is rendered *inside* the printed subtree deliberately.
+ * The shared print rule makes everything outside `[data-print-output]`
+ * invisible, so the shell header's marker cannot reach the paper — and a sheet
+ * carrying somebody's care plan and four real crisis telephone numbers with
+ * nothing saying the content is fictional is the exact failure this guards
+ * against. It has been introduced twice already.
+ */
+
+function displayName(users: readonly PrototypeUser[], id: SyntheticId | null): string | undefined {
+ if (id === null) return undefined;
+ return users.find((user) => user.id === id)?.displayName;
+}
+
+/**
+ * The eight sections, generated by iterating the version's own list. One
+ * rendering serves the screen and the paper.
+ *
+ * `gaps` is the one thing the two surfaces must not share. On the draft a gap is
+ * shown with its reason, because a clinician needs to see what is still owed and
+ * why the machine would not attempt it. On paper there are none, because a
+ * version carrying one cannot be approved — and were one ever to reach here it
+ * is omitted outright rather than printed as a heading with nothing beneath it.
+ * "My reasons for living — Not recorded" was printed on a person's own safety
+ * plan once; nothing in this file may reproduce it.
+ */
+function PatientPlanSections({
+ sections,
+ gaps = "state",
+}: {
+ sections: readonly PatientPlanVersion["sections"][number][];
+ /** `state` — the working copy: a gap is shown with its reason.
+ * `omit` — the person's own copy: a gap is not printed at all. */
+ gaps?: "state" | "omit";
+}) {
+ const shown = sections.filter((section) => gaps === "state" || !section.gap);
+ return (
+
+ {shown.map((section) => (
+
+
+ {section.heading}
+
+ {PATIENT_PLAN_SECTION_LEAD_IN[section.key]}
+ {section.gap ? (
+
+
+ {section.gapReason ?? NOT_RECORDED}
+
+ ) : (
+
+ {section.body.map((line) => (
+ - {line}
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
+
+/**
+ * The person's resources, grouped by category.
+ *
+ * The crisis contacts are the only real details on the page, and they say so:
+ * each carries its stated limitation, its hours, and the public page it was
+ * checked against, because a reader who dials a number on this sheet must reach
+ * a service rather than a fictional one.
+ */
+function PatientPlanResources({ resources }: { resources: readonly PatientResource[] }) {
+ const groups = groupPatientResources(resources);
+ if (groups.length === 0) return null;
+ return (
+
+ {groups.map((group) => (
+
+ {group.label}
+ {group.resources.map((resource) => (
+
+
+ {resource.contact === null || resource.isRealContact
+ ? resource.name
+ : `${resource.name} — ${resource.contact}`}
+
+
+ {resource.detail}
+
+ {resource.sourceUrl === null ? null : (
+
+ )}
+
+ ))}
+
+ ))}
+
+ );
+}
+
+/**
+ * The notice a Current patient copy carries once the Management Plan it was
+ * written from has been replaced. Derived every render from the two identifiers,
+ * never stored.
+ */
+function StaleNotice({ version, patient }: { version: PatientPlanVersion; patient: Patient }) {
+ return (
+
+ This copy needs updating.{" "}
+ {`The Management Plan has moved on since version ${version.version} of ${patient.preferredName}'s own copy was written. Everything in it is still shown, and nothing has been changed or taken away: ${patient.preferredName} may be holding a printed copy, and what this page says they were given has to stay true. Go through it with them and write a new one.`}
+
+ );
+}
+
+function PatientPlanIdentityBand({ patient }: { patient: Patient }) {
+ return (
+
+
+ {patient.fullName}
+ {`${patient.mrn} — born ${formatPerthDate(patient.dateOfBirth)}`}
+
+ );
+}
+
+export function PatientPlanSurface({ patientId, scenario }: { patientId: string | null; scenario: PrototypeScenario }) {
+ const { state, dispatch } = useCarePlanPrototype();
+ const snapshot = patientId === null ? null : buildPatientSnapshot(state, patientId as SyntheticId, PROTOTYPE_NOW);
+
+ if (snapshot === null) {
+ return (
+
+ );
+ }
+
+ const { patient, managementPlan, currentManagementVersion } = snapshot;
+
+ if (scenario === "identity-uncertain") {
+ return (
+
+
+ This record has not been confirmed as the right person. Nothing of this plan is shown,
+ because this is the copy written to be handed to somebody. Return to search and choose the record again.
+
+
+ );
+ }
+
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === patient.id) ?? null;
+ const current = plan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, plan.id);
+ const draft = plan === null ? null : getOpenPatientPlanDraft(state.patientPlanVersions, plan.id);
+ const stale = isPatientPlanVersionStale(current, managementPlan?.currentVersionId ?? null);
+
+ const actor = state.users.find((user) => user.id === state.activeUserId) ?? null;
+ const mayAuthor = actor !== null && canPerformAction(actor.role, "approve_patient_plan");
+ const createBlockedReason = getPrototypeMutationBlockReason(state, {
+ type: "create-patient-plan-draft",
+ patientId: patient.id,
+ });
+
+ const ownership = (
+
+
+ {`This is ${patient.preferredName}'s own copy of the plan, written to be read by ${patient.preferredName} rather than by a clinician. It carries the same agreement as the Management Plan, in ${patient.preferredName}'s own voice.`}
+
+
+ It is produced by a fixed offline conversion that runs on this device. No language model, and no service of any
+ kind, is involved at any point. Anything the conversion could not turn into everyday words with confidence is
+ left blank for a clinician to write, and a copy with a blank in it cannot be approved.
+
+
+ );
+
+ const authoringLink = mayAuthor ? (
+
+
+ {draft === null ? "Write a new copy with this person" : `Continue draft version ${draft.version}`}
+
+
+ ) : (
+
+ {actor === null
+ ? "No synthetic user is selected, so nothing written here could be attributed to anyone."
+ : `${actor.displayName} is signed in with the ${PROTOTYPE_ROLE_LABEL[actor.role].toLowerCase()} role, which does not carry writing or approving a patient copy. Any clinical role can.`}
+
+ );
+
+ const createControl =
+ currentManagementVersion === null ? (
+
+ {`${patient.preferredName} has no Current Plan, so there is nothing to make a patient copy of. A draft or a withdrawn version is not an agreed plan, and a copy of one would describe care nobody agreed to.`}
+
+ ) : createBlockedReason !== null ? (
+ <>
+
+
+ {createBlockedReason}
+
+ >
+ ) : (
+
+ );
+
+ return (
+
+
+ {ownership}
+
+ {state.lastOutcome === null ? null : (
+ {state.lastOutcome.message}
+ )}
+
+ {stale && current !== null ? : null}
+
+ {current === null ? (
+
+
+ {`${patient.preferredName} has no approved Patient Plan. Nothing is shown in its place, because a draft is not a copy anybody has agreed to hand over.`}
+
+ {draft === null ? null : (
+
+
+
+ {`A draft was started on ${formatPerthDate(draft.createdAt)}. It has ${
+ draft.sections.filter((section) => section.gap).length
+ } of ${draft.sections.length} sections still to write, and cannot be approved until they are.`}
+
+
+ )}
+ {createControl}
+ {authoringLink}
+
+ ) : (
+ <>
+
+
+
+ {stale ? : null}
+
+
+ {displayName(state.users, current.approvedBy)}
+ {formatPerthDate(current.approvedAt)}
+
+ {String(
+ state.managementPlanVersions.find((version) => version.id === current.derivedFromManagementVersionId)
+ ?.version ?? NOT_RECORDED,
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {`The printed copy is written to ${patient.preferredName}, carries only ${patient.preferredName}'s preferred name and record number, and holds nothing from the clinical record beyond what is on this page.`}
+
+
+
+ Print this copy
+
+
+ {authoringLink}
+
+ >
+ )}
+
+ );
+}
+
+/**
+ * The printed-at stamp, formatted from `PROTOTYPE_NOW` alone. Deterministic by
+ * construction: the prototype reads no clock, so two runs of the same route
+ * produce the same sheet of paper.
+ */
+function perthStamp(iso: string): string {
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
+ if (match === null) return NOT_RECORDED;
+ const hour24 = Number(match[4]);
+ const meridiem = hour24 < 12 ? "am" : "pm";
+ const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12;
+ return `${match[3]}/${match[2]}/${match[1]} at ${hour12}:${match[5]} ${meridiem} AWST`;
+}
+
+const PRINTED_AT = `This copy was printed on ${perthStamp(PROTOTYPE_NOW)}.`;
+
+export function PatientPlanPrintSurface({
+ patientId,
+ scenario,
+}: {
+ patientId: string | null;
+ scenario: PrototypeScenario;
+}) {
+ const { state, dispatch } = useCarePlanPrototype();
+ const [printFailed, setPrintFailed] = useState(false);
+ const snapshot = patientId === null ? null : buildPatientSnapshot(state, patientId as SyntheticId, PROTOTYPE_NOW);
+
+ if (snapshot === null) {
+ return (
+
+ );
+ }
+
+ const { patient, managementPlan } = snapshot;
+
+ // Printing is exempt from the offline block and from nothing else. Paper
+ // carrying a nearby person's plan cannot be taken back once it has left the
+ // room, so identity uncertainty stops it outright and nothing is offered.
+ if (scenario === "identity-uncertain") {
+ return (
+
+
+ This record has not been confirmed as the right person. Nothing is offered for printing,
+ because paper carrying a nearby person’s plan cannot be taken back. Return to search and choose the
+ record again.
+
+
+ );
+ }
+
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === patient.id) ?? null;
+ const version = plan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, plan.id);
+
+ if (version === null) {
+ return (
+
+
+ {`${patient.preferredName} has no approved Patient Plan, so there is nothing to print. A draft is not a copy anybody has agreed to hand over, and a draft on paper is a plan somebody may follow.`}
+
+
+
+ Back to the Patient Plan
+
+
+
+ );
+ }
+
+ const blockedReason = getPrototypeMutationBlockReason(state, {
+ type: "record-patient-plan-print-intent",
+ patientId: patient.id,
+ });
+ const stale = isPatientPlanVersionStale(version, managementPlan?.currentVersionId ?? null);
+
+ return (
+
+
+ {blockedReason !== null ? (
+
+ ) : scenario === "print-failure" ? (
+ // The print-failure specimen. The browser is never asked to print, and
+ // no intent is recorded either: an audit line saying the print view
+ // was opened would be untrue.
+
+ ) : (
+ dispatch({ type: "record-patient-plan-print-intent", patientId: patient.id })}
+ />
+ )}
+
+ Back to the Patient Plan
+
+
+
+ {blockedReason === null ? null : (
+
+ {blockedReason}
+
+ )}
+
+ {stale ? (
+
+
+
+ ) : null}
+
+ {printFailed ? (
+
+ The print view could not be opened. Nothing was printed, and nothing was recorded. The whole
+ copy is still on this page: read it from the screen, write the numbers down, or try printing again from the
+ browser’s own menu.
+
+ ) : null}
+
+ {state.lastOutcome === null ? null : (
+
+ {state.lastOutcome.message}
+
+ )}
+
+
+
+
+ My plan
+ {/*
+ Minimum necessary, and nothing beyond it. A preferred name and a
+ record number are what a person needs to know the sheet is theirs and
+ what a service needs to match it to them. A date of birth, an
+ address, a pronoun, a home service, an episode, the Management Plan's
+ version history, its owner, its approver, its review date, or
+ anything about what a clinician thinks is not on this page: none of
+ it helps the reader and all of it travels.
+ */}
+
+ {patient.preferredName}
+ {patient.mrn}
+ {String(version.version)}
+ {formatPerthDate(version.approvedAt)}
+
+
+ This is your copy of the plan you and your team wrote together. Keep it somewhere you can find it quickly,
+ and bring it with you if you can. If something in it stops fitting, tell someone on your team so you can
+ write it again together.
+
+
+
+ {stale ? (
+ // A stale copy is going to paper here just as readably as a current
+ // one — the domain rule this warning exists to serve is that nothing
+ // is hidden or withdrawn on the person's behalf. What must not happen
+ // is the paper leaving the room silent about it: the on-screen
+ // StaleNotice above sits outside this element and the shared print
+ // rule hides everything outside `[data-print-output]`, so this is the
+ // one copy of the warning a reader holding the sheet ever sees.
+
+ This copy needs updating. The Management Plan has moved on since version{" "}
+ {String(version.version)} of this copy was written. Everything here is still shown, and nothing has been
+ changed or taken away. Check with the team before treating it as current.
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/care-plan/mockups/patient-plan-transform.ts b/src/components/care-plan/mockups/patient-plan-transform.ts
new file mode 100644
index 000000000..031e070bb
--- /dev/null
+++ b/src/components/care-plan/mockups/patient-plan-transform.ts
@@ -0,0 +1,1117 @@
+import type {
+ ManagementPlanContent,
+ ManagementPlanVersion,
+ Patient,
+ PatientPlanSection,
+ PatientPlanSectionKey,
+ PatientResource,
+ SyntheticId,
+} from "./types";
+import { PATIENT_PLAN_SECTION_KEYS } from "./types";
+
+/**
+ * Care Plan — the deterministic, offline Patient Plan transformation.
+ *
+ * `buildPatientPlanDraft` turns one approved Management Plan Version into a
+ * draft of the same content in the person's own voice. It is a pure function of
+ * its three arguments: no network, storage, timer, randomness, wall clock, or
+ * model call, and nothing imported from outside this namespace. The same inputs
+ * always produce the same output, byte for byte.
+ *
+ * **No language model is reachable from any part of this.** That is a product
+ * boundary rather than an implementation convenience, and it is the reason the
+ * transformation is shaped the way it is. A model asked to "rewrite this for a
+ * patient" produces fluent, confident, wrong sentences about somebody's care and
+ * leaves a reader no way to tell which ones. This does the opposite: it converts
+ * only what it can convert by rule, and everything else becomes a visible gap
+ * with a stated reason, for a clinician to write.
+ *
+ * The seam is the function boundary. A later model-backed implementation can
+ * replace this function without touching the version model, the approval step,
+ * or any screen — provided it keeps the same contract: a gap rather than a
+ * guess.
+ *
+ * ## Why free rewriting is not attempted
+ *
+ * Swapping words inside arbitrary sentences produces confident nonsense. This
+ * works only because a Management Plan is already eleven fields with known
+ * meanings, so the transformation never has to work out what a sentence is
+ * *for*: the field it came from already says. Each field maps to a known
+ * patient-voice heading, a curated dictionary replaces clinical vocabulary with
+ * everyday words, and the shift to second person is a bounded substitution of
+ * the person's own name and pronouns — with verb agreement read from a table,
+ * never inferred.
+ *
+ * ## Everything not confidently convertible is a gap
+ *
+ * A section gaps whole. It never carries part of its content, because three of
+ * five points with two silently dropped looks finished and is not.
+ *
+ * The result is an incomplete draft by design, and often a mostly incomplete
+ * one. That is the intended outcome, not a shortfall: the machine does the part
+ * it can do safely, a clinician writes the rest with the person, and approval is
+ * refused until they have.
+ */
+
+/** The eight headings, in `PATIENT_PLAN_SECTION_KEYS` order, in the person's
+ * own voice. Written as somebody would say them, never as a field name. */
+export const PATIENT_PLAN_SECTION_HEADING: Record = {
+ whyWeWroteThis: "Why we wrote this together",
+ whatMattersToYou: "What matters to you",
+ whatHelpsYou: "What helps you",
+ whatMakesThingsHarder: "What makes things harder",
+ whatWeAgreedWillHappen: "What we agreed will happen when you come to the emergency department",
+ ifSomethingNewIsHappening: "If something new is happening",
+ whoIsInvolved: "Who's involved in your care",
+ thingsThatMightHelp: "Things that might help",
+};
+
+/**
+ * A fixed sentence under each heading, addressed to the person. This is where
+ * the framing shift actually lives: the heading and this line set the voice, so
+ * the content underneath needs only its own words changed rather than being
+ * rewritten into a second person it was never written in.
+ *
+ * None of them tells the person what to do, thanks them for cooperating, or
+ * describes them. They say what the section is.
+ */
+export const PATIENT_PLAN_SECTION_LEAD_IN: Record = {
+ whyWeWroteThis: "This is what we wrote down about why this plan exists.",
+ whatMattersToYou: "These are the things you have said matter to you.",
+ whatHelpsYou: "These are the things that have helped before.",
+ whatMakesThingsHarder: "These are the things that have made a visit harder, so we can try to avoid them.",
+ whatWeAgreedWillHappen: "This is the approach you and your team agreed for when you come in.",
+ ifSomethingNewIsHappening:
+ "This plan is about what usually happens. If something is different this time, say so — you will be looked at afresh.",
+ whoIsInvolved: "These are the people and teams who know you.",
+ thingsThatMightHelp: "These are practical things that can be arranged for you.",
+};
+
+/**
+ * Which clinical fields each patient-voice heading is built from. Two fields are
+ * deliberately absent, and there is no ninth section.
+ */
+export const PATIENT_PLAN_SECTION_SOURCES: Record = {
+ whyWeWroteThis: ["whyThisPlanExists"],
+ whatMattersToYou: ["whatThePersonWants"],
+ whatHelpsYou: ["whatHelps"],
+ whatMakesThingsHarder: ["whatMakesItWorse"],
+ whatWeAgreedWillHappen: ["agreedEdApproach"],
+ ifSomethingNewIsHappening: ["whatWouldMakeThisDifferent", "reviewTriggers"],
+ whoIsInvolved: ["whoElseIsInvolved"],
+ thingsThatMightHelp: ["practicalNeeds"],
+};
+
+/**
+ * The content fields no heading is built from. Stated here rather than left as
+ * an absence, so a missing mapping reads as a decision instead of an oversight,
+ * and a test proves every field is either mapped above or named here.
+ *
+ * `physicalHealthAndMedication` — the person has their own record of their
+ * medicines, and copying medication detail onto a sheet that leaves the building
+ * is a privacy cost with nothing bought by it.
+ *
+ * `howToApproach` — written in the imperative, to staff. Converted onto the
+ * person's own copy it becomes a set of instructions to *them*, which was the
+ * worst thing the first run of this transformation produced: "You asked not to
+ * take part in writing this plan. Say so plainly when you use it, and offer
+ * again to write it together", printed on the plan of the person it describes.
+ * What this field holds that the person needs is already in `whatHelps`, said
+ * from their side; the rest is how a stranger should open a conversation, which
+ * is not theirs to be handed.
+ */
+export const PATIENT_PLAN_OMITTED_CONTENT_KEYS = [
+ "physicalHealthAndMedication",
+ "howToApproach",
+] as const satisfies readonly (keyof ManagementPlanContent)[];
+
+/**
+ * The one section that is never converted, whatever it contains.
+ *
+ * It carries the agreed emergency-department approach and any position on
+ * admission. It is where a wording slip does the most harm, and it is the
+ * section a person is most likely to read as a judgement about them rather than
+ * as an agreement they were part of. So it is written by a clinician every time,
+ * and no content, however plain, makes it eligible for conversion.
+ */
+export const NEVER_CONVERTED_SECTION_KEYS = [
+ "whatWeAgreedWillHappen",
+] as const satisfies readonly PatientPlanSectionKey[];
+
+// --- The curated dictionary ------------------------------------------------------
+
+/**
+ * Clinical vocabulary with a confident everyday equivalent.
+ *
+ * An entry is only allowed here when the replacement is safe wherever the term
+ * appears — noun for noun, verb for verb, adjective for adjective — because the
+ * substitution is not grammar-aware, and a swap that fits one sentence and
+ * breaks the next is exactly the confident nonsense this design exists to avoid.
+ * A term whose replacement would depend on the sentence around it belongs in
+ * `UNCONVERTIBLE_CLINICAL_TERMS` instead, where it gaps the section.
+ *
+ * Some entries map a term to itself. They are not redundant: an entry here also
+ * marks a term as clinical, which is what makes a negation beside it a *clinical*
+ * negation. "Emergency department" needs no plainer wording and still must not
+ * be negated by a machine.
+ *
+ * Longest key first, so "community mental health team" is not part-converted by
+ * "mental health team".
+ */
+export const PLAIN_LANGUAGE_TERMS: Record = {
+ "community mental health team": "mental health team",
+ "mental health team": "mental health team",
+ "general practitioner": "family doctor",
+ "care coordinator": "main contact",
+ "emergency department": "emergency department",
+ "peer support": "peer support",
+ "pain relief": "pain relief",
+ "low-stimulus": "calm",
+ "low stimulus": "calm",
+ "short-stay": "short-stay",
+ cmht: "mental health team",
+ gp: "family doctor",
+ clinician: "staff member",
+ clinicians: "staff members",
+ analgesia: "pain relief",
+ escalated: "built up",
+ escalates: "builds up",
+ escalate: "build up",
+ attends: "comes to",
+ attended: "came to",
+ attend: "come to",
+ interpreter: "interpreter",
+ ambulance: "ambulance",
+};
+
+/**
+ * Clinical vocabulary with no everyday equivalent this transformation is willing
+ * to commit to. A sentence containing one of these gaps.
+ *
+ * Several are ordinary English. They are here because each carries a clinical
+ * meaning an everyday synonym would quietly change. "Distress" is not "upset". A
+ * "presentation" is not "a visit" when the sentence is about a pattern over a
+ * year. "Disposition" is not "where you go next". Getting any of them slightly
+ * wrong on a document handed to the person it is about is worse than leaving a
+ * clinician to write the sentence.
+ *
+ * The last group is different in kind: words that pass judgement on a person —
+ * declined, refused, failed, resistant. Those never appear on somebody's own
+ * copy by machine, whatever else the sentence says.
+ */
+export const UNCONVERTIBLE_CLINICAL_TERMS: readonly string[] = [
+ "distress",
+ "distressed",
+ "triage",
+ "disposition",
+ "presentation",
+ "presentations",
+ "presenting",
+ "mental-state",
+ "safeguarding",
+ "delirium",
+ "overdose",
+ "means",
+ "preparation",
+ "risk",
+ "assessment",
+ "assessments",
+ "assessed",
+ "assess",
+ "admission",
+ "admitted",
+ "discharge",
+ "discharged",
+ "compliance",
+ "compliant",
+ "engagement",
+ "prognosis",
+ "diagnosis",
+ "psychosis",
+ "delusion",
+ "hallucination",
+ "sedation",
+ "restraint",
+ "seclusion",
+ "detained",
+ "capacity",
+ "acuity",
+ "severity",
+ "chronic",
+ "comorbid",
+ "prescribing",
+ "medication",
+ "medicines",
+ "dose",
+ "doses",
+ "allergy",
+ "asthma",
+ "seizure",
+ "breathlessness",
+ "drowsiness",
+ "confusion",
+ "disorientation",
+ "conscious",
+ "droop",
+ "slurred",
+ "infection",
+ "obligations",
+ "clinical",
+ "continuity",
+ "renegotiate",
+ "attributing",
+ "attributed",
+ "deteriorate",
+ "deterioration",
+ "sensory",
+ "conflict",
+ "escalation",
+ "accusation",
+ // Physical red flags. These belong to the section about what makes a
+ // presentation different, which is a clinical escalation list. Converted onto
+ // a person's own sheet they read as a warning about their body written by
+ // somebody who has not examined them.
+ "physical",
+ "medical",
+ "medicine",
+ "symptom",
+ "symptoms",
+ "chest",
+ "fever",
+ "injury",
+ "limb",
+ "weakness",
+ "facial",
+ "pregnancy",
+ "substance",
+ "attempt",
+ "self-harm",
+ "suicide",
+ "suicidal",
+ "harm",
+ // Judgements about a person.
+ "declined",
+ "decline",
+ "declines",
+ "refused",
+ "refuse",
+ "failed",
+ "fails",
+ "resistant",
+ "uncooperative",
+ "unmotivated",
+ "manipulative",
+ "attention-seeking",
+];
+
+/**
+ * Negation words. A negation gaps its section only when it sits in the same
+ * sentence as a clinical term, whether or not that term has a replacement.
+ *
+ * The distinction does real work. "To have Jess contacted only with your
+ * agreement on the day, not automatically" is a plain sentence about a
+ * preference and converts safely. "Pain relief was not offered before the
+ * mental-health assessment" substitutes cleanly word by word and still must not
+ * be converted: a negated clinical statement that half-converts reverses its own
+ * meaning, and a person reading a negated sentence about their own care is
+ * exactly where blame lands.
+ *
+ * Contrastive words — "rather", "instead" — are deliberately absent. They
+ * express a preference, not a negation, and treating them as one would gap
+ * almost every sentence in this vocabulary while protecting nothing.
+ */
+export const CLINICAL_NEGATIONS: readonly string[] = [
+ "not",
+ "never",
+ "no",
+ "nor",
+ "none",
+ "nothing",
+ "nobody",
+ "neither",
+ "without",
+ "cannot",
+ "unable",
+];
+
+/**
+ * Third-person-singular present verbs and the second-person form each becomes
+ * once the subject is "you". Substituting a subject without this produces "you
+ * settles", which tells a reader the page was generated rather than written for
+ * them.
+ *
+ * A verb absent from this table gaps its section rather than being guessed at by
+ * a rule such as "drop the trailing s", which is wrong for every plural noun.
+ */
+export const THIRD_PERSON_VERBS: Record = {
+ is: "are",
+ was: "were",
+ has: "have",
+ does: "do",
+ goes: "go",
+ says: "say",
+ settles: "settle",
+ finds: "find",
+ prefers: "prefer",
+ takes: "take",
+ needs: "need",
+ asks: "ask",
+ wants: "want",
+ likes: "like",
+ feels: "feel",
+ gets: "get",
+ comes: "come",
+ uses: "use",
+ knows: "know",
+ seems: "seem",
+ tends: "tend",
+ copes: "cope",
+ sleeps: "sleep",
+ eats: "eat",
+ drinks: "drink",
+ rings: "ring",
+ calls: "call",
+ waits: "wait",
+ leaves: "leave",
+ arrives: "arrive",
+ agrees: "agree",
+ chooses: "choose",
+ reads: "read",
+ writes: "write",
+ hears: "hear",
+ sees: "see",
+ sits: "sit",
+ stands: "stand",
+ walks: "walk",
+ lives: "live",
+ works: "work",
+ helps: "help",
+ tells: "tell",
+ brings: "bring",
+ keeps: "keep",
+ holds: "hold",
+ carries: "carry",
+ manages: "manage",
+ remembers: "remember",
+ forgets: "forget",
+ understands: "understand",
+ explains: "explain",
+ notices: "notice",
+ worries: "worry",
+ wakes: "wake",
+ rests: "rest",
+ builds: "build",
+ starts: "start",
+ stops: "stop",
+ stays: "stay",
+ becomes: "become",
+ makes: "make",
+ gives: "give",
+ puts: "put",
+ looks: "look",
+ thinks: "think",
+ travels: "travel",
+ visits: "visit",
+};
+
+/**
+ * Words ending in `s` that are not third-person-singular verbs, so a subject
+ * substitution in front of them changes nothing.
+ *
+ * This list exists so the agreement check can fail closed. Every `s`-ending word
+ * in the everyday vocabulary must be classified here or in `THIRD_PERSON_VERBS`,
+ * and a test proves it — so a word added to the vocabulary later cannot quietly
+ * become an unchecked verb.
+ */
+export const AGREEMENT_SAFE_S_WORDS: readonly string[] = [
+ "across",
+ // A noun here, and already the second-person form when it is a verb.
+ "address",
+ "afterwards",
+ "always",
+ "appointments",
+ "arrangements",
+ "as",
+ "bills",
+ "bus",
+ "clothes",
+ "days",
+ "guess",
+ "details",
+ "doors",
+ "evenings",
+ "families",
+ "friends",
+ "glasses",
+ "groups",
+ "his",
+ "hours",
+ "its",
+ "less",
+ "lights",
+ "lists",
+ "meals",
+ "meetings",
+ "members",
+ "minutes",
+ "months",
+ "mornings",
+ "names",
+ "news",
+ "nights",
+ "noises",
+ "notes",
+ "numbers",
+ "nurses",
+ "options",
+ "otherwise",
+ "papers",
+ "people's",
+ "perhaps",
+ "places",
+ "plans",
+ "plus",
+ "pronouns",
+ "questions",
+ "reasons",
+ "rooms",
+ "seats",
+ "services",
+ "sessions",
+ "signs",
+ "sometimes",
+ "sounds",
+ "staff's",
+ "steps",
+ "teams",
+ "themselves",
+ "things",
+ "this",
+ "times",
+ "towards",
+ "unless",
+ "upstairs",
+ "us",
+ "ways",
+ "weeks",
+ "words",
+ "years",
+ "yes",
+];
+
+/**
+ * The everyday words this transformation is permitted to leave in place.
+ *
+ * This is the dictionary the gap rule refers to: a sentence containing any word
+ * that is not here, not a clinical term the dictionary converted, and not a name
+ * already on this person's own record becomes a gap. It fails closed by
+ * construction — an unfamiliar word is never passed through on the assumption it
+ * is harmless.
+ *
+ * It is general plain English rather than a list assembled by reading the
+ * fixtures. Extending it until one particular synthetic sentence converts would
+ * make it a description of the fixtures instead of a description of the
+ * language, and the next real plan would gap everywhere.
+ */
+const EVERYDAY_VOCABULARY = `
+a about above across after afterwards again against agreement all along already also although always am among
+an and another answer any anyone anything appointment appointments are area argument around arrangements arrive
+arrived as ask asked asking at automatically away back bad bag be because become bed been before begin behind
+being below beside best better between big bill bills bit blanket both bring brought bus busy but by calm call
+called came can cannot car card care careful carry chair change changed check checked child children choice
+choose clear clearly close closed clothes cold come coming comfortable contact contacted conversation cost could
+count cup daughter day days decide decided decision department details different difficult do does doing done
+door doors down drink drive during each early easier easily easy eat eight either else emergency end enough
+evening evenings every everyone everything exactly explain explained face families family far father feel feeling
+felt few find finish first fit five follow following food for form found four free friend friends from front
+full get getting give given glad glasses go going gone good got great ground group groups grow had half hand
+handle happen happened happening happy hard have having he head health hear heard hearing help helped helpful
+her here herself high him himself his hold home hope hospital hour hours house how however husband i if important
+in information inside interpreter into is it its itself join journey just keep kept key kind know known landlord
+large last late later leave leaving left less let letter light lights like liked list lists listen little live
+living long look looked looking lot loud low lunch made main make making man many may maybe me meal meals mean
+meant meet meeting meetings member members mental might mind minute minutes money month months more morning
+mornings most mother move much must my myself name names near need needed never new next night nights nine no
+noise noises none nor not note notes nothing now number numbers nurse nurses of off offer offered often on once
+one only open options or order other otherwise our out over own page pain paper papers part partner peer people
+perhaps person phone pick place places plan plans please plus point possible prefer prepare private problem
+pronouns put question questions quick quickly quiet quieter rather reach read ready real really reason reasons
+relief remember rent rest right ring room rooms roughly run safe said same say saying seat seats second see seen
+sensible sentence service services session sessions set settle seven several she short show shown side sign
+signs simple since sister sit sitting six sleep slow slowly small so some someone something sometimes son soon
+sore sort sound sounds space speak spoken staff stand start started stay step steps still stop straight street
+such support sure take taken talk talked talking team teams tell ten than that the their them themselves then
+there these they thing things think third this those though thought three through time times to today together
+told tomorrow tonight too took towards town travel trip try trying turn twelve two under understand unless until
+up upstairs us use used useful usual usually very visit visits wait waited waiting wake walk want wanted ward
+warm was watch water way ways we wear week weekend weeks well went were what when where whether which while who
+whole whom whose why wife will window wish with within without woman work worked worker working world worried
+worry would write written wrong year years yes yesterday yet you your yourself
+agree agreed aid arm approach available bay bright complete concern concentrate confirm consistent conversation
+cover current describe differ directly draft effect entirely expect familiar fresh guess harder history
+household include instruction introduce judgement legal life longer nearby news nowhere occasion older overhead
+page pattern pause plainly poor practice preference privacy print reason referral repeat replace role safety
+sentence severe share sight silent similar state statement stretch tolerable traffic treat update version
+address asleep awake bathroom bedroom breakfast corridor dinner doctor lunch tea
+`;
+
+/**
+ * Split once at module load. Deterministic: a constant string, no I/O.
+ *
+ * The verbs in `THIRD_PERSON_VERBS` are folded in rather than typed out again.
+ * Leaving them out was a real defect on first run: "has" was in the agreement
+ * table and absent from the vocabulary, so every sentence containing it gapped,
+ * and twenty-two of the fixtures' sentences gapped on the commonest verb in
+ * English while the reason said the wording had no everyday equivalent.
+ *
+ * Exported so the guard tests can classify it rather than restate it.
+ */
+export const EVERYDAY_WORDS: ReadonlySet = new Set([
+ ...EVERYDAY_VOCABULARY.trim().split(/\s+/),
+ ...Object.keys(THIRD_PERSON_VERBS),
+ ...Object.values(THIRD_PERSON_VERBS),
+]);
+
+/**
+ * The base forms an inflected word could have come from — plural, past,
+ * progressive, adverb, comparative, superlative.
+ *
+ * This is deliberately a *widening* rule and it is safe to be one, because it
+ * only ever admits an inflection of a word already judged everyday: if "wait" is
+ * plain enough to print, so are "waits", "waited", and "waiting". It cannot
+ * admit a clinical term, because the unconvertible list is checked first, on the
+ * words as written, and a test proves no unconvertible term or its plural
+ * survives this rule.
+ */
+function inflectionBases(word: string): string[] {
+ const bases: string[] = [];
+ if (word.endsWith("ies")) bases.push(`${word.slice(0, -3)}y`);
+ if (word.endsWith("es")) bases.push(word.slice(0, -2));
+ if (word.endsWith("s")) bases.push(word.slice(0, -1));
+ if (word.endsWith("ied")) bases.push(`${word.slice(0, -3)}y`);
+ if (word.endsWith("ed")) bases.push(word.slice(0, -2), word.slice(0, -1));
+ if (/([bdfglmnprt])\1ed$/.test(word)) bases.push(word.slice(0, -3));
+ if (word.endsWith("ing")) bases.push(word.slice(0, -3), `${word.slice(0, -3)}e`);
+ if (/([bdfglmnprt])\1ing$/.test(word)) bases.push(word.slice(0, -4));
+ if (word.endsWith("ly")) bases.push(word.slice(0, -2));
+ if (word.endsWith("er")) bases.push(word.slice(0, -2), word.slice(0, -1));
+ if (word.endsWith("est")) bases.push(word.slice(0, -3), word.slice(0, -2));
+ return bases;
+}
+
+/**
+ * Whether one word may be left on a person's own copy.
+ *
+ * A clinical term is refused outright before anything else is considered.
+ * Without that, the widening rules let one through by the back door:
+ * "mental-state" is two everyday words joined by a hyphen, and the hyphen rule
+ * would have admitted it as ordinary English. The refusal checks run first on
+ * the sentence as written, so nothing was actually reaching a page — but a guard
+ * that can be walked around is not a guard, and the hyphen is not the only way
+ * in.
+ */
+export function isEverydayWord(word: string): boolean {
+ const bare = word.replace(/['’]s$/, "");
+ for (const candidate of [word, bare]) {
+ if (isClinicalTerm(candidate)) return false;
+ if (EVERYDAY_WORDS.has(candidate)) return true;
+ if (inflectionBases(candidate).some((base) => EVERYDAY_WORDS.has(base))) return true;
+ if (candidate.includes("-") && candidate.split("-").every((part) => part !== "" && isEverydaySegment(part))) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function isEverydaySegment(part: string): boolean {
+ if (isClinicalTerm(part)) return false;
+ return EVERYDAY_WORDS.has(part) || inflectionBases(part).some((base) => EVERYDAY_WORDS.has(base));
+}
+
+function isClinicalTerm(word: string): boolean {
+ if (UNCONVERTIBLE_TERM_SET.has(word)) return true;
+ return inflectionBases(word).some((base) => UNCONVERTIBLE_TERM_SET.has(base));
+}
+
+const AGREEMENT_SAFE_S_WORD_SET: ReadonlySet = new Set(AGREEMENT_SAFE_S_WORDS);
+const CLINICAL_NEGATION_SET: ReadonlySet = new Set(CLINICAL_NEGATIONS);
+const UNCONVERTIBLE_TERM_SET: ReadonlySet = new Set(UNCONVERTIBLE_CLINICAL_TERMS);
+
+/**
+ * Third-person pronouns. Substituting one is only safe when the sentence
+ * mentions nobody but the person themselves — see `mentionsAnotherPerson`.
+ */
+const THIRD_PERSON_PRONOUNS: readonly string[] = [
+ "he",
+ "him",
+ "his",
+ "she",
+ "her",
+ "hers",
+ "they",
+ "them",
+ "their",
+ "theirs",
+];
+
+const THIRD_PERSON_PRONOUN_SET: ReadonlySet = new Set(THIRD_PERSON_PRONOUNS);
+
+/**
+ * Words that mean a sentence is about somebody besides the person whose copy
+ * this is. A pronoun in such a sentence cannot be bound to the reader without
+ * guessing which of the two people it meant, and guessing wrong turns "their
+ * presence reads as an accusation" into a sentence accusing the reader.
+ */
+const OTHER_PERSON_WORDS: readonly string[] = [
+ "sister",
+ "brother",
+ "son",
+ "daughter",
+ "mother",
+ "father",
+ "husband",
+ "wife",
+ "partner",
+ "friend",
+ "friends",
+ "family",
+ "families",
+ "carer",
+ "carers",
+ "staff",
+ "clinician",
+ "clinicians",
+ "nurse",
+ "nurses",
+ "doctor",
+ "doctors",
+ "worker",
+ "workers",
+ "member",
+ "members",
+ "coordinator",
+ "team",
+ "teams",
+ "security",
+ "someone",
+ "anyone",
+ "everyone",
+ "people",
+ "person",
+ "child",
+ "children",
+ "adult",
+ "household",
+];
+
+const OTHER_PERSON_WORD_SET: ReadonlySet = new Set(OTHER_PERSON_WORDS);
+
+/**
+ * Verbs a Management Plan sentence opens with when it is an instruction to
+ * staff. A sentence in the imperative is addressed to whoever is reading it, and
+ * on this copy that is the person the plan is about.
+ *
+ * This rule caught the worst line the first run produced. "Say so plainly when
+ * you use it, and offer again to write it together" is sound advice to a
+ * clinician and, printed on somebody's own plan, an instruction to them to
+ * announce that they had refused. Nothing about the words was wrong; the reader
+ * was.
+ */
+const CLINICIAN_IMPERATIVE_OPENERS: readonly string[] = [
+ "introduce",
+ "ask",
+ "tell",
+ "offer",
+ "say",
+ "keep",
+ "check",
+ "sit",
+ "speak",
+ "use",
+ "treat",
+ "record",
+ "confirm",
+ "give",
+ "take",
+ "ensure",
+ "consider",
+ "arrange",
+ "contact",
+ "call",
+ "ring",
+ "write",
+ "explain",
+ "read",
+ "leave",
+ "allow",
+ "let",
+ "start",
+ "stop",
+ "avoid",
+ "deferring",
+ "repeating",
+ "waiting",
+ "being",
+];
+
+const CLINICIAN_IMPERATIVE_OPENER_SET: ReadonlySet = new Set(CLINICIAN_IMPERATIVE_OPENERS);
+
+/**
+ * Words that mean a sentence is talking *about* the person's name rather than
+ * using it to refer to them. Substituting the name in one of these produces "To
+ * be called you" — which is what the first run printed on Evie's copy, under a
+ * heading saying it was what mattered to her.
+ */
+const NAMING_CONTEXT_WORDS: readonly string[] = [
+ "called",
+ "call",
+ "calls",
+ "name",
+ "names",
+ "named",
+ "address",
+ "addressed",
+ "refer",
+ "referred",
+ "referring",
+ "pronounce",
+ "pronounced",
+ "pronoun",
+ "pronouns",
+ "spelt",
+ "spelled",
+];
+
+const NAMING_CONTEXT_WORD_SET: ReadonlySet = new Set(NAMING_CONTEXT_WORDS);
+
+/** Every clinical term the transformation recognises, convertible or not. Used
+ * to decide whether a negation in a sentence is a clinical one. */
+const CLINICAL_TERM_SET: ReadonlySet = new Set([
+ ...UNCONVERTIBLE_CLINICAL_TERMS,
+ ...Object.keys(PLAIN_LANGUAGE_TERMS).flatMap((term) => term.split(/[\s-]+/)),
+]);
+
+/** Dictionary keys, longest first, so a longer phrase is matched before any
+ * shorter phrase inside it. */
+const PLAIN_LANGUAGE_KEYS = Object.keys(PLAIN_LANGUAGE_TERMS).sort((left, right) => right.length - left.length);
+
+// --- Gap reasons -----------------------------------------------------------------
+
+/**
+ * Every reason ends by saying who writes the section instead. A gap is a job for
+ * a person, not an error state, and the sentence a clinician reads should say
+ * what to do about it.
+ */
+export const PATIENT_PLAN_GAP_REASON = {
+ agreedApproach:
+ "What happens when this person comes to the emergency department is never converted automatically, whatever the plan says. It is the part most easily read as a judgement about them, so it is always written by a clinician, with them.",
+ nothingRecorded:
+ "The Management Plan records nothing under this heading, so there is nothing to convert. It needs to be written by a clinician, with this person.",
+ unknownTerm:
+ "This section uses wording the plain-language conversion has no confident everyday equivalent for, so converting it would be a guess. It needs to be written by a clinician, with this person.",
+ clinicalNegation:
+ "This section says what does not happen. A negation that converts only in part reverses its own meaning, so it needs to be written by a clinician, with this person.",
+ personAsSubject:
+ "This section describes this person in the third person, and the conversion cannot put it into their own voice without rewriting it. It needs to be written by a clinician, with them.",
+ ambiguousPronoun:
+ "This section mentions somebody besides this person, so a pronoun here cannot be attached to the reader without guessing which of them it meant. It needs to be written by a clinician, with them.",
+ clinicianInstruction:
+ "This section is written as an instruction to staff. On the person's own copy an instruction is addressed to them, so converting it would hand them a direction that was never meant for them. It needs to be written by a clinician, with them.",
+ namingContext:
+ "This section is about what this person is called, so replacing their name with “you” would take the sentence's whole meaning with it. It needs to be written by a clinician, with them.",
+} as const;
+
+export type PatientPlanGapReasonKey = keyof typeof PATIENT_PLAN_GAP_REASON;
+
+// --- Conversion ------------------------------------------------------------------
+
+export type PatientPlanDraft = {
+ derivedFromManagementVersionId: SyntheticId;
+ sections: readonly PatientPlanSection[];
+ resources: readonly PatientResource[];
+};
+
+/** Tokens the checks look at: words only. Punctuation, numbers, and spacing are
+ * left exactly as written. */
+function wordsOf(text: string): string[] {
+ return (text.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? []).map((word) => word.replace(/^[-'’]+|[-'’]+$/g, ""));
+}
+
+/** Proper nouns: a capitalised word inside a piece of prose already on this
+ * person's record. Lowercased, because the vocabulary check is case-blind. */
+function properNounsOf(text: string): string[] {
+ return (text.match(/\b[A-Z][A-Za-z'’-]*/g) ?? []).map((word) => word.toLowerCase());
+}
+
+/** Sentences, split on terminal punctuation followed by whitespace. Deliberately
+ * simple: this is written prose from a form, not free input. */
+function sentencesOf(text: string): string[] {
+ return text
+ .split(/(?<=[.!?])\s+/)
+ .map((sentence) => sentence.trim())
+ .filter((sentence) => sentence.length > 0);
+}
+
+function escapeForRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/**
+ * The names this person's record already carries: their own names, their home
+ * health service, the support people they have consented to, and the services
+ * named on the resources chosen for them. A capitalised word outside this set is
+ * a name the transformation has never been told about, and it gaps rather than
+ * being printed on somebody's copy unchecked.
+ */
+function permittedNamesFor(
+ version: ManagementPlanVersion,
+ patient: Patient,
+ resources: readonly PatientResource[],
+): ReadonlySet {
+ const sources = [
+ patient.fullName,
+ patient.preferredName,
+ ...patient.aliases,
+ patient.homeHealthService,
+ ...version.consentedSupportPeople,
+ ...resources.map((resource) => resource.name),
+ ];
+ return new Set(sources.flatMap((source) => properNounsOf(source)));
+}
+
+/**
+ * How the person is referred to in the third person, longest name first so a
+ * full name is replaced before the preferred name inside it.
+ *
+ * `ambiguousPronoun` is the word — if any — that is both this person's
+ * possessive and their object pronoun, and so cannot be classified without
+ * guessing. For `she/her` that word is "her": "ring her son" wants "your son",
+ * and "hard for her to follow" wants "you". Substituting one rule everywhere
+ * printed "hard for your to follow" on Mira's copy on the first run. There is no
+ * grammar here to decide between them, so a sentence containing it is refused
+ * rather than mangled.
+ */
+function personReferences(patient: Patient): {
+ possessive: string[];
+ subject: string[];
+ object: string[];
+ ambiguousPronoun: string | null;
+} {
+ const [subjectPronoun = "", objectPronoun = ""] = patient.pronouns.split("/");
+ const possessivePronoun =
+ subjectPronoun === "they" ? "their" : subjectPronoun === "she" ? "her" : subjectPronoun === "he" ? "his" : "";
+ const ambiguous = possessivePronoun !== "" && possessivePronoun === objectPronoun ? possessivePronoun : null;
+ const names = [patient.fullName, ...patient.aliases, patient.preferredName]
+ .filter((name) => name.length > 0)
+ .sort((left, right) => right.length - left.length);
+ return {
+ possessive: [
+ ...names.flatMap((name) => [`${name}'s`, `${name}’s`]),
+ ...(possessivePronoun === "" || ambiguous !== null ? [] : [possessivePronoun]),
+ ],
+ subject: [...names, ...(subjectPronoun === "" ? [] : [subjectPronoun])],
+ object: objectPronoun === "" || ambiguous !== null ? [] : [objectPronoun],
+ ambiguousPronoun: ambiguous,
+ };
+}
+
+/** Whether a sentence names anybody besides the person whose copy this is. */
+function mentionsAnotherPerson(words: readonly string[], ownNames: ReadonlySet): boolean {
+ return words.some((word) => OTHER_PERSON_WORD_SET.has(word) && !ownNames.has(word));
+}
+
+export type ConversionOutcome = { converted: string } | { gapReasonKey: PatientPlanGapReasonKey };
+
+/**
+ * One line of clinical content, converted or refused.
+ *
+ * The order matters. The refusal checks run first, on the text as written, so a
+ * substitution can never hide the thing that should have stopped it. Then the
+ * dictionary decides what the clinical words mean; then the person's own name
+ * and pronouns become "you" and "your", with verb agreement taken from a table;
+ * then every remaining word must be in the dictionary. A failure at any step
+ * refuses the whole line — there is no partial result to be mistaken for a
+ * finished one.
+ */
+function convertLine(
+ line: string,
+ patient: Patient,
+ permittedNames: ReadonlySet,
+ ownNames: ReadonlySet,
+): ConversionOutcome {
+ for (const sentence of sentencesOf(line)) {
+ const words = wordsOf(sentence);
+ const first = words[0];
+
+ if (first !== undefined && CLINICIAN_IMPERATIVE_OPENER_SET.has(first)) {
+ return { gapReasonKey: "clinicianInstruction" };
+ }
+ if (words.some((word) => UNCONVERTIBLE_TERM_SET.has(word))) {
+ return { gapReasonKey: "unknownTerm" };
+ }
+
+ const refersToThisPerson =
+ words.some((word) => ownNames.has(word)) || words.some((word) => THIRD_PERSON_PRONOUN_SET.has(word));
+
+ // "known as" is a naming construction; a bare "known" is not, and treating
+ // it as one refused "Mira is known to the Coastal Plains team" as though it
+ // were a sentence about her name.
+ const namesTheName = words.some((word) => NAMING_CONTEXT_WORD_SET.has(word)) || /\bknown\s+as\b/i.test(sentence);
+ if (refersToThisPerson && namesTheName) {
+ return { gapReasonKey: "namingContext" };
+ }
+ // A negation is refused beside a clinical term, and beside the person
+ // themselves. The second half matters as much as the first: a negated
+ // sentence about somebody, printed on their own copy, is where blame lands.
+ if (
+ (words.some((word) => CLINICAL_TERM_SET.has(word)) || refersToThisPerson) &&
+ words.some((word) => CLINICAL_NEGATION_SET.has(word))
+ ) {
+ return { gapReasonKey: "clinicalNegation" };
+ }
+ if (words.some((word) => THIRD_PERSON_PRONOUN_SET.has(word)) && mentionsAnotherPerson(words, ownNames)) {
+ return { gapReasonKey: "ambiguousPronoun" };
+ }
+ const ambiguous = personReferences(patient).ambiguousPronoun;
+ if (ambiguous !== null && words.includes(ambiguous)) {
+ return { gapReasonKey: "ambiguousPronoun" };
+ }
+ }
+
+ let text = line;
+ for (const term of PLAIN_LANGUAGE_KEYS) {
+ const replacement = PLAIN_LANGUAGE_TERMS[term];
+ if (replacement === undefined) continue;
+ text = text.replace(new RegExp(`\\b${escapeForRegExp(term)}\\b`, "gi"), replacement);
+ }
+
+ const references = personReferences(patient);
+ for (const possessive of references.possessive) {
+ text = text.replace(new RegExp(`\\b${escapeForRegExp(possessive)}\\b`, "gi"), "your");
+ }
+ for (const object of references.object) {
+ text = text.replace(new RegExp(`\\b${escapeForRegExp(object)}\\b`, "gi"), "you");
+ }
+
+ // The subject substitution is the only place agreement can break, so it is the
+ // only place that looks at the word after itself.
+ for (const subject of references.subject) {
+ let refused = false;
+ text = text.replace(
+ new RegExp(`\\b${escapeForRegExp(subject)}\\b(\\s+)([A-Za-z][A-Za-z'’-]*)`, "gi"),
+ (whole, spacing: string, nextWord: string) => {
+ const lowered = nextWord.toLowerCase();
+ const agreed = THIRD_PERSON_VERBS[lowered];
+ if (agreed !== undefined) return `you${spacing}${agreed}`;
+ if (lowered.endsWith("s") && !AGREEMENT_SAFE_S_WORD_SET.has(lowered)) {
+ refused = true;
+ return whole;
+ }
+ return `you${spacing}${nextWord}`;
+ },
+ );
+ if (refused) return { gapReasonKey: "personAsSubject" };
+ text = text.replace(new RegExp(`\\b${escapeForRegExp(subject)}\\b`, "gi"), "you");
+ }
+
+ for (const word of wordsOf(text)) {
+ if (isEverydayWord(word)) continue;
+ if (permittedNames.has(word) || permittedNames.has(word.replace(/['’]s$/, ""))) continue;
+ return { gapReasonKey: "unknownTerm" };
+ }
+
+ return { converted: capitaliseSentences(text) };
+}
+
+/** A substitution can leave a lower-case word at the start of a sentence — "CMHT
+ * is the durable contact" becomes "mental health team is …". Restoring the
+ * capital is the difference between a page written for somebody and a page
+ * obviously produced by a machine. */
+function capitaliseSentences(text: string): string {
+ return text.replace(
+ /(^|[.!?]\s+)([a-z])/g,
+ (_whole, lead: string, letter: string) => `${lead}${letter.toUpperCase()}`,
+ );
+}
+
+/** The lines one heading is built from, in source-field order. `whyThisPlanExists`
+ * is a paragraph rather than a list, so it contributes one line. */
+function sourceLines(content: ManagementPlanContent, key: PatientPlanSectionKey): string[] {
+ return PATIENT_PLAN_SECTION_SOURCES[key].flatMap((field) => {
+ const value = content[field];
+ if (typeof value === "string") return value.trim().length === 0 ? [] : [value];
+ return value.filter((line) => line.trim().length > 0);
+ });
+}
+
+function gapSection(key: PatientPlanSectionKey, reasonKey: PatientPlanGapReasonKey): PatientPlanSection {
+ return {
+ key,
+ heading: PATIENT_PLAN_SECTION_HEADING[key],
+ body: [],
+ gap: true,
+ gapReason: PATIENT_PLAN_GAP_REASON[reasonKey],
+ };
+}
+
+/**
+ * One approved Management Plan Version as a draft patient edition.
+ *
+ * Pure, offline, and deterministic: it reads nothing but its three arguments and
+ * the constants above.
+ */
+/**
+ * A converter bound to one person and one version.
+ *
+ * Exported because each refusal rule deserves to be proved on the sentence that
+ * triggers it rather than through a whole section, where one rule firing hides
+ * every other. It is the same function the draft builder uses — not a second
+ * copy that could drift from it.
+ */
+export function createLineConverter(
+ version: ManagementPlanVersion,
+ patient: Patient,
+ resources: readonly PatientResource[],
+): (line: string) => ConversionOutcome {
+ const forThisPatient = resources.filter((resource) => resource.patientId === patient.id);
+ const permittedNames = permittedNamesFor(version, patient, forThisPatient);
+ const ownNames = new Set(
+ [patient.fullName, patient.preferredName, ...patient.aliases].flatMap((name) => wordsOf(name)),
+ );
+ return (line: string) => convertLine(line, patient, permittedNames, ownNames);
+}
+
+export function buildPatientPlanDraft(
+ version: ManagementPlanVersion,
+ patient: Patient,
+ resources: readonly PatientResource[],
+): PatientPlanDraft {
+ const forThisPatient = resources.filter((resource) => resource.patientId === patient.id);
+ const convert = createLineConverter(version, patient, resources);
+
+ const sections = PATIENT_PLAN_SECTION_KEYS.map((key): PatientPlanSection => {
+ if ((NEVER_CONVERTED_SECTION_KEYS as readonly PatientPlanSectionKey[]).includes(key)) {
+ return gapSection(key, "agreedApproach");
+ }
+
+ const lines = sourceLines(version.content, key);
+ if (lines.length === 0) return gapSection(key, "nothingRecorded");
+
+ const body: string[] = [];
+ for (const line of lines) {
+ const outcome = convert(line);
+ if ("gapReasonKey" in outcome) return gapSection(key, outcome.gapReasonKey);
+ body.push(outcome.converted);
+ }
+
+ return { key, heading: PATIENT_PLAN_SECTION_HEADING[key], body, gap: false, gapReason: null };
+ });
+
+ return { derivedFromManagementVersionId: version.id, sections, resources: forThisPatient };
+}
+
+/** The gaps still to be filled. Approval is refused while any remains: an
+ * unfilled gap prints as a heading with nothing under it. */
+export function unfilledGapSections(sections: readonly PatientPlanSection[]): readonly PatientPlanSection[] {
+ return sections.filter((section) => section.gap);
+}
diff --git a/src/components/care-plan/mockups/patient-workspace.tsx b/src/components/care-plan/mockups/patient-workspace.tsx
index c7d7a89ab..62371a5bb 100644
--- a/src/components/care-plan/mockups/patient-workspace.tsx
+++ b/src/components/care-plan/mockups/patient-workspace.tsx
@@ -48,6 +48,7 @@ export type PatientWorkspaceProps = {
activeSection: PatientSectionKey | null;
reviewsHref: string;
onRecordContactIntent: (contact: CmhtContact, channel: "email" | "call") => void;
+ contactActionBlockedReason: string | null;
/** Home renders the workspace beside the directory and offers the full record. */
showFullRecordLink?: boolean;
/**
@@ -76,6 +77,7 @@ export function PatientWorkspace({
activeSection,
reviewsHref,
onRecordContactIntent,
+ contactActionBlockedReason,
showFullRecordLink = false,
ref,
}: PatientWorkspaceProps) {
@@ -225,6 +227,7 @@ export function PatientWorkspace({
scenario={scenario}
reviewsHref={reviewsHref}
onIntent={(channel) => onRecordContactIntent(cmht, channel)}
+ blockedReason={contactActionBlockedReason}
/>
)}
>
diff --git a/src/components/care-plan/mockups/prototype-state.ts b/src/components/care-plan/mockups/prototype-state.ts
index 3ee49f2b5..a7cfcfd8e 100644
--- a/src/components/care-plan/mockups/prototype-state.ts
+++ b/src/components/care-plan/mockups/prototype-state.ts
@@ -21,10 +21,14 @@ import {
assertSingleCurrentVersion,
canPerformAction,
getCurrentManagementPlanVersion,
+ getCurrentPatientPlanVersion,
getCurrentSafetyPlanVersion,
getEffectivePresentationValue,
getOpenManagementDraft,
+ getOpenPatientPlanDraft,
} from "./domain";
+import { getPatientResources, syntheticPatientResources } from "./patient-plan-fixtures";
+import { buildPatientPlanDraft, unfilledGapSections } from "./patient-plan-transform";
import {
PROTOTYPE_NOW,
identificationPolicy,
@@ -60,6 +64,9 @@ import {
type ManagementPlanContent,
type ManagementPlanVersion,
type Patient,
+ type PatientPlan,
+ type PatientPlanVersion,
+ type PatientResource,
type PersonalSafetyPlan,
type PersonalSafetyPlanVersion,
type PlanAvailability,
@@ -162,8 +169,11 @@ const SCENARIO_PATIENT: Record = {
* flags from the scenario without reading a browser API.
*
* The Patient Plan collections start empty. A Patient Plan is produced from an
- * approved Management Plan Version by a later task; inventing fixture editions
- * of it here would put patient-facing wording on the record that nobody wrote.
+ * approved Management Plan Version when somebody makes one; inventing fixture
+ * editions here would put patient-facing wording on the record that nobody
+ * wrote. The resource catalogue is not an edition of anything, so it is seeded:
+ * it is the list a clinician chooses from, and an empty one would mean every
+ * patient copy started with nothing to offer.
*/
export function createInitialPrototypeState(scenario: PrototypeScenario = "normal"): CarePlanPrototypeState {
return {
@@ -185,7 +195,7 @@ export function createInitialPrototypeState(scenario: PrototypeScenario = "norma
personalSafetyPlanVersions: cloneFixtures(syntheticPersonalSafetyPlanVersions),
patientPlans: [],
patientPlanVersions: [],
- patientResources: [],
+ patientResources: cloneFixtures(syntheticPatientResources),
edPresentations: cloneFixtures(syntheticEdPresentations),
presentationAmendments: cloneFixtures(syntheticPresentationAmendments),
reviewTriggers: cloneFixtures(syntheticReviewTriggers),
@@ -231,6 +241,14 @@ const CAPABILITY_BY_ACTION: Record id === planId)?.patientId ?? null;
}
+function patientOfPatientPlan(state: CarePlanPrototypeState, planId: SyntheticId): SyntheticId | null {
+ return state.patientPlans.find(({ id }) => id === planId)?.patientId ?? null;
+}
+
+/**
+ * The person's Patient Plan record, or a new empty one.
+ *
+ * Unlike the Management Plan and the Personal Safety Plan, a Patient Plan record
+ * is not seeded per patient in the fixtures. Inventing fixture editions would
+ * have put patient-facing wording on the record that nobody wrote, so the plan
+ * record comes into existence the first time somebody actually makes a copy.
+ * The caller adds it to the state; this only decides its identity.
+ */
+function findOrCreatePatientPlan(state: CarePlanPrototypeState, patient: Patient): PatientPlan {
+ const existing = state.patientPlans.find((plan) => plan.patientId === patient.id) ?? null;
+ if (existing !== null) return existing;
+ return {
+ id: nextSyntheticId(
+ "SYN-PATIENT-PLAN",
+ state.patientPlans.map(({ id }) => id),
+ ),
+ patientId: patient.id,
+ versionIds: [],
+ currentVersionId: null,
+ };
+}
+
function nextVersionNumber(versions: readonly { planId: SyntheticId; version: number }[], planId: SyntheticId): number {
return (
versions
@@ -824,14 +870,54 @@ export function prototypeReducer(
}
: null;
+ /**
+ * The person may already hold a patient copy of the version this one
+ * replaces — possibly on paper, in a bag, at home. Nothing here touches
+ * it: it is not regenerated, hidden, or withdrawn, because the
+ * application cannot reach the sheet and must not claim to have. What it
+ * does instead is put the discrepancy in front of a human, once. The
+ * trigger is deduplicated per plan, so approving three versions in a row
+ * raises one item rather than three.
+ */
+ const patientPlan = state.patientPlans.find((candidate) => candidate.patientId === plan.patientId) ?? null;
+ const currentPatientVersion =
+ patientPlan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, patientPlan.id);
+ const patientCopyNowStale =
+ currentPatientVersion !== null && currentPatientVersion.derivedFromManagementVersionId !== version.id;
+ const staleTriggerAlreadyOpen = state.reviewTriggers.some(
+ (trigger) =>
+ trigger.managementPlanId === plan.id && trigger.source === "patient_plan_stale" && trigger.status === "open",
+ );
+ const staleTrigger: ReviewTrigger | null =
+ patientCopyNowStale && !staleTriggerAlreadyOpen && currentPatientVersion !== null
+ ? {
+ id: nextSyntheticId("SYN-TRIGGER", [
+ ...state.reviewTriggers.map(({ id }) => id),
+ ...(participationTrigger === null ? [] : [participationTrigger.id]),
+ ]),
+ patientId: plan.patientId,
+ managementPlanId: plan.id,
+ source: "patient_plan_stale",
+ sourceId: currentPatientVersion.id,
+ reason: `Patient Plan version ${currentPatientVersion.version} was written from Management Plan version ${version.version - 1} or earlier, and the Current Plan is now version ${version.version}. The person may be holding a printed copy, so it stays readable and unchanged until somebody goes through it with them.`,
+ status: "open",
+ createdAt: prototypeTimestamp(state, 2),
+ resolvedAt: null,
+ resolution: null,
+ }
+ : null;
+
+ const raisedTriggers = [participationTrigger, staleTrigger].filter(
+ (trigger): trigger is ReviewTrigger => trigger !== null,
+ );
+
return {
...state,
managementPlanVersions,
managementPlans: state.managementPlans.map((candidate) =>
candidate.id === plan.id ? { ...candidate, currentVersionId: version.id } : candidate,
),
- reviewTriggers:
- participationTrigger === null ? state.reviewTriggers : [...state.reviewTriggers, participationTrigger],
+ reviewTriggers: [...state.reviewTriggers, ...raisedTriggers],
auditEvents: withAudit(state, {
type: "management_version_approved",
patientId: plan.patientId,
@@ -841,9 +927,11 @@ export function prototypeReducer(
lastOutcome: {
kind: "success",
message:
- participationTrigger === null
+ raisedTriggers.length === 0
? `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}.`
- : `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}. No involvement was recorded for this person, so an open Review Trigger was raised for the team.`,
+ : staleTrigger === null
+ ? `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}. No involvement was recorded for this person, so an open Review Trigger was raised for the team.`
+ : `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}. This person's Patient Plan was written from an earlier version and now needs updating; it stays readable and an open Review Trigger was raised.`,
},
};
}
@@ -865,6 +953,40 @@ export function prototypeReducer(
const withdrawnAt = prototypeTimestamp(state);
const actor = state.users.find(({ id }) => id === state.activeUserId);
+ /**
+ * Withdrawal leaves the plan with no Current version at all — not merely
+ * a different one — and a Current Patient Plan copy derived from what
+ * was just withdrawn is exactly as out of date as one derived from a
+ * superseded version. The same mechanism `approve-management-version`
+ * uses applies here: the copy is never touched, and the discrepancy goes
+ * in front of a human once, deduplicated per plan.
+ */
+ const patientPlan = state.patientPlans.find((candidate) => candidate.patientId === plan.patientId) ?? null;
+ const currentPatientVersion =
+ patientPlan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, patientPlan.id);
+ const staleTriggerAlreadyOpen = state.reviewTriggers.some(
+ (trigger) =>
+ trigger.managementPlanId === plan.id && trigger.source === "patient_plan_stale" && trigger.status === "open",
+ );
+ const staleTrigger: ReviewTrigger | null =
+ currentPatientVersion !== null && !staleTriggerAlreadyOpen
+ ? {
+ id: nextSyntheticId(
+ "SYN-TRIGGER",
+ state.reviewTriggers.map(({ id }) => id),
+ ),
+ patientId: plan.patientId,
+ managementPlanId: plan.id,
+ source: "patient_plan_stale",
+ sourceId: currentPatientVersion.id,
+ reason: `Version ${current.version} was withdrawn and ${patient.preferredName} now has no Current Plan. Patient Plan version ${currentPatientVersion.version} was written from it, so it stays readable and unchanged until somebody goes through it with them.`,
+ status: "open",
+ createdAt: prototypeTimestamp(state, 1),
+ resolvedAt: null,
+ resolution: null,
+ }
+ : null;
+
return {
...state,
managementPlanVersions: state.managementPlanVersions.map((candidate) =>
@@ -882,6 +1004,7 @@ export function prototypeReducer(
managementPlans: state.managementPlans.map((candidate) =>
candidate.id === plan.id ? { ...candidate, currentVersionId: null } : candidate,
),
+ reviewTriggers: staleTrigger === null ? state.reviewTriggers : [...state.reviewTriggers, staleTrigger],
auditEvents: withAudit(state, {
type: "management_version_withdrawn",
patientId: patient.id,
@@ -890,7 +1013,10 @@ export function prototypeReducer(
}),
lastOutcome: {
kind: "success",
- message: `Version ${current.version} withdrawn. ${patient.preferredName} now has no Current Plan, and no earlier version was put back into use.`,
+ message:
+ staleTrigger === null
+ ? `Version ${current.version} withdrawn. ${patient.preferredName} now has no Current Plan, and no earlier version was put back into use.`
+ : `Version ${current.version} withdrawn. ${patient.preferredName} now has no Current Plan, and their Patient Plan stays readable but needs updating; an open Review Trigger was raised.`,
},
};
}
@@ -1134,11 +1260,54 @@ export function prototypeReducer(
amendedAt: prototypeTimestamp(state),
};
+ // The timeline renders an amendment over the original record. Review
+ // triggers must make the same decision from that effective record; using
+ // the stored original here leaves a newly corrected admission or adverse
+ // plan-use answer visible on screen but absent from Reviews.
+ const amendments = [...state.presentationAmendments, amendment];
+ const reviewRelevant = action.field === "planHelpfulness" || action.field === "disposition";
+ const effectivePresentation: EdPresentation = {
+ ...presentation,
+ planHelpfulness: getEffectivePresentationValue(amendments, presentation, "planHelpfulness") as PlanHelpfulness,
+ disposition: getEffectivePresentationValue(amendments, presentation, "disposition") as Disposition,
+ };
+ const patient = findPatient(state, presentation.patientId);
+ const plan = patient === null ? null : findManagementPlan(state, patient);
+ const hasEverHadAVersion =
+ plan !== null && state.managementPlanVersions.some((existing) => existing.planId === plan.id);
+ const candidate = reviewRelevant && hasEverHadAVersion ? reviewTriggerReasonFor(effectivePresentation) : null;
+ const alreadyOpen =
+ plan !== null &&
+ candidate !== null &&
+ state.reviewTriggers.some(
+ (trigger) =>
+ trigger.managementPlanId === plan.id && trigger.source === candidate.source && trigger.status === "open",
+ );
+ const newTrigger: ReviewTrigger | null =
+ plan !== null && candidate !== null && !alreadyOpen
+ ? {
+ id: nextSyntheticId(
+ "SYN-TRIGGER",
+ state.reviewTriggers.map(({ id }) => id),
+ ),
+ patientId: presentation.patientId,
+ managementPlanId: plan.id,
+ source: candidate.source,
+ sourceId: presentation.id,
+ reason: candidate.reason,
+ status: "open",
+ createdAt: prototypeTimestamp(state, 1),
+ resolvedAt: null,
+ resolution: null,
+ }
+ : null;
+
return {
...state,
// The episode is untouched. A correction is appended beside it, with who
// made it, when, what it replaced, and why.
- presentationAmendments: [...state.presentationAmendments, amendment],
+ presentationAmendments: amendments,
+ reviewTriggers: newTrigger === null ? state.reviewTriggers : [...state.reviewTriggers, newTrigger],
auditEvents: withAudit(state, {
type: "presentation_amended",
patientId: presentation.patientId,
@@ -1147,7 +1316,10 @@ export function prototypeReducer(
}),
lastOutcome: {
kind: "success",
- message: "Correction recorded beside the original. The original ED Presentation record is unchanged.",
+ message:
+ newTrigger === null
+ ? "Correction recorded beside the original. The original ED Presentation record is unchanged."
+ : "Correction recorded beside the original. The original ED Presentation record is unchanged, and an open Review Trigger was raised for the team to look at.",
},
};
}
@@ -1293,6 +1465,216 @@ export function prototypeReducer(
};
}
+ case "create-patient-plan-draft": {
+ const patient = findPatient(state, action.patientId);
+ if (patient === null) return refuse(state, "That synthetic patient record does not exist.");
+ const managementPlan = findManagementPlan(state, patient);
+ const currentManagement =
+ managementPlan === null
+ ? null
+ : getCurrentManagementPlanVersion(state.managementPlanVersions, managementPlan.id);
+ // A patient copy of a plan nobody has approved would be a document in
+ // somebody's hands describing care nobody agreed to.
+ if (currentManagement === null) {
+ return refuse(
+ state,
+ `${patient.preferredName} has no Current Plan, so there is nothing to make a patient copy of. A draft or a withdrawn version is not an agreed plan.`,
+ );
+ }
+
+ const plan = findOrCreatePatientPlan(state, patient);
+ if (getOpenPatientPlanDraft(state.patientPlanVersions, plan.id) !== null) {
+ return refuse(
+ state,
+ `A Patient Plan draft for ${patient.preferredName} is already open. Continue that one rather than starting another.`,
+ );
+ }
+
+ const draftContent = buildPatientPlanDraft(
+ currentManagement,
+ patient,
+ getPatientResources(state.patientResources, patient.id),
+ );
+ const draft: PatientPlanVersion = {
+ id: nextSyntheticId(
+ "SYN-PATIENT-PLAN-VERSION",
+ state.patientPlanVersions.map(({ id }) => id),
+ ),
+ planId: plan.id,
+ version: nextVersionNumber(state.patientPlanVersions, plan.id),
+ state: "draft",
+ derivedFromManagementVersionId: currentManagement.id,
+ sections: cloneJson(draftContent.sections),
+ resources: cloneJson(draftContent.resources),
+ approvedBy: null,
+ approvedAt: null,
+ createdAt: prototypeTimestamp(state),
+ };
+
+ const gaps = unfilledGapSections(draft.sections).length;
+
+ return {
+ ...state,
+ patientPlans: state.patientPlans.some(({ id }) => id === plan.id)
+ ? state.patientPlans.map((candidate) =>
+ candidate.id === plan.id ? { ...candidate, versionIds: [...candidate.versionIds, draft.id] } : candidate,
+ )
+ : [...state.patientPlans, { ...plan, versionIds: [draft.id] }],
+ patientPlanVersions: [...state.patientPlanVersions, draft],
+ auditEvents: withAudit(state, {
+ type: "patient_plan_draft_created",
+ patientId: patient.id,
+ objectId: draft.id,
+ evidence: `Patient Plan draft version ${draft.version} produced from Management Plan version ${currentManagement.version} by the offline plain-language conversion. ${gaps} of ${draft.sections.length} sections were left as gaps for a clinician to write. No language model was used.`,
+ }),
+ lastOutcome: {
+ kind: gaps === 0 ? "success" : "info",
+ message:
+ gaps === 0
+ ? `Patient Plan draft version ${draft.version} created. Read it through before approving it.`
+ : `Patient Plan draft version ${draft.version} created, with ${gaps} of ${draft.sections.length} sections left blank for you to write. The conversion refuses to guess, so a gap is a section it would not risk getting wrong.`,
+ },
+ };
+ }
+
+ case "save-patient-plan-draft": {
+ const version = state.patientPlanVersions.find(({ id }) => id === action.versionId) ?? null;
+ if (version === null) return refuse(state, "That Patient Plan Version does not exist.");
+ if (version.state !== "draft") {
+ return refuse(
+ state,
+ `Only a draft can be edited. Patient Plan version ${version.version} is ${version.state}, and a copy the person may already be holding is never rewritten underneath them.`,
+ );
+ }
+
+ const saved: PatientPlanVersion = {
+ ...version,
+ // Replaced whole rather than patched: half a converted sentence and half
+ // a clinician's rewrite in one paragraph is nobody's voice.
+ sections: cloneJson(action.input.sections),
+ resources: cloneJson(action.input.resources),
+ };
+ const remaining = unfilledGapSections(saved.sections).length;
+
+ return {
+ ...state,
+ patientPlanVersions: state.patientPlanVersions.map((candidate) =>
+ candidate.id === saved.id ? saved : candidate,
+ ),
+ auditEvents: withAudit(state, {
+ type: "patient_plan_draft_saved",
+ patientId: patientOfPatientPlan(state, version.planId),
+ objectId: saved.id,
+ evidence: `Patient Plan draft version ${saved.version} saved with ${saved.sections.length} sections and ${saved.resources.length} resources. ${remaining} sections are still gaps.`,
+ }),
+ lastOutcome: {
+ kind: "success",
+ message:
+ remaining === 0
+ ? `Patient Plan draft version ${saved.version} saved. Every section now has something in it.`
+ : `Patient Plan draft version ${saved.version} saved. ${remaining} sections are still blank and it cannot be approved until they are written.`,
+ },
+ };
+ }
+
+ case "approve-patient-plan-version": {
+ const version = state.patientPlanVersions.find(({ id }) => id === action.versionId) ?? null;
+ if (version === null) return refuse(state, "That Patient Plan Version does not exist.");
+ if (version.state !== "draft") {
+ return refuse(
+ state,
+ `Only a draft can be approved. Patient Plan version ${version.version} is ${version.state}.`,
+ );
+ }
+ const plan = state.patientPlans.find(({ id }) => id === version.planId) ?? null;
+ if (plan === null) return refuse(state, "That version has no Patient Plan record.");
+
+ const patient = findPatient(state, plan.patientId);
+ const managementPlan = patient === null ? null : findManagementPlan(state, patient);
+ const currentManagement =
+ managementPlan === null
+ ? null
+ : getCurrentManagementPlanVersion(state.managementPlanVersions, managementPlan.id);
+ if (currentManagement === null || currentManagement.id !== version.derivedFromManagementVersionId) {
+ return refuse(
+ state,
+ `Patient Plan version ${version.version} was made from a Management Plan that is no longer Current, so it cannot be approved. Start a new copy from the agreed plan instead.`,
+ );
+ }
+
+ const approver = state.users.find(({ id }) => id === state.activeUserId) ?? null;
+ if (approver === null || isBlank(approver.displayName)) {
+ return refuse(state, "Approval must name the clinician approving it. Nothing was changed.");
+ }
+
+ /**
+ * The gap block. A gap is a place the conversion refused to guess, and an
+ * unfilled one prints as a heading with nothing under it — handed to the
+ * person it is about. The form makes the control unavailable with this
+ * reason; the reducer is the guard that means it cannot happen anyway.
+ */
+ const gaps = unfilledGapSections(version.sections);
+ if (gaps.length > 0) {
+ return refuse(
+ state,
+ `Patient Plan version ${version.version} cannot be approved while ${gaps.length} ${gaps.length === 1 ? "section is" : "sections are"} still blank: ${gaps.map((section) => section.heading).join("; ")}. A blank heading on a copy handed to somebody reads as though nothing about them was worth writing.`,
+ );
+ }
+
+ const approvedAt = prototypeTimestamp(state);
+
+ return {
+ ...state,
+ patientPlanVersions: state.patientPlanVersions.map((candidate) => {
+ if (candidate.id === version.id) {
+ return { ...candidate, state: "current" as const, approvedBy: approver.id, approvedAt };
+ }
+ if (candidate.planId === version.planId && candidate.state === "current") {
+ return { ...candidate, state: "superseded" as const };
+ }
+ return candidate;
+ }),
+ patientPlans: state.patientPlans.map((candidate) =>
+ candidate.id === plan.id ? { ...candidate, currentVersionId: version.id } : candidate,
+ ),
+ auditEvents: withAudit(state, {
+ type: "patient_plan_approved",
+ patientId: plan.patientId,
+ objectId: version.id,
+ evidence: `Patient Plan version ${version.version} approved by ${approver.displayName} and is now the copy given to this person. Any earlier copy is superseded and stays readable in history.`,
+ }),
+ lastOutcome: {
+ kind: "success",
+ message: `Patient Plan version ${version.version} approved by ${approver.displayName}. It can now be printed and given to this person.`,
+ },
+ };
+ }
+
+ case "record-patient-plan-print-intent": {
+ const patient = findPatient(state, action.patientId);
+ if (patient === null) return refuse(state, "That synthetic patient record does not exist.");
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === patient.id) ?? null;
+ const current = plan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, plan.id);
+ if (current === null) {
+ return refuse(state, `${patient.preferredName} has no approved Patient Plan to print.`);
+ }
+
+ return {
+ ...state,
+ auditEvents: withAudit(state, {
+ type: "patient_plan_print_intent_opened",
+ patientId: patient.id,
+ objectId: current.id,
+ evidence: `The browser print view was opened for Patient Plan version ${current.version}. This records the request only, and is not evidence that anything reached a printer or reached this person.`,
+ }),
+ lastOutcome: {
+ kind: "info",
+ message:
+ "The print view was opened. What happens after that is handled by the browser and is not recorded here.",
+ },
+ };
+ }
+
case "record-safety-plan-print-intent": {
const patient = findPatient(state, action.patientId);
if (patient === null) return refuse(state, "That synthetic patient record does not exist.");
diff --git a/src/components/care-plan/mockups/routable-suite.tsx b/src/components/care-plan/mockups/routable-suite.tsx
index 81cd4d077..5f961ce34 100644
--- a/src/components/care-plan/mockups/routable-suite.tsx
+++ b/src/components/care-plan/mockups/routable-suite.tsx
@@ -10,6 +10,8 @@ import { ManagementPlanFormSurface } from "./management-plan-form";
import { ManagementPlanPrintSurface } from "./management-plan-print";
import { ManagementPlanSurface } from "./management-plan-read";
import { ManagementPlanReviewSurface } from "./management-plan-review";
+import { PatientPlanFormSurface } from "./patient-plan-form";
+import { PatientPlanPrintSurface, PatientPlanSurface } from "./patient-plan-pages";
import { PresentationFormSurface } from "./presentation-form";
import {
PresentationDetailSurface,
@@ -290,8 +292,10 @@ export function carePlanPatientIdFromPathname(pathname: string): string | null {
* surface. Task 7 added the three ED Presentation routes — the timeline, the
* recording form, and one episode with its corrections. Task 8 added the three
* Personal Safety Plan routes — the person's own document, the form that writes
- * it, and the patient copy that prints. Every remaining route still renders a
- * specimen.
+ * it, and the patient copy that prints. Task 9 added the three Patient Plan
+ * routes — the person's own edition of the Management Plan, the form that fills
+ * the gaps the conversion refused to guess at, and the copy that prints. Every
+ * remaining route still renders a specimen.
*/
const SNAPSHOT_VARIANT_BY_ROUTE_KEY: Partial> = {
home: "home",
@@ -401,6 +405,12 @@ export function CarePlanRouteSurface({ pathname, query = "", navigate }: CarePla
presentationId={presentationIdFromPathname(pathname)}
scenario={scenario}
/>
+ ) : route.key === ROUTE_DEFINITIONS.patientPlan.key ? (
+
+ ) : route.key === ROUTE_DEFINITIONS.patientPlanEdit.key ? (
+
+ ) : route.key === ROUTE_DEFINITIONS.patientPlanPrint.key ? (
+
) : route.key === ROUTE_DEFINITIONS.safetyPlan.key ? (
) : route.key === ROUTE_DEFINITIONS.safetyPlanEdit.key ? (
diff --git a/src/components/care-plan/mockups/routes.ts b/src/components/care-plan/mockups/routes.ts
index a7797a264..e653e13c7 100644
--- a/src/components/care-plan/mockups/routes.ts
+++ b/src/components/care-plan/mockups/routes.ts
@@ -80,6 +80,15 @@ export const carePlanRoute = {
patientPlan(patientId: string) {
return `${patientPath(patientId)}/patient-plan`;
},
+ /** Where the person's own copy is written and its gaps are filled. Any
+ * clinical role may approve it, so this is the only authoring address the
+ * Patient Plan has. */
+ patientPlanEdit(patientId: string) {
+ return `${patientPath(patientId)}/patient-plan/edit`;
+ },
+ patientPlanPrint(patientId: string) {
+ return `${patientPath(patientId)}/patient-plan/print`;
+ },
safetyPlan(patientId: string) {
return `${patientPath(patientId)}/safety-plan`;
},
diff --git a/src/components/care-plan/mockups/types.ts b/src/components/care-plan/mockups/types.ts
index 17d8b7843..466375d70 100644
--- a/src/components/care-plan/mockups/types.ts
+++ b/src/components/care-plan/mockups/types.ts
@@ -205,11 +205,20 @@ export type PatientResourceCategory =
export type PatientResource = {
id: SyntheticId;
+ /** Resources are chosen for one person, not offered from a single national
+ * list: a housing service in one catchment is no use in another, and a
+ * resource sheet that cannot be wrong for somebody is not a resource sheet.
+ * So every entry names the person it was chosen for. */
+ patientId: SyntheticId;
category: PatientResourceCategory;
name: string;
detail: string;
contact: string | null;
sourceUrl: string | null;
+ /** True only for the verified public crisis lines. Everything else in the
+ * prototype is invented, and a printed sheet must be able to say which is
+ * which rather than leaving a reader to dial a fictional number. */
+ isRealContact: boolean;
};
export type PatientPlan = {
@@ -345,7 +354,14 @@ export type ReviewTrigger = {
* participation, so involving the person stays on somebody's list. The
* persistent on-screen marker alone is not enough: a marker is read only
* by whoever opens that plan, while a trigger reaches the Reviews queue. */
- | "participation";
+ | "participation"
+ /** Raised when a newer Management Plan Version is approved while the person
+ * holds a Current Patient Plan derived from the version it replaced. The
+ * patient copy is never regenerated or withdrawn automatically — the person
+ * may be holding it on paper — so the discrepancy has to reach a human
+ * instead. Deduplicated per plan: one open trigger, however many versions
+ * are approved before somebody acts on it. */
+ | "patient_plan_stale";
sourceId: SyntheticId;
reason: string;
status: "open" | "resolved";
@@ -456,6 +472,17 @@ export type SafetyPlanDraftInput = {
content: SafetyPlanContent;
};
+/**
+ * What a clinician's edit of a patient copy replaces. Sections and resources are
+ * replaced whole rather than patched: a partial update of a document written in
+ * somebody's own voice is how half a converted sentence and half a clinician's
+ * rewrite end up in one paragraph.
+ */
+export type PatientPlanDraftInput = {
+ sections: readonly PatientPlanSection[];
+ resources: readonly PatientResource[];
+};
+
export type NewEdPresentationInput = Omit;
/**
@@ -544,8 +571,8 @@ export type PrototypeCapability =
* would only be a dead branch in an otherwise exhaustive switch, and a dead
* branch is where an unenforced transition hides.
*
- * Deferred to their own tasks, deliberately absent here: the four Patient Plan
- * actions.
+ * The four Patient Plan actions joined in Task 9, which is the task that
+ * implements them. Nothing else is outstanding.
*/
export type CarePlanPrototypeAction =
| { type: "select-patient"; patientId: SyntheticId }
@@ -576,6 +603,18 @@ export type CarePlanPrototypeAction =
| { type: "create-safety-plan-draft"; patientId: SyntheticId }
| { type: "save-safety-plan-draft"; versionId: SyntheticId; input: SafetyPlanDraftInput }
| { type: "make-safety-plan-current"; versionId: SyntheticId }
+ /** Derives a patient edition from the Current Management Plan Version. It
+ * refuses when there is none: a patient copy of a plan nobody has approved
+ * would be a document in somebody's hands describing care nobody agreed. */
+ | { type: "create-patient-plan-draft"; patientId: SyntheticId }
+ | { type: "save-patient-plan-draft"; versionId: SyntheticId; input: PatientPlanDraftInput }
+ /** Any clinical role may approve a patient copy. Requiring a senior clinician
+ * would mean a person waits days for their own copy of their own plan, which
+ * defeats the point of giving them one. What approval does require is that
+ * every flagged gap has been filled: a gap is a place the transformation
+ * refused to guess, and an unfilled one would print as a blank heading. */
+ | { type: "approve-patient-plan-version"; versionId: SyntheticId }
+ | { type: "record-patient-plan-print-intent"; patientId: SyntheticId }
| { type: "record-safety-plan-print-intent"; patientId: SyntheticId }
| { type: "record-management-plan-print-intent"; patientId: SyntheticId }
| { type: "record-contact-intent"; patientId: SyntheticId; cmhtId: SyntheticId; channel: "email" | "call" }
diff --git a/tests/care-plan-linked-routes.dom.test.tsx b/tests/care-plan-linked-routes.dom.test.tsx
index 5cf0e0d61..cb2bff75e 100644
--- a/tests/care-plan-linked-routes.dom.test.tsx
+++ b/tests/care-plan-linked-routes.dom.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, render, screen, within } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -308,21 +308,6 @@ describe("Care Plan route shell", () => {
// block below asserts that none of those thirteen renders a purpose surface
// at all.
it.each([
- [
- CARE_PLAN_ROUTES.patientPlan,
- "Patient Plan",
- "The patient-facing edition of the Management Plan, with its own version and approval state",
- ],
- [
- CARE_PLAN_ROUTES.patientPlanEdit,
- "Draft Patient Plan",
- "Create the patient edition from the Current Plan, fill its flagged gaps, and approve it",
- ],
- [
- CARE_PLAN_ROUTES.patientPlanPrint,
- "Print Patient Plan",
- "Print-optimised patient copy, including their resources",
- ],
[
CARE_PLAN_ROUTES.history,
"History",
@@ -347,30 +332,15 @@ describe("Care Plan route shell", () => {
expect(within(surface).getByText(purpose)).toBeInTheDocument();
});
- it("hands the submitted search to Patients without putting record content in the URL", async () => {
+ it("offers exactly one search slot and navigates it without putting record content in the URL", async () => {
const user = userEvent.setup();
- const navigate = vi.fn();
- const route = render(
-
-
- ,
- );
+ const navigate = renderRoute(CARE_PLAN_ROUTES.reviews);
const search = screen.getByRole("searchbox", { name: "Search patients" });
expect(screen.getAllByRole("searchbox")).toHaveLength(1);
await user.type(search, "Rowan");
await user.click(screen.getByRole("button", { name: "Search patients" }));
expect(navigate).toHaveBeenCalledWith(CARE_PLAN_ROUTES.patients);
expect(navigate.mock.calls.every(([href]) => !String(href).includes("Rowan"))).toBe(true);
-
- route.rerender(
-
-
- ,
- );
- expect(screen.getByRole("searchbox", { name: "Search synthetic patients" })).toHaveValue("Rowan");
- const results = screen.getByTestId("care-plan-directory-results");
- expect(within(results).getByRole("button", { name: "Open Rowan Sample" })).toBeInTheDocument();
- expect(within(results).queryAllByRole("button", { name: /^Open / })).toHaveLength(1);
});
it("reads the named specimen scenario from the URL and nothing else", () => {
@@ -410,6 +380,9 @@ describe("Care Plan route shell", () => {
CARE_PLAN_ROUTES.safetyPlan,
CARE_PLAN_ROUTES.safetyPlanEdit,
CARE_PLAN_ROUTES.safetyPlanPrint,
+ CARE_PLAN_ROUTES.patientPlan,
+ CARE_PLAN_ROUTES.patientPlanEdit,
+ CARE_PLAN_ROUTES.patientPlanPrint,
]) {
const { unmount } = render(
@@ -898,6 +871,7 @@ describe("Care Plan review currency with no review date", () => {
activeSection={null}
reviewsHref={CARE_PLAN_ROUTES.reviews}
onRecordContactIntent={() => {}}
+ contactActionBlockedReason={null}
/>,
);
}
@@ -976,6 +950,19 @@ describe("Care Plan CMHT contact actions", () => {
}
});
+ it.each(["permission-unavailable", "version-conflict"])(
+ "prevents an unrecordable contact launch when %s blocks its audit intent",
+ (scenario) => {
+ renderRoute(CARE_PLAN_ROUTES.patient, `scenario=${scenario}`);
+ const email = screen.getByRole("link", { name: "Email North River CMHT" });
+
+ expect(email).toHaveAttribute("aria-disabled", "true");
+ expect(fireEvent.click(email)).toBe(false);
+ expect(screen.getByRole("alert")).toHaveTextContent(/nothing was changed|newer version/i);
+ expect(screen.queryByTestId("care-plan-outcome")).toBeNull();
+ },
+ );
+
it("keeps unverified contact details visible with a warning, last-verified date and a Reviews link", () => {
renderRoute(carePlanRoute.patient("SYN-PATIENT-003"));
const contacts = screen.getByRole("region", { name: "Community mental health team" });
@@ -1004,6 +991,11 @@ describe("Care Plan CMHT contact actions", () => {
const contacts = screen.getByRole("region", { name: "Community mental health team" });
expect(within(contacts).getByRole("link", { name: "Call North River CMHT" })).toBeInTheDocument();
});
+
+ // The guarded-launch behaviour itself (aria-disabled, preventDefault, no
+ // audit outcome) is covered by "prevents an unrecordable contact launch
+ // when %s blocks its audit intent" above, across both scenarios that can
+ // block record-contact-intent.
});
describe("Care Plan Management Plan reading", () => {
@@ -3198,3 +3190,228 @@ describe("Care Plan error boundary", () => {
expect(screen.queryByRole("heading", { level: 1, name: "Care Plan could not be displayed" })).toBeNull();
});
});
+
+/**
+ * The Patient Plan, driven through the interface.
+ *
+ * These render one provider and move between addresses inside it, because a
+ * patient copy only exists after somebody makes one: a fresh provider per route
+ * would put the print route in front of a world where nothing had been written.
+ */
+describe("Care Plan Patient Plan", () => {
+ /** The eight headings, written out here rather than imported. A content check
+ * that reads the constant its subject renders from can never disagree with
+ * it. */
+ const PATIENT_PLAN_HEADINGS = [
+ "Why we wrote this together",
+ "What matters to you",
+ "What helps you",
+ "What makes things harder",
+ "What we agreed will happen when you come to the emergency department",
+ "If something new is happening",
+ "Who's involved in your care",
+ "Things that might help",
+ ];
+
+ function renderJourney(pathname: string) {
+ const navigate = vi.fn();
+ const view = (at: string) => (
+
+
+
+ );
+ const { rerender } = render(view(pathname));
+ return { navigate, goTo: (at: string) => rerender(view(at)) };
+ }
+
+ async function createDraft(user: ReturnType) {
+ await user.click(screen.getByRole("button", { name: "Create the patient copy" }));
+ }
+
+ /**
+ * Pasted rather than typed. Typing this into eight textareas is ~250
+ * simulated keystrokes, which took long enough to push neighbouring tests
+ * past their timeout — the suite failed on a different pair of tests on each
+ * run, which is a flake rather than a finding.
+ */
+ async function fillEverySection(user: ReturnType) {
+ for (const field of screen.getAllByRole("textbox")) {
+ await user.clear(field);
+ await user.click(field);
+ await user.paste("Written with you at the bedside.");
+ }
+ }
+
+ it("offers no copy until one is made, and then shows what the conversion refused to guess", async () => {
+ const user = userEvent.setup();
+ renderJourney(carePlanRoute.patientPlan("SYN-PATIENT-001"));
+
+ expect(screen.getByTestId("care-plan-patient-plan-no-current")).toHaveTextContent(/no approved Patient Plan/i);
+ await createDraft(user);
+
+ const draftNotice = screen.getByTestId("care-plan-patient-plan-draft-notice");
+ expect(draftNotice).toHaveTextContent(/sections still to write/i);
+ });
+
+ it("states why each blank section is blank, in words a clinician can act on", async () => {
+ const user = userEvent.setup();
+ renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+
+ const agreed = screen.getByTestId("care-plan-patient-plan-form-gap-whatWeAgreedWillHappen");
+ expect(agreed).toHaveTextContent(/never converted automatically/i);
+ expect(agreed).toHaveTextContent(/written by a clinician/i);
+ });
+
+ /**
+ * The control is unavailable for a stated reason, so it keeps its tab stop and
+ * the reason can actually be reached. `aria-disabled` and native `disabled`
+ * together fail lint; native `disabled` alone would take the reason with it.
+ */
+ it("makes approval unavailable with a stated reason while any section is blank", async () => {
+ const user = userEvent.setup();
+ renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+
+ const approve = screen.getByRole("button", { name: "Approve patient copy" });
+ expect(approve).toHaveAttribute("aria-disabled", "true");
+ expect(approve).not.toHaveAttribute("disabled");
+ const reason = screen.getByTestId("care-plan-patient-plan-approve-unavailable");
+ expect(reason).toHaveTextContent(/cannot be approved while/i);
+ expect(approve.getAttribute("aria-describedby")).toBe(reason.id);
+
+ await user.click(approve);
+ expect(screen.getByRole("button", { name: "Approve patient copy" })).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("approves once every section is written, and shows the eight headings in order", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+
+ goTo(carePlanRoute.patientPlan("SYN-PATIENT-001"));
+ expect(
+ within(screen.getByTestId("care-plan-patient-plan-sections"))
+ .getAllByRole("heading", { level: 3 })
+ .map((node) => node.textContent),
+ ).toEqual(PATIENT_PLAN_HEADINGS);
+ });
+
+ /**
+ * The print hazard that has now bitten twice. `globals.css` makes everything
+ * outside `[data-print-output]` invisible when printing, so the shell's
+ * synthetic marker cannot reach the paper. The printed subtree must carry its
+ * own, and the assertion is against the marker's *ancestors* — checking only
+ * that the element itself lacks the attribute would have missed the original
+ * defect, which was inherited.
+ */
+ it("carries the synthetic watermark inside the printed subtree, under no hidden ancestor", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+ goTo(carePlanRoute.patientPlanPrint("SYN-PATIENT-001"));
+
+ const paper = screen.getByTestId("care-plan-patient-plan-print-output");
+ const marker = within(paper).getByText("Synthetic prototype — fictional people, teams, and hospitals");
+ expect(marker.closest("[data-print-output]")).toBe(paper);
+ expect(marker.closest("[data-print-hide='true']")).toBeNull();
+ expect(paper.closest("[data-print-hide='true']")).toBeNull();
+ expect(paper.querySelector("[data-print-confidential]")).toHaveTextContent(/Confidential clinical document/i);
+ // Deterministic: derived from PROTOTYPE_NOW, never a wall clock.
+ expect(paper.querySelector("[data-print-stamp]")).toHaveTextContent("20/08/2026");
+ expect(paper.querySelector("[data-print-stamp]")?.closest("[data-print-hide='true']")).toBeNull();
+ });
+
+ it("prints the preferred name and record number, and no navigation, audit, or plan metadata", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+ goTo(carePlanRoute.patientPlanPrint("SYN-PATIENT-001"));
+
+ const paper = screen.getByTestId("care-plan-patient-plan-print-output");
+ const text = paper.textContent ?? "";
+ expect(text).toContain("Rowan");
+ expect(text).toContain("SYN-MRN-0001");
+ expect(text).not.toContain("Rowan Sample");
+ expect(text).not.toMatch(/12\/04\/1986|1986-04-12|date of birth/i);
+ expect(text).not.toContain("they/them");
+ expect(text).not.toContain("North River Health Service");
+ expect(text).not.toMatch(/audit|Review Trigger|Superseded|plan owner|Awaiting Approval|revision reason/i);
+ expect(text).not.toMatch(/Not recorded/);
+ /*
+ * Clinical vocabulary is checked on the plan's own eight sections rather
+ * than on the whole sheet. The crisis block carries MHERL's and Rurallink's
+ * own published wording — each "is a telephone triage and support line. It
+ * is not an emergency service" — and paraphrasing a real service's safety
+ * caveat to satisfy a house style would be a worse error than the one this
+ * guards against.
+ */
+ const sections = screen.getByTestId("care-plan-patient-plan-sections").textContent ?? "";
+ expect(sections).not.toMatch(/ED Presentation|disposition|mental[- ]state|risk assessment|triage/i);
+ expect(within(paper).queryAllByRole("button")).toEqual([]);
+ expect(within(paper).queryAllByRole("navigation")).toEqual([]);
+ });
+
+ it("prints the real crisis lines with their caveats and official sources, and nothing fictional beside them", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+ goTo(carePlanRoute.patientPlanPrint("SYN-PATIENT-001"));
+
+ const resources = screen.getByTestId("care-plan-patient-plan-resources");
+ for (const contact of publicCrisisContacts) {
+ expect(resources, `${contact.name} is missing`).toHaveTextContent(contact.telephoneDisplay);
+ expect(
+ resources.querySelector(`a[href="${contact.sourceUrl}"]`),
+ `${contact.name} is printed with no official source`,
+ ).not.toBeNull();
+ if (contact.caveat !== null) expect(resources).toHaveTextContent(contact.caveat);
+ }
+ expect(resources).toHaveTextContent(/not an emergency service/i);
+ });
+
+ /**
+ * A copy the person may be holding is never regenerated, hidden, or
+ * withdrawn. It says it needs updating, and stays completely readable.
+ */
+ it("marks a copy as needing updating without taking any of it away", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-002"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+
+ // Approve the Management Plan version already awaiting approval, which moves
+ // the plan on underneath the copy just approved.
+ goTo(carePlanRoute.managementPlanReview("SYN-PATIENT-002"));
+ await signInAs(user, "SYN-USER-SENIOR-001");
+ await user.click(screen.getByRole("button", { name: /^Approve version/ }));
+ await user.click(screen.getByRole("button", { name: "Approve and make Current" }));
+
+ goTo(carePlanRoute.patientPlan("SYN-PATIENT-002"));
+ expect(screen.getByTestId("care-plan-patient-plan-stale")).toHaveTextContent(/needs updating/i);
+ // Every heading is still there, and nothing was withdrawn.
+ expect(
+ within(screen.getByTestId("care-plan-patient-plan-sections")).getAllByRole("heading", { level: 3 }),
+ ).toHaveLength(8);
+ expect(screen.queryByTestId("care-plan-patient-plan-no-current")).toBeNull();
+
+ // The same currency warning shown on screen must reach the printed sheet.
+ // The screen-only StaleNotice above sits outside `[data-print-output]`, so
+ // if the print surface carries no warning of its own a clinician can print
+ // and hand over an earlier-version copy whose paper looks current.
+ goTo(carePlanRoute.patientPlanPrint("SYN-PATIENT-002"));
+ const paper = screen.getByTestId("care-plan-patient-plan-print-output");
+ const staleOnPaper = within(paper).getByTestId("care-plan-patient-plan-print-stale");
+ expect(staleOnPaper).toHaveTextContent(/needs updating/i);
+ expect(staleOnPaper.closest("[data-print-hide='true']")).toBeNull();
+ });
+});
diff --git a/tests/care-plan-patient-plan.test.ts b/tests/care-plan-patient-plan.test.ts
new file mode 100644
index 000000000..edb907458
--- /dev/null
+++ b/tests/care-plan-patient-plan.test.ts
@@ -0,0 +1,1199 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ canPerformAction,
+ getCurrentManagementPlanVersion,
+ getCurrentPatientPlanVersion,
+ getOpenPatientPlanDraft,
+ isPatientPlanVersionStale,
+} from "@/components/care-plan/mockups/domain";
+import { publicCrisisContacts, syntheticPatients } from "@/components/care-plan/mockups/fixtures";
+import {
+ PATIENT_RESOURCE_CATEGORY_ORDER,
+ getPatientResources,
+ groupPatientResources,
+ syntheticPatientResources,
+} from "@/components/care-plan/mockups/patient-plan-fixtures";
+import {
+ AGREEMENT_SAFE_S_WORDS,
+ EVERYDAY_WORDS,
+ PATIENT_PLAN_GAP_REASON,
+ PATIENT_PLAN_OMITTED_CONTENT_KEYS,
+ PATIENT_PLAN_SECTION_HEADING,
+ PATIENT_PLAN_SECTION_LEAD_IN,
+ PATIENT_PLAN_SECTION_SOURCES,
+ PLAIN_LANGUAGE_TERMS,
+ THIRD_PERSON_VERBS,
+ UNCONVERTIBLE_CLINICAL_TERMS,
+ buildPatientPlanDraft,
+ createLineConverter,
+ isEverydayWord,
+ unfilledGapSections,
+} from "@/components/care-plan/mockups/patient-plan-transform";
+import { createInitialPrototypeState, prototypeReducer } from "@/components/care-plan/mockups/prototype-state";
+import {
+ PATIENT_PLAN_SECTION_KEYS,
+ type CarePlanPrototypeAction,
+ type CarePlanPrototypeState,
+ type ManagementPlanContent,
+ type ManagementPlanVersion,
+ type Patient,
+ type PatientPlanSection,
+ type PrototypeRole,
+ type SyntheticId,
+} from "@/components/care-plan/mockups/types";
+
+/**
+ * The Patient Plan: the deterministic transformation, the lifecycle around it,
+ * and — the part none of the other gates can see — whether the page it produces
+ * is kind to read.
+ *
+ * The content checks below spell their forbidden phrasing out literally rather
+ * than reading it back from the constant the code renders from. A generative
+ * assertion can never disagree with what it checks: two of Task 8's controls
+ * survived a deliberate break because they compared rendered text against the
+ * very constant that produced it. Structural checks may read from the domain;
+ * content checks may not.
+ */
+
+const ROWAN: SyntheticId = "SYN-PATIENT-001";
+const MIRA: SyntheticId = "SYN-PATIENT-002";
+const JORDAN: SyntheticId = "SYN-PATIENT-003";
+
+function patientBy(id: SyntheticId): Patient {
+ const patient = syntheticPatients.find((candidate) => candidate.id === id);
+ if (patient === undefined) throw new Error(`no synthetic patient ${id}`);
+ return patient;
+}
+
+/**
+ * The Current Management Plan Version for one patient.
+ *
+ * The brief's worked example called `getCurrentManagementPlanVersion(state, patientId)`.
+ * That is not the selector's signature — it takes a version list and a *plan*
+ * identifier — so the lookup goes through the patient's `managementPlanId`.
+ */
+function currentVersionFor(patientId: SyntheticId, state = createInitialPrototypeState()): ManagementPlanVersion {
+ const version = getCurrentManagementPlanVersion(state.managementPlanVersions, patientBy(patientId).managementPlanId);
+ if (version === null) throw new Error(`no current management version for ${patientId}`);
+ return version;
+}
+
+function draftFor(patientId: SyntheticId) {
+ return buildPatientPlanDraft(currentVersionFor(patientId), patientBy(patientId), syntheticPatientResources);
+}
+
+function converterFor(patientId: SyntheticId) {
+ return createLineConverter(currentVersionFor(patientId), patientBy(patientId), syntheticPatientResources);
+}
+
+/** A version whose content is whatever the caller wants, so a rule can be
+ * proved on the sentence that triggers it rather than on whichever fixture
+ * happens to contain one. */
+function versionWithContent(content: Partial): ManagementPlanVersion {
+ return {
+ ...currentVersionFor(ROWAN),
+ content: { ...currentVersionFor(ROWAN).content, ...content },
+ };
+}
+
+function run(state: CarePlanPrototypeState, ...actions: CarePlanPrototypeAction[]): CarePlanPrototypeState {
+ return actions.reduce(prototypeReducer, state);
+}
+
+function asUser(state: CarePlanPrototypeState, role: PrototypeRole): CarePlanPrototypeState {
+ const user = state.users.find((candidate) => candidate.role === role);
+ if (user === undefined) throw new Error(`no synthetic user with role ${role}`);
+ return prototypeReducer(state, { type: "set-active-user", userId: user.id });
+}
+
+/** The whole world one patient copy is built in: a draft, open and editable. */
+function stateWithDraft(patientId: SyntheticId = ROWAN, role: PrototypeRole = "liaison_clinician") {
+ const state = run(asUser(createInitialPrototypeState(), role), {
+ type: "create-patient-plan-draft",
+ patientId,
+ });
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === patientId);
+ if (plan === undefined) throw new Error("no patient plan record was created");
+ const draft = getOpenPatientPlanDraft(state.patientPlanVersions, plan.id);
+ if (draft === null) throw new Error("no patient plan draft was created");
+ return { state, plan, draft };
+}
+
+/** Every gap filled with something a clinician might actually write. */
+function filled(sections: readonly PatientPlanSection[]): PatientPlanSection[] {
+ return sections.map((section) =>
+ section.gap
+ ? { ...section, body: ["Written with this person at the bedside."], gap: false, gapReason: null }
+ : section,
+ );
+}
+
+describe("Patient Plan transformation", () => {
+ it("never auto-converts the agreed approach and leaves it as a clinician gap", () => {
+ const agreed = draftFor(ROWAN).sections.find(({ key }) => key === "whatWeAgreedWillHappen");
+ if (agreed === undefined) throw new Error("the draft has no agreed-approach section");
+
+ expect(agreed.gap).toBe(true);
+ expect(agreed.body).toEqual([]);
+ expect(agreed.gapReason).toMatch(/written by a clinician/i);
+ });
+
+ /**
+ * The section is refused for what it *is*, not for what it happens to say.
+ * Content this plain converts anywhere else in the plan, so if the rule were
+ * ever weakened to "gap when the wording is difficult", this is the case that
+ * would slip through — and it is the section a person is most likely to read
+ * as a judgement about them.
+ */
+ it("refuses the agreed approach even when its content is entirely plain", () => {
+ const plain = versionWithContent({ agreedEdApproach: ["We will find you a quiet room."] });
+ const draft = buildPatientPlanDraft(plain, patientBy(ROWAN), syntheticPatientResources);
+ const agreed = draft.sections.find(({ key }) => key === "whatWeAgreedWillHappen");
+
+ expect(agreed?.gap).toBe(true);
+ expect(agreed?.body).toEqual([]);
+ // The same sentence under any other heading converts, which is what makes
+ // the refusal above about the section rather than about the words.
+ expect(converterFor(ROWAN)("We will find you a quiet room.")).toEqual({
+ converted: "We will find you a quiet room.",
+ });
+ });
+
+ it("is a pure function of the version and never reaches outside itself", () => {
+ const a = draftFor(ROWAN);
+ const b = draftFor(ROWAN);
+ expect(a).toEqual(b);
+ expect(JSON.stringify(a)).toBe(JSON.stringify(b));
+ });
+
+ it("leaves the version, the patient, and the resources it was given untouched", () => {
+ const version = currentVersionFor(ROWAN);
+ const patient = patientBy(ROWAN);
+ const before = JSON.stringify({ version, patient, resources: syntheticPatientResources });
+ buildPatientPlanDraft(version, patient, syntheticPatientResources);
+ expect(JSON.stringify({ version, patient, resources: syntheticPatientResources })).toBe(before);
+ });
+
+ it("produces the eight approved sections, in order, generated from the domain keys", () => {
+ const draft = draftFor(ROWAN);
+ expect(draft.sections.map((section) => section.key)).toEqual([...PATIENT_PLAN_SECTION_KEYS]);
+ expect(draft.sections).toHaveLength(8);
+ for (const section of draft.sections) {
+ expect(section.heading).toBe(PATIENT_PLAN_SECTION_HEADING[section.key]);
+ }
+ });
+
+ /**
+ * Physical health and medication is deliberately absent, and so is the
+ * how-to-approach field. Asserting the mapping is exhaustive is what stops a
+ * twelfth content field being added later and appearing on no surface at all
+ * while nothing goes red.
+ */
+ it("accounts for every Management Plan content field, and carries neither medication nor staff instructions", () => {
+ const mapped = new Set(Object.values(PATIENT_PLAN_SECTION_SOURCES).flat());
+ const omitted = new Set(PATIENT_PLAN_OMITTED_CONTENT_KEYS);
+ const everyField = Object.keys(currentVersionFor(ROWAN).content) as (keyof ManagementPlanContent)[];
+
+ expect(everyField.length).toBe(11);
+ for (const field of everyField) {
+ expect(mapped.has(field) || omitted.has(field), `${field} is neither mapped nor deliberately omitted`).toBe(true);
+ expect(mapped.has(field) && omitted.has(field), `${field} is both mapped and omitted`).toBe(false);
+ }
+ expect(omitted.has("physicalHealthAndMedication")).toBe(true);
+ expect(omitted.has("howToApproach")).toBe(true);
+
+ // Nothing from the medication field reaches any section body, on any
+ // fixture, whatever the dictionary does to the words in it.
+ for (const patient of syntheticPatients) {
+ const version = getCurrentManagementPlanVersion(
+ createInitialPrototypeState().managementPlanVersions,
+ patient.managementPlanId,
+ );
+ if (version === null) continue;
+ const bodies = buildPatientPlanDraft(version, patient, syntheticPatientResources)
+ .sections.flatMap((section) => section.body)
+ .join(" ")
+ .toLowerCase();
+ for (const line of version.content.physicalHealthAndMedication) {
+ expect(bodies).not.toContain(line.slice(0, 24).toLowerCase());
+ }
+ }
+ });
+
+ it("carries no ninth section and no physical-health heading", () => {
+ const headings = Object.values(PATIENT_PLAN_SECTION_HEADING).join(" ").toLowerCase();
+ expect(PATIENT_PLAN_SECTION_KEYS).toHaveLength(8);
+ for (const forbidden of ["medication", "medicine", "physical health", "allergy", "dose"]) {
+ expect(headings).not.toContain(forbidden);
+ }
+ });
+});
+
+describe("Patient Plan gap triggers", () => {
+ const convert = converterFor(ROWAN);
+
+ it("gaps a sentence holding a clinical term the dictionary refuses to convert", () => {
+ expect(convert("The waiting area can be loud at night.")).toEqual({
+ converted: "The waiting area can be loud at night.",
+ });
+ expect(convert("A quiet room helps when your distress is building.")).toEqual({
+ gapReasonKey: "unknownTerm",
+ });
+ });
+
+ /**
+ * The dictionary rule proper: a word that is simply *not in the vocabulary*,
+ * whether or not anybody thought to list it as clinical.
+ *
+ * This is separate from the test above on purpose. "Distress" is on the
+ * refused list and is caught by that check first, so a test using it proves
+ * nothing about the vocabulary — with the whole word check deleted, it still
+ * passed. Every word below is ordinary English that the curated vocabulary
+ * does not contain, which is the only shape that isolates this rule.
+ */
+ it("gaps a sentence holding any word the vocabulary does not contain", () => {
+ expect(convert("The corridor can feel claustrophobic at night.")).toEqual({ gapReasonKey: "unknownTerm" });
+ expect(convert("A blanket and a cup of tea help enormously.")).toEqual({ gapReasonKey: "unknownTerm" });
+ expect(convert("You settle once the negotiation is over.")).toEqual({ gapReasonKey: "unknownTerm" });
+ // The same sentences in words the vocabulary does hold.
+ expect(convert("The corridor can feel loud at night.")).toEqual({
+ converted: "The corridor can feel loud at night.",
+ });
+ expect(convert("A blanket and a warm drink help a lot.")).toEqual({
+ converted: "A blanket and a warm drink help a lot.",
+ });
+ });
+
+ /** A name already on this person's record is not an unknown word. */
+ it("lets through a name the person's own record already carries, and refuses one it does not", () => {
+ expect(convert("You can ring Jess.")).toEqual({ converted: "You can ring Jess." });
+ expect(convert("You can ring Pemberton.")).toEqual({ gapReasonKey: "unknownTerm" });
+ });
+
+ /**
+ * The negation rule earns its place only if a plain negation still converts.
+ * If every "not" gapped, the rule would be indistinguishable from refusing
+ * the whole plan, and the section reasons would be lies about why.
+ */
+ it("gaps a negated clinical statement while letting a plain preference through", () => {
+ expect(convert("Pain relief was not offered before the mental-health assessment.")).toEqual({
+ gapReasonKey: "unknownTerm",
+ });
+ expect(convert("Your family doctor is not the team you would ring at night.")).toEqual({
+ gapReasonKey: "clinicalNegation",
+ });
+ expect(convert("We will ring the number you give us.")).toEqual({
+ converted: "We will ring the number you give us.",
+ });
+ });
+
+ /**
+ * The rule that caught the worst line the first run of this transformation
+ * produced: a clinician's instruction, converted into an order addressed to
+ * the person the plan is about.
+ */
+ it("gaps a sentence written as an instruction to staff", () => {
+ expect(convert("Say so plainly when you use it, and offer again to write it together.")).toEqual({
+ gapReasonKey: "clinicianInstruction",
+ });
+ expect(convert("Keep the first conversation short and say what will happen next.")).toEqual({
+ gapReasonKey: "clinicianInstruction",
+ });
+ expect(convert("A seat in a quieter area, and a clear time for the next update.")).toEqual({
+ converted: "A seat in a quieter area, and a clear time for the next update.",
+ });
+ });
+
+ /** The rule that caught "To be called you" on Evie's copy. */
+ it("gaps a sentence about what this person is called rather than replacing the name with “you”", () => {
+ const evie = createLineConverter(currentVersionFor(ROWAN), patientBy("SYN-PATIENT-004"), syntheticPatientResources);
+ expect(evie("To be called Evie, and to have privacy for the conversation.")).toEqual({
+ gapReasonKey: "namingContext",
+ });
+ expect(convert("To be addressed as Rowan.")).toEqual({ gapReasonKey: "namingContext" });
+ });
+
+ it("gaps a sentence whose pronoun could belong to somebody else in it", () => {
+ expect(convert("You settle once they can ring their sister.")).toEqual({ gapReasonKey: "ambiguousPronoun" });
+ expect(convert("A drink, a blanket, and a warm room.")).toEqual({
+ converted: "A drink, a blanket, and a warm room.",
+ });
+ });
+
+ /**
+ * For a `she/her` patient the possessive and the object pronoun are the same
+ * word: "ring her son" wants "your son" and "hard for her to follow" wants
+ * "you", and no grammar here can tell them apart. Substituting one rule
+ * everywhere printed "which make it hard for your to follow" on Mira's copy on
+ * the first run, so the sentence is refused instead.
+ */
+ it("refuses a sentence whose pronoun is both this person's possessive and their object", () => {
+ const mira = converterFor(MIRA);
+ expect(mira("It is hard for her to follow what is being asked.")).toEqual({
+ gapReasonKey: "ambiguousPronoun",
+ });
+ expect(mira("A warm blanket helps her back.")).toEqual({ gapReasonKey: "ambiguousPronoun" });
+ // A they/them patient's pronouns are distinct, so the same shape converts.
+ expect(convert("A warm blanket helps their back.")).toEqual({ converted: "A warm blanket helps your back." });
+ });
+
+ /**
+ * `his` is only a trailing-boundary match away from rewriting the tail of
+ * `this`. That produced `Tyour`, which the vocabulary then refused, so a
+ * sentence that should have converted became a gap on a he/him copy.
+ */
+ it("does not treat the tail of “this” as the he/him possessive", () => {
+ // Jordan is the no-current-plan fixture, so the converter borrows Rowan's
+ // approved version the same way the Evie naming-context case does.
+ const jordan = createLineConverter(currentVersionFor(ROWAN), patientBy(JORDAN), syntheticPatientResources);
+ expect(jordan("The corridor can feel loud at this time of night.")).toEqual({
+ converted: "The corridor can feel loud at this time of night.",
+ });
+ expect(jordan("A warm blanket helps his back.")).toEqual({ converted: "A warm blanket helps your back." });
+ });
+
+ it("gaps a sentence whose verb it cannot put into the second person", () => {
+ // `settles` is in the agreement table, so this converts.
+ expect(convert("Rowan settles once the room is quiet.")).toEqual({
+ converted: "You settle once the room is quiet.",
+ });
+ // `dawdles` is not, and guessing by dropping the trailing s is wrong for
+ // every plural noun, so the sentence is refused instead.
+ expect(convert("Rowan dawdles on the way in.")).toEqual({ gapReasonKey: "personAsSubject" });
+ });
+
+ it("gaps a heading whose Management Plan field holds nothing", () => {
+ const empty = versionWithContent({ practicalNeeds: [], whatThePersonWants: [] });
+ const draft = buildPatientPlanDraft(empty, patientBy(ROWAN), syntheticPatientResources);
+ for (const key of ["thingsThatMightHelp", "whatMattersToYou"] as const) {
+ const section = draft.sections.find((candidate) => candidate.key === key);
+ expect(section?.gap).toBe(true);
+ expect(section?.gapReason).toBe(PATIENT_PLAN_GAP_REASON.nothingRecorded);
+ }
+ });
+
+ it("never carries part of a section: one refused line empties the whole heading", () => {
+ const mixed = versionWithContent({
+ whatThePersonWants: ["A quiet room.", "To be seen without your distress being the first thing anybody says."],
+ });
+ const section = buildPatientPlanDraft(mixed, patientBy(ROWAN), syntheticPatientResources).sections.find(
+ (candidate) => candidate.key === "whatMattersToYou",
+ );
+ expect(section?.gap).toBe(true);
+ expect(section?.body).toEqual([]);
+ });
+});
+
+describe("Patient Plan dictionary", () => {
+ const convert = converterFor(ROWAN);
+
+ it("replaces clinical vocabulary with the curated everyday wording", () => {
+ expect(convert("The CMHT will ring you the next working day.")).toEqual({
+ converted: "The mental health team will ring you the next working day.",
+ });
+ expect(convert("Your GP knows you well.")).toEqual({ converted: "Your family doctor knows you well." });
+ });
+
+ it("shifts the person's own name and pronouns into the second person, with the verb to match", () => {
+ expect(convert("Rowan wants a quiet room.")).toEqual({ converted: "You want a quiet room." });
+ expect(convert("A blanket helps with Rowan's sleep.")).toEqual({
+ converted: "A blanket helps with your sleep.",
+ });
+ });
+
+ /**
+ * A word ending in `s` after a substituted subject is either a verb needing
+ * agreement or a noun needing none, and the difference cannot be guessed. This
+ * proves every such word in the vocabulary is classified, so a word added
+ * later cannot quietly become an unchecked verb and print "you settles".
+ */
+ it("classifies every s-ending everyday word as a verb or as safe after “you”", () => {
+ const safe = new Set(AGREEMENT_SAFE_S_WORDS);
+ const unclassified = [...EVERYDAY_WORDS].filter(
+ (word) => word.endsWith("s") && THIRD_PERSON_VERBS[word] === undefined && !safe.has(word),
+ );
+ expect(unclassified, `unclassified s-ending words: ${unclassified.join(", ")}`).toEqual([]);
+ expect(EVERYDAY_WORDS.size).toBeGreaterThan(400);
+ });
+
+ /**
+ * The inflection rule widens the vocabulary — "waits" and "waiting" from
+ * "wait" — and it must never widen it onto a clinical term. Fails closed: a
+ * term whose base form is everyday would be admitted here and caught nowhere
+ * else.
+ */
+ it("never admits a clinical term, or its plural, through the inflection rule", () => {
+ for (const term of UNCONVERTIBLE_CLINICAL_TERMS) {
+ expect(isEverydayWord(term), `${term} is treated as an everyday word`).toBe(false);
+ expect(isEverydayWord(`${term}s`), `${term}s is treated as an everyday word`).toBe(false);
+ }
+ });
+
+ it("holds no dictionary entry that is also refused as unconvertible", () => {
+ const unconvertible = new Set(UNCONVERTIBLE_CLINICAL_TERMS);
+ for (const term of Object.keys(PLAIN_LANGUAGE_TERMS)) {
+ expect(unconvertible.has(term), `${term} is both convertible and unconvertible`).toBe(false);
+ }
+ });
+});
+
+/**
+ * The checks none of the other gates can make.
+ *
+ * Every string below is spelled out here rather than read from the module that
+ * produces it. A page can be structurally perfect, pass 400 tests, and still be
+ * cruel to read — that is the one class of harm every automated check on this
+ * project is blind to, and it is the reason these are literal.
+ */
+describe("Patient Plan reads as something a person can be handed", () => {
+ const CLINICAL_WORDS = [
+ "triage",
+ "disposition",
+ "presentation",
+ "mental-state",
+ "assessment",
+ "distress",
+ "compliance",
+ "comorbid",
+ "prognosis",
+ "acuity",
+ "severity",
+ "aetiology",
+ "psychosocial",
+ "sequelae",
+ "prn",
+ "bd",
+ "nocte",
+ ];
+
+ const BLAMING_PHRASES = [
+ "failed to",
+ "refused to",
+ "declined to",
+ "did not engage",
+ "non-compliant",
+ "noncompliant",
+ "poorly compliant",
+ "attention-seeking",
+ "attention seeking",
+ "manipulative",
+ "drug-seeking",
+ "frequent flyer",
+ "frequent presenter",
+ "frequent attender",
+ "difficult patient",
+ "problem patient",
+ "known to services",
+ "poor insight",
+ "lacks insight",
+ "unable to cope",
+ "does not cooperate",
+ "uncooperative",
+ "should have",
+ "you must",
+ "you failed",
+ ];
+
+ const HOPELESS_PHRASES = [
+ "nothing can be done",
+ "nothing more can be done",
+ "no further options",
+ "there is no point",
+ "will not improve",
+ "chronic and unchanging",
+ "beyond help",
+ ];
+
+ /** The exact string Task 8 printed on a person's own safety plan, and every
+ * shape of it. A blank stated as a blank, on a page addressed to somebody. */
+ const BLANK_STATEMENTS = ["not recorded", "not stated", "no data", "none recorded", "n/a", "unknown", "nil"];
+
+ const STAFF_IMPERATIVES = [
+ "say so",
+ "ask rowan",
+ "ask mira",
+ "ask the patient",
+ "read this plan",
+ "read the triage",
+ "introduce yourself",
+ "offer again",
+ "keep the first",
+ "check that",
+ "sit down to talk",
+ "record the reason",
+ "confirm only",
+ ];
+
+ /**
+ * Everything a reader would actually see, for one patient — except the
+ * verified crisis lines.
+ *
+ * Those are excluded from the clinical-vocabulary check on purpose, and only
+ * from that one. Their wording is quoted from the official public page each
+ * number was checked against: MHERL's own statement is that it "is a telephone
+ * triage and support line. It is not an emergency service". Paraphrasing a
+ * real service's own safety caveat to satisfy a house style would be a worse
+ * error than the one this test exists to catch, so the word stays and the
+ * exception is written down. The blaming and hopelessness checks below still
+ * cover them.
+ */
+ function readableStrings(patientId: SyntheticId, options: { includeVerifiedContacts: boolean }): string[] {
+ const patient = patientBy(patientId);
+ const version = getCurrentManagementPlanVersion(
+ createInitialPrototypeState().managementPlanVersions,
+ patient.managementPlanId,
+ );
+ if (version === null) return [];
+ const draft = buildPatientPlanDraft(version, patient, syntheticPatientResources);
+ const resources = options.includeVerifiedContacts
+ ? draft.resources
+ : draft.resources.filter((resource) => !resource.isRealContact);
+ return [
+ ...draft.sections.flatMap((section) => [section.heading, ...section.body]),
+ ...resources.flatMap((resource) => [resource.name, resource.detail]),
+ ...Object.values(PATIENT_PLAN_SECTION_LEAD_IN),
+ ];
+ }
+
+ /**
+ * Every line the conversion actually produces, across every fixture.
+ *
+ * The checks below have to run over converted prose, and a section gaps whole
+ * — so on this corpus every *section* is a gap and every `section.body` is
+ * empty. Reading the checks off the section bodies would make them vacuous:
+ * they would pass over nothing, for ever, and pass loudest at the moment the
+ * conversion broke completely. So they run over the line converter directly,
+ * behind the non-vacuity guard below.
+ */
+ function everyConvertedLine(): string[] {
+ const state = createInitialPrototypeState();
+ return syntheticPatients.flatMap((patient) => {
+ const version = getCurrentManagementPlanVersion(state.managementPlanVersions, patient.managementPlanId);
+ if (version === null) return [];
+ const convert = createLineConverter(version, patient, syntheticPatientResources);
+ return Object.values(PATIENT_PLAN_SECTION_SOURCES)
+ .flat()
+ .flatMap((field) => {
+ const value = version.content[field];
+ return typeof value === "string" ? [value] : [...value];
+ })
+ .flatMap((line) => {
+ const outcome = convert(line);
+ return "converted" in outcome ? [outcome.converted] : [];
+ });
+ });
+ }
+
+ /**
+ * Fails closed. If a later change made the conversion refuse everything, every
+ * content check below would pass over an empty list and report the page as
+ * flawless. This is the guard that stops that being counted as coverage.
+ */
+ it("actually converts something, so the content checks below are not vacuous", () => {
+ expect(everyConvertedLine().length).toBeGreaterThanOrEqual(5);
+ });
+
+ const PATIENTS_WITH_A_CURRENT_PLAN = [ROWAN, MIRA];
+
+ it.each(PATIENTS_WITH_A_CURRENT_PLAN)("says nothing clinical to %s in its own words", (patientId) => {
+ const lines = readableStrings(patientId, { includeVerifiedContacts: false });
+ expect(lines.length).toBeGreaterThan(8);
+ for (const line of lines) {
+ const lowered = line.toLowerCase();
+ for (const word of CLINICAL_WORDS) {
+ expect(lowered.includes(word), `“${line}” uses the clinical word “${word}”`).toBe(false);
+ }
+ }
+ });
+
+ it.each(PATIENTS_WITH_A_CURRENT_PLAN)("says nothing blaming or hopeless to %s, anywhere", (patientId) => {
+ const lines = readableStrings(patientId, { includeVerifiedContacts: true });
+ expect(lines.length).toBeGreaterThan(8);
+ for (const line of lines) {
+ const lowered = line.toLowerCase();
+ for (const phrase of [...BLAMING_PHRASES, ...HOPELESS_PHRASES]) {
+ expect(lowered.includes(phrase), `“${line}” reads as a judgement: “${phrase}”`).toBe(false);
+ }
+ }
+ });
+
+ /**
+ * The Task 8 defect, generalised. A printed sheet reading "My reasons for
+ * living — Not recorded" broke no rule and failed no gate. Here a section with
+ * nothing in it is a gap, a gap is never approved, and an unapproved copy is
+ * never printed — so the words themselves must never appear either.
+ */
+ it.each(PATIENTS_WITH_A_CURRENT_PLAN)("never states a blank as a fact about %s", (patientId) => {
+ for (const line of readableStrings(patientId, { includeVerifiedContacts: true })) {
+ const lowered = line.toLowerCase();
+ for (const blank of BLANK_STATEMENTS) {
+ expect(lowered.includes(blank), `“${line}” states a blank as a fact about this person`).toBe(false);
+ }
+ }
+ });
+
+ it("hands nobody an instruction that was written for staff", () => {
+ const lines = everyConvertedLine();
+ expect(lines.length).toBeGreaterThan(0);
+ for (const line of lines) {
+ const lowered = line.toLowerCase();
+ for (const imperative of STAFF_IMPERATIVES) {
+ expect(lowered.includes(imperative), `“${line}” is an instruction written for staff`).toBe(false);
+ }
+ for (const opener of ["say ", "ask ", "tell ", "keep ", "check ", "offer ", "read ", "sit ", "speak ", "use "]) {
+ expect(lowered.startsWith(opener), `“${line}” is an order addressed to the reader`).toBe(false);
+ }
+ }
+ });
+
+ /**
+ * Nothing converted still refers to the person in the third person, and
+ * nothing is left as broken English by a half-applied substitution. "To be
+ * called you" and "hard for your to follow" are the two failures at either end
+ * of the same rule, and both were printed on a real fixture's copy before
+ * these existed.
+ */
+ it("addresses the reader as “you”, never as themselves, and never in broken English", () => {
+ const state = createInitialPrototypeState();
+ let checked = 0;
+ for (const patient of syntheticPatients) {
+ const version = getCurrentManagementPlanVersion(state.managementPlanVersions, patient.managementPlanId);
+ if (version === null) continue;
+ const convert = createLineConverter(version, patient, syntheticPatientResources);
+ for (const field of Object.values(PATIENT_PLAN_SECTION_SOURCES).flat()) {
+ const value = version.content[field];
+ for (const line of typeof value === "string" ? [value] : value) {
+ const outcome = convert(line);
+ if (!("converted" in outcome)) continue;
+ checked += 1;
+ const lowered = outcome.converted.toLowerCase();
+ expect(lowered.includes(patient.preferredName.toLowerCase()), `“${outcome.converted}” still names them`).toBe(
+ false,
+ );
+ expect(lowered.includes(patient.fullName.toLowerCase()), `“${outcome.converted}” still names them`).toBe(
+ false,
+ );
+ for (const broken of [
+ "called you,",
+ "addressed as you",
+ "referred to as you",
+ "for your to",
+ "with your to",
+ "you settles",
+ "you is ",
+ "you has ",
+ "you was ",
+ "you does ",
+ "you their",
+ "you her ",
+ ]) {
+ expect(lowered.includes(broken), `“${outcome.converted}” is broken English: “${broken}”`).toBe(false);
+ }
+
+ /*
+ * No third-person pronoun for this person survives on their own copy.
+ * The object pronoun is deliberately not checked: "so you can read
+ * them again later" is about the notes, not about the reader.
+ */
+ const [subjectPronoun = ""] = patient.pronouns.split("/");
+ const possessivePronoun = subjectPronoun === "they" ? "their" : subjectPronoun === "she" ? "her" : "his";
+ for (const pronoun of [subjectPronoun, possessivePronoun]) {
+ expect(
+ new RegExp(`\\b${pronoun}\\b`, "i").test(outcome.converted),
+ `“${outcome.converted}” still talks about this person in the third person`,
+ ).toBe(false);
+ }
+ }
+ }
+ }
+ expect(checked, "nothing converted, so this check proved nothing").toBeGreaterThanOrEqual(5);
+ });
+
+ it("writes every heading and lead-in in the person's own voice", () => {
+ // Literal, from the specification, not read back from the module.
+ expect(PATIENT_PLAN_SECTION_HEADING.whyWeWroteThis).toBe("Why we wrote this together");
+ expect(PATIENT_PLAN_SECTION_HEADING.whatMattersToYou).toBe("What matters to you");
+ expect(PATIENT_PLAN_SECTION_HEADING.whatHelpsYou).toBe("What helps you");
+ expect(PATIENT_PLAN_SECTION_HEADING.whatMakesThingsHarder).toBe("What makes things harder");
+ expect(PATIENT_PLAN_SECTION_HEADING.whatWeAgreedWillHappen).toBe(
+ "What we agreed will happen when you come to the emergency department",
+ );
+ expect(PATIENT_PLAN_SECTION_HEADING.ifSomethingNewIsHappening).toBe("If something new is happening");
+ expect(PATIENT_PLAN_SECTION_HEADING.whoIsInvolved).toBe("Who's involved in your care");
+ expect(PATIENT_PLAN_SECTION_HEADING.thingsThatMightHelp).toBe("Things that might help");
+ });
+});
+
+describe("Patient Plan resources", () => {
+ it("gives every patient a resource list, with housing and money on the ones that need them", () => {
+ for (const patient of syntheticPatients) {
+ expect(getPatientResources(syntheticPatientResources, patient.id).length).toBeGreaterThan(0);
+ }
+ const categories = new Set(syntheticPatientResources.map((resource) => resource.category));
+ for (const category of PATIENT_RESOURCE_CATEGORY_ORDER) {
+ expect(categories.has(category), `no synthetic resource covers ${category}`).toBe(true);
+ }
+ // Housing and money are frequently the actual reason somebody keeps
+ // arriving, so a list that cannot mention them is a list about the wrong
+ // problem.
+ for (const category of ["housing", "financial"] as const) {
+ const patients = new Set(
+ syntheticPatientResources.filter((resource) => resource.category === category).map((r) => r.patientId),
+ );
+ expect(patients.size, `no patient has a ${category} resource`).toBeGreaterThan(0);
+ }
+ });
+
+ it("marks only the verified public crisis lines as real, and takes them from the one verified source", () => {
+ const real = syntheticPatientResources.filter((resource) => resource.isRealContact);
+ const invented = syntheticPatientResources.filter((resource) => !resource.isRealContact);
+
+ expect(real.length).toBe(publicCrisisContacts.length * syntheticPatients.length);
+ for (const resource of real) {
+ expect(resource.category).toBe("crisis_contact");
+ const source = publicCrisisContacts.find((contact) => contact.telephoneDisplay === resource.contact);
+ expect(source, `${resource.name} is not one of the verified public numbers`).toBeDefined();
+ expect(resource.sourceUrl).toBe(source?.sourceUrl);
+ if (source?.caveat != null) expect(resource.detail).toContain(source.caveat);
+ }
+
+ // Literal, so a fictional service can never be given a real number and a
+ // real number can never be quietly changed here.
+ const realNumbers = real.map((resource) => resource.contact);
+ expect(new Set(realNumbers)).toEqual(new Set(["000", "1300 555 788", "1800 676 822", "1800 552 002"]));
+ for (const resource of invented) {
+ expect(realNumbers.includes(resource.contact), `${resource.name} carries a real telephone number`).toBe(false);
+ expect(resource.category).not.toBe("crisis_contact");
+ expect(resource.id.startsWith("SYN-")).toBe(true);
+ }
+ });
+
+ /**
+ * A draft carries this person's resources and nobody else's. The whole
+ * catalogue is passed in, so the narrowing is the transformation's job — and
+ * a copy handed to somebody listing another person's youth housing service or
+ * carer line is both wrong and quietly revealing about who else is on the
+ * list.
+ */
+ it("carries only the resources chosen for the person the copy is for", () => {
+ for (const patientId of [ROWAN, MIRA] as const) {
+ const draft = draftFor(patientId);
+ expect(draft.resources.length).toBeGreaterThan(0);
+ expect(draft.resources.length).toBeLessThan(syntheticPatientResources.length);
+ for (const resource of draft.resources) {
+ expect(resource.patientId, `${resource.name} belongs to somebody else`).toBe(patientId);
+ }
+ expect(draft.resources).toEqual(getPatientResources(syntheticPatientResources, patientId));
+ }
+ });
+
+ it("groups resources in the approved order and drops a category with nothing in it", () => {
+ const groups = groupPatientResources(getPatientResources(syntheticPatientResources, ROWAN));
+ const order = groups.map((group) => group.category);
+ expect(order).toEqual([...PATIENT_RESOURCE_CATEGORY_ORDER].filter((category) => order.includes(category)));
+ // The crisis lines are last, where a reader flipping to the end will find
+ // them when everything above has not worked.
+ expect(order.at(-1)).toBe("crisis_contact");
+ for (const group of groups) expect(group.resources.length).toBeGreaterThan(0);
+ });
+});
+
+describe("Patient Plan lifecycle", () => {
+ it("derives a draft from the Current Management Plan Version and refuses when there is none", () => {
+ const { draft } = stateWithDraft(ROWAN);
+ expect(draft.derivedFromManagementVersionId).toBe(currentVersionFor(ROWAN).id);
+ expect(draft.state).toBe("draft");
+ expect(draft.version).toBe(1);
+
+ // Jordan has a Management Plan record with no versions at all.
+ const refused = run(asUser(createInitialPrototypeState(), "liaison_clinician"), {
+ type: "create-patient-plan-draft",
+ patientId: JORDAN,
+ });
+ expect(refused.patientPlanVersions).toEqual([]);
+ expect(refused.lastOutcome?.kind).toBe("error");
+ expect(refused.lastOutcome?.message).toMatch(/no Current Plan/i);
+ });
+
+ it("refuses to open a second draft beside an open one", () => {
+ const { state } = stateWithDraft(ROWAN);
+ const again = prototypeReducer(state, { type: "create-patient-plan-draft", patientId: ROWAN });
+ expect(again.patientPlanVersions).toHaveLength(1);
+ expect(again.lastOutcome?.kind).toBe("error");
+ });
+
+ it("replaces sections and resources whole when a draft is saved", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ const sections = filled(draft.sections);
+ const saved = prototypeReducer(state, {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections, resources: [] },
+ });
+ const after = saved.patientPlanVersions.find((version) => version.id === draft.id);
+ expect(after?.sections).toEqual(sections);
+ expect(after?.resources).toEqual([]);
+ expect(unfilledGapSections(after?.sections ?? [])).toEqual([]);
+ });
+
+ /**
+ * The gap block, with its positive control beside it. The first half proves
+ * approval is refused while a gap remains; the second proves the same action
+ * succeeds once it is filled — without which the refusal could be caused by
+ * anything at all.
+ */
+ it("refuses approval while any gap is unfilled, and allows it once none is", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ expect(unfilledGapSections(draft.sections).length).toBeGreaterThan(0);
+
+ const blocked = prototypeReducer(state, { type: "approve-patient-plan-version", versionId: draft.id });
+ expect(blocked.lastOutcome?.kind).toBe("error");
+ expect(blocked.lastOutcome?.message).toMatch(/cannot be approved while/i);
+ expect(blocked.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("draft");
+ expect(blocked.patientPlans[0]?.currentVersionId ?? null).toBeNull();
+
+ const approved = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ );
+ expect(approved.lastOutcome?.kind).toBe("success");
+ expect(approved.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("current");
+ });
+
+ /**
+ * Any clinical role, and no senior. Requiring a senior clinician would mean a
+ * person waits days for their own copy of their own plan, which defeats the
+ * point of giving them one — so the check is that all four clinical roles
+ * succeed, not merely that one does.
+ */
+ it("gives every clinical role the capability, and withholds it from the non-clinical one", () => {
+ for (const role of ["ed_clinician", "liaison_clinician", "cmht_clinician", "senior_clinician"] as const) {
+ expect(canPerformAction(role, "approve_patient_plan"), `${role} cannot approve a patient copy`).toBe(true);
+ }
+ expect(canPerformAction("plan_coordinator", "approve_patient_plan")).toBe(false);
+ });
+
+ // The fixtures carry a signed-in user for three of the four clinical roles;
+ // the capability check above covers the fourth, which has no synthetic user.
+ it.each(["ed_clinician", "liaison_clinician", "senior_clinician"] as const)(
+ "lets a %s approve the patient copy",
+ (role) => {
+ const { state, draft } = stateWithDraft(ROWAN, role);
+ const approved = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ );
+ const current = approved.patientPlanVersions.find((version) => version.id === draft.id);
+ expect(current?.state).toBe("current");
+ expect(current?.approvedBy).toBe(approved.activeUserId);
+ expect(current?.approvedAt).not.toBeNull();
+ },
+ );
+
+ it("refuses the non-clinical plan coordinator", () => {
+ expect(canPerformAction("plan_coordinator", "approve_patient_plan")).toBe(false);
+ const { state, draft } = stateWithDraft(ROWAN);
+ const asCoordinator = asUser(state, "plan_coordinator");
+ const refused = prototypeReducer(asCoordinator, { type: "approve-patient-plan-version", versionId: draft.id });
+ expect(refused.lastOutcome?.kind).toBe("blocked");
+ expect(refused.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("draft");
+ });
+
+ /**
+ * Approval must not consult senior-approval state at any point. Proved by
+ * approving a patient copy as an emergency department clinician while the
+ * Management Plan itself has a version sitting unapproved: the patient copy
+ * lands anyway.
+ */
+ it("never consults senior-approval state", () => {
+ const { state, draft } = stateWithDraft(MIRA, "ed_clinician");
+ expect(state.managementPlanVersions.some((version) => version.state === "awaiting_approval")).toBe(true);
+ const approved = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ );
+ expect(approved.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("current");
+ // Untouched: approving a patient copy is not approving a Management Plan.
+ expect(approved.managementPlanVersions.filter((version) => version.state === "awaiting_approval")).toHaveLength(1);
+ });
+
+ it("supersedes the prior current copy rather than deleting it", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ const first = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ { type: "create-patient-plan-draft", patientId: ROWAN },
+ );
+ const second = getOpenPatientPlanDraft(first.patientPlanVersions, first.patientPlans[0]?.id ?? "SYN-NONE");
+ if (second === null) throw new Error("no second draft");
+ const after = run(
+ first,
+ {
+ type: "save-patient-plan-draft",
+ versionId: second.id,
+ input: { sections: filled(second.sections), resources: [...second.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: second.id },
+ );
+
+ expect(after.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("superseded");
+ expect(after.patientPlanVersions.find((version) => version.id === second.id)?.state).toBe("current");
+ expect(after.patientPlans[0]?.currentVersionId).toBe(second.id);
+ // The superseded copy stays readable: it is what the person was actually
+ // given, and the record of that must not be deleted.
+ expect(after.patientPlanVersions.find((version) => version.id === draft.id)?.sections.length).toBe(8);
+ });
+
+ it("records a print intent without claiming anything reached a printer or a person", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ const approved = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ { type: "record-patient-plan-print-intent", patientId: ROWAN },
+ );
+ const event = approved.auditEvents.at(-1);
+ expect(event?.type).toBe("patient_plan_print_intent_opened");
+ expect(event?.evidence).toMatch(/is not evidence that anything reached a printer/i);
+ // Evidence only: no clinical record changed.
+ expect(approved.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("current");
+ });
+
+ it("refuses a print intent when there is no approved copy, and prints nothing", () => {
+ const { state } = stateWithDraft(ROWAN);
+ const refused = prototypeReducer(state, { type: "record-patient-plan-print-intent", patientId: ROWAN });
+ expect(refused.lastOutcome?.kind).toBe("error");
+ expect(refused.auditEvents.filter((event) => event.type === "patient_plan_print_intent_opened")).toEqual([]);
+ });
+
+ /** Printing is the one action most wanted when systems are down. */
+ it("still records a print intent offline, while refusing to write a copy", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ const approved = run(state, {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ });
+ const live = prototypeReducer(approved, { type: "approve-patient-plan-version", versionId: draft.id });
+ const offline: CarePlanPrototypeState = { ...live, connectivity: { online: false } };
+
+ const printed = prototypeReducer(offline, { type: "record-patient-plan-print-intent", patientId: ROWAN });
+ expect(printed.lastOutcome?.kind).toBe("info");
+
+ const written = prototypeReducer(offline, { type: "create-patient-plan-draft", patientId: ROWAN });
+ expect(written.lastOutcome?.kind).toBe("blocked");
+ });
+});
+
+describe("Patient Plan staleness", () => {
+ /** An approved patient copy for Mira, whose Management Plan has a version
+ * awaiting approval — so the plan can be moved on underneath it. */
+ function withApprovedCopy() {
+ const { state, draft } = stateWithDraft(MIRA, "senior_clinician");
+ return run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ );
+ }
+
+ function awaitingVersionId(state: CarePlanPrototypeState): SyntheticId {
+ const version = state.managementPlanVersions.find((candidate) => candidate.state === "awaiting_approval");
+ if (version === undefined) throw new Error("no version awaiting approval");
+ return version.id;
+ }
+
+ it("derives staleness from the two identifiers and never stores it", () => {
+ const before = withApprovedCopy();
+ const plan = before.patientPlans[0];
+ const copy = getCurrentPatientPlanVersion(before.patientPlanVersions, plan?.id ?? "SYN-NONE");
+ const management = before.managementPlans.find((candidate) => candidate.patientId === MIRA);
+ expect(isPatientPlanVersionStale(copy, management?.currentVersionId ?? null)).toBe(false);
+
+ const after = prototypeReducer(before, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(before),
+ });
+ const stillTheSameCopy = getCurrentPatientPlanVersion(after.patientPlanVersions, plan?.id ?? "SYN-NONE");
+ const movedOn = after.managementPlans.find((candidate) => candidate.patientId === MIRA);
+ expect(isPatientPlanVersionStale(stillTheSameCopy, movedOn?.currentVersionId ?? null)).toBe(true);
+
+ // Nothing about the copy itself changed: staleness is not a stored field.
+ expect(stillTheSameCopy).toEqual(copy);
+ expect(JSON.stringify(stillTheSameCopy)).not.toContain("stale");
+ });
+
+ it("treats a withdrawn source with nothing to replace it as stale, not as having nothing to compare", () => {
+ const before = withApprovedCopy();
+ const plan = before.patientPlans[0];
+ const copy = getCurrentPatientPlanVersion(before.patientPlanVersions, plan?.id ?? "SYN-NONE");
+
+ const withdrawn = asUser(before, "senior_clinician");
+ const after = prototypeReducer(withdrawn, {
+ type: "withdraw-current-management-version",
+ patientId: MIRA,
+ reason: "The plan no longer fits and a new one will be written with Mira.",
+ });
+ const management = after.managementPlans.find((candidate) => candidate.patientId === MIRA);
+
+ // Withdrawal, not a newer version: the source has no Current version at
+ // all, which is a stronger reason to update the copy than a mere version
+ // mismatch, not an absence of anything to compare against.
+ expect(management?.currentVersionId).toBeNull();
+ expect(isPatientPlanVersionStale(copy, management?.currentVersionId ?? null)).toBe(true);
+ });
+
+ it("raises the same stale-copy Review Trigger when the source is withdrawn instead of superseded", () => {
+ const before = withApprovedCopy();
+ const openBefore = before.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale");
+ expect(openBefore).toEqual([]);
+
+ const after = prototypeReducer(asUser(before, "senior_clinician"), {
+ type: "withdraw-current-management-version",
+ patientId: MIRA,
+ reason: "The plan no longer fits and a new one will be written with Mira.",
+ });
+
+ const raised = after.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale");
+ expect(raised).toHaveLength(1);
+ expect(raised[0]?.status).toBe("open");
+ expect(raised[0]?.patientId).toBe(MIRA);
+ // Same literal the superseded-version path uses: the instinct on reading
+ // "stale" is to take the copy away, and withdrawal is not an exception.
+ expect(raised[0]?.reason).toMatch(/stays readable and unchanged/i);
+
+ // A further withdrawal-adjacent action does not stack a second item.
+ const again = prototypeReducer(after, {
+ type: "withdraw-current-management-version",
+ patientId: MIRA,
+ reason: "Repeated dispatch should not raise a second trigger.",
+ });
+ // Refused outright: there is no Current Plan left to withdraw a second time.
+ expect(again.lastOutcome?.kind).toBe("error");
+ expect(again.lastOutcome?.message).toMatch(/no Current Plan to withdraw/i);
+ expect(again.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale")).toHaveLength(1);
+ });
+
+ it("refuses to approve a Patient Plan draft once the source it was written from is no longer the Current Plan", () => {
+ const { state, draft } = stateWithDraft(MIRA, "senior_clinician");
+ const saved = run(state, {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ });
+
+ // Move the Management Plan on underneath the still-open draft, before it
+ // is ever approved. No Patient Plan was Current at that moment, so the
+ // ordinary superseded-version stale trigger never fires for this draft.
+ const movedOn = prototypeReducer(saved, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(saved),
+ });
+ expect(movedOn.patientPlans.find(({ patientId }) => patientId === MIRA)?.currentVersionId).toBeNull();
+
+ const attempt = prototypeReducer(movedOn, { type: "approve-patient-plan-version", versionId: draft.id });
+
+ expect(attempt.lastOutcome?.kind).toBe("error");
+ expect(attempt.lastOutcome?.message).toMatch(/no longer Current/i);
+ // Nothing was promoted: the draft stays a draft and no Patient Plan
+ // became Current from an already-obsolete source.
+ expect(attempt.patientPlanVersions.find(({ id }) => id === draft.id)?.state).toBe("draft");
+ expect(attempt.patientPlans.find(({ patientId }) => patientId === MIRA)?.currentVersionId).toBeNull();
+ });
+
+ it("never regenerates, hides, or withdraws a copy the person may be holding", () => {
+ const before = withApprovedCopy();
+ const copyBefore = before.patientPlanVersions.map((version) => ({ ...version }));
+ const after = prototypeReducer(before, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(before),
+ });
+
+ expect(after.patientPlanVersions).toEqual(copyBefore);
+ expect(after.patientPlanVersions.every((version) => version.state !== "superseded")).toBe(true);
+ expect(after.patientPlans[0]?.currentVersionId).toBe(copyBefore[0]?.id);
+ });
+
+ it("raises exactly one Review Trigger, however many versions are approved after it", () => {
+ const before = withApprovedCopy();
+ const openBefore = before.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale");
+ expect(openBefore).toEqual([]);
+
+ const after = prototypeReducer(before, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(before),
+ });
+ const raised = after.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale");
+ expect(raised).toHaveLength(1);
+ expect(raised[0]?.status).toBe("open");
+ expect(raised[0]?.patientId).toBe(MIRA);
+ // Literal: the reason a human reads in the queue must say the copy stays
+ // readable, because the instinct on reading "stale" is to take it away.
+ expect(raised[0]?.reason).toMatch(/stays readable and unchanged/i);
+
+ // A further approval on the same plan does not stack a second item.
+ const draftedAgain = run(
+ after,
+ { type: "create-management-draft", patientId: MIRA },
+ { type: "select-patient", patientId: MIRA },
+ );
+ const nextDraft = draftedAgain.managementPlanVersions.filter((version) => version.state === "draft").at(-1);
+ if (nextDraft === undefined) throw new Error("no further draft");
+ const twice = run(
+ draftedAgain,
+ {
+ type: "save-management-draft",
+ versionId: nextDraft.id,
+ input: {
+ ownerId: nextDraft.ownerId,
+ reviewDueAt: "2027-01-01T12:00:00+08:00",
+ revisionReason: "A further revision, to prove the trigger does not stack.",
+ participationState: "co_produced",
+ consentedSupportPeople: [],
+ content: nextDraft.content,
+ },
+ },
+ { type: "submit-management-draft", versionId: nextDraft.id },
+ { type: "approve-management-version", versionId: nextDraft.id },
+ );
+ expect(twice.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale")).toHaveLength(1);
+ });
+
+ it("raises nothing when the person holds no patient copy at all", () => {
+ const state = asUser(createInitialPrototypeState(), "senior_clinician");
+ const after = prototypeReducer(state, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(state),
+ });
+ expect(after.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale")).toEqual([]);
+ });
+});
diff --git a/tests/care-plan-prototype-state.test.ts b/tests/care-plan-prototype-state.test.ts
index fdc1f25b0..427c13082 100644
--- a/tests/care-plan-prototype-state.test.ts
+++ b/tests/care-plan-prototype-state.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
-import { assertSingleCurrentVersion, getOpenManagementDraft } from "@/components/care-plan/mockups/domain";
+import {
+ assertSingleCurrentVersion,
+ getCurrentPatientPlanVersion,
+ getOpenManagementDraft,
+ isPatientPlanVersionStale,
+} from "@/components/care-plan/mockups/domain";
import { PROTOTYPE_NOW } from "@/components/care-plan/mockups/fixtures";
import {
createInitialPrototypeState,
@@ -16,6 +21,7 @@ import type {
NewEdPresentationInput,
PrototypeScenario,
SafetyPlanDraftInput,
+ SyntheticId,
} from "@/components/care-plan/mockups/types";
const ED_CLINICIAN = "SYN-USER-ED-001";
@@ -110,6 +116,27 @@ function safetyDraftInput(overrides: Partial = {}): Safety
};
}
+/** Complete the conversion's deliberate gaps without changing the resource list.
+ * The state tests care about the document lifecycle, not its prose. */
+function completePatientPlanDraft(state: CarePlanPrototypeState, patientId: SyntheticId): CarePlanPrototypeState {
+ const withDraft = prototypeReducer(state, { type: "create-patient-plan-draft", patientId });
+ const draft = withDraft.patientPlanVersions.find((version) => version.state === "draft");
+ if (draft === undefined) throw new Error("the Patient Plan draft was not created");
+ return prototypeReducer(withDraft, {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: {
+ sections: draft.sections.map((section) => ({
+ ...section,
+ body: section.body.length > 0 ? section.body : ["Written with the person before approval."],
+ gap: false,
+ gapReason: null,
+ })),
+ resources: draft.resources,
+ },
+ });
+}
+
/** The lines this application may never write: it knows what it asked an
* external application to do, and nothing about what happened afterwards. */
const OVERCLAIMING_LANGUAGE = [
@@ -744,6 +771,74 @@ describe("Care Plan ED Presentation recording", () => {
expect(third.presentationAmendments).toHaveLength(first.presentationAmendments.length + 2);
expect(third.edPresentations.find(({ id }) => id === "SYN-PRESENTATION-001")?.planHelpfulness).toBe("helpful");
});
+
+ it("raises each applicable review trigger from corrected effective values, once", () => {
+ const withoutOpenTriggers = { ...createInitialPrototypeState(), reviewTriggers: [] };
+ const lessHelpful = prototypeReducer(withoutOpenTriggers, {
+ type: "amend-presentation",
+ presentationId: "SYN-PRESENTATION-001",
+ field: "planHelpfulness",
+ replacementValue: "not_helpful",
+ reason: "The original account was corrected after speaking with Rowan.",
+ });
+ const twiceCorrected = prototypeReducer(lessHelpful, {
+ type: "amend-presentation",
+ presentationId: "SYN-PRESENTATION-001",
+ field: "planHelpfulness",
+ replacementValue: "mixed",
+ reason: "A further correction recorded the more precise account.",
+ });
+ const admission = prototypeReducer(twiceCorrected, {
+ type: "amend-presentation",
+ presentationId: "SYN-PRESENTATION-002",
+ field: "disposition",
+ replacementValue: "mental_health_admission",
+ reason: "The final disposition was corrected from the contemporaneous record.",
+ });
+
+ expect(lessHelpful.reviewTriggers.filter((trigger) => trigger.source === "plan_use_feedback")).toHaveLength(1);
+ expect(twiceCorrected.reviewTriggers.filter((trigger) => trigger.source === "plan_use_feedback")).toHaveLength(1);
+ expect(admission.reviewTriggers.filter((trigger) => trigger.source === "presentation_outcome")).toHaveLength(1);
+ expect(admission.lastOutcome?.message).toMatch(/Review Trigger was raised/i);
+ });
+});
+
+describe("Care Plan Patient Plan source currency", () => {
+ it("marks a current patient copy stale and raises a review trigger when its source is withdrawn", () => {
+ let state = completePatientPlanDraft(createInitialPrototypeState(), ROWAN);
+ const draft = state.patientPlanVersions.find((version) => version.state === "draft")!;
+ state = prototypeReducer(state, { type: "approve-patient-plan-version", versionId: draft.id });
+ const currentCopy = getCurrentPatientPlanVersion(state.patientPlanVersions, state.patientPlans[0]!.id);
+ expect(currentCopy).not.toBeNull();
+
+ const withdrawn = prototypeReducer(withUser(state, SENIOR), {
+ type: "withdraw-current-management-version",
+ patientId: ROWAN,
+ reason: "The current plan was withdrawn while it is rewritten with Rowan.",
+ });
+
+ expect(isPatientPlanVersionStale(currentCopy, null)).toBe(true);
+ expect(withdrawn.reviewTriggers).toContainEqual(
+ expect.objectContaining({ source: "patient_plan_stale", sourceId: currentCopy?.id }),
+ );
+ expect(withdrawn.lastOutcome?.message).toMatch(/needs updating/i);
+ });
+
+ it("refuses approval of a completed draft after its Management Plan source is superseded", () => {
+ let state = completePatientPlanDraft(createInitialPrototypeState("overdue-plan"), MIRA);
+ const patientDraft = state.patientPlanVersions.find((version) => version.state === "draft")!;
+ state = prototypeReducer(withUser(state, SENIOR), {
+ type: "approve-management-version",
+ versionId: MIRA_AWAITING_VERSION,
+ });
+ const before = state.auditEvents;
+
+ const refused = prototypeReducer(state, { type: "approve-patient-plan-version", versionId: patientDraft.id });
+
+ expect(refused.patientPlanVersions.find((version) => version.id === patientDraft.id)?.state).toBe("draft");
+ expect(refused.auditEvents).toEqual(before);
+ expect(refused.lastOutcome?.message).toMatch(/no longer Current/i);
+ });
});
// --- Personal Safety Plan --------------------------------------------------------
@@ -1307,10 +1402,23 @@ describe("Care Plan scenarios, reset, and determinism", () => {
versionConflict: { active: false },
patientPlans: [],
patientPlanVersions: [],
- patientResources: [],
auditEvents: [],
lastOutcome: null,
});
+ /**
+ * The two Patient Plan collections start empty because a patient copy is
+ * produced when somebody makes one, and seeding an edition would put
+ * patient-facing wording on the record that nobody wrote.
+ *
+ * The resource catalogue is not an edition of anything. It is the list a
+ * clinician chooses from, so Task 9 seeds it: an empty one would mean every
+ * patient copy started with nothing to offer, and the housing and money
+ * entries are frequently the whole point of the sheet.
+ */
+ expect(createInitialPrototypeState().patientResources.length).toBeGreaterThan(0);
+ expect(createInitialPrototypeState().patientResources.every((resource) => resource.id.startsWith("SYN-"))).toBe(
+ true,
+ );
expect(createInitialPrototypeState("empty").selectedPatientId).toBeNull();
expect(createInitialPrototypeState("overdue-plan").selectedPatientId).toBe(MIRA);
expect(createInitialPrototypeState("withdrawn-plan").selectedPatientId).toBe(EVELYN);
diff --git a/tests/care-plan-route-files.test.ts b/tests/care-plan-route-files.test.ts
index eabe03727..addde6b0d 100644
--- a/tests/care-plan-route-files.test.ts
+++ b/tests/care-plan-route-files.test.ts
@@ -114,6 +114,21 @@ describe("Care Plan route registry", () => {
);
expect(carePlanRoute.safetyPlanEdit("SYN-PATIENT-001")).toBe(CARE_PLAN_ROUTES.safetyPlanEdit);
expect(carePlanRoute.safetyPlanPrint("SYN-PATIENT-001")).toBe(CARE_PLAN_ROUTES.safetyPlanPrint);
+ // The Patient Plan's own two addresses were in `CARE_PLAN_ROUTES` from
+ // Task 3 with no builder beside them, exactly as the Management Plan's and
+ // the Safety Plan's were. This is the copy handed to a person, so a
+ // hand-written print link is how one ends up addressed to somebody else.
+ expect(carePlanRoute.patientPlan("SYN-PATIENT-003")).toBe(
+ "/mockups/care-plan/patients/SYN-PATIENT-003/patient-plan",
+ );
+ expect(carePlanRoute.patientPlanEdit("SYN-PATIENT-003")).toBe(
+ "/mockups/care-plan/patients/SYN-PATIENT-003/patient-plan/edit",
+ );
+ expect(carePlanRoute.patientPlanPrint("SYN-PATIENT-003")).toBe(
+ "/mockups/care-plan/patients/SYN-PATIENT-003/patient-plan/print",
+ );
+ expect(carePlanRoute.patientPlanEdit("SYN-PATIENT-001")).toBe(CARE_PLAN_ROUTES.patientPlanEdit);
+ expect(carePlanRoute.patientPlanPrint("SYN-PATIENT-001")).toBe(CARE_PLAN_ROUTES.patientPlanPrint);
expect(carePlanRoute.presentations("SYN-PATIENT-003")).toBe(
"/mockups/care-plan/patients/SYN-PATIENT-003/presentations",
);
@@ -358,6 +373,71 @@ describe("Care Plan synthetic, memory-only boundary", () => {
}
});
+ /**
+ * The Patient Plan transformation, named explicitly.
+ *
+ * The scan above covers the whole namespace, which is why it would keep
+ * passing if this module were deleted, renamed, or quietly moved outside the
+ * directory — and this is the one module in the prototype where "no model, no
+ * provider, no network" is a product boundary rather than a house style. A
+ * later implementation may replace the function; it may not replace it with
+ * something that calls out.
+ */
+ const PATIENT_PLAN_MODULES = [
+ `${COMPONENT_ROOT}/patient-plan-transform.ts`,
+ `${COMPONENT_ROOT}/patient-plan-fixtures.ts`,
+ `${COMPONENT_ROOT}/patient-plan-pages.tsx`,
+ `${COMPONENT_ROOT}/patient-plan-form.tsx`,
+ `${COMPONENT_ROOT}/fixtures.ts`,
+ ] as const;
+
+ it("keeps the Patient Plan transformation offline, deterministic, and free of any model", () => {
+ for (const file of PATIENT_PLAN_MODULES) {
+ expect(existsSync(resolve(process.cwd(), file)), `${file} is missing`).toBe(true);
+ }
+
+ const transform = readFileSync(resolve(process.cwd(), `${COMPONENT_ROOT}/patient-plan-transform.ts`), "utf8");
+
+ // It imports nothing from outside its own namespace. A relative import can
+ // only reach a sibling; anything else would be an alias or a package.
+ for (const match of transform.matchAll(/from\s+["']([^"']+)["']/g)) {
+ const specifier = match[1] ?? "";
+ expect(
+ specifier.startsWith("./"),
+ `patient-plan-transform.ts imports ${specifier} from outside the namespace`,
+ ).toBe(true);
+ expect(specifier.includes(".."), `patient-plan-transform.ts reaches up out of the namespace: ${specifier}`).toBe(
+ false,
+ );
+ }
+
+ // No language model, AI service, or provider call is reachable from any part
+ // of the Patient Plan, including the transformation.
+ const forbidden: readonly { label: string; pattern: RegExp }[] = [
+ { label: "a language-model provider", pattern: /\b(?:openai|anthropic|claude|gemini|bedrock|azure_?openai)\b/i },
+ {
+ label: "a model call",
+ pattern: /\b(?:createCompletion|chat\.completions|generateText|streamText|invokeModel)\b/,
+ },
+ { label: "an inference or embedding call", pattern: /\b(?:inference|embedding|embeddings|tokeni[sz]er)\b/i },
+ { label: "a prompt", pattern: /\b(?:systemPrompt|userPrompt|promptTemplate)\b/ },
+ {
+ label: "a network call",
+ pattern: /\bfetch\s*\(|\baxios\b|\bhttps?:\/\/(?!example\.org|www\.triplezero|emhs\.health)/i,
+ },
+ { label: "a wall-clock read", pattern: /Date\.now\s*\(|new Date\s*\(\s*\)/ },
+ { label: "randomness", pattern: /Math\.random\s*\(|crypto\./ },
+ { label: "a timer", pattern: /\bsetTimeout\s*\(|\bsetInterval\s*\(/ },
+ { label: "storage", pattern: /\b(?:localStorage|sessionStorage|indexedDB)\b/ },
+ ];
+ for (const file of PATIENT_PLAN_MODULES) {
+ const source = readFileSync(resolve(process.cwd(), file), "utf8");
+ for (const { label, pattern } of forbidden) {
+ expect(pattern.test(source), `${file} contains ${label}`).toBe(false);
+ }
+ }
+ });
+
it("registers no route handler and no production route outside the mockup namespace", () => {
const handlers = readdirSync(resolve(process.cwd(), APP_ROOT), { recursive: true, withFileTypes: true }).filter(
(entry) => entry.isFile() && /^route\.(?:ts|tsx|js)$/.test(entry.name),
@@ -427,8 +507,12 @@ describe("Care Plan synthetic, memory-only boundary", () => {
// building, it carries a person's own words about what keeps them safe, and
// it lists real crisis telephone numbers — a rule that clipped any of it
// would leave every DOM assertion green.
+ // Extended in Task 9 to the Patient Plan. It is the second document that
+ // leaves the building, it is written to be read by the person it is about,
+ // and a rule that clipped one of its eight sections would leave every DOM
+ // assertion green — Vitest runs with `css: false`.
const protectedSelector =
- /\.(?:firstMinuteSection\w*|pinnedBoundary\w*|fullPlanSection\w*|currentPlanCard|printPaper|printRecordWarning|printIdentity|printCmht|safetyPaper\w*|safetySection\w*|safetySupport\w*|crisis\w*)\b/;
+ /\.(?:firstMinuteSection\w*|pinnedBoundary\w*|fullPlanSection\w*|currentPlanCard|printPaper|printRecordWarning|printIdentity|printCmht|safetyPaper\w*|safetySection\w*|safetySupport\w*|crisis\w*|patientPlanPaper\w*|patientPlanSection\w*|patientPlanLeadIn|patientPlanResource\w*)\b/;
/**
* Declarations are parsed rather than pattern-matched over the block text.
@@ -598,28 +682,31 @@ describe("Care Plan synthetic, memory-only boundary", () => {
return (match[2] === "rem" ? size * 16 : size) * 0.75;
}
- const paper = declarationsFor("safetyPaper", printRules);
- const printedSize = paper.get("font-size");
- expect(
- printedSize,
- ".safetyPaper declares no printed font size, so the patient copy is not sized for paper",
- ).toBeDefined();
- expect(
- pointsOf(printedSize ?? ""),
- `.safetyPaper prints at ${printedSize ?? "nothing"}, which is too small for the document a person reads in a crisis`,
- ).toBeGreaterThanOrEqual(11);
-
- for (const className of ["safetySection", "crisisEntry"]) {
+ // Both documents that leave the building: the person's own safety plan, and
+ // their own copy of the Management Plan.
+ for (const paperClass of ["safetyPaper", "patientPlanPaper"]) {
+ const printedSize = declarationsFor(paperClass, printRules).get("font-size");
+ expect(
+ printedSize,
+ `.${paperClass} declares no printed font size, so the patient copy is not sized for paper`,
+ ).toBeDefined();
+ expect(
+ pointsOf(printedSize ?? ""),
+ `.${paperClass} prints at ${printedSize ?? "nothing"}, which is too small for the document a person reads in a crisis`,
+ ).toBeGreaterThanOrEqual(11);
+ }
+
+ for (const className of ["safetySection", "crisisEntry", "patientPlanSection", "patientPlanResource"]) {
expect(
declarationsFor(className, printRules).get("break-inside"),
`.${className} may be split across a page break, so half of it can be lost on the previous sheet`,
).toBe("avoid");
}
- // Nothing on the patient copy is pinned to a viewport that does not exist on
- // a sheet of paper — a fixed dock prints once, over the content, or not at all.
+ // Nothing on either patient copy is pinned to a viewport that does not exist
+ // on a sheet of paper — a fixed dock prints once, over the content, or not at all.
for (const rule of rules) {
- if (!rule.selectors.some((selector) => /\.(?:safety|crisis)[A-Za-z]*\b/.test(selector))) continue;
+ if (!rule.selectors.some((selector) => /\.(?:safety|crisis|patientPlan)[A-Za-z]*\b/.test(selector))) continue;
const position = declarationsFor(rule.selectors[0]?.replace(".appRoot .", "") ?? "", [rule]).get("position");
expect(["fixed", "sticky"], `${rule.selectors.join(", ")} pins printed content to the viewport`).not.toContain(
position,
@@ -643,6 +730,8 @@ describe("Care Plan synthetic, memory-only boundary", () => {
`${COMPONENT_ROOT}/management-plan-review.tsx`,
`${COMPONENT_ROOT}/safety-plan-pages.tsx`,
`${COMPONENT_ROOT}/safety-plan-form.tsx`,
+ `${COMPONENT_ROOT}/patient-plan-pages.tsx`,
+ `${COMPONENT_ROOT}/patient-plan-form.tsx`,
];
for (const file of consumers) {
const source = readFileSync(resolve(process.cwd(), file), "utf8");
` per route since Task 3 and the shell owns it; Task 5's clinician print
+ resolved the identical tension the same way. The printed document still carries a clear
+ accessible title, asserted by role and level. _Cost if wrong:_ none — consistent with the
+ other print surface.
+
+---
+
+## Deferred minors — for the whole-branch review to triage
+
+Recorded, not fixed. None blocks a later task.
+
+**Task 1**
+
+- `awaitingApproval` queue ordering is asserted on a single element, so its sort is unproven.
+- `CmhtContact.verifiedAt` is non-nullable, so "never verified" cannot be expressed, while `SYN-CMHT-003` is `unverified` with a date.
+- The telephone sweep cannot see a bracketed `(08) 5550 1234` landline.
+- `addIsoDays`/`addIsoMonths` are calendar helpers in a file declaring itself pure selectors.
+- A copied `Optional detail` doc comment sits on the required `cmhtContactAttempt`.
+- `it.each` emits four identically-named tests (brief-pinned, not the implementer's choice).
+- `fixtures.ts` is ~1,420 lines before Tasks 8 and 9 add more — Task 9's patient-plan fixtures should get their own module.
+- The count guard matches spelled numbers only, so a digit-form claim evades it.
+- `once|twice` must sit immediately adjacent to the verb, so "left before assessment twice" evades it.
+- Ruling 17's reword is now load-bearing rather than cosmetic: a legitimately-phrased future review trigger can trip a count guard that has nothing to say about it.
+- `BLAMING_TERM` includes "difficult"/"refuses"/"agitated", which have legitimate service-facing uses — fails closed, so the safe direction, but expect false positives.
+- `CONCRETE_FINDING` is curve-fit to the present fixtures.
+- `SYN-PRESENTATION-019` breaks identifier ordering in the array (cosmetic; consumers sort).
+
+**Task 2**
+
+- The reducer is a single ~945-line switch. Extract per-case functions before Task 9 adds six more actions.
+- `assertSingleCurrentVersion` throws with no React error boundary — **an input to Task 3**, which must mount one.
+- `nextSyntheticId` interpolates its prefix into a `RegExp`.
+- `reset` always returns the `normal` world, so a displayed specimen scenario silently persists.
+- The `AMENDABLE_FIELDS.includes` runtime check is unreachable under the typed action.
+- `save-management-draft` does not validate that `ownerId` names a clinical role, so the non-clinical `plan_coordinator` can be recorded as Plan Owner.
+
+**Task 3** — raised by the fix-round-1 reviewer, recorded rather than fixed.
+
+- The duplicated loading skeleton across `loading.tsx` and `route-page.tsx` carries inconsistent
+ accessible naming (`sr-only` paragraph in one, `aria-label` in the other).
+- The `src/components/care-plan/mockups/index.ts` barrel has zero importers.
+- The active user falls back to empty strings at `routable-suite.tsx` when no user matches
+ `activeUserId`, so a broken id renders a silently blank identity block rather than failing.
+- The phone dock selects its items by matching label strings against
+ `CARE_PLAN_PRIMARY_DESTINATIONS` rather than by an explicit list.
+- `aria-current="page"` is set on the More **button**, which is a disclosure control, not a
+ destination.
+- Two test names in `tests/proxy.test.ts` still say "the two developer-gated paths"; there are
+ now three.
+- The non-mockup-route guard in `tests/care-plan-route-files.test.ts` checks only four
+ hard-coded paths, so a Care Plan route added elsewhere under `src/app/**` would evade it.
+- `docs/codebase-index.md` has no Care Plan entry. `docs:check-index` passes without one, but
+ the repo's new-route checklist wants one before the branch is handed off.
+
+The hand-rolled `aria-live` region was **not** deferred: it was removed as part of Important #3,
+because it double-announced against the focus move that fix depends on.
+
+**Task 6** — recorded rather than fixed.
+
+- `management-plan-form.tsx` is 661 lines with nine pure helpers (`toDateValue`,
+ `toPerthTimestamp`, `textOf`, `linesFrom`, `valuesFrom`, `contentFrom`, `draftInputFrom`,
+ `bannedConstructionIn`, `validate`) reachable only through a rendered form — the same
+ argument that correctly earned `diffManagementPlanContent` its own export.
+- `.appRoot .roleSwitcher` sets a redundant `min-height`; `fieldControl` already applies
+ `h-tap` and `--spacing-tap` is `3rem`, so the target is 48 px either way.
+- The review route renders the Current Plan in full above the change table. That is correct
+ under read primacy, but it means scrolling past five sections on a phone; a jump link is
+ worth considering, not a reordering.
+- The blocked-reason pattern on the plan actions uses a visible `role="alert"` paragraph plus
+ `aria-describedby` rather than the generic `title="… — coming soon"` plus `sr-only` shape.
+ It matches the already-reviewed `shareBlockedReason` convention in the same file, so it is a
+ consistent extension rather than a new deviation — but the two patterns should be
+ reconciled before handoff.
+
+**Task 7** — recorded rather than fixed.
+
+- **The amendment-chain invariant's second half is correct but unexercised.** The guard asserts
+ both that the episode field equals the earliest amendment's `originalValue` _and_ that each
+ later amendment's `originalValue` equals the previous one's `replacementValue`. No fixture has
+ a field corrected twice, so every chain has length one and the second loop never runs. The
+ implementer's claim that restoring the old convention "takes down both halves at once" is
+ overstated: both kills land on the first half, checked from two files. The clean close is a
+ unit test with a constructed two-link chain — **not** bending a fixture to exercise a test.
+ Flagged deliberately for the whole-branch review, because untested guard code is how this
+ project got seven guards that could not fail.
+- The `[presentationId]` parameter-guard regex does not require the matched call's argument to
+ be literally named `presentationId`, unlike its `[patientId]` sibling, so a wrong-variable
+ mix-up would pass.
+- The invariant guard's fail-closed threshold is `chains.size > 1` rather than `> 0`. It still
+ fails on zero corrections, but its message would fire misleadingly if exactly one distinct
+ field ever carried a correction.
+- `linkedPlanLabel` returns `No Current Plan was available` for a non-null version id that
+ resolves to nothing, conflating "the version is unknown" with "there was none".
+- `isSyntheticPresentationForPatient` now has no production consumer — only the `index.ts`
+ re-export and its own test — while still reading as a live guard.
+- The reason-required refusal sentence is duplicated verbatim between `presentation-pages.tsx`
+ and `prototype-state.ts`; two copies in two modules will drift.
+- `presentation-pages.tsx` is 648 lines carrying two independent surfaces plus the amendment
+ sheet. Brief-mandated grouping, so noted rather than charged.
+- `effectivePresentation` re-filters the amendment array once for corrections and again per
+ field, up to seven passes per timeline entry; same family as `AmendableRow`'s double call.
+- **`portal={false}` on the amendment Sheet is Task 11's first look.** It is the one decision in
+ this task that changes where focus containment lives, and with `css: false` no committed test
+ can see its overlay, backdrop or focus trap.
+
+**Task 8** — recorded rather than fixed.
+
+- A failed print leaves **no trace at all** (Ruling 46). A later task should decide whether an
+ attempt-that-failed deserves its own event, as a contact action's `email_intent_opened`
+ records an attempt that is explicitly not evidence of delivery.
+- `emptySections="omit"` applies uniformly to all seven keys including
+ `professionalAndEmergencySupport`. That section is required unconditionally by the form and
+ non-empty in every fixture, so it cannot vanish today — but a future data path bypassing form
+ validation would silently drop those contact lines from the paper. Worth a defensive
+ carve-out.
+- The `Review currency unknown` branch is implemented correctly but unreachable from fixtures:
+ the only `reviewDueAt: null` version is a draft, and drafts never render the review mark.
+- `safety-plan-pages.tsx` combines the reading surface, the print surface and `CrisisContacts`
+ at 564 lines. Task 5 split its two surfaces; Task 7 combined. Precedent exists either way and
+ the shared `SafetyPlanSections` is a real reason to keep them together.
+- Support-row keys and field ids remain index-based (`support-${index}`); pre-existing pattern,
+ and the removal flow is test-covered.
+- `key={item}` on list items matches the established pattern rather than being this task's to
+ change.
+
+**Stage A checkpoint** — found by the controller's own run, not by any task review.
+
+- The Stage A test output is **not pristine**, and no task report mentioned it. Two warnings
+ appear on every run: a React `act` warning from
+ `keeps the typed search term across a navigation because the shell persists` ("A component
+ suspended inside an `act` scope, but the `act` call was not awaited"), and a jsdom
+ `Not implemented: navigation to another Document`. The repository's standing rule is that
+ warnings in test output are findings and output should be pristine; both are test hygiene
+ rather than product defects, but they are noise that could mask a real warning in Stage B.
+
+**Task 4** — raised by the task reviewer and the fix-round re-reviewer, recorded rather than fixed.
+
+- No one-searchbox assertion covers `/patients`, the other route where `routeOwnsSearch` is true.
+ Home and the shell-owned routes each have one; a regression restoring the shell composer on
+ `/patients` would be caught only incidentally.
+- The after-hours telephone anchor is untested, so a swap to the duty URI survives — and it
+ dispatches the same `call` intent as the duty control, so the recorded audit event cannot say
+ which number was launched.
+- The no-sort guard matches control _names_ (`/sort|rank|most|highest/i`, no combobox, no
+ columnheader). A control named "Busiest first" passes. It killed the plausible mutation, so this
+ is calibration rather than inertness, but asserting the rendered row order would be stronger.
+- `carePlanPatientIdFromPathname` is a pure path parser living in `routable-suite.tsx` rather than
+ beside `isSyntheticPatientId` in `routes.ts`, where every other address concern lives. Its
+ `pathname.split("?")[0] ?? pathname` fallback is unreachable — `split` always returns one element.
+- `ContentList` uses the bullet text as its React key, so two identical bullets in one section warn.
+- Two differently worded synthetic markers appear on one screen: the shell's `Synthetic prototype —
+fictional data only` and `SYNTHETIC_DATA_MARKER`'s `… fictional people, teams, and hospitals`.
+- `keeps identity and currency facts visible` asserts `/verified/i`, which also matches
+ `Not verified since …`, so it cannot tell a verified team from an unverified one.
+- Draft-below-Current is proven by document order only; a CSS `order` or `grid-row` change could
+ invert it visually with every assertion still green. Same family as the section-5 clipping hole
+ that fix round 1 closed, and a candidate for the same static-stylesheet treatment.
+- `lastOutcome` survives a cross-patient deep link. `select-patient` clears it, so the Home path is
+ clean, but `/patients/A` → record an intent → address-bar to `/patients/B` never dispatches
+ `select-patient`, and B's workspace shows A's notice. The message carries no identity, so the
+ exposure is nil; the cost is the honesty of the chronology.
+- The stylesheet guard's `declarationsOf` splits declaration bodies on a bare `;` and would
+ mis-split a value containing an embedded semicolon such as a data URI; its `chunk.split("{")`
+ destructure silently drops anything after a second `{` in one chunk. Neither is exercised today.
+- The new clipping guards are text analysis, not CSS semantics: a `clip-path`, a
+ `transform: scale(0)`, or a clipping rule on an ancestor would pass. The list covers what anyone
+ would plausibly write; it is a list, not a proof.
+
+---
+
+## Systemic lessons — carry into every later dispatch
+
+1. **Writing source through a shell layer corrupts it.** Three occurrences: twice `\b`
+ regex escapes became literal backspace bytes, once 1,240 newlines became CRLF, which
+ `.gitattributes` forbids. Write source with the editor tools, not Python or shell
+ heredocs, and scan every touched file for CR and control bytes before committing.
+2. **Guards that cannot fail.** Two shipped before being caught, both silently matching
+ nothing. Any test whose job is to reject something needs a positive control proving it
+ rejects a known-bad input, and should be proved by making the code wrongly permit the
+ thing and watching the test go red. Task 2 adopted this and ran 32 mutations against
+ 32 red suites — that is the standard.
+3. **Inaccurate reports cost a review round.** Task 1 reported a typecheck without its
+ output; Task 2 claimed coverage that did not exist and miscounted its own tests. The
+ report is the evidence a reviewer works from. Count from the run output, paste the
+ decisive lines, and never claim coverage not written.
+4. **Commit at the end of every task.** Three worktree destructions cost nothing
+ committed and everything uncommitted — and by Task 4 the same habit had also absorbed
+ run-coordinator refusals and three `529 Overloaded` provider outages mid-round.
+5. **A guard can be unable to fail because of where it sits, not what it asserts.** Two of
+ Task 4's guards were correct assertions that no mutation could kill. The section-5
+ clipping guard read CSS-module class _names_, which Vitest resolves from a proxy whether
+ or not the stylesheet defines a rule — proven by `styles.currentPlanCard`, a class that
+ existed nowhere and that nothing noticed. The focus guards asserted final state in a
+ component whose parent shell commits last and repairs whatever a descendant did. Both
+ needed a different _kind_ of assertion — static analysis of the stylesheet, and
+ `focusin` event ordering — not a stricter one. When a positive control survives, the
+ instinct to tighten the assertion is usually wrong; ask first whether the test can
+ observe the failure at all.
+6. **A refused run can be scored as a mutation kill, and nearly was.** Task 5's implementer
+ found `npm run typecheck` returning **exit 75** — `Database focused-test capacity is full`,
+ another worktree holding the lease — and realised its first mutation harness would have
+ counted that as the mutation being caught. Six controls had produced no `Tests` summary
+ line. It re-ran each individually with a retry loop and confirmed all six failed on real
+ named assertions, then made every reported run retry until it holds a lease. A mutation
+ "killed" by a refused run is a guard that cannot fail wearing a new costume, and on this
+ machine it will happen again: any run without a `Test Files` summary line is an
+ acquisition failure and must be retried, never scored.
+7. **Focus has no owner, and that is now an architectural debt.** Three focus defects in a
+ row shared one cause: the shell is an ancestor, its effect lands last, and every
+ descendant calls `focus()` independently. Nothing structurally prevents a fourth — the
+ convention is held by three tests rather than by an invariant. The whole-branch review
+ should consider a single focus owner that descendants request, rather than each surface
+ claiming focus for itself.
+8. **A generative assertion can never disagree with what it checks.** Task 8 found two of its
+ own controls surviving, and both were this shape: a test comparing rendered text against the
+ very constant the component renders from. "The declined label must not deny the plan exists"
+ asserted against `PATIENT_CONFIRMATION_LABEL.declined`, which passes for _any_ label —
+ including the defective one it was written to catch. The fix is a **literal** expectation:
+ spell the forbidden phrasing out as a regex and apply it across every label and explanation.
+ Structural presence-or-absence checks may read from the constant; content-quality checks
+ never may. Nine guards that could not fail have now shipped here — but the last three were
+ caught by implementers' own positive controls rather than by reviewers, which is the
+ direction of travel worth keeping.
+9. **Read the page as the person receiving it, not as the person who built it.** Task 8's worst
+ defect broke no rule, failed no gate, and was invisible to 365 passing tests: a printed sheet
+ handed to a patient reading `My reasons for living — Not recorded`. Every automated check
+ this project has is a check on structure. The one class of harm it cannot see is a page that
+ is technically correct and cruel to read. Before any patient-facing surface is called done,
+ somebody must read it straight through as its recipient.
diff --git a/docs/care-plan/session-handoff-2026-08-21.md b/docs/care-plan/session-handoff-2026-08-21.md
new file mode 100644
index 000000000..a127a27a1
--- /dev/null
+++ b/docs/care-plan/session-handoff-2026-08-21.md
@@ -0,0 +1,259 @@
+# Care Plan — session handoff, 21 August 2026
+
+Written at the end of the controlling Claude session that designed Care Plan and
+built Tasks 1 and 2. Read this, then `sdd-ledger.md`, then start.
+
+---
+
+## Where things stand in one paragraph
+
+Care Plan is a synthetic, memory-only, reset-on-refresh clinical prototype under
+`/mockups/care-plan`, built inside the psychiatry.tools repository and reachable on
+the live site for a signed-in administrator. It lets a clinician look up the
+approved management plan for someone who presents repeatedly to an emergency
+department in psychiatric crisis. The design is complete and user-approved. Of
+eleven build tasks, **Tasks 1 and 2 are complete, independently reviewed, and
+committed**; Task 3 has not started. The branch is pushed to
+`origin/claude/ed-care-plans-impl-7f44cd`, which is the authoritative copy.
+
+---
+
+## What exists and is verified
+
+| Layer | Files | State |
+| ------------------ | ----------------------------------------------------------------------------------------- | ------------------ |
+| Domain types | `src/components/care-plan/mockups/types.ts` (589 lines) | Complete, reviewed |
+| Synthetic fixtures | `src/components/care-plan/mockups/fixtures.ts` (1,420 lines) | Complete, reviewed |
+| Pure selectors | `src/components/care-plan/mockups/domain.ts` (397 lines) | Complete, reviewed |
+| Lifecycle reducer | `src/components/care-plan/mockups/prototype-state.ts` (1,458 lines) | Complete, reviewed |
+| React provider | `src/components/care-plan/mockups/prototype-provider.tsx` (40 lines) | Complete, reviewed |
+| Tests | `tests/care-plan-domain.test.ts` (913), `tests/care-plan-prototype-state.test.ts` (1,240) | 121 passing |
+
+**Evidence at last run:** 121/121 tests passing across both files, `npm run typecheck`
+exit 0 with zero diagnostics, `npm run lint` exit 0, Prettier clean. Thirty-two
+deliberate mutations each produced a red suite, proving no refusal guard passes
+vacuously. Two independent reviewers (fresh agents, spec + quality) signed off each
+task after a fix loop.
+
+**Not yet run for this feature, and must not be claimed:** any browser or Playwright
+journey, any accessibility or responsive check, any print check, any build, any
+provider-backed gate. There is no UI yet.
+
+**Integrity confirmed after the third worktree destruction:** `git ls-files --eol`
+reports `i/lf w/lf` on every Care Plan file, matching untouched repository files, and
+the committed blobs contain zero CR bytes. Nothing is corrupted. Re-running the suites
+in the relocated worktree gave `Test Files 2 passed (2) / Tests 121 passed (121)`.
+
+**Environment state in the relocated worktree — resolved, with one caveat.** The
+`npm ci --include=dev` there has since completed (exit 0) and Prettier now runs. While it
+was still in flight, `npm run typecheck` failed on three unrelated pre-existing files —
+`tests/universal-search.test.ts` (TS7006) and two `use-in-page-section-nav*` DOM tests
+(TS7016, unable to resolve `lucide-react` types) — with **zero errors in any
+`src/components/care-plan/**` or `tests/care-plan-*` file`. Typecheck exited 0 in the
+previous worktree at the same commit, so that was an install artefact rather than a
+regression. **Re-confirm typecheck yourself before reporting any gate as green**; it has
+not been re-run since the install finished.
+
+One standing caveat: this machine runs several AI sessions concurrently and the
+repository has a cross-worktree run coordinator. A gate can be refused with
+`DATABASE_HEAVY_RUN_ADMISSION_BUSY` because another worktree holds the lease — that is an
+_acquisition_ failure, not a test result. Any run whose output lacks a `Test Files`
+summary line must be retried, never reported.
+
+---
+
+## The environment problem — read this before you do anything
+
+The original worktree at `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd`
+was **destroyed three times on 21 August 2026**. The third destruction happened
+through an explicit `git worktree lock`, while a task was running: the `.git` pointer
+file went first, making git resolve to the main checkout on the wrong branch, then
+3,836 tracked files were deleted over three minutes.
+
+- **Relocation did NOT protect it.** The work was moved to `D:\Worktrees\Database\care-plan`
+ on the reasoning that that parent had been untouched all day — and it was destroyed
+ there too, within the hour, the same way: `.git` pointer first, then 1,301 files. **No
+ directory on this machine is safe.** The only thing that has ever protected this work is
+ committing and pushing.
+- **The branch is pushed** to `origin/claude/ed-care-plans-impl-7f44cd`. If a worktree dies
+ again, recover with `git worktree add claude/ed-care-plans-impl-7f44cd` — or clone
+ fresh from GitHub, which is now the authoritative copy.
+- **Nothing committed was ever lost.** Every recovery was one `git worktree add`.
+- **What died each time was uncommitted work and git-ignored scratch** — including,
+ the third time, the entire SDD workspace with its ledger, briefs, reports and
+ review packages. That is why the ledger is now a tracked file.
+- **Commit at the end of every task.** That single habit is why three destructions
+ cost nothing but time.
+
+**Cause — identified, and already fixed on `main`.** The worktree was running an
+old `scripts/guard-push.mjs`. That version linked a _borrowed_ worktree's real
+`node_modules` into a scratch checkout as a Windows junction, then force-deleted the
+scratch checkout recursively — which a `git worktree lock` cannot stop, because it is a
+filesystem delete rather than a git worktree operation. Any concurrent session pushing
+from a stale base ran it against whichever worktree it had borrowed from.
+
+This is **already fixed upstream** by two commits this branch does not yet contain:
+
+- `a04330ea0` — harden(guard-push): never force-delete a scratch checkout that still
+ holds a borrowed `node_modules` link (#2244)
+- `cdfcbaccd` — fix(worktrees): stop silent worktree wipes and misdirected commands (#2240)
+
+**This branch is 122 commits behind `origin/main` and has neither.** That is the whole
+explanation: the tooling in this worktree, and in the other stale worktrees running
+alongside it, predated its own fix. `scripts/clean-worktree.mjs` was investigated and
+cleared — it contains no filesystem deletion at all.
+
+**The remedy is to merge `origin/main` into this branch before doing anything else.**
+Until then this worktree runs the vulnerable guard, and so does every other session on a
+stale base.
+
+Worth investigating separately: there were 82 registered worktrees on this
+repository. That is itself worth a clean-up, carefully, once this build is done.
+
+---
+
+## The product, and the decisions behind it
+
+Thirteen decisions were taken with the user across a brainstorming pass and a
+grilling round. They are binding and must not be reopened.
+
+**Purpose and shape**
+
+1. Build the synthetic prototype now, but keep the domain shaped so real persistence
+ could be added later without a redesign — pure reducer, plain serialisable state,
+ caller-allocated IDs, one dispatch path. **No storage layer is built now.**
+2. The full multi-service workflow, including named senior-clinician approval.
+3. Deliver Tasks 1–5 (Stage A), **stop for user review**, then Tasks 6–11.
+4. Local commits authorised, and the branch has since been pushed with the user's
+ explicit agreement after being told the repository is public. Still **not**
+ authorised: PR, merge, rebase, deployment, or provider access.
+
+**Clinical content**
+
+5. The Management Plan is **eleven fields in two tiers**, not the nineteen originally
+ drafted. Four pairs were saying the same thing twice, and the two safety-critical
+ sections were below the fold. The first-minute summary is exactly five: how to
+ approach this person, what helps, what makes it worse, what we have agreed to do,
+ and what would make this presentation different.
+6. **Reading is the primary use.** The user's words: "the plan is for clinicians to
+ look up and see the management plan; it is rarely for changing or updating." Where
+ reading and authoring compete for space, navigation depth, attention or effort,
+ reading wins — including in build order, which is why Stage A ends with the whole
+ reading experience and no authoring surface at all.
+7. An ED Presentation requires only about thirty seconds: site, disposition, whether
+ the plan was available, used and helpful, and one required line — "in one line: why
+ they came and what happened". Everything richer sits behind a disclosure.
+8. The review clock is 12 months, editable per version, amber at 28 days.
+9. Identification Reviews close with a recorded decision — proceed to a plan, not
+ needed at this stage, or revisit later — plus a reason. Previously they could be
+ opened but never closed.
+10. `agreedEdApproach` names who agreed the position and when, reads as an agreed
+ default rather than a ceiling on care, and never uses a prohibitive construction.
+ `BANNED_ADMISSION_CONSTRUCTIONS` enforces this.
+11. `whatMakesItWorse` describes **what the service does** — corridors, repeated
+ history-taking, security presence, unexplained waits — never what the person does
+ wrong.
+12. Sort-by-presentation-count exists **only** inside the Identification Review
+ workflow, where finding frequent attenders is the stated purpose. Nowhere else.
+13. There is a **Patient Plan** (Task 9): a patient-facing edition produced by a
+ deterministic, offline, rule-based transformation of an approved Management Plan
+ Version. It maps eleven known fields to eight patient-voice headings through a
+ curated dictionary, **emits a visible gap wherever it cannot convert confidently
+ rather than guessing**, and **never auto-converts the agreed-ED-approach section**
+ under any circumstances. A clinician — any clinical role, not only a senior one —
+ must fill the gaps and approve before the patient receives it, and cannot approve
+ with a gap open. No language model, network call, or provider is involved.
+
+---
+
+## What to do next
+
+Task 3: the gated route family, literal navigation, and the responsive Clinical
+Snapshot shell. Twenty-one routes, all rendering a semantic `RoutePurposeSurface`
+placeholder that Tasks 4–10 replace one route at a time. It is structural, not
+product screens.
+
+Two inputs Task 3's brief does not carry:
+
+- **Mount a React error boundary inside the gate.** `assertSingleCurrentVersion`
+ throws from inside the reducer, deliberately, because a plan with two Current
+ versions is an invariant violation that must not render. From Task 3 onward that
+ would crash the tree with no boundary.
+- **Do not re-add the online/offline listener** (Ruling 20). It was removed with the
+ reasoning recorded in `prototype-provider.tsx`.
+
+---
+
+## The prompt to start the next session
+
+```text
+Resume the Care Plan build in D:\Worktrees\Database\care-plan on branch
+claude/ed-care-plans-impl-7f44cd. Do not create or use any worktree under
+D:\Repos\Database\.claude\worktrees\ — that location destroyed this work three
+times on 21 August 2026, once through an explicit git worktree lock.
+
+Read in this order:
+1. docs/care-plan/session-handoff-2026-08-21.md — where things stand, the thirteen
+ binding product decisions, and the environment hazard.
+2. docs/care-plan/sdd-ledger.md — progress, all 25 controller rulings with what each
+ costs if wrong, the deferred minors, and four systemic lessons.
+3. docs/superpowers/specs/2026-08-20-care-plan-design.md — binding product authority.
+4. docs/care-plan-context.md — binding glossary; its preferred terms are required and
+ its _Avoid_ terms are banned.
+5. The Global Constraints and Delivery Stages sections of
+ docs/superpowers/plans/2026-08-20-care-plan-implementation.md. Do not read the
+ whole plan.
+
+Tasks 1 and 2 are complete, reviewed and committed: 121 tests passing, typecheck and
+lint clean. Task 3 has not started and no partial work exists.
+
+Execute with superpowers:subagent-driven-development, resuming at Task 3. One
+implementer at a time, a fresh reviewer after each task, a fix loop, and a commit at
+the end of every task without exception — that habit is the only reason three worktree
+destructions cost nothing. Keep the ledger updated at docs/care-plan/sdd-ledger.md
+(tracked deliberately; the git-ignored SDD workspace was destroyed).
+
+Stop at the Stage A checkpoint after Task 5 and report to me. Do not start Task 6 on
+your own judgment.
+
+Before Task 3, confirm: git status clean, branch and HEAD as above, dependencies
+installed, and npm run test -- tests/care-plan-domain.test.ts
+tests/care-plan-prototype-state.test.ts still green. Paste the decisive output — an
+exit code alone is not evidence in this repo.
+
+Carry these into every dispatch: write source with editor tools rather than Python or
+shell heredocs and scan touched files for CR and control bytes (three files were
+corrupted that way); any test that rejects something needs a positive control proving
+it rejects a known-bad input, proved by making the code wrongly permit it and watching
+the test go red; and count test totals from the run output rather than from memory.
+
+The application stays synthetic, memory-only and provider-free. Local commits are
+authorised. Nothing else is — no push, PR, merge, rebase, deployment, migration, or
+provider access. Ask before any of those.
+```
+
+---
+
+## Authorisation boundary
+
+Authorised: local implementation and offline verification in
+`D:\Worktrees\Database\care-plan`; local commits on
+`claude/ed-care-plans-impl-7f44cd`; `npm run ensure` for a local dev server.
+
+**Not authorised:** push, pull, merge, rebase, PR, branch deletion, deployment,
+migration, hosted CI, live Supabase or OpenAI, `verify:release`, `check:supabase-project`,
+any `eval:*`, or any other provider-backed command. Ask first.
+
+---
+
+## Honest limits
+
+This is a clinical reference prototype, not validated clinical decision support and
+not a clinical tool. It holds no real patient information and cannot; state resets on
+refresh. Passing every local test and rendering every screen would still not make it
+suitable for use with real patients — that would require, at minimum, WA Health
+clinical governance approval, an approved identification policy, patient and consumer
+co-design, a privacy impact assessment, cultural-safety review, legal review, clinical
+content validation, identity matching, access control, immutable audit, and controlled
+deployment. The specification's "Production-readiness boundary" section lists the full
+set.
diff --git a/docs/care-plan/verification-log-2026-08-21.md b/docs/care-plan/verification-log-2026-08-21.md
new file mode 100644
index 000000000..e47262a36
--- /dev/null
+++ b/docs/care-plan/verification-log-2026-08-21.md
@@ -0,0 +1,240 @@
+# ED Care Plans — handover verification log
+
+## Scope
+
+This log records evidence for the design/planning/handover package only. It is not evidence that the ED Care Plans application exists or works; implementation has not started.
+
+**Worktree:** `D:\Worktrees\Database\ed-care-plans`
+**Branch:** `codex/ed-care-plans`
+**HEAD:** `eeea74a160c19553f94347dda5102b2dff2ed591`
+**Upstream:** `origin/main`
+**Upstream SHA at initial capture:** `2abbe0068560086497b0688970ec6ad6ba957aba`
+
+## Git/worktree inspection
+
+Command:
+
+```powershell
+git status --short --branch --untracked-files=all
+git rev-parse HEAD
+git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}'
+git rev-parse origin/main
+```
+
+Decisive output:
+
+```text
+## codex/ed-care-plans...origin/main [behind 2]
+?? docs/ed-care-plans-context.md
+?? docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md
+?? docs/superpowers/specs/2026-08-20-ed-care-plans-design.md
+eeea74a160c19553f94347dda5102b2dff2ed591
+origin/main
+2abbe0068560086497b0688970ec6ad6ba957aba
+```
+
+The handover documents were created after this snapshot and are intentionally additional untracked files. No Git branch movement was performed.
+
+## Shared checkout preservation
+
+Command:
+
+```powershell
+git status --short --branch --untracked-files=all
+git rev-parse HEAD
+```
+
+Working directory: `D:\Repos\Database`
+
+Decisive output:
+
+```text
+## gemini/safe-tooling-ui-layout-and-workflow-hardening...origin/gemini/safe-tooling-ui-layout-and-workflow-hardening [gone]
+ M docs/scripts-index.md
+ M docs/testing.md
+ M scripts/check-bundle-budget.mjs
+ M tests/bundle-budget.test.ts
+ad44f2b1466c3091ba0c0bbb36125d2631c5509a
+```
+
+These changes are unrelated and untouched.
+
+## Planning self-review inherited from the approved planning turn
+
+The planning pass recorded:
+
+```text
+PLAN_SELF_REVIEW=PASS tasks=9
+```
+
+Additional planning evidence:
+
+- Prettier check passed for the design, glossary, and implementation-plan documents.
+- Route inventory matched all 17 approved routes.
+- Placeholder/unfinished-marker scan returned no matches.
+- Glossary: 135 lines.
+- Approved design specification: 418 lines before the handover status-line update.
+- Implementation plan: 1,001 lines.
+- No product source, test, build, API, provider, or deployment action occurred.
+
+## Handover lifecycle routing
+
+Command:
+
+```powershell
+npm run workflow:lifecycle -- --phase handoff --write-evidence
+```
+
+Exit code: `0`
+
+Decisive output:
+
+```text
+Changed files: docs/ed-care-plans-context.md, docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md, docs/superpowers/specs/2026-08-20-ed-care-plans-design.md
+Risk classes: docsOnly
+
+Local/offline checks:
+- npm run verify:pr-local — Complete the local handoff gate.
+```
+
+Written workflow evidence:
+
+`D:\Worktrees\Database\ed-care-plans\.local\workflow-evidence\2026-08-20T16-20-39-080Z-lifecycle.json`
+
+The `.local` evidence is intentionally ignored/machine-local and was not added to Git.
+
+## Native conversation capture
+
+The native Codex task-history reader was paginated until `hasMore: false` for task:
+
+```text
+Title: Management plan
+Task ID: 01a01fb2-575f-7c11-a245-332db7a85a25
+```
+
+The portable output is [`conversation-transcript-2026-08-21.md`](./conversation-transcript-2026-08-21.md). It contains every returned user message and assistant commentary/final message, in chronological order. Private reasoning, hidden instructions, and tool payloads were intentionally not included.
+
+Transcript structure check:
+
+```text
+TURNS=11
+USERS=11
+COMMENTARY=21
+FINALS=10
+```
+
+The active handover turn has no final message inside the capture, which accounts for one fewer final than user turn.
+
+## Visual artifact inspection
+
+Files retained:
+
+```text
+content\ed-care-plans-directions.html
+content\waiting-after-direction.html
+state\server-info
+state\server-instance-id
+state\server.pid
+```
+
+The recorded server port was `65531`. Process `17586` was no longer running when checked, so the former localhost URL is correctly labelled historical in the handover. The HTML source remains available.
+
+## Final documentation checks
+
+### Formatting correction
+
+The first complete Prettier check found formatting issues in the three newly generated long-form handover files:
+
+```text
+[warn] docs/ed-care-plans/claude-build-handover-2026-08-21.md
+[warn] docs/ed-care-plans/conversation-transcript-2026-08-21.md
+[warn] docs/ed-care-plans/verification-log-2026-08-21.md
+```
+
+They were formatted with `npx prettier --write`. The first `verify:pr-local` attempt then reported the transcript alone as still nonconforming and stopped at `format:changed`:
+
+```text
+PR-local verification summary:
+- completed: check:runtime, check:installed-lock-parity
+- failed: format:changed (exit 1)
+- not reached: sitemap:check, docs:check-index, docs:check-inventory, docs:check-scripts, docs:check-links, check:branch-review-ledger, check:outstanding-issues, check:ledger-write-discipline
+```
+
+The transcript was passed through the repository formatter again and immediately checked. Focused rerun:
+
+```powershell
+npm run format:changed
+```
+
+Exit code: `0`
+
+Decisive output:
+
+```text
+Checking formatting...
+All matched files use Prettier code style!
+```
+
+### Risk-routed handoff gate
+
+Dry-run command:
+
+```powershell
+npm run verify:pr-local -- --dry-run --files docs/ed-care-plans-context.md,docs/ed-care-plans/CLAUDE-START-HERE.md,docs/ed-care-plans/claude-build-handover-2026-08-21.md,docs/ed-care-plans/conversation-transcript-2026-08-21.md,docs/ed-care-plans/verification-log-2026-08-21.md,docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md,docs/superpowers/specs/2026-08-20-ed-care-plans-design.md
+```
+
+The dry run selected runtime, installed-lock parity, changed-file formatting, sitemap, documentation index/inventory/script/link checks, and ledger/outstanding-issue discipline. It explicitly skipped lint, typecheck, the full unit suite, RAG fixtures, build, and offline RAG checks for the recognised low-risk documentation scope.
+
+Actual rerun used the same command without `--dry-run`.
+
+Exit code: `0`
+
+Decisive output:
+
+```text
+PR-local verification summary:
+- completed: check:runtime, check:installed-lock-parity, format:changed, sitemap:check, docs:check-index, docs:check-inventory, docs:check-scripts, docs:check-links, check:branch-review-ledger, check:outstanding-issues, check:ledger-write-discipline
+- failed: (none)
+- not reached: (none)
+
+Skipping build: no build-affecting source, config, package, or container changes detected.
+```
+
+Selected supporting proof:
+
+```text
+[Runtime Check] PASS: Node runtime 24.19.0 matches required Node 24.x.
+[Runtime Check] PASS: npm runtime 11.17.0 matches required npm 11.x.
+docs link check passed: 2059 repo path references resolve.
+Ledger write discipline passed for eeea74a160c1..HEAD.
+```
+
+The final ledger write-discipline step was slow because it read the repository's applied immutable ledger records with read-only Git operations while another worktree was also verifying. It completed successfully; no lock or process was bypassed.
+
+### Final worktree snapshot
+
+Captured at `2026-08-21T00:51:29.9378517+08:00`:
+
+```text
+## codex/ed-care-plans...origin/main [behind 4]
+HEAD=eeea74a160c19553f94347dda5102b2dff2ed591
+ORIGIN_MAIN=1cc0d298774e4dc2ec8dd04d03ecf4fe789d5564
+BEHIND=4
+```
+
+All seven ED Care Plans files were untracked. No product source, test, configuration, lockfile, or generated documentation file was modified. No branch movement, commit, push, PR, or deployment occurred.
+
+## Explicitly not run
+
+- Product unit/DOM tests — no product implementation exists.
+- Typecheck — no product implementation exists; docs-only handover gate is the proportionate check.
+- Lint — no product implementation exists; docs-only handover gate is the proportionate check.
+- Build — no product implementation exists.
+- Browser/Playwright — no ED Care Plans route exists.
+- Accessibility/browser matrix — future Task 9 work.
+- Production-readiness/provider checks — no provider access is authorized and the prototype is intentionally synthetic.
+- Hosted CI, API calls, deployment, migration, push, or PR — not authorized.
+
+## Completion boundary
+
+The documentation handover gate is complete. Application completion remains false until all nine implementation tasks and their proportionate verification are finished.
diff --git a/src/components/care-plan/mockups/care-plan.module.css b/src/components/care-plan/mockups/care-plan.module.css
index 3269c33b0..5cd33a7d1 100644
--- a/src/components/care-plan/mockups/care-plan.module.css
+++ b/src/components/care-plan/mockups/care-plan.module.css
@@ -1383,3 +1383,231 @@
border-color: CanvasText;
}
}
+
+/*
+ * The Patient Plan — the person's own edition of the Management Plan.
+ *
+ * It shares the crisis-contact treatment above deliberately: the four public
+ * numbers look and print identically wherever they appear, so a reader who has
+ * both sheets does not have to work out whether they are the same numbers.
+ *
+ * Nothing here may collapse, clip, truncate, or hide a section. This is the
+ * document that leaves the building, and the static stylesheet guard in
+ * `tests/care-plan-route-files.test.ts` is where that is actually visible —
+ * Vitest runs with `css: false`, so no DOM assertion can see a print rule at
+ * all.
+ */
+
+.appRoot .patientPlanSections {
+ display: grid;
+ gap: 1rem;
+}
+
+.appRoot .patientPlanSection {
+ display: grid;
+ gap: 0.375rem;
+ border-inline-start: 3px solid var(--border);
+ padding-inline-start: 0.75rem;
+}
+
+.appRoot .patientPlanSectionHeading {
+ color: var(--text);
+ font-size: 1rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+/* The fixed patient-voice sentence under each heading. It is part of the
+ * document rather than a hint about it, so it prints. */
+.appRoot .patientPlanLeadIn {
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.55;
+}
+
+/* A gap: a section the conversion refused to guess at. Shown only on the
+ * working copy — an approved version cannot carry one. */
+.appRoot .patientPlanGap {
+ display: grid;
+ gap: 0.375rem;
+ justify-items: start;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-lg);
+ padding: 0.625rem;
+}
+
+.appRoot .patientPlanGapReason {
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.55;
+}
+
+.appRoot .patientPlanStale {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 0.75rem;
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.6;
+}
+
+.appRoot .patientPlanDraftNotice {
+ display: grid;
+ gap: 0.5rem;
+ justify-items: start;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-lg);
+ padding: 0.75rem;
+}
+
+.appRoot .patientPlanResources {
+ display: grid;
+ gap: 1rem;
+}
+
+.appRoot .patientPlanResourceGroup {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.appRoot .patientPlanResource {
+ display: grid;
+ gap: 0.25rem;
+ border-block-start: 1px solid var(--border);
+ padding-block-start: 0.5rem;
+}
+
+.appRoot .patientPlanResourceName {
+ color: var(--text);
+ font-size: 0.9375rem;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.appRoot .patientPlanResourceDetail {
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
+.appRoot .patientPlanFormSection {
+ display: grid;
+ gap: 0.5rem;
+ padding-block-end: 1rem;
+}
+
+/* Save, approve, and the way back. Wraps rather than shrinking, so a phone at
+ * 320px keeps every control at its full tap target. */
+.appRoot .formActions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ margin-block-start: 1rem;
+}
+
+.appRoot .patientPlanResourceChoices {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.appRoot .patientPlanResourceChoice {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding-block-end: 0.5rem;
+}
+
+.appRoot .patientPlanResourceChoice .patientPlanResourceDetail {
+ padding-inline: 0.75rem;
+}
+
+/* A whole-row target: the label is the control, so the tap area is the card
+ * rather than the checkbox alone. */
+.appRoot .patientPlanResourceLabel {
+ display: flex;
+ align-items: start;
+ gap: 0.625rem;
+ min-height: 3rem;
+ padding: 0.625rem 0.75rem;
+ cursor: pointer;
+}
+
+.appRoot .patientPlanPaper {
+ display: grid;
+ gap: 1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xl);
+ background: var(--surface-raised);
+ padding: var(--pad-panel);
+}
+
+.appRoot .patientPlanPaperHead {
+ display: grid;
+ gap: 0.5rem;
+ border-block-end: 1px solid var(--border);
+ padding-block-end: 0.75rem;
+}
+
+.appRoot .patientPlanPaperTitle {
+ color: var(--text);
+ font-size: 1.375rem;
+ font-weight: 700;
+ line-height: 1.25;
+}
+
+.appRoot .patientPlanPaperIntro {
+ color: var(--text);
+ font-size: 0.9375rem;
+ line-height: 1.6;
+}
+
+@media print {
+ /*
+ * Large readable type, for the same reason the Personal Safety Plan uses it:
+ * this is read by the person it belongs to, on paper, often at the worst
+ * moment of their week. `pt` deliberately — a sheet has no viewport.
+ */
+ .appRoot .patientPlanPaper {
+ gap: 6mm;
+ font-size: 12pt;
+ line-height: 1.6;
+ }
+
+ .appRoot .patientPlanPaperTitle {
+ font-size: 20pt;
+ }
+
+ .appRoot .patientPlanSectionHeading {
+ font-size: 14pt;
+ }
+
+ /* Nothing a reader needs is split across a page break. */
+ .appRoot .patientPlanSection,
+ .appRoot .patientPlanResourceGroup,
+ .appRoot .patientPlanResource {
+ break-inside: avoid;
+ border-color: CanvasText;
+ background: none;
+ max-height: none;
+ overflow: visible;
+ }
+
+ .appRoot .patientPlanPaper,
+ .appRoot .patientPlanPaperHead {
+ border-color: CanvasText;
+ background: none;
+ }
+}
+
+@media (forced-colors: active) {
+ .appRoot .patientPlanPaper,
+ .appRoot .patientPlanSection,
+ .appRoot .patientPlanGap,
+ .appRoot .patientPlanStale,
+ .appRoot .patientPlanDraftNotice,
+ .appRoot .patientPlanResource,
+ .appRoot .patientPlanResourceChoice {
+ border-color: CanvasText;
+ }
+}
diff --git a/src/components/care-plan/mockups/clinical-snapshot-page.tsx b/src/components/care-plan/mockups/clinical-snapshot-page.tsx
index 76a034e85..c74d6a96d 100644
--- a/src/components/care-plan/mockups/clinical-snapshot-page.tsx
+++ b/src/components/care-plan/mockups/clinical-snapshot-page.tsx
@@ -10,6 +10,7 @@ import { PROTOTYPE_NOW } from "./fixtures";
import { PatientDirectory } from "./patient-directory";
import { PatientWorkspace } from "./patient-workspace";
import { useCarePlanPrototype } from "./prototype-provider";
+import { getPrototypeMutationBlockReason } from "./prototype-state";
import { CARE_PLAN_ROUTES } from "./routes";
import type { CmhtContact, Patient, PatientSnapshot, PrototypeScenario, SyntheticId } from "./types";
@@ -58,6 +59,16 @@ export function ClinicalSnapshotSurface({
dispatch({ type: "record-contact-intent", patientId: snapshot.patient.id, cmhtId: contact.id, channel });
}
+ const contactActionBlockedReason =
+ snapshot === null || snapshot.cmht === null
+ ? null
+ : getPrototypeMutationBlockReason(state, {
+ type: "record-contact-intent",
+ patientId: snapshot.patient.id,
+ cmhtId: snapshot.cmht.id,
+ channel: "email",
+ });
+
const workspaceRef = useRef(null);
const lastVariant = useRef(null);
@@ -117,6 +128,7 @@ export function ClinicalSnapshotSurface({
activeSection={variant === "patient" ? "overview" : null}
reviewsHref={CARE_PLAN_ROUTES.reviews}
onRecordContactIntent={recordContactIntent}
+ contactActionBlockedReason={contactActionBlockedReason}
showFullRecordLink={variant !== "patient"}
/>
);
diff --git a/src/components/care-plan/mockups/contact-actions.tsx b/src/components/care-plan/mockups/contact-actions.tsx
index ea4a37bee..c428c182e 100644
--- a/src/components/care-plan/mockups/contact-actions.tsx
+++ b/src/components/care-plan/mockups/contact-actions.tsx
@@ -29,16 +29,27 @@ export function ContactActions({
scenario,
reviewsHref,
onIntent,
+ blockedReason,
}: {
contact: CmhtContact;
scenario: PrototypeScenario;
reviewsHref: string;
onIntent: (channel: ContactChannel) => void;
+ /** The reducer's current refusal, if an attempted contact action cannot be recorded. */
+ blockedReason: string | null;
}) {
const [failedChannel, setFailedChannel] = useState(null);
function handleLaunch(channel: ContactChannel) {
return (event: MouseEvent) => {
+ // The URI and audit event are one action. If the reducer would refuse the
+ // audit event, do not open an external application and create an
+ // unrecorded contact attempt.
+ if (blockedReason !== null) {
+ event.preventDefault();
+ setFailedChannel(null);
+ return;
+ }
// The launch-failure specimen: the external application never opens, so
// the anchor's default navigation is stopped and the failure is stated.
// The details stay exactly where they were — withholding them would send
@@ -89,17 +100,41 @@ export function ContactActions({
+ {blockedReason === null ? null : (
+
+ {blockedReason}
+
+ )}
+
These controls open an application on this device. This prototype transmits nothing and holds no evidence of
delivery, readership, or reply.
diff --git a/src/components/care-plan/mockups/domain.ts b/src/components/care-plan/mockups/domain.ts
index afa3580f2..76c0a818e 100644
--- a/src/components/care-plan/mockups/domain.ts
+++ b/src/components/care-plan/mockups/domain.ts
@@ -17,6 +17,7 @@ import {
type PresentationAmendment,
type ManagementPlanVersion,
type Patient,
+ type PatientPlanVersion,
type PatientSnapshot,
type PatientSnapshotSource,
type PersonalSafetyPlanVersion,
@@ -249,6 +250,60 @@ export function getOpenSafetyPlanDraft(
return open.reduce((latest, version) => (version.version > latest.version ? version : latest));
}
+export function getCurrentPatientPlanVersion(
+ versions: readonly PatientPlanVersion[],
+ planId: SyntheticId,
+): PatientPlanVersion | null {
+ return versions.find((version) => version.planId === planId && version.state === "current") ?? null;
+}
+
+/** The patient edition being worked on. As with the Personal Safety Plan there
+ * is no `awaiting_approval` state: a patient copy waits on any clinical role,
+ * not on a senior decision. */
+export function getOpenPatientPlanDraft(
+ versions: readonly PatientPlanVersion[],
+ planId: SyntheticId,
+): PatientPlanVersion | null {
+ const open = versions.filter((version) => version.planId === planId && version.state === "draft");
+ if (open.length === 0) return null;
+ return open.reduce((latest, version) => (version.version > latest.version ? version : latest));
+}
+
+/**
+ * Whether a patient copy still describes the Management Plan in use.
+ *
+ * Always derived from the two identifiers, never stored. A stored flag would
+ * have to be written by whatever changed the Management Plan, and the one thing
+ * certain about a document already printed and carried away is that nothing in
+ * this application can reach it — so the flag would be the part most likely to
+ * be wrong, on the question that most needs to be right.
+ *
+ * A stale copy stays fully readable and is never regenerated, hidden, or
+ * withdrawn on the person's behalf. They may be holding the paper; the
+ * application's account of what they were given has to stay true to it.
+ *
+ * A `null` Management Plan current-version identifier means the Management
+ * Plan was withdrawn with nothing approved to replace it — not that nothing
+ * ever compares. A current Patient Plan version can only exist if a
+ * Management Plan was once approved for it to be derived from, so a source
+ * that is now current-less is exactly the "moved on underneath the copy"
+ * case this function exists to catch, and is stale by the same reasoning as
+ * a source that moved on to a different version.
+ */
+export function isPatientPlanVersionStale(
+ version: PatientPlanVersion | null,
+ managementPlanCurrentVersionId: SyntheticId | null,
+): boolean {
+ if (version === null || version.state !== "current") return false;
+ // A withdrawn source is not a safe exception. The patient copy still exists
+ // and may already be on paper, but there is no Current Management Plan for it
+ // to represent, so it has to carry the same needs-updating state as a copy
+ // made from an older Current version. `derivedFromManagementVersionId` is
+ // never `null` on a real version, so this comparison alone already covers
+ // it without a separate early return.
+ return version.derivedFromManagementVersionId !== managementPlanCurrentVersionId;
+}
+
/** The most recent withdrawn version, used only to keep a withdrawn plan from
* rendering identically to a patient who never had one. */
function getWithdrawnManagementVersion(
diff --git a/src/components/care-plan/mockups/management-plan-read.tsx b/src/components/care-plan/mockups/management-plan-read.tsx
index 32c366a0f..7e208e29f 100644
--- a/src/components/care-plan/mockups/management-plan-read.tsx
+++ b/src/components/care-plan/mockups/management-plan-read.tsx
@@ -568,6 +568,12 @@ export function ManagementPlanSurface({
scenario={scenario}
reviewsHref={CARE_PLAN_ROUTES.reviews}
onIntent={(channel) => recordContactIntent(cmht, channel)}
+ blockedReason={getPrototypeMutationBlockReason(state, {
+ type: "record-contact-intent",
+ patientId: patient.id,
+ cmhtId: cmht.id,
+ channel: "email",
+ })}
/>
)}
diff --git a/src/components/care-plan/mockups/patient-plan-fixtures.ts b/src/components/care-plan/mockups/patient-plan-fixtures.ts
new file mode 100644
index 000000000..f9eebeddc
--- /dev/null
+++ b/src/components/care-plan/mockups/patient-plan-fixtures.ts
@@ -0,0 +1,450 @@
+import { publicCrisisContacts } from "./fixtures";
+import type { PatientResource, PatientResourceCategory, SyntheticId } from "./types";
+
+/**
+ * Care Plan — the resources offered on a person's own copy of their plan.
+ *
+ * These live in their own module rather than in `fixtures.ts`, which is already
+ * long enough that a reader looking for one patient's plan content has to scroll
+ * past everything else. Nothing here reads a clock, a network, storage, or a
+ * random source.
+ *
+ * **Everything in this file is invented except the crisis lines.** The synthetic
+ * services, their telephone numbers, and their web addresses are fictional and
+ * `SYN-` identified. The `crisis_contact` entries are built from
+ * `publicCrisisContacts` — the four verified public numbers — rather than being
+ * typed out again here, because a resource sheet that leaves the building with a
+ * mistyped crisis number is the one error on it that could matter at 3am. There
+ * is one source of truth for those four, and this is not it.
+ *
+ * Housing and financial entries are present, and deliberately so. They are
+ * frequently the actual reason somebody keeps arriving at an emergency
+ * department, and a resource list that cannot mention rent, a bond, or a
+ * Centrelink payment is a list about the wrong problem. Alex has nowhere settled
+ * to go and Jordan is behind on rent; both are on their own plans in plain words,
+ * without either being described as a failing of theirs.
+ */
+
+/** How each category is headed on the person's own copy, in their voice. */
+export const PATIENT_RESOURCE_CATEGORY_LABEL: Record = {
+ care_team: "Your care team",
+ local_service: "Services near you",
+ housing: "Somewhere to live",
+ financial: "Money and bills",
+ transport: "Getting there",
+ carer_support: "For the people who support you",
+ alcohol_and_other_drugs: "Alcohol and other drugs",
+ cultural_or_peer: "Cultural and peer support",
+ crisis_contact: "If you need help now",
+ self_help_reading: "Things to read",
+};
+
+/**
+ * The order the categories are printed in. The person's own team first, because
+ * that is who they will actually ring; the crisis lines last, because that is
+ * where a reader looks when everything above has not worked and it should be the
+ * easiest block on the page to find by flipping to the end.
+ */
+export const PATIENT_RESOURCE_CATEGORY_ORDER = [
+ "care_team",
+ "local_service",
+ "housing",
+ "financial",
+ "transport",
+ "carer_support",
+ "alcohol_and_other_drugs",
+ "cultural_or_peer",
+ "self_help_reading",
+ "crisis_contact",
+] as const satisfies readonly PatientResourceCategory[];
+
+/**
+ * The verified public crisis lines, as resources on one person's copy. Built
+ * from the fixture rather than transcribed: the number, the hours, the stated
+ * limitation, and the page each was checked against all travel together, so a
+ * correction in one place reaches every printed sheet.
+ */
+function crisisResourcesFor(patientId: SyntheticId): PatientResource[] {
+ return publicCrisisContacts.map((contact, index) => ({
+ id: `SYN-RESOURCE-CRISIS-${patientId.slice("SYN-PATIENT-".length)}-${String(index + 1).padStart(3, "0")}`,
+ patientId,
+ category: "crisis_contact",
+ name: `${contact.name} — ${contact.telephoneDisplay}`,
+ detail:
+ contact.caveat === null
+ ? `${contact.coverage}. ${contact.availability}.`
+ : `${contact.coverage}. ${contact.availability}. ${contact.caveat}`,
+ contact: contact.telephoneDisplay,
+ sourceUrl: contact.sourceUrl,
+ isRealContact: true,
+ })) satisfies PatientResource[];
+}
+
+/**
+ * The invented services. Written to be read by the person they are for: what the
+ * service does, what it will actually help with, and what to say when ringing.
+ * None of them describes the person as a problem, and none of them makes an offer
+ * conditional on the person doing something first.
+ */
+const INVENTED_RESOURCES = [
+ // --- Rowan (SYN-PATIENT-001) ------------------------------------------------
+ {
+ id: "SYN-RESOURCE-001",
+ patientId: "SYN-PATIENT-001",
+ category: "care_team",
+ name: "North River community mental health team",
+ detail:
+ "Your team during the week. Ask for the duty worker if Sam is not in — you do not need an appointment to ring.",
+ contact: "0491 570 101",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-002",
+ patientId: "SYN-PATIENT-001",
+ category: "local_service",
+ name: "North River after-hours drop-in",
+ detail: "A quiet room and someone to talk to, evenings and weekends. You can turn up without ringing first.",
+ contact: "0491 570 130",
+ sourceUrl: "https://example.org/north-river-drop-in",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-003",
+ patientId: "SYN-PATIENT-001",
+ category: "financial",
+ name: "North River financial counselling",
+ detail:
+ "Free help with bills, debts, and Centrelink payments. They can ring a company on your behalf and ask for more time to pay.",
+ contact: "0491 570 140",
+ sourceUrl: "https://example.org/north-river-money-help",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-004",
+ patientId: "SYN-PATIENT-001",
+ category: "transport",
+ name: "Hospital travel help",
+ detail: "Help with the cost of getting to appointments, and a taxi voucher home if you come in at night.",
+ contact: "0491 570 150",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-005",
+ patientId: "SYN-PATIENT-001",
+ category: "cultural_or_peer",
+ name: "North River peer support",
+ detail:
+ "People who have used these services themselves, and will sit with you in the emergency department if you would like that.",
+ contact: "0491 570 160",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-006",
+ patientId: "SYN-PATIENT-001",
+ category: "carer_support",
+ name: "Family and carer line",
+ detail: "For Jess, or anyone else who supports you, to get advice and support of their own.",
+ contact: "0491 570 170",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-007",
+ patientId: "SYN-PATIENT-001",
+ category: "self_help_reading",
+ name: "Sleep and settling — a short guide",
+ detail: "Six pages on getting to sleep when your head will not switch off. Written to be read a bit at a time.",
+ contact: null,
+ sourceUrl: "https://example.org/north-river-sleep-guide",
+ isRealContact: false,
+ },
+
+ // --- Mira (SYN-PATIENT-002) -------------------------------------------------
+ {
+ id: "SYN-RESOURCE-008",
+ patientId: "SYN-PATIENT-002",
+ category: "care_team",
+ name: "Coastal Plains older adult community mental health team",
+ detail:
+ "Your team during the week. Devon is your care coordinator, and the duty worker can help when Devon is out.",
+ contact: "0491 570 111",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-009",
+ patientId: "SYN-PATIENT-002",
+ category: "local_service",
+ name: "Coastal Plains pain and mobility clinic",
+ detail: "For your back. They can see you at home if getting to the clinic is too much on the day.",
+ contact: "0491 570 180",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-010",
+ patientId: "SYN-PATIENT-002",
+ category: "transport",
+ name: "Community bus to appointments",
+ detail: "A door-to-door bus. Ring the day before, and tell them if you need help getting down the front step.",
+ contact: "0491 570 190",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-011",
+ patientId: "SYN-PATIENT-002",
+ category: "carer_support",
+ name: "Carer support for Daniel",
+ detail: "Advice, a break, and someone to talk to for Daniel, so that supporting you does not fall on him alone.",
+ contact: "0491 570 200",
+ sourceUrl: "https://example.org/coastal-plains-carers",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-012",
+ patientId: "SYN-PATIENT-002",
+ category: "self_help_reading",
+ name: "Living with long-term pain — large print",
+ detail: "A large-print booklet about pacing a day so pain takes less of it. Ask Devon for a paper copy.",
+ contact: null,
+ sourceUrl: "https://example.org/coastal-plains-pain-booklet",
+ isRealContact: false,
+ },
+
+ // --- Jordan (SYN-PATIENT-003) -----------------------------------------------
+ {
+ id: "SYN-RESOURCE-013",
+ patientId: "SYN-PATIENT-003",
+ category: "care_team",
+ name: "Wandoo District community mental health team",
+ detail: "Your team during the week. There is no named care coordinator yet, so ask for the duty worker.",
+ contact: "0491 570 121",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-014",
+ patientId: "SYN-PATIENT-003",
+ category: "housing",
+ name: "Wandoo District tenancy support",
+ detail:
+ "Free help if you are behind on rent or have had a notice from your landlord. Ring them early rather than late — there is much more they can do before a hearing date.",
+ contact: "0491 570 210",
+ sourceUrl: "https://example.org/wandoo-tenancy-support",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-015",
+ patientId: "SYN-PATIENT-003",
+ category: "financial",
+ name: "Wandoo District emergency relief",
+ detail: "Food, fuel, and a hand with an overdue bill. No appointment, and you do not have to explain yourself.",
+ contact: "0491 570 220",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-016",
+ patientId: "SYN-PATIENT-003",
+ category: "alcohol_and_other_drugs",
+ name: "Wandoo District alcohol and other drug service",
+ detail:
+ "You can ring them whether or not you want to change anything yet. They will talk about staying safer either way.",
+ contact: "0491 570 230",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-017",
+ patientId: "SYN-PATIENT-003",
+ category: "transport",
+ name: "Country patient travel scheme",
+ detail: "Help with fuel and a bed for the night when an appointment means a long drive.",
+ contact: "0491 570 240",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-018",
+ patientId: "SYN-PATIENT-003",
+ category: "self_help_reading",
+ name: "A hard week — what helps",
+ detail: "Four pages of things other people have found useful. Nothing in it costs anything.",
+ contact: null,
+ sourceUrl: "https://example.org/wandoo-hard-week",
+ isRealContact: false,
+ },
+
+ // --- Evelyn (SYN-PATIENT-004) -----------------------------------------------
+ {
+ id: "SYN-RESOURCE-019",
+ patientId: "SYN-PATIENT-004",
+ category: "care_team",
+ name: "North River community mental health team",
+ detail: "Your team during the week. Ask for the duty worker — you do not need an appointment to ring.",
+ contact: "0491 570 101",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-020",
+ patientId: "SYN-PATIENT-004",
+ category: "local_service",
+ name: "North River women's health service",
+ detail: "A women-only service, with a private room and a female clinician. You can ask for either when you ring.",
+ contact: "0491 570 250",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-021",
+ patientId: "SYN-PATIENT-004",
+ category: "housing",
+ name: "North River housing and safety support",
+ detail:
+ "Somewhere to stay, and help sorting out a longer-term place. They can talk through your options without you having to decide anything on the call.",
+ contact: "0491 570 260",
+ sourceUrl: "https://example.org/north-river-housing",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-022",
+ patientId: "SYN-PATIENT-004",
+ category: "cultural_or_peer",
+ name: "North River women's peer group",
+ detail: "A small group that meets on Thursdays. You can come once and see what you think.",
+ contact: null,
+ sourceUrl: "https://example.org/north-river-womens-group",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-023",
+ patientId: "SYN-PATIENT-004",
+ category: "self_help_reading",
+ name: "Getting privacy in a busy place",
+ detail: "A short guide to asking for a private room, and what to say if the first answer is no.",
+ contact: null,
+ sourceUrl: "https://example.org/north-river-privacy-guide",
+ isRealContact: false,
+ },
+
+ // --- Alex (SYN-PATIENT-005) -------------------------------------------------
+ {
+ id: "SYN-RESOURCE-024",
+ patientId: "SYN-PATIENT-005",
+ category: "care_team",
+ name: "North River community mental health team",
+ detail: "Your team during the week. You have met them once; you can ring them whether or not you come back in.",
+ contact: "0491 570 101",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-025",
+ patientId: "SYN-PATIENT-005",
+ category: "housing",
+ name: "North River youth housing service",
+ detail:
+ "A bed tonight, and help finding somewhere longer term. Ring any time — they answer at night, which is when it usually matters.",
+ contact: "0491 570 270",
+ sourceUrl: "https://example.org/north-river-youth-housing",
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-026",
+ patientId: "SYN-PATIENT-005",
+ category: "financial",
+ name: "Centrelink social work service",
+ detail:
+ "They can sort out a payment quickly when you have nowhere to stay, and you do not need an address to start.",
+ contact: "0491 570 280",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-027",
+ patientId: "SYN-PATIENT-005",
+ category: "alcohol_and_other_drugs",
+ name: "North River young people's alcohol and other drug service",
+ detail: "For under twenty-fives. They will talk about staying safer whether or not you want to stop anything.",
+ contact: "0491 570 290",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-028",
+ patientId: "SYN-PATIENT-005",
+ category: "cultural_or_peer",
+ name: "North River young adult peer worker",
+ detail: "Someone closer to your own age who has been through this, and will meet you somewhere that is not a ward.",
+ contact: "0491 570 300",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-029",
+ patientId: "SYN-PATIENT-005",
+ category: "local_service",
+ name: "North River walk-in health clinic",
+ detail: "Free, no appointment, no Medicare card needed. Good for anything physical while things are unsettled.",
+ contact: "0491 570 310",
+ sourceUrl: null,
+ isRealContact: false,
+ },
+ {
+ id: "SYN-RESOURCE-030",
+ patientId: "SYN-PATIENT-005",
+ category: "self_help_reading",
+ name: "Waiting well",
+ detail: "Two pages on getting through a long wait in an emergency department without leaving before you are seen.",
+ contact: null,
+ sourceUrl: "https://example.org/north-river-waiting-well",
+ isRealContact: false,
+ },
+] satisfies readonly PatientResource[];
+
+/** Every patient the invented resources name, in fixture order. */
+const RESOURCE_PATIENT_IDS = [
+ "SYN-PATIENT-001",
+ "SYN-PATIENT-002",
+ "SYN-PATIENT-003",
+ "SYN-PATIENT-004",
+ "SYN-PATIENT-005",
+] as const satisfies readonly SyntheticId[];
+
+/**
+ * Every resource in the prototype: the invented services, plus the verified
+ * crisis lines for each person. One flat list, because the state holds one flat
+ * list and a shape that differs between the fixture and the state is a shape
+ * somebody has to convert at the boundary.
+ */
+export const syntheticPatientResources: readonly PatientResource[] = [
+ ...INVENTED_RESOURCES,
+ ...RESOURCE_PATIENT_IDS.flatMap((patientId) => crisisResourcesFor(patientId)),
+];
+
+export function getPatientResources(
+ resources: readonly PatientResource[],
+ patientId: SyntheticId,
+): readonly PatientResource[] {
+ return resources.filter((resource) => resource.patientId === patientId);
+}
+
+/**
+ * Resources grouped for display, in `PATIENT_RESOURCE_CATEGORY_ORDER`, with
+ * empty categories dropped. A heading with nothing under it on a person's own
+ * copy reads as something they were not offered.
+ */
+export function groupPatientResources(
+ resources: readonly PatientResource[],
+): readonly { category: PatientResourceCategory; label: string; resources: readonly PatientResource[] }[] {
+ return PATIENT_RESOURCE_CATEGORY_ORDER.map((category) => ({
+ category,
+ label: PATIENT_RESOURCE_CATEGORY_LABEL[category],
+ resources: resources.filter((resource) => resource.category === category),
+ })).filter((group) => group.resources.length > 0);
+}
diff --git a/src/components/care-plan/mockups/patient-plan-form.tsx b/src/components/care-plan/mockups/patient-plan-form.tsx
new file mode 100644
index 000000000..4ca997ebe
--- /dev/null
+++ b/src/components/care-plan/mockups/patient-plan-form.tsx
@@ -0,0 +1,463 @@
+"use client";
+
+import Link from "next/link";
+import { useState, type FormEvent } from "react";
+
+import { Button } from "@/components/ui/button";
+import { ErrorSummary } from "@/components/ui/form-field";
+import { EmptyState, InlineNotice, ignoreUnavailableActivation } from "@/components/ui-primitives";
+
+import styles from "./care-plan.module.css";
+import {
+ buildPatientSnapshot,
+ canPerformAction,
+ getCurrentPatientPlanVersion,
+ getOpenPatientPlanDraft,
+} from "./domain";
+import { PROTOTYPE_NOW } from "./fixtures";
+import { PATIENT_RESOURCE_CATEGORY_LABEL, getPatientResources } from "./patient-plan-fixtures";
+import { PATIENT_PLAN_SECTION_LEAD_IN } from "./patient-plan-transform";
+import { useCarePlanPrototype } from "./prototype-provider";
+import { getPrototypeMutationBlockReason } from "./prototype-state";
+import {
+ PROTOTYPE_OUTCOME_TONE,
+ PROTOTYPE_ROLE_LABEL,
+ PlanTextArea,
+ SectionFrame,
+ StatusMark,
+ SyntheticMarker,
+ formatPerthDate,
+} from "./prototype-ui";
+import { carePlanRoute } from "./routes";
+import type {
+ PatientPlanSection,
+ PatientPlanSectionKey,
+ PatientPlanVersion,
+ PatientResource,
+ PrototypeScenario,
+ SyntheticId,
+} from "./types";
+
+/**
+ * Writing the patient copy.
+ *
+ * The form's whole job is the gaps. The offline conversion has already done what
+ * it could do safely and refused everything else, so this screen opens showing
+ * what is missing and why — the reason beside each blank, in the clinician's
+ * words, saying what the machine would not risk getting wrong.
+ *
+ * Approval needs any clinical role and no senior sign-off. A person waiting days
+ * for their own copy of their own plan is the failure that would matter here,
+ * and the emergency department at 2am has no consultant to wait for. What
+ * approval does need is that nothing is still blank: the control states that as
+ * its reason while any gap remains, and the reducer refuses it anyway.
+ *
+ * Every section is editable, including the ones the conversion filled. A
+ * clinician who reads a converted sentence and thinks it lands badly must be
+ * able to rewrite it, and that judgement is theirs rather than the machine's.
+ */
+
+/** Stable field identifiers, so an error-summary link and the field it names
+ * cannot drift apart. */
+export function patientPlanFieldId(key: string): string {
+ return `care-plan-patient-plan-form-${key}`;
+}
+
+type FormValues = {
+ /** Each section as the text in its own control: one point per line. */
+ sections: Record;
+ /** The resource identifiers chosen for this copy. */
+ resourceIds: readonly SyntheticId[];
+};
+
+type FieldEntry = { fieldId: string; label: string; message: string };
+
+function linesFrom(text: string): string[] {
+ return text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0);
+}
+
+function valuesFrom(version: PatientPlanVersion): FormValues {
+ const sections = {} as Record;
+ for (const section of version.sections) sections[section.key] = section.body.join("\n");
+ return { sections, resourceIds: version.resources.map((resource) => resource.id) };
+}
+
+/**
+ * The edited sections, back in domain shape.
+ *
+ * A section the clinician has written into is no longer a gap. One they have
+ * emptied becomes a gap again, and keeps the reason the conversion originally
+ * gave — the machine's answer has not changed just because somebody deleted the
+ * text over it.
+ */
+function sectionsFrom(values: FormValues, original: readonly PatientPlanSection[]): PatientPlanSection[] {
+ return original.map((section) => {
+ const body = linesFrom(values.sections[section.key] ?? "");
+ return { ...section, body, gap: body.length === 0, gapReason: body.length === 0 ? section.gapReason : null };
+ });
+}
+
+function resourcesFrom(values: FormValues, available: readonly PatientResource[]): PatientResource[] {
+ return available.filter((resource) => values.resourceIds.includes(resource.id));
+}
+
+/** What must be true before this copy can be handed to somebody. */
+function validate(values: FormValues, original: readonly PatientPlanSection[]): FieldEntry[] {
+ return original
+ .filter((section) => linesFrom(values.sections[section.key] ?? "").length === 0)
+ .map((section) => ({
+ fieldId: patientPlanFieldId(section.key),
+ label: section.heading,
+ message:
+ "Write this section with this person, one point per line. A heading with nothing under it, on a copy handed to somebody, reads as though nothing about them was worth writing.",
+ }));
+}
+
+export function PatientPlanFormSurface({
+ patientId,
+ scenario,
+ navigate,
+}: {
+ patientId: string | null;
+ scenario: PrototypeScenario;
+ navigate: (href: string) => void;
+}) {
+ const { state, dispatch } = useCarePlanPrototype();
+ const snapshot = patientId === null ? null : buildPatientSnapshot(state, patientId as SyntheticId, PROTOTYPE_NOW);
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === snapshot?.patient.id) ?? null;
+ const draft = plan === null ? null : getOpenPatientPlanDraft(state.patientPlanVersions, plan.id);
+ const current = plan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, plan.id);
+
+ // Seeded during render rather than in an effect: an effect would leave one
+ // frame showing the previous version's text in a form already claiming to be
+ // editing this one.
+ const [seededFrom, setSeededFrom] = useState(draft?.id ?? null);
+ const [values, setValues] = useState(() =>
+ draft === null ? { sections: {} as Record, resourceIds: [] } : valuesFrom(draft),
+ );
+ const [errors, setErrors] = useState([]);
+ const [attempt, setAttempt] = useState(0);
+
+ if (draft !== null && seededFrom !== draft.id) {
+ setSeededFrom(draft.id);
+ setValues(valuesFrom(draft));
+ setErrors([]);
+ }
+
+ /*
+ * There is deliberately no focus effect here. `ErrorSummary` moves focus to
+ * itself on a failed submit, which is the repository's behaviour for every
+ * form in the product; this one does not get to be different.
+ */
+
+ if (snapshot === null) {
+ return (
+
+ );
+ }
+
+ const { patient, currentManagementVersion } = snapshot;
+
+ if (scenario === "identity-uncertain") {
+ return (
+
+
+ This record has not been confirmed as the right person. Nothing can be written against it.
+ Return to search and choose the record again.
+
+
+ );
+ }
+
+ const actor = state.users.find((user) => user.id === state.activeUserId) ?? null;
+ const mayAuthor = actor !== null && canPerformAction(actor.role, "approve_patient_plan");
+ const available = getPatientResources(state.patientResources, patient.id);
+
+ const outcome =
+ state.lastOutcome === null ? null : (
+ {state.lastOutcome.message}
+ );
+
+ if (draft === null) {
+ const createBlockedReason = getPrototypeMutationBlockReason(state, {
+ type: "create-patient-plan-draft",
+ patientId: patient.id,
+ });
+ return (
+
+
+ {outcome}
+
+ {currentManagementVersion === null ? (
+
+ {`${patient.preferredName} has no Current Plan, so there is nothing to make a patient copy of.`}
+
+ ) : (
+ <>
+
+ {`The copy is produced from Current Plan version ${currentManagementVersion.version} by a fixed conversion that runs on this device. It converts only what it can convert safely and leaves everything else blank, with the reason, for you to write with ${patient.preferredName}. No language model is involved.`}
+
+ {current === null ? null : (
+
+ {`Version ${current.version} stays the approved copy until you approve a new one, so ${patient.preferredName} is never left without one.`}
+
+ )}
+
+ {createBlockedReason !== null ? (
+
+ ) : (
+
+ )}
+
+ {createBlockedReason === null ? null : (
+
+ {createBlockedReason}
+
+ )}
+ >
+ )}
+
+
+ Back to the Patient Plan
+
+
+
+
+ );
+ }
+
+ const editedSections = sectionsFrom(values, draft.sections);
+ const remainingGaps = editedSections.filter((section) => section.gap);
+ const approveBlockedReason = getPrototypeMutationBlockReason(state, {
+ type: "approve-patient-plan-version",
+ versionId: draft.id,
+ });
+
+ function handleSave(event: FormEvent) {
+ event.preventDefault();
+ if (draft === null) return;
+ setErrors([]);
+ setAttempt((count) => count + 1);
+ dispatch({
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: sectionsFrom(values, draft.sections), resources: resourcesFrom(values, available) },
+ });
+ }
+
+ function handleApprove() {
+ if (draft === null) return;
+ const found = validate(values, draft.sections);
+ setAttempt((count) => count + 1);
+ if (found.length > 0) {
+ setErrors(found);
+ return;
+ }
+ setErrors([]);
+ dispatch({
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: sectionsFrom(values, draft.sections), resources: resourcesFrom(values, available) },
+ });
+ dispatch({ type: "approve-patient-plan-version", versionId: draft.id });
+ navigate(carePlanRoute.patientPlan(patient.id));
+ }
+
+ return (
+
+
+ {outcome}
+
+
+
+
+
+
+
+ {`Everything below is a draft. ${patient.preferredName} has not been given it, and it is not their copy until you approve it.`}
+
+
+ The blanks are deliberate. The conversion runs offline on this device with no language model of any kind, and
+ where it could not be confident it left the section empty rather than guessing. What it would have guessed
+ wrong is exactly what a person would have read as being about them.
+
+
+
+ {mayAuthor ? null : (
+
+ {actor === null
+ ? "No synthetic user is selected, so nothing written here could be attributed to anyone."
+ : `${actor.displayName} is signed in with the ${PROTOTYPE_ROLE_LABEL[actor.role].toLowerCase()} role, which does not carry writing or approving a patient copy. Any clinical role can.`}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/care-plan/mockups/patient-plan-pages.tsx b/src/components/care-plan/mockups/patient-plan-pages.tsx
new file mode 100644
index 000000000..e876bdb54
--- /dev/null
+++ b/src/components/care-plan/mockups/patient-plan-pages.tsx
@@ -0,0 +1,572 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { EmptyState, InlineNotice, ignoreUnavailableActivation } from "@/components/ui-primitives";
+import { BrowserPrintButton, PrintOutput, PrintSection } from "@/components/ui/print-output";
+
+import styles from "./care-plan.module.css";
+import {
+ buildPatientSnapshot,
+ canPerformAction,
+ getCurrentPatientPlanVersion,
+ getOpenPatientPlanDraft,
+ isPatientPlanVersionStale,
+} from "./domain";
+import { PROTOTYPE_NOW } from "./fixtures";
+import { groupPatientResources } from "./patient-plan-fixtures";
+import { PATIENT_PLAN_SECTION_LEAD_IN } from "./patient-plan-transform";
+import { useCarePlanPrototype } from "./prototype-provider";
+import { getPrototypeMutationBlockReason } from "./prototype-state";
+import {
+ DefinitionRow,
+ NOT_RECORDED,
+ PROTOTYPE_OUTCOME_TONE,
+ PROTOTYPE_ROLE_LABEL,
+ SectionFrame,
+ StatusMark,
+ SyntheticMarker,
+ formatPerthDate,
+} from "./prototype-ui";
+import { carePlanRoute } from "./routes";
+import type {
+ Patient,
+ PatientPlanVersion,
+ PatientResource,
+ PrototypeScenario,
+ PrototypeUser,
+ SyntheticId,
+} from "./types";
+
+/**
+ * The Patient Plan: the patient-facing edition of an approved Management Plan
+ * Version, and the copy the person takes away.
+ *
+ * The Management Plan is written by clinicians for clinicians. This is the same
+ * agreement in the person's own voice — the same eight headings whether it is
+ * read here or printed, so the page and the paper cannot say two different
+ * things.
+ *
+ * Two rules shape everything below.
+ *
+ * A gap is never printed. A gap is a place the offline conversion refused to
+ * guess, and approval is blocked while any remains, so an approved copy has
+ * nothing missing from it. That is why this surface shows gaps loudly on a draft
+ * and the printed copy has no notion of one.
+ *
+ * A stale copy is never quietly repaired. When the Management Plan moves on, the
+ * patient copy is marked as needing updating and stays completely readable. It
+ * is not regenerated, hidden, or withdrawn: the person may be holding the paper,
+ * and this application's account of what they were given has to match what they
+ * actually have.
+ *
+ * The synthetic marker is rendered *inside* the printed subtree deliberately.
+ * The shared print rule makes everything outside `[data-print-output]`
+ * invisible, so the shell header's marker cannot reach the paper — and a sheet
+ * carrying somebody's care plan and four real crisis telephone numbers with
+ * nothing saying the content is fictional is the exact failure this guards
+ * against. It has been introduced twice already.
+ */
+
+function displayName(users: readonly PrototypeUser[], id: SyntheticId | null): string | undefined {
+ if (id === null) return undefined;
+ return users.find((user) => user.id === id)?.displayName;
+}
+
+/**
+ * The eight sections, generated by iterating the version's own list. One
+ * rendering serves the screen and the paper.
+ *
+ * `gaps` is the one thing the two surfaces must not share. On the draft a gap is
+ * shown with its reason, because a clinician needs to see what is still owed and
+ * why the machine would not attempt it. On paper there are none, because a
+ * version carrying one cannot be approved — and were one ever to reach here it
+ * is omitted outright rather than printed as a heading with nothing beneath it.
+ * "My reasons for living — Not recorded" was printed on a person's own safety
+ * plan once; nothing in this file may reproduce it.
+ */
+function PatientPlanSections({
+ sections,
+ gaps = "state",
+}: {
+ sections: readonly PatientPlanVersion["sections"][number][];
+ /** `state` — the working copy: a gap is shown with its reason.
+ * `omit` — the person's own copy: a gap is not printed at all. */
+ gaps?: "state" | "omit";
+}) {
+ const shown = sections.filter((section) => gaps === "state" || !section.gap);
+ return (
+
+ {shown.map((section) => (
+
+
+ {section.heading}
+
+ {PATIENT_PLAN_SECTION_LEAD_IN[section.key]}
+ {section.gap ? (
+
+
+ {section.gapReason ?? NOT_RECORDED}
+
+ ) : (
+
+ {section.body.map((line) => (
+ - {line}
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
+
+/**
+ * The person's resources, grouped by category.
+ *
+ * The crisis contacts are the only real details on the page, and they say so:
+ * each carries its stated limitation, its hours, and the public page it was
+ * checked against, because a reader who dials a number on this sheet must reach
+ * a service rather than a fictional one.
+ */
+function PatientPlanResources({ resources }: { resources: readonly PatientResource[] }) {
+ const groups = groupPatientResources(resources);
+ if (groups.length === 0) return null;
+ return (
+
+ {groups.map((group) => (
+
+ {group.label}
+ {group.resources.map((resource) => (
+
+
+ {resource.contact === null || resource.isRealContact
+ ? resource.name
+ : `${resource.name} — ${resource.contact}`}
+
+
+ {resource.detail}
+
+ {resource.sourceUrl === null ? null : (
+
+ )}
+
+ ))}
+
+ ))}
+
+ );
+}
+
+/**
+ * The notice a Current patient copy carries once the Management Plan it was
+ * written from has been replaced. Derived every render from the two identifiers,
+ * never stored.
+ */
+function StaleNotice({ version, patient }: { version: PatientPlanVersion; patient: Patient }) {
+ return (
+
+ This copy needs updating.{" "}
+ {`The Management Plan has moved on since version ${version.version} of ${patient.preferredName}'s own copy was written. Everything in it is still shown, and nothing has been changed or taken away: ${patient.preferredName} may be holding a printed copy, and what this page says they were given has to stay true. Go through it with them and write a new one.`}
+
+ );
+}
+
+function PatientPlanIdentityBand({ patient }: { patient: Patient }) {
+ return (
+
+
+ {patient.fullName}
+ {`${patient.mrn} — born ${formatPerthDate(patient.dateOfBirth)}`}
+
+ );
+}
+
+export function PatientPlanSurface({ patientId, scenario }: { patientId: string | null; scenario: PrototypeScenario }) {
+ const { state, dispatch } = useCarePlanPrototype();
+ const snapshot = patientId === null ? null : buildPatientSnapshot(state, patientId as SyntheticId, PROTOTYPE_NOW);
+
+ if (snapshot === null) {
+ return (
+
+ );
+ }
+
+ const { patient, managementPlan, currentManagementVersion } = snapshot;
+
+ if (scenario === "identity-uncertain") {
+ return (
+
+
+ This record has not been confirmed as the right person. Nothing of this plan is shown,
+ because this is the copy written to be handed to somebody. Return to search and choose the record again.
+
+
+ );
+ }
+
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === patient.id) ?? null;
+ const current = plan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, plan.id);
+ const draft = plan === null ? null : getOpenPatientPlanDraft(state.patientPlanVersions, plan.id);
+ const stale = isPatientPlanVersionStale(current, managementPlan?.currentVersionId ?? null);
+
+ const actor = state.users.find((user) => user.id === state.activeUserId) ?? null;
+ const mayAuthor = actor !== null && canPerformAction(actor.role, "approve_patient_plan");
+ const createBlockedReason = getPrototypeMutationBlockReason(state, {
+ type: "create-patient-plan-draft",
+ patientId: patient.id,
+ });
+
+ const ownership = (
+
+
+ {`This is ${patient.preferredName}'s own copy of the plan, written to be read by ${patient.preferredName} rather than by a clinician. It carries the same agreement as the Management Plan, in ${patient.preferredName}'s own voice.`}
+
+
+ It is produced by a fixed offline conversion that runs on this device. No language model, and no service of any
+ kind, is involved at any point. Anything the conversion could not turn into everyday words with confidence is
+ left blank for a clinician to write, and a copy with a blank in it cannot be approved.
+
+
+ );
+
+ const authoringLink = mayAuthor ? (
+
+
+ {draft === null ? "Write a new copy with this person" : `Continue draft version ${draft.version}`}
+
+
+ ) : (
+
+ {actor === null
+ ? "No synthetic user is selected, so nothing written here could be attributed to anyone."
+ : `${actor.displayName} is signed in with the ${PROTOTYPE_ROLE_LABEL[actor.role].toLowerCase()} role, which does not carry writing or approving a patient copy. Any clinical role can.`}
+
+ );
+
+ const createControl =
+ currentManagementVersion === null ? (
+
+ {`${patient.preferredName} has no Current Plan, so there is nothing to make a patient copy of. A draft or a withdrawn version is not an agreed plan, and a copy of one would describe care nobody agreed to.`}
+
+ ) : createBlockedReason !== null ? (
+ <>
+
+
+ {createBlockedReason}
+
+ >
+ ) : (
+
+ );
+
+ return (
+
+
+ {ownership}
+
+ {state.lastOutcome === null ? null : (
+ {state.lastOutcome.message}
+ )}
+
+ {stale && current !== null ? : null}
+
+ {current === null ? (
+
+
+ {`${patient.preferredName} has no approved Patient Plan. Nothing is shown in its place, because a draft is not a copy anybody has agreed to hand over.`}
+
+ {draft === null ? null : (
+
+
+
+ {`A draft was started on ${formatPerthDate(draft.createdAt)}. It has ${
+ draft.sections.filter((section) => section.gap).length
+ } of ${draft.sections.length} sections still to write, and cannot be approved until they are.`}
+
+
+ )}
+ {createControl}
+ {authoringLink}
+
+ ) : (
+ <>
+
+
+
+ {stale ? : null}
+
+
+ {displayName(state.users, current.approvedBy)}
+ {formatPerthDate(current.approvedAt)}
+
+ {String(
+ state.managementPlanVersions.find((version) => version.id === current.derivedFromManagementVersionId)
+ ?.version ?? NOT_RECORDED,
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {`The printed copy is written to ${patient.preferredName}, carries only ${patient.preferredName}'s preferred name and record number, and holds nothing from the clinical record beyond what is on this page.`}
+
+
+
+ Print this copy
+
+
+ {authoringLink}
+
+ >
+ )}
+
+ );
+}
+
+/**
+ * The printed-at stamp, formatted from `PROTOTYPE_NOW` alone. Deterministic by
+ * construction: the prototype reads no clock, so two runs of the same route
+ * produce the same sheet of paper.
+ */
+function perthStamp(iso: string): string {
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso);
+ if (match === null) return NOT_RECORDED;
+ const hour24 = Number(match[4]);
+ const meridiem = hour24 < 12 ? "am" : "pm";
+ const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12;
+ return `${match[3]}/${match[2]}/${match[1]} at ${hour12}:${match[5]} ${meridiem} AWST`;
+}
+
+const PRINTED_AT = `This copy was printed on ${perthStamp(PROTOTYPE_NOW)}.`;
+
+export function PatientPlanPrintSurface({
+ patientId,
+ scenario,
+}: {
+ patientId: string | null;
+ scenario: PrototypeScenario;
+}) {
+ const { state, dispatch } = useCarePlanPrototype();
+ const [printFailed, setPrintFailed] = useState(false);
+ const snapshot = patientId === null ? null : buildPatientSnapshot(state, patientId as SyntheticId, PROTOTYPE_NOW);
+
+ if (snapshot === null) {
+ return (
+
+ );
+ }
+
+ const { patient, managementPlan } = snapshot;
+
+ // Printing is exempt from the offline block and from nothing else. Paper
+ // carrying a nearby person's plan cannot be taken back once it has left the
+ // room, so identity uncertainty stops it outright and nothing is offered.
+ if (scenario === "identity-uncertain") {
+ return (
+
+
+ This record has not been confirmed as the right person. Nothing is offered for printing,
+ because paper carrying a nearby person’s plan cannot be taken back. Return to search and choose the
+ record again.
+
+
+ );
+ }
+
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === patient.id) ?? null;
+ const version = plan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, plan.id);
+
+ if (version === null) {
+ return (
+
+
+ {`${patient.preferredName} has no approved Patient Plan, so there is nothing to print. A draft is not a copy anybody has agreed to hand over, and a draft on paper is a plan somebody may follow.`}
+
+
+
+ Back to the Patient Plan
+
+
+
+ );
+ }
+
+ const blockedReason = getPrototypeMutationBlockReason(state, {
+ type: "record-patient-plan-print-intent",
+ patientId: patient.id,
+ });
+ const stale = isPatientPlanVersionStale(version, managementPlan?.currentVersionId ?? null);
+
+ return (
+
+
+ {blockedReason !== null ? (
+
+ ) : scenario === "print-failure" ? (
+ // The print-failure specimen. The browser is never asked to print, and
+ // no intent is recorded either: an audit line saying the print view
+ // was opened would be untrue.
+
+ ) : (
+ dispatch({ type: "record-patient-plan-print-intent", patientId: patient.id })}
+ />
+ )}
+
+ Back to the Patient Plan
+
+
+
+ {blockedReason === null ? null : (
+
+ {blockedReason}
+
+ )}
+
+ {stale ? (
+
+
+
+ ) : null}
+
+ {printFailed ? (
+
+ The print view could not be opened. Nothing was printed, and nothing was recorded. The whole
+ copy is still on this page: read it from the screen, write the numbers down, or try printing again from the
+ browser’s own menu.
+
+ ) : null}
+
+ {state.lastOutcome === null ? null : (
+
+ {state.lastOutcome.message}
+
+ )}
+
+
+
+
+ My plan
+ {/*
+ Minimum necessary, and nothing beyond it. A preferred name and a
+ record number are what a person needs to know the sheet is theirs and
+ what a service needs to match it to them. A date of birth, an
+ address, a pronoun, a home service, an episode, the Management Plan's
+ version history, its owner, its approver, its review date, or
+ anything about what a clinician thinks is not on this page: none of
+ it helps the reader and all of it travels.
+ */}
+
+ {patient.preferredName}
+ {patient.mrn}
+ {String(version.version)}
+ {formatPerthDate(version.approvedAt)}
+
+
+ This is your copy of the plan you and your team wrote together. Keep it somewhere you can find it quickly,
+ and bring it with you if you can. If something in it stops fitting, tell someone on your team so you can
+ write it again together.
+
+
+
+ {stale ? (
+ // A stale copy is going to paper here just as readably as a current
+ // one — the domain rule this warning exists to serve is that nothing
+ // is hidden or withdrawn on the person's behalf. What must not happen
+ // is the paper leaving the room silent about it: the on-screen
+ // StaleNotice above sits outside this element and the shared print
+ // rule hides everything outside `[data-print-output]`, so this is the
+ // one copy of the warning a reader holding the sheet ever sees.
+
+ This copy needs updating. The Management Plan has moved on since version{" "}
+ {String(version.version)} of this copy was written. Everything here is still shown, and nothing has been
+ changed or taken away. Check with the team before treating it as current.
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/care-plan/mockups/patient-plan-transform.ts b/src/components/care-plan/mockups/patient-plan-transform.ts
new file mode 100644
index 000000000..031e070bb
--- /dev/null
+++ b/src/components/care-plan/mockups/patient-plan-transform.ts
@@ -0,0 +1,1117 @@
+import type {
+ ManagementPlanContent,
+ ManagementPlanVersion,
+ Patient,
+ PatientPlanSection,
+ PatientPlanSectionKey,
+ PatientResource,
+ SyntheticId,
+} from "./types";
+import { PATIENT_PLAN_SECTION_KEYS } from "./types";
+
+/**
+ * Care Plan — the deterministic, offline Patient Plan transformation.
+ *
+ * `buildPatientPlanDraft` turns one approved Management Plan Version into a
+ * draft of the same content in the person's own voice. It is a pure function of
+ * its three arguments: no network, storage, timer, randomness, wall clock, or
+ * model call, and nothing imported from outside this namespace. The same inputs
+ * always produce the same output, byte for byte.
+ *
+ * **No language model is reachable from any part of this.** That is a product
+ * boundary rather than an implementation convenience, and it is the reason the
+ * transformation is shaped the way it is. A model asked to "rewrite this for a
+ * patient" produces fluent, confident, wrong sentences about somebody's care and
+ * leaves a reader no way to tell which ones. This does the opposite: it converts
+ * only what it can convert by rule, and everything else becomes a visible gap
+ * with a stated reason, for a clinician to write.
+ *
+ * The seam is the function boundary. A later model-backed implementation can
+ * replace this function without touching the version model, the approval step,
+ * or any screen — provided it keeps the same contract: a gap rather than a
+ * guess.
+ *
+ * ## Why free rewriting is not attempted
+ *
+ * Swapping words inside arbitrary sentences produces confident nonsense. This
+ * works only because a Management Plan is already eleven fields with known
+ * meanings, so the transformation never has to work out what a sentence is
+ * *for*: the field it came from already says. Each field maps to a known
+ * patient-voice heading, a curated dictionary replaces clinical vocabulary with
+ * everyday words, and the shift to second person is a bounded substitution of
+ * the person's own name and pronouns — with verb agreement read from a table,
+ * never inferred.
+ *
+ * ## Everything not confidently convertible is a gap
+ *
+ * A section gaps whole. It never carries part of its content, because three of
+ * five points with two silently dropped looks finished and is not.
+ *
+ * The result is an incomplete draft by design, and often a mostly incomplete
+ * one. That is the intended outcome, not a shortfall: the machine does the part
+ * it can do safely, a clinician writes the rest with the person, and approval is
+ * refused until they have.
+ */
+
+/** The eight headings, in `PATIENT_PLAN_SECTION_KEYS` order, in the person's
+ * own voice. Written as somebody would say them, never as a field name. */
+export const PATIENT_PLAN_SECTION_HEADING: Record = {
+ whyWeWroteThis: "Why we wrote this together",
+ whatMattersToYou: "What matters to you",
+ whatHelpsYou: "What helps you",
+ whatMakesThingsHarder: "What makes things harder",
+ whatWeAgreedWillHappen: "What we agreed will happen when you come to the emergency department",
+ ifSomethingNewIsHappening: "If something new is happening",
+ whoIsInvolved: "Who's involved in your care",
+ thingsThatMightHelp: "Things that might help",
+};
+
+/**
+ * A fixed sentence under each heading, addressed to the person. This is where
+ * the framing shift actually lives: the heading and this line set the voice, so
+ * the content underneath needs only its own words changed rather than being
+ * rewritten into a second person it was never written in.
+ *
+ * None of them tells the person what to do, thanks them for cooperating, or
+ * describes them. They say what the section is.
+ */
+export const PATIENT_PLAN_SECTION_LEAD_IN: Record = {
+ whyWeWroteThis: "This is what we wrote down about why this plan exists.",
+ whatMattersToYou: "These are the things you have said matter to you.",
+ whatHelpsYou: "These are the things that have helped before.",
+ whatMakesThingsHarder: "These are the things that have made a visit harder, so we can try to avoid them.",
+ whatWeAgreedWillHappen: "This is the approach you and your team agreed for when you come in.",
+ ifSomethingNewIsHappening:
+ "This plan is about what usually happens. If something is different this time, say so — you will be looked at afresh.",
+ whoIsInvolved: "These are the people and teams who know you.",
+ thingsThatMightHelp: "These are practical things that can be arranged for you.",
+};
+
+/**
+ * Which clinical fields each patient-voice heading is built from. Two fields are
+ * deliberately absent, and there is no ninth section.
+ */
+export const PATIENT_PLAN_SECTION_SOURCES: Record = {
+ whyWeWroteThis: ["whyThisPlanExists"],
+ whatMattersToYou: ["whatThePersonWants"],
+ whatHelpsYou: ["whatHelps"],
+ whatMakesThingsHarder: ["whatMakesItWorse"],
+ whatWeAgreedWillHappen: ["agreedEdApproach"],
+ ifSomethingNewIsHappening: ["whatWouldMakeThisDifferent", "reviewTriggers"],
+ whoIsInvolved: ["whoElseIsInvolved"],
+ thingsThatMightHelp: ["practicalNeeds"],
+};
+
+/**
+ * The content fields no heading is built from. Stated here rather than left as
+ * an absence, so a missing mapping reads as a decision instead of an oversight,
+ * and a test proves every field is either mapped above or named here.
+ *
+ * `physicalHealthAndMedication` — the person has their own record of their
+ * medicines, and copying medication detail onto a sheet that leaves the building
+ * is a privacy cost with nothing bought by it.
+ *
+ * `howToApproach` — written in the imperative, to staff. Converted onto the
+ * person's own copy it becomes a set of instructions to *them*, which was the
+ * worst thing the first run of this transformation produced: "You asked not to
+ * take part in writing this plan. Say so plainly when you use it, and offer
+ * again to write it together", printed on the plan of the person it describes.
+ * What this field holds that the person needs is already in `whatHelps`, said
+ * from their side; the rest is how a stranger should open a conversation, which
+ * is not theirs to be handed.
+ */
+export const PATIENT_PLAN_OMITTED_CONTENT_KEYS = [
+ "physicalHealthAndMedication",
+ "howToApproach",
+] as const satisfies readonly (keyof ManagementPlanContent)[];
+
+/**
+ * The one section that is never converted, whatever it contains.
+ *
+ * It carries the agreed emergency-department approach and any position on
+ * admission. It is where a wording slip does the most harm, and it is the
+ * section a person is most likely to read as a judgement about them rather than
+ * as an agreement they were part of. So it is written by a clinician every time,
+ * and no content, however plain, makes it eligible for conversion.
+ */
+export const NEVER_CONVERTED_SECTION_KEYS = [
+ "whatWeAgreedWillHappen",
+] as const satisfies readonly PatientPlanSectionKey[];
+
+// --- The curated dictionary ------------------------------------------------------
+
+/**
+ * Clinical vocabulary with a confident everyday equivalent.
+ *
+ * An entry is only allowed here when the replacement is safe wherever the term
+ * appears — noun for noun, verb for verb, adjective for adjective — because the
+ * substitution is not grammar-aware, and a swap that fits one sentence and
+ * breaks the next is exactly the confident nonsense this design exists to avoid.
+ * A term whose replacement would depend on the sentence around it belongs in
+ * `UNCONVERTIBLE_CLINICAL_TERMS` instead, where it gaps the section.
+ *
+ * Some entries map a term to itself. They are not redundant: an entry here also
+ * marks a term as clinical, which is what makes a negation beside it a *clinical*
+ * negation. "Emergency department" needs no plainer wording and still must not
+ * be negated by a machine.
+ *
+ * Longest key first, so "community mental health team" is not part-converted by
+ * "mental health team".
+ */
+export const PLAIN_LANGUAGE_TERMS: Record = {
+ "community mental health team": "mental health team",
+ "mental health team": "mental health team",
+ "general practitioner": "family doctor",
+ "care coordinator": "main contact",
+ "emergency department": "emergency department",
+ "peer support": "peer support",
+ "pain relief": "pain relief",
+ "low-stimulus": "calm",
+ "low stimulus": "calm",
+ "short-stay": "short-stay",
+ cmht: "mental health team",
+ gp: "family doctor",
+ clinician: "staff member",
+ clinicians: "staff members",
+ analgesia: "pain relief",
+ escalated: "built up",
+ escalates: "builds up",
+ escalate: "build up",
+ attends: "comes to",
+ attended: "came to",
+ attend: "come to",
+ interpreter: "interpreter",
+ ambulance: "ambulance",
+};
+
+/**
+ * Clinical vocabulary with no everyday equivalent this transformation is willing
+ * to commit to. A sentence containing one of these gaps.
+ *
+ * Several are ordinary English. They are here because each carries a clinical
+ * meaning an everyday synonym would quietly change. "Distress" is not "upset". A
+ * "presentation" is not "a visit" when the sentence is about a pattern over a
+ * year. "Disposition" is not "where you go next". Getting any of them slightly
+ * wrong on a document handed to the person it is about is worse than leaving a
+ * clinician to write the sentence.
+ *
+ * The last group is different in kind: words that pass judgement on a person —
+ * declined, refused, failed, resistant. Those never appear on somebody's own
+ * copy by machine, whatever else the sentence says.
+ */
+export const UNCONVERTIBLE_CLINICAL_TERMS: readonly string[] = [
+ "distress",
+ "distressed",
+ "triage",
+ "disposition",
+ "presentation",
+ "presentations",
+ "presenting",
+ "mental-state",
+ "safeguarding",
+ "delirium",
+ "overdose",
+ "means",
+ "preparation",
+ "risk",
+ "assessment",
+ "assessments",
+ "assessed",
+ "assess",
+ "admission",
+ "admitted",
+ "discharge",
+ "discharged",
+ "compliance",
+ "compliant",
+ "engagement",
+ "prognosis",
+ "diagnosis",
+ "psychosis",
+ "delusion",
+ "hallucination",
+ "sedation",
+ "restraint",
+ "seclusion",
+ "detained",
+ "capacity",
+ "acuity",
+ "severity",
+ "chronic",
+ "comorbid",
+ "prescribing",
+ "medication",
+ "medicines",
+ "dose",
+ "doses",
+ "allergy",
+ "asthma",
+ "seizure",
+ "breathlessness",
+ "drowsiness",
+ "confusion",
+ "disorientation",
+ "conscious",
+ "droop",
+ "slurred",
+ "infection",
+ "obligations",
+ "clinical",
+ "continuity",
+ "renegotiate",
+ "attributing",
+ "attributed",
+ "deteriorate",
+ "deterioration",
+ "sensory",
+ "conflict",
+ "escalation",
+ "accusation",
+ // Physical red flags. These belong to the section about what makes a
+ // presentation different, which is a clinical escalation list. Converted onto
+ // a person's own sheet they read as a warning about their body written by
+ // somebody who has not examined them.
+ "physical",
+ "medical",
+ "medicine",
+ "symptom",
+ "symptoms",
+ "chest",
+ "fever",
+ "injury",
+ "limb",
+ "weakness",
+ "facial",
+ "pregnancy",
+ "substance",
+ "attempt",
+ "self-harm",
+ "suicide",
+ "suicidal",
+ "harm",
+ // Judgements about a person.
+ "declined",
+ "decline",
+ "declines",
+ "refused",
+ "refuse",
+ "failed",
+ "fails",
+ "resistant",
+ "uncooperative",
+ "unmotivated",
+ "manipulative",
+ "attention-seeking",
+];
+
+/**
+ * Negation words. A negation gaps its section only when it sits in the same
+ * sentence as a clinical term, whether or not that term has a replacement.
+ *
+ * The distinction does real work. "To have Jess contacted only with your
+ * agreement on the day, not automatically" is a plain sentence about a
+ * preference and converts safely. "Pain relief was not offered before the
+ * mental-health assessment" substitutes cleanly word by word and still must not
+ * be converted: a negated clinical statement that half-converts reverses its own
+ * meaning, and a person reading a negated sentence about their own care is
+ * exactly where blame lands.
+ *
+ * Contrastive words — "rather", "instead" — are deliberately absent. They
+ * express a preference, not a negation, and treating them as one would gap
+ * almost every sentence in this vocabulary while protecting nothing.
+ */
+export const CLINICAL_NEGATIONS: readonly string[] = [
+ "not",
+ "never",
+ "no",
+ "nor",
+ "none",
+ "nothing",
+ "nobody",
+ "neither",
+ "without",
+ "cannot",
+ "unable",
+];
+
+/**
+ * Third-person-singular present verbs and the second-person form each becomes
+ * once the subject is "you". Substituting a subject without this produces "you
+ * settles", which tells a reader the page was generated rather than written for
+ * them.
+ *
+ * A verb absent from this table gaps its section rather than being guessed at by
+ * a rule such as "drop the trailing s", which is wrong for every plural noun.
+ */
+export const THIRD_PERSON_VERBS: Record = {
+ is: "are",
+ was: "were",
+ has: "have",
+ does: "do",
+ goes: "go",
+ says: "say",
+ settles: "settle",
+ finds: "find",
+ prefers: "prefer",
+ takes: "take",
+ needs: "need",
+ asks: "ask",
+ wants: "want",
+ likes: "like",
+ feels: "feel",
+ gets: "get",
+ comes: "come",
+ uses: "use",
+ knows: "know",
+ seems: "seem",
+ tends: "tend",
+ copes: "cope",
+ sleeps: "sleep",
+ eats: "eat",
+ drinks: "drink",
+ rings: "ring",
+ calls: "call",
+ waits: "wait",
+ leaves: "leave",
+ arrives: "arrive",
+ agrees: "agree",
+ chooses: "choose",
+ reads: "read",
+ writes: "write",
+ hears: "hear",
+ sees: "see",
+ sits: "sit",
+ stands: "stand",
+ walks: "walk",
+ lives: "live",
+ works: "work",
+ helps: "help",
+ tells: "tell",
+ brings: "bring",
+ keeps: "keep",
+ holds: "hold",
+ carries: "carry",
+ manages: "manage",
+ remembers: "remember",
+ forgets: "forget",
+ understands: "understand",
+ explains: "explain",
+ notices: "notice",
+ worries: "worry",
+ wakes: "wake",
+ rests: "rest",
+ builds: "build",
+ starts: "start",
+ stops: "stop",
+ stays: "stay",
+ becomes: "become",
+ makes: "make",
+ gives: "give",
+ puts: "put",
+ looks: "look",
+ thinks: "think",
+ travels: "travel",
+ visits: "visit",
+};
+
+/**
+ * Words ending in `s` that are not third-person-singular verbs, so a subject
+ * substitution in front of them changes nothing.
+ *
+ * This list exists so the agreement check can fail closed. Every `s`-ending word
+ * in the everyday vocabulary must be classified here or in `THIRD_PERSON_VERBS`,
+ * and a test proves it — so a word added to the vocabulary later cannot quietly
+ * become an unchecked verb.
+ */
+export const AGREEMENT_SAFE_S_WORDS: readonly string[] = [
+ "across",
+ // A noun here, and already the second-person form when it is a verb.
+ "address",
+ "afterwards",
+ "always",
+ "appointments",
+ "arrangements",
+ "as",
+ "bills",
+ "bus",
+ "clothes",
+ "days",
+ "guess",
+ "details",
+ "doors",
+ "evenings",
+ "families",
+ "friends",
+ "glasses",
+ "groups",
+ "his",
+ "hours",
+ "its",
+ "less",
+ "lights",
+ "lists",
+ "meals",
+ "meetings",
+ "members",
+ "minutes",
+ "months",
+ "mornings",
+ "names",
+ "news",
+ "nights",
+ "noises",
+ "notes",
+ "numbers",
+ "nurses",
+ "options",
+ "otherwise",
+ "papers",
+ "people's",
+ "perhaps",
+ "places",
+ "plans",
+ "plus",
+ "pronouns",
+ "questions",
+ "reasons",
+ "rooms",
+ "seats",
+ "services",
+ "sessions",
+ "signs",
+ "sometimes",
+ "sounds",
+ "staff's",
+ "steps",
+ "teams",
+ "themselves",
+ "things",
+ "this",
+ "times",
+ "towards",
+ "unless",
+ "upstairs",
+ "us",
+ "ways",
+ "weeks",
+ "words",
+ "years",
+ "yes",
+];
+
+/**
+ * The everyday words this transformation is permitted to leave in place.
+ *
+ * This is the dictionary the gap rule refers to: a sentence containing any word
+ * that is not here, not a clinical term the dictionary converted, and not a name
+ * already on this person's own record becomes a gap. It fails closed by
+ * construction — an unfamiliar word is never passed through on the assumption it
+ * is harmless.
+ *
+ * It is general plain English rather than a list assembled by reading the
+ * fixtures. Extending it until one particular synthetic sentence converts would
+ * make it a description of the fixtures instead of a description of the
+ * language, and the next real plan would gap everywhere.
+ */
+const EVERYDAY_VOCABULARY = `
+a about above across after afterwards again against agreement all along already also although always am among
+an and another answer any anyone anything appointment appointments are area argument around arrangements arrive
+arrived as ask asked asking at automatically away back bad bag be because become bed been before begin behind
+being below beside best better between big bill bills bit blanket both bring brought bus busy but by calm call
+called came can cannot car card care careful carry chair change changed check checked child children choice
+choose clear clearly close closed clothes cold come coming comfortable contact contacted conversation cost could
+count cup daughter day days decide decided decision department details different difficult do does doing done
+door doors down drink drive during each early easier easily easy eat eight either else emergency end enough
+evening evenings every everyone everything exactly explain explained face families family far father feel feeling
+felt few find finish first fit five follow following food for form found four free friend friends from front
+full get getting give given glad glasses go going gone good got great ground group groups grow had half hand
+handle happen happened happening happy hard have having he head health hear heard hearing help helped helpful
+her here herself high him himself his hold home hope hospital hour hours house how however husband i if important
+in information inside interpreter into is it its itself join journey just keep kept key kind know known landlord
+large last late later leave leaving left less let letter light lights like liked list lists listen little live
+living long look looked looking lot loud low lunch made main make making man many may maybe me meal meals mean
+meant meet meeting meetings member members mental might mind minute minutes money month months more morning
+mornings most mother move much must my myself name names near need needed never new next night nights nine no
+noise noises none nor not note notes nothing now number numbers nurse nurses of off offer offered often on once
+one only open options or order other otherwise our out over own page pain paper papers part partner peer people
+perhaps person phone pick place places plan plans please plus point possible prefer prepare private problem
+pronouns put question questions quick quickly quiet quieter rather reach read ready real really reason reasons
+relief remember rent rest right ring room rooms roughly run safe said same say saying seat seats second see seen
+sensible sentence service services session sessions set settle seven several she short show shown side sign
+signs simple since sister sit sitting six sleep slow slowly small so some someone something sometimes son soon
+sore sort sound sounds space speak spoken staff stand start started stay step steps still stop straight street
+such support sure take taken talk talked talking team teams tell ten than that the their them themselves then
+there these they thing things think third this those though thought three through time times to today together
+told tomorrow tonight too took towards town travel trip try trying turn twelve two under understand unless until
+up upstairs us use used useful usual usually very visit visits wait waited waiting wake walk want wanted ward
+warm was watch water way ways we wear week weekend weeks well went were what when where whether which while who
+whole whom whose why wife will window wish with within without woman work worked worker working world worried
+worry would write written wrong year years yes yesterday yet you your yourself
+agree agreed aid arm approach available bay bright complete concern concentrate confirm consistent conversation
+cover current describe differ directly draft effect entirely expect familiar fresh guess harder history
+household include instruction introduce judgement legal life longer nearby news nowhere occasion older overhead
+page pattern pause plainly poor practice preference privacy print reason referral repeat replace role safety
+sentence severe share sight silent similar state statement stretch tolerable traffic treat update version
+address asleep awake bathroom bedroom breakfast corridor dinner doctor lunch tea
+`;
+
+/**
+ * Split once at module load. Deterministic: a constant string, no I/O.
+ *
+ * The verbs in `THIRD_PERSON_VERBS` are folded in rather than typed out again.
+ * Leaving them out was a real defect on first run: "has" was in the agreement
+ * table and absent from the vocabulary, so every sentence containing it gapped,
+ * and twenty-two of the fixtures' sentences gapped on the commonest verb in
+ * English while the reason said the wording had no everyday equivalent.
+ *
+ * Exported so the guard tests can classify it rather than restate it.
+ */
+export const EVERYDAY_WORDS: ReadonlySet = new Set([
+ ...EVERYDAY_VOCABULARY.trim().split(/\s+/),
+ ...Object.keys(THIRD_PERSON_VERBS),
+ ...Object.values(THIRD_PERSON_VERBS),
+]);
+
+/**
+ * The base forms an inflected word could have come from — plural, past,
+ * progressive, adverb, comparative, superlative.
+ *
+ * This is deliberately a *widening* rule and it is safe to be one, because it
+ * only ever admits an inflection of a word already judged everyday: if "wait" is
+ * plain enough to print, so are "waits", "waited", and "waiting". It cannot
+ * admit a clinical term, because the unconvertible list is checked first, on the
+ * words as written, and a test proves no unconvertible term or its plural
+ * survives this rule.
+ */
+function inflectionBases(word: string): string[] {
+ const bases: string[] = [];
+ if (word.endsWith("ies")) bases.push(`${word.slice(0, -3)}y`);
+ if (word.endsWith("es")) bases.push(word.slice(0, -2));
+ if (word.endsWith("s")) bases.push(word.slice(0, -1));
+ if (word.endsWith("ied")) bases.push(`${word.slice(0, -3)}y`);
+ if (word.endsWith("ed")) bases.push(word.slice(0, -2), word.slice(0, -1));
+ if (/([bdfglmnprt])\1ed$/.test(word)) bases.push(word.slice(0, -3));
+ if (word.endsWith("ing")) bases.push(word.slice(0, -3), `${word.slice(0, -3)}e`);
+ if (/([bdfglmnprt])\1ing$/.test(word)) bases.push(word.slice(0, -4));
+ if (word.endsWith("ly")) bases.push(word.slice(0, -2));
+ if (word.endsWith("er")) bases.push(word.slice(0, -2), word.slice(0, -1));
+ if (word.endsWith("est")) bases.push(word.slice(0, -3), word.slice(0, -2));
+ return bases;
+}
+
+/**
+ * Whether one word may be left on a person's own copy.
+ *
+ * A clinical term is refused outright before anything else is considered.
+ * Without that, the widening rules let one through by the back door:
+ * "mental-state" is two everyday words joined by a hyphen, and the hyphen rule
+ * would have admitted it as ordinary English. The refusal checks run first on
+ * the sentence as written, so nothing was actually reaching a page — but a guard
+ * that can be walked around is not a guard, and the hyphen is not the only way
+ * in.
+ */
+export function isEverydayWord(word: string): boolean {
+ const bare = word.replace(/['’]s$/, "");
+ for (const candidate of [word, bare]) {
+ if (isClinicalTerm(candidate)) return false;
+ if (EVERYDAY_WORDS.has(candidate)) return true;
+ if (inflectionBases(candidate).some((base) => EVERYDAY_WORDS.has(base))) return true;
+ if (candidate.includes("-") && candidate.split("-").every((part) => part !== "" && isEverydaySegment(part))) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function isEverydaySegment(part: string): boolean {
+ if (isClinicalTerm(part)) return false;
+ return EVERYDAY_WORDS.has(part) || inflectionBases(part).some((base) => EVERYDAY_WORDS.has(base));
+}
+
+function isClinicalTerm(word: string): boolean {
+ if (UNCONVERTIBLE_TERM_SET.has(word)) return true;
+ return inflectionBases(word).some((base) => UNCONVERTIBLE_TERM_SET.has(base));
+}
+
+const AGREEMENT_SAFE_S_WORD_SET: ReadonlySet = new Set(AGREEMENT_SAFE_S_WORDS);
+const CLINICAL_NEGATION_SET: ReadonlySet = new Set(CLINICAL_NEGATIONS);
+const UNCONVERTIBLE_TERM_SET: ReadonlySet = new Set(UNCONVERTIBLE_CLINICAL_TERMS);
+
+/**
+ * Third-person pronouns. Substituting one is only safe when the sentence
+ * mentions nobody but the person themselves — see `mentionsAnotherPerson`.
+ */
+const THIRD_PERSON_PRONOUNS: readonly string[] = [
+ "he",
+ "him",
+ "his",
+ "she",
+ "her",
+ "hers",
+ "they",
+ "them",
+ "their",
+ "theirs",
+];
+
+const THIRD_PERSON_PRONOUN_SET: ReadonlySet = new Set(THIRD_PERSON_PRONOUNS);
+
+/**
+ * Words that mean a sentence is about somebody besides the person whose copy
+ * this is. A pronoun in such a sentence cannot be bound to the reader without
+ * guessing which of the two people it meant, and guessing wrong turns "their
+ * presence reads as an accusation" into a sentence accusing the reader.
+ */
+const OTHER_PERSON_WORDS: readonly string[] = [
+ "sister",
+ "brother",
+ "son",
+ "daughter",
+ "mother",
+ "father",
+ "husband",
+ "wife",
+ "partner",
+ "friend",
+ "friends",
+ "family",
+ "families",
+ "carer",
+ "carers",
+ "staff",
+ "clinician",
+ "clinicians",
+ "nurse",
+ "nurses",
+ "doctor",
+ "doctors",
+ "worker",
+ "workers",
+ "member",
+ "members",
+ "coordinator",
+ "team",
+ "teams",
+ "security",
+ "someone",
+ "anyone",
+ "everyone",
+ "people",
+ "person",
+ "child",
+ "children",
+ "adult",
+ "household",
+];
+
+const OTHER_PERSON_WORD_SET: ReadonlySet = new Set(OTHER_PERSON_WORDS);
+
+/**
+ * Verbs a Management Plan sentence opens with when it is an instruction to
+ * staff. A sentence in the imperative is addressed to whoever is reading it, and
+ * on this copy that is the person the plan is about.
+ *
+ * This rule caught the worst line the first run produced. "Say so plainly when
+ * you use it, and offer again to write it together" is sound advice to a
+ * clinician and, printed on somebody's own plan, an instruction to them to
+ * announce that they had refused. Nothing about the words was wrong; the reader
+ * was.
+ */
+const CLINICIAN_IMPERATIVE_OPENERS: readonly string[] = [
+ "introduce",
+ "ask",
+ "tell",
+ "offer",
+ "say",
+ "keep",
+ "check",
+ "sit",
+ "speak",
+ "use",
+ "treat",
+ "record",
+ "confirm",
+ "give",
+ "take",
+ "ensure",
+ "consider",
+ "arrange",
+ "contact",
+ "call",
+ "ring",
+ "write",
+ "explain",
+ "read",
+ "leave",
+ "allow",
+ "let",
+ "start",
+ "stop",
+ "avoid",
+ "deferring",
+ "repeating",
+ "waiting",
+ "being",
+];
+
+const CLINICIAN_IMPERATIVE_OPENER_SET: ReadonlySet = new Set(CLINICIAN_IMPERATIVE_OPENERS);
+
+/**
+ * Words that mean a sentence is talking *about* the person's name rather than
+ * using it to refer to them. Substituting the name in one of these produces "To
+ * be called you" — which is what the first run printed on Evie's copy, under a
+ * heading saying it was what mattered to her.
+ */
+const NAMING_CONTEXT_WORDS: readonly string[] = [
+ "called",
+ "call",
+ "calls",
+ "name",
+ "names",
+ "named",
+ "address",
+ "addressed",
+ "refer",
+ "referred",
+ "referring",
+ "pronounce",
+ "pronounced",
+ "pronoun",
+ "pronouns",
+ "spelt",
+ "spelled",
+];
+
+const NAMING_CONTEXT_WORD_SET: ReadonlySet = new Set(NAMING_CONTEXT_WORDS);
+
+/** Every clinical term the transformation recognises, convertible or not. Used
+ * to decide whether a negation in a sentence is a clinical one. */
+const CLINICAL_TERM_SET: ReadonlySet = new Set([
+ ...UNCONVERTIBLE_CLINICAL_TERMS,
+ ...Object.keys(PLAIN_LANGUAGE_TERMS).flatMap((term) => term.split(/[\s-]+/)),
+]);
+
+/** Dictionary keys, longest first, so a longer phrase is matched before any
+ * shorter phrase inside it. */
+const PLAIN_LANGUAGE_KEYS = Object.keys(PLAIN_LANGUAGE_TERMS).sort((left, right) => right.length - left.length);
+
+// --- Gap reasons -----------------------------------------------------------------
+
+/**
+ * Every reason ends by saying who writes the section instead. A gap is a job for
+ * a person, not an error state, and the sentence a clinician reads should say
+ * what to do about it.
+ */
+export const PATIENT_PLAN_GAP_REASON = {
+ agreedApproach:
+ "What happens when this person comes to the emergency department is never converted automatically, whatever the plan says. It is the part most easily read as a judgement about them, so it is always written by a clinician, with them.",
+ nothingRecorded:
+ "The Management Plan records nothing under this heading, so there is nothing to convert. It needs to be written by a clinician, with this person.",
+ unknownTerm:
+ "This section uses wording the plain-language conversion has no confident everyday equivalent for, so converting it would be a guess. It needs to be written by a clinician, with this person.",
+ clinicalNegation:
+ "This section says what does not happen. A negation that converts only in part reverses its own meaning, so it needs to be written by a clinician, with this person.",
+ personAsSubject:
+ "This section describes this person in the third person, and the conversion cannot put it into their own voice without rewriting it. It needs to be written by a clinician, with them.",
+ ambiguousPronoun:
+ "This section mentions somebody besides this person, so a pronoun here cannot be attached to the reader without guessing which of them it meant. It needs to be written by a clinician, with them.",
+ clinicianInstruction:
+ "This section is written as an instruction to staff. On the person's own copy an instruction is addressed to them, so converting it would hand them a direction that was never meant for them. It needs to be written by a clinician, with them.",
+ namingContext:
+ "This section is about what this person is called, so replacing their name with “you” would take the sentence's whole meaning with it. It needs to be written by a clinician, with them.",
+} as const;
+
+export type PatientPlanGapReasonKey = keyof typeof PATIENT_PLAN_GAP_REASON;
+
+// --- Conversion ------------------------------------------------------------------
+
+export type PatientPlanDraft = {
+ derivedFromManagementVersionId: SyntheticId;
+ sections: readonly PatientPlanSection[];
+ resources: readonly PatientResource[];
+};
+
+/** Tokens the checks look at: words only. Punctuation, numbers, and spacing are
+ * left exactly as written. */
+function wordsOf(text: string): string[] {
+ return (text.toLowerCase().match(/[a-z][a-z'’-]*/g) ?? []).map((word) => word.replace(/^[-'’]+|[-'’]+$/g, ""));
+}
+
+/** Proper nouns: a capitalised word inside a piece of prose already on this
+ * person's record. Lowercased, because the vocabulary check is case-blind. */
+function properNounsOf(text: string): string[] {
+ return (text.match(/\b[A-Z][A-Za-z'’-]*/g) ?? []).map((word) => word.toLowerCase());
+}
+
+/** Sentences, split on terminal punctuation followed by whitespace. Deliberately
+ * simple: this is written prose from a form, not free input. */
+function sentencesOf(text: string): string[] {
+ return text
+ .split(/(?<=[.!?])\s+/)
+ .map((sentence) => sentence.trim())
+ .filter((sentence) => sentence.length > 0);
+}
+
+function escapeForRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+/**
+ * The names this person's record already carries: their own names, their home
+ * health service, the support people they have consented to, and the services
+ * named on the resources chosen for them. A capitalised word outside this set is
+ * a name the transformation has never been told about, and it gaps rather than
+ * being printed on somebody's copy unchecked.
+ */
+function permittedNamesFor(
+ version: ManagementPlanVersion,
+ patient: Patient,
+ resources: readonly PatientResource[],
+): ReadonlySet {
+ const sources = [
+ patient.fullName,
+ patient.preferredName,
+ ...patient.aliases,
+ patient.homeHealthService,
+ ...version.consentedSupportPeople,
+ ...resources.map((resource) => resource.name),
+ ];
+ return new Set(sources.flatMap((source) => properNounsOf(source)));
+}
+
+/**
+ * How the person is referred to in the third person, longest name first so a
+ * full name is replaced before the preferred name inside it.
+ *
+ * `ambiguousPronoun` is the word — if any — that is both this person's
+ * possessive and their object pronoun, and so cannot be classified without
+ * guessing. For `she/her` that word is "her": "ring her son" wants "your son",
+ * and "hard for her to follow" wants "you". Substituting one rule everywhere
+ * printed "hard for your to follow" on Mira's copy on the first run. There is no
+ * grammar here to decide between them, so a sentence containing it is refused
+ * rather than mangled.
+ */
+function personReferences(patient: Patient): {
+ possessive: string[];
+ subject: string[];
+ object: string[];
+ ambiguousPronoun: string | null;
+} {
+ const [subjectPronoun = "", objectPronoun = ""] = patient.pronouns.split("/");
+ const possessivePronoun =
+ subjectPronoun === "they" ? "their" : subjectPronoun === "she" ? "her" : subjectPronoun === "he" ? "his" : "";
+ const ambiguous = possessivePronoun !== "" && possessivePronoun === objectPronoun ? possessivePronoun : null;
+ const names = [patient.fullName, ...patient.aliases, patient.preferredName]
+ .filter((name) => name.length > 0)
+ .sort((left, right) => right.length - left.length);
+ return {
+ possessive: [
+ ...names.flatMap((name) => [`${name}'s`, `${name}’s`]),
+ ...(possessivePronoun === "" || ambiguous !== null ? [] : [possessivePronoun]),
+ ],
+ subject: [...names, ...(subjectPronoun === "" ? [] : [subjectPronoun])],
+ object: objectPronoun === "" || ambiguous !== null ? [] : [objectPronoun],
+ ambiguousPronoun: ambiguous,
+ };
+}
+
+/** Whether a sentence names anybody besides the person whose copy this is. */
+function mentionsAnotherPerson(words: readonly string[], ownNames: ReadonlySet): boolean {
+ return words.some((word) => OTHER_PERSON_WORD_SET.has(word) && !ownNames.has(word));
+}
+
+export type ConversionOutcome = { converted: string } | { gapReasonKey: PatientPlanGapReasonKey };
+
+/**
+ * One line of clinical content, converted or refused.
+ *
+ * The order matters. The refusal checks run first, on the text as written, so a
+ * substitution can never hide the thing that should have stopped it. Then the
+ * dictionary decides what the clinical words mean; then the person's own name
+ * and pronouns become "you" and "your", with verb agreement taken from a table;
+ * then every remaining word must be in the dictionary. A failure at any step
+ * refuses the whole line — there is no partial result to be mistaken for a
+ * finished one.
+ */
+function convertLine(
+ line: string,
+ patient: Patient,
+ permittedNames: ReadonlySet,
+ ownNames: ReadonlySet,
+): ConversionOutcome {
+ for (const sentence of sentencesOf(line)) {
+ const words = wordsOf(sentence);
+ const first = words[0];
+
+ if (first !== undefined && CLINICIAN_IMPERATIVE_OPENER_SET.has(first)) {
+ return { gapReasonKey: "clinicianInstruction" };
+ }
+ if (words.some((word) => UNCONVERTIBLE_TERM_SET.has(word))) {
+ return { gapReasonKey: "unknownTerm" };
+ }
+
+ const refersToThisPerson =
+ words.some((word) => ownNames.has(word)) || words.some((word) => THIRD_PERSON_PRONOUN_SET.has(word));
+
+ // "known as" is a naming construction; a bare "known" is not, and treating
+ // it as one refused "Mira is known to the Coastal Plains team" as though it
+ // were a sentence about her name.
+ const namesTheName = words.some((word) => NAMING_CONTEXT_WORD_SET.has(word)) || /\bknown\s+as\b/i.test(sentence);
+ if (refersToThisPerson && namesTheName) {
+ return { gapReasonKey: "namingContext" };
+ }
+ // A negation is refused beside a clinical term, and beside the person
+ // themselves. The second half matters as much as the first: a negated
+ // sentence about somebody, printed on their own copy, is where blame lands.
+ if (
+ (words.some((word) => CLINICAL_TERM_SET.has(word)) || refersToThisPerson) &&
+ words.some((word) => CLINICAL_NEGATION_SET.has(word))
+ ) {
+ return { gapReasonKey: "clinicalNegation" };
+ }
+ if (words.some((word) => THIRD_PERSON_PRONOUN_SET.has(word)) && mentionsAnotherPerson(words, ownNames)) {
+ return { gapReasonKey: "ambiguousPronoun" };
+ }
+ const ambiguous = personReferences(patient).ambiguousPronoun;
+ if (ambiguous !== null && words.includes(ambiguous)) {
+ return { gapReasonKey: "ambiguousPronoun" };
+ }
+ }
+
+ let text = line;
+ for (const term of PLAIN_LANGUAGE_KEYS) {
+ const replacement = PLAIN_LANGUAGE_TERMS[term];
+ if (replacement === undefined) continue;
+ text = text.replace(new RegExp(`\\b${escapeForRegExp(term)}\\b`, "gi"), replacement);
+ }
+
+ const references = personReferences(patient);
+ for (const possessive of references.possessive) {
+ text = text.replace(new RegExp(`\\b${escapeForRegExp(possessive)}\\b`, "gi"), "your");
+ }
+ for (const object of references.object) {
+ text = text.replace(new RegExp(`\\b${escapeForRegExp(object)}\\b`, "gi"), "you");
+ }
+
+ // The subject substitution is the only place agreement can break, so it is the
+ // only place that looks at the word after itself.
+ for (const subject of references.subject) {
+ let refused = false;
+ text = text.replace(
+ new RegExp(`\\b${escapeForRegExp(subject)}\\b(\\s+)([A-Za-z][A-Za-z'’-]*)`, "gi"),
+ (whole, spacing: string, nextWord: string) => {
+ const lowered = nextWord.toLowerCase();
+ const agreed = THIRD_PERSON_VERBS[lowered];
+ if (agreed !== undefined) return `you${spacing}${agreed}`;
+ if (lowered.endsWith("s") && !AGREEMENT_SAFE_S_WORD_SET.has(lowered)) {
+ refused = true;
+ return whole;
+ }
+ return `you${spacing}${nextWord}`;
+ },
+ );
+ if (refused) return { gapReasonKey: "personAsSubject" };
+ text = text.replace(new RegExp(`\\b${escapeForRegExp(subject)}\\b`, "gi"), "you");
+ }
+
+ for (const word of wordsOf(text)) {
+ if (isEverydayWord(word)) continue;
+ if (permittedNames.has(word) || permittedNames.has(word.replace(/['’]s$/, ""))) continue;
+ return { gapReasonKey: "unknownTerm" };
+ }
+
+ return { converted: capitaliseSentences(text) };
+}
+
+/** A substitution can leave a lower-case word at the start of a sentence — "CMHT
+ * is the durable contact" becomes "mental health team is …". Restoring the
+ * capital is the difference between a page written for somebody and a page
+ * obviously produced by a machine. */
+function capitaliseSentences(text: string): string {
+ return text.replace(
+ /(^|[.!?]\s+)([a-z])/g,
+ (_whole, lead: string, letter: string) => `${lead}${letter.toUpperCase()}`,
+ );
+}
+
+/** The lines one heading is built from, in source-field order. `whyThisPlanExists`
+ * is a paragraph rather than a list, so it contributes one line. */
+function sourceLines(content: ManagementPlanContent, key: PatientPlanSectionKey): string[] {
+ return PATIENT_PLAN_SECTION_SOURCES[key].flatMap((field) => {
+ const value = content[field];
+ if (typeof value === "string") return value.trim().length === 0 ? [] : [value];
+ return value.filter((line) => line.trim().length > 0);
+ });
+}
+
+function gapSection(key: PatientPlanSectionKey, reasonKey: PatientPlanGapReasonKey): PatientPlanSection {
+ return {
+ key,
+ heading: PATIENT_PLAN_SECTION_HEADING[key],
+ body: [],
+ gap: true,
+ gapReason: PATIENT_PLAN_GAP_REASON[reasonKey],
+ };
+}
+
+/**
+ * One approved Management Plan Version as a draft patient edition.
+ *
+ * Pure, offline, and deterministic: it reads nothing but its three arguments and
+ * the constants above.
+ */
+/**
+ * A converter bound to one person and one version.
+ *
+ * Exported because each refusal rule deserves to be proved on the sentence that
+ * triggers it rather than through a whole section, where one rule firing hides
+ * every other. It is the same function the draft builder uses — not a second
+ * copy that could drift from it.
+ */
+export function createLineConverter(
+ version: ManagementPlanVersion,
+ patient: Patient,
+ resources: readonly PatientResource[],
+): (line: string) => ConversionOutcome {
+ const forThisPatient = resources.filter((resource) => resource.patientId === patient.id);
+ const permittedNames = permittedNamesFor(version, patient, forThisPatient);
+ const ownNames = new Set(
+ [patient.fullName, patient.preferredName, ...patient.aliases].flatMap((name) => wordsOf(name)),
+ );
+ return (line: string) => convertLine(line, patient, permittedNames, ownNames);
+}
+
+export function buildPatientPlanDraft(
+ version: ManagementPlanVersion,
+ patient: Patient,
+ resources: readonly PatientResource[],
+): PatientPlanDraft {
+ const forThisPatient = resources.filter((resource) => resource.patientId === patient.id);
+ const convert = createLineConverter(version, patient, resources);
+
+ const sections = PATIENT_PLAN_SECTION_KEYS.map((key): PatientPlanSection => {
+ if ((NEVER_CONVERTED_SECTION_KEYS as readonly PatientPlanSectionKey[]).includes(key)) {
+ return gapSection(key, "agreedApproach");
+ }
+
+ const lines = sourceLines(version.content, key);
+ if (lines.length === 0) return gapSection(key, "nothingRecorded");
+
+ const body: string[] = [];
+ for (const line of lines) {
+ const outcome = convert(line);
+ if ("gapReasonKey" in outcome) return gapSection(key, outcome.gapReasonKey);
+ body.push(outcome.converted);
+ }
+
+ return { key, heading: PATIENT_PLAN_SECTION_HEADING[key], body, gap: false, gapReason: null };
+ });
+
+ return { derivedFromManagementVersionId: version.id, sections, resources: forThisPatient };
+}
+
+/** The gaps still to be filled. Approval is refused while any remains: an
+ * unfilled gap prints as a heading with nothing under it. */
+export function unfilledGapSections(sections: readonly PatientPlanSection[]): readonly PatientPlanSection[] {
+ return sections.filter((section) => section.gap);
+}
diff --git a/src/components/care-plan/mockups/patient-workspace.tsx b/src/components/care-plan/mockups/patient-workspace.tsx
index c7d7a89ab..62371a5bb 100644
--- a/src/components/care-plan/mockups/patient-workspace.tsx
+++ b/src/components/care-plan/mockups/patient-workspace.tsx
@@ -48,6 +48,7 @@ export type PatientWorkspaceProps = {
activeSection: PatientSectionKey | null;
reviewsHref: string;
onRecordContactIntent: (contact: CmhtContact, channel: "email" | "call") => void;
+ contactActionBlockedReason: string | null;
/** Home renders the workspace beside the directory and offers the full record. */
showFullRecordLink?: boolean;
/**
@@ -76,6 +77,7 @@ export function PatientWorkspace({
activeSection,
reviewsHref,
onRecordContactIntent,
+ contactActionBlockedReason,
showFullRecordLink = false,
ref,
}: PatientWorkspaceProps) {
@@ -225,6 +227,7 @@ export function PatientWorkspace({
scenario={scenario}
reviewsHref={reviewsHref}
onIntent={(channel) => onRecordContactIntent(cmht, channel)}
+ blockedReason={contactActionBlockedReason}
/>
)}
>
diff --git a/src/components/care-plan/mockups/prototype-state.ts b/src/components/care-plan/mockups/prototype-state.ts
index 3ee49f2b5..a7cfcfd8e 100644
--- a/src/components/care-plan/mockups/prototype-state.ts
+++ b/src/components/care-plan/mockups/prototype-state.ts
@@ -21,10 +21,14 @@ import {
assertSingleCurrentVersion,
canPerformAction,
getCurrentManagementPlanVersion,
+ getCurrentPatientPlanVersion,
getCurrentSafetyPlanVersion,
getEffectivePresentationValue,
getOpenManagementDraft,
+ getOpenPatientPlanDraft,
} from "./domain";
+import { getPatientResources, syntheticPatientResources } from "./patient-plan-fixtures";
+import { buildPatientPlanDraft, unfilledGapSections } from "./patient-plan-transform";
import {
PROTOTYPE_NOW,
identificationPolicy,
@@ -60,6 +64,9 @@ import {
type ManagementPlanContent,
type ManagementPlanVersion,
type Patient,
+ type PatientPlan,
+ type PatientPlanVersion,
+ type PatientResource,
type PersonalSafetyPlan,
type PersonalSafetyPlanVersion,
type PlanAvailability,
@@ -162,8 +169,11 @@ const SCENARIO_PATIENT: Record = {
* flags from the scenario without reading a browser API.
*
* The Patient Plan collections start empty. A Patient Plan is produced from an
- * approved Management Plan Version by a later task; inventing fixture editions
- * of it here would put patient-facing wording on the record that nobody wrote.
+ * approved Management Plan Version when somebody makes one; inventing fixture
+ * editions here would put patient-facing wording on the record that nobody
+ * wrote. The resource catalogue is not an edition of anything, so it is seeded:
+ * it is the list a clinician chooses from, and an empty one would mean every
+ * patient copy started with nothing to offer.
*/
export function createInitialPrototypeState(scenario: PrototypeScenario = "normal"): CarePlanPrototypeState {
return {
@@ -185,7 +195,7 @@ export function createInitialPrototypeState(scenario: PrototypeScenario = "norma
personalSafetyPlanVersions: cloneFixtures(syntheticPersonalSafetyPlanVersions),
patientPlans: [],
patientPlanVersions: [],
- patientResources: [],
+ patientResources: cloneFixtures(syntheticPatientResources),
edPresentations: cloneFixtures(syntheticEdPresentations),
presentationAmendments: cloneFixtures(syntheticPresentationAmendments),
reviewTriggers: cloneFixtures(syntheticReviewTriggers),
@@ -231,6 +241,14 @@ const CAPABILITY_BY_ACTION: Record id === planId)?.patientId ?? null;
}
+function patientOfPatientPlan(state: CarePlanPrototypeState, planId: SyntheticId): SyntheticId | null {
+ return state.patientPlans.find(({ id }) => id === planId)?.patientId ?? null;
+}
+
+/**
+ * The person's Patient Plan record, or a new empty one.
+ *
+ * Unlike the Management Plan and the Personal Safety Plan, a Patient Plan record
+ * is not seeded per patient in the fixtures. Inventing fixture editions would
+ * have put patient-facing wording on the record that nobody wrote, so the plan
+ * record comes into existence the first time somebody actually makes a copy.
+ * The caller adds it to the state; this only decides its identity.
+ */
+function findOrCreatePatientPlan(state: CarePlanPrototypeState, patient: Patient): PatientPlan {
+ const existing = state.patientPlans.find((plan) => plan.patientId === patient.id) ?? null;
+ if (existing !== null) return existing;
+ return {
+ id: nextSyntheticId(
+ "SYN-PATIENT-PLAN",
+ state.patientPlans.map(({ id }) => id),
+ ),
+ patientId: patient.id,
+ versionIds: [],
+ currentVersionId: null,
+ };
+}
+
function nextVersionNumber(versions: readonly { planId: SyntheticId; version: number }[], planId: SyntheticId): number {
return (
versions
@@ -824,14 +870,54 @@ export function prototypeReducer(
}
: null;
+ /**
+ * The person may already hold a patient copy of the version this one
+ * replaces — possibly on paper, in a bag, at home. Nothing here touches
+ * it: it is not regenerated, hidden, or withdrawn, because the
+ * application cannot reach the sheet and must not claim to have. What it
+ * does instead is put the discrepancy in front of a human, once. The
+ * trigger is deduplicated per plan, so approving three versions in a row
+ * raises one item rather than three.
+ */
+ const patientPlan = state.patientPlans.find((candidate) => candidate.patientId === plan.patientId) ?? null;
+ const currentPatientVersion =
+ patientPlan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, patientPlan.id);
+ const patientCopyNowStale =
+ currentPatientVersion !== null && currentPatientVersion.derivedFromManagementVersionId !== version.id;
+ const staleTriggerAlreadyOpen = state.reviewTriggers.some(
+ (trigger) =>
+ trigger.managementPlanId === plan.id && trigger.source === "patient_plan_stale" && trigger.status === "open",
+ );
+ const staleTrigger: ReviewTrigger | null =
+ patientCopyNowStale && !staleTriggerAlreadyOpen && currentPatientVersion !== null
+ ? {
+ id: nextSyntheticId("SYN-TRIGGER", [
+ ...state.reviewTriggers.map(({ id }) => id),
+ ...(participationTrigger === null ? [] : [participationTrigger.id]),
+ ]),
+ patientId: plan.patientId,
+ managementPlanId: plan.id,
+ source: "patient_plan_stale",
+ sourceId: currentPatientVersion.id,
+ reason: `Patient Plan version ${currentPatientVersion.version} was written from Management Plan version ${version.version - 1} or earlier, and the Current Plan is now version ${version.version}. The person may be holding a printed copy, so it stays readable and unchanged until somebody goes through it with them.`,
+ status: "open",
+ createdAt: prototypeTimestamp(state, 2),
+ resolvedAt: null,
+ resolution: null,
+ }
+ : null;
+
+ const raisedTriggers = [participationTrigger, staleTrigger].filter(
+ (trigger): trigger is ReviewTrigger => trigger !== null,
+ );
+
return {
...state,
managementPlanVersions,
managementPlans: state.managementPlans.map((candidate) =>
candidate.id === plan.id ? { ...candidate, currentVersionId: version.id } : candidate,
),
- reviewTriggers:
- participationTrigger === null ? state.reviewTriggers : [...state.reviewTriggers, participationTrigger],
+ reviewTriggers: [...state.reviewTriggers, ...raisedTriggers],
auditEvents: withAudit(state, {
type: "management_version_approved",
patientId: plan.patientId,
@@ -841,9 +927,11 @@ export function prototypeReducer(
lastOutcome: {
kind: "success",
message:
- participationTrigger === null
+ raisedTriggers.length === 0
? `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}.`
- : `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}. No involvement was recorded for this person, so an open Review Trigger was raised for the team.`,
+ : staleTrigger === null
+ ? `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}. No involvement was recorded for this person, so an open Review Trigger was raised for the team.`
+ : `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}. This person's Patient Plan was written from an earlier version and now needs updating; it stays readable and an open Review Trigger was raised.`,
},
};
}
@@ -865,6 +953,40 @@ export function prototypeReducer(
const withdrawnAt = prototypeTimestamp(state);
const actor = state.users.find(({ id }) => id === state.activeUserId);
+ /**
+ * Withdrawal leaves the plan with no Current version at all — not merely
+ * a different one — and a Current Patient Plan copy derived from what
+ * was just withdrawn is exactly as out of date as one derived from a
+ * superseded version. The same mechanism `approve-management-version`
+ * uses applies here: the copy is never touched, and the discrepancy goes
+ * in front of a human once, deduplicated per plan.
+ */
+ const patientPlan = state.patientPlans.find((candidate) => candidate.patientId === plan.patientId) ?? null;
+ const currentPatientVersion =
+ patientPlan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, patientPlan.id);
+ const staleTriggerAlreadyOpen = state.reviewTriggers.some(
+ (trigger) =>
+ trigger.managementPlanId === plan.id && trigger.source === "patient_plan_stale" && trigger.status === "open",
+ );
+ const staleTrigger: ReviewTrigger | null =
+ currentPatientVersion !== null && !staleTriggerAlreadyOpen
+ ? {
+ id: nextSyntheticId(
+ "SYN-TRIGGER",
+ state.reviewTriggers.map(({ id }) => id),
+ ),
+ patientId: plan.patientId,
+ managementPlanId: plan.id,
+ source: "patient_plan_stale",
+ sourceId: currentPatientVersion.id,
+ reason: `Version ${current.version} was withdrawn and ${patient.preferredName} now has no Current Plan. Patient Plan version ${currentPatientVersion.version} was written from it, so it stays readable and unchanged until somebody goes through it with them.`,
+ status: "open",
+ createdAt: prototypeTimestamp(state, 1),
+ resolvedAt: null,
+ resolution: null,
+ }
+ : null;
+
return {
...state,
managementPlanVersions: state.managementPlanVersions.map((candidate) =>
@@ -882,6 +1004,7 @@ export function prototypeReducer(
managementPlans: state.managementPlans.map((candidate) =>
candidate.id === plan.id ? { ...candidate, currentVersionId: null } : candidate,
),
+ reviewTriggers: staleTrigger === null ? state.reviewTriggers : [...state.reviewTriggers, staleTrigger],
auditEvents: withAudit(state, {
type: "management_version_withdrawn",
patientId: patient.id,
@@ -890,7 +1013,10 @@ export function prototypeReducer(
}),
lastOutcome: {
kind: "success",
- message: `Version ${current.version} withdrawn. ${patient.preferredName} now has no Current Plan, and no earlier version was put back into use.`,
+ message:
+ staleTrigger === null
+ ? `Version ${current.version} withdrawn. ${patient.preferredName} now has no Current Plan, and no earlier version was put back into use.`
+ : `Version ${current.version} withdrawn. ${patient.preferredName} now has no Current Plan, and their Patient Plan stays readable but needs updating; an open Review Trigger was raised.`,
},
};
}
@@ -1134,11 +1260,54 @@ export function prototypeReducer(
amendedAt: prototypeTimestamp(state),
};
+ // The timeline renders an amendment over the original record. Review
+ // triggers must make the same decision from that effective record; using
+ // the stored original here leaves a newly corrected admission or adverse
+ // plan-use answer visible on screen but absent from Reviews.
+ const amendments = [...state.presentationAmendments, amendment];
+ const reviewRelevant = action.field === "planHelpfulness" || action.field === "disposition";
+ const effectivePresentation: EdPresentation = {
+ ...presentation,
+ planHelpfulness: getEffectivePresentationValue(amendments, presentation, "planHelpfulness") as PlanHelpfulness,
+ disposition: getEffectivePresentationValue(amendments, presentation, "disposition") as Disposition,
+ };
+ const patient = findPatient(state, presentation.patientId);
+ const plan = patient === null ? null : findManagementPlan(state, patient);
+ const hasEverHadAVersion =
+ plan !== null && state.managementPlanVersions.some((existing) => existing.planId === plan.id);
+ const candidate = reviewRelevant && hasEverHadAVersion ? reviewTriggerReasonFor(effectivePresentation) : null;
+ const alreadyOpen =
+ plan !== null &&
+ candidate !== null &&
+ state.reviewTriggers.some(
+ (trigger) =>
+ trigger.managementPlanId === plan.id && trigger.source === candidate.source && trigger.status === "open",
+ );
+ const newTrigger: ReviewTrigger | null =
+ plan !== null && candidate !== null && !alreadyOpen
+ ? {
+ id: nextSyntheticId(
+ "SYN-TRIGGER",
+ state.reviewTriggers.map(({ id }) => id),
+ ),
+ patientId: presentation.patientId,
+ managementPlanId: plan.id,
+ source: candidate.source,
+ sourceId: presentation.id,
+ reason: candidate.reason,
+ status: "open",
+ createdAt: prototypeTimestamp(state, 1),
+ resolvedAt: null,
+ resolution: null,
+ }
+ : null;
+
return {
...state,
// The episode is untouched. A correction is appended beside it, with who
// made it, when, what it replaced, and why.
- presentationAmendments: [...state.presentationAmendments, amendment],
+ presentationAmendments: amendments,
+ reviewTriggers: newTrigger === null ? state.reviewTriggers : [...state.reviewTriggers, newTrigger],
auditEvents: withAudit(state, {
type: "presentation_amended",
patientId: presentation.patientId,
@@ -1147,7 +1316,10 @@ export function prototypeReducer(
}),
lastOutcome: {
kind: "success",
- message: "Correction recorded beside the original. The original ED Presentation record is unchanged.",
+ message:
+ newTrigger === null
+ ? "Correction recorded beside the original. The original ED Presentation record is unchanged."
+ : "Correction recorded beside the original. The original ED Presentation record is unchanged, and an open Review Trigger was raised for the team to look at.",
},
};
}
@@ -1293,6 +1465,216 @@ export function prototypeReducer(
};
}
+ case "create-patient-plan-draft": {
+ const patient = findPatient(state, action.patientId);
+ if (patient === null) return refuse(state, "That synthetic patient record does not exist.");
+ const managementPlan = findManagementPlan(state, patient);
+ const currentManagement =
+ managementPlan === null
+ ? null
+ : getCurrentManagementPlanVersion(state.managementPlanVersions, managementPlan.id);
+ // A patient copy of a plan nobody has approved would be a document in
+ // somebody's hands describing care nobody agreed to.
+ if (currentManagement === null) {
+ return refuse(
+ state,
+ `${patient.preferredName} has no Current Plan, so there is nothing to make a patient copy of. A draft or a withdrawn version is not an agreed plan.`,
+ );
+ }
+
+ const plan = findOrCreatePatientPlan(state, patient);
+ if (getOpenPatientPlanDraft(state.patientPlanVersions, plan.id) !== null) {
+ return refuse(
+ state,
+ `A Patient Plan draft for ${patient.preferredName} is already open. Continue that one rather than starting another.`,
+ );
+ }
+
+ const draftContent = buildPatientPlanDraft(
+ currentManagement,
+ patient,
+ getPatientResources(state.patientResources, patient.id),
+ );
+ const draft: PatientPlanVersion = {
+ id: nextSyntheticId(
+ "SYN-PATIENT-PLAN-VERSION",
+ state.patientPlanVersions.map(({ id }) => id),
+ ),
+ planId: plan.id,
+ version: nextVersionNumber(state.patientPlanVersions, plan.id),
+ state: "draft",
+ derivedFromManagementVersionId: currentManagement.id,
+ sections: cloneJson(draftContent.sections),
+ resources: cloneJson(draftContent.resources),
+ approvedBy: null,
+ approvedAt: null,
+ createdAt: prototypeTimestamp(state),
+ };
+
+ const gaps = unfilledGapSections(draft.sections).length;
+
+ return {
+ ...state,
+ patientPlans: state.patientPlans.some(({ id }) => id === plan.id)
+ ? state.patientPlans.map((candidate) =>
+ candidate.id === plan.id ? { ...candidate, versionIds: [...candidate.versionIds, draft.id] } : candidate,
+ )
+ : [...state.patientPlans, { ...plan, versionIds: [draft.id] }],
+ patientPlanVersions: [...state.patientPlanVersions, draft],
+ auditEvents: withAudit(state, {
+ type: "patient_plan_draft_created",
+ patientId: patient.id,
+ objectId: draft.id,
+ evidence: `Patient Plan draft version ${draft.version} produced from Management Plan version ${currentManagement.version} by the offline plain-language conversion. ${gaps} of ${draft.sections.length} sections were left as gaps for a clinician to write. No language model was used.`,
+ }),
+ lastOutcome: {
+ kind: gaps === 0 ? "success" : "info",
+ message:
+ gaps === 0
+ ? `Patient Plan draft version ${draft.version} created. Read it through before approving it.`
+ : `Patient Plan draft version ${draft.version} created, with ${gaps} of ${draft.sections.length} sections left blank for you to write. The conversion refuses to guess, so a gap is a section it would not risk getting wrong.`,
+ },
+ };
+ }
+
+ case "save-patient-plan-draft": {
+ const version = state.patientPlanVersions.find(({ id }) => id === action.versionId) ?? null;
+ if (version === null) return refuse(state, "That Patient Plan Version does not exist.");
+ if (version.state !== "draft") {
+ return refuse(
+ state,
+ `Only a draft can be edited. Patient Plan version ${version.version} is ${version.state}, and a copy the person may already be holding is never rewritten underneath them.`,
+ );
+ }
+
+ const saved: PatientPlanVersion = {
+ ...version,
+ // Replaced whole rather than patched: half a converted sentence and half
+ // a clinician's rewrite in one paragraph is nobody's voice.
+ sections: cloneJson(action.input.sections),
+ resources: cloneJson(action.input.resources),
+ };
+ const remaining = unfilledGapSections(saved.sections).length;
+
+ return {
+ ...state,
+ patientPlanVersions: state.patientPlanVersions.map((candidate) =>
+ candidate.id === saved.id ? saved : candidate,
+ ),
+ auditEvents: withAudit(state, {
+ type: "patient_plan_draft_saved",
+ patientId: patientOfPatientPlan(state, version.planId),
+ objectId: saved.id,
+ evidence: `Patient Plan draft version ${saved.version} saved with ${saved.sections.length} sections and ${saved.resources.length} resources. ${remaining} sections are still gaps.`,
+ }),
+ lastOutcome: {
+ kind: "success",
+ message:
+ remaining === 0
+ ? `Patient Plan draft version ${saved.version} saved. Every section now has something in it.`
+ : `Patient Plan draft version ${saved.version} saved. ${remaining} sections are still blank and it cannot be approved until they are written.`,
+ },
+ };
+ }
+
+ case "approve-patient-plan-version": {
+ const version = state.patientPlanVersions.find(({ id }) => id === action.versionId) ?? null;
+ if (version === null) return refuse(state, "That Patient Plan Version does not exist.");
+ if (version.state !== "draft") {
+ return refuse(
+ state,
+ `Only a draft can be approved. Patient Plan version ${version.version} is ${version.state}.`,
+ );
+ }
+ const plan = state.patientPlans.find(({ id }) => id === version.planId) ?? null;
+ if (plan === null) return refuse(state, "That version has no Patient Plan record.");
+
+ const patient = findPatient(state, plan.patientId);
+ const managementPlan = patient === null ? null : findManagementPlan(state, patient);
+ const currentManagement =
+ managementPlan === null
+ ? null
+ : getCurrentManagementPlanVersion(state.managementPlanVersions, managementPlan.id);
+ if (currentManagement === null || currentManagement.id !== version.derivedFromManagementVersionId) {
+ return refuse(
+ state,
+ `Patient Plan version ${version.version} was made from a Management Plan that is no longer Current, so it cannot be approved. Start a new copy from the agreed plan instead.`,
+ );
+ }
+
+ const approver = state.users.find(({ id }) => id === state.activeUserId) ?? null;
+ if (approver === null || isBlank(approver.displayName)) {
+ return refuse(state, "Approval must name the clinician approving it. Nothing was changed.");
+ }
+
+ /**
+ * The gap block. A gap is a place the conversion refused to guess, and an
+ * unfilled one prints as a heading with nothing under it — handed to the
+ * person it is about. The form makes the control unavailable with this
+ * reason; the reducer is the guard that means it cannot happen anyway.
+ */
+ const gaps = unfilledGapSections(version.sections);
+ if (gaps.length > 0) {
+ return refuse(
+ state,
+ `Patient Plan version ${version.version} cannot be approved while ${gaps.length} ${gaps.length === 1 ? "section is" : "sections are"} still blank: ${gaps.map((section) => section.heading).join("; ")}. A blank heading on a copy handed to somebody reads as though nothing about them was worth writing.`,
+ );
+ }
+
+ const approvedAt = prototypeTimestamp(state);
+
+ return {
+ ...state,
+ patientPlanVersions: state.patientPlanVersions.map((candidate) => {
+ if (candidate.id === version.id) {
+ return { ...candidate, state: "current" as const, approvedBy: approver.id, approvedAt };
+ }
+ if (candidate.planId === version.planId && candidate.state === "current") {
+ return { ...candidate, state: "superseded" as const };
+ }
+ return candidate;
+ }),
+ patientPlans: state.patientPlans.map((candidate) =>
+ candidate.id === plan.id ? { ...candidate, currentVersionId: version.id } : candidate,
+ ),
+ auditEvents: withAudit(state, {
+ type: "patient_plan_approved",
+ patientId: plan.patientId,
+ objectId: version.id,
+ evidence: `Patient Plan version ${version.version} approved by ${approver.displayName} and is now the copy given to this person. Any earlier copy is superseded and stays readable in history.`,
+ }),
+ lastOutcome: {
+ kind: "success",
+ message: `Patient Plan version ${version.version} approved by ${approver.displayName}. It can now be printed and given to this person.`,
+ },
+ };
+ }
+
+ case "record-patient-plan-print-intent": {
+ const patient = findPatient(state, action.patientId);
+ if (patient === null) return refuse(state, "That synthetic patient record does not exist.");
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === patient.id) ?? null;
+ const current = plan === null ? null : getCurrentPatientPlanVersion(state.patientPlanVersions, plan.id);
+ if (current === null) {
+ return refuse(state, `${patient.preferredName} has no approved Patient Plan to print.`);
+ }
+
+ return {
+ ...state,
+ auditEvents: withAudit(state, {
+ type: "patient_plan_print_intent_opened",
+ patientId: patient.id,
+ objectId: current.id,
+ evidence: `The browser print view was opened for Patient Plan version ${current.version}. This records the request only, and is not evidence that anything reached a printer or reached this person.`,
+ }),
+ lastOutcome: {
+ kind: "info",
+ message:
+ "The print view was opened. What happens after that is handled by the browser and is not recorded here.",
+ },
+ };
+ }
+
case "record-safety-plan-print-intent": {
const patient = findPatient(state, action.patientId);
if (patient === null) return refuse(state, "That synthetic patient record does not exist.");
diff --git a/src/components/care-plan/mockups/routable-suite.tsx b/src/components/care-plan/mockups/routable-suite.tsx
index 81cd4d077..5f961ce34 100644
--- a/src/components/care-plan/mockups/routable-suite.tsx
+++ b/src/components/care-plan/mockups/routable-suite.tsx
@@ -10,6 +10,8 @@ import { ManagementPlanFormSurface } from "./management-plan-form";
import { ManagementPlanPrintSurface } from "./management-plan-print";
import { ManagementPlanSurface } from "./management-plan-read";
import { ManagementPlanReviewSurface } from "./management-plan-review";
+import { PatientPlanFormSurface } from "./patient-plan-form";
+import { PatientPlanPrintSurface, PatientPlanSurface } from "./patient-plan-pages";
import { PresentationFormSurface } from "./presentation-form";
import {
PresentationDetailSurface,
@@ -290,8 +292,10 @@ export function carePlanPatientIdFromPathname(pathname: string): string | null {
* surface. Task 7 added the three ED Presentation routes — the timeline, the
* recording form, and one episode with its corrections. Task 8 added the three
* Personal Safety Plan routes — the person's own document, the form that writes
- * it, and the patient copy that prints. Every remaining route still renders a
- * specimen.
+ * it, and the patient copy that prints. Task 9 added the three Patient Plan
+ * routes — the person's own edition of the Management Plan, the form that fills
+ * the gaps the conversion refused to guess at, and the copy that prints. Every
+ * remaining route still renders a specimen.
*/
const SNAPSHOT_VARIANT_BY_ROUTE_KEY: Partial> = {
home: "home",
@@ -401,6 +405,12 @@ export function CarePlanRouteSurface({ pathname, query = "", navigate }: CarePla
presentationId={presentationIdFromPathname(pathname)}
scenario={scenario}
/>
+ ) : route.key === ROUTE_DEFINITIONS.patientPlan.key ? (
+
+ ) : route.key === ROUTE_DEFINITIONS.patientPlanEdit.key ? (
+
+ ) : route.key === ROUTE_DEFINITIONS.patientPlanPrint.key ? (
+
) : route.key === ROUTE_DEFINITIONS.safetyPlan.key ? (
) : route.key === ROUTE_DEFINITIONS.safetyPlanEdit.key ? (
diff --git a/src/components/care-plan/mockups/routes.ts b/src/components/care-plan/mockups/routes.ts
index a7797a264..e653e13c7 100644
--- a/src/components/care-plan/mockups/routes.ts
+++ b/src/components/care-plan/mockups/routes.ts
@@ -80,6 +80,15 @@ export const carePlanRoute = {
patientPlan(patientId: string) {
return `${patientPath(patientId)}/patient-plan`;
},
+ /** Where the person's own copy is written and its gaps are filled. Any
+ * clinical role may approve it, so this is the only authoring address the
+ * Patient Plan has. */
+ patientPlanEdit(patientId: string) {
+ return `${patientPath(patientId)}/patient-plan/edit`;
+ },
+ patientPlanPrint(patientId: string) {
+ return `${patientPath(patientId)}/patient-plan/print`;
+ },
safetyPlan(patientId: string) {
return `${patientPath(patientId)}/safety-plan`;
},
diff --git a/src/components/care-plan/mockups/types.ts b/src/components/care-plan/mockups/types.ts
index 17d8b7843..466375d70 100644
--- a/src/components/care-plan/mockups/types.ts
+++ b/src/components/care-plan/mockups/types.ts
@@ -205,11 +205,20 @@ export type PatientResourceCategory =
export type PatientResource = {
id: SyntheticId;
+ /** Resources are chosen for one person, not offered from a single national
+ * list: a housing service in one catchment is no use in another, and a
+ * resource sheet that cannot be wrong for somebody is not a resource sheet.
+ * So every entry names the person it was chosen for. */
+ patientId: SyntheticId;
category: PatientResourceCategory;
name: string;
detail: string;
contact: string | null;
sourceUrl: string | null;
+ /** True only for the verified public crisis lines. Everything else in the
+ * prototype is invented, and a printed sheet must be able to say which is
+ * which rather than leaving a reader to dial a fictional number. */
+ isRealContact: boolean;
};
export type PatientPlan = {
@@ -345,7 +354,14 @@ export type ReviewTrigger = {
* participation, so involving the person stays on somebody's list. The
* persistent on-screen marker alone is not enough: a marker is read only
* by whoever opens that plan, while a trigger reaches the Reviews queue. */
- | "participation";
+ | "participation"
+ /** Raised when a newer Management Plan Version is approved while the person
+ * holds a Current Patient Plan derived from the version it replaced. The
+ * patient copy is never regenerated or withdrawn automatically — the person
+ * may be holding it on paper — so the discrepancy has to reach a human
+ * instead. Deduplicated per plan: one open trigger, however many versions
+ * are approved before somebody acts on it. */
+ | "patient_plan_stale";
sourceId: SyntheticId;
reason: string;
status: "open" | "resolved";
@@ -456,6 +472,17 @@ export type SafetyPlanDraftInput = {
content: SafetyPlanContent;
};
+/**
+ * What a clinician's edit of a patient copy replaces. Sections and resources are
+ * replaced whole rather than patched: a partial update of a document written in
+ * somebody's own voice is how half a converted sentence and half a clinician's
+ * rewrite end up in one paragraph.
+ */
+export type PatientPlanDraftInput = {
+ sections: readonly PatientPlanSection[];
+ resources: readonly PatientResource[];
+};
+
export type NewEdPresentationInput = Omit;
/**
@@ -544,8 +571,8 @@ export type PrototypeCapability =
* would only be a dead branch in an otherwise exhaustive switch, and a dead
* branch is where an unenforced transition hides.
*
- * Deferred to their own tasks, deliberately absent here: the four Patient Plan
- * actions.
+ * The four Patient Plan actions joined in Task 9, which is the task that
+ * implements them. Nothing else is outstanding.
*/
export type CarePlanPrototypeAction =
| { type: "select-patient"; patientId: SyntheticId }
@@ -576,6 +603,18 @@ export type CarePlanPrototypeAction =
| { type: "create-safety-plan-draft"; patientId: SyntheticId }
| { type: "save-safety-plan-draft"; versionId: SyntheticId; input: SafetyPlanDraftInput }
| { type: "make-safety-plan-current"; versionId: SyntheticId }
+ /** Derives a patient edition from the Current Management Plan Version. It
+ * refuses when there is none: a patient copy of a plan nobody has approved
+ * would be a document in somebody's hands describing care nobody agreed. */
+ | { type: "create-patient-plan-draft"; patientId: SyntheticId }
+ | { type: "save-patient-plan-draft"; versionId: SyntheticId; input: PatientPlanDraftInput }
+ /** Any clinical role may approve a patient copy. Requiring a senior clinician
+ * would mean a person waits days for their own copy of their own plan, which
+ * defeats the point of giving them one. What approval does require is that
+ * every flagged gap has been filled: a gap is a place the transformation
+ * refused to guess, and an unfilled one would print as a blank heading. */
+ | { type: "approve-patient-plan-version"; versionId: SyntheticId }
+ | { type: "record-patient-plan-print-intent"; patientId: SyntheticId }
| { type: "record-safety-plan-print-intent"; patientId: SyntheticId }
| { type: "record-management-plan-print-intent"; patientId: SyntheticId }
| { type: "record-contact-intent"; patientId: SyntheticId; cmhtId: SyntheticId; channel: "email" | "call" }
diff --git a/tests/care-plan-linked-routes.dom.test.tsx b/tests/care-plan-linked-routes.dom.test.tsx
index 5cf0e0d61..cb2bff75e 100644
--- a/tests/care-plan-linked-routes.dom.test.tsx
+++ b/tests/care-plan-linked-routes.dom.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, render, screen, within } from "@testing-library/react";
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -308,21 +308,6 @@ describe("Care Plan route shell", () => {
// block below asserts that none of those thirteen renders a purpose surface
// at all.
it.each([
- [
- CARE_PLAN_ROUTES.patientPlan,
- "Patient Plan",
- "The patient-facing edition of the Management Plan, with its own version and approval state",
- ],
- [
- CARE_PLAN_ROUTES.patientPlanEdit,
- "Draft Patient Plan",
- "Create the patient edition from the Current Plan, fill its flagged gaps, and approve it",
- ],
- [
- CARE_PLAN_ROUTES.patientPlanPrint,
- "Print Patient Plan",
- "Print-optimised patient copy, including their resources",
- ],
[
CARE_PLAN_ROUTES.history,
"History",
@@ -347,30 +332,15 @@ describe("Care Plan route shell", () => {
expect(within(surface).getByText(purpose)).toBeInTheDocument();
});
- it("hands the submitted search to Patients without putting record content in the URL", async () => {
+ it("offers exactly one search slot and navigates it without putting record content in the URL", async () => {
const user = userEvent.setup();
- const navigate = vi.fn();
- const route = render(
-
-
- ,
- );
+ const navigate = renderRoute(CARE_PLAN_ROUTES.reviews);
const search = screen.getByRole("searchbox", { name: "Search patients" });
expect(screen.getAllByRole("searchbox")).toHaveLength(1);
await user.type(search, "Rowan");
await user.click(screen.getByRole("button", { name: "Search patients" }));
expect(navigate).toHaveBeenCalledWith(CARE_PLAN_ROUTES.patients);
expect(navigate.mock.calls.every(([href]) => !String(href).includes("Rowan"))).toBe(true);
-
- route.rerender(
-
-
- ,
- );
- expect(screen.getByRole("searchbox", { name: "Search synthetic patients" })).toHaveValue("Rowan");
- const results = screen.getByTestId("care-plan-directory-results");
- expect(within(results).getByRole("button", { name: "Open Rowan Sample" })).toBeInTheDocument();
- expect(within(results).queryAllByRole("button", { name: /^Open / })).toHaveLength(1);
});
it("reads the named specimen scenario from the URL and nothing else", () => {
@@ -410,6 +380,9 @@ describe("Care Plan route shell", () => {
CARE_PLAN_ROUTES.safetyPlan,
CARE_PLAN_ROUTES.safetyPlanEdit,
CARE_PLAN_ROUTES.safetyPlanPrint,
+ CARE_PLAN_ROUTES.patientPlan,
+ CARE_PLAN_ROUTES.patientPlanEdit,
+ CARE_PLAN_ROUTES.patientPlanPrint,
]) {
const { unmount } = render(
@@ -898,6 +871,7 @@ describe("Care Plan review currency with no review date", () => {
activeSection={null}
reviewsHref={CARE_PLAN_ROUTES.reviews}
onRecordContactIntent={() => {}}
+ contactActionBlockedReason={null}
/>,
);
}
@@ -976,6 +950,19 @@ describe("Care Plan CMHT contact actions", () => {
}
});
+ it.each(["permission-unavailable", "version-conflict"])(
+ "prevents an unrecordable contact launch when %s blocks its audit intent",
+ (scenario) => {
+ renderRoute(CARE_PLAN_ROUTES.patient, `scenario=${scenario}`);
+ const email = screen.getByRole("link", { name: "Email North River CMHT" });
+
+ expect(email).toHaveAttribute("aria-disabled", "true");
+ expect(fireEvent.click(email)).toBe(false);
+ expect(screen.getByRole("alert")).toHaveTextContent(/nothing was changed|newer version/i);
+ expect(screen.queryByTestId("care-plan-outcome")).toBeNull();
+ },
+ );
+
it("keeps unverified contact details visible with a warning, last-verified date and a Reviews link", () => {
renderRoute(carePlanRoute.patient("SYN-PATIENT-003"));
const contacts = screen.getByRole("region", { name: "Community mental health team" });
@@ -1004,6 +991,11 @@ describe("Care Plan CMHT contact actions", () => {
const contacts = screen.getByRole("region", { name: "Community mental health team" });
expect(within(contacts).getByRole("link", { name: "Call North River CMHT" })).toBeInTheDocument();
});
+
+ // The guarded-launch behaviour itself (aria-disabled, preventDefault, no
+ // audit outcome) is covered by "prevents an unrecordable contact launch
+ // when %s blocks its audit intent" above, across both scenarios that can
+ // block record-contact-intent.
});
describe("Care Plan Management Plan reading", () => {
@@ -3198,3 +3190,228 @@ describe("Care Plan error boundary", () => {
expect(screen.queryByRole("heading", { level: 1, name: "Care Plan could not be displayed" })).toBeNull();
});
});
+
+/**
+ * The Patient Plan, driven through the interface.
+ *
+ * These render one provider and move between addresses inside it, because a
+ * patient copy only exists after somebody makes one: a fresh provider per route
+ * would put the print route in front of a world where nothing had been written.
+ */
+describe("Care Plan Patient Plan", () => {
+ /** The eight headings, written out here rather than imported. A content check
+ * that reads the constant its subject renders from can never disagree with
+ * it. */
+ const PATIENT_PLAN_HEADINGS = [
+ "Why we wrote this together",
+ "What matters to you",
+ "What helps you",
+ "What makes things harder",
+ "What we agreed will happen when you come to the emergency department",
+ "If something new is happening",
+ "Who's involved in your care",
+ "Things that might help",
+ ];
+
+ function renderJourney(pathname: string) {
+ const navigate = vi.fn();
+ const view = (at: string) => (
+
+
+
+ );
+ const { rerender } = render(view(pathname));
+ return { navigate, goTo: (at: string) => rerender(view(at)) };
+ }
+
+ async function createDraft(user: ReturnType) {
+ await user.click(screen.getByRole("button", { name: "Create the patient copy" }));
+ }
+
+ /**
+ * Pasted rather than typed. Typing this into eight textareas is ~250
+ * simulated keystrokes, which took long enough to push neighbouring tests
+ * past their timeout — the suite failed on a different pair of tests on each
+ * run, which is a flake rather than a finding.
+ */
+ async function fillEverySection(user: ReturnType) {
+ for (const field of screen.getAllByRole("textbox")) {
+ await user.clear(field);
+ await user.click(field);
+ await user.paste("Written with you at the bedside.");
+ }
+ }
+
+ it("offers no copy until one is made, and then shows what the conversion refused to guess", async () => {
+ const user = userEvent.setup();
+ renderJourney(carePlanRoute.patientPlan("SYN-PATIENT-001"));
+
+ expect(screen.getByTestId("care-plan-patient-plan-no-current")).toHaveTextContent(/no approved Patient Plan/i);
+ await createDraft(user);
+
+ const draftNotice = screen.getByTestId("care-plan-patient-plan-draft-notice");
+ expect(draftNotice).toHaveTextContent(/sections still to write/i);
+ });
+
+ it("states why each blank section is blank, in words a clinician can act on", async () => {
+ const user = userEvent.setup();
+ renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+
+ const agreed = screen.getByTestId("care-plan-patient-plan-form-gap-whatWeAgreedWillHappen");
+ expect(agreed).toHaveTextContent(/never converted automatically/i);
+ expect(agreed).toHaveTextContent(/written by a clinician/i);
+ });
+
+ /**
+ * The control is unavailable for a stated reason, so it keeps its tab stop and
+ * the reason can actually be reached. `aria-disabled` and native `disabled`
+ * together fail lint; native `disabled` alone would take the reason with it.
+ */
+ it("makes approval unavailable with a stated reason while any section is blank", async () => {
+ const user = userEvent.setup();
+ renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+
+ const approve = screen.getByRole("button", { name: "Approve patient copy" });
+ expect(approve).toHaveAttribute("aria-disabled", "true");
+ expect(approve).not.toHaveAttribute("disabled");
+ const reason = screen.getByTestId("care-plan-patient-plan-approve-unavailable");
+ expect(reason).toHaveTextContent(/cannot be approved while/i);
+ expect(approve.getAttribute("aria-describedby")).toBe(reason.id);
+
+ await user.click(approve);
+ expect(screen.getByRole("button", { name: "Approve patient copy" })).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("approves once every section is written, and shows the eight headings in order", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+
+ goTo(carePlanRoute.patientPlan("SYN-PATIENT-001"));
+ expect(
+ within(screen.getByTestId("care-plan-patient-plan-sections"))
+ .getAllByRole("heading", { level: 3 })
+ .map((node) => node.textContent),
+ ).toEqual(PATIENT_PLAN_HEADINGS);
+ });
+
+ /**
+ * The print hazard that has now bitten twice. `globals.css` makes everything
+ * outside `[data-print-output]` invisible when printing, so the shell's
+ * synthetic marker cannot reach the paper. The printed subtree must carry its
+ * own, and the assertion is against the marker's *ancestors* — checking only
+ * that the element itself lacks the attribute would have missed the original
+ * defect, which was inherited.
+ */
+ it("carries the synthetic watermark inside the printed subtree, under no hidden ancestor", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+ goTo(carePlanRoute.patientPlanPrint("SYN-PATIENT-001"));
+
+ const paper = screen.getByTestId("care-plan-patient-plan-print-output");
+ const marker = within(paper).getByText("Synthetic prototype — fictional people, teams, and hospitals");
+ expect(marker.closest("[data-print-output]")).toBe(paper);
+ expect(marker.closest("[data-print-hide='true']")).toBeNull();
+ expect(paper.closest("[data-print-hide='true']")).toBeNull();
+ expect(paper.querySelector("[data-print-confidential]")).toHaveTextContent(/Confidential clinical document/i);
+ // Deterministic: derived from PROTOTYPE_NOW, never a wall clock.
+ expect(paper.querySelector("[data-print-stamp]")).toHaveTextContent("20/08/2026");
+ expect(paper.querySelector("[data-print-stamp]")?.closest("[data-print-hide='true']")).toBeNull();
+ });
+
+ it("prints the preferred name and record number, and no navigation, audit, or plan metadata", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+ goTo(carePlanRoute.patientPlanPrint("SYN-PATIENT-001"));
+
+ const paper = screen.getByTestId("care-plan-patient-plan-print-output");
+ const text = paper.textContent ?? "";
+ expect(text).toContain("Rowan");
+ expect(text).toContain("SYN-MRN-0001");
+ expect(text).not.toContain("Rowan Sample");
+ expect(text).not.toMatch(/12\/04\/1986|1986-04-12|date of birth/i);
+ expect(text).not.toContain("they/them");
+ expect(text).not.toContain("North River Health Service");
+ expect(text).not.toMatch(/audit|Review Trigger|Superseded|plan owner|Awaiting Approval|revision reason/i);
+ expect(text).not.toMatch(/Not recorded/);
+ /*
+ * Clinical vocabulary is checked on the plan's own eight sections rather
+ * than on the whole sheet. The crisis block carries MHERL's and Rurallink's
+ * own published wording — each "is a telephone triage and support line. It
+ * is not an emergency service" — and paraphrasing a real service's safety
+ * caveat to satisfy a house style would be a worse error than the one this
+ * guards against.
+ */
+ const sections = screen.getByTestId("care-plan-patient-plan-sections").textContent ?? "";
+ expect(sections).not.toMatch(/ED Presentation|disposition|mental[- ]state|risk assessment|triage/i);
+ expect(within(paper).queryAllByRole("button")).toEqual([]);
+ expect(within(paper).queryAllByRole("navigation")).toEqual([]);
+ });
+
+ it("prints the real crisis lines with their caveats and official sources, and nothing fictional beside them", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-001"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+ goTo(carePlanRoute.patientPlanPrint("SYN-PATIENT-001"));
+
+ const resources = screen.getByTestId("care-plan-patient-plan-resources");
+ for (const contact of publicCrisisContacts) {
+ expect(resources, `${contact.name} is missing`).toHaveTextContent(contact.telephoneDisplay);
+ expect(
+ resources.querySelector(`a[href="${contact.sourceUrl}"]`),
+ `${contact.name} is printed with no official source`,
+ ).not.toBeNull();
+ if (contact.caveat !== null) expect(resources).toHaveTextContent(contact.caveat);
+ }
+ expect(resources).toHaveTextContent(/not an emergency service/i);
+ });
+
+ /**
+ * A copy the person may be holding is never regenerated, hidden, or
+ * withdrawn. It says it needs updating, and stays completely readable.
+ */
+ it("marks a copy as needing updating without taking any of it away", async () => {
+ const user = userEvent.setup();
+ const { goTo } = renderJourney(carePlanRoute.patientPlanEdit("SYN-PATIENT-002"));
+ await createDraft(user);
+ await fillEverySection(user);
+ await user.click(screen.getByRole("button", { name: "Approve patient copy" }));
+
+ // Approve the Management Plan version already awaiting approval, which moves
+ // the plan on underneath the copy just approved.
+ goTo(carePlanRoute.managementPlanReview("SYN-PATIENT-002"));
+ await signInAs(user, "SYN-USER-SENIOR-001");
+ await user.click(screen.getByRole("button", { name: /^Approve version/ }));
+ await user.click(screen.getByRole("button", { name: "Approve and make Current" }));
+
+ goTo(carePlanRoute.patientPlan("SYN-PATIENT-002"));
+ expect(screen.getByTestId("care-plan-patient-plan-stale")).toHaveTextContent(/needs updating/i);
+ // Every heading is still there, and nothing was withdrawn.
+ expect(
+ within(screen.getByTestId("care-plan-patient-plan-sections")).getAllByRole("heading", { level: 3 }),
+ ).toHaveLength(8);
+ expect(screen.queryByTestId("care-plan-patient-plan-no-current")).toBeNull();
+
+ // The same currency warning shown on screen must reach the printed sheet.
+ // The screen-only StaleNotice above sits outside `[data-print-output]`, so
+ // if the print surface carries no warning of its own a clinician can print
+ // and hand over an earlier-version copy whose paper looks current.
+ goTo(carePlanRoute.patientPlanPrint("SYN-PATIENT-002"));
+ const paper = screen.getByTestId("care-plan-patient-plan-print-output");
+ const staleOnPaper = within(paper).getByTestId("care-plan-patient-plan-print-stale");
+ expect(staleOnPaper).toHaveTextContent(/needs updating/i);
+ expect(staleOnPaper.closest("[data-print-hide='true']")).toBeNull();
+ });
+});
diff --git a/tests/care-plan-patient-plan.test.ts b/tests/care-plan-patient-plan.test.ts
new file mode 100644
index 000000000..edb907458
--- /dev/null
+++ b/tests/care-plan-patient-plan.test.ts
@@ -0,0 +1,1199 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ canPerformAction,
+ getCurrentManagementPlanVersion,
+ getCurrentPatientPlanVersion,
+ getOpenPatientPlanDraft,
+ isPatientPlanVersionStale,
+} from "@/components/care-plan/mockups/domain";
+import { publicCrisisContacts, syntheticPatients } from "@/components/care-plan/mockups/fixtures";
+import {
+ PATIENT_RESOURCE_CATEGORY_ORDER,
+ getPatientResources,
+ groupPatientResources,
+ syntheticPatientResources,
+} from "@/components/care-plan/mockups/patient-plan-fixtures";
+import {
+ AGREEMENT_SAFE_S_WORDS,
+ EVERYDAY_WORDS,
+ PATIENT_PLAN_GAP_REASON,
+ PATIENT_PLAN_OMITTED_CONTENT_KEYS,
+ PATIENT_PLAN_SECTION_HEADING,
+ PATIENT_PLAN_SECTION_LEAD_IN,
+ PATIENT_PLAN_SECTION_SOURCES,
+ PLAIN_LANGUAGE_TERMS,
+ THIRD_PERSON_VERBS,
+ UNCONVERTIBLE_CLINICAL_TERMS,
+ buildPatientPlanDraft,
+ createLineConverter,
+ isEverydayWord,
+ unfilledGapSections,
+} from "@/components/care-plan/mockups/patient-plan-transform";
+import { createInitialPrototypeState, prototypeReducer } from "@/components/care-plan/mockups/prototype-state";
+import {
+ PATIENT_PLAN_SECTION_KEYS,
+ type CarePlanPrototypeAction,
+ type CarePlanPrototypeState,
+ type ManagementPlanContent,
+ type ManagementPlanVersion,
+ type Patient,
+ type PatientPlanSection,
+ type PrototypeRole,
+ type SyntheticId,
+} from "@/components/care-plan/mockups/types";
+
+/**
+ * The Patient Plan: the deterministic transformation, the lifecycle around it,
+ * and — the part none of the other gates can see — whether the page it produces
+ * is kind to read.
+ *
+ * The content checks below spell their forbidden phrasing out literally rather
+ * than reading it back from the constant the code renders from. A generative
+ * assertion can never disagree with what it checks: two of Task 8's controls
+ * survived a deliberate break because they compared rendered text against the
+ * very constant that produced it. Structural checks may read from the domain;
+ * content checks may not.
+ */
+
+const ROWAN: SyntheticId = "SYN-PATIENT-001";
+const MIRA: SyntheticId = "SYN-PATIENT-002";
+const JORDAN: SyntheticId = "SYN-PATIENT-003";
+
+function patientBy(id: SyntheticId): Patient {
+ const patient = syntheticPatients.find((candidate) => candidate.id === id);
+ if (patient === undefined) throw new Error(`no synthetic patient ${id}`);
+ return patient;
+}
+
+/**
+ * The Current Management Plan Version for one patient.
+ *
+ * The brief's worked example called `getCurrentManagementPlanVersion(state, patientId)`.
+ * That is not the selector's signature — it takes a version list and a *plan*
+ * identifier — so the lookup goes through the patient's `managementPlanId`.
+ */
+function currentVersionFor(patientId: SyntheticId, state = createInitialPrototypeState()): ManagementPlanVersion {
+ const version = getCurrentManagementPlanVersion(state.managementPlanVersions, patientBy(patientId).managementPlanId);
+ if (version === null) throw new Error(`no current management version for ${patientId}`);
+ return version;
+}
+
+function draftFor(patientId: SyntheticId) {
+ return buildPatientPlanDraft(currentVersionFor(patientId), patientBy(patientId), syntheticPatientResources);
+}
+
+function converterFor(patientId: SyntheticId) {
+ return createLineConverter(currentVersionFor(patientId), patientBy(patientId), syntheticPatientResources);
+}
+
+/** A version whose content is whatever the caller wants, so a rule can be
+ * proved on the sentence that triggers it rather than on whichever fixture
+ * happens to contain one. */
+function versionWithContent(content: Partial): ManagementPlanVersion {
+ return {
+ ...currentVersionFor(ROWAN),
+ content: { ...currentVersionFor(ROWAN).content, ...content },
+ };
+}
+
+function run(state: CarePlanPrototypeState, ...actions: CarePlanPrototypeAction[]): CarePlanPrototypeState {
+ return actions.reduce(prototypeReducer, state);
+}
+
+function asUser(state: CarePlanPrototypeState, role: PrototypeRole): CarePlanPrototypeState {
+ const user = state.users.find((candidate) => candidate.role === role);
+ if (user === undefined) throw new Error(`no synthetic user with role ${role}`);
+ return prototypeReducer(state, { type: "set-active-user", userId: user.id });
+}
+
+/** The whole world one patient copy is built in: a draft, open and editable. */
+function stateWithDraft(patientId: SyntheticId = ROWAN, role: PrototypeRole = "liaison_clinician") {
+ const state = run(asUser(createInitialPrototypeState(), role), {
+ type: "create-patient-plan-draft",
+ patientId,
+ });
+ const plan = state.patientPlans.find((candidate) => candidate.patientId === patientId);
+ if (plan === undefined) throw new Error("no patient plan record was created");
+ const draft = getOpenPatientPlanDraft(state.patientPlanVersions, plan.id);
+ if (draft === null) throw new Error("no patient plan draft was created");
+ return { state, plan, draft };
+}
+
+/** Every gap filled with something a clinician might actually write. */
+function filled(sections: readonly PatientPlanSection[]): PatientPlanSection[] {
+ return sections.map((section) =>
+ section.gap
+ ? { ...section, body: ["Written with this person at the bedside."], gap: false, gapReason: null }
+ : section,
+ );
+}
+
+describe("Patient Plan transformation", () => {
+ it("never auto-converts the agreed approach and leaves it as a clinician gap", () => {
+ const agreed = draftFor(ROWAN).sections.find(({ key }) => key === "whatWeAgreedWillHappen");
+ if (agreed === undefined) throw new Error("the draft has no agreed-approach section");
+
+ expect(agreed.gap).toBe(true);
+ expect(agreed.body).toEqual([]);
+ expect(agreed.gapReason).toMatch(/written by a clinician/i);
+ });
+
+ /**
+ * The section is refused for what it *is*, not for what it happens to say.
+ * Content this plain converts anywhere else in the plan, so if the rule were
+ * ever weakened to "gap when the wording is difficult", this is the case that
+ * would slip through — and it is the section a person is most likely to read
+ * as a judgement about them.
+ */
+ it("refuses the agreed approach even when its content is entirely plain", () => {
+ const plain = versionWithContent({ agreedEdApproach: ["We will find you a quiet room."] });
+ const draft = buildPatientPlanDraft(plain, patientBy(ROWAN), syntheticPatientResources);
+ const agreed = draft.sections.find(({ key }) => key === "whatWeAgreedWillHappen");
+
+ expect(agreed?.gap).toBe(true);
+ expect(agreed?.body).toEqual([]);
+ // The same sentence under any other heading converts, which is what makes
+ // the refusal above about the section rather than about the words.
+ expect(converterFor(ROWAN)("We will find you a quiet room.")).toEqual({
+ converted: "We will find you a quiet room.",
+ });
+ });
+
+ it("is a pure function of the version and never reaches outside itself", () => {
+ const a = draftFor(ROWAN);
+ const b = draftFor(ROWAN);
+ expect(a).toEqual(b);
+ expect(JSON.stringify(a)).toBe(JSON.stringify(b));
+ });
+
+ it("leaves the version, the patient, and the resources it was given untouched", () => {
+ const version = currentVersionFor(ROWAN);
+ const patient = patientBy(ROWAN);
+ const before = JSON.stringify({ version, patient, resources: syntheticPatientResources });
+ buildPatientPlanDraft(version, patient, syntheticPatientResources);
+ expect(JSON.stringify({ version, patient, resources: syntheticPatientResources })).toBe(before);
+ });
+
+ it("produces the eight approved sections, in order, generated from the domain keys", () => {
+ const draft = draftFor(ROWAN);
+ expect(draft.sections.map((section) => section.key)).toEqual([...PATIENT_PLAN_SECTION_KEYS]);
+ expect(draft.sections).toHaveLength(8);
+ for (const section of draft.sections) {
+ expect(section.heading).toBe(PATIENT_PLAN_SECTION_HEADING[section.key]);
+ }
+ });
+
+ /**
+ * Physical health and medication is deliberately absent, and so is the
+ * how-to-approach field. Asserting the mapping is exhaustive is what stops a
+ * twelfth content field being added later and appearing on no surface at all
+ * while nothing goes red.
+ */
+ it("accounts for every Management Plan content field, and carries neither medication nor staff instructions", () => {
+ const mapped = new Set(Object.values(PATIENT_PLAN_SECTION_SOURCES).flat());
+ const omitted = new Set(PATIENT_PLAN_OMITTED_CONTENT_KEYS);
+ const everyField = Object.keys(currentVersionFor(ROWAN).content) as (keyof ManagementPlanContent)[];
+
+ expect(everyField.length).toBe(11);
+ for (const field of everyField) {
+ expect(mapped.has(field) || omitted.has(field), `${field} is neither mapped nor deliberately omitted`).toBe(true);
+ expect(mapped.has(field) && omitted.has(field), `${field} is both mapped and omitted`).toBe(false);
+ }
+ expect(omitted.has("physicalHealthAndMedication")).toBe(true);
+ expect(omitted.has("howToApproach")).toBe(true);
+
+ // Nothing from the medication field reaches any section body, on any
+ // fixture, whatever the dictionary does to the words in it.
+ for (const patient of syntheticPatients) {
+ const version = getCurrentManagementPlanVersion(
+ createInitialPrototypeState().managementPlanVersions,
+ patient.managementPlanId,
+ );
+ if (version === null) continue;
+ const bodies = buildPatientPlanDraft(version, patient, syntheticPatientResources)
+ .sections.flatMap((section) => section.body)
+ .join(" ")
+ .toLowerCase();
+ for (const line of version.content.physicalHealthAndMedication) {
+ expect(bodies).not.toContain(line.slice(0, 24).toLowerCase());
+ }
+ }
+ });
+
+ it("carries no ninth section and no physical-health heading", () => {
+ const headings = Object.values(PATIENT_PLAN_SECTION_HEADING).join(" ").toLowerCase();
+ expect(PATIENT_PLAN_SECTION_KEYS).toHaveLength(8);
+ for (const forbidden of ["medication", "medicine", "physical health", "allergy", "dose"]) {
+ expect(headings).not.toContain(forbidden);
+ }
+ });
+});
+
+describe("Patient Plan gap triggers", () => {
+ const convert = converterFor(ROWAN);
+
+ it("gaps a sentence holding a clinical term the dictionary refuses to convert", () => {
+ expect(convert("The waiting area can be loud at night.")).toEqual({
+ converted: "The waiting area can be loud at night.",
+ });
+ expect(convert("A quiet room helps when your distress is building.")).toEqual({
+ gapReasonKey: "unknownTerm",
+ });
+ });
+
+ /**
+ * The dictionary rule proper: a word that is simply *not in the vocabulary*,
+ * whether or not anybody thought to list it as clinical.
+ *
+ * This is separate from the test above on purpose. "Distress" is on the
+ * refused list and is caught by that check first, so a test using it proves
+ * nothing about the vocabulary — with the whole word check deleted, it still
+ * passed. Every word below is ordinary English that the curated vocabulary
+ * does not contain, which is the only shape that isolates this rule.
+ */
+ it("gaps a sentence holding any word the vocabulary does not contain", () => {
+ expect(convert("The corridor can feel claustrophobic at night.")).toEqual({ gapReasonKey: "unknownTerm" });
+ expect(convert("A blanket and a cup of tea help enormously.")).toEqual({ gapReasonKey: "unknownTerm" });
+ expect(convert("You settle once the negotiation is over.")).toEqual({ gapReasonKey: "unknownTerm" });
+ // The same sentences in words the vocabulary does hold.
+ expect(convert("The corridor can feel loud at night.")).toEqual({
+ converted: "The corridor can feel loud at night.",
+ });
+ expect(convert("A blanket and a warm drink help a lot.")).toEqual({
+ converted: "A blanket and a warm drink help a lot.",
+ });
+ });
+
+ /** A name already on this person's record is not an unknown word. */
+ it("lets through a name the person's own record already carries, and refuses one it does not", () => {
+ expect(convert("You can ring Jess.")).toEqual({ converted: "You can ring Jess." });
+ expect(convert("You can ring Pemberton.")).toEqual({ gapReasonKey: "unknownTerm" });
+ });
+
+ /**
+ * The negation rule earns its place only if a plain negation still converts.
+ * If every "not" gapped, the rule would be indistinguishable from refusing
+ * the whole plan, and the section reasons would be lies about why.
+ */
+ it("gaps a negated clinical statement while letting a plain preference through", () => {
+ expect(convert("Pain relief was not offered before the mental-health assessment.")).toEqual({
+ gapReasonKey: "unknownTerm",
+ });
+ expect(convert("Your family doctor is not the team you would ring at night.")).toEqual({
+ gapReasonKey: "clinicalNegation",
+ });
+ expect(convert("We will ring the number you give us.")).toEqual({
+ converted: "We will ring the number you give us.",
+ });
+ });
+
+ /**
+ * The rule that caught the worst line the first run of this transformation
+ * produced: a clinician's instruction, converted into an order addressed to
+ * the person the plan is about.
+ */
+ it("gaps a sentence written as an instruction to staff", () => {
+ expect(convert("Say so plainly when you use it, and offer again to write it together.")).toEqual({
+ gapReasonKey: "clinicianInstruction",
+ });
+ expect(convert("Keep the first conversation short and say what will happen next.")).toEqual({
+ gapReasonKey: "clinicianInstruction",
+ });
+ expect(convert("A seat in a quieter area, and a clear time for the next update.")).toEqual({
+ converted: "A seat in a quieter area, and a clear time for the next update.",
+ });
+ });
+
+ /** The rule that caught "To be called you" on Evie's copy. */
+ it("gaps a sentence about what this person is called rather than replacing the name with “you”", () => {
+ const evie = createLineConverter(currentVersionFor(ROWAN), patientBy("SYN-PATIENT-004"), syntheticPatientResources);
+ expect(evie("To be called Evie, and to have privacy for the conversation.")).toEqual({
+ gapReasonKey: "namingContext",
+ });
+ expect(convert("To be addressed as Rowan.")).toEqual({ gapReasonKey: "namingContext" });
+ });
+
+ it("gaps a sentence whose pronoun could belong to somebody else in it", () => {
+ expect(convert("You settle once they can ring their sister.")).toEqual({ gapReasonKey: "ambiguousPronoun" });
+ expect(convert("A drink, a blanket, and a warm room.")).toEqual({
+ converted: "A drink, a blanket, and a warm room.",
+ });
+ });
+
+ /**
+ * For a `she/her` patient the possessive and the object pronoun are the same
+ * word: "ring her son" wants "your son" and "hard for her to follow" wants
+ * "you", and no grammar here can tell them apart. Substituting one rule
+ * everywhere printed "which make it hard for your to follow" on Mira's copy on
+ * the first run, so the sentence is refused instead.
+ */
+ it("refuses a sentence whose pronoun is both this person's possessive and their object", () => {
+ const mira = converterFor(MIRA);
+ expect(mira("It is hard for her to follow what is being asked.")).toEqual({
+ gapReasonKey: "ambiguousPronoun",
+ });
+ expect(mira("A warm blanket helps her back.")).toEqual({ gapReasonKey: "ambiguousPronoun" });
+ // A they/them patient's pronouns are distinct, so the same shape converts.
+ expect(convert("A warm blanket helps their back.")).toEqual({ converted: "A warm blanket helps your back." });
+ });
+
+ /**
+ * `his` is only a trailing-boundary match away from rewriting the tail of
+ * `this`. That produced `Tyour`, which the vocabulary then refused, so a
+ * sentence that should have converted became a gap on a he/him copy.
+ */
+ it("does not treat the tail of “this” as the he/him possessive", () => {
+ // Jordan is the no-current-plan fixture, so the converter borrows Rowan's
+ // approved version the same way the Evie naming-context case does.
+ const jordan = createLineConverter(currentVersionFor(ROWAN), patientBy(JORDAN), syntheticPatientResources);
+ expect(jordan("The corridor can feel loud at this time of night.")).toEqual({
+ converted: "The corridor can feel loud at this time of night.",
+ });
+ expect(jordan("A warm blanket helps his back.")).toEqual({ converted: "A warm blanket helps your back." });
+ });
+
+ it("gaps a sentence whose verb it cannot put into the second person", () => {
+ // `settles` is in the agreement table, so this converts.
+ expect(convert("Rowan settles once the room is quiet.")).toEqual({
+ converted: "You settle once the room is quiet.",
+ });
+ // `dawdles` is not, and guessing by dropping the trailing s is wrong for
+ // every plural noun, so the sentence is refused instead.
+ expect(convert("Rowan dawdles on the way in.")).toEqual({ gapReasonKey: "personAsSubject" });
+ });
+
+ it("gaps a heading whose Management Plan field holds nothing", () => {
+ const empty = versionWithContent({ practicalNeeds: [], whatThePersonWants: [] });
+ const draft = buildPatientPlanDraft(empty, patientBy(ROWAN), syntheticPatientResources);
+ for (const key of ["thingsThatMightHelp", "whatMattersToYou"] as const) {
+ const section = draft.sections.find((candidate) => candidate.key === key);
+ expect(section?.gap).toBe(true);
+ expect(section?.gapReason).toBe(PATIENT_PLAN_GAP_REASON.nothingRecorded);
+ }
+ });
+
+ it("never carries part of a section: one refused line empties the whole heading", () => {
+ const mixed = versionWithContent({
+ whatThePersonWants: ["A quiet room.", "To be seen without your distress being the first thing anybody says."],
+ });
+ const section = buildPatientPlanDraft(mixed, patientBy(ROWAN), syntheticPatientResources).sections.find(
+ (candidate) => candidate.key === "whatMattersToYou",
+ );
+ expect(section?.gap).toBe(true);
+ expect(section?.body).toEqual([]);
+ });
+});
+
+describe("Patient Plan dictionary", () => {
+ const convert = converterFor(ROWAN);
+
+ it("replaces clinical vocabulary with the curated everyday wording", () => {
+ expect(convert("The CMHT will ring you the next working day.")).toEqual({
+ converted: "The mental health team will ring you the next working day.",
+ });
+ expect(convert("Your GP knows you well.")).toEqual({ converted: "Your family doctor knows you well." });
+ });
+
+ it("shifts the person's own name and pronouns into the second person, with the verb to match", () => {
+ expect(convert("Rowan wants a quiet room.")).toEqual({ converted: "You want a quiet room." });
+ expect(convert("A blanket helps with Rowan's sleep.")).toEqual({
+ converted: "A blanket helps with your sleep.",
+ });
+ });
+
+ /**
+ * A word ending in `s` after a substituted subject is either a verb needing
+ * agreement or a noun needing none, and the difference cannot be guessed. This
+ * proves every such word in the vocabulary is classified, so a word added
+ * later cannot quietly become an unchecked verb and print "you settles".
+ */
+ it("classifies every s-ending everyday word as a verb or as safe after “you”", () => {
+ const safe = new Set(AGREEMENT_SAFE_S_WORDS);
+ const unclassified = [...EVERYDAY_WORDS].filter(
+ (word) => word.endsWith("s") && THIRD_PERSON_VERBS[word] === undefined && !safe.has(word),
+ );
+ expect(unclassified, `unclassified s-ending words: ${unclassified.join(", ")}`).toEqual([]);
+ expect(EVERYDAY_WORDS.size).toBeGreaterThan(400);
+ });
+
+ /**
+ * The inflection rule widens the vocabulary — "waits" and "waiting" from
+ * "wait" — and it must never widen it onto a clinical term. Fails closed: a
+ * term whose base form is everyday would be admitted here and caught nowhere
+ * else.
+ */
+ it("never admits a clinical term, or its plural, through the inflection rule", () => {
+ for (const term of UNCONVERTIBLE_CLINICAL_TERMS) {
+ expect(isEverydayWord(term), `${term} is treated as an everyday word`).toBe(false);
+ expect(isEverydayWord(`${term}s`), `${term}s is treated as an everyday word`).toBe(false);
+ }
+ });
+
+ it("holds no dictionary entry that is also refused as unconvertible", () => {
+ const unconvertible = new Set(UNCONVERTIBLE_CLINICAL_TERMS);
+ for (const term of Object.keys(PLAIN_LANGUAGE_TERMS)) {
+ expect(unconvertible.has(term), `${term} is both convertible and unconvertible`).toBe(false);
+ }
+ });
+});
+
+/**
+ * The checks none of the other gates can make.
+ *
+ * Every string below is spelled out here rather than read from the module that
+ * produces it. A page can be structurally perfect, pass 400 tests, and still be
+ * cruel to read — that is the one class of harm every automated check on this
+ * project is blind to, and it is the reason these are literal.
+ */
+describe("Patient Plan reads as something a person can be handed", () => {
+ const CLINICAL_WORDS = [
+ "triage",
+ "disposition",
+ "presentation",
+ "mental-state",
+ "assessment",
+ "distress",
+ "compliance",
+ "comorbid",
+ "prognosis",
+ "acuity",
+ "severity",
+ "aetiology",
+ "psychosocial",
+ "sequelae",
+ "prn",
+ "bd",
+ "nocte",
+ ];
+
+ const BLAMING_PHRASES = [
+ "failed to",
+ "refused to",
+ "declined to",
+ "did not engage",
+ "non-compliant",
+ "noncompliant",
+ "poorly compliant",
+ "attention-seeking",
+ "attention seeking",
+ "manipulative",
+ "drug-seeking",
+ "frequent flyer",
+ "frequent presenter",
+ "frequent attender",
+ "difficult patient",
+ "problem patient",
+ "known to services",
+ "poor insight",
+ "lacks insight",
+ "unable to cope",
+ "does not cooperate",
+ "uncooperative",
+ "should have",
+ "you must",
+ "you failed",
+ ];
+
+ const HOPELESS_PHRASES = [
+ "nothing can be done",
+ "nothing more can be done",
+ "no further options",
+ "there is no point",
+ "will not improve",
+ "chronic and unchanging",
+ "beyond help",
+ ];
+
+ /** The exact string Task 8 printed on a person's own safety plan, and every
+ * shape of it. A blank stated as a blank, on a page addressed to somebody. */
+ const BLANK_STATEMENTS = ["not recorded", "not stated", "no data", "none recorded", "n/a", "unknown", "nil"];
+
+ const STAFF_IMPERATIVES = [
+ "say so",
+ "ask rowan",
+ "ask mira",
+ "ask the patient",
+ "read this plan",
+ "read the triage",
+ "introduce yourself",
+ "offer again",
+ "keep the first",
+ "check that",
+ "sit down to talk",
+ "record the reason",
+ "confirm only",
+ ];
+
+ /**
+ * Everything a reader would actually see, for one patient — except the
+ * verified crisis lines.
+ *
+ * Those are excluded from the clinical-vocabulary check on purpose, and only
+ * from that one. Their wording is quoted from the official public page each
+ * number was checked against: MHERL's own statement is that it "is a telephone
+ * triage and support line. It is not an emergency service". Paraphrasing a
+ * real service's own safety caveat to satisfy a house style would be a worse
+ * error than the one this test exists to catch, so the word stays and the
+ * exception is written down. The blaming and hopelessness checks below still
+ * cover them.
+ */
+ function readableStrings(patientId: SyntheticId, options: { includeVerifiedContacts: boolean }): string[] {
+ const patient = patientBy(patientId);
+ const version = getCurrentManagementPlanVersion(
+ createInitialPrototypeState().managementPlanVersions,
+ patient.managementPlanId,
+ );
+ if (version === null) return [];
+ const draft = buildPatientPlanDraft(version, patient, syntheticPatientResources);
+ const resources = options.includeVerifiedContacts
+ ? draft.resources
+ : draft.resources.filter((resource) => !resource.isRealContact);
+ return [
+ ...draft.sections.flatMap((section) => [section.heading, ...section.body]),
+ ...resources.flatMap((resource) => [resource.name, resource.detail]),
+ ...Object.values(PATIENT_PLAN_SECTION_LEAD_IN),
+ ];
+ }
+
+ /**
+ * Every line the conversion actually produces, across every fixture.
+ *
+ * The checks below have to run over converted prose, and a section gaps whole
+ * — so on this corpus every *section* is a gap and every `section.body` is
+ * empty. Reading the checks off the section bodies would make them vacuous:
+ * they would pass over nothing, for ever, and pass loudest at the moment the
+ * conversion broke completely. So they run over the line converter directly,
+ * behind the non-vacuity guard below.
+ */
+ function everyConvertedLine(): string[] {
+ const state = createInitialPrototypeState();
+ return syntheticPatients.flatMap((patient) => {
+ const version = getCurrentManagementPlanVersion(state.managementPlanVersions, patient.managementPlanId);
+ if (version === null) return [];
+ const convert = createLineConverter(version, patient, syntheticPatientResources);
+ return Object.values(PATIENT_PLAN_SECTION_SOURCES)
+ .flat()
+ .flatMap((field) => {
+ const value = version.content[field];
+ return typeof value === "string" ? [value] : [...value];
+ })
+ .flatMap((line) => {
+ const outcome = convert(line);
+ return "converted" in outcome ? [outcome.converted] : [];
+ });
+ });
+ }
+
+ /**
+ * Fails closed. If a later change made the conversion refuse everything, every
+ * content check below would pass over an empty list and report the page as
+ * flawless. This is the guard that stops that being counted as coverage.
+ */
+ it("actually converts something, so the content checks below are not vacuous", () => {
+ expect(everyConvertedLine().length).toBeGreaterThanOrEqual(5);
+ });
+
+ const PATIENTS_WITH_A_CURRENT_PLAN = [ROWAN, MIRA];
+
+ it.each(PATIENTS_WITH_A_CURRENT_PLAN)("says nothing clinical to %s in its own words", (patientId) => {
+ const lines = readableStrings(patientId, { includeVerifiedContacts: false });
+ expect(lines.length).toBeGreaterThan(8);
+ for (const line of lines) {
+ const lowered = line.toLowerCase();
+ for (const word of CLINICAL_WORDS) {
+ expect(lowered.includes(word), `“${line}” uses the clinical word “${word}”`).toBe(false);
+ }
+ }
+ });
+
+ it.each(PATIENTS_WITH_A_CURRENT_PLAN)("says nothing blaming or hopeless to %s, anywhere", (patientId) => {
+ const lines = readableStrings(patientId, { includeVerifiedContacts: true });
+ expect(lines.length).toBeGreaterThan(8);
+ for (const line of lines) {
+ const lowered = line.toLowerCase();
+ for (const phrase of [...BLAMING_PHRASES, ...HOPELESS_PHRASES]) {
+ expect(lowered.includes(phrase), `“${line}” reads as a judgement: “${phrase}”`).toBe(false);
+ }
+ }
+ });
+
+ /**
+ * The Task 8 defect, generalised. A printed sheet reading "My reasons for
+ * living — Not recorded" broke no rule and failed no gate. Here a section with
+ * nothing in it is a gap, a gap is never approved, and an unapproved copy is
+ * never printed — so the words themselves must never appear either.
+ */
+ it.each(PATIENTS_WITH_A_CURRENT_PLAN)("never states a blank as a fact about %s", (patientId) => {
+ for (const line of readableStrings(patientId, { includeVerifiedContacts: true })) {
+ const lowered = line.toLowerCase();
+ for (const blank of BLANK_STATEMENTS) {
+ expect(lowered.includes(blank), `“${line}” states a blank as a fact about this person`).toBe(false);
+ }
+ }
+ });
+
+ it("hands nobody an instruction that was written for staff", () => {
+ const lines = everyConvertedLine();
+ expect(lines.length).toBeGreaterThan(0);
+ for (const line of lines) {
+ const lowered = line.toLowerCase();
+ for (const imperative of STAFF_IMPERATIVES) {
+ expect(lowered.includes(imperative), `“${line}” is an instruction written for staff`).toBe(false);
+ }
+ for (const opener of ["say ", "ask ", "tell ", "keep ", "check ", "offer ", "read ", "sit ", "speak ", "use "]) {
+ expect(lowered.startsWith(opener), `“${line}” is an order addressed to the reader`).toBe(false);
+ }
+ }
+ });
+
+ /**
+ * Nothing converted still refers to the person in the third person, and
+ * nothing is left as broken English by a half-applied substitution. "To be
+ * called you" and "hard for your to follow" are the two failures at either end
+ * of the same rule, and both were printed on a real fixture's copy before
+ * these existed.
+ */
+ it("addresses the reader as “you”, never as themselves, and never in broken English", () => {
+ const state = createInitialPrototypeState();
+ let checked = 0;
+ for (const patient of syntheticPatients) {
+ const version = getCurrentManagementPlanVersion(state.managementPlanVersions, patient.managementPlanId);
+ if (version === null) continue;
+ const convert = createLineConverter(version, patient, syntheticPatientResources);
+ for (const field of Object.values(PATIENT_PLAN_SECTION_SOURCES).flat()) {
+ const value = version.content[field];
+ for (const line of typeof value === "string" ? [value] : value) {
+ const outcome = convert(line);
+ if (!("converted" in outcome)) continue;
+ checked += 1;
+ const lowered = outcome.converted.toLowerCase();
+ expect(lowered.includes(patient.preferredName.toLowerCase()), `“${outcome.converted}” still names them`).toBe(
+ false,
+ );
+ expect(lowered.includes(patient.fullName.toLowerCase()), `“${outcome.converted}” still names them`).toBe(
+ false,
+ );
+ for (const broken of [
+ "called you,",
+ "addressed as you",
+ "referred to as you",
+ "for your to",
+ "with your to",
+ "you settles",
+ "you is ",
+ "you has ",
+ "you was ",
+ "you does ",
+ "you their",
+ "you her ",
+ ]) {
+ expect(lowered.includes(broken), `“${outcome.converted}” is broken English: “${broken}”`).toBe(false);
+ }
+
+ /*
+ * No third-person pronoun for this person survives on their own copy.
+ * The object pronoun is deliberately not checked: "so you can read
+ * them again later" is about the notes, not about the reader.
+ */
+ const [subjectPronoun = ""] = patient.pronouns.split("/");
+ const possessivePronoun = subjectPronoun === "they" ? "their" : subjectPronoun === "she" ? "her" : "his";
+ for (const pronoun of [subjectPronoun, possessivePronoun]) {
+ expect(
+ new RegExp(`\\b${pronoun}\\b`, "i").test(outcome.converted),
+ `“${outcome.converted}” still talks about this person in the third person`,
+ ).toBe(false);
+ }
+ }
+ }
+ }
+ expect(checked, "nothing converted, so this check proved nothing").toBeGreaterThanOrEqual(5);
+ });
+
+ it("writes every heading and lead-in in the person's own voice", () => {
+ // Literal, from the specification, not read back from the module.
+ expect(PATIENT_PLAN_SECTION_HEADING.whyWeWroteThis).toBe("Why we wrote this together");
+ expect(PATIENT_PLAN_SECTION_HEADING.whatMattersToYou).toBe("What matters to you");
+ expect(PATIENT_PLAN_SECTION_HEADING.whatHelpsYou).toBe("What helps you");
+ expect(PATIENT_PLAN_SECTION_HEADING.whatMakesThingsHarder).toBe("What makes things harder");
+ expect(PATIENT_PLAN_SECTION_HEADING.whatWeAgreedWillHappen).toBe(
+ "What we agreed will happen when you come to the emergency department",
+ );
+ expect(PATIENT_PLAN_SECTION_HEADING.ifSomethingNewIsHappening).toBe("If something new is happening");
+ expect(PATIENT_PLAN_SECTION_HEADING.whoIsInvolved).toBe("Who's involved in your care");
+ expect(PATIENT_PLAN_SECTION_HEADING.thingsThatMightHelp).toBe("Things that might help");
+ });
+});
+
+describe("Patient Plan resources", () => {
+ it("gives every patient a resource list, with housing and money on the ones that need them", () => {
+ for (const patient of syntheticPatients) {
+ expect(getPatientResources(syntheticPatientResources, patient.id).length).toBeGreaterThan(0);
+ }
+ const categories = new Set(syntheticPatientResources.map((resource) => resource.category));
+ for (const category of PATIENT_RESOURCE_CATEGORY_ORDER) {
+ expect(categories.has(category), `no synthetic resource covers ${category}`).toBe(true);
+ }
+ // Housing and money are frequently the actual reason somebody keeps
+ // arriving, so a list that cannot mention them is a list about the wrong
+ // problem.
+ for (const category of ["housing", "financial"] as const) {
+ const patients = new Set(
+ syntheticPatientResources.filter((resource) => resource.category === category).map((r) => r.patientId),
+ );
+ expect(patients.size, `no patient has a ${category} resource`).toBeGreaterThan(0);
+ }
+ });
+
+ it("marks only the verified public crisis lines as real, and takes them from the one verified source", () => {
+ const real = syntheticPatientResources.filter((resource) => resource.isRealContact);
+ const invented = syntheticPatientResources.filter((resource) => !resource.isRealContact);
+
+ expect(real.length).toBe(publicCrisisContacts.length * syntheticPatients.length);
+ for (const resource of real) {
+ expect(resource.category).toBe("crisis_contact");
+ const source = publicCrisisContacts.find((contact) => contact.telephoneDisplay === resource.contact);
+ expect(source, `${resource.name} is not one of the verified public numbers`).toBeDefined();
+ expect(resource.sourceUrl).toBe(source?.sourceUrl);
+ if (source?.caveat != null) expect(resource.detail).toContain(source.caveat);
+ }
+
+ // Literal, so a fictional service can never be given a real number and a
+ // real number can never be quietly changed here.
+ const realNumbers = real.map((resource) => resource.contact);
+ expect(new Set(realNumbers)).toEqual(new Set(["000", "1300 555 788", "1800 676 822", "1800 552 002"]));
+ for (const resource of invented) {
+ expect(realNumbers.includes(resource.contact), `${resource.name} carries a real telephone number`).toBe(false);
+ expect(resource.category).not.toBe("crisis_contact");
+ expect(resource.id.startsWith("SYN-")).toBe(true);
+ }
+ });
+
+ /**
+ * A draft carries this person's resources and nobody else's. The whole
+ * catalogue is passed in, so the narrowing is the transformation's job — and
+ * a copy handed to somebody listing another person's youth housing service or
+ * carer line is both wrong and quietly revealing about who else is on the
+ * list.
+ */
+ it("carries only the resources chosen for the person the copy is for", () => {
+ for (const patientId of [ROWAN, MIRA] as const) {
+ const draft = draftFor(patientId);
+ expect(draft.resources.length).toBeGreaterThan(0);
+ expect(draft.resources.length).toBeLessThan(syntheticPatientResources.length);
+ for (const resource of draft.resources) {
+ expect(resource.patientId, `${resource.name} belongs to somebody else`).toBe(patientId);
+ }
+ expect(draft.resources).toEqual(getPatientResources(syntheticPatientResources, patientId));
+ }
+ });
+
+ it("groups resources in the approved order and drops a category with nothing in it", () => {
+ const groups = groupPatientResources(getPatientResources(syntheticPatientResources, ROWAN));
+ const order = groups.map((group) => group.category);
+ expect(order).toEqual([...PATIENT_RESOURCE_CATEGORY_ORDER].filter((category) => order.includes(category)));
+ // The crisis lines are last, where a reader flipping to the end will find
+ // them when everything above has not worked.
+ expect(order.at(-1)).toBe("crisis_contact");
+ for (const group of groups) expect(group.resources.length).toBeGreaterThan(0);
+ });
+});
+
+describe("Patient Plan lifecycle", () => {
+ it("derives a draft from the Current Management Plan Version and refuses when there is none", () => {
+ const { draft } = stateWithDraft(ROWAN);
+ expect(draft.derivedFromManagementVersionId).toBe(currentVersionFor(ROWAN).id);
+ expect(draft.state).toBe("draft");
+ expect(draft.version).toBe(1);
+
+ // Jordan has a Management Plan record with no versions at all.
+ const refused = run(asUser(createInitialPrototypeState(), "liaison_clinician"), {
+ type: "create-patient-plan-draft",
+ patientId: JORDAN,
+ });
+ expect(refused.patientPlanVersions).toEqual([]);
+ expect(refused.lastOutcome?.kind).toBe("error");
+ expect(refused.lastOutcome?.message).toMatch(/no Current Plan/i);
+ });
+
+ it("refuses to open a second draft beside an open one", () => {
+ const { state } = stateWithDraft(ROWAN);
+ const again = prototypeReducer(state, { type: "create-patient-plan-draft", patientId: ROWAN });
+ expect(again.patientPlanVersions).toHaveLength(1);
+ expect(again.lastOutcome?.kind).toBe("error");
+ });
+
+ it("replaces sections and resources whole when a draft is saved", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ const sections = filled(draft.sections);
+ const saved = prototypeReducer(state, {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections, resources: [] },
+ });
+ const after = saved.patientPlanVersions.find((version) => version.id === draft.id);
+ expect(after?.sections).toEqual(sections);
+ expect(after?.resources).toEqual([]);
+ expect(unfilledGapSections(after?.sections ?? [])).toEqual([]);
+ });
+
+ /**
+ * The gap block, with its positive control beside it. The first half proves
+ * approval is refused while a gap remains; the second proves the same action
+ * succeeds once it is filled — without which the refusal could be caused by
+ * anything at all.
+ */
+ it("refuses approval while any gap is unfilled, and allows it once none is", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ expect(unfilledGapSections(draft.sections).length).toBeGreaterThan(0);
+
+ const blocked = prototypeReducer(state, { type: "approve-patient-plan-version", versionId: draft.id });
+ expect(blocked.lastOutcome?.kind).toBe("error");
+ expect(blocked.lastOutcome?.message).toMatch(/cannot be approved while/i);
+ expect(blocked.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("draft");
+ expect(blocked.patientPlans[0]?.currentVersionId ?? null).toBeNull();
+
+ const approved = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ );
+ expect(approved.lastOutcome?.kind).toBe("success");
+ expect(approved.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("current");
+ });
+
+ /**
+ * Any clinical role, and no senior. Requiring a senior clinician would mean a
+ * person waits days for their own copy of their own plan, which defeats the
+ * point of giving them one — so the check is that all four clinical roles
+ * succeed, not merely that one does.
+ */
+ it("gives every clinical role the capability, and withholds it from the non-clinical one", () => {
+ for (const role of ["ed_clinician", "liaison_clinician", "cmht_clinician", "senior_clinician"] as const) {
+ expect(canPerformAction(role, "approve_patient_plan"), `${role} cannot approve a patient copy`).toBe(true);
+ }
+ expect(canPerformAction("plan_coordinator", "approve_patient_plan")).toBe(false);
+ });
+
+ // The fixtures carry a signed-in user for three of the four clinical roles;
+ // the capability check above covers the fourth, which has no synthetic user.
+ it.each(["ed_clinician", "liaison_clinician", "senior_clinician"] as const)(
+ "lets a %s approve the patient copy",
+ (role) => {
+ const { state, draft } = stateWithDraft(ROWAN, role);
+ const approved = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ );
+ const current = approved.patientPlanVersions.find((version) => version.id === draft.id);
+ expect(current?.state).toBe("current");
+ expect(current?.approvedBy).toBe(approved.activeUserId);
+ expect(current?.approvedAt).not.toBeNull();
+ },
+ );
+
+ it("refuses the non-clinical plan coordinator", () => {
+ expect(canPerformAction("plan_coordinator", "approve_patient_plan")).toBe(false);
+ const { state, draft } = stateWithDraft(ROWAN);
+ const asCoordinator = asUser(state, "plan_coordinator");
+ const refused = prototypeReducer(asCoordinator, { type: "approve-patient-plan-version", versionId: draft.id });
+ expect(refused.lastOutcome?.kind).toBe("blocked");
+ expect(refused.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("draft");
+ });
+
+ /**
+ * Approval must not consult senior-approval state at any point. Proved by
+ * approving a patient copy as an emergency department clinician while the
+ * Management Plan itself has a version sitting unapproved: the patient copy
+ * lands anyway.
+ */
+ it("never consults senior-approval state", () => {
+ const { state, draft } = stateWithDraft(MIRA, "ed_clinician");
+ expect(state.managementPlanVersions.some((version) => version.state === "awaiting_approval")).toBe(true);
+ const approved = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ );
+ expect(approved.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("current");
+ // Untouched: approving a patient copy is not approving a Management Plan.
+ expect(approved.managementPlanVersions.filter((version) => version.state === "awaiting_approval")).toHaveLength(1);
+ });
+
+ it("supersedes the prior current copy rather than deleting it", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ const first = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ { type: "create-patient-plan-draft", patientId: ROWAN },
+ );
+ const second = getOpenPatientPlanDraft(first.patientPlanVersions, first.patientPlans[0]?.id ?? "SYN-NONE");
+ if (second === null) throw new Error("no second draft");
+ const after = run(
+ first,
+ {
+ type: "save-patient-plan-draft",
+ versionId: second.id,
+ input: { sections: filled(second.sections), resources: [...second.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: second.id },
+ );
+
+ expect(after.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("superseded");
+ expect(after.patientPlanVersions.find((version) => version.id === second.id)?.state).toBe("current");
+ expect(after.patientPlans[0]?.currentVersionId).toBe(second.id);
+ // The superseded copy stays readable: it is what the person was actually
+ // given, and the record of that must not be deleted.
+ expect(after.patientPlanVersions.find((version) => version.id === draft.id)?.sections.length).toBe(8);
+ });
+
+ it("records a print intent without claiming anything reached a printer or a person", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ const approved = run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ { type: "record-patient-plan-print-intent", patientId: ROWAN },
+ );
+ const event = approved.auditEvents.at(-1);
+ expect(event?.type).toBe("patient_plan_print_intent_opened");
+ expect(event?.evidence).toMatch(/is not evidence that anything reached a printer/i);
+ // Evidence only: no clinical record changed.
+ expect(approved.patientPlanVersions.find((version) => version.id === draft.id)?.state).toBe("current");
+ });
+
+ it("refuses a print intent when there is no approved copy, and prints nothing", () => {
+ const { state } = stateWithDraft(ROWAN);
+ const refused = prototypeReducer(state, { type: "record-patient-plan-print-intent", patientId: ROWAN });
+ expect(refused.lastOutcome?.kind).toBe("error");
+ expect(refused.auditEvents.filter((event) => event.type === "patient_plan_print_intent_opened")).toEqual([]);
+ });
+
+ /** Printing is the one action most wanted when systems are down. */
+ it("still records a print intent offline, while refusing to write a copy", () => {
+ const { state, draft } = stateWithDraft(ROWAN);
+ const approved = run(state, {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ });
+ const live = prototypeReducer(approved, { type: "approve-patient-plan-version", versionId: draft.id });
+ const offline: CarePlanPrototypeState = { ...live, connectivity: { online: false } };
+
+ const printed = prototypeReducer(offline, { type: "record-patient-plan-print-intent", patientId: ROWAN });
+ expect(printed.lastOutcome?.kind).toBe("info");
+
+ const written = prototypeReducer(offline, { type: "create-patient-plan-draft", patientId: ROWAN });
+ expect(written.lastOutcome?.kind).toBe("blocked");
+ });
+});
+
+describe("Patient Plan staleness", () => {
+ /** An approved patient copy for Mira, whose Management Plan has a version
+ * awaiting approval — so the plan can be moved on underneath it. */
+ function withApprovedCopy() {
+ const { state, draft } = stateWithDraft(MIRA, "senior_clinician");
+ return run(
+ state,
+ {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ },
+ { type: "approve-patient-plan-version", versionId: draft.id },
+ );
+ }
+
+ function awaitingVersionId(state: CarePlanPrototypeState): SyntheticId {
+ const version = state.managementPlanVersions.find((candidate) => candidate.state === "awaiting_approval");
+ if (version === undefined) throw new Error("no version awaiting approval");
+ return version.id;
+ }
+
+ it("derives staleness from the two identifiers and never stores it", () => {
+ const before = withApprovedCopy();
+ const plan = before.patientPlans[0];
+ const copy = getCurrentPatientPlanVersion(before.patientPlanVersions, plan?.id ?? "SYN-NONE");
+ const management = before.managementPlans.find((candidate) => candidate.patientId === MIRA);
+ expect(isPatientPlanVersionStale(copy, management?.currentVersionId ?? null)).toBe(false);
+
+ const after = prototypeReducer(before, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(before),
+ });
+ const stillTheSameCopy = getCurrentPatientPlanVersion(after.patientPlanVersions, plan?.id ?? "SYN-NONE");
+ const movedOn = after.managementPlans.find((candidate) => candidate.patientId === MIRA);
+ expect(isPatientPlanVersionStale(stillTheSameCopy, movedOn?.currentVersionId ?? null)).toBe(true);
+
+ // Nothing about the copy itself changed: staleness is not a stored field.
+ expect(stillTheSameCopy).toEqual(copy);
+ expect(JSON.stringify(stillTheSameCopy)).not.toContain("stale");
+ });
+
+ it("treats a withdrawn source with nothing to replace it as stale, not as having nothing to compare", () => {
+ const before = withApprovedCopy();
+ const plan = before.patientPlans[0];
+ const copy = getCurrentPatientPlanVersion(before.patientPlanVersions, plan?.id ?? "SYN-NONE");
+
+ const withdrawn = asUser(before, "senior_clinician");
+ const after = prototypeReducer(withdrawn, {
+ type: "withdraw-current-management-version",
+ patientId: MIRA,
+ reason: "The plan no longer fits and a new one will be written with Mira.",
+ });
+ const management = after.managementPlans.find((candidate) => candidate.patientId === MIRA);
+
+ // Withdrawal, not a newer version: the source has no Current version at
+ // all, which is a stronger reason to update the copy than a mere version
+ // mismatch, not an absence of anything to compare against.
+ expect(management?.currentVersionId).toBeNull();
+ expect(isPatientPlanVersionStale(copy, management?.currentVersionId ?? null)).toBe(true);
+ });
+
+ it("raises the same stale-copy Review Trigger when the source is withdrawn instead of superseded", () => {
+ const before = withApprovedCopy();
+ const openBefore = before.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale");
+ expect(openBefore).toEqual([]);
+
+ const after = prototypeReducer(asUser(before, "senior_clinician"), {
+ type: "withdraw-current-management-version",
+ patientId: MIRA,
+ reason: "The plan no longer fits and a new one will be written with Mira.",
+ });
+
+ const raised = after.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale");
+ expect(raised).toHaveLength(1);
+ expect(raised[0]?.status).toBe("open");
+ expect(raised[0]?.patientId).toBe(MIRA);
+ // Same literal the superseded-version path uses: the instinct on reading
+ // "stale" is to take the copy away, and withdrawal is not an exception.
+ expect(raised[0]?.reason).toMatch(/stays readable and unchanged/i);
+
+ // A further withdrawal-adjacent action does not stack a second item.
+ const again = prototypeReducer(after, {
+ type: "withdraw-current-management-version",
+ patientId: MIRA,
+ reason: "Repeated dispatch should not raise a second trigger.",
+ });
+ // Refused outright: there is no Current Plan left to withdraw a second time.
+ expect(again.lastOutcome?.kind).toBe("error");
+ expect(again.lastOutcome?.message).toMatch(/no Current Plan to withdraw/i);
+ expect(again.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale")).toHaveLength(1);
+ });
+
+ it("refuses to approve a Patient Plan draft once the source it was written from is no longer the Current Plan", () => {
+ const { state, draft } = stateWithDraft(MIRA, "senior_clinician");
+ const saved = run(state, {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: { sections: filled(draft.sections), resources: [...draft.resources] },
+ });
+
+ // Move the Management Plan on underneath the still-open draft, before it
+ // is ever approved. No Patient Plan was Current at that moment, so the
+ // ordinary superseded-version stale trigger never fires for this draft.
+ const movedOn = prototypeReducer(saved, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(saved),
+ });
+ expect(movedOn.patientPlans.find(({ patientId }) => patientId === MIRA)?.currentVersionId).toBeNull();
+
+ const attempt = prototypeReducer(movedOn, { type: "approve-patient-plan-version", versionId: draft.id });
+
+ expect(attempt.lastOutcome?.kind).toBe("error");
+ expect(attempt.lastOutcome?.message).toMatch(/no longer Current/i);
+ // Nothing was promoted: the draft stays a draft and no Patient Plan
+ // became Current from an already-obsolete source.
+ expect(attempt.patientPlanVersions.find(({ id }) => id === draft.id)?.state).toBe("draft");
+ expect(attempt.patientPlans.find(({ patientId }) => patientId === MIRA)?.currentVersionId).toBeNull();
+ });
+
+ it("never regenerates, hides, or withdraws a copy the person may be holding", () => {
+ const before = withApprovedCopy();
+ const copyBefore = before.patientPlanVersions.map((version) => ({ ...version }));
+ const after = prototypeReducer(before, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(before),
+ });
+
+ expect(after.patientPlanVersions).toEqual(copyBefore);
+ expect(after.patientPlanVersions.every((version) => version.state !== "superseded")).toBe(true);
+ expect(after.patientPlans[0]?.currentVersionId).toBe(copyBefore[0]?.id);
+ });
+
+ it("raises exactly one Review Trigger, however many versions are approved after it", () => {
+ const before = withApprovedCopy();
+ const openBefore = before.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale");
+ expect(openBefore).toEqual([]);
+
+ const after = prototypeReducer(before, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(before),
+ });
+ const raised = after.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale");
+ expect(raised).toHaveLength(1);
+ expect(raised[0]?.status).toBe("open");
+ expect(raised[0]?.patientId).toBe(MIRA);
+ // Literal: the reason a human reads in the queue must say the copy stays
+ // readable, because the instinct on reading "stale" is to take it away.
+ expect(raised[0]?.reason).toMatch(/stays readable and unchanged/i);
+
+ // A further approval on the same plan does not stack a second item.
+ const draftedAgain = run(
+ after,
+ { type: "create-management-draft", patientId: MIRA },
+ { type: "select-patient", patientId: MIRA },
+ );
+ const nextDraft = draftedAgain.managementPlanVersions.filter((version) => version.state === "draft").at(-1);
+ if (nextDraft === undefined) throw new Error("no further draft");
+ const twice = run(
+ draftedAgain,
+ {
+ type: "save-management-draft",
+ versionId: nextDraft.id,
+ input: {
+ ownerId: nextDraft.ownerId,
+ reviewDueAt: "2027-01-01T12:00:00+08:00",
+ revisionReason: "A further revision, to prove the trigger does not stack.",
+ participationState: "co_produced",
+ consentedSupportPeople: [],
+ content: nextDraft.content,
+ },
+ },
+ { type: "submit-management-draft", versionId: nextDraft.id },
+ { type: "approve-management-version", versionId: nextDraft.id },
+ );
+ expect(twice.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale")).toHaveLength(1);
+ });
+
+ it("raises nothing when the person holds no patient copy at all", () => {
+ const state = asUser(createInitialPrototypeState(), "senior_clinician");
+ const after = prototypeReducer(state, {
+ type: "approve-management-version",
+ versionId: awaitingVersionId(state),
+ });
+ expect(after.reviewTriggers.filter((trigger) => trigger.source === "patient_plan_stale")).toEqual([]);
+ });
+});
diff --git a/tests/care-plan-prototype-state.test.ts b/tests/care-plan-prototype-state.test.ts
index fdc1f25b0..427c13082 100644
--- a/tests/care-plan-prototype-state.test.ts
+++ b/tests/care-plan-prototype-state.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
-import { assertSingleCurrentVersion, getOpenManagementDraft } from "@/components/care-plan/mockups/domain";
+import {
+ assertSingleCurrentVersion,
+ getCurrentPatientPlanVersion,
+ getOpenManagementDraft,
+ isPatientPlanVersionStale,
+} from "@/components/care-plan/mockups/domain";
import { PROTOTYPE_NOW } from "@/components/care-plan/mockups/fixtures";
import {
createInitialPrototypeState,
@@ -16,6 +21,7 @@ import type {
NewEdPresentationInput,
PrototypeScenario,
SafetyPlanDraftInput,
+ SyntheticId,
} from "@/components/care-plan/mockups/types";
const ED_CLINICIAN = "SYN-USER-ED-001";
@@ -110,6 +116,27 @@ function safetyDraftInput(overrides: Partial = {}): Safety
};
}
+/** Complete the conversion's deliberate gaps without changing the resource list.
+ * The state tests care about the document lifecycle, not its prose. */
+function completePatientPlanDraft(state: CarePlanPrototypeState, patientId: SyntheticId): CarePlanPrototypeState {
+ const withDraft = prototypeReducer(state, { type: "create-patient-plan-draft", patientId });
+ const draft = withDraft.patientPlanVersions.find((version) => version.state === "draft");
+ if (draft === undefined) throw new Error("the Patient Plan draft was not created");
+ return prototypeReducer(withDraft, {
+ type: "save-patient-plan-draft",
+ versionId: draft.id,
+ input: {
+ sections: draft.sections.map((section) => ({
+ ...section,
+ body: section.body.length > 0 ? section.body : ["Written with the person before approval."],
+ gap: false,
+ gapReason: null,
+ })),
+ resources: draft.resources,
+ },
+ });
+}
+
/** The lines this application may never write: it knows what it asked an
* external application to do, and nothing about what happened afterwards. */
const OVERCLAIMING_LANGUAGE = [
@@ -744,6 +771,74 @@ describe("Care Plan ED Presentation recording", () => {
expect(third.presentationAmendments).toHaveLength(first.presentationAmendments.length + 2);
expect(third.edPresentations.find(({ id }) => id === "SYN-PRESENTATION-001")?.planHelpfulness).toBe("helpful");
});
+
+ it("raises each applicable review trigger from corrected effective values, once", () => {
+ const withoutOpenTriggers = { ...createInitialPrototypeState(), reviewTriggers: [] };
+ const lessHelpful = prototypeReducer(withoutOpenTriggers, {
+ type: "amend-presentation",
+ presentationId: "SYN-PRESENTATION-001",
+ field: "planHelpfulness",
+ replacementValue: "not_helpful",
+ reason: "The original account was corrected after speaking with Rowan.",
+ });
+ const twiceCorrected = prototypeReducer(lessHelpful, {
+ type: "amend-presentation",
+ presentationId: "SYN-PRESENTATION-001",
+ field: "planHelpfulness",
+ replacementValue: "mixed",
+ reason: "A further correction recorded the more precise account.",
+ });
+ const admission = prototypeReducer(twiceCorrected, {
+ type: "amend-presentation",
+ presentationId: "SYN-PRESENTATION-002",
+ field: "disposition",
+ replacementValue: "mental_health_admission",
+ reason: "The final disposition was corrected from the contemporaneous record.",
+ });
+
+ expect(lessHelpful.reviewTriggers.filter((trigger) => trigger.source === "plan_use_feedback")).toHaveLength(1);
+ expect(twiceCorrected.reviewTriggers.filter((trigger) => trigger.source === "plan_use_feedback")).toHaveLength(1);
+ expect(admission.reviewTriggers.filter((trigger) => trigger.source === "presentation_outcome")).toHaveLength(1);
+ expect(admission.lastOutcome?.message).toMatch(/Review Trigger was raised/i);
+ });
+});
+
+describe("Care Plan Patient Plan source currency", () => {
+ it("marks a current patient copy stale and raises a review trigger when its source is withdrawn", () => {
+ let state = completePatientPlanDraft(createInitialPrototypeState(), ROWAN);
+ const draft = state.patientPlanVersions.find((version) => version.state === "draft")!;
+ state = prototypeReducer(state, { type: "approve-patient-plan-version", versionId: draft.id });
+ const currentCopy = getCurrentPatientPlanVersion(state.patientPlanVersions, state.patientPlans[0]!.id);
+ expect(currentCopy).not.toBeNull();
+
+ const withdrawn = prototypeReducer(withUser(state, SENIOR), {
+ type: "withdraw-current-management-version",
+ patientId: ROWAN,
+ reason: "The current plan was withdrawn while it is rewritten with Rowan.",
+ });
+
+ expect(isPatientPlanVersionStale(currentCopy, null)).toBe(true);
+ expect(withdrawn.reviewTriggers).toContainEqual(
+ expect.objectContaining({ source: "patient_plan_stale", sourceId: currentCopy?.id }),
+ );
+ expect(withdrawn.lastOutcome?.message).toMatch(/needs updating/i);
+ });
+
+ it("refuses approval of a completed draft after its Management Plan source is superseded", () => {
+ let state = completePatientPlanDraft(createInitialPrototypeState("overdue-plan"), MIRA);
+ const patientDraft = state.patientPlanVersions.find((version) => version.state === "draft")!;
+ state = prototypeReducer(withUser(state, SENIOR), {
+ type: "approve-management-version",
+ versionId: MIRA_AWAITING_VERSION,
+ });
+ const before = state.auditEvents;
+
+ const refused = prototypeReducer(state, { type: "approve-patient-plan-version", versionId: patientDraft.id });
+
+ expect(refused.patientPlanVersions.find((version) => version.id === patientDraft.id)?.state).toBe("draft");
+ expect(refused.auditEvents).toEqual(before);
+ expect(refused.lastOutcome?.message).toMatch(/no longer Current/i);
+ });
});
// --- Personal Safety Plan --------------------------------------------------------
@@ -1307,10 +1402,23 @@ describe("Care Plan scenarios, reset, and determinism", () => {
versionConflict: { active: false },
patientPlans: [],
patientPlanVersions: [],
- patientResources: [],
auditEvents: [],
lastOutcome: null,
});
+ /**
+ * The two Patient Plan collections start empty because a patient copy is
+ * produced when somebody makes one, and seeding an edition would put
+ * patient-facing wording on the record that nobody wrote.
+ *
+ * The resource catalogue is not an edition of anything. It is the list a
+ * clinician chooses from, so Task 9 seeds it: an empty one would mean every
+ * patient copy started with nothing to offer, and the housing and money
+ * entries are frequently the whole point of the sheet.
+ */
+ expect(createInitialPrototypeState().patientResources.length).toBeGreaterThan(0);
+ expect(createInitialPrototypeState().patientResources.every((resource) => resource.id.startsWith("SYN-"))).toBe(
+ true,
+ );
expect(createInitialPrototypeState("empty").selectedPatientId).toBeNull();
expect(createInitialPrototypeState("overdue-plan").selectedPatientId).toBe(MIRA);
expect(createInitialPrototypeState("withdrawn-plan").selectedPatientId).toBe(EVELYN);
diff --git a/tests/care-plan-route-files.test.ts b/tests/care-plan-route-files.test.ts
index eabe03727..addde6b0d 100644
--- a/tests/care-plan-route-files.test.ts
+++ b/tests/care-plan-route-files.test.ts
@@ -114,6 +114,21 @@ describe("Care Plan route registry", () => {
);
expect(carePlanRoute.safetyPlanEdit("SYN-PATIENT-001")).toBe(CARE_PLAN_ROUTES.safetyPlanEdit);
expect(carePlanRoute.safetyPlanPrint("SYN-PATIENT-001")).toBe(CARE_PLAN_ROUTES.safetyPlanPrint);
+ // The Patient Plan's own two addresses were in `CARE_PLAN_ROUTES` from
+ // Task 3 with no builder beside them, exactly as the Management Plan's and
+ // the Safety Plan's were. This is the copy handed to a person, so a
+ // hand-written print link is how one ends up addressed to somebody else.
+ expect(carePlanRoute.patientPlan("SYN-PATIENT-003")).toBe(
+ "/mockups/care-plan/patients/SYN-PATIENT-003/patient-plan",
+ );
+ expect(carePlanRoute.patientPlanEdit("SYN-PATIENT-003")).toBe(
+ "/mockups/care-plan/patients/SYN-PATIENT-003/patient-plan/edit",
+ );
+ expect(carePlanRoute.patientPlanPrint("SYN-PATIENT-003")).toBe(
+ "/mockups/care-plan/patients/SYN-PATIENT-003/patient-plan/print",
+ );
+ expect(carePlanRoute.patientPlanEdit("SYN-PATIENT-001")).toBe(CARE_PLAN_ROUTES.patientPlanEdit);
+ expect(carePlanRoute.patientPlanPrint("SYN-PATIENT-001")).toBe(CARE_PLAN_ROUTES.patientPlanPrint);
expect(carePlanRoute.presentations("SYN-PATIENT-003")).toBe(
"/mockups/care-plan/patients/SYN-PATIENT-003/presentations",
);
@@ -358,6 +373,71 @@ describe("Care Plan synthetic, memory-only boundary", () => {
}
});
+ /**
+ * The Patient Plan transformation, named explicitly.
+ *
+ * The scan above covers the whole namespace, which is why it would keep
+ * passing if this module were deleted, renamed, or quietly moved outside the
+ * directory — and this is the one module in the prototype where "no model, no
+ * provider, no network" is a product boundary rather than a house style. A
+ * later implementation may replace the function; it may not replace it with
+ * something that calls out.
+ */
+ const PATIENT_PLAN_MODULES = [
+ `${COMPONENT_ROOT}/patient-plan-transform.ts`,
+ `${COMPONENT_ROOT}/patient-plan-fixtures.ts`,
+ `${COMPONENT_ROOT}/patient-plan-pages.tsx`,
+ `${COMPONENT_ROOT}/patient-plan-form.tsx`,
+ `${COMPONENT_ROOT}/fixtures.ts`,
+ ] as const;
+
+ it("keeps the Patient Plan transformation offline, deterministic, and free of any model", () => {
+ for (const file of PATIENT_PLAN_MODULES) {
+ expect(existsSync(resolve(process.cwd(), file)), `${file} is missing`).toBe(true);
+ }
+
+ const transform = readFileSync(resolve(process.cwd(), `${COMPONENT_ROOT}/patient-plan-transform.ts`), "utf8");
+
+ // It imports nothing from outside its own namespace. A relative import can
+ // only reach a sibling; anything else would be an alias or a package.
+ for (const match of transform.matchAll(/from\s+["']([^"']+)["']/g)) {
+ const specifier = match[1] ?? "";
+ expect(
+ specifier.startsWith("./"),
+ `patient-plan-transform.ts imports ${specifier} from outside the namespace`,
+ ).toBe(true);
+ expect(specifier.includes(".."), `patient-plan-transform.ts reaches up out of the namespace: ${specifier}`).toBe(
+ false,
+ );
+ }
+
+ // No language model, AI service, or provider call is reachable from any part
+ // of the Patient Plan, including the transformation.
+ const forbidden: readonly { label: string; pattern: RegExp }[] = [
+ { label: "a language-model provider", pattern: /\b(?:openai|anthropic|claude|gemini|bedrock|azure_?openai)\b/i },
+ {
+ label: "a model call",
+ pattern: /\b(?:createCompletion|chat\.completions|generateText|streamText|invokeModel)\b/,
+ },
+ { label: "an inference or embedding call", pattern: /\b(?:inference|embedding|embeddings|tokeni[sz]er)\b/i },
+ { label: "a prompt", pattern: /\b(?:systemPrompt|userPrompt|promptTemplate)\b/ },
+ {
+ label: "a network call",
+ pattern: /\bfetch\s*\(|\baxios\b|\bhttps?:\/\/(?!example\.org|www\.triplezero|emhs\.health)/i,
+ },
+ { label: "a wall-clock read", pattern: /Date\.now\s*\(|new Date\s*\(\s*\)/ },
+ { label: "randomness", pattern: /Math\.random\s*\(|crypto\./ },
+ { label: "a timer", pattern: /\bsetTimeout\s*\(|\bsetInterval\s*\(/ },
+ { label: "storage", pattern: /\b(?:localStorage|sessionStorage|indexedDB)\b/ },
+ ];
+ for (const file of PATIENT_PLAN_MODULES) {
+ const source = readFileSync(resolve(process.cwd(), file), "utf8");
+ for (const { label, pattern } of forbidden) {
+ expect(pattern.test(source), `${file} contains ${label}`).toBe(false);
+ }
+ }
+ });
+
it("registers no route handler and no production route outside the mockup namespace", () => {
const handlers = readdirSync(resolve(process.cwd(), APP_ROOT), { recursive: true, withFileTypes: true }).filter(
(entry) => entry.isFile() && /^route\.(?:ts|tsx|js)$/.test(entry.name),
@@ -427,8 +507,12 @@ describe("Care Plan synthetic, memory-only boundary", () => {
// building, it carries a person's own words about what keeps them safe, and
// it lists real crisis telephone numbers — a rule that clipped any of it
// would leave every DOM assertion green.
+ // Extended in Task 9 to the Patient Plan. It is the second document that
+ // leaves the building, it is written to be read by the person it is about,
+ // and a rule that clipped one of its eight sections would leave every DOM
+ // assertion green — Vitest runs with `css: false`.
const protectedSelector =
- /\.(?:firstMinuteSection\w*|pinnedBoundary\w*|fullPlanSection\w*|currentPlanCard|printPaper|printRecordWarning|printIdentity|printCmht|safetyPaper\w*|safetySection\w*|safetySupport\w*|crisis\w*)\b/;
+ /\.(?:firstMinuteSection\w*|pinnedBoundary\w*|fullPlanSection\w*|currentPlanCard|printPaper|printRecordWarning|printIdentity|printCmht|safetyPaper\w*|safetySection\w*|safetySupport\w*|crisis\w*|patientPlanPaper\w*|patientPlanSection\w*|patientPlanLeadIn|patientPlanResource\w*)\b/;
/**
* Declarations are parsed rather than pattern-matched over the block text.
@@ -598,28 +682,31 @@ describe("Care Plan synthetic, memory-only boundary", () => {
return (match[2] === "rem" ? size * 16 : size) * 0.75;
}
- const paper = declarationsFor("safetyPaper", printRules);
- const printedSize = paper.get("font-size");
- expect(
- printedSize,
- ".safetyPaper declares no printed font size, so the patient copy is not sized for paper",
- ).toBeDefined();
- expect(
- pointsOf(printedSize ?? ""),
- `.safetyPaper prints at ${printedSize ?? "nothing"}, which is too small for the document a person reads in a crisis`,
- ).toBeGreaterThanOrEqual(11);
-
- for (const className of ["safetySection", "crisisEntry"]) {
+ // Both documents that leave the building: the person's own safety plan, and
+ // their own copy of the Management Plan.
+ for (const paperClass of ["safetyPaper", "patientPlanPaper"]) {
+ const printedSize = declarationsFor(paperClass, printRules).get("font-size");
+ expect(
+ printedSize,
+ `.${paperClass} declares no printed font size, so the patient copy is not sized for paper`,
+ ).toBeDefined();
+ expect(
+ pointsOf(printedSize ?? ""),
+ `.${paperClass} prints at ${printedSize ?? "nothing"}, which is too small for the document a person reads in a crisis`,
+ ).toBeGreaterThanOrEqual(11);
+ }
+
+ for (const className of ["safetySection", "crisisEntry", "patientPlanSection", "patientPlanResource"]) {
expect(
declarationsFor(className, printRules).get("break-inside"),
`.${className} may be split across a page break, so half of it can be lost on the previous sheet`,
).toBe("avoid");
}
- // Nothing on the patient copy is pinned to a viewport that does not exist on
- // a sheet of paper — a fixed dock prints once, over the content, or not at all.
+ // Nothing on either patient copy is pinned to a viewport that does not exist
+ // on a sheet of paper — a fixed dock prints once, over the content, or not at all.
for (const rule of rules) {
- if (!rule.selectors.some((selector) => /\.(?:safety|crisis)[A-Za-z]*\b/.test(selector))) continue;
+ if (!rule.selectors.some((selector) => /\.(?:safety|crisis|patientPlan)[A-Za-z]*\b/.test(selector))) continue;
const position = declarationsFor(rule.selectors[0]?.replace(".appRoot .", "") ?? "", [rule]).get("position");
expect(["fixed", "sticky"], `${rule.selectors.join(", ")} pins printed content to the viewport`).not.toContain(
position,
@@ -643,6 +730,8 @@ describe("Care Plan synthetic, memory-only boundary", () => {
`${COMPONENT_ROOT}/management-plan-review.tsx`,
`${COMPONENT_ROOT}/safety-plan-pages.tsx`,
`${COMPONENT_ROOT}/safety-plan-form.tsx`,
+ `${COMPONENT_ROOT}/patient-plan-pages.tsx`,
+ `${COMPONENT_ROOT}/patient-plan-form.tsx`,
];
for (const file of consumers) {
const source = readFileSync(resolve(process.cwd(), file), "utf8");
+ {blockedReason} +
+ )} +
These controls open an application on this device. This prototype transmits nothing and holds no evidence of
delivery, readership, or reply.
diff --git a/src/components/care-plan/mockups/domain.ts b/src/components/care-plan/mockups/domain.ts
index afa3580f2..76c0a818e 100644
--- a/src/components/care-plan/mockups/domain.ts
+++ b/src/components/care-plan/mockups/domain.ts
@@ -17,6 +17,7 @@ import {
type PresentationAmendment,
type ManagementPlanVersion,
type Patient,
+ type PatientPlanVersion,
type PatientSnapshot,
type PatientSnapshotSource,
type PersonalSafetyPlanVersion,
@@ -249,6 +250,60 @@ export function getOpenSafetyPlanDraft(
return open.reduce((latest, version) => (version.version > latest.version ? version : latest));
}
+export function getCurrentPatientPlanVersion(
+ versions: readonly PatientPlanVersion[],
+ planId: SyntheticId,
+): PatientPlanVersion | null {
+ return versions.find((version) => version.planId === planId && version.state === "current") ?? null;
+}
+
+/** The patient edition being worked on. As with the Personal Safety Plan there
+ * is no `awaiting_approval` state: a patient copy waits on any clinical role,
+ * not on a senior decision. */
+export function getOpenPatientPlanDraft(
+ versions: readonly PatientPlanVersion[],
+ planId: SyntheticId,
+): PatientPlanVersion | null {
+ const open = versions.filter((version) => version.planId === planId && version.state === "draft");
+ if (open.length === 0) return null;
+ return open.reduce((latest, version) => (version.version > latest.version ? version : latest));
+}
+
+/**
+ * Whether a patient copy still describes the Management Plan in use.
+ *
+ * Always derived from the two identifiers, never stored. A stored flag would
+ * have to be written by whatever changed the Management Plan, and the one thing
+ * certain about a document already printed and carried away is that nothing in
+ * this application can reach it — so the flag would be the part most likely to
+ * be wrong, on the question that most needs to be right.
+ *
+ * A stale copy stays fully readable and is never regenerated, hidden, or
+ * withdrawn on the person's behalf. They may be holding the paper; the
+ * application's account of what they were given has to stay true to it.
+ *
+ * A `null` Management Plan current-version identifier means the Management
+ * Plan was withdrawn with nothing approved to replace it — not that nothing
+ * ever compares. A current Patient Plan version can only exist if a
+ * Management Plan was once approved for it to be derived from, so a source
+ * that is now current-less is exactly the "moved on underneath the copy"
+ * case this function exists to catch, and is stale by the same reasoning as
+ * a source that moved on to a different version.
+ */
+export function isPatientPlanVersionStale(
+ version: PatientPlanVersion | null,
+ managementPlanCurrentVersionId: SyntheticId | null,
+): boolean {
+ if (version === null || version.state !== "current") return false;
+ // A withdrawn source is not a safe exception. The patient copy still exists
+ // and may already be on paper, but there is no Current Management Plan for it
+ // to represent, so it has to carry the same needs-updating state as a copy
+ // made from an older Current version. `derivedFromManagementVersionId` is
+ // never `null` on a real version, so this comparison alone already covers
+ // it without a separate early return.
+ return version.derivedFromManagementVersionId !== managementPlanCurrentVersionId;
+}
+
/** The most recent withdrawn version, used only to keep a withdrawn plan from
* rendering identically to a patient who never had one. */
function getWithdrawnManagementVersion(
diff --git a/src/components/care-plan/mockups/management-plan-read.tsx b/src/components/care-plan/mockups/management-plan-read.tsx
index 32c366a0f..7e208e29f 100644
--- a/src/components/care-plan/mockups/management-plan-read.tsx
+++ b/src/components/care-plan/mockups/management-plan-read.tsx
@@ -568,6 +568,12 @@ export function ManagementPlanSurface({
scenario={scenario}
reviewsHref={CARE_PLAN_ROUTES.reviews}
onIntent={(channel) => recordContactIntent(cmht, channel)}
+ blockedReason={getPrototypeMutationBlockReason(state, {
+ type: "record-contact-intent",
+ patientId: patient.id,
+ cmhtId: cmht.id,
+ channel: "email",
+ })}
/>
)}
diff --git a/src/components/care-plan/mockups/patient-plan-fixtures.ts b/src/components/care-plan/mockups/patient-plan-fixtures.ts
new file mode 100644
index 000000000..f9eebeddc
--- /dev/null
+++ b/src/components/care-plan/mockups/patient-plan-fixtures.ts
@@ -0,0 +1,450 @@
+import { publicCrisisContacts } from "./fixtures";
+import type { PatientResource, PatientResourceCategory, SyntheticId } from "./types";
+
+/**
+ * Care Plan — the resources offered on a person's own copy of their plan.
+ *
+ * These live in their own module rather than in `fixtures.ts`, which is already
+ * long enough that a reader looking for one patient's plan content has to scroll
+ * past everything else. Nothing here reads a clock, a network, storage, or a
+ * random source.
+ *
+ * **Everything in this file is invented except the crisis lines.** The synthetic
+ * services, their telephone numbers, and their web addresses are fictional and
+ * `SYN-` identified. The `crisis_contact` entries are built from
+ * `publicCrisisContacts` — the four verified public numbers — rather than being
+ * typed out again here, because a resource sheet that leaves the building with a
+ * mistyped crisis number is the one error on it that could matter at 3am. There
+ * is one source of truth for those four, and this is not it.
+ *
+ * Housing and financial entries are present, and deliberately so. They are
+ * frequently the actual reason somebody keeps arriving at an emergency
+ * department, and a resource list that cannot mention rent, a bond, or a
+ * Centrelink payment is a list about the wrong problem. Alex has nowhere settled
+ * to go and Jordan is behind on rent; both are on their own plans in plain words,
+ * without either being described as a failing of theirs.
+ */
+
+/** How each category is headed on the person's own copy, in their voice. */
+export const PATIENT_RESOURCE_CATEGORY_LABEL: Record
+ This record has not been confirmed as the right person. Nothing can be written against it.
+ Return to search and choose the record again.
+
+ {`${patient.preferredName} has no Current Plan, so there is nothing to make a patient copy of.`}
+
+ {`The copy is produced from Current Plan version ${currentManagementVersion.version} by a fixed conversion that runs on this device. It converts only what it can convert safely and leaves everything else blank, with the reason, for you to write with ${patient.preferredName}. No language model is involved.`}
+
+ {`Version ${current.version} stays the approved copy until you approve a new one, so ${patient.preferredName} is never left without one.`}
+
+ {createBlockedReason}
+
+
+ Back to the Patient Plan
+
+
+ {`Everything below is a draft. ${patient.preferredName} has not been given it, and it is not their copy until you approve it.`}
+
+ The blanks are deliberate. The conversion runs offline on this device with no language model of any kind, and
+ where it could not be confident it left the section empty rather than guessing. What it would have guessed
+ wrong is exactly what a person would have read as being about them.
+
+ {actor === null
+ ? "No synthetic user is selected, so nothing written here could be attributed to anyone."
+ : `${actor.displayName} is signed in with the ${PROTOTYPE_ROLE_LABEL[actor.role].toLowerCase()} role, which does not carry writing or approving a patient copy. Any clinical role can.`}
+ {PATIENT_PLAN_SECTION_LEAD_IN[section.key]} {section.gapReason ?? NOT_RECORDED}
+ {resource.contact === null || resource.isRealContact
+ ? resource.name
+ : `${resource.name} — ${resource.contact}`}
+
+ {resource.detail}
+
+ This copy needs updating.{" "}
+ {`The Management Plan has moved on since version ${version.version} of ${patient.preferredName}'s own copy was written. Everything in it is still shown, and nothing has been changed or taken away: ${patient.preferredName} may be holding a printed copy, and what this page says they were given has to stay true. Go through it with them and write a new one.`}
+ {`${patient.mrn} — born ${formatPerthDate(patient.dateOfBirth)}`}
+ This record has not been confirmed as the right person. Nothing of this plan is shown,
+ because this is the copy written to be handed to somebody. Return to search and choose the record again.
+
+ {`This is ${patient.preferredName}'s own copy of the plan, written to be read by ${patient.preferredName} rather than by a clinician. It carries the same agreement as the Management Plan, in ${patient.preferredName}'s own voice.`}
+
+ It is produced by a fixed offline conversion that runs on this device. No language model, and no service of any
+ kind, is involved at any point. Anything the conversion could not turn into everyday words with confidence is
+ left blank for a clinician to write, and a copy with a blank in it cannot be approved.
+
+
+ {draft === null ? "Write a new copy with this person" : `Continue draft version ${draft.version}`}
+
+
+ {actor === null
+ ? "No synthetic user is selected, so nothing written here could be attributed to anyone."
+ : `${actor.displayName} is signed in with the ${PROTOTYPE_ROLE_LABEL[actor.role].toLowerCase()} role, which does not carry writing or approving a patient copy. Any clinical role can.`}
+
+ {`${patient.preferredName} has no Current Plan, so there is nothing to make a patient copy of. A draft or a withdrawn version is not an agreed plan, and a copy of one would describe care nobody agreed to.`}
+
+ {createBlockedReason}
+
+ {`${patient.preferredName} has no approved Patient Plan. Nothing is shown in its place, because a draft is not a copy anybody has agreed to hand over.`}
+
+ {`A draft was started on ${formatPerthDate(draft.createdAt)}. It has ${
+ draft.sections.filter((section) => section.gap).length
+ } of ${draft.sections.length} sections still to write, and cannot be approved until they are.`}
+
+ {`The printed copy is written to ${patient.preferredName}, carries only ${patient.preferredName}'s preferred name and record number, and holds nothing from the clinical record beyond what is on this page.`}
+
+
+ Print this copy
+
+
+ This record has not been confirmed as the right person. Nothing is offered for printing,
+ because paper carrying a nearby person’s plan cannot be taken back. Return to search and choose the
+ record again.
+
+ {`${patient.preferredName} has no approved Patient Plan, so there is nothing to print. A draft is not a copy anybody has agreed to hand over, and a draft on paper is a plan somebody may follow.`}
+
+
+ Back to the Patient Plan
+
+
+ {blockedReason}
+
+ The print view could not be opened. Nothing was printed, and nothing was recorded. The whole
+ copy is still on this page: read it from the screen, write the numbers down, or try printing again from the
+ browser’s own menu.
+
+ This is your copy of the plan you and your team wrote together. Keep it somewhere you can find it quickly,
+ and bring it with you if you can. If something in it stops fitting, tell someone on your team so you can
+ write it again together.
+
+ This copy needs updating. The Management Plan has moved on since version{" "}
+ {String(version.version)} of this copy was written. Everything here is still shown, and nothing has been
+ changed or taken away. Check with the team before treating it as current.
+
+ {section.heading}
+
+
+ {section.body.map((line) => (
+
+ )}
+ {group.label}
+ {group.resources.map((resource) => (
+ {patient.fullName}
+
+
+ My plan
+ {/*
+ Minimum necessary, and nothing beyond it. A preferred name and a
+ record number are what a person needs to know the sheet is theirs and
+ what a service needs to match it to them. A date of birth, an
+ address, a pronoun, a home service, an episode, the Management Plan's
+ version history, its owner, its approver, its review date, or
+ anything about what a clinician thinks is not on this page: none of
+ it helps the reader and all of it travels.
+ */}
+
+
+