diff --git a/PR_POLICY_BODY.md b/PR_POLICY_BODY.md new file mode 100644 index 000000000..174d43353 --- /dev/null +++ b/PR_POLICY_BODY.md @@ -0,0 +1,33 @@ +## Summary + +Adds a gated, synthetic Care Plan prototype: clinicians can read approved Management Plans, author governed revisions, record ED presentations, and generate patient-facing Care Plans from an approved source. Patient copies stay readable when the source changes or is withdrawn, with a visible needs-updating warning (including on print). Contact and print actions honour the mutation guard instead of launching when blocked. + +## Verification + +- [x] `npm run verify:pr-local` — hosted Static PR checks, Safety and config checks, Unit coverage, and Build passed on head `cb6d65eec6e84598b771c3241d10db25b77af943` +- [x] `npm run verify:ui` — hosted Production UI critical and Production UI (1/2/3) passed on the same head +- Retrieval/ranking evals not applicable: this PR does not change retrieval, ranking, selection, chunking, or scoring behaviour. +- `npm run verify:release` not run: not a release/handoff-confidence request; no provider-backed gates authorised. + +## Risk and rollout + +- Risk: medium — patient-facing Care Plan authoring, print, and crisis-support contact surfaces are new, but they run as a synthetic in-memory prototype behind the existing admin-gated route family with no persistence or live provider calls. +- Rollback: revert the squash merge. No migrations, schema, or production config ship with this change. +- Provider or production effects: None +- RAG impact: no retrieval behaviour change — Care Plan prototype and tests only; no rag/, retrieval RPC, ranking-config, or golden-fixture files. + +## Clinical Governance Preflight + +- [x] Source-backed claims still require linked source verification before clinical use +- [x] No patient-identifiable document workflow was introduced or expanded without explicit governance approval +- [x] Supabase target remains `Clinical KB Database` (`sjrfecxgysukkwxsowpy`) +- [x] Service-role keys and private document access remain server-only +- [x] Demo/synthetic content remains clearly separated from real clinical sources +- [x] Source metadata, review status, and outdated/unknown-source behavior remain conservative +- [x] Deployment classification/TGA SaMD impact was checked when clinical decision-support behavior changed + +This change is a clinician-facing synthetic prototype, not validated clinical decision support. Fixtures are labelled synthetic; there is no live patient document store, no service-role exposure, and no SaMD claim. + +## Notes + +Codex P1 review threads on stale print warnings, withdrawal staleness, print/contact guards, and superseded Patient Plan approval were addressed on this branch and resolved. diff --git a/docs/branch-review-records/222ddecb7394f5ef160bc68d94fe857bb735d67672444e7a28528f984db53b58.record.md b/docs/branch-review-records/222ddecb7394f5ef160bc68d94fe857bb735d67672444e7a28528f984db53b58.record.md new file mode 100644 index 000000000..76dbedf13 --- /dev/null +++ b/docs/branch-review-records/222ddecb7394f5ef160bc68d94fe857bb735d67672444e7a28528f984db53b58.record.md @@ -0,0 +1 @@ +| 2026-08-22 | PR-2291 | 37e46caac7b777517d61d56c30d3582d98963a8f | Run PR: main merge and six P1 review fixes | main merged; six P1 fixes published; local runtime blocked before executable gates | git diff --cached --check PASS; unmerged=0; setup blocked npm 11.9.0 vs 11.17.0; format executor disconnected; no local tests | diff --git a/docs/branch-review-records/4bd26d4badd0bcc4410f69b50ff57255bece54812392affec6cad066652891dd.record.md b/docs/branch-review-records/4bd26d4badd0bcc4410f69b50ff57255bece54812392affec6cad066652891dd.record.md new file mode 100644 index 000000000..f24b49442 --- /dev/null +++ b/docs/branch-review-records/4bd26d4badd0bcc4410f69b50ff57255bece54812392affec6cad066652891dd.record.md @@ -0,0 +1 @@ +| 2026-08-23 | PR-2291 | a47059e2b2534151a463199f09bfb30afa1c6c44 | Run PR: CI repair for blocked CMHT contact actions | published the omitted contact-action guard, workspace forwarding, and management caller after CI failures | targeted care-plan DOM suite PASS (221/221); tsc --noEmit PASS; Prettier check PASS; git diff --cached --check PASS | diff --git a/docs/branch-review-records/5e6887ef5a98b9bd18e2ec20b3c724488166d6a899f5442d605c052854e2ef74.record.md b/docs/branch-review-records/5e6887ef5a98b9bd18e2ec20b3c724488166d6a899f5442d605c052854e2ef74.record.md new file mode 100644 index 000000000..07ecbac90 --- /dev/null +++ b/docs/branch-review-records/5e6887ef5a98b9bd18e2ec20b3c724488166d6a899f5442d605c052854e2ef74.record.md @@ -0,0 +1 @@ +| 2026-08-22 | claude/ed-care-plans-impl-7f44cd (PR #2291) | 620743d434dfcc158811720ca53d09caf9c4b750 | Run PR sweep: CI fix + threads + drift | Before: mergeable_state dirty (main-sync merge conflict never attempted — hard-stopped and reported per clinical-content-conflict policy since the PR branch and main both independently built the care-plan feature after PR #2274 landed), PR mergeability + PR policy both failing, 0 of 6 unresolved P1 review threads addressed, real CI (pr-required) never triggered. After: PR owner (BigSimmo) resolved the main-sync merge conflict live and independently fixed all 6 P1 Codex findings on the branch while this sweep was in progress; this session's own independent fixes for the same 6 findings were reconciled against the owner's landed versions (kept the owner's naming/implementation where duplicated, kept this session's unique patient-plan-print-stale-in-subtree fix which the owner's merge did not touch, removed this session's now-dead-code duplicate blocks e.g. two near-identical withdrawal stale-trigger blocks and two approve-patient-plan-version refusal blocks left by concurrent independent 3-way merges, added 3 new regression tests). mergeable_state now blocked (not dirty) — no more content conflict. PR mergeability: success. PR policy: still failing (only blocker: PR body Clinical Governance Preflight all 7 boxes unchecked plus advisory warnings on blank Summary/Verification/Risk sections — PR body left untouched per this sweep's explicit instruction not to edit it). Real CI (pr-required aggregate) triggered for the first time on this PR and was still in progress after 25 minutes of observation (Build, Unit coverage, Static PR checks, Production UI x4, Advisory UI, Lighthouse budget all in_progress; Change scope/Gitleaks/Semgrep/Safety and config checks/GitGuardian all green) — not yet settled at end of this sweep's observation window. All 6 review threads already resolved by BigSimmo before this session's push landed; nothing left to reply/resolve. 3 commits pushed: 61c6b562 (own fixes), 70e700db (merge #1, resolving owner's first live main-sync push), 620743d4 (merge #2, resolving owner's second live push that independently fixed the exact same contact-guard finding). | Local only, no provider-backed checks run: npm run typecheck (clean, 4695 files), npm run lint (clean, 4695 files), node scripts/run-vitest.mjs run tests/care-plan-patient-plan.test.ts tests/care-plan-linked-routes.dom.test.tsx tests/care-plan-prototype-state.test.ts tests/care-plan-domain.test.ts tests/care-plan-route-files.test.ts (440 passed), npm run test full suite (8476 passed, 1 skipped, 1 unrelated pre-existing async-timer-after-teardown flake in caring-contact-shell-frame.tsx unrelated to this diff), npx prettier --check on touched files (clean). No eval:rag, eval:quality, eval:retrieval:quality, verify:release, check:supabase-project, or test:live run. | diff --git a/docs/branch-review-records/816d269908ad19d9830f2c7a085f6f11d79150537f76544123c06ec01ca3afa6.record.md b/docs/branch-review-records/816d269908ad19d9830f2c7a085f6f11d79150537f76544123c06ec01ca3afa6.record.md new file mode 100644 index 000000000..584881cd9 --- /dev/null +++ b/docs/branch-review-records/816d269908ad19d9830f2c7a085f6f11d79150537f76544123c06ec01ca3afa6.record.md @@ -0,0 +1 @@ +| 2026-08-22 | PR-2291 | 9621f5138f5c7919633602c08f7b313e8dc16259 | Run PR: final main sync after review fixes | latest main merged cleanly after review fixes; executable local gates remain blocked by runtime | git merge-tree --write-tree 62cfcab483077d437a513b33eec87272852fa197 6d95245204e344335bd6ea10798d8b74111c9fcf PASS; git diff --cached --check PASS; local setup/format/test unavailable | diff --git a/docs/branch-review-records/92c1abfb9f9ac018fd923247076c528cfafaefb88cd8441ea9e00d4dfd48ce99.record.md b/docs/branch-review-records/92c1abfb9f9ac018fd923247076c528cfafaefb88cd8441ea9e00d4dfd48ce99.record.md new file mode 100644 index 000000000..e5774f38c --- /dev/null +++ b/docs/branch-review-records/92c1abfb9f9ac018fd923247076c528cfafaefb88cd8441ea9e00d4dfd48ce99.record.md @@ -0,0 +1 @@ +| 2026-08-22 | PR-2291 | f93b2209ebb9a41aca3159be9ba4f2c8e0bdf289 | Run PR: final current-main sync after review fixes | newly advanced main merged cleanly; executable local gates remain blocked by runtime | git merge-tree --write-tree 3e5c2234b93183d41815886c1b49b947ba544aeb a8968673796710de95cc660802e75c8dbd5e4972 PASS; git diff --cached --check PASS; local setup/format/test unavailable | diff --git a/docs/care-plan/CLAUDE-START-HERE.md b/docs/care-plan/CLAUDE-START-HERE.md new file mode 100644 index 000000000..83e159432 --- /dev/null +++ b/docs/care-plan/CLAUDE-START-HERE.md @@ -0,0 +1,72 @@ +# Care Plan — Claude start here + +**Last updated:** 22 August 2026 (Australia/Perth) +**Implementation status:** Tasks 1–8 complete, reviewed and committed. Task 9 (Patient Plan) is on this branch. Tasks 10–11 not started. +**Design status:** Approved +**Working directory:** live state is `origin/claude/ed-care-plans-impl-7f44cd` (PR #2291). Local Windows worktree paths in older notes are historical. +**Branch:** `claude/ed-care-plans-impl-7f44cd` + +## One-minute start + +**This file is orientation. The live state of the build lives in two others — read those first:** + +1. [`session-handoff-2026-08-21.md`](./session-handoff-2026-08-21.md) — where things stand, the thirteen binding product decisions, the environment hazard, and the prompt to start the next session. +2. [`sdd-ledger.md`](./sdd-ledger.md) — task progress, all 25 controller rulings with what each costs if wrong, the deferred minors, and four systemic lessons. + +**Where to work.** Branch `claude/ed-care-plans-impl-7f44cd`. The authoritative copy is `origin/claude/ed-care-plans-impl-7f44cd`. **Do not create or use any worktree under `.claude/worktrees/`** — that location destroyed this work three times on 21 August 2026. Nothing committed was ever lost; commit at the end of every task. Do not treat dated Windows paths in older handovers as the live checkout. + +**The product in one line.** A synthetic, memory-only, reset-on-refresh prototype under `/mockups/care-plan` that lets a clinician look up the approved management plan for someone who presents repeatedly to an emergency department in psychiatric crisis. Reading is the primary use; authoring is supporting machinery. + +Read these files in order before writing code: + +1. [`AGENTS.md`](../../AGENTS.md) — binding repository rules. +2. [`CLAUDE.md`](../../CLAUDE.md) — Claude orientation and repository map. +3. [`claude-build-handover-2026-08-21.md`](./claude-build-handover-2026-08-21.md) — current state, approvals, boundaries, precedents, and exact next action. +4. [`2026-08-20-care-plan-design.md`](../superpowers/specs/2026-08-20-care-plan-design.md) — approved product and visual specification; this is the product authority. +5. [`care-plan-context.md`](../care-plan-context.md) — binding domain language. +6. [`2026-08-20-care-plan-implementation.md`](../superpowers/plans/2026-08-20-care-plan-implementation.md) — executable nine-task, file-by-file, test-first build plan. +7. [`conversation-transcript-2026-08-21.md`](./conversation-transcript-2026-08-21.md) — portable transcript of the complete visible Codex conversation through the handover request. +8. [`verification-log-2026-08-21.md`](./verification-log-2026-08-21.md) — exact planning and handover evidence, plus what has not run. + +The visual-direction source files are outside Git and remain available at: + +`C:\Users\joshs\.codex\visualizations\2026\08\20\01a01fb2-575f-7c11-a245-332db7a85a25\care-plan\.superpowers\brainstorm\17559-1787239654\content\care-plan-directions.html` + +The previous localhost visual server is no longer running. Open the HTML file directly or start a new task-owned visual server if comparison is needed. + +## Copy/paste prompt for Claude + +```text +Resume the Care Plan build on branch claude/ed-care-plans-impl-7f44cd. + +Read, in order: docs/superpowers/specs/2026-08-20-care-plan-design.md (binding), +docs/care-plan-context.md (binding glossary), and the Global Constraints and +Delivery Stages sections of +docs/superpowers/plans/2026-08-20-care-plan-implementation.md. + +Then read docs/care-plan/sdd-ledger.md — the SDD ledger. Tasks 1–8 are complete; +Task 9 (Patient Plan) is on this branch. Resume at the first task without a +complete line. The ledger also carries every controller ruling. + +Execute with superpowers:subagent-driven-development. Eleven tasks. Stop at the Stage +A checkpoint after Task 5 and report to the user; do not start Task 6 on your own +judgment. + +Synthetic, memory-only, provider-free throughout. Local commits are authorised; +nothing else is — no push, PR, merge, rebase, deployment, or provider access. +``` + +## Current Git checkpoint + +Captured on 21 August 2026: + +- `HEAD`: `eeea74a160c19553f94347dda5102b2dff2ed591` +- Upstream: `origin/main` +- `origin/main` at the final 00:51 AWST snapshot: `1cc0d298774e4dc2ec8dd04d03ecf4fe789d5564` +- State at that snapshot: branch is four commits behind `origin/main`; no branch movement was performed. +- Working tree: the planning and handover documents are untracked; product code is untouched. +- Commits/pushes/PRs/deployments: none for Care Plan. + +## Exact first action + +After the read order and Git preflight, begin implementation-plan **Task 1: domain types, deterministic fixtures, selectors, and privacy invariants**. Do not start with page scaffolding. diff --git a/docs/care-plan/claude-build-handover-2026-08-21.md b/docs/care-plan/claude-build-handover-2026-08-21.md new file mode 100644 index 000000000..daee5854d --- /dev/null +++ b/docs/care-plan/claude-build-handover-2026-08-21.md @@ -0,0 +1,389 @@ +# ED Care Plans — detailed Claude build handover + +> **Historical handover (21 August 2026).** The product name is now Care Plan and the route family is `/mockups/care-plan`. Binding specs live at [`2026-08-20-care-plan-design.md`](../superpowers/specs/2026-08-20-care-plan-design.md), [`care-plan-context.md`](../care-plan-context.md), and [`2026-08-20-care-plan-implementation.md`](../superpowers/plans/2026-08-20-care-plan-implementation.md). Identifiers below (`ED Care Plans`, `/mockups/ed-care-plans`, `ed-care-plans-*.md`) are the names as they stood on this date. + +## Handover outcome + +ED Care Plans is fully brainstormed, clinically bounded, visually selected, specified, and decomposed into a nine-task implementation plan. No application code has been written. This handover is the bridge from the approved Codex design session to a Claude implementation session. + +The next worker should build the complete synthetic standalone prototype in the existing isolated worktree, beginning with the domain and reducer rather than the UI shell. + +## Authority and read order + +When sources differ, use this priority: + +1. Current user instructions in the Claude session. +2. Repository [`AGENTS.md`](../../AGENTS.md). +3. Approved [`Care Plan design specification`](../superpowers/specs/2026-08-20-care-plan-design.md). +4. Binding [`domain glossary`](../care-plan-context.md). +5. Executable [`implementation plan`](../superpowers/plans/2026-08-20-care-plan-implementation.md). +6. This handover and the [`conversation transcript`](./conversation-transcript-2026-08-21.md). + +The specification is the product authority. The implementation plan is deliberately more detailed about files, symbols, tests, and order, but it may not relax a specification invariant. + +## User-approved product + +**Name:** Care Plan (recorded on this date as ED Care Plans) +**Descriptor:** Continuity for recurrent presentations +**Route family:** `/mockups/care-plan` (recorded on this date as `/mockups/ed-care-plans`) +**Cohort:** Adults and older adults in a fictional multi-site WA health-service network +**Delivery:** Complete, linked, interactive, deterministic synthetic prototype +**Persistence:** In-memory only; state resets on refresh + +The app exists to help an authorised clinician rapidly find a person who has recurrent psychiatric ED presentations, locate the approved Current Management Plan, understand the continuity approach, record the current ED Presentation, contact the relevant CMHT, and reach or print the patient's distinct Personal Safety Plan. + +## Approved decisions that must not be reopened + +- Search authorised synthetic records by name, MRN, date of birth, and alias. Do not search clinical narrative, diagnosis, indication, or Safety Plan content. +- Each patient has one longitudinal clinician Management Plan with version history. +- A version becomes the visible **Current Plan** only after approval by a named senior clinician. +- Draft and Awaiting Approval versions remain visibly separate. They never replace or obscure the existing Current Plan. +- The previous Current Plan remains in force until a replacement is approved; approval supersedes it atomically. +- The Personal Safety Plan is a distinct patient-owned, co-produced, independently versioned document. It is printable and does not use the Management Plan's senior-approval gate. +- ED Presentations are a separate append-only record. Corrections are signed amendments, never silent overwrites. +- CMHT contact details include the shared mailbox, duty telephone, hours, care coordinator where appropriate, after-hours pathway, and last verified date. +- Email and telephone controls launch only `mailto:` and `tel:` intents. The email subject is generic and contains no patient identifier or clinical detail. The prototype never claims a message was sent, delivered, read, answered, or that contact was completed. +- The desktop direction is **A — Clinical Snapshot**. Use Direction B's longitudinal timeline within the patient record and Direction C's queues within Reviews. +- The phone layout is single-column at 320 px and 390 px, respects the effective safe-area inset, has 48 px targets, and keeps Current Plan, CMHT, Safety Plan, and Record ED Presentation directly reachable. +- The repository Clinical White / Sky Graphite system, shared components, dark mode, forced colours, reduced motion, visible focus, and keyboard contracts apply. + +## Deliberately unresolved governance decision + +The numeric identification threshold is **not approved**. + +The user explicitly asked to leave the previously suggested “four ED presentations in a rolling 12 months” open for further review. Therefore: + +```ts +IdentificationPolicy.status === "pending_governance"; +IdentificationPolicy.thresholdCount === null; +IdentificationPolicy.thresholdLookbackMonths === null; +``` + +The prototype may display objective raw activity such as “7 ED presentations in 12 months” and may support authorised manual referral to Identification Review. It must not automatically create a patient label, diagnosis, risk state, eligibility decision, plan, mandatory workflow, or “Review suggested” state from a count. + +## Core clinical and privacy invariants + +- The Management Plan supports continuity but never replaces fresh triage, physical assessment, mental-state assessment, or immediate risk assessment. +- A patient is never defined by their presentation count. Do not use “frequent flyer”, “problem patient”, or punitive utilisation language. +- Medication content is limited to allergies and a reference/link to the authoritative medication record. Do not build a parallel prescribing record or orders workflow. +- Record patient and carer involvement as co-produced, discussed, declined, or patient unavailable. Never describe non-participation as non-compliance. +- Support-person, carer, interpreter, cultural, Aboriginal Liaison, peer, accessibility, and communication preferences are consent-aware clinical context, not decorative demographics. +- Presentation records capture the plan version available at that time, whether it was available/used/helpful, any deviation and reason, outcome/disposition, CMHT contact outcome, and whether review is requested. +- Printing uses minimum necessary identifiers, printed-at/version metadata, a “check the electronic record” warning, and a confidential-document footer. +- Public WA crisis numbers may be real; all patients, clinicians, services, teams, sites, events, plans, and internal contacts must be unmistakably synthetic and use `SYN-` identifiers. +- No network, provider, analytics, storage, persistence, database, API, route handler, Server Action, Supabase, OpenAI, EDIS, EMR, PAS, PSOLIS, or real email transport is in scope. + +## Complete route suite + +All routes are below `/mockups/ed-care-plans`: + +- `/` +- `/patients` +- `/patients/[patientId]` +- `/patients/[patientId]/management-plan` +- `/patients/[patientId]/management-plan/edit` +- `/patients/[patientId]/management-plan/review` +- `/patients/[patientId]/safety-plan` +- `/patients/[patientId]/safety-plan/edit` +- `/patients/[patientId]/safety-plan/print` +- `/patients/[patientId]/presentations` +- `/patients/[patientId]/presentations/new` +- `/patients/[patientId]/presentations/[presentationId]` +- `/patients/[patientId]/history` +- `/reviews` +- `/team` +- `/governance` +- `/system-states` + +The route must also be linked from the existing Developer hub at `src/app/mockups/development/page.tsx`. + +## Primary information architecture + +### Global navigation + +- Home +- Patients +- Reviews +- Team +- Governance + +### Patient workspace + +- Overview +- Management Plan +- Personal Safety Plan +- ED Presentations +- Version and audit history + +### Reviews queues + +- Awaiting Approval +- Review Suggested +- Contact Verification +- Identification Review + +### Clinical Snapshot desktop + +Use a fixed Sky Graphite clinical rail, prominent search, compact recent/directory list on the left, and the selected patient workspace on the right. The approved Current Plan is the centre of gravity. CMHT and Safety Plan access remain visible without burying them in secondary navigation. + +### Phone + +Collapse to search followed by one full-width patient workspace. There must be no horizontal page scroll at 320 px or 390 px. Background may paint behind the system region, but meaningful top content stays below the effective safe-area inset. + +## Personal Safety Plan contract + +The patient-voice structure follows seven Australian safety-planning steps: + +1. Warning signs. +2. Making the environment safer. +3. Reasons for living. +4. Personal coping strategies. +5. Supportive people and places. +6. Family and friends to contact. +7. Professional and emergency support. + +The print view is plain language, generous spacing, monochrome-safe, and minimal. It includes current CMHT and urgent-help information without confusing MHERL with an emergency service. + +Public crisis details verified from the official sources during planning: + +- Emergency: `000`. +- MHERL Perth: `1300 555 788`. +- MHERL Peel: `1800 676 822`. +- Rurallink: `1800 552 002`; 4:30 pm–8:30 am weeknights and 24 hours on weekends/public holidays. +- MHERL must be described as not an emergency service. + +Source links: + +- [WA Health — Mental Health Emergency Response Line](https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/MHERL) +- [WA Health — Rurallink](https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/Rurallink) + +Recheck official public details if implementation happens materially later than this handover; do not silently change them from a secondary source. + +## Safe and degraded states to demonstrate + +- No Management Plan. +- Draft only. +- Awaiting Approval while the previous Current Plan remains visible. +- Review overdue. +- Withdrawn plan. +- Superseded version in history. +- Conflicting concurrent draft. +- Identity uncertainty. +- CMHT contact details not verified/stale. +- Temporary offline or unavailable record. +- ED Presentation amendment. +- Patient unavailable or declining participation. +- No search results. +- Unauthorized transition or role. + +The interface must never quietly substitute a draft, superseded version, or empty state for the Current Plan. + +## Explicitly deferred or prohibited + +- AI or predictive scoring. +- Automatic identification, risk labelling, or plan creation. +- Automated clinical recommendations. +- Medication ordering or a second medication list. +- Automated messages or emails containing patient information. +- Staff chat, inbox, or monitored-reply workflow. +- Real clinical-system integrations. +- Patient portal editing. +- Attendance-reduction targets. +- Complex population analytics. +- Claims that the prototype is clinically validated, production-ready, interoperable, secure for real data, or suitable for clinical use. + +## Existing planning artifacts + +| Artifact | Location | State | +| ------------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| Start point | [`CLAUDE-START-HERE.md`](./CLAUDE-START-HERE.md) | Complete | +| Approved design | [`2026-08-20-ed-care-plans-design.md`](../superpowers/specs/2026-08-20-ed-care-plans-design.md) | Complete and user-approved | +| Domain glossary | [`ed-care-plans-context.md`](../ed-care-plans-context.md) | Complete | +| Implementation plan | [`2026-08-20-ed-care-plans-implementation.md`](../superpowers/plans/2026-08-20-ed-care-plans-implementation.md) | Complete; nine tasks | +| Conversation | [`conversation-transcript-2026-08-21.md`](./conversation-transcript-2026-08-21.md) | Complete through handover request | +| Evidence | [`verification-log-2026-08-21.md`](./verification-log-2026-08-21.md) | Current handover evidence | + +At handover creation the three original planning files contained 1,554 lines in total: glossary 135, design 418, and implementation plan 1,001. + +## Relevant repository locations + +### Binding orientation and design system + +- `D:\Worktrees\Database\ed-care-plans\AGENTS.md` +- `D:\Worktrees\Database\ed-care-plans\CLAUDE.md` +- `D:\Worktrees\Database\ed-care-plans\docs\codebase-index.md` +- `D:\Worktrees\Database\ed-care-plans\docs\design-system\README.md` +- `D:\Worktrees\Database\ed-care-plans\docs\wiring-conventions.md` +- `D:\Worktrees\Database\ed-care-plans\docs\testing.md` +- `D:\Worktrees\Database\ed-care-plans\docs\process-hardening.md` +- `D:\Worktrees\Database\ed-care-plans\src\app\globals.css` +- `D:\Worktrees\Database\ed-care-plans\src\components\ui` + +### Current Caring Contact precedent in this worktree + +- `src/app/mockups/caring-contacts/layout.tsx` +- `src/app/mockups/caring-contacts/route-page.tsx` +- `src/components/caring-contacts/mockups/types.ts` +- `src/components/caring-contacts/mockups/fixtures.ts` +- `src/components/caring-contacts/mockups/prototype-state.ts` +- `src/components/caring-contacts/mockups/routes.ts` +- `src/components/caring-contacts/mockups/routable-suite.tsx` +- `src/components/caring-contacts/mockups/caring-contact-shell-frame.tsx` +- Caring Contact focused unit/DOM tests under `tests/caring-contact-*` +- Caring Contact browser journey `tests/ui-caring-contact-mockup.spec.ts` + +Use this precedent for the gated mockup route, in-memory reducer/provider, route reconstruction, shell, and wrapped browser-test pattern. Do not import the Caring Contact one-way-SMS product semantics into ED Care Plans. + +### Earlier work-in-progress references + +- WA Ward Flow / Patient Flow: `C:\Users\joshs\.codex\worktrees\ward-management-design\Database\src\app\ward-management` +- WA Ward Flow components: `C:\Users\joshs\.codex\worktrees\ward-management-design\Database\src\components\ward-management` +- Caring Contact linked mockup: `D:\Worktrees\Database\caring-contact-linked-mockup\src\app\mockups\caring-contacts` +- Caring Contact components: `D:\Worktrees\Database\caring-contact-linked-mockup\src\components\caring-contacts\mockups` + +These are reference-only. The implementation target remains the ED Care Plans worktree. + +### Gate and navigation owners + +- `src/app/mockups/development/page.tsx` +- `src/app/mockups/mockups-layout-client.tsx` +- `src/lib/developer-area/headers.ts` +- `src/proxy.ts` +- `tests/proxy.test.ts` +- `package.json` +- `playwright.config.ts` + +### Installed Next.js documentation + +Next.js is 16.3. Read the relevant installed guide under `node_modules/next/dist/docs/` before framework code. The prior planning pass already checked layouts/pages, linking/navigation, server/client boundaries, dynamic routes, and CSS; a new implementer should still read the exact version-matched files needed for each task. + +## Requested skills and execution method + +The user explicitly requested the Superpowers process. Relevant skill locations are: + +- `C:\Users\joshs\.codex\skills\20-superpowers\SKILL.md` +- `C:\Users\joshs\.codex\skills\20-superpowers\brainstorming\SKILL.md` +- `C:\Users\joshs\.codex\skills\grill-me\SKILL.md` +- `C:\Users\joshs\.agents\skills\grill-with-docs\SKILL.md` +- `C:\Users\joshs\.codex\plugins\cache\openai-curated-remote\superpowers\6.3.0\skills\subagent-driven-development\SKILL.md` +- `C:\Users\joshs\.codex\plugins\cache\openai-curated-remote\superpowers\6.3.0\skills\writing-plans\SKILL.md` +- `C:\Users\joshs\.codex\plugins\cache\openai-curated-remote\superpowers\6.3.0\skills\test-driven-development\SKILL.md` +- `C:\Users\joshs\.codex\plugins\cache\openai-curated-remote\superpowers\6.3.0\skills\requesting-code-review\SKILL.md` +- `C:\Users\joshs\.codex\plugins\cache\openai-curated-remote\superpowers\6.3.0\skills\verification-before-completion\SKILL.md` + +Brainstorming and grilling are complete; do not restart them. The implementation plan requires one implementer at a time, a fresh specification reviewer, a fresh quality reviewer, test-first behaviour changes, and recorded task evidence. + +Repository skills relevant during the build: + +- `.agents/skills/ui/SKILL.md` +- `.agents/skills/clinical/SKILL.md` +- `.agents/skills/test/SKILL.md` +- `.agents/skills/handover/SKILL.md` + +## Nine-task build sequence + +The implementation plan is executable and names every file, symbol, red/green command, and review gate. Its task sequence is: + +1. Domain types, deterministic fixtures, selectors, and privacy invariants. +2. Reducer, provider, role permissions, and lifecycle transitions. +3. Gated route family and shell. +4. Clinical Snapshot, patient search, and CMHT contact actions. +5. Management Plan drafting, submission, comparison, approval, withdrawal, and history. +6. ED Presentation recording, plan-use feedback, and append-only amendments. +7. Personal Safety Plan lifecycle and print view. +8. Reviews queues, Team, Governance, audit history, and degraded/system states. +9. Browser/accessibility coverage, generated documentation, and handoff verification. + +Do not parallelize overlapping writes. Each task should end with the required reviews and the smallest green gate before the next implementer starts. + +## Git and worktree state at handover + +### Intended worktree + +- Path: `D:\Worktrees\Database\ed-care-plans` +- Branch: `codex/ed-care-plans` +- HEAD: `eeea74a160c19553f94347dda5102b2dff2ed591` +- Upstream: `origin/main` +- `origin/main` at the final 00:51 AWST snapshot: `1cc0d298774e4dc2ec8dd04d03ecf4fe789d5564` +- Divergence at that snapshot: behind by four commits, ahead by zero. +- Existing ED Care Plans files: untracked planning/handover documents only. +- Product files changed: none. + +The worktree was created from then-current `origin/main`; main advanced afterward. No pull, merge, rebase, or branch movement was authorized or performed. Inspect the four commits and ask before moving the base. The design is not tied to those commits, so implementation may continue safely on the current base if repository inspection confirms no required conflict. + +### Shared checkout to preserve + +Do not work in `D:\Repos\Database`. At handover it was: + +- Branch: `gemini/safe-tooling-ui-layout-and-workflow-hardening` +- HEAD: `ad44f2b1466c3091ba0c0bbb36125d2631c5509a` +- Upstream: gone. +- Unrelated modified files: + - `docs/scripts-index.md` + - `docs/testing.md` + - `scripts/check-bundle-budget.mjs` + - `tests/bundle-budget.test.ts` + +These changes belong to another task/process and must not be staged, moved, stashed, reset, or absorbed. + +## Authorization boundary + +This handover authorizes no external effect. + +- Routine local implementation in the isolated worktree is the intended next activity in Claude. +- Offline/mock verification is intended. +- No API, provider, production data, hosted CI, migration, deployment, message, or external publication is authorized. +- No commit, push, pull, merge, rebase, PR, or branch deletion is authorized. +- The Codex implementation plan asked for explicit permission to create local SDD checkpoint commits. The user switched to requesting a Claude handover instead of granting that permission. Claude must ask before creating local commits. + +## Verification state + +Planning artifacts were formatted and self-reviewed. The plan self-review reported `PLAN_SELF_REVIEW=PASS tasks=9`. The route inventory matched all 17 approved routes, and the unfinished-marker scan returned no matches. + +The handover lifecycle classifier reported: + +```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. +``` + +Exact final documentation checks are recorded in [`verification-log-2026-08-21.md`](./verification-log-2026-08-21.md). + +No application test, typecheck, lint, build, browser journey, accessibility run, or product gate has run because no product code exists yet. Do not present planning proof as implementation proof. + +## Visual companion + +The approved visual comparison is preserved outside Git: + +- Direction source: `C:\Users\joshs\.codex\visualizations\2026\08\20\01a01fb2-575f-7c11-a245-332db7a85a25\ed-care-plans\.superpowers\brainstorm\17559-1787239654\content\ed-care-plans-directions.html` +- Post-selection screen: `C:\Users\joshs\.codex\visualizations\2026\08\20\01a01fb2-575f-7c11-a245-332db7a85a25\ed-care-plans\.superpowers\brainstorm\17559-1787239654\content\waiting-after-direction.html` +- Former URL: `http://localhost:65531/?key=c8ec5482b262fbd2da66537c291b1258cac85114af2a9fbf107ee70cf5dfec47` +- Server status: not running at handover; the HTML assets remain. +- Approved direction: A — Clinical Snapshot. + +The former URL is historical evidence, not a promised live endpoint. Use the file or create a new task-owned server if visual comparison is necessary. + +## Original Codex task + +- Task title: `Management plan` +- Task ID: `01a01fb2-575f-7c11-a245-332db7a85a25` +- Task working directory: `D:\Repos\Database` +- Portable transcript: [`conversation-transcript-2026-08-21.md`](./conversation-transcript-2026-08-21.md) + +The transcript contains every visible user and assistant message returned by the native task-history reader through the handover request. It intentionally omits private reasoning, hidden instructions, and raw tool payloads. Tool and verification evidence is separately recorded in the verification log. + +## Remaining risks and open decisions + +- Numeric presentation threshold remains pending local governance. +- The prototype is not clinically validated and must stay synthetic. +- Public crisis details can change; recheck official sources if implementation is delayed. +- The branch was four commits behind `origin/main` at the final handover snapshot; recheck, and do not silently rebase or merge. +- Local commits are not yet authorized, although the requested SDD process normally depends on them. +- Physical iPhone Safari/PWA acceptance cannot be closed by Chromium emulation; if phone chrome changes are material, report that gap separately. +- Browser, dark-mode, forced-colour, reduced-motion, print, and 320/390 px evidence remain future Task 9 work. + +## Exact next action + +In Claude, verify `git status --short --branch --untracked-files=all`, `git rev-parse HEAD`, and `git rev-parse origin/main`; confirm the handover files are intact; then execute implementation-plan Task 1 using its prescribed failing focused test. Do not scaffold routes first, and do not encode a numeric identification threshold. diff --git a/docs/care-plan/complete-work-ledger.md b/docs/care-plan/complete-work-ledger.md new file mode 100644 index 000000000..7d7081494 --- /dev/null +++ b/docs/care-plan/complete-work-ledger.md @@ -0,0 +1,207 @@ +# Care Plan — complete work ledger + +Everything done on Care Plan, across every session, in order. Written 21 August 2026 +(Australia/Perth) at the end of the second session. This is the record of _what +happened_; `sdd-ledger.md` is the record of _decisions taken_, and +`session-handoff-2026-08-21.md` is the record of _where to pick up_. + +--- + +## Session 1 — Codex, 20–21 August 2026 + +Task title "Management plan", task id `01a01fb2-575f-7c11-a245-332db7a85a25`, worked in +`D:\Repos\Database` and then the worktree `D:\Worktrees\Database\ed-care-plans` on branch +`codex/ed-care-plans`. + +**Produced, all as untracked planning documents:** + +| Artefact | Now at | +| ----------------------------- | --------------------------------------------------------------- | +| Domain glossary | `docs/care-plan-context.md` | +| Design specification | `docs/superpowers/specs/2026-08-20-care-plan-design.md` | +| Nine-task implementation plan | `docs/superpowers/plans/2026-08-20-care-plan-implementation.md` | +| Build handover | `docs/care-plan/claude-build-handover-2026-08-21.md` | +| Conversation transcript | `docs/care-plan/conversation-transcript-2026-08-21.md` | +| Verification log | `docs/care-plan/verification-log-2026-08-21.md` | +| Start-here entry point | `docs/care-plan/CLAUDE-START-HERE.md` | + +Paths in this table are the current post-rename locations. Earlier session artefacts used the `ed-care-plans` names as historical aliases. + +**Decided:** the product concept; the domain language, with an explicit `_Avoid_` list +per term; one longitudinal Management Plan per patient with named senior-clinician +approval; a separate patient-owned Personal Safety Plan; append-only ED Presentations +with visible amendments; intent-only contact actions; the visual direction (A, Clinical +Snapshot); and — deliberately — **no numeric identification threshold**, left pending +local governance. + +**Verified:** plan self-review passed, 17 routes matched, no unfinished markers. No +application code was written and no product gate was run. + +**Left open:** local commits were never authorised, so nothing was committed. The +branch sat four commits behind `origin/main`. + +--- + +## Session 2 — Claude, 21 August 2026 + +Asked to review the handover, use the relevant skills, and ask whatever was needed to +perfect it. Ran `superpowers:brainstorming`, `writing-plans`, +`subagent-driven-development`, `dispatching-parallel-agents`, the `grilling` skill, and +`prompt`. + +### 01:29 — first four decisions, and the plan lands in a tracked worktree + +Read everything, verified the plan's claims against the repository (all twelve named UI +primitives exist; two are in a different directory than the plan said), and surfaced the +central contradiction: the user described "a clinical tool for clinicians", the +specification described a synthetic prototype that forgets everything on refresh. + +Four decisions taken with the user: build the prototype now but shape the domain so real +storage could be added later without a redesign; keep the full multi-service workflow +including senior approval; deliver Tasks 1–5 then stop for review; local commits +authorised. + +`92097e53e` — planning documents copied into the tracked worktree, decisions applied. + +### 01:53 — design review + +Interrogated the parts of the specification written from published guidance rather than +practice. Five changes: Management Plan content cut from nineteen fields to eleven in two +tiers, removing four duplicate pairs and promoting the two safety-critical sections into +the first-minute summary; the ED Presentation record cut to a roughly thirty-second set; +the review clock defined (it had three states and no durations); Identification Reviews +given a way to close (they could be opened but never closed); and the first-minute summary +fixed at exactly five sections. + +`3997ea37b`. + +### 02:57 — grilling round, rename, and the Patient Plan + +Nine more questions, each with a recommendation. Outcomes: an admission-wording ban; +the safety boundary pinned above all plan content; `whatMakesItWorse` written about what +the service does rather than what the person does; a permanent marker and review trigger +when a plan is approved without the person's involvement; senior-only withdrawal rendering +distinctly from "no plan"; safety-plan authorship open to every clinical role; sort-by-count +confined to the identification workflow; amendment extended to the one-line account and the +plan-use answers; production reachability behind the administrator gate; and the required +note reframed as "in one line: why they came and what happened". + +The user also stated the ordering principle that reshaped the build — _"the plan is for +clinicians to look up and see the management plan; it is rarely for changing or updating"_ +— which became **read primacy**, and moved the whole reading experience into Stage A with +no authoring surface at all. + +A new **Patient Plan** task was added: a deterministic, offline, rule-based transformation +of an approved version into a patient-facing edition that flags what it cannot convert +rather than guessing, never auto-converts the agreed-approach section, and requires +clinician approval before the patient receives it. + +The product was renamed **ED Care Plans → Care Plan**. The plan grew from nine tasks and +seventeen routes to eleven tasks and twenty-one routes. + +`8a2e6a6d1`. + +### 07:47–11:17 — Task 1: domain, fixtures, selectors + +One opus implementer, three pre-review fix rounds, one opus reviewer, two review fix +rounds. Final: 58/58 passing, typecheck clean. + +`bfbee15b1`, `e5b3f4c12`, `3423449d2`, `2f5a6f32b`, `59d101fe9`, `9d72974ac`, `7b2864572`, +`8652e73ff`. + +Notable: the implementer caught four defects in the controller's own plan and was right +every time — including refusing to print a fictional telephone number under a real crisis +service's name. The reviewer caught fixture prose asserting histories the records did not +support, a glossary-banned word inside the most-copied paragraph in the fixture set, and +two safety guards that could not fail. + +### 13:11–15:34 — Task 2: reducer, provider, lifecycle + +One opus implementer, one pre-review fix round, one opus reviewer, one review fix round. +Final: 121/121 passing across both files, typecheck exit 0, lint exit 0. + +`e796775c9`, `9600e1250`, `0e1fb4a4f`, `bdcf2f0db`, `def541e6a`. + +Notable: **32 deliberate mutations, 32 red suites** — every refusal guard proved by +breaking the code and watching the test go red, including two-directional proof that the +participation trigger is not simply always-on. The reviewer found that people whose plan +had been withdrawn were falling out of the review queue entirely, and that printing a +safety plan was blocked in the offline specimen — the one thing you most want when systems +are down. + +### 16:50–17:36 — Task 3 blocked, and recovery + +Task 3 was dispatched and the worktree was destroyed underneath it, for the third time +that day, through an explicit `git worktree lock`. The implementer repaired the `.git` +pointer, found a mass deletion already under way, confirmed the branch was intact, and +reported `BLOCKED` without committing — which was the right call, since committing would +have recorded a mass deletion. + +Recovery: relocated to `D:\Worktrees\Database\care-plan`; confirmed all commits intact; +confirmed no file corruption via `git ls-files --eol` and a zero-CR check on the committed +blobs; reinstalled dependencies; re-ran the suites — 121/121 passing. + +`9227ec457`, `0447f35a8` — the SDD ledger and session handoff, committed as **tracked** +files because the git-ignored workspace had just been destroyed with everything in it. + +--- + +## Current state + +| | | +| ---------------- | -------------------------------------------------------------- | +| Branch | `claude/ed-care-plans-impl-7f44cd`, 18 commits ahead of `main` | +| Worktree | `D:\Worktrees\Database\care-plan` | +| Tasks complete | 2 of 11 (domain and fixtures; reducer and provider) | +| Tests | 121 passing across two files | +| Application code | ~3,900 lines across five modules; ~2,150 lines of tests | +| Pushed | **No** — local to one machine, no upstream | + +## What has _not_ been done, and must not be claimed + +No UI exists. No route, page, component or stylesheet has been written. Consequently: +no browser or Playwright journey, no accessibility check, no responsive or phone-chrome +check, no print check, no build, no bundle-budget check, and no provider-backed gate has +ever run for this feature. `npm run typecheck` currently fails in the relocated worktree +on three unrelated pre-existing files because the dependency install did not finish — zero +errors in any Care Plan file. + +## The three worktree destructions + +`D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` was destroyed three times +on 21 August 2026 — twice during Task 1, once during Task 3. The third went through an +explicit `git worktree lock` while a subagent was running, taking the `.git` pointer first +and then 3,836 tracked files over three minutes. + +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 entire SDD workspace on +the third occasion — which is why the ledger is now tracked. + +**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. + +## Honest limits + +Care Plan 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. Completing all eleven tasks would still not make it fit for use with real +patients — see the specification's "Production-readiness boundary". diff --git a/docs/care-plan/conversation-transcript-2026-08-21.md b/docs/care-plan/conversation-transcript-2026-08-21.md new file mode 100644 index 000000000..244317085 --- /dev/null +++ b/docs/care-plan/conversation-transcript-2026-08-21.md @@ -0,0 +1,591 @@ +# ED Care Plans — Codex conversation transcript + +## Transcript metadata + +- Native task title: `Management plan` +- Native task ID: `01a01fb2-575f-7c11-a245-332db7a85a25` +- Native task working directory: `D:\Repos\Database` +- Capture date: 21 August 2026 (Australia/Perth) +- Capture method: Codex native task-history reader, paginated through the complete task +- Coverage: Every visible user message and assistant commentary/final message from the initial request through the handover request and its first assistant update + +This is a portable transcript of the conversational text. It intentionally excludes private reasoning, hidden system/developer instructions, command invocations, and raw tool outputs. Those are not chat text. Relevant repository and verification evidence is preserved separately in [`verification-log-2026-08-21.md`](./verification-log-2026-08-21.md). + +The messages below are chronological. HTML entities and user spelling are retained where the native history returned them. + +--- + +## Turn `01a01fb2-5d51-7e61-9d21-ea0ffc2f70f3` + +### User + +I need you to implement a Complete standalone app similarly to what you had for patient flow or for caring contact. Please confirm you can find these work in progress projects. + +Here is the project I want... A simple interface that is used for identifying frequently presenting patients and creating management plans which are accessible. This is in the context of psychiatry. It should be a place where management plans can be created and stored for patients who are frequent presenters to ED. Easy to search as well. That is the focus. Be built utilising the current design system of this repository to seamlessly fit it. + +Please utilise all superpowers skills relevant to brainstorm this for me. + +[$superpowers](C:\Users\joshs\\.codex\skills\20-superpowers\SKILL.md) [$brainstorming](C:\Users\joshs.codex\skills\20-superpowers\brainstorming\SKILL.md) [$grill-me](C:\Users\joshs\\.codex\skills\grill-me\SKILL.md) [$grill-with-docs](C:\Users\joshs.agents\skills\grill-with-docs\SKILL.md) [$superpowers:subagent-driven-development](C:\Users\joshs\\.codex\plugins\cache\openai-curated-remote\superpowers\6.3.0\skills\subagent-driven-development\SKILL.md) [$superpowers:writing-plans](C:\Users\joshs.codex\plugins\cache\openai-curated-remote\superpowers\6.3.0\skills\writing-plans\SKILL.md) + +### Assistant commentary + +Yes—I’ll first confirm the existing Patient Flow and Caring Contact work, then use the named Superpowers workflow to ground this in the repository, pressure-test the clinical/product boundaries one question at a time, write the implementation plan, and build it with delegated review where the skill calls for it. + +Reasoning split: xhigh for product/clinical planning, high for implementation—the costly mistakes here are access, privacy, workflow ownership, and accidentally turning a focused plan repository into a broader clinical decision system. + +### Assistant commentary + +The named brainstorming skill imposes a useful hard gate: I can inspect and shape the design now, but I will not scaffold or edit the app until I have presented the design and you approve it. The grilling skills also require a domain-model interview, so I’ll keep the discovery deliberately one question at a time. + +### Assistant commentary + +The earlier work is already showing up in the project record: Caring Contact is an isolated linked mockup, and the Patient Flow precedent is the WA Ward Flow/Constellation surface. I’m verifying both against the live filesystem now so I don’t rely on potentially stale memory. + +### Assistant commentary + +I’m also using the repository’s `clinical` and `ui` skills because this is a psychiatry workflow with access, record-status, and safety boundaries, and because the result must inherit the existing visual system rather than become an isolated dashboard style. The Superpowers process controls the sequence; these two skills supply the domain and interface constraints. + +### Assistant final + +Yes—I found both current precedents: + +- **WA Ward Flow / Patient Flow:** [ward-management](/C:/Users/joshs/.codex/worktrees/ward-management-design/Database/src/app/ward-management) with its role-aware coordination screens, domain model, and tests. The worktree is clean. +- **Caring Contact:** [caring-contacts](/D:/Worktrees/Database/caring-contact-linked-mockup/src/app/mockups/caring-contacts) with 13 linked routes, synthetic state, creation workflows, search, responsive layouts, and screenshot evidence. The worktree is clean. + +The current repository checkout contains unrelated modifications, so after design approval I’ll create a dedicated isolated worktree. + +My starting assumption is a complete, synthetic standalone prototype—no real patient data, provider integration, database, or API—with realistic create/edit/search/version-history interactions held locally. This follows the safety boundary of the two precedents. + +First grilling question: should each patient have **one current longitudinal management plan**, updated across repeated ED presentations while previous approved versions remain available in history? + +--- + +## Turn `01a01fbc-64a3-7a71-8f82-c9745dcc652a` + +### User + +Yes for fast grilling... one current longitudinal management plan. There should also be a way to track ED presentations as well somewhere which just states the ED presentation and indication and outcome etc. It should also have the CMHT (community mental health team) contact that is available and a link to easily contact them, launches an email etc. + +### Assistant commentary + +Continuing the fast `grill-me` and domain-model pass: I’ve now separated the longitudinal **Current Plan** from immutable **ED Presentation** entries, and treated the CMHT details as a shared care-team contact attached to the patient record. + +### Assistant final + +Locked in: one longitudinal plan, version history, an ED presentation timeline capturing indication and outcome, and a CMHT contact with a one-click email action. + +Should a plan become the visible **Current Plan** only after approval by a named senior clinician, with drafts clearly separated? + +--- + +## Turn `01a01fbc-fdef-77e1-a853-a275bb1f7d44` + +### User + +It should also have the patients personal safety plan as well that can be printed off. + +### Assistant final + +Added: a separately printable, plain-language **Personal Safety Plan**. + +Should it remain a distinct patient-owned document—co-produced with the patient and independently updated—rather than being embedded inside the clinician-facing management plan? + +--- + +## Turn `01a01fbe-b4bd-7b03-b7ee-cabbd39a3b4e` + +### User + +yes in regards to the personal safety plan + +Should a plan become the visible **Current Plan** only after approval by a named senior clinician, with drafts clearly separated? +yes + +Also please also ask all qeustions together for me with your recommendatiosn + +### Assistant commentary + +I’ll switch the grilling format to one consolidated decision sheet with my recommended defaults, so you can reply “accept all” and only change the exceptions. I’m doing a short official-source check first because the safety-plan, crisis-contact, privacy, and approval recommendations are clinically consequential. + +### Assistant final + +Official guidance supports making individual care plans readily accessible to ED clinicians, involving patients and carers, and communicating with follow-up services. Frequent-use programs also emphasise avoiding stigmatizing labels and using shared, person-centred plans. [Victorian Health](https://www.health.vic.gov.au/practice-and-service-quality/emergency-departments-and-clinical-care), [NSW Agency for Clinical Innovation](https://aci.health.nsw.gov.au/ie/projects/complex-care-coordination). + +## Already decided + +- One longitudinal management plan per patient. +- Only a senior-clinician-approved version becomes **Current**. +- Drafts remain clearly separate; previous versions remain available. +- ED presentations are tracked separately. +- The CMHT contact is immediately accessible. +- The Personal Safety Plan is distinct, patient-owned, co-produced, independently updated, and printable. + +## Remaining decisions and my recommendations + +| # | Decision | Recommended answer | +| --- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | First-delivery maturity | Build a complete synthetic standalone prototype under `/mockups/ed-care-plans`. Use deterministic in-memory state that resets on refresh; no database, API, real patient data, or provider integration. | +| 2 | Product name | **ED Care Plans**, with the subtitle “Continuity for recurrent presentations.” Avoid labels such as “frequent flyer”; even “frequent presenter” should mainly describe the service workflow rather than the person. Stigma reduction is an explicit goal in comparable programs. [NSW ACI](https://aci.health.nsw.gov.au/ie/projects/vip-project) | +| 3 | Service scope | Model a multi-site WA health-service network with three synthetic EDs and several CMHTs. Do not imply statewide production access or interoperability. | +| 4 | Initial patient cohort | Adults and older adults only. Leave CAMHS, forensic, and specialist cohorts outside the first version because their governance and workflows differ materially. | +| 5 | Identification rule | Demonstrate a transparent service-configured threshold of **four ED presentations in a rolling 12 months**, plus manual referral. Display “Review suggested,” never an automatic diagnosis, risk label, or mandatory plan. Comparable programs use different locally determined thresholds, so this must remain configurable. [NSW ACI](https://aci.health.nsw.gov.au/ie/projects/integrating-care-in-goulburn) | +| 6 | Search | Search authorised records by synthetic name, MRN, date of birth, and alias, with recent patients and exact-match emphasis. Do not offer broad searching by diagnosis, indication, or safety-plan content. | +| 7 | Primary users | ED clinicians read and use the Current Plan; ED mental-health liaison and CMHT clinicians create drafts; a named senior clinician approves them. All meaningful actions appear in an audit timeline. | +| 8 | Management-plan lifecycle | `No plan → Draft → Awaiting approval → Current → Review due → Superseded/withdrawn`. An overdue plan stays visible with a strong warning—it should not disappear when it may still provide useful context. The previous Current Plan remains active until its replacement is approved. | +| 9 | Management-plan structure | Include purpose and applicability, preferred engagement approach, usual presentation pattern, what helps, what may worsen distress, assessment considerations, agreed ED response, escalation/disposition guidance, CMHT coordination, patient/carer involvement, owner, approver, and review date. It must state that the plan never replaces fresh triage, physical assessment, mental-state assessment, or immediate risk assessment. | +| 10 | Medication content | Show allergies and a link/reference to the authoritative medication record. Do not reproduce medication orders or create a parallel prescribing record that can become stale. | +| 11 | ED presentation record | Make entries concise and append-only: date/time, ED site, presenting indication, assessment outcome, disposition, CMHT contact and result, Current Plan version used, any deviation and reason, and recording clinician. Corrections become visible amendments rather than silent edits. | +| 12 | Personal Safety Plan | Use patient-voice sections aligned with the Australian seven-step safety-planning model: warning signs, making the environment safer, reasons for living, personal coping strategies, supportive people and places, family/friends, and professional/emergency support. [Beyond Blue/Lifeline](https://www.beyondblue.org.au/mental-health/suicide-prevention/suicide-safety-planning) | +| 13 | Safety-plan status | Give it its own `Draft`, `Current`, and `Review due` lifecycle. The patient confirms the Current version; a clinician records who collaborated and when. It does not require the same senior-clinician approval as the ED Management Plan. | +| 14 | Printing | Provide a dedicated print view with plain language, generous spacing, minimal patient identifiers, last-updated date, personal supports, CMHT contacts, and urgent-help information. Use print CSS and the browser’s print/PDF facility rather than adding a PDF library. | +| 15 | CMHT contact | Store the team name, shared mailbox, duty phone, operating hours, named care coordinator when appropriate, and after-hours pathway. The email action opens a blank `mailto:` addressed to the shared mailbox without automatically inserting patient identifiers or clinical information. | +| 16 | Crisis contacts | Present the CMHT first during operating hours and the governed after-hours pathway separately. For the WA prototype, show synthetic service contacts plus clearly sourced MHERL/Rurallink and `000` emergency guidance. MHERL is currently a 24-hour clinical crisis-support service but explicitly not an emergency service. [WA Health](https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/MHERL) | +| 17 | Patient and carer involvement | Record whether the management plan was co-produced, discussed, declined, or completed while the patient was unavailable, plus any consented carer involvement. Never treat non-participation as non-compliance. | +| 18 | Home screen | Keep it search-first: one prominent patient search, then **Recent presentations**, **Plans needing review**, and **Awaiting approval**. Avoid a metric-heavy command dashboard. | +| 19 | Patient workspace | Use four obvious sections: **Overview**, **Management Plan**, **Safety Plan**, and **ED Presentations**. Keep primary contacts and plan status visible across all four. | +| 20 | Prototype interactions | Fully demonstrate patient search, recording a presentation, creating and editing a draft, submitting it, approving a version, viewing history, editing the safety plan, printing it, emailing the CMHT, and handling empty/overdue/conflict/offline states. No outbound message is actually sent. | +| 21 | Visual direction | Extend the repository’s Clinical White/Sky Graphite system, shared controls, restrained surfaces, semantic state labels, 48px targets, dark mode, forced colours, reduced motion, keyboard operation, and safe 320px/390px phone layouts. | +| 22 | Success criteria | A clinician finds the Current Plan within 10 seconds, understands the recommended ED approach within 30 seconds, records a presentation within two minutes, and reaches the printable Safety Plan within three actions. | + +Reply **“accept all”** if these defaults are right, or list only the numbers you want changed. + +--- + +## Turn `01a01fc4-0d00-7330-a4ce-b6fb5a7ea365` + +### User + +Identification ruleDemonstrate a transparent service-configured threshold of **four ED presentations in a rolling 12 months**, plus manual referral. Display “Review suggested,” never an automatic diagnosis, risk label, or mandatory plan. Comparable programs use different locally determined thresholds, so this must remain configurable. [NSW ACI](https://aci.health.nsw.gov.au/ie/projects/integrating-care-in-goulburn) + +please leave this open for further review in regards to the number... + +otherwise... what are other useful features to add or considerations? + +### Assistant commentary + +I’ll leave the presentation-count threshold explicitly **unresolved and service-configurable**—the prototype can demonstrate the mechanism without asserting a number. + +I’m using the brainstorming discipline to separate genuinely useful additions from attractive feature creep. I’m skipping the business-growth idea engine here because viral, GEO, and SaaS scoring do not serve a clinical ED workflow. + +### Assistant final + +The numeric identification threshold is now **open for governance review**. I won’t place “four presentations” into the specification, fixtures, or interface as an approved rule. + +Until a threshold is decided, the app should show objective activity—such as “7 ED presentations in 12 months”—and let an authorised clinician manually add someone to the review worklist. No automatic “frequent presenter” label is needed. + +## Recommended first-release additions + +1. **Plan at a glance** + + Put the Current Plan’s most immediately useful information above the fold: + + - Status, version, approval date, review date, and owner. + - Preferred engagement approach. + - What usually helps. + - Important considerations for this presentation. + - CMHT and after-hours contacts. + - A clear warning that every presentation requires fresh assessment. + +2. **Start presentation workflow** + + A prominent “Record ED presentation” action should create a concise episode linked to the plan version available at that moment. The clinician can later add the indication, outcome, disposition, CMHT contact outcome, and any deviation from the plan. + +3. **Plan-use feedback** + + At the end of each presentation, ask: + + - Was the Current Plan available? + - Was it used? + - Was it helpful? + - Was anything inappropriate or outdated? + - Does the plan need review? + + This creates a useful learning loop. Australian safety standards recommend monitoring whether care plans remain effective and revising them when circumstances change. [Australian Commission on Safety and Quality in Health Care](https://www.safetyandquality.gov.au/standards/nsqhs-standards/comprehensive-care-standard/clinical-governance-and-quality-improvement-support-comprehensive-care) + +4. **Version comparison** + + Before approving a replacement, show a clear “What changed?” comparison. Highlight added, removed, and modified guidance, the reason for revision, contributors, and approver. The previous Current Plan remains usable until approval is complete. + +5. **Review triggers** + + In addition to a review date, suggest review after: + + - A clinician reports that the plan was ineffective. + - A materially different presentation or outcome. + - Psychiatric admission or significant discharge change. + - Change of CMHT or care coordinator. + - Change in patient preferences or Personal Safety Plan. + - Repeated deviations from the plan. + +6. **Patient voice and communication preferences** + + Add a concise patient-authored area for: + + - How I prefer staff to communicate with me. + - What helps me feel safe and heard. + - What may increase my distress. + - Interpreter, accessibility, sensory, cultural, or support-person needs. + - Preferred name and pronouns. + + Care planning should capture patient goals, preferences, and nominated support people through shared decision-making. [Australian Commission](https://www.safetyandquality.gov.au/publications-and-resources/resource-library/components-comprehensive-care-plan-information-clinicians) + +7. **Cultural safety and nominated supports** + + Allow patients to nominate family, carers, Aboriginal Liaison services, interpreters, peer workers, or other supports—with the applicable consent recorded. Cultural connection and support involvement should be treated as part of care, not an optional demographic note. [Australian Commission](https://www.safetyandquality.gov.au/clinical-care-standards/psychotropic-medicines-cognitive-disability-or-impairment) + +8. **Verified contact details** + + Every CMHT contact should show: + + - Team and care coordinator. + - Shared mailbox and duty phone. + - Operating hours. + - After-hours pathway. + - Last verified date. + - “Contact details may be stale” state. + + The email action should never automatically insert patient identifiers or clinical information. + +9. **Safe unavailable and stale states** + + Explicitly design for: + + - No management plan. + - Draft only. + - Current Plan under review. + - Review overdue. + - Plan withdrawn. + - Identity uncertainty. + - Temporary offline/unavailable record. + - Conflicting concurrent draft. + - CMHT details not verified. + + The interface must never quietly substitute a draft, superseded plan, or empty state for a Current Plan. + +10. **Correction and audit trail** + + Presentation entries should not be silently overwritten. Corrections become signed amendments. The timeline should record creation, submission, approval, review, withdrawal, printing, and contact actions without pretending that opening an email means it was sent. + +11. **Case-review workspace** + + Add a compact review screen for multidisciplinary meetings: + + - Plan summary. + - Recent presentation pattern and outcomes. + - Feedback on whether the plan helped. + - Open review questions. + - Participants and patient/carer involvement. + - Decision and assigned actions. + +12. **Privacy-aware printing** + + Alongside the patient-facing Safety Plan printout, optionally support a controlled clinician summary with: + + - Minimum necessary identifiers. + - Printed-at timestamp and version. + - “Check electronic record for current version.” + - Confidential-document footer. + - No presentation-history narrative unless explicitly requested. + + Health information is sensitive information and requires careful collection, disclosure, and handling. [OAIC](https://www.oaic.gov.au/privacy/privacy-guidance-for-organisations-and-government-agencies/health-service-providers/guide-to-health-privacy/introduction-and-key-concepts) + +## Useful governance surface + +Include a small **Governance** page showing: + +- Identification threshold: “Pending local approval.” +- Manual referral remains available. +- Who can author and approve plans. +- Review and withdrawal rules. +- Permitted uses of presentation data. +- Explicitly prohibited uses, including punitive utilisation management. +- Synthetic-prototype and non-production status. + +## Features I recommend deferring + +These would make the first version less safe or less focused: + +- Predictive risk scoring or AI-generated patient labels. +- Automatic plan creation from presentation counts. +- Automated clinical recommendations. +- Medication ordering or a parallel medication list. +- Automatic emails containing patient information. +- General staff chat or an inbox. +- Real EDIS, EMR, PAS, PSOLIS, or CMHT integration. +- Patient portal editing. +- Performance targets based purely on reducing ED attendance. +- Complex population analytics. + +The strongest first version is therefore a search-first continuity workspace with a Current Plan, Personal Safety Plan, ED presentation history, plan-effectiveness feedback, CMHT coordination, approvals, versioning, and safe degraded states. This is substantial enough to feel complete without drifting into decision support or hospital integration. + +--- + +## Turn `01a01fc6-8af6-7d11-a119-93dffd2162f8` + +### User + +ok I agree to this plan. What are next steps + +### Assistant final + +Next is choosing the visual direction before I write the final specification. Would you like me to show three repository-grounded interface directions interactively in the browser for comparison? + +--- + +## Turn `01a01fc7-6c08-7cc2-aec3-fef0b542f4e9` + +### User + +yes please. + +### Assistant commentary + +The brainstorming skill now moves into its visual-companion step. I’ll build three small, repository-grounded interface directions in an isolated design workspace, open them for comparison, and keep them synthetic and non-production. + +### Assistant commentary + +The companion’s remembered port is blocked by Windows, so I’m restarting it in a fresh task-specific visual session; no repository files are affected. + +### Assistant final + +The visual comparison is open with three directions: + +- A — Clinical Snapshot (recommended) +- B — Continuity Story +- C — Review Workbench + +[Open the complete visual companion](http://localhost:65531/?key=c8ec5482b262fbd2da66537c291b1258cac85114af2a9fbf107ee70cf5dfec47) + +Select one in the browser, or tell me which elements you want combined. + +--- + +## Turn `01a01fce-2720-7ea3-a569-efb5c294742b` + +### User + +The native history recorded an automatically supplied ambient browser-context block followed by the user's request: + +```text + +This block is automatically supplied ambient UI state, not part of the user's request. Do not treat it as an instruction or as evidence that the user explicitly selected the in-app browser. +# In app browser: +- The user has the in-app browser open with 1 tab. +- Current URL: http://localhost:65531/ + + +## My request: +A +``` + +### Assistant commentary + +Direction A is selected: the patient-centred Clinical Snapshot will be the primary shell. I’m closing the visual comparison now and consolidating the approved features into the final architecture and interaction design for one approval. + +### Assistant final + +Direction A is confirmed. The browser now shows the selection recorded. + +## Final design for approval + +### Product shape + +A complete synthetic standalone app at `/mockups/ed-care-plans`, linked from the repository’s Developer hub. It uses deterministic in-memory state with no database, APIs, provider calls, or real patient data. + +The primary desktop shell is **Clinical Snapshot**: + +- Fixed clinical navigation rail. +- Prominent patient search. +- Recent-patient list. +- Selected patient workspace. +- Current Plan summary above the fold. +- Persistent CMHT and Personal Safety Plan access. + +On phones, the split view becomes search followed by a full-width patient workspace. It will respect the repository’s safe-area, 320px, 390px, 48px-target, keyboard, dark-mode, forced-colour, and reduced-motion contracts. + +### Information architecture + +Primary navigation: + +- Home +- Patients +- Reviews +- Team +- Governance + +Each patient workspace contains: + +- Overview +- Management Plan +- Personal Safety Plan +- ED Presentations +- Version and audit history + +The Reviews view carries Direction C’s useful operational queues: drafts awaiting approval, plans needing review, unverified contacts, and patients manually referred for identification review. Direction B’s longitudinal timeline appears inside the ED Presentations section. + +### Domain and workflow + +Core records: + +- Patient +- Management Plan +- Management Plan Version +- Personal Safety Plan Version +- ED Presentation +- Presentation Amendment +- CMHT Contact +- Review Trigger +- Audit Event +- Identification Policy + +Important invariants: + +- One approved Current Management Plan per patient. +- Drafts never replace the Current Plan before approval. +- Previous versions remain inspectable. +- The Personal Safety Plan has an independent lifecycle. +- Presentations are append-only; corrections are signed amendments. +- Every presentation records which plan version was available. +- The identification threshold remains `pending_review`; no number is encoded. +- Raw presentation counts remain visible, with authorised manual referral to the review worklist. + +Primary journeys include searching for a patient, recording a presentation, creating and submitting a revised plan, reviewing changes, approving it, recording plan effectiveness, updating and printing the Personal Safety Plan, and opening CMHT email or telephone actions. + +### Safety and degraded states + +Every patient workspace states that the plan supports continuity but never replaces fresh triage, physical assessment, mental-state assessment, or immediate risk assessment. + +The prototype explicitly demonstrates: + +- No plan. +- Draft only. +- Review overdue. +- Withdrawn or superseded plan. +- Conflicting draft. +- Identity uncertainty. +- Unverified CMHT details. +- Offline or unavailable record. +- Presentation amendment. +- Patient unavailable or declining participation. + +Email links contain no patient identifiers or clinical content. Printing uses minimum necessary identifiers, timestamps, version information, and a warning to check the electronic record for the current version. + +### Architecture and verification + +The implementation will use focused route components, a typed synthetic domain model, a pure reducer/state provider, repository design-system components, and small page-specific CSS modules. No new dependency or parallel search/navigation framework is planned. + +Verification will cover: + +- Reducer invariants and refused transitions. +- Search and patient selection. +- Draft, submission, approval, supersession, and comparison. +- Presentation recording and amendment. +- Plan-effectiveness feedback. +- Safety-plan editing and print layout. +- CMHT `mailto:` safety. +- Synthetic-data isolation and prohibited overclaiming. +- Route reachability and button wiring. +- Desktop, 390px, and 320px journeys. +- Keyboard, focus, dark mode, forced colours, reduced motion, and print. +- Focused build and browser evidence, with broader checks selected according to the final diff. + +If you approve this final design, I’ll write the repository design specification next, self-review it, and return it for your final written-spec approval before creating the executable implementation plan. + +--- + +## Turn `01a01fd1-7954-7011-9de8-70453ca75d2a` + +### User + +ok approve + +### Assistant commentary + +Design approved. I’m using the Superpowers worktree skill now because the current checkout contains unrelated work; the written specification will go into a dedicated `codex/ed-care-plans` worktree without touching those changes. Repository policy prevents an unrequested commit, so I’ll leave the spec as a reviewable local diff. + +### Assistant commentary + +The isolated worktree is ready at `D:\Worktrees\Database\ed-care-plans` on `codex/ed-care-plans`, based on current `origin/main`. I’m now writing the approved design specification and its domain glossary; no implementation code is being touched yet. + +### Assistant final + +The written design is ready for review: + +- [ED Care Plans design specification](/D:/Worktrees/Database/ed-care-plans/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md) +- [ED Care Plans domain glossary](/D:/Worktrees/Database/ed-care-plans/docs/ed-care-plans-context.md) + +Self-review passed: + +- No placeholders or unresolved implementation ambiguity. +- No numeric identification threshold encoded. +- Prettier check passed. +- `git diff --check` passed. +- No implementation, tests, build, commit, push, or API work performed. + +Work remains isolated on `codex/ed-care-plans`. + +Do you approve the written specification as the authority for implementation planning? + +--- + +## Turn `01a01fd8-8753-7673-920c-e2de1db81a38` + +### User + +I approve. Now what ? + +### Assistant commentary + +I’m using the `writing-plans` skill now to turn the approved specification into an exact, task-by-task implementation plan—named files, interfaces, test-first steps, commands, and review gates. After you approve that plan, I’ll execute it in this worktree using the requested subagent-driven development loop. + +### Assistant commentary + +Your approval closes the design gate. I’m turning the approved specification into a file-by-file implementation plan now; after that, your requested subagent-driven build starts in the isolated `codex/ed-care-plans` worktree. Plan effort: high; build effort: medium–high, because the clinical workflow and approval/audit states need precise contracts before parallel implementation. + +### Assistant commentary + +The repository inspection confirms the right precedent: Caring Contact already supplies the gated mockup route pattern, in-memory reducer architecture, direct-link reconstruction, and browser-test wrappers. I’m preserving that architecture while defining ED Care Plans’ own clinical state model and route family; the configurable identification threshold remains governance-pending with no number encoded. + +### Assistant commentary + +I’m also applying the repository’s UI, clinical-safety, and focused-test skills because this is a patient-safety-sensitive interactive prototype. They add three concrete gates to the plan: repository design-sweep evidence before browser QA, deterministic clinical/privacy proofs, and wrapper-based focused testing only—no live providers or direct Playwright commands. + +### Assistant commentary + +Your requested Superpowers development mode now determines the build handoff: each implementation task will be test-first, reviewed against the approved spec, then quality-reviewed before the next task. I’m loading those exact execution contracts before I freeze the task briefs. + +### Assistant commentary + +One implementation detail needs fresh authoritative data before I lock the fixtures: the printed Safety Plan’s WA crisis contacts. I’m rechecking the official WA Health page so the plan names exact public numbers and caveats, while keeping all patient/service fixtures fictional. + +### Assistant commentary + +The task map is now fixed at nine reviewed tranches: domain/fixtures, lifecycle reducer, gated route shell, Clinical Snapshot/search, Management Plan workflow, ED Presentations, Personal Safety Plan/print, operational queues/degraded states, and browser/handoff verification. I’m writing those as executable briefs with exact tests and file ownership. + +### Assistant commentary + +The implementation plan is written. I’m doing the required main-agent self-review now: specification coverage, task/interface consistency, unfinished-marker scan, formatting, and the deliberate no-threshold/no-provider boundaries. + +### Assistant final + +The build is ready to start. I created and self-reviewed the nine-task [implementation plan](D:/Worktrees/Database/ed-care-plans/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md), based on the approved [design specification](D:/Worktrees/Database/ed-care-plans/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md) and [domain glossary](D:/Worktrees/Database/ed-care-plans/docs/ed-care-plans-context.md). + +It includes current official [MHERL](https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/MHERL) and [Rurallink](https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/Rurallink) details for the printable Safety Plan. + +One explicit authorization is needed: may I create local task commits on `codex/ed-care-plans` so the requested subagent-driven workflow can checkpoint and review each task? This would not authorize pushing, opening a PR, deploying, API access, or production changes. + +--- + +## Turn `01a01ff4-47ab-71c2-8602-7b939340ca84` + +### User + +ok... I want to build this in claude... Please can you create a detailed handover for building this...Link all the chat... all the text... all the relevant locations, this chat log and everything please and have logs and all the files and be clear for me + +### Assistant commentary + +I’m using the repository’s handover skill to package this as a Claude-ready continuation, including the approved decisions, exact worktree/branch, specifications, implementation plan, verification evidence, source links, and a chronological transcript. I’ll make the handover self-contained and leave all files uncommitted. + +## End of captured conversation + +The active turn continued after this point to create the handover package and its verification evidence. Its final assistant response is not part of the native-history capture above; the durable result is the set of files linked from [`CLAUDE-START-HERE.md`](./CLAUDE-START-HERE.md). diff --git a/docs/care-plan/sdd-ledger.md b/docs/care-plan/sdd-ledger.md new file mode 100644 index 000000000..01d810ce5 --- /dev/null +++ b/docs/care-plan/sdd-ledger.md @@ -0,0 +1,1002 @@ +# Care Plan — SDD ledger + +**Plan:** `docs/superpowers/plans/2026-08-20-care-plan-implementation.md` +**Spec (binding):** `docs/superpowers/specs/2026-08-20-care-plan-design.md` +**Glossary (binding):** `docs/care-plan-context.md` +**Branch:** `claude/ed-care-plans-impl-7f44cd` +**Worktree:** `D:\Worktrees\Database\care-plan-impl` (relocated again 22 Aug 2026 — see Environment hazard) +**Tasks:** 11. Stage A = 1–5 then a mandatory user checkpoint; Stage B = 6–11. + +> **Why this file is tracked rather than git-ignored scratch.** The +> `superpowers:subagent-driven-development` skill puts its ledger at +> `.superpowers/sdd//progress.md`, git-ignored. That directory was destroyed +> along with the whole worktree on 21 August 2026, taking every ruling, brief, +> report and review package with it. The commits survived because they live in +> the object store. This ledger is therefore committed: the recovery map must not +> live somewhere a cleanup can delete. Reconstructed from the controlling +> session's context and the commit history, both of which agree. + +--- + +## Environment hazard — read before doing anything + +`D:\Repos\Database\.claude\worktrees\**` destroyed this task's worktree **three +times on 21 August 2026**, the third time through an explicit `git worktree lock` +and while a task was running. Deletion order observed on the third occasion: the +`.git` pointer file first (making git resolve to the main checkout on the wrong +branch), then a mass delete of 1,420 → 2,388 → 3,836 tracked files over three +minutes. + +- **Nothing committed was ever lost.** Git objects live in `D:\Repos\Database\.git\objects`, + outside any worktree. Every recovery was `git worktree add `. +- **What was lost each time was uncommitted work and git-ignored scratch** — + including, on the third occasion, the entire SDD workspace. +- **Relocation did NOT protect it — there was a fourth destruction.** The work was + moved to `D:\Worktrees\Database\care-plan`, and that copy was destroyed within the + hour by the same method. **No directory on this machine is safe.** Commit and push; + nothing else has ever protected this work. The branch is now on GitHub at + `origin/claude/ed-care-plans-impl-7f44cd`, which is the authoritative copy. +- **Commit at the end of every task, without exception.** That rule is what made + all three recoveries cheap. + +**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 was 124 commits behind `origin/main` and had neither.** That was 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. + +### Resolved 22 August 2026 — the merge landed + +`origin/main` was merged into this branch as `3febc69a4`, with **zero conflicts** — +including in `docs/care-plan/**` and `docs/superpowers/**`, which were expected to +conflict and did not. `git merge-base --is-ancestor` confirms both `a04330ea0` and +`cdfcbaccd` are now ancestors of HEAD, so this worktree runs the hardened +`guard-push.mjs` and the fixed worktree tooling. It was a merge, not a rebase, because +the branch is published. + +Pushed as `f01b8583c..d421bc2dc` (exit 0). Two things about that push are worth knowing. +The **ledger-write guard blocked it as a false positive**: merging `main` necessarily +carries `main`'s own inbox reconciliation onto this branch, and the guard reads that as +this branch introducing ledger rows. `git diff --name-status origin/main...HEAD` over +`docs/outstanding-issues*`, `docs/branch-review-ledger.md` and `docs/reviews` is **empty**, +so the push used the guard's own documented scope, `SKIP_LEDGER_WRITE_GUARD=1`, and nothing +else was skipped. And the **static guard did not run**: it was refused with +`DATABASE_HEAVY_RUN_ADMISSION_BUSY` because another worktree held the heavy lease, so +`lint` and `typecheck` were not run _by the guard_. Typecheck was run separately in this +worktree (exit 0, zero diagnostics); **`lint` has not been run since the merge** and must +not be reported as green until it is. + +**Left on disk, and a live hazard.** The first push attempt was killed at a 10-minute +timeout and left a scratch checkout at +`C:\Users\joshs\AppData\Local\Temp\guard-push-format-6YSFcp` whose `node_modules` is a link +into this worktree's real `node_modules` — the precise arrangement behind all four +destructions. The hardened guard refuses to force-delete it, which is why nothing was +harmed; an old guard in another stale worktree would not. Remove the **link only** +(`rmdir` on the junction, never a recursive delete of the folder), then +`git worktree prune`. Not done here: the sandbox declined the command, correctly. + +The work now lives at `D:\Worktrees\Database\care-plan-impl`. The previous copy at +`D:\Worktrees\Database\care-plan` was left untouched on disk and simply detached from +the branch (`git switch --detach`) so the branch could be checked out in the new +worktree; nothing was deleted to make room. Its `node_modules` had been emptied, which +is the only damage it carried — its tracked tree was clean and byte-identical to +`origin`. + +--- + +## Progress + +| Task | State | Commits | Evidence | +| ---------------------------------- | -------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------- | +| 1. Domain, fixtures, selectors | **complete, review clean** | `8a2e6a6d1..8652e73ff` | 58/58 passing, typecheck clean | +| 2. Reducer, provider, lifecycle | **complete, review clean** | `8652e73ff..def541e6a` | 121/121 passing, typecheck + lint clean, 32 mutations / 32 red suites | +| 3. Routes, gate, shell | **complete, review clean** | `d421bc2dc..bb68ea8da` | 209/209 passing, typecheck + lint clean, 39 mutations / 39 red suites | +| 4. Snapshot, search, contacts | **complete, review clean** | `f2f6389fa..d66e7a38b` | 232/232 passing, typecheck + lint clean, 62 mutations / 62 red suites | +| 5. Plan reading, boundary, print | **complete, review clean** | `9bab6ad44..90c1d01a3` | 291/291 passing + Therapy Compass 25/25, typecheck + lint clean, 61 mutations / 61 red | +| 6. Authoring, approval, withdrawal | **complete, review clean** | `f0ab0d41b..2113fe97f` | 306/306 passing + form-field consumers 161/161, typecheck + lint clean, 28 mutations / 27 killed + 1 equivalent | +| 7. ED presentations, amendments | **complete, review clean** | `e02a03ab9..a8a8264be` | 335/335 passing, typecheck + lint clean, 40 mutations / 40 killed | +| 8. Personal Safety Plan, print | **complete, review clean** | `5e8f812a7..79cfa97b3` | 369/369 passing, typecheck + lint clean, 58 mutations / 58 killed | +| 9–11 | not started | — | — | + +Stage A is Tasks 1–5. The plan makes Task 5 a mandatory stop for user review; **the user +lifted that stop on 22 August 2026**, instructing the session to report at the boundary and +continue into Stage B without waiting. Recorded here because the plan's own text still says +otherwise. + +### Stage A checkpoint — run 22 August 2026 + +| Check | Result | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Full Stage A test set | `Test Files 6 passed (6)` / `Tests 291 passed (291)` — after one lease refusal, retried until acquired | +| `npm run typecheck` | exit 0, zero diagnostics | +| Local server identity | `/api/local-project-id` returns `clinical-kb:4573c0c0381a` — this project, on the printed port | +| All 21 routes over HTTP | every one 200 | +| Unknown patient parameter | reaches `notFound()` (`NEXT_HTTP_ERROR_FALLBACK;404` in the payload) and leaks **zero** occurrences of another patient's name or MRN | +| Server-rendered DOM | correct: rail, both synthetic markers, search, three patients rendering three genuinely distinct plan states (`Current version 2`, `No plan in use`, `Withdrawn`), identity band, patient sections | +| **Browser visual proof** | **NOT OBTAINED — see below** | + +**The browser walk could not be performed, and nothing about rendering should be inferred +from the attempt.** The browser pane in this session does not composite frames, so every +screenshot timed out and the accessibility tree came back empty even on the production app +at `/`. The DOM was readable, which is where the server-rendered evidence above comes from, +but no pixel was ever painted, nothing was clicked, and no viewport was resized. Phone +layout at 320 px and 390 px, dark mode, forced colours, reduced motion, focus rings and +print preview all remain **unverified**, exactly as the plan always intended for Task 11. + +One observation from that attempt needs recording so a later session does not re-diagnose +it as a Care Plan defect: the DOM showed two `
` landmarks, the Suspense fallback +visible at full height and the real content inside a `display:none` container with no React +fiber attached — i.e. never hydrated. **The pre-existing, shipped Caring Contacts prototype +reproduced this identically**, and so did the main application at `/`. It is therefore a +property of this non-compositing browser session, not of Care Plan, and not a regression +introduced by Tasks 3–5. It is also not evidence that the prototype hydrates correctly — +that question is simply still open, and Task 11's Chromium journeys are what will answer it. + +### Task 1 — complete + +- Dispatched opus. Returned `DONE_WITH_CONCERNS`; three pre-review fix rounds, then + task review (opus) returned Spec ❌ / Changes needed, then two review fix rounds. +- Final: 58/58 passing, typecheck clean. +- Worktree destroyed twice during this task; implementer recovered it once itself. + `task-1-brief.md` was destroyed and restored from transcript; the controller + regenerated it with `scripts/task-brief` and diffed — the restoration matched exactly. + +### Task 2 — complete + +- Dispatched opus. Returned `DONE_WITH_CONCERNS`; one pre-review fix round, then + task review (opus) returned Spec ❌ / Changes needed, then one review fix round. +- Final: 121/121 passing across both test files, typecheck exit 0, lint exit 0. +- Implementer proved every refusal guard with deliberate mutations: 32 mutations, + 32 red suites, including both-directions proof for the participation trigger and + three-way proof (too wide / too narrow / wrong guard) for the print exemption. + +### Task 3 — complete + +The third worktree destruction had blocked it; no partial work existed and there was +nothing to reconcile, so it restarted from the brief unchanged on 22 August 2026 with +BASE `d421bc2dc`. + +- Dispatched opus. Returned `DONE_WITH_CONCERNS` at `9d3a104da` (39 files, 202 tests). + Task review (opus) returned Spec ❌ / Needs fixes with four Important findings and no + Critical. One fix round (`bb68ea8da`), then a scoped re-review (sonnet) verdicted all + four ADDRESSED with no new breakage. +- Final: `Test Files 7 passed (7)` / `Tests 209 passed (209)`; typecheck and lint both + re-run with `GATE_RECEIPTS=refresh`, so neither is a reused receipt. Byte scan of 10 + touched files: zero CR or control-byte offenders. +- Guards proved by mutation: 31 in the first pass, 8 more across the fix round, every one + red then reverted. +- The three findings that were real were all guard or safety defects rather than + structure: printing any route stripped the only `fictional data only` marker off the + page, route-change focus was keyed on the heading text so moving between two patients + with the same heading announced nothing, and half the developer-index guard was + satisfied by pre-existing text in the file it read — this project's third + guard-that-cannot-fail. The structural spec, all 21 routes, was clean first time. +- Commits `d421bc2dc..bb68ea8da`, review clean, pushed. + +**Not proven by Task 3, and must not be claimed:** no browser or Playwright journey, no +responsive/accessibility/print observation at real viewports, no production build, so the +`notFound()` path's production 404 status is unverified (dev returns HTTP 200 with the +`NEXT_HTTP_ERROR_FALLBACK;404` digest). Print correctness is asserted structurally — the +marker has no `data-print-hide` ancestor — not visually. + +### Task 4 — complete + +- Dispatched opus. Returned `DONE_WITH_CONCERNS` at `0c9b32c64` (10 files, 219 tests). Task + review (opus) returned Spec ❌ / Needs fixes: three Important, no Critical, thirteen Minor. + Fix round 1 (`773da7bf3`) closed all three Importants plus three Minors and the scoped + re-review verdicted all seven ADDRESSED — but surfaced one new Important **in the fix + diff itself**, so it joined the open list rather than deferring. Fix round 2 + (`d66e7a38b`) closed it and its re-review found no new breakage. +- Final: `Test Files 4 passed (4)` / `Tests 232 passed (232)`; typecheck and lint both + freshly recorded, not reused receipts. One typecheck attempt was refused with + `DATABASE_HEAVY_RUN_ADMISSION_BUSY` and retried rather than reported — correct handling. +- Guards proved by mutation across the task: 62, every one red then reverted. +- Commits `f2f6389fa..d66e7a38b`, review clean, pushed. + +**Three interruptions, no loss.** The implementer was killed mid-round three times by +transient `529 Overloaded` API errors. Each time the worktree was clean at the last commit, +and resuming the same agent restored its context intact. This is the third distinct way this +task has been interrupted — worktree destruction, run-coordinator refusal, and now provider +capacity — and the same habit covered all three: commit at the end of every unit, push, and +never rush a half-built commit to protect work. + +**The finding that needed two rounds is worth understanding.** Selecting a patient moved no +focus, so fix round 1 added a focus move. That fix could then fire on a genuine route change +— and was invisible, because the shell is an ancestor whose pathname-keyed effect commits +last and silently repairs whatever a descendant did to focus. No final-state assertion in +this component can fail. Only a `focusin` event-order test can see it, and the implementer +discovered this the hard way when two of its own positive controls survived. Fix round 2 +keyed the effect on the previous **variant** rather than a boolean that collapsed two of the +three variants together; the re-reviewer verified the two effects are now mutually exclusive +by construction rather than by test. + +### Task 5 — complete. Stage A closes here. + +- Dispatched opus. Returned `DONE_WITH_CONCERNS` at `891f4bff4`. Task review (opus) returned + **Spec ✅ on every enumerated constraint** — the first task to do so — with two Important + findings that were the same defect, and five small ones. One fix round (`90c1d01a3`), and + the scoped re-review verdicted all seven ADDRESSED with no new breakage. +- Final: `Test Files 6 passed (6)` / `Tests 291 passed (291)`, plus the Therapy Compass + regression at `Test Files 3 passed (3)` / `Tests 25 passed (25)`. Typecheck and lint fresh + under `GATE_RECEIPTS=refresh`. 61 mutations across the task, all killed. +- The shared primitive held. `src/components/ui/print-output.tsx` gained four capabilities, + every one additive and default-off; the reviewer verified both Therapy Compass consumers + emit identical DOM, and a `container.innerHTML` byte-for-byte pin now fails on any future + non-default-off addition — a stronger guard than any per-prop assertion. +- **A second print defect of the Task 3 kind was found and closed.** The shared print rule + makes everything outside `[data-print-output]` invisible, so on a `PrintOutput` route the + shell header's synthetic marker never reached the paper. The printed subtree now carries + its own marker. Twice now, a print change has silently removed the one line telling a + reader the document is fictional; any future print work should assume it will happen again. + +**Not proven by Stage A, and must not be claimed:** nothing has rendered in a print medium. +Pagination, cascade order against Tailwind utilities, whether the monochrome rule actually +wins, and real print preview are all Task 11 work. No Chromium journey, no accessibility +audit, no responsive observation at 320 px or 390 px, no production build, and no +provider-backed gate has run for this feature. + +### Task 6 — complete. Stage B opens here. + +- Dispatched opus. Returned `DONE_WITH_CONCERNS` at `01f386ace`. Task review (opus) returned + Spec ❌ with **two Critical**, three Important and several Minor. Two fix rounds + (`65f930994`, `2113fe97f`); the scoped re-review verdicted every finding ADDRESSED with no + new breakage, and confirmed each new guard would catch its original defect if reintroduced. +- Final: `Test Files 4 passed (4)` / `Tests 306 passed (306)`, plus `form-field.tsx`'s other + consumers at 161/161. Typecheck and lint clean. 28 mutations, 27 killed and one honestly + reported as **equivalent rather than killed**. +- The whole offline suite ran at 2 failed / 8036 passed. Both failures are `gate-receipts` + file-mode tests, environmental on this Dev Drive (`fs.chmodSync` reports + `mode before: 100666 after: 100666 changed: false`) and untouched by a diff that goes + nowhere near `scripts/` or `.githooks/`. Recorded as red, not rounded to green. + +**Both Critical findings reached the user and neither was visible to any gate.** The first was +a stylesheet block inserted between a selector list's comma and its continuation, which +silently rebound `.pinnedBoundaryLink` — the jump link inside the pinned safety boundary — so +it lost its colour, weight and underline and rendered as plain text with no affordance at all. +Three existing guards all passed it: Vitest runs `css: false` so no DOM test can see styling; +the protected-selector guard inspects declarations only for _suppression_, so a rebinding is +invisible to it; and the `.appRoot` scoping test splits on the comma and finds both halves +correctly scoped. The second told an approving senior clinician that **nothing had changed in +any section of a patient's first plan**, comparing the proposed content against itself under +the heading `Comparing Current version 0`. Reachable today on `SYN-PATIENT-005`. + +**The implementer caught the sixth guard-that-cannot-fail itself**, which is the first time +that has happened on this project rather than a reviewer finding it. Its scenario-sync test +navigated only the pathname, leaving every effect dependency untouched, so the effect never +re-ran and the test would have passed whatever the guard did. + +### Task 7 — complete + +- Dispatched opus. Returned `DONE_WITH_CONCERNS` at `2be157ff1` (12 files, +2288/−18, 332 + tests, 34 mutations all killed). Task review (opus) returned Spec ❌ with **no Critical**, + three Important and a long Minor list. One fix round (`a8a8264be`); the scoped re-review + verdicted every item ADDRESSED with no new breakage and no test loosened to accommodate the + fixture change. +- Final: `Test Files 4 passed (4)` / `Tests 335 passed (335)`. Typecheck and lint fresh under + `GATE_RECEIPTS=refresh`, Prettier clean, all staged blobs free of CR, control bytes, NBSP + and BOM. + +**The finding that mattered most was clinical, not structural.** A correction that changed two +fields at once could be **partially applied while the banner reported success**: clearing the +one-line note and changing plan helpfulness in the same correction refused the note (it +precedes helpfulness in field order), appended the helpfulness amendment, closed the sheet, and +overwrote the refusal in `lastOutcome` with the success message. On a record whose entire +purpose is that corrections are visible and attributed, a correction discarded with no trace is +the worst available outcome. It now refuses the whole save before dispatching anything, names +the offending field, and leaves the sheet open holding the clinician's typed work. + +**The seventh guard-that-cannot-fail.** The task legitimately relocated the episode page's +parameter check — the old server guard would have 404'd every episode recorded in a live +session — but the test holding it asserted an alternation, `isSyntheticPatientId` **or** +`isSyntheticPresentationForPatient`, which the patient half satisfied alone. Deleting the +presentation check entirely left the suite green. It is now checked per parameter, by name, +with a fail-closed count of how many pages carry `[presentationId]`. + +### Task 8 — complete + +- Dispatched opus. Returned `DONE_WITH_CONCERNS` at `87b28b3ac` (365 tests, 44 mutations all + killed). Task review (opus) returned Spec ❌ with **no Critical** and **one Important** — + but that one was the most consequential finding in the whole build. One fix round + (`79cfa97b3`); the scoped re-review verdicted every item ADDRESSED with no new breakage. +- Final: `Test Files 4 passed (4)` / `Tests 369 passed (369)`. Typecheck and lint fresh, + Prettier clean, 58 mutations across the task, all killed. + +**The printed patient copy handed a person a list of blanks about their own life.** The print +surface reused the reading surface's section component, which renders `Not recorded` for an +empty section. `SYN-SAFETY-VERSION-004` is a genuinely current plan whose owner declined to +write one in her own words — all six patient-voice arrays empty, holding only crisis numbers. +Printing it produced a sheet addressed to her reading `Last confirmed — Not recorded`, then +"This is your plan, in your own words.", then six headings each answered `Not recorded`, +including **`My reasons for living — Not recorded`**. + +Nothing was broken. `Not recorded` is correct on the clinician's reading surface, and the print +surface simply inherited it. No test had ever printed that patient — every print test used +`SYN-PATIENT-001`. It took a reviewer reading the page as its recipient rather than as an +engineer. The printed copy now omits an empty section entirely rather than printing its heading +over a blank, suppresses the own-words claim and the `Last confirmed` row when there is nothing +to claim, and keeps `Not recorded` unchanged for the clinician — with a control proving the fix +cannot be re-applied to the reading surface and quietly remove that signal. + +**Two more guards that could not fail, both found by the implementer's own controls.** That +makes nine on this project, and these two were a **new shape**: assertions that read their +expected value from the same constant the component renders from, so they can never disagree +with what is on screen. One test for "the declined label must not deny the plan exists" compared +the rendered text against `PATIENT_CONFIRMATION_LABEL.declined` — which passes for _any_ label, +including the defective one it was written to catch. Repaired with a literal forbidden-phrasing +regex applied across all four labels and all four explanations. + +--- + +## Rulings + +Every decision taken on the user's behalf. Each says what was decided, why, and +what it costs if wrong. + +### Pre-flight scan (before Task 1) + +The scan compared every pair of tasks sharing a file or interface, and each task's +text against itself. Six conflicts found, six ruled. + +1. **Task 1 creates every type in the Canonical Interfaces block, including the + patient-plan entities.** Both Task 1 and Task 9 were told to create them; the + block's own sentence assigns them to Task 1, and two tasks editing the same + symbols is how parallel definitions get born. _Cost if wrong:_ Task 1's diff is + larger than its checklist implies. +2. **Task 9 owns all patient-plan fixture data**, including `syntheticResources`. + Task 1 creates the types only; Task 2 seeds `patientPlans: []`, + `patientPlanVersions: []`, `patientResources: []`. Authoring that clinical + content in Task 1 would bury it in a task nobody reads for it. _Cost if wrong:_ + Task 9 carries fixture authoring as well as logic. +3. **Each action joins `CarePlanPrototypeAction` in the task that implements it.** + Task 2 defines only its own; Task 5 adds `record-management-plan-print-intent`, + Task 6 adds `record-plan-shared-with-patient`, Task 9 adds the four patient-plan + actions. The exhaustive switch stays exhaustive at every commit. _Cost if wrong:_ + three later tasks touch two shared files. +4. **Task 1 creates `BANNED_ADMISSION_CONSTRUCTIONS` in `domain.ts`** plus the + fixture scan. It is a fixture-language rule and Task 1 owns fixture language; + Task 6 imports it for form validation. _Cost if wrong:_ the constant exists one + task before its first UI consumer. +5. **Task 4 creates `CurrentPlanSummary` and `PinnedSafetyBoundary` in + `prototype-ui.tsx`; Task 5 consumes them.** The spec requires the pinned safety + boundary above all plan content wherever plan content appears, which includes + the Clinical Snapshot, so one shared component is the only way both surfaces stay + correct. _Cost if wrong:_ a component is built one task early, against two + drifting copies of a safety-critical element. +6. **Task 5 may extend `src/components/ui/print-output.tsx` only additively and + default-off**, must not change rendering for any existing consumer, must carry a + focused test per capability, and must run the Therapy Compass print tests before + committing. _Cost if wrong:_ a shared primitive regresses two Therapy Compass + screens; the required test run is the guard. + +### Task 1 + +7. **The mailto subject kept the old product name — plan defect, fixed.** The + `ED Care Plans` → `Care Plan` rename replaced the spaced and hyphenated forms but + not the URL-encoded `ED+Care+Plans`, so the brief pinned a stale string in a + verbatim test and the implementer correctly followed it. _Cost if wrong:_ none. +8. **`PresentationAmendment.field` widens to `AmendableField` — spec is binding.** + The grilling round widened the amendable set to disposition, assessment outcome, + the one-line account and the three plan-use answers, but the canonical type still + allowed only the first two, so the plan contradicted its own spec. Six values; + `originalValue`/`replacementValue` stay `string`; the reducer validates that a + disposition replacement parses. The plan-use answers group in the UI but each + changed answer records its own attributed amendment. _Cost if wrong:_ Task 7's + amendment sheet is richer than needed. +9. **Fixture source URLs use organisation roots where no official deep link + exists — accepted, not a defect.** The implementer declined to invent slugs + offline, which was right. The controller supplied the verified MHERL and + Rurallink deep links; `000` stays an organisation root. _Cost if wrong:_ a reader + navigates one extra step. +10. **The `0491 570 006`–`156` block is the ACMA range reserved for fiction and must + not be "fixed".** Recorded in the plan so no later task changes it to a + real-looking but allocatable number. _Cost if wrong:_ none identified. +11. **The rename over-reached and rewrote real identifiers — corrected.** It also + rewrote the worktree path, this branch name, the Codex planning worktree path + and `codex/ed-care-plans`, so the plan's first Global Constraint pointed a future + implementer at a directory that does not exist. Restored, and every `src/`, + `tests/` and `scripts/` path named in the plan and spec was swept against the + filesystem — all present except the two SDD skill scripts, which correctly live + in the skill directory. _Cost if wrong:_ none — verified against the filesystem. +12. **Ruling 10 was right about the range and wrong to assume the fixtures were + inside it — renumbered.** The implementer checked and found `0491 570 210`–`222`, + which sit **above** the reserved span and are ordinary allocatable numbers. These + print onto a patient-facing safety plan. Reallocated to `101/102`, `111/112`, + `121/122`. _Cost if wrong:_ none — the range is conservative either way. +13. **The fixture also contradicted a value the plan pins three tasks ahead.** + Task 4's example test pins North River CMHT at `tel:+61491570101`; the fixture had + `0491 570 210`, so Task 4 would have failed against Task 1's data. The renumbering + resolved both defects at once. Required a numeric range assertion rather than a + literal list, so a seventh number added at Task 8 or 9 cannot sit outside the span + unnoticed. _Cost if wrong:_ none identified. +14. **The renumbering table was wrong to cover the after-hours numbers — reversing + my own instruction.** The implementer flagged that swapping fictional mobiles into + `afterHours*` while keeping real crisis-service names would print a fictional + number under MHERL's name. Its reasoning was right; the instruction was the defect. + A duty line belongs to a fictional team and must be fictional; the after-hours + pathway **is** the real public crisis service, and it prints on the patient-facing + safety plan under "who to call at 2am", so a demo reader who dials it must reach a + real service. Restored MHERL Perth `1300 555 788` (North River), MHERL Peel + `1800 676 822` (Coastal Plains), Rurallink `1800 552 002` (Wandoo), keeping the + not-an-emergency-service caveats and Rurallink's hours. Range test became a numeric + check for fictional mobiles plus an explicit four-entry allowlist for the public + lines. _Cost if wrong:_ a fictional directory contains four real public numbers, + which the spec explicitly permits as the only intentional non-fictional fixtures. +15. **Stored `reviewState` removed from both version types; review state is always + derived.** `ManagementPlanVersion.reviewState` and + `PersonalSafetyPlanVersion.reviewState` restated what + `deriveReviewState(reviewDueAt, now)` computes and could drift, and the + consistency test covered only Management Plan current versions. Removed the class + rather than adding the missing test, since nothing consumed the field yet. _Cost + if wrong:_ Tasks 5 and 8 call `deriveReviewState` at render — a trivial call, + against a stored indicator that could tell a clinician a plan is current when its + date says otherwise. +16. **`deriveReviewState` must fail conservatively.** It returned `within_review` for + an unparseable date because both comparisons were false — a malformed value + silently producing the most reassuring state on a clinical currency indicator. + This repo's standing rule is that failure degrades conservatively rather than + guessing, so it returns `overdue`. _Cost if wrong:_ a plan shows as overdue when + it is not, which is the safe direction. +17. **The numeric review trigger is compliant but reworded anyway.** "Two or more + presentations where the plan was recorded as not helpful" prompts review of an + existing plan rather than creating eligibility, so it does not breach the + identification-threshold ban — but it was the only number a reader could misread + as a rule, and fixtures are written to be imitated. _Cost if wrong:_ none. +18. **Fix Evelyn's referral by moving it, not rewording again.** Verified against + `PROTOTYPE_NOW`: withdrawal 4 July, referral 11 July, only post-withdrawal + presentation 4 August — 24 days after the referral claimed it happened. Moving + `referredAt` to `daysAgo(14)` makes the claim true and gives the referral a + triggering event instead of leaving it floating free. Also required a chronology + guard, since the count sweep checks how many and never when. _Cost if wrong:_ the + identification queue's ordering shifts by one position. + +### Task 2 + +19. **`ReviewTrigger.source` gains `"participation"` — the type could not express a + constraint the spec requires.** Approving at `declined`/`patient_unavailable` + must raise an open trigger, but the canonical union had no value for it and + redefining canonical types is forbidden, so the implementer correctly stopped. + The on-screen marker is not a substitute: a marker is read only by whoever opens + that plan, whereas a trigger reaches the Reviews queue where somebody owns it. + _Cost if wrong:_ one extra queue item per such approval, which is the intent. +20. **Remove the online/offline listener — plan-mandated, spec-contradicted, spec + wins.** The spec says the offline state exists "only in the dedicated specimen + scenario". Nothing in a memory-only prototype depends on the network, and the + implementer's route through `apply-scenario` reconstructs fixtures, discarding a + user's in-session draft because their wifi blipped. _Cost if wrong:_ a genuinely + offline browser shows no degraded state — correct, because nothing here is + degraded by being offline. +21. **The plan's stale Task 2 snippet is corrected to match Task 1's shipped names, + not the reverse.** Verified against the fixtures: `SYN-MGMT-PLAN-002`, + `getOpenManagementDraft(versions, planId)`, and Mira's former Current genuinely is + version 1 with the awaiting version at 2. Task 1 is committed and reviewed; + churning identifiers would cost more than a stale example. _Cost if wrong:_ none. +22. **Review Triggers reach anyone who has ever had a version, not only those with a + live Current one.** The reducer gated on a Current version, so a person whose plan + was withdrawn who then presents and is admitted produced nothing for the Reviews + queue — the cohort that queue most exists for. Only a patient who has never had + any version raises none, because their pathway is Identification Review. _Cost if + wrong:_ a queue item for a plan with no current version, which is the signal wanted. +23. **Approval requires a non-empty `revisionReason`.** A version must not become the + Current Plan with no stated reason for existing, and the reducer is the final guard + rather than the form. _Cost if wrong:_ Task 6's form must supply a reason, which it + already collects. +24. **Printing the Personal Safety Plan is exempt from the offline/connectivity block.** + It is the single action you most want when systems are down, and it appends an audit + event rather than changing a record. Identity uncertainty still blocks it — printing + the wrong person's safety plan is a real harm — so the exemption is from the + connectivity block only, not the funnel. _Cost if wrong:_ an intent audit event is + recorded in a specimen scenario where other mutations are unavailable. +25. **The default participation wording must not assert an unrecorded fact.** A new + draft defaults to `patient_unavailable`, so the trigger reason asserted "this person + was not available to take part" when nobody had recorded anything. Keep the + conservative default and the trigger; word the reason to say only what is known. + Same family as the non-stigmatising-language rule. _Cost if wrong:_ none. + +### Task 3 + +26. **The error boundary is a client class component inside the gate, wrapping the + provider — not a Next `error.tsx` at the Care Plan segment.** Task 2's deferred minor + records that `assertSingleCurrentVersion` throws with no boundary, and the session + brief makes mounting one an input to Task 3. A segment `error.tsx` cannot catch it: + `assertSingleCurrentVersion` throws from inside the reducer, the reducer runs during + the render phase of the component owning the state, and that component is + `CarePlanPrototypeProvider`, which the plan places in `layout.tsx` — a segment's + `error.tsx` never catches a throw from its own layout. So the boundary must be an + ancestor of the provider and a descendant of `DeveloperAreaGate`. It renders the + existing shared `RouteErrorBoundary` panel from + `src/components/route-error-boundary.tsx` as its fallback rather than new markup, per + the standing rule against duplicating a primitive that already exists. _Cost if + wrong:_ one small extra component; a segment `error.tsx` can still be added beside it + later if page-level throws want their own treatment. + +27. **The glossary's `_Avoid_` lists are concept-scoped, not a blanket lexical ban — the + reviewer's finding is overruled, its underlying point adopted.** The task review + flagged `Create or edit a draft version` and `Print-optimised patient copy` as + breaching `_Avoid_: Edit, overwrite` and `_Avoid_: Copy, document`. But those entries + sit under **Presentation Amendment** and **Management Plan Version** respectively: + they ban calling an amendment an edit and calling a version a copy, neither of which + those strings does. A blanket reading also makes the glossary contradict itself — its + own Draft entry reads "can be edited" — and both strings are **verbatim from the + binding specification's route table**, which the controller supplied as approved copy, + so changing them would put the code out of step with the spec. The copy stands. The + reviewer was right about the cause, though: the banned-phrase scan covered three + phrases out of a namespace that will grow by twenty routes, so it was widened to the + terms that genuinely are blanket-banned — stigmatising labels for a person, + quantified risk or severity verdicts, and all seventeen + `BANNED_ADMISSION_CONSTRUCTIONS` imported from `domain.ts` rather than retyped. The + concept-scoped terms are explicitly excluded in a comment, so nobody "completes" the + list later and reintroduces this. _Cost if wrong:_ three route-purpose strings read + slightly closer to the amendment and version vocabulary than a purist would like; + every genuinely harmful term is now machine-checked, which it was not before. + +28. **The live region was removed rather than repaired, and that is accepted.** Fixing the + route-focus defect made the hand-rolled `aria-live` region a double announcement, and + keying it on the pathname would have put synthetic record identifiers into a + screen-reader announcement. Focus-to-heading is the standard pattern, now keyed on the + address and covered by a test that re-renders at a second pathname resolving to the + same heading. _Cost if wrong:_ if the whole-branch review wants a live region back it + needs a per-route value carrying no record content; the deferred-minors list already + records that the repository's shared `LiveAnnouncer` is the right home for it. + +### Task 4 + +29. **The Task 4 brief describes the summary card in the superseded nineteen-field + vocabulary; the spec wins and the card is the five `FIRST_MINUTE_CONTENT_KEYS`.** The + brief asks for "preferred engagement, what helps, what may increase distress, + immediate continuity considerations, CMHT coordination". Two of those are exactly the + duplicate pairs the 21 August design review deleted — engagement against + agreed-approach, and "may increase distress" against "what makes it worse" — and the + specification's acceptance criterion is that the card is _exactly_ the five + first-minute sections in order. The card is therefore generated by iterating the + `FIRST_MINUTE_CONTENT_KEYS` constant rather than transcribing a list, so a sixth + section cannot be added without changing `types.ts`. Caught before dispatch, not by a + reviewer. _Cost if wrong:_ none identified — the spec, the shipped types and the + acceptance criteria all agree against the plan's stale prose. + +30. **The brief's worked example pins version numbers the fixtures contradict; the + fixtures win.** It expects "Awaiting Approval version 3 / Current version 2"; Mira's + plan is Current 1 with the awaiting version at 2. Ruling 21 already recorded the same + fact for Task 2, so this is the second appearance of one stale example. The + implementer used the fixture-true numbers and kept the rest of the example verbatim. + _Cost if wrong:_ none — Task 1 is committed and reviewed, and churning identifiers to + match an example costs more than the example is worth. + +31. **Selecting a patient on Home renders the workspace on Home rather than navigating — + the brief contradicts itself and the constraint behind it is met.** The brief's prose + says selection navigates to a full-width workspace on phones; its own worked example + requires the workspace to render on Home after the click, with `navigate` mocked. The + real constraint is the phone layout — single column below `64rem`, directory first, no + compressed second column — and that is satisfied, with a link to the full-width record + offered as well. But route navigation was what bought the focus move, so the departure + left selection silent for a screen-reader user; a focus move to the workspace was added + to replace what navigation would have given. _Cost if wrong:_ a one-line change to + navigate instead, and the focus effect becomes redundant rather than wrong. + +32. **`judgement` stands; the specification's `judgment` is the defect.** The repository + writes Australian English throughout and the Global Constraints require `en-AU`, where + `judgement` is the standard spelling. The continuity sentence keeps it. _Cost if + wrong:_ one word differs from the spec's own prose, which should be corrected at the + Stage A checkpoint rather than the code bent to match it. + +33. **The pinned safety boundary's copy was overstating the section, and was changed.** It + read "This plan does not apply if today is different", which is a stronger claim than + section 5 makes — the plan still supports continuity when today differs, as the + continuity boundary in the same card says. It now reads "Do not rely on this plan if + today is different — assess afresh." This is the single most safety-critical line in + the product and it renders in print, so it was fixed immediately rather than deferred. + _Cost if wrong:_ none identified; the weaker claim is also the accurate one. + +34. **The Task 3 shell gained an optional `routeOwnsSearch` prop rather than Home carrying + two search fields.** Home and Patients own their own search; without the prop the shell + composer would render a second one, breaching the repository's one-composer-per-page + rule. Three Task 3 tests moved from `home`/`patients` to `reviews`/`team`; the + re-reviewer verified their assertions are byte-identical, and a new test asserts Home + has exactly one searchbox _and_ that the survivor is the directory's — so the rule is + met rather than sidestepped. _Cost if wrong:_ one optional prop on a shell that will + carry more route-specific behaviour as Tasks 6–10 land. + +### Task 5 + +35. **`record-management-plan-print-intent` joins `CONNECTIVITY_EXEMPT_ACTIONS`.** Ruling 24 + exempted the Personal Safety Plan print from the offline block because printing "is the + single action you most want when systems are down, and it appends an audit event rather + than changing a record". Both halves of that are properties of the _action_, not of which + document it prints; the rationale reads as being about the Safety Plan only because that + was the sole print route in existence when it was written. Generalising it to its own + logic is not contradicting the reviewed decision — narrowing it to the document it + happened to name would be. + + The behaviour decided it anyway, and the mechanism was worse than either the implementer + or I assumed: `BrowserPrintButton` calls `onBeforePrint?.()` and then `window.print()` + **unconditionally**, ignoring the hook's return, so the reducer's refusal could not stop + anything. In the offline specimen the dialogue opened, a clinical document could leave + the building, `auditEvents` was unchanged, and the reader was told "nothing was changed". + Paper left the room and the application's own account denied it — an underclaim in + exactly the direction this prototype's audit discipline exists to prevent, and it made + two identical operations diverge. + + Identity uncertainty still blocks printing: Ruling 24 exempted the connectivity block + only, never the funnel, and the re-reviewer confirmed the identity, permission and + version-conflict checks are separate unconditional statements that were not touched. + `print-output.tsx` was deliberately **not** given a veto contract — with the exemption in + place nothing on this route depends on one, and adding a new contract to a shared + primitive is a bigger change than this warranted. _Cost if wrong:_ a print intent is + recorded in a specimen where other mutations are unavailable, which is Ruling 24's stated + intent. The latent limitation stands: no caller can refuse a print from a + `BrowserPrintButton`, so a future refusable print route needs the primitive changed. + +36. **`REVIEW_STATE_TONE` was deleted rather than exported — the implementer deviated and was + right.** The instruction was to export the constant and consume it in place of a + hand-copied ternary. But fixing the duplicated status marks removed that ternary's only + consumer, so exporting would have published a constant to feed nothing. The duplication + risk is gone by deletion instead of by coupling. _Cost if wrong:_ a future surface needing + the tone exports it then, which is the cheaper moment to decide the shape. + +### Task 6 + +37. **A third worked example pinned version 3 against a fixture that says 2 — the fixtures + win, and this is now a pattern rather than an incident.** Rulings 21 and 30 recorded the + same defect for Tasks 2 and 4; Task 6's example asserted `Approve version 3` / + `Current version 3` for `SYN-PATIENT-002` when `SYN-MGMT-VERSION-004` is + `awaiting_approval` at version 2 over a Current at version 1. Every remaining task brief + that names a version number should be checked against the fixtures before dispatch, not + after. _Cost if wrong:_ none — the rest of the example verified exactly. + +38. **Task 6 builds the role switcher, which no earlier task built.** The brief's example + selects a `Prototype role` combobox that did not exist: the shell displayed the active + synthetic user as static text and offered no way to change it, though the `set-active-user` + action had existed since Task 2. Approval is role-gated and withdrawal is + `senior_clinician`-only, so a prototype that cannot switch between a senior and a + non-senior view cannot demonstrate its own central governance rule, and every later queue + and permission surface needs the same control. It is interaction modelling, not + authentication — the displayed user explains why an action is available and the reducer + remains the final guard. _Cost if wrong:_ a control in the rail that a later task would + have had to build anyway. + +39. **The URL-to-reducer scenario sync lands in Task 6, not deferred.** Every reducer-gated + refusal across Tasks 4, 5 and 6 was **dead in the running application**: the provider is + mounted in a server layout that cannot read a query string, so `state.scenario` was always + `normal` and every branch of `getPrototypeMutationBlockReason` was unreachable. Three tasks + had shipped refusals no user could ever see, and each further task would add more. The + implementer's reason for deferring — that a route-level effect resets the whole world and + might collide with a later task's design — was weaker than it looked: the sibling Caring + Contacts prototype already implements the same guarded sync in about eight lines. + + **The guard is the substance of the ruling.** `apply-scenario` reconstructs fixtures, and + Ruling 20 removed an online/offline listener precisely because routing through it discards + a clinician's in-session draft. So the effect dispatches **only when the scenario named in + the URL differs from `state.scenario`** — never on every render, never on an ordinary + navigation within one scenario. Both directions are tested. What is genuinely prop-driven + was never at risk: `identity-uncertain` and `launch-failure` were always reachable, and + `unverified-contact` comes from the fixture rather than the query, so Task 4's + contact-failure behaviours were intact throughout. _Cost if wrong:_ an effect in the route + surface that Task 10 may want to reshape when it builds the System states controls — that + screen now owns the question of whether a hand-toggled scenario also updates the URL. + +40. **The shared `ErrorSummary` keeps focus on a failed submit; the round-1 opt-out was + reverted.** The brief says to focus the first invalid field, and the repository primitive + focuses itself, so the combination gave a screen-reader user two focus events and moved + them off a summary of up to seven linked errors before it could be read. Round 1 resolved + it by opting the summary out. Overruled: the primitive already made this decision for + every form in the product, and a Care Plan form behaving differently from every other form + is worse for a user than either target on its own. With this many errors the summary is + also the more useful landing place — the user hears how many problems there are and + chooses — and its links are how they reach the field, so the brief's requirement is + satisfied by their choice rather than pre-empted. Reverting also returned + `src/components/ui/form-field.tsx` to **untouched**, which the re-reviewer confirmed from + the diff rather than from the implementer's word. _Cost if wrong:_ a one-line flip, and the + ordering test pins whichever way it points. + +### Task 7 + +41. **The one-line note keeps the specification's label, not the brief's.** The brief calls the + required free text `Anything worth flagging?`; the specification says + `In one line: why they came and what happened`, twice. That line was deliberately made to + do the work the separate `presenting indication` and `assessment outcome` fields would + otherwise do, on the reasoning that one line from the clinician who was there beats two + structured boxes that get skipped at the end of a shift. The brief's wording invites an + optional afterthought instead. _Cost if wrong:_ none — the spec states it twice and the + plan's own Global Constraints agree. + +42. **The fixtures were wrong and the reducer right on append-only.** `SYN-PRESENTATION-002` + and `-003` stored the _corrected_ value on the episode with the amendment recording what it + replaced; the reducer never rewrites an episode. Both conventions were pinned by committed + tests in different files, and the implementer correctly refused to resolve it alone. The + glossary settles it: a Presentation Amendment "preserves the original record", and the + specification says the original text is not replaced. So the episode holds what was first + recorded and the amendment records what it became. + + Fixtures on this project are written to be imitated — an earlier ruling says so in those + words — so leaving two records encoding the opposite convention was the real risk. Both + corrected, `tests/care-plan-domain.test.ts:748` inverted to assert the **earliest** + amendment's `originalValue`, and a chain invariant added. Nothing rendered wrongly under + either model beforehand, because `getRecordedPresentationValue` reads the first correction's + `originalValue`, which is truthful either way — good design that happened to mask the + contradiction. _Cost if wrong:_ two fixture records and one inverted assertion; the + reviewer traced the blast radius and confirmed no filter, count or other assertion moved. + +43. **A correction refuses as a whole rather than applying in part — the implementer's trade, + accepted.** Recording the valid corrections and reporting the rest would need per-dispatch + outcomes from the reducer, which is a reducer change and out of scope. Refusing everything + is the conservative direction, the error names the offending field, and the sheet keeps the + clinician's typed work so nothing is lost. _Cost if wrong:_ a clinician who blanks one + field must fix it before any of their other corrections are recorded — friction, not loss. + +### Task 8 + +44. **A person's own six sections are required only when they confirmed or discussed the + version.** The brief's literal "at least one item in every section" cannot coexist with its + own "do not treat declined or unavailable as non-compliance": `SYN-SAFETY-VERSION-004` is a + legitimate declined-and-current version holding only crisis numbers and is unreproducible + under the literal rule, and requiring seven sections of somebody's own words when they took + no part would make a clinician **invent words and attribute them to the patient**. The + implementer raised this rather than choosing silently, and the reviewer confirmed the + reasoning survives contact with the code and the fixtures. Review date, collaboration note + and `professionalAndEmergencySupport` stay mandatory in all four states, and the per-person + name, relationship and phone checks run whether or not the section is required — nothing + lets a strictly empty plan become Current. _Cost if wrong:_ a clinician can make a sparse + version current for someone who took no part, which is the situation the relaxation exists + to serve. + +45. **The printed copy omits an empty section rather than printing its heading over a blank, + and the own-words claim is conditioned on content rather than on confirmation state.** + Omission beats rewording here: six headings each inviting her to write would read as a + checklist of things she declined to do, and what remains — her contacts and the crisis + block — is what her plan actually is. Conditioning on content rather than on + `patientConfirmation` was the implementer's own variation on the instruction and is the + safer one: content is what makes the sentence true or false, and it also covers a + `confirmed` version that somehow holds nothing. The two agree in every state the form can + produce. _Cost if wrong:_ a future data path that produced a current version with an empty + `professionalAndEmergencySupport` would drop those contact lines from the paper, though the + hardcoded crisis block still renders — recorded below as a defensive carve-out worth adding. + +46. **`print-failure` records no audit event at all.** The only available action's outcome + message asserts "The print view was opened", which did not happen, and adding a new action + is barred by the pre-flight ruling assigning actions to tasks. Recording nothing is the only + honest option inside that constraint, and it matches the standing rule that an Audit Event + describes only evidence the application actually has. _Cost if wrong:_ a failed print leaves + no trace in the chronology at all — recorded as a deferred minor for a later task, since a + contact action _does_ record an attempt that is explicitly not evidence of delivery. + +47. **The printed title is an `

`, 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 : ( + + )} +

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 : ( + + )} + + )} +

+ + 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.`} +

+ )} + +
+ + + + {draft.sections.map((section) => ( +
+ {section.gap ? ( +

+ {section.gapReason} +

+ ) : null} + + setValues((previous) => ({ + ...previous, + sections: { ...previous.sections, [section.key]: value }, + })) + } + error={errors.find((entry) => entry.fieldId === patientPlanFieldId(section.key))?.message} + required + rows={4} + /> +
+ ))} +
+ + +
    + {available.map((resource) => { + const chosen = values.resourceIds.includes(resource.id); + const fieldId = patientPlanFieldId(resource.id); + const detailId = `${fieldId}-detail`; + return ( +
  • + {/* The detail is described rather than labelled: a screen + reader announcing the whole paragraph as the checkbox's + name would make a list of thirteen resources unusable, and + the detail is what the person reads on the sheet anyway. */} + +

    + {`${PATIENT_RESOURCE_CATEGORY_LABEL[resource.category]} — ${resource.detail}`} +

    +
  • + ); + })} +
+
+ +
+ + {remainingGaps.length > 0 ? ( + + ) : approveBlockedReason !== null ? ( + + ) : ( + + )} + + Back to the Patient Plan + +
+ + {remainingGaps.length > 0 || approveBlockedReason !== null ? ( +

+ {remainingGaps.length > 0 + ? `This copy cannot be approved while ${remainingGaps.length} ${remainingGaps.length === 1 ? "section is" : "sections are"} still blank: ${remainingGaps.map((section) => section.heading).join("; ")}.` + : approveBlockedReason} +

+ ) : null} + +
+ ); +} 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 : ( +

+ + {resource.sourceUrl} + +

+ )} +
+ ))} +
+ ))} +
+ ); +} + +/** + * 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 ? ( + <> + + + + ) : ( + + ); + + 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 : ( + + )} + + {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");