From 92097e53e096bc33c8b23f73874981b2e3214af3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:29:48 +0800 Subject: [PATCH 01/56] docs(ed-care-plans): land approved plan and apply four user decisions Brings the ED Care Plans design specification, domain glossary, nine-task implementation plan and handover documents into this worktree, and applies the four decisions the user made on 21 August 2026: - Build the synthetic prototype now, but keep the domain shaped so real persistence could be added later without redesigning it. Recorded as a Global Constraint (pure reducer, serialisable state, caller-allocated IDs, single dispatch path) and as decision 9 in the specification. No storage layer, adapter or migration scaffolding is built now. - Keep the full multi-service workflow, including named senior-clinician approval. No change to the specification. - Deliver Tasks 1-5 as Stage A, stop at a mandatory checkpoint for user review, then Tasks 6-9 as Stage B. Added a Delivery Stages section and a Stage A Checkpoint with its own evidence steps. - Local task commits authorised; nothing pushed. Replaced the nine "after explicit local-commit permission" steps accordingly. Also corrected three stale facts in the plan: the target worktree and branch (now this worktree, based on current main), the "task-start preflight has already run" line, and the import homes of the twelve shared UI primitives (InlineNotice and EmptyState live in src/components/ui-primitives.tsx, not src/components/ui/). All twelve export paths were verified against this worktree. No application code exists yet. No test, typecheck, lint, build or browser gate has run for this feature. Co-Authored-By: Claude Opus 5 --- docs/ed-care-plans-context.md | 135 +++ docs/ed-care-plans/CLAUDE-START-HERE.md | 84 ++ .../claude-build-handover-2026-08-21.md | 387 ++++++ .../conversation-transcript-2026-08-21.md | 591 +++++++++ .../verification-log-2026-08-21.md | 240 ++++ ...2026-08-20-ed-care-plans-implementation.md | 1053 +++++++++++++++++ .../specs/2026-08-20-ed-care-plans-design.md | 419 +++++++ 7 files changed, 2909 insertions(+) create mode 100644 docs/ed-care-plans-context.md create mode 100644 docs/ed-care-plans/CLAUDE-START-HERE.md create mode 100644 docs/ed-care-plans/claude-build-handover-2026-08-21.md create mode 100644 docs/ed-care-plans/conversation-transcript-2026-08-21.md create mode 100644 docs/ed-care-plans/verification-log-2026-08-21.md create mode 100644 docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md create mode 100644 docs/superpowers/specs/2026-08-20-ed-care-plans-design.md diff --git a/docs/ed-care-plans-context.md b/docs/ed-care-plans-context.md new file mode 100644 index 000000000..36cc903d2 --- /dev/null +++ b/docs/ed-care-plans-context.md @@ -0,0 +1,135 @@ +# ED Care Plans — domain glossary + +The ubiquitous language for the ED Care Plans context. This glossary defines the clinical-workflow concepts without describing schemas, routes, components, or implementation choices. + +## People and services + +**Patient**: +A person whose recurrent emergency care may benefit from a shared continuity plan. A patient is never defined by their presentation count. +_Avoid_: Frequent flyer, high utilizer, problem patient + +**Community Mental Health Team (CMHT)**: +The community service responsible for ongoing specialist mental-health care and coordination during its stated operating hours. +_Avoid_: Clinic, case-management inbox + +**Care Coordinator**: +A named clinician who coordinates the patient's community care within the CMHT. The CMHT remains the durable service contact when the named person is unavailable. +_Avoid_: Plan owner, approver + +**Support Person**: +A family member, carer, advocate, peer, cultural support, or other person the patient wants involved in communication or decisions. +_Avoid_: Next of kin, unless that legal relationship is specifically meant + +## Presentation activity + +**ED Presentation**: +One episode beginning when the patient arrives at an emergency department and ending in a recorded disposition. It is an episode record, not the patient and not the longitudinal plan. +_Avoid_: Visit, attendance, encounter + +**Presenting Indication**: +A concise statement of why emergency assessment was sought during one ED Presentation. It is not automatically a diagnosis or a description of clinical severity. +_Avoid_: Chief complaint, diagnosis + +**Presentation Outcome**: +The concise assessment and disposition result recorded when an ED Presentation closes. +_Avoid_: Plan outcome, treatment success + +**Presentation Activity**: +Objective counts of a patient's ED Presentations over an explicitly stated period. Activity can prompt human review but never determines a diagnosis, risk state, or mandatory pathway. +_Avoid_: Frequent-presenter score, risk score + +**Presentation Amendment**: +A visible, attributed correction to a completed ED Presentation. It preserves the original record and states what changed and why. +_Avoid_: Edit, overwrite + +## Identification + +**Identification Policy**: +The locally governed rule describing when presentation activity or professional referral should prompt review for coordinated care planning. No approved numeric rule exists in this prototype. +_Avoid_: Algorithm, clinical rule + +**Identification Review**: +A human multidisciplinary review of whether coordinated care planning may benefit the patient. It does not enrol the patient, create a plan, or make a severity judgment. +_Avoid_: Frequent-presenter flag, automatic enrolment + +**Manual Referral**: +An authorised clinician's reasoned request for Identification Review independent of any numeric threshold. +_Avoid_: Override + +## Management planning + +**Management Plan**: +The patient's longitudinal clinician-facing continuity record for psychiatric ED presentations. It is one evolving plan composed of controlled versions, not a new plan for every presentation. +_Avoid_: ED note, treatment order, risk assessment + +**Management Plan Version**: +One preserved edition of the Management Plan with an author, reason, state, and review history. +_Avoid_: Copy, document + +**Current Plan**: +The single Management Plan Version approved for use now. Only named senior-clinician approval makes a version Current. +_Avoid_: Latest plan, active draft + +**Draft**: +A proposed Management Plan Version that can be edited but is not approved for use. A Draft never displaces the Current Plan. +_Avoid_: Working Current Plan + +**Awaiting Approval**: +A submitted Management Plan Version that is read-only while a senior clinician compares and approves it or returns it for changes. +_Avoid_: Current, approved + +**Superseded Plan**: +A previously Current Management Plan Version replaced by a newer approved version. It remains available as history and never becomes Current again automatically. +_Avoid_: Expired plan + +**Withdrawn Plan**: +A formerly Current Management Plan Version deliberately removed from current use with a recorded reason. Withdrawal leaves no Current Plan unless another version is separately approved. +_Avoid_: Deleted plan + +**Review State**: +The currency of a Current Plan relative to its formal review expectation: within review, due soon, or overdue. An overdue Current Plan remains Current until reviewed, replaced, or withdrawn. +_Avoid_: Version state, expiry + +**Plan Owner**: +The clinician or team accountable for coordinating review and keeping the Management Plan current. Ownership does not itself grant approval. +_Avoid_: Care Coordinator, approver + +**Approver**: +The named senior clinician who confirms that a submitted Management Plan Version can become Current. +_Avoid_: Author, owner + +**Review Trigger**: +A reason the Current Plan should be reconsidered, such as ineffective guidance, changed circumstances, repeated deviation, or stale contact information. A trigger never changes the plan automatically. +_Avoid_: Alert, automatic update + +**Plan-use Feedback**: +The ED clinician's structured account of whether the Current Plan was available, used, and helpful during one ED Presentation. +_Avoid_: Compliance score, effectiveness verdict + +## Personal safety planning + +**Personal Safety Plan**: +A distinct patient-owned, patient-voice plan for recognising distress, using coping strategies, making the environment safer, and reaching personal and professional support. +_Avoid_: Management Plan section, risk-management plan + +**Personal Safety Plan Version**: +One preserved edition of the Personal Safety Plan with a collaboration and patient-confirmation state. It does not require Management Plan approval. +_Avoid_: Clinical plan version + +**Patient Confirmation**: +The recorded state of the patient's involvement with a Personal Safety Plan Version: confirmed, discussed but not confirmed, declined, or unavailable. +_Avoid_: Clinical approval, compliance + +## Communication and evidence + +**Contact Action**: +An explicit attempt to open an external telephone or email application using displayed service details. It is not evidence that communication occurred. +_Avoid_: Contact completed, message sent + +**Contact Verification**: +Confirmation that a CMHT's shared contact details and operating hours were checked on a stated date. +_Avoid_: Service availability guarantee + +**Audit Event**: +An attributed record that a meaningful workflow action occurred inside ED Care Plans. It describes only evidence the application actually has. +_Avoid_: Activity feed, communication log diff --git a/docs/ed-care-plans/CLAUDE-START-HERE.md b/docs/ed-care-plans/CLAUDE-START-HERE.md new file mode 100644 index 000000000..43bd90e6a --- /dev/null +++ b/docs/ed-care-plans/CLAUDE-START-HERE.md @@ -0,0 +1,84 @@ +# ED Care Plans — Claude start here + +**Handover date:** 21 August 2026 (Australia/Perth) +**Implementation status:** Not started +**Design status:** Approved +**Planning status:** Complete +**Working directory:** `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` (superseded 21 Aug 2026; the original `D:\Worktrees\Database\ed-care-plans` is now planning-only and must not be written to) +**Branch:** `claude/ed-care-plans-impl-7f44cd` (based on `main` at `97f614223`) + +## One-minute start + +**Superseded 21 August 2026.** Do all ED Care Plans work in `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` on branch `claude/ed-care-plans-impl-7f44cd`, which is based on current `main`. The four planning documents were copied there and are now tracked with the code. The original planning worktree `D:\Worktrees\Database\ed-care-plans` is read-only reference; do not edit it. Do not edit the dirty shared checkout at `D:\Repos\Database`. + +Four user decisions were recorded on 21 August 2026 and are binding: build the synthetic prototype now but keep the domain shaped for later real storage; keep the full multi-service workflow including named senior-clinician approval; deliver Tasks 1–5 first and stop for user review; local task commits are authorised (nothing pushed). See the Revision history and Delivery Stages sections of the implementation plan. + +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-ed-care-plans-design.md`](../superpowers/specs/2026-08-20-ed-care-plans-design.md) — approved product and visual specification; this is the product authority. +5. [`ed-care-plans-context.md`](../ed-care-plans-context.md) — binding domain language. +6. [`2026-08-20-ed-care-plans-implementation.md`](../superpowers/plans/2026-08-20-ed-care-plans-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\ed-care-plans\.superpowers\brainstorm\17559-1787239654\content\ed-care-plans-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 +Continue the approved ED Care Plans build from the handover in +D:\Worktrees\Database\ed-care-plans on branch codex/ed-care-plans. + +Work only in that isolated worktree. Do not touch D:\Repos\Database, which contains +unrelated dirty work. Read AGENTS.md and CLAUDE.md, then read these files in order: + +1. docs/ed-care-plans/CLAUDE-START-HERE.md +2. docs/ed-care-plans/claude-build-handover-2026-08-21.md +3. docs/superpowers/specs/2026-08-20-ed-care-plans-design.md +4. docs/ed-care-plans-context.md +5. docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md +6. docs/ed-care-plans/conversation-transcript-2026-08-21.md +7. docs/ed-care-plans/verification-log-2026-08-21.md + +The design and implementation plan are approved. Do not re-brainstorm or encode a +numeric presentation threshold. Execute the nine tasks in order using the named +Superpowers subagent-driven-development and test-driven-development workflow. Keep +the app completely synthetic, reset-on-refresh, and provider-free. Use repository +test and browser wrappers only. + +Before editing, verify the branch, HEAD, upstream, status, and the current gap to +origin/main. Preserve every existing untracked handover/specification file. Do not +merge, rebase, pull, or move the base without my explicit authorization. + +Routine local implementation and offline verification are intended. No commit, +push, pull, merge, rebase, PR, deployment, provider/API access, migration, or live +data access is authorized by this handover. Ask separately before creating local +commits because the requested SDD workflow normally uses them as checkpoints. + +Begin with Task 1 in the implementation plan. Use a failing focused test first, +record the decisive red and green lines, obtain the task reviews required by the +plan, and continue task by task. Stop only for a material authorization boundary or +a conflict that cannot be resolved safely from the repository. +``` + +## 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 ED Care Plans. + +## 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/ed-care-plans/claude-build-handover-2026-08-21.md b/docs/ed-care-plans/claude-build-handover-2026-08-21.md new file mode 100644 index 000000000..5ddc2c07e --- /dev/null +++ b/docs/ed-care-plans/claude-build-handover-2026-08-21.md @@ -0,0 +1,387 @@ +# ED Care Plans — detailed Claude build handover + +## 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 [`ED Care Plans design specification`](../superpowers/specs/2026-08-20-ed-care-plans-design.md). +4. Binding [`domain glossary`](../ed-care-plans-context.md). +5. Executable [`implementation plan`](../superpowers/plans/2026-08-20-ed-care-plans-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:** ED Care Plans +**Descriptor:** Continuity for recurrent presentations +**Route family:** `/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/ed-care-plans/conversation-transcript-2026-08-21.md b/docs/ed-care-plans/conversation-transcript-2026-08-21.md new file mode 100644 index 000000000..244317085 --- /dev/null +++ b/docs/ed-care-plans/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/ed-care-plans/verification-log-2026-08-21.md b/docs/ed-care-plans/verification-log-2026-08-21.md new file mode 100644 index 000000000..e47262a36 --- /dev/null +++ b/docs/ed-care-plans/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/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md b/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md new file mode 100644 index 000000000..0a375fbb7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md @@ -0,0 +1,1053 @@ +# ED Care Plans Standalone Synthetic Application Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan one task at a time, `superpowers:test-driven-development` for every behaviour change, `superpowers:requesting-code-review` after each task and for the final branch review, and `superpowers:verification-before-completion` before any completion claim. + +**Goal:** Deliver the user-approved, complete, linked, reset-on-refresh ED Care Plans prototype under `/mockups/ed-care-plans`, using only deterministic synthetic data and the repository design system. + +**Architecture:** A Next.js 16 App Router route family delegates to one client-side route surface. A layout-scoped provider owns the complete in-memory domain state; a pure reducer enforces Management Plan, Personal Safety Plan, ED Presentation, identification-review, contact-intent, and audit invariants. Server page files validate the finite synthetic dynamic parameters and otherwise render the same client route surface. Shared repository primitives own controls, dialogs, sheets, fields, tabs, announcements, and page structure. Tailwind token utilities provide most styling; one route-scoped CSS Module owns the split shell, phone safe-area behaviour, and print-only rules. + +**Tech Stack:** Next.js 16.3 App Router, React 19, TypeScript, repository Clinical White / Sky Graphite tokens, Tailwind CSS utilities, CSS Modules, Vitest, Testing Library, and repository-wrapped Playwright Chromium. + +**Spec:** [`docs/superpowers/specs/2026-08-20-ed-care-plans-design.md`](../specs/2026-08-20-ed-care-plans-design.md) is binding. [`docs/ed-care-plans-context.md`](../../ed-care-plans-context.md) supplies the binding domain language. If this plan conflicts with the specification, follow the specification and record an SDD ledger ruling. + +## Revision history + +- **2026-08-20 (Codex):** original nine-task plan, written against the approved specification. +- **2026-08-21 (Claude session, user-approved):** four user decisions applied — (1) build the synthetic prototype now but keep the domain shaped for later real storage (see the future-persistence seam in Global Constraints); (2) keep the full multi-service workflow including named senior-clinician approval; (3) deliver Tasks 1–5 as Stage A, stop for user review, then Tasks 6–9 as Stage B; (4) local task commits authorised. Also corrected: the target worktree/branch, the stale "preflight already run" line, and the verified import homes of the shared UI primitives. + +## Global Constraints + +- Work only in `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` on `claude/ed-care-plans-impl-7f44cd`, based on `main` at `97f614223`. Preserve the unrelated dirty checkout at `D:\Repos\Database` and the earlier planning worktree at `D:\Worktrees\Database\ed-care-plans`; copy from them, never write to them. +- This worktree is fresh. Confirm dependencies are installed (`node_modules` present and `npm run check:installed-lock-parity` clean) before the first test command; run `npm ci --include=dev` only if they are not. +- The application is synthetic and memory-only. Do not add `fetch`, route handlers, Server Actions, local/session storage, IndexedDB, cookies for prototype state, Supabase, OpenAI, analytics, email providers, or any other network or persistence path. +- No API, provider, production-data, deployment, migration, or live-canary action is authorised. +- Every patient, clinician, team, presentation, plan, review, amendment, and audit identifier begins with `SYN-`. Public crisis telephone numbers are the only intentional non-fictional contact fixtures. +- `IdentificationPolicy.status` is exactly `pending_governance`, `thresholdCount` is exactly `null`, and `thresholdLookbackMonths` is exactly `null`. Raw Presentation Activity may show counts over named observation windows; no count creates eligibility, a patient label, a risk state, or a plan. +- Each patient has one longitudinal Management Plan. Only a named user whose role is `senior_clinician` can approve an `awaiting_approval` version. Approval atomically produces exactly one `current` version and marks the former Current version `superseded`. +- A Draft or Awaiting Approval version never hides or replaces the Current Plan. Withdrawal leaves `currentVersionId: null` and never restores a superseded version. +- A Personal Safety Plan is patient-owned, independently versioned, and does not use the Management Plan senior-approval transition. +- An ED Presentation is append-only. Corrections append a `PresentationAmendment`; they never overwrite the original field. Plan-use feedback may append a `ReviewTrigger` and never edits a plan. +- CMHT email and telephone actions record only `email_intent_opened` or `call_intent_opened`. The `mailto:` subject is generic and contains no name, MRN, date of birth, presentation content, or plan content. Never claim sent, delivered, read, answered, or completed contact. +- Use the official public WA crisis details verified on 20 August 2026: `000`; MHERL Perth `1300 555 788`; MHERL Peel `1800 676 822`; Rurallink `1800 552 002`, available 4:30 pm–8:30 am weeknights and 24 hours on weekends/public holidays. Display that MHERL is not an emergency service. Retain the official source URLs in fixture metadata. +- Use Australian English, `en-AU`, `Australia/Perth`, ISO source timestamps, plain non-stigmatising language, and the glossary's preferred terms. +- Reuse the existing repository primitives where their contracts apply; every button must have a real action or a stated unavailable reason. Verified import homes in this worktree: + - `Button` — `src/components/ui/button.tsx` + - `TextField`, `SearchField` — `src/components/ui/text-field.tsx` + - `FormField` — `src/components/ui/form-field.tsx` + - `Select` — `src/components/ui/select.tsx` + - `Tabs` — `src/components/ui/tabs.tsx` + - `Sheet` — `src/components/ui/sheet.tsx` + - `ConfirmDialog` — `src/components/ui/confirm-dialog.tsx` + - `PageHeader` — `src/components/ui/page-header.tsx` + - `LiveAnnouncer` — `src/components/ui/live-announcer.tsx` + - `InlineNotice`, `EmptyState` — `src/components/ui-primitives.tsx` (NOT `src/components/ui/`) + Confirm each import path before use; do not create a parallel local copy of a primitive that already exists. +- Internal navigation uses `next/link` or `router.push`; contact launch actions alone use external `mailto:` and `tel:` anchors. +- Phone layouts are single-column at 320 px and 390 px, respect the effective top safe-area inset, preserve 48 px targets, and have no horizontal page scroll. Current Plan, CMHT, Safety Plan, and `Record ED presentation` remain directly reachable. +- Support keyboard use, visible focus, dark mode, forced colours, reduced motion, 200% zoom, and monochrome print. Colour never carries state alone. +- Before UI code, follow the already-read installed Next.js 16 guides for layouts/pages, navigation, server/client boundaries, dynamic `params: Promise<...>`, and CSS ordering. +- Use repository wrappers: `npm run test -- ...`, `npm run ensure`, and `npm run test:e2e:ed-care-plans-mockup`. Never call Playwright directly and never assume a localhost port. +- Apply TDD to every production behaviour: add the smallest failing test, run it and confirm the expected failure, implement the minimum, rerun to green, then refactor while green. +- Before writing the first test in any task, the implementer reads `C:\Users\joshs\.codex\plugins\cache\openai-curated-remote\superpowers\6.3.0\skills\test-driven-development\writing-good-tests.md` and names the production change that would make each test fail. +- SDD runs only one implementer at a time, followed by a fresh task reviewer. The controller creates the task brief and review package and records results in this plan's ignored SDD ledger. +- **Local commits are authorised** (user, 21 August 2026): commit once at the end of each task, on this branch, in this worktree. Nothing else is authorised — no push, pull, merge, rebase, PR, deployment, provider access, or publication. +- **Future-persistence seam (user decision, 21 August 2026).** The prototype stays memory-only and reset-on-refresh, but must be built so that storage could later be added without redesigning the domain. Therefore: `prototypeReducer` stays a pure `(state, action) => state` function with no browser, timer, network, or module-level mutable state; `EdCarePlansPrototypeState` and every entity in it stays plain JSON-serialisable data (no `Date`, `Map`, `Set`, class instance, or function value); all IDs are allocated by the caller or by `nextSyntheticId`, never by `crypto`/`Math.random`/`Date.now`; and every state change goes through one dispatched action. Do NOT add a storage layer, adapter interface, persistence flag, or migration scaffolding now — that is speculative work the prototype does not need. The seam is the discipline, not extra code. + +## File Map + +### Domain and state + +- Create `src/components/ed-care-plans/mockups/types.ts` — complete domain/entity/input/action types. +- Create `src/components/ed-care-plans/mockups/fixtures.ts` — deterministic patients, users, sites, CMHTs, plans, presentations, reviews, audit events, scenarios, and public crisis contacts. +- Create `src/components/ed-care-plans/mockups/domain.ts` — search, current-version, activity, queue, contact-URI, and permission selectors. +- Create `src/components/ed-care-plans/mockups/prototype-state.ts` — initial-state builder, pure reducer, transition guards, IDs, and deterministic timestamps. +- Create `src/components/ed-care-plans/mockups/prototype-provider.tsx` — layout-scoped React provider and hook. +- Create `src/components/ed-care-plans/mockups/routes.ts` — literal route registry, query builders, and finite dynamic-parameter guards. + +### Route family and shell + +- Create `src/app/mockups/ed-care-plans/layout.tsx`, `loading.tsx`, and `route-page.tsx`. +- Create the seventeen `page.tsx` files listed in Task 3. +- Create `src/components/ed-care-plans/mockups/routable-suite.tsx` — pathname/query interpretation and route-to-page composition. +- Create `src/components/ed-care-plans/mockups/ed-care-plans-shell-frame.tsx` — desktop rail, phone navigation, synthetic marker, patient search, role specimen, route header, and announcements. +- Create `src/components/ed-care-plans/mockups/ed-care-plans.module.css` — route-scoped layout/safe-area/print rules. +- Create `src/components/ed-care-plans/mockups/index.ts` — public exports used by route files and tests. + +### Product surfaces + +- Create `clinical-snapshot-page.tsx`, `patient-directory.tsx`, `patient-workspace.tsx`, `patient-navigation.tsx`, and `contact-actions.tsx`. +- Create `management-plan-pages.tsx`, `management-plan-form.tsx`, and `management-plan-diff.tsx`. +- Create `presentation-pages.tsx`, `presentation-form.tsx`, and `presentation-timeline.tsx`. +- Create `safety-plan-pages.tsx` and `safety-plan-form.tsx`. +- Create `operations-pages.tsx`, `history-page.tsx`, and `system-states-page.tsx`. +- Create `prototype-ui.tsx` only for ED Care Plans-specific presentational patterns that combine repository primitives; do not duplicate shared primitives. + +### Existing integration files + +- Modify `src/lib/developer-area/headers.ts` and related proxy comments so `/mockups/ed-care-plans/**` uses the existing signed-in-administrator developer gate. +- Modify `src/app/mockups/mockups-layout-client.tsx` so ED Care Plans owns its shell instead of inheriting global mockup search chrome. +- Modify `src/app/mockups/development/page.tsx` to add the literal inbound Developer-hub entry and selected deep links. +- Modify `package.json`, `playwright.config.ts`, `docs/codebase-index.md`, `docs/site-map.md`, and generated documentation required by `npm run docs:update`. + +### Tests and handoff evidence + +- Create `tests/ed-care-plans-domain.test.ts`. +- Create `tests/ed-care-plans-prototype-state.test.ts`. +- Create `tests/ed-care-plans-route-files.test.ts`. +- Create `tests/ed-care-plans-linked-routes.dom.test.tsx`. +- Create `tests/ui-ed-care-plans-mockup.spec.ts`. +- Modify `tests/proxy.test.ts` and `tests/playwright-project-isolation.test.ts`. +- Create `docs/ed-care-plans/interaction-matrix.md`, `clinical-language-trace.md`, `accessibility-acceptance.md`, `implementation-handoff.md`, and `verification-report.md`. + +## Canonical Interfaces + +Task 1 must implement these names and unions exactly; later tasks consume them without redefining parallel types: + +```ts +export type SyntheticId = `SYN-${string}`; +export type PrototypeRole = + "ed_clinician" | "liaison_clinician" | "cmht_clinician" | "senior_clinician" | "plan_coordinator"; +export type ManagementPlanVersionState = "draft" | "awaiting_approval" | "current" | "superseded" | "withdrawn"; +export type SafetyPlanVersionState = "draft" | "current" | "superseded"; +export type ReviewState = "within_review" | "due_soon" | "overdue"; +export type ParticipationState = "co_produced" | "discussed" | "declined" | "patient_unavailable"; +export type PatientConfirmationState = "confirmed" | "discussed_not_confirmed" | "declined" | "unavailable"; +export type Disposition = + | "discharged_home" + | "short_stay" + | "mental_health_admission" + | "medical_admission" + | "transfer" + | "left_before_completion" + | "other"; +export type PlanAvailability = "available" | "unavailable" | "not_applicable"; +export type PlanUse = "used" | "partially_used" | "not_used" | "not_applicable"; +export type PlanHelpfulness = "helpful" | "mixed" | "not_helpful" | "not_assessed"; +export type PrototypeScenario = + | "normal" + | "empty" + | "no-current-plan" + | "overdue-plan" + | "withdrawn-plan" + | "unverified-contact" + | "identity-uncertain" + | "version-conflict" + | "offline" + | "permission-unavailable" + | "launch-failure" + | "print-failure"; + +export type ManagementPlanContent = { + purposeAndApplicability: string; + preferredEngagement: readonly string[]; + whatUsuallyHelps: readonly string[]; + whatMayIncreaseDistress: readonly string[]; + immediateContinuityConsiderations: readonly string[]; + patientGoalsAndPreferences: readonly string[]; + usualPresentationPattern: readonly string[]; + contextualTriggers: readonly string[]; + mustAssessAfresh: readonly string[]; + agreedEdApproach: readonly string[]; + escalationAndDispositionGuidance: readonly string[]; + helpfulInterventions: readonly string[]; + unhelpfulInterventions: readonly string[]; + physicalHealthReminders: readonly string[]; + medicationRecordReminders: readonly string[]; + coordination: readonly string[]; + communicationPreferences: readonly string[]; + accessibilityAndSensoryPreferences: readonly string[]; + culturalSpiritualAndSupportPreferences: readonly string[]; +}; + +export type SafetyPlanContent = { + warningSigns: readonly string[]; + saferSurroundings: readonly string[]; + reasonsForLiving: readonly string[]; + selfStrategies: readonly string[]; + connectionPeopleAndPlaces: readonly string[]; + personalSupports: readonly { name: string; relationship: string; phone: string }[]; + professionalAndEmergencySupport: readonly string[]; +}; + +export type IdentificationPolicy = { + id: SyntheticId; + status: "pending_governance"; + thresholdCount: null; + thresholdLookbackMonths: null; + manualReferralEnabled: true; + explanation: string; +}; + +export type PrototypeUser = { + id: SyntheticId; + displayName: string; + title: string; + role: PrototypeRole; +}; + +export type Patient = { + id: SyntheticId; + fullName: string; + preferredName: string; + aliases: readonly string[]; + mrn: SyntheticId; + dateOfBirth: string; + ageCohort: "adult" | "older_adult"; + pronouns: string; + homeHealthService: string; + cmhtId: SyntheticId; + managementPlanId: SyntheticId; + personalSafetyPlanId: SyntheticId; +}; + +export type EdSite = { id: SyntheticId; name: string; healthService: string }; + +export type CmhtContact = { + id: SyntheticId; + name: string; + catchment: string; + sharedMailbox: string; + dutyTelephoneDisplay: string; + dutyTelephoneUri: string; + operatingHours: string; + timezone: "Australia/Perth"; + careCoordinator: string | null; + afterHoursLabel: string; + afterHoursTelephoneDisplay: string; + afterHoursTelephoneUri: string; + verifiedAt: string; + verificationState: "verified" | "review_due" | "unverified"; +}; + +export type ManagementPlan = { + id: SyntheticId; + patientId: SyntheticId; + versionIds: readonly SyntheticId[]; + currentVersionId: SyntheticId | null; +}; + +export type ManagementPlanVersion = { + id: SyntheticId; + planId: SyntheticId; + version: number; + state: ManagementPlanVersionState; + reviewState: ReviewState | null; + authorId: SyntheticId; + ownerId: SyntheticId; + approverId: SyntheticId | null; + createdAt: string; + submittedAt: string | null; + approvedAt: string | null; + reviewDueAt: string | null; + revisionReason: string; + participationState: ParticipationState; + consentedSupportPeople: readonly string[]; + returnedReason: string | null; + withdrawalReason: string | null; + content: ManagementPlanContent; +}; + +export type PersonalSafetyPlan = { + id: SyntheticId; + patientId: SyntheticId; + versionIds: readonly SyntheticId[]; + currentVersionId: SyntheticId | null; +}; + +export type PersonalSafetyPlanVersion = { + id: SyntheticId; + planId: SyntheticId; + version: number; + state: SafetyPlanVersionState; + reviewState: ReviewState | null; + authorId: SyntheticId; + createdAt: string; + confirmedAt: string | null; + reviewDueAt: string | null; + patientConfirmation: PatientConfirmationState; + collaborationNote: string; + content: SafetyPlanContent; +}; + +export type EdPresentation = { + id: SyntheticId; + patientId: SyntheticId; + arrivedAt: string; + siteId: SyntheticId; + presentingIndication: string; + assessmentOutcome: string; + disposition: Disposition; + cmhtContactAttempt: "not_attempted" | "attempted"; + cmhtContactOutcome: string; + managementPlanVersionId: SyntheticId | null; + planAvailability: PlanAvailability; + planUse: PlanUse; + planHelpfulness: PlanHelpfulness; + deviationOccurred: boolean; + deviationReason: string | null; + reviewSuggested: boolean; + reviewReason: string | null; + recordedBy: SyntheticId; + recordedAt: string; +}; + +export type PresentationAmendment = { + id: SyntheticId; + presentationId: SyntheticId; + field: "assessmentOutcome" | "disposition"; + originalValue: string; + replacementValue: string; + reason: string; + authorId: SyntheticId; + amendedAt: string; +}; + +export type ReviewTrigger = { + id: SyntheticId; + patientId: SyntheticId; + managementPlanId: SyntheticId; + source: "plan_use_feedback" | "presentation_outcome" | "plan_deviation" | "formal_review" | "contact_verification"; + sourceId: SyntheticId; + reason: string; + status: "open" | "resolved"; + createdAt: string; + resolvedAt: string | null; + resolution: string | null; +}; + +export type IdentificationReview = { + id: SyntheticId; + patientId: SyntheticId; + reason: string; + referredBy: SyntheticId; + referredAt: string; + status: "open" | "closed"; +}; + +export type AuditEventType = + | "management_draft_created" + | "management_draft_saved" + | "management_version_submitted" + | "management_version_returned" + | "management_version_approved" + | "management_version_withdrawn" + | "management_review_recorded" + | "presentation_recorded" + | "presentation_amended" + | "safety_plan_draft_created" + | "safety_plan_draft_saved" + | "safety_plan_made_current" + | "safety_plan_print_intent_opened" + | "email_intent_opened" + | "call_intent_opened" + | "identification_review_created" + | "cmht_contact_verified" + | "review_trigger_resolved"; + +export type AuditEvent = { + id: SyntheticId; + type: AuditEventType; + patientId: SyntheticId | null; + objectId: SyntheticId; + actorId: SyntheticId; + occurredAt: string; + evidence: string; +}; + +export type PrototypeOutcome = { + kind: "success" | "blocked" | "info" | "error"; + message: string; +}; + +export type EdCarePlansPrototypeState = { + scenario: PrototypeScenario; + persistence: "memory-only"; + activeUserId: SyntheticId; + selectedPatientId: SyntheticId | null; + connectivity: { online: boolean }; + permission: { available: boolean }; + identity: { certain: boolean }; + versionConflict: { active: boolean }; + users: PrototypeUser[]; + patients: Patient[]; + edSites: EdSite[]; + cmhtContacts: CmhtContact[]; + managementPlans: ManagementPlan[]; + managementPlanVersions: ManagementPlanVersion[]; + personalSafetyPlans: PersonalSafetyPlan[]; + personalSafetyPlanVersions: PersonalSafetyPlanVersion[]; + edPresentations: EdPresentation[]; + presentationAmendments: PresentationAmendment[]; + reviewTriggers: ReviewTrigger[]; + identificationPolicy: IdentificationPolicy; + identificationReviews: IdentificationReview[]; + auditEvents: AuditEvent[]; + lastOutcome: PrototypeOutcome | null; +}; + +export type ManagementDraftInput = { + ownerId: SyntheticId; + reviewDueAt: string; + revisionReason: string; + participationState: ParticipationState; + consentedSupportPeople: readonly string[]; + content: ManagementPlanContent; +}; + +export type SafetyPlanDraftInput = { + reviewDueAt: string; + patientConfirmation: PatientConfirmationState; + collaborationNote: string; + content: SafetyPlanContent; +}; + +export type NewEdPresentationInput = Omit; +``` + +`types.ts` must also define the discriminated `EdCarePlansPrototypeAction` union in Task 2 using only these canonical types. + +--- + +## Delivery Stages + +The user asked to see working software before the whole nine tasks are built. The plan therefore runs in two stages with one mandatory stop between them. + +**Stage A — Tasks 1 to 5.** Domain, reducer, gated route family and shell, Clinical Snapshot with patient search and CMHT contact, and the complete governed Management Plan lifecycle. At the end of Stage A the clinical journey the specification's acceptance criteria measure — find the patient, see whether a Current Plan exists, read the first-minute guidance, reach the CMHT, and draft/submit/compare/approve a replacement version — is fully working and testable in a browser. + +**Stage A checkpoint (mandatory stop).** Tasks 6 to 9 do not begin until the user has reviewed Stage A and said to continue. Deliberately deferred at that point, and stated as such rather than presented as complete: ED Presentation recording and amendments (Task 6), Personal Safety Plan and print (Task 7), Reviews/Team/Governance/History/System states (Task 8), and all browser, accessibility, responsive and documentation proof (Task 9). Tasks 3 and 4 leave the deferred routes rendering their `RoutePurposeSurface` specimen, which is a truthful placeholder, not a broken page. + +**Stage B — Tasks 6 to 9.** The remaining product surfaces and the full verification and documentation gate. + +Stage A does not lower any gate. Every Stage A task still runs its own RED/GREEN cycle, task review, typecheck, format, and commit exactly as written below. + +--- + +## Task 1: Domain Model, Deterministic Fixtures, Search, and Privacy-Safe Selectors + +**Outcome:** The application has one exact domain vocabulary, deterministic synthetic scenarios, searchable patient fixtures, objective Presentation Activity, safe contact URIs, and review-queue selectors without any UI or state mutation. + +**Files:** + +- Create: `src/components/ed-care-plans/mockups/types.ts` +- Create: `src/components/ed-care-plans/mockups/fixtures.ts` +- Create: `src/components/ed-care-plans/mockups/domain.ts` +- Create: `tests/ed-care-plans-domain.test.ts` + +- [ ] Add `tests/ed-care-plans-domain.test.ts` first. Import the not-yet-created domain modules and write one test for each contract below. + +```ts +it("keeps identification policy governance-pending without a numeric rule", () => { + expect(identificationPolicy).toEqual({ + id: "SYN-IDENTIFICATION-POLICY-001", + status: "pending_governance", + thresholdCount: null, + thresholdLookbackMonths: null, + manualReferralEnabled: true, + explanation: expect.stringMatching(/local clinical and privacy governance/i), + }); +}); + +it.each([ + ["Rowan", "SYN-PATIENT-001"], + ["SYN-MRN-0001", "SYN-PATIENT-001"], + ["1986-04-12", "SYN-PATIENT-001"], + ["Ro", "SYN-PATIENT-001"], +])("finds a patient by supported synthetic identity field", (query, patientId) => { + expect(searchPatients(syntheticPatients, query).map(({ id }) => id)).toContain(patientId); +}); + +it("builds a generic CMHT email intent without patient information", () => { + const contact = syntheticCmhtContacts[0]!; + const href = buildCmhtMailto(contact); + expect(href).toBe("mailto:north-river.cmht@example.org?subject=ED+Care+Plans+%E2%80%94+team+contact+request"); + expect(href).not.toMatch(/Rowan|SYN-MRN|1986|presentation|management plan/i); +}); +``` + +- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts`. A module-resolution error is setup evidence, not the RED gate. Add only the requested export signatures with empty values/throwing bodies, rerun, and confirm an assertion now fails for the intended missing domain behaviour before implementing it. +- [ ] Add the canonical unions and entity types to `types.ts`. Use readonly content arrays in preserved versions; reducer actions will replace whole versions rather than mutating nested arrays. +- [ ] Add deterministic fixture constants to `fixtures.ts` with `PROTOTYPE_NOW = "2026-08-20T14:30:00+08:00"` and these stable entity identities: + +| Entity | Stable fixtures | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Patients | `SYN-PATIENT-001` Rowan Sample; `002` Mira Example; `003` Jordan Test; `004` Evelyn Demo; `005` Alex Fiction | +| Users | `SYN-USER-ED-001` Dr Casey Example; `SYN-USER-LIAISON-001` Morgan Sample; `SYN-USER-SENIOR-001` Dr Taylor Fiction; `SYN-USER-COORD-001` Riley Demo | +| EDs | `SYN-ED-001` North River Hospital ED; `002` Coastal Plains Hospital ED; `003` Wandoo District Hospital ED | +| CMHTs | `SYN-CMHT-001` North River CMHT; `002` Coastal Plains Older Adult CMHT; `003` Wandoo District CMHT | +| Scenarios | Normal Current Plan; overdue Current plus Awaiting Approval; no Current Plan; withdrawn Current; mixed-helpfulness Review Trigger; unverified contact | + +- [ ] Give every fictional CMHT a reserved-example shared mailbox, fictional Australian mobile, hours, timezone, coordinator, after-hours path, verification date, and verification state. Add the separately labelled official crisis-contact fixtures and source URLs specified in Global Constraints. +- [ ] Add enough ED Presentation fixtures to derive each displayed activity count from presentation timestamps. Rowan must show `7 ED presentations in rolling 12 months`; this is observation-only copy and is never compared with policy. +- [ ] Implement in `domain.ts`: `searchPatients`, `getPatientById`, `getCurrentManagementPlanVersion`, `getOpenManagementDraft`, `getCurrentSafetyPlanVersion`, `countPresentationActivity`, `buildPatientSnapshot`, `buildCmhtMailto`, `buildCmhtTel`, `getReviewQueues`, `canPerformAction`, and `assertSingleCurrentVersion`. +- [ ] Make `searchPatients` trim and case-fold input and match only synthetic full name, preferred name, alias, MRN, and ISO/display DOB. Do not search plan, presentation, safety-plan, cultural, support-person, or clinical text. +- [ ] Make `getReviewQueues` return exactly `{ awaitingApproval, reviewSuggested, contactVerification, identificationReview }`, each ordered oldest-actionable-first and never severity-ranked. +- [ ] Finish the remaining tests: all entity IDs are synthetic; Current selection is unique; Draft stays separate; raw activity derives from episodes; public contacts match the exact authorised list; no fixture uses stigmatising labels; safe URI builders contain no patient field. +- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts`. Expected GREEN: the new domain test file passes with zero failures. +- [ ] Run `npx prettier --write src/components/ed-care-plans/mockups/types.ts src/components/ed-care-plans/mockups/fixtures.ts src/components/ed-care-plans/mockups/domain.ts tests/ed-care-plans-domain.test.ts` and rerun the same test command. +- [ ] Review the task diff for duplicate entity types, mutable preserved versions, non-synthetic IDs, hidden threshold logic, and real organisation names outside the authorised public crisis fixtures. +- [ ] Commit only Task 1 files with `feat(ed-care-plans): define synthetic clinical domain`. Do not push. + +## Task 2: Pure Lifecycle Reducer and Layout-Scoped Provider + +**Outcome:** One reducer enforces the complete longitudinal lifecycle and audit semantics; one provider shares the same state across every route and resets on refresh. + +**Files:** + +- Modify: `src/components/ed-care-plans/mockups/types.ts` +- Create: `src/components/ed-care-plans/mockups/prototype-state.ts` +- Create: `src/components/ed-care-plans/mockups/prototype-provider.tsx` +- Create: `tests/ed-care-plans-prototype-state.test.ts` + +- [ ] Write `tests/ed-care-plans-prototype-state.test.ts` before production code. Start with the approval invariant and append-only presentation tests. + +```ts +it("approves an awaiting version atomically and preserves exactly one Current Plan", () => { + let state = createInitialPrototypeState("overdue-plan"); + state = prototypeReducer(state, { type: "set-active-user", userId: "SYN-USER-SENIOR-001" }); + const awaitingId = getOpenManagementDraft(state, "SYN-PATIENT-002")!.id; + const next = prototypeReducer(state, { type: "approve-management-version", versionId: awaitingId }); + const versions = next.managementPlanVersions.filter(({ planId }) => planId === "SYN-MANAGEMENT-PLAN-002"); + + expect(versions.filter(({ state }) => state === "current")).toHaveLength(1); + expect(versions.find(({ id }) => id === awaitingId)?.state).toBe("current"); + expect(versions.find(({ version }) => version === 2)?.state).toBe("superseded"); + expect(next.managementPlans.find(({ id }) => id === "SYN-MANAGEMENT-PLAN-002")?.currentVersionId).toBe(awaitingId); +}); + +it("adds a visible amendment without changing the original ED Presentation", () => { + const state = createInitialPrototypeState(); + const original = state.edPresentations.find(({ id }) => id === "SYN-PRESENTATION-001")!; + const next = prototypeReducer(state, { + type: "amend-presentation", + presentationId: original.id, + field: "assessmentOutcome", + replacementValue: "Discharged after senior review and follow-up confirmation.", + reason: "Clarify the recorded outcome.", + }); + + expect(next.edPresentations.find(({ id }) => id === original.id)?.assessmentOutcome).toBe(original.assessmentOutcome); + expect(next.presentationAmendments.at(-1)).toMatchObject({ presentationId: original.id, field: "assessmentOutcome" }); +}); +``` + +- [ ] Run `npm run test -- tests/ed-care-plans-prototype-state.test.ts`. If the import cannot resolve, add only the exported reducer signatures with throwing bodies, rerun, and confirm the first behavioural assertion fails for the intended lifecycle reason before implementing the transition. +- [ ] Define `createInitialPrototypeState(scenario: PrototypeScenario = "normal")` by deep-cloning fixture arrays, setting `persistence: "memory-only"`, selecting Rowan by default, and deriving scenario flags without browser APIs. +- [ ] Define `getPrototypeMutationBlockReason(state, action)` so offline, unavailable permission, identity uncertainty, and version conflict leave clinical entities unchanged and set a specific `lastOutcome`. +- [ ] Implement deterministic ID allocation as `nextSyntheticId(prefix, existingIds)` plus `nextPresentationId(state)`, and use `PROTOTYPE_NOW` plus stable per-action minute offsets. Do not use randomness or the wall clock in reducer tests. Reducer actions that receive an allocated ID must reject duplicates and the wrong synthetic prefix. +- [ ] Implement the exact discriminated action set: + +```ts +export type EdCarePlansPrototypeAction = + | { type: "select-patient"; patientId: SyntheticId } + | { type: "set-active-user"; userId: SyntheticId } + | { type: "create-management-draft"; patientId: SyntheticId } + | { type: "save-management-draft"; versionId: SyntheticId; input: ManagementDraftInput } + | { type: "submit-management-draft"; versionId: SyntheticId } + | { type: "return-management-version"; versionId: SyntheticId; reason: string } + | { type: "approve-management-version"; versionId: SyntheticId } + | { type: "withdraw-current-management-version"; patientId: SyntheticId; reason: string } + | { + type: "record-formal-management-review"; + patientId: SyntheticId; + reason: string; + nextReviewDueAt: string; + } + | { type: "record-presentation"; presentationId: SyntheticId; input: NewEdPresentationInput } + | { + type: "amend-presentation"; + presentationId: SyntheticId; + field: "assessmentOutcome"; + replacementValue: string; + reason: string; + } + | { + type: "amend-presentation"; + presentationId: SyntheticId; + field: "disposition"; + replacementValue: Disposition; + reason: string; + } + | { type: "create-safety-plan-draft"; patientId: SyntheticId } + | { type: "save-safety-plan-draft"; versionId: SyntheticId; input: SafetyPlanDraftInput } + | { type: "make-safety-plan-current"; versionId: SyntheticId } + | { type: "record-safety-plan-print-intent"; patientId: SyntheticId } + | { type: "record-contact-intent"; patientId: SyntheticId; cmhtId: SyntheticId; channel: "email" | "call" } + | { type: "create-identification-review"; patientId: SyntheticId; reason: string } + | { type: "verify-cmht-contact"; cmhtId: SyntheticId } + | { type: "resolve-review-trigger"; triggerId: SyntheticId; resolution: string } + | { type: "apply-scenario"; scenario: PrototypeScenario } + | { type: "clear-outcome" } + | { type: "reset" }; +``` + +- [ ] Enforce role permissions through `canPerformAction`. The reducer must independently recheck permission; unavailable UI is not the transition guard. +- [ ] Make approval validate: actor role, version state, named approver, complete required content, and an existing plan. In one returned state, supersede the previous Current version, make the submitted version Current, set approver/approval date/review state, update `currentVersionId`, and append one audit event. +- [ ] Make return-for-changes require a non-empty reason and return Awaiting Approval to Draft without touching Current. Make withdrawal require a non-empty reason and leave no Current version. +- [ ] Make `record-presentation` append an episode and audit event. If helpfulness is `mixed` or `not_helpful`, `reviewSuggested` is true, disposition is a mental-health/medical admission, or a material deviation is recorded, append one deduplicated open Review Trigger. +- [ ] Make `amend-presentation` append original/replacement/reason/actor/time evidence while keeping the episode immutable. Restrict amendable fields to the union in the action. +- [ ] Make Safety Plan publication require an editable Draft, supersede the prior Current Safety Plan, and set the new version Current without consulting senior-approval state. +- [ ] Make contact-intent actions append only intent audit events; make manual identification referral append an Identification Review and audit event without creating a Management Plan or Review Trigger. +- [ ] Add `EdCarePlansPrototypeProvider`, `useEdCarePlansPrototype`, and an online/offline listener to `prototype-provider.tsx`. The provider calls `useReducer` once and performs no persistence. +- [ ] Complete reducer tests for draft/current separation, non-senior approval refusal, return reason, withdrawal, formal review, Review Trigger creation/deduplication, Safety Plan independence, manual referral, intent-only audit language, degraded-state refusal, reset, and scenario reconstruction. +- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts`. Expected GREEN: both files pass with zero failures. +- [ ] Format the four Task 2 files, rerun the two tests, and inspect the diff for in-place array mutation, wall-clock/random IDs, overclaimed audit events, and transition paths that bypass permission checks. +- [ ] Commit only Task 2 files with `feat(ed-care-plans): enforce longitudinal plan lifecycles`. Do not push. + +## Task 3: Gated Route Family, Literal Navigation, and Responsive Clinical Shell + +**Outcome:** All approved URLs compile, validate finite synthetic parameters, share one provider, remain directly reconstructable, use the existing Developer-area authorization boundary, and render a responsive Clinical Snapshot shell without global mockup search chrome. + +**Files:** + +- Create: `src/components/ed-care-plans/mockups/routes.ts` +- Create: `src/components/ed-care-plans/mockups/ed-care-plans-shell-frame.tsx` +- Create: `src/components/ed-care-plans/mockups/ed-care-plans.module.css` +- Create: `src/components/ed-care-plans/mockups/routable-suite.tsx` +- Create: `src/components/ed-care-plans/mockups/index.ts` +- Create: `src/app/mockups/ed-care-plans/layout.tsx` +- Create: `src/app/mockups/ed-care-plans/loading.tsx` +- Create: `src/app/mockups/ed-care-plans/route-page.tsx` +- Create: every `page.tsx` listed below +- Modify: `src/lib/developer-area/headers.ts` +- Modify: `src/proxy.ts` comments describing gated prefixes +- Modify: `src/app/mockups/mockups-layout-client.tsx` +- Modify: `src/app/mockups/development/page.tsx` +- Modify: `tests/proxy.test.ts` +- Create: `tests/ed-care-plans-route-files.test.ts` +- Create: `tests/ed-care-plans-linked-routes.dom.test.tsx` + +- [ ] Add route-file tests first. Pin the exact route registry and every expected file path: + +```ts +expect(ED_CARE_PLANS_ROUTES).toEqual({ + home: "/mockups/ed-care-plans", + patients: "/mockups/ed-care-plans/patients", + patient: "/mockups/ed-care-plans/patients/SYN-PATIENT-001", + managementPlan: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/management-plan", + managementPlanEdit: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/management-plan/edit", + managementPlanReview: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/management-plan/review", + safetyPlan: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/safety-plan", + safetyPlanEdit: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/safety-plan/edit", + safetyPlanPrint: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/safety-plan/print", + presentations: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/presentations", + newPresentation: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/presentations/new", + presentation: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/presentations/SYN-PRESENTATION-001", + history: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/history", + reviews: "/mockups/ed-care-plans/reviews", + team: "/mockups/ed-care-plans/team", + governance: "/mockups/ed-care-plans/governance", + systemStates: "/mockups/ed-care-plans/system-states", +}); +``` + +- [ ] In the same test file, recursively read only the new route/component namespaces and reject `fetch(`, storage APIs, cookies, OpenAI/Supabase/analytics imports, route handlers, and non-mockup application routes. +- [ ] Add proxy tests that expect production access to pass through to `DeveloperAreaGate` for the base, patient deep route, and presentation deep route; expect similarly prefixed archive paths to remain blocked. +- [ ] Add initial DOM tests that render `EdCarePlansRouteSurface` with an injected `navigate` spy, then assert one `

`, the synthetic boundary, desktop rail links, phone navigation, active destination, and route headings. +- [ ] Run `npm run test -- tests/ed-care-plans-route-files.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx tests/proxy.test.ts`. The file-existence and proxy assertions must fail directly. If the DOM import cannot resolve, add only the route-surface export signature returning `null`, rerun, and confirm its landmark/navigation assertion fails before implementing the shell. +- [ ] Run `npm run workflow:design-sweep -- --files src/app/mockups/ed-care-plans,src/components/ed-care-plans/mockups,src/app/mockups/mockups-layout-client.tsx --write-evidence` before UI implementation, review the ignored `.local/workflow-evidence` output, and record the result in the SDD report. This is the repository UI skill's design-system preflight, not product verification. +- [ ] Run `npm run ensure`, use only the repository-printed URL, and confirm `/api/local-project-id` identifies this Database project. Do not attach to or stop another project's server. +- [ ] Create `routes.ts` with `ED_CARE_PLANS_BASE`, the exact `ED_CARE_PLANS_ROUTES` object above, `edCarePlansRoute.patient(patientId)`, `managementPlan(patientId)`, `safetyPlan(patientId)`, `presentations(patientId)`, `presentation(patientId, presentationId)`, `scenario(name, route?)`, and `withQuery(route, key, value)`. +- [ ] Export finite `SYNTHETIC_PATIENT_PARAMS` and `SYNTHETIC_PRESENTATION_PARAMS` aligned to fixtures plus `isSyntheticPatientId` and `isSyntheticPresentationForPatient`. Unknown dynamic parameters must call `notFound()` in the server page. +- [ ] Create these page files; static pages return ``, patient pages await `params: Promise<{ patientId: string }>`, and the episode page awaits both IDs: + +```text +src/app/mockups/ed-care-plans/page.tsx +src/app/mockups/ed-care-plans/patients/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/management-plan/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/management-plan/edit/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/management-plan/review/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/safety-plan/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/safety-plan/edit/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/safety-plan/print/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/presentations/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/presentations/new/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/presentations/[presentationId]/page.tsx +src/app/mockups/ed-care-plans/patients/[patientId]/history/page.tsx +src/app/mockups/ed-care-plans/reviews/page.tsx +src/app/mockups/ed-care-plans/team/page.tsx +src/app/mockups/ed-care-plans/governance/page.tsx +src/app/mockups/ed-care-plans/system-states/page.tsx +``` + +- [ ] Add `generateStaticParams()` to every dynamic page from the finite parameter lists. Do not duplicate literal IDs across page files. +- [ ] Create `layout.tsx` that nests `DeveloperAreaGate` outside `EdCarePlansPrototypeProvider`. Create one `loading.tsx`/Suspense fallback that exposes `aria-busy` and no fake patient content. +- [ ] Add `/mockups/ed-care-plans` to `DEVELOPER_GATED_PATH_PREFIXES`, update the proxy's explanatory comment, and update proxy tests. Do not widen access to all `/mockups/**`. +- [ ] Add `isEdCarePlansMockup` to `mockups-layout-client.tsx`; use the same base-or-descendant test as Caring Contact and exclude the route family from both shared composer and shared chrome. +- [ ] Add an ED Care Plans surface to `DEVELOPMENT_SURFACES` with a literal home link and deep links for Patients, Reviews, Governance, and System states. +- [ ] Build `EdCarePlansShellFrame` with desktop `Home`, `Patients`, `Reviews`, `Team`, and `Governance` links; a phone `Home`, `Patients`, `Reviews`, and `More` navigation; one search slot; displayed synthetic user/role; `Synthetic prototype — fictional data only`; page title; and one action slot. +- [ ] Use `Sheet` for phone More navigation and keep the bottom dock outside print. Every link comes from `routes.ts`; every button has a handler. Set `aria-current="page"` from the resolved destination. +- [ ] In `ed-care-plans.module.css`, scope all selectors below `.appRoot`; implement desktop rail plus content, phone single column, top safe-area padding using `max(..., var(--safe-area-top))`, phone dock clearance, reduced-motion overrides, forced-colour borders, and print suppression through `data-print-hide`. +- [ ] Build `EdCarePlansRouteSurface({ pathname, query, navigate })` and `EdCarePlansRoutableSuite()`. The testable surface receives strings and a navigation callback; the router wrapper supplies `usePathname`, `useSearchParams`, and `router.push`. +- [ ] For this task only, route content is a semantic `RoutePurposeSurface` containing the approved route heading and purpose copy. It is a working shell specimen, contains no unavailable controls, and is replaced route-by-route in Tasks 4–8. +- [ ] Run the three-test RED command again. Expected GREEN: route files, DOM shell, and proxy boundary all pass. +- [ ] Run `npm run typecheck`. Expected GREEN: Next 16 async params, client/server boundaries, CSS module, and route imports compile. +- [ ] Format all Task 3 files, rerun the three tests and typecheck, and inspect literal links, gate scope, focus names, 48 px targets, and absence of raw patient content in query strings. +- [ ] Commit Task 3 with `feat(ed-care-plans): add gated clinical route shell`. Do not push. + +## Task 4: Clinical Snapshot, Patient Search, Current Plan Hierarchy, and CMHT Actions + +**Outcome:** An authorised synthetic ED clinician can search supported identity fields, select the right patient, recognise Current-versus-Draft state, read first-minute guidance, and launch a privacy-safe CMHT email or call intent. + +**Files:** + +- Create: `src/components/ed-care-plans/mockups/prototype-ui.tsx` +- Create: `src/components/ed-care-plans/mockups/clinical-snapshot-page.tsx` +- Create: `src/components/ed-care-plans/mockups/patient-directory.tsx` +- Create: `src/components/ed-care-plans/mockups/patient-workspace.tsx` +- Create: `src/components/ed-care-plans/mockups/patient-navigation.tsx` +- Create: `src/components/ed-care-plans/mockups/contact-actions.tsx` +- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` +- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` + +- [ ] Add failing DOM tests for search, selection, Current hierarchy, overdue/no-plan states, patient tabs, and contact intents before creating any Task 4 component. + +```tsx +it("finds a synthetic patient and keeps Current Plan above an awaiting draft", async () => { + const user = userEvent.setup(); + renderRoute(ED_CARE_PLANS_ROUTES.home, "scenario=overdue-plan"); + await user.type(screen.getByRole("searchbox", { name: "Search synthetic patients" }), "SYN-MRN-0002"); + await user.click(screen.getByRole("button", { name: /Open Mira Example/i })); + + const workspace = screen.getByRole("region", { name: "Mira Example clinical snapshot" }); + expect(within(workspace).getByRole("heading", { level: 2, name: "Current Plan" })).toBeInTheDocument(); + expect(within(workspace).getByText(/Awaiting Approval version 3/i)).toBeInTheDocument(); + expect(within(workspace).getByText(/Current version 2 remains in use/i)).toBeInTheDocument(); +}); + +it("exposes only intent-safe CMHT launch links", () => { + renderRoute(ED_CARE_PLANS_ROUTES.patient); + expect(screen.getByRole("link", { name: "Email North River CMHT" })).toHaveAttribute( + "href", + "mailto:north-river.cmht@example.org?subject=ED+Care+Plans+%E2%80%94+team+contact+request", + ); + expect(screen.getByRole("link", { name: "Call North River CMHT" })).toHaveAttribute("href", "tel:+61491570101"); +}); +``` + +- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "synthetic patient|CMHT|Current Plan|No Current Plan|patient sections"`. Confirm RED because the product surfaces do not exist. +- [ ] Add ED Care Plans-specific `StatusMark`, `DefinitionRow`, `SectionFrame`, `SyntheticMarker`, and `ReviewWarning` compositions to `prototype-ui.tsx`. These combine tokens and shared primitives; they must not recreate Button, fields, tabs, dialogs, or sheets. +- [ ] Implement `PatientDirectory` with `SearchField`, recent patients, objective rolling counts, explicit lookback labels, manual-referral entry point, keyboard-operable row buttons, and deterministic no-results content. Keep all search state local and all selected-patient state in the provider. +- [ ] Make Home a desktop split of directory and selected workspace. On phone, present the directory first and use route navigation to the full-width patient workspace rather than retaining a compressed second column. +- [ ] Implement `PatientWorkspace` identity band with fictional marker, name, MRN, DOB, age cohort, preferred name, pronouns, home health service, plan currency, Safety Plan currency, CMHT verification, and Presentation Activity. +- [ ] Render a central Current Plan summary with preferred engagement, what helps, what may increase distress, immediate continuity considerations, CMHT coordination, owner, approver, version, approval date, review date/state, and the mandatory fresh-assessment boundary. +- [ ] If a Draft/Awaiting Approval exists, render it in a separate secondary region with exact state and state that Current remains in use. If no Current exists, say `No Current Plan` and never promote a Draft visually. If Current is overdue, keep the content readable below an amber text warning. +- [ ] Implement patient navigation links for `Overview`, `Management Plan`, `Personal Safety Plan`, `ED Presentations`, and secondary `History`, all generated from the selected patient's ID. +- [ ] Implement `ContactActions` with displayed mailbox, number, hours, coordinator, after-hours route, verification date/state, and external anchors. `onClick` dispatches `record-contact-intent`; success copy says only that the external application was requested. +- [ ] In `launch-failure`, leave contact details visible, intercept the action, and show what happened/what it means/what the user can do. In `unverified-contact`, keep the details visible with warning copy and a Reviews link. +- [ ] Wire Home, Patients, and patient Overview paths in `routable-suite.tsx`; remove their Task 3 route-purpose surfaces. +- [ ] Complete DOM tests for all supported search fields, empty query/results, route selection, visible Current status, Awaiting Approval separation, no Current, overdue, withdrawn, contact audit intent, unverified warning, launch failure, and accessible patient navigation. +- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 4 files, rerun the same checks, and inspect the 30-second snapshot hierarchy against the spec. +- [ ] Commit Task 4 with `feat(ed-care-plans): deliver searchable clinical snapshot`. Do not push. + +## Task 5: Management Plan Read, Draft, Compare, Return, Approval, Review, and Withdrawal + +**Outcome:** The complete clinician-facing plan is readable, a replacement version can be drafted and submitted, a named senior clinician can compare and approve or return it, and withdrawal/review remain explicit and audited. + +**Files:** + +- Create: `src/components/ed-care-plans/mockups/management-plan-pages.tsx` +- Create: `src/components/ed-care-plans/mockups/management-plan-form.tsx` +- Create: `src/components/ed-care-plans/mockups/management-plan-diff.tsx` +- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` +- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` + +- [ ] Add failing DOM tests for full-plan content, create/edit Draft, submit, version comparison, permission refusal, return-for-changes, approval, formal review, and withdrawal. + +```tsx +it("requires named senior approval before an awaiting version becomes Current", async () => { + const user = userEvent.setup(); + renderRoute(edCarePlansRoute.managementPlanReview("SYN-PATIENT-002"), "scenario=overdue-plan"); + await user.selectOptions(screen.getByRole("combobox", { name: "Prototype role" }), "SYN-USER-SENIOR-001"); + await user.click(screen.getByRole("button", { name: "Approve version 3" })); + const dialog = screen.getByRole("dialog", { name: "Approve Management Plan version 3" }); + await user.click(within(dialog).getByRole("button", { name: "Approve and make Current" })); + + expect(screen.getByRole("heading", { level: 2, name: "Current Plan" })).toBeInTheDocument(); + expect(screen.getByText(/Current version 3/i)).toBeInTheDocument(); + expect(screen.getByText(/Approved by Dr Taylor Fiction/i)).toBeInTheDocument(); +}); +``` + +- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "Management Plan|senior approval|return for changes|withdraw"`. Confirm RED for missing Management Plan surfaces. +- [ ] Implement the read page with Current summary first, full structured sections second, review state, open triggers, owner/approver metadata, separate proposed version, version list, and fresh-assessment boundary repeated near clinical guidance. +- [ ] Use `ManagementPlanForm` for both new Draft and edit Draft. Initialise from Current when creating a replacement; expose owner, next review date, revision reason, structured inputs for the six first-minute sections, and patient/communication/cultural preferences; preserve unchanged full-plan sections from the source version. +- [ ] Validate required fields in the form, render a linked error summary, focus the first invalid field, and dispatch `create-management-draft`/`save-management-draft` only after local validation. +- [ ] Add `Save Draft` and `Submit for senior approval`. Submission uses `ConfirmDialog`, changes only Draft to Awaiting Approval, then navigates to the review route. The existing Current remains visible. +- [ ] Implement `ManagementPlanDiff` as semantic sections with `Added`, `Changed`, `Removed`, and `Unchanged` labels; compare the submitted version against the Current version without clinical interpretation. +- [ ] On the review page, show named author, owner, proposed approver, revision reason, current/proposed versions, patient participation, and change table. Do not allow edits while Awaiting Approval. +- [ ] Return-for-changes opens a Sheet with required reason; on confirm, dispatches the return action and navigates to edit. Approval is available only to `senior_clinician`, opens a plain-language ConfirmDialog, and dispatches the atomic reducer action. +- [ ] Add formal-review and withdrawal actions on the Current plan. Formal review requires a reason plus next review date and updates review evidence without changing plan content or creating a new version. Withdrawal requires a reason, explicit confirmation, and shows `No Current Plan` afterward without restoring history. +- [ ] Show unavailable actions with the repository's stated-reason pattern when the active role, offline state, permission state, identity state, or version state blocks them. The reducer remains the final guard. +- [ ] Wire Management Plan read/edit/review paths in `routable-suite.tsx`; remove their Task 3 route-purpose surfaces. +- [ ] Complete DOM tests for field errors, save/submit, read-only Awaiting Approval, Current preservation, diff labels, non-senior refusal, return reason, approval metadata, exactly one Current, overdue formal review, withdrawal, offline/version-conflict refusal, and live announcements. +- [ ] Run `npm run test -- tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 5 files, rerun both checks, and inspect every action label and state transition against the glossary. +- [ ] Commit Task 5 with `feat(ed-care-plans): implement governed management plans`. Do not push. + +--- + +## Stage A Checkpoint — stop here for user review + +**Do not start Task 6 until the user has reviewed Stage A and asked to continue.** + +- [ ] Run the complete Stage A focused test set and record the decisive pass line: + +```powershell +npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-route-files.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx tests/proxy.test.ts +``` + +- [ ] Run `npm run typecheck`. Expected GREEN. +- [ ] Run `npm run ensure`, confirm `/api/local-project-id` identifies this Database project, and use only the printed URL. Do not assume a port and do not disturb another project's server. +- [ ] Walk the Stage A journey in the running app as an ED clinician would: search a synthetic patient, open the Clinical Snapshot, confirm Current versus Draft hierarchy, read the first-minute guidance, see the CMHT contact block, then open a plan, create a draft, submit it, compare it, and approve it as the senior clinician. Check it at desktop width and at 390 px. +- [ ] Report to the user in plain language: what works, what is deliberately not built yet, the exact test evidence, and the local URL to look at. State plainly that Chromium/browser, accessibility, print and responsive proof are Task 9 work and have not run. +- [ ] Wait for the user's decision. Do not proceed to Task 6 on your own judgment; this checkpoint is one of the four things that stop an SDD controller. + +--- + +## Task 6: ED Presentation Timeline, Concise Recording, Plan-Use Feedback, and Visible Amendments + +**Outcome:** Clinicians can see the longitudinal episode timeline, record a concise ED Presentation, capture plan availability/use/helpfulness, create a human Review Trigger when indicated, and correct outcome/disposition through append-only amendments. + +**Files:** + +- Create: `src/components/ed-care-plans/mockups/presentation-pages.tsx` +- Create: `src/components/ed-care-plans/mockups/presentation-form.tsx` +- Create: `src/components/ed-care-plans/mockups/presentation-timeline.tsx` +- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` +- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` + +- [ ] Add failing DOM tests for the chronological timeline, new-presentation validation, current-version linkage, helpfulness feedback, Review Trigger creation, detail view, and amendment preservation. + +```tsx +it("records plan-use feedback and creates a Review Suggested item without changing the plan", async () => { + const user = userEvent.setup(); + renderRoute(edCarePlansRoute.newPresentation("SYN-PATIENT-001")); + await user.type(screen.getByLabelText("Presenting indication"), "Escalating distress and reduced sleep."); + await user.type(screen.getByLabelText("Assessment outcome"), "Discharged with CMHT follow-up confirmed."); + await user.selectOptions(screen.getByLabelText("Disposition"), "discharged_home"); + await user.selectOptions(screen.getByLabelText("Was the Current Plan used?"), "partially_used"); + await user.selectOptions(screen.getByLabelText("Was the plan helpful?"), "mixed"); + await user.type(screen.getByLabelText("Why is review suggested?"), "The sensory guidance needs clarification."); + await user.click(screen.getByRole("button", { name: "Record ED presentation" })); + + expect(screen.getByRole("status")).toHaveTextContent(/ED Presentation recorded in this synthetic session/i); + expect(screen.getByText(/Review Suggested/i)).toBeInTheDocument(); + expect(screen.getByText(/Current version 3/i)).toBeInTheDocument(); +}); +``` + +- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "ED Presentation|plan-use|amendment"`. Confirm RED because the episode surfaces do not exist. +- [ ] Implement `PresentationTimeline` as a descending semantic list with date/site, indication, outcome, disposition, linked plan version, plan-use summary, CMHT attempt outcome, Review Suggested text, and visible amendment count. Use a line-and-node treatment visually and retain complete text equivalents. +- [ ] Implement the list page with objective rolling counts, explicit observation windows, site filter, disposition filter, and `Record ED presentation` link. Do not add eligibility or severity labels. +- [ ] Implement `PresentationForm` with persistent labels for arrival date/time, fictional ED, concise indication, concise assessment outcome, disposition, CMHT contact attempt/outcome, plan availability, use, helpfulness, deviation/reason, and review suggestion/reason. +- [ ] Default the linked Management Plan Version to the Current version at form open; if none exists, show `No Current Plan was available` and submit `managementPlanVersionId: null`. Never link a Draft as the available plan. +- [ ] Require indication, outcome, disposition, site, plan availability/use/helpfulness, and review reason when feedback/deviation/disposition creates a trigger. Use an error summary and focus the first invalid field. +- [ ] On save, call `nextPresentationId(state)`, dispatch `record-presentation` with that ID, announce only local synthetic recording, and navigate to the matching detail route. The reducer validates the caller-provided synthetic ID before appending. +- [ ] Implement the detail page with the original immutable episode, recording clinician/time, linked plan version, plan-use feedback, outcome, Review Trigger, and an `Amend recorded outcome` action. +- [ ] The amendment Sheet permits only assessment outcome or disposition, shows the original value, and requires replacement plus reason. On save, display original and latest amendment together; do not replace the original DOM text. +- [ ] Wire presentation list/new/detail paths in `routable-suite.tsx`; validate that the episode belongs to the patient before rendering and show identity uncertainty rather than another patient's data on mismatch. +- [ ] Complete DOM tests for required fields, no-Current linkage, Current linkage, helpful/no-trigger, mixed/not-helpful trigger, admission trigger, deviation trigger, deduplication, deterministic navigation, original-plus-amendment rendering, and mismatched identity refusal. +- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 6 files, rerun the checks, and inspect that no form field duplicates a full ED note, diagnosis list, medication chart, or risk assessment. +- [ ] Commit Task 6 with `feat(ed-care-plans): track ED presentation continuity`. Do not push. + +## Task 7: Patient-Owned Personal Safety Plan, Independent Versioning, and Privacy-Aware Print + +**Outcome:** The current Personal Safety Plan is clearly separate from the Management Plan, can be co-produced and versioned without senior approval, and prints a patient-facing seven-step copy with minimum necessary synthetic identifiers and verified crisis contacts. + +**Files:** + +- Create: `src/components/ed-care-plans/mockups/safety-plan-pages.tsx` +- Create: `src/components/ed-care-plans/mockups/safety-plan-form.tsx` +- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` +- Modify: `src/components/ed-care-plans/mockups/ed-care-plans.module.css` +- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` + +- [ ] Add failing DOM tests for seven patient-voice sections, confirmation state, independent publication, no senior approval, print content minimisation, print intent, and print failure. + +```tsx +it("renders a print-only patient copy without ED Presentation or audit content", async () => { + const print = vi.spyOn(window, "print").mockImplementation(() => undefined); + const user = userEvent.setup(); + renderRoute(edCarePlansRoute.safetyPlanPrint("SYN-PATIENT-001")); + + expect(screen.getByRole("heading", { level: 1, name: "My Personal Safety Plan" })).toBeInTheDocument(); + expect(screen.getAllByRole("heading", { level: 2 })).toHaveLength(7); + expect(screen.queryByText(/ED Presentation timeline/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/audit history/i)).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Print Personal Safety Plan" })); + expect(print).toHaveBeenCalledOnce(); +}); +``` + +- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "Personal Safety Plan|print"`. Confirm RED because the Safety Plan surfaces do not exist. +- [ ] Implement the view page with patient-owned language, version, last-confirmed date, review state, patient confirmation state, collaboration author, and the seven exact headings from the specification. +- [ ] Keep the clinician-facing plan boundary explicit: this document supports the person's own coping and support actions and is not a Management Plan or a replacement for fresh assessment. +- [ ] Implement `SafetyPlanForm` for a new or existing Draft. Use a labelled repeatable textarea/list treatment for all seven content keys and structured personal-support name/relationship/phone entries. +- [ ] Require at least one item in every section, a next review date, a collaboration note, and one of the four patient-confirmation states. Do not treat declined or unavailable as non-compliance. +- [ ] Save the Draft independently, then use `Make current Personal Safety Plan` with a plain confirmation. Do not show or call the Management Plan approval action. The reducer supersedes the former Current Safety Plan. +- [ ] Implement the print route with `My Personal Safety Plan` as the patient-facing heading, preferred name plus synthetic MRN only, version, last-confirmed date, seven sections, personal supports, CMHT contact, `000`, MHERL Perth/Peel, Rurallink, service caveats/hours, public source links, synthetic watermark, and deterministic printed-at text. +- [ ] Add `data-print-hide` to shell/navigation/actions/audit links and `data-print-only` to the printed timestamp/watermark as needed. In `@media print`, use monochrome-safe borders, large readable type, no clipped sections, no fixed dock, and page-break avoidance for each safety section. +- [ ] The print button dispatches `record-safety-plan-print-intent` and calls `window.print()`. In `print-failure`, do not call print; retain the complete plan and show retry instructions. +- [ ] Wire Safety Plan view/edit/print paths in `routable-suite.tsx`; remove their Task 3 route-purpose surfaces. +- [ ] Complete DOM tests for section labels, edit errors, independent Draft/Current transition, declined/unavailable language, minimum-necessary print content, exact public contacts/caveats, absence of ED/audit content, intent-only audit, and failure recovery. +- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 7 files, rerun the checks, and inspect print DOM and CSS for hidden interactive controls and monochrome state clarity. +- [ ] Commit Task 7 with `feat(ed-care-plans): add printable personal safety plans`. Do not push. + +## Task 8: Reviews, Team, Governance, Audit History, and Deterministic Degraded States + +**Outcome:** All remaining routes are real operational surfaces: focused human worklists, CMHT/owner directory, combined evidence chronology, explicit pending identification governance, and reconstructable failure/degraded specimens. + +**Files:** + +- Create: `src/components/ed-care-plans/mockups/operations-pages.tsx` +- Create: `src/components/ed-care-plans/mockups/history-page.tsx` +- Create: `src/components/ed-care-plans/mockups/system-states-page.tsx` +- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` +- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` + +- [ ] Add failing DOM tests for all four Review queues, manual Identification Review, contact verification, team details, governance policy nulls, combined history, scenario deep links, and mutation refusal. + +```tsx +it("creates a manual Identification Review without creating a plan or applying eligibility", async () => { + const user = userEvent.setup(); + renderRoute(ED_CARE_PLANS_ROUTES.patients, "scenario=no-current-plan"); + await user.click(screen.getByRole("button", { name: "Refer Jordan Test for Identification Review" })); + const dialog = screen.getByRole("dialog", { name: "Refer for Identification Review" }); + await user.type( + within(dialog).getByLabelText("Reason for multidisciplinary review"), + "Coordinate continuity across services.", + ); + await user.click(within(dialog).getByRole("button", { name: "Add to Identification Review" })); + + expect(screen.getByRole("status")).toHaveTextContent(/review added/i); + expect(screen.getByText("No Current Plan")).toBeInTheDocument(); + expect(screen.queryByText(/eligible|high risk|frequent flyer/i)).not.toBeInTheDocument(); +}); +``` + +- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "Identification Review|Awaiting Approval queue|Governance|System states|Audit history"`. Confirm RED because the operational surfaces do not exist. +- [ ] Implement Reviews with `Tabs` for exactly `Awaiting Approval`, `Review Suggested`, `Contact Verification`, and `Identification Review`. Each item shows reason, source/time, owner, next action, and direct patient/plan route; no severity order or dashboard scoring. +- [ ] Add manual-referral Sheet from Patients and patient Overview, require a reason, dispatch `create-identification-review`, and show the item in Reviews. The action does not create a plan or change Presentation Activity. +- [ ] Add Review Trigger resolution with required resolution text and contact-verification action with last/next verified evidence. Both are role-gated and audited. +- [ ] Implement Team as a directory of fictional CMHTs and plan owners showing catchment, shared mailbox, duty telephone, operating hours/timezone, coordinator, after-hours path, verification state/date, and current owned-plan count. +- [ ] Implement Governance with the exact prototype boundary, illustrated role responsibilities, lifecycle rules, audit-evidence limits, privacy/print/contact rules, and an Identification Policy panel that displays `Pending local governance`, `No approved threshold count`, `No approved threshold lookback`, and `Manual referral enabled`. +- [ ] Never render a candidate numeric policy, default, slider, comparison line, configuration control, or activity-to-eligibility mapping. +- [ ] Implement History as one chronological semantic list joining plan-version actions, Safety Plan actions, ED Presentation recordings/amendments, print intents, contact intents, contact verification, and identification referrals. Label intent evidence accurately and allow type filters. +- [ ] Implement System states cards and query links for every `PrototypeScenario`. `apply-scenario` reconstructs fixtures from the URL; `normal` clears scenario query. Include normal, empty, no Current, overdue, withdrawn, unverified contact, identity uncertainty, conflict, offline, permission unavailable, launch failure, and print failure. +- [ ] Each degraded state says what happened, what it means, and the available action. Offline/permission/identity/conflict states keep readable synthetic data only where specified and make every mutation unavailable with a reason. +- [ ] Add prototype-role selection to the shell from the exact synthetic users. It changes only interaction-model permission state and never claims authentication or RBAC enforcement. +- [ ] Wire Reviews, Team, Governance, History, and System states in `routable-suite.tsx`; remove all remaining Task 3 route-purpose surfaces. +- [ ] Complete DOM tests for queue membership/order, manual referral, trigger resolution, contact verification, team contacts, governance null policy, history evidence language, role selection, every scenario URL, recovery, blocked mutation, and empty state. +- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-route-files.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx tests/proxy.test.ts`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 8 files, rerun the checks, and inspect the full route map to ensure no route-purpose specimen remains. +- [ ] Commit Task 8 with `feat(ed-care-plans): complete review and governance workspace`. Do not push. + +## Task 9: Browser Journeys, Responsive and Accessibility Proof, Documentation, and Handoff Gate + +**Outcome:** The complete route family has repository-wrapped Chromium proof at the required widths/modes, generated documentation is current, clinical/privacy constraints have deterministic evidence, and the final unpushed branch is ready for user review. + +Task 9 adds cross-layer acceptance evidence, not new clinical product behaviour. Every production behaviour it exercises must already have completed a RED/GREEN reducer or DOM cycle in Tasks 1–8; do not manufacture an artificial browser failure. The Playwright project-registration change itself still follows RED/GREEN through `tests/playwright-project-isolation.test.ts`. + +**Files:** + +- Create: `tests/ui-ed-care-plans-mockup.spec.ts` +- Modify: `playwright.config.ts` +- Modify: `tests/playwright-project-isolation.test.ts` +- Modify: `package.json` +- Modify: `docs/codebase-index.md` +- Modify: `docs/site-map.md` +- Modify: generated documentation produced by `npm run docs:update` +- Create: `docs/ed-care-plans/interaction-matrix.md` +- Create: `docs/ed-care-plans/clinical-language-trace.md` +- Create: `docs/ed-care-plans/accessibility-acceptance.md` +- Create: `docs/ed-care-plans/implementation-handoff.md` +- Create: `docs/ed-care-plans/verification-report.md` + +- [ ] Modify `tests/playwright-project-isolation.test.ts` first so it requires `ui-ed-care-plans-mockup` in the mockup project and rejects it from production projects. +- [ ] Run `npm run test -- tests/playwright-project-isolation.test.ts`. Confirm RED because the current Playwright matchers do not register the new spec. +- [ ] Add `ui-ed-care-plans-mockup` to the mockup test regex in both `testMatch` and `mockupSpecPattern`, then add the package script exactly: + +```json +"test:e2e:ed-care-plans-mockup": "node scripts/run-playwright.mjs --project=chromium-mockups tests/ui-ed-care-plans-mockup.spec.ts" +``` + +- [ ] Add the browser spec with `@mockup` and helper functions `gotoRoute`, `expectNoHorizontalOverflow`, `expectSyntheticBoundary`, `expectPhoneDockClearance`, and `expectSinglePageHeading`. Its stable route table must use `ED_CARE_PLANS_BASE` literals, not real patient data. +- [ ] Write browser journeys for: direct reconstruction of all 17 routes; search-to-Current Plan; Current plus Awaiting Approval hierarchy; create/submit/compare/return/approve version; record ED Presentation and Review Trigger; amend outcome; edit/publish/print Safety Plan; safe CMHT mailto/tel; manual Identification Review; resolve Review Trigger; verify contact; audit chronology; and all degraded scenarios. +- [ ] Add layout checks at 320, 390, 768, 1024, and 1440 px. At each width assert no page overflow, heading/action wrapping, Current Plan readability, CMHT/Safety access, 48 px primary targets, and phone dock clearance where applicable. +- [ ] Add keyboard traversal, Escape/focus restoration for Sheet/ConfirmDialog, reduced-motion, forced-colours, dark mode, 200% equivalent reflow, and print-media assertions. Browser automation is Chromium evidence; do not claim physical iPhone Safari or installed-PWA acceptance. +- [ ] Add optional screenshot capture behind `ED_CARE_PLANS_CAPTURE_EVIDENCE=1`, writing only ignored files under `.local/ed-care-plans/atlas`. Capture Home/patient/plan/review/presentation/Safety Print/Reviews/System states at 320, 390, and 1440 plus dark/forced-colour specimens. +- [ ] Rerun `npm run test -- tests/playwright-project-isolation.test.ts`. Expected GREEN: the new browser spec is collected only by `chromium-mockups`. +- [ ] Carry forward the reviewed Task 3 design-sweep evidence into the verification report. Do not rerun an unchanged design preflight; rerun it only if later work changed a shared UI foundation outside the ED Care Plans namespace. +- [ ] Run `npm run workflow:clinical-proof -- --files src/app/mockups/ed-care-plans,src/components/ed-care-plans/mockups,tests/ed-care-plans-domain.test.ts,tests/ed-care-plans-prototype-state.test.ts,tests/ed-care-plans-linked-routes.dom.test.tsx --write-evidence`. Record privacy, clinical-language, source, failure-mode, and rollback/prototype-boundary evidence from the ignored `.local/workflow-evidence` output. Do not run provider-backed workflows. +- [ ] Run `npm run ensure` and use only the printed URL after `/api/local-project-id` confirms this Database project. Leave unrelated local servers untouched. +- [ ] Run `npm run test:e2e:ed-care-plans-mockup`. Expected GREEN: all focused Chromium journeys pass. If a browser failure reveals a behaviour defect, first add the smallest failing reducer/DOM regression test, then fix production code and rerun the focused browser case through the wrapper. +- [ ] Run the browser command once with `ED_CARE_PLANS_CAPTURE_EVIDENCE=1` if the environment supports screenshot writing; record captured paths separately from pass/fail evidence. Screenshots are visual evidence, not correctness proof. +- [ ] Add the five handoff documents. `clinical-language-trace.md` maps every consequential label to the approved glossary/spec; `interaction-matrix.md` lists trigger/action/state/result for every control; `accessibility-acceptance.md` records viewport/input/media evidence; `implementation-handoff.md` records routes, reset boundary, fixtures, and limits; `verification-report.md` contains exact commands, exit codes, counts, failures, and unrun/provider-gated checks. +- [ ] Update `docs/codebase-index.md` with the ED Care Plans component/route family and add `/mockups/ed-care-plans` to the Developer-area gate description. Run `npm run docs:update`, then review `docs/site-map.md`, `docs/scripts-index.md`, and any other generated diff. +- [ ] Run the complete focused unit/DOM set: + +```powershell +npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-route-files.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx tests/proxy.test.ts tests/playwright-project-isolation.test.ts +``` + +Expected GREEN: zero failed files and zero failed tests. + +- [ ] Run the exact privacy/source scans and record their output: + +```powershell +rg -n -i "frequent flyer|high utili[sz]er|problem patient|risk score|automatic enrol|automatically identif" src/components/ed-care-plans src/app/mockups/ed-care-plans +rg -n "localStorage|sessionStorage|indexedDB|document\.cookie|\bfetch\s*\(" src/components/ed-care-plans src/app/mockups/ed-care-plans +rg -n -i "\b(sent|delivered|read|replied|contact completed)\b" src/components/ed-care-plans src/app/mockups/ed-care-plans +``` + +Expected result: the first two scans have no matches; the evidence-language scan has only explicit negations or test assertions and each match is manually classified. + +- [ ] Run a small Node/Vitest privacy assertion that every `mailto:` returned for every patient/contact pair equals the contact-only builder output and contains none of that patient's name, preferred name, aliases, MRN, DOB, presentation text, or plan text. +- [ ] Run `npm run typecheck`. Expected GREEN. +- [ ] Run `npm run check:production-readiness`. This is local source/config evidence only; passing does not make the prototype production-ready. +- [ ] Run `npm run build`. Expected GREEN: all routes compile and `check:bundle-budget` accepts the separate mockup-only chunk total. If the build guard reports an active task-owned dev server, verify project identity, stop only that listener, build, and restart only if browser work remains. +- [ ] Run `npm run verify:pr-local -- --dry-run --files ` and record the selected gate. Then run `npm run verify:pr-local` once because this is a complete cross-cutting handoff. Classify any environment/baseline failure accurately; do not repeat an unchanged failing aggregate. +- [ ] Run `npm run format`, inspect all formatting diffs for unrelated changes, and rerun only the checks whose inputs formatting changed. Run `git diff --check` and inspect `git status --short`. +- [ ] Re-read the approved specification and mark every acceptance criterion with direct code/test/browser evidence in `verification-report.md`. Do not infer requirement completion from a green test suite alone. +- [ ] Dispatch the most capable available final reviewer with the full branch review package, approved spec, SDD ledger rulings/parked findings, and exact verification report. Fix Critical/Important findings through one SDD fix wave and one scoped re-review. +- [ ] Commit Task 9 with `test(ed-care-plans): verify complete synthetic workflow`. Do not push, open a PR, deploy, or publish. + +## Final Acceptance Matrix + +The final reviewer and controller must verify these observable facts, not merely component presence: + +| Requirement | Direct proof | +| -------------------------------------- | -------------------------------------------------------------------------------- | +| Search and identity | Domain search tests plus search-to-plan browser journey | +| Current/Draft separation | Reducer invariant plus DOM and browser hierarchy assertions | +| Named senior approval | Permission/refusal tests plus compare-and-approve journey | +| One Current version | Pure reducer count assertion after approval | +| Overdue remains readable | DOM and 320/390/desktop browser checks | +| Withdrawal restores nothing | Reducer and patient workspace assertions | +| Concise ED Presentation | Form validation and detail/timeline browser journey | +| Append-only amendment | Original-value reducer assertion plus detail DOM/browser view | +| Review Trigger without auto-change | Reducer before/after plan equality plus Reviews queue assertion | +| Separate Safety Plan | Independent reducer transition and distinct route/heading assertions | +| Printable seven-step plan | DOM section count, print-media browser check, and content-minimisation assertion | +| CMHT email/call | Exact href privacy tests and intent-only audit history | +| Manual Identification Review | Null policy test plus manual-referral journey showing no new plan | +| No encoded numeric identification rule | Policy null types/fixtures, governance UI, and source inspection | +| Synthetic/offline boundary | Recursive namespace scan, route marker checks, reset-on-refresh browser journey | +| Accessibility/responsiveness | Keyboard/focus/media/320/390/768/1024/1440 evidence | +| Production boundary | Governance and handoff copy plus no-provider verification report | + +## Execution Handoff + +Local task commits are authorised. The controller must: + +1. Run the SDD `scripts/sdd-workspace` helper for this exact plan file and create the plan-identified `progress.md` ledger. +2. Read this plan and the approved spec once, write the required file/interface conflict table to the ledger, and rule on any conflict before Task 1. +3. Use `scripts/task-brief` for each task, dispatch one fresh implementer with the brief/report paths and explicit model, then create a review package and dispatch a separate task reviewer. +4. Complete each review/fix loop before starting the next implementer; never parallelise implementation writes. +5. Preserve every local verification result and ruling in the ledger and final handoff. +6. Stop at the Stage A Checkpoint after Task 5, report to the user, and wait. Resume at Task 6 only on the user's word. +7. Stop after the unpushed local branch is fully reviewed and verified. Commit permission does not authorise push, PR, merge, deployment, provider access, or any production action. diff --git a/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md b/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md new file mode 100644 index 000000000..c7e3cf241 --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md @@ -0,0 +1,419 @@ +# ED Care Plans — Standalone Synthetic Application Design + +**Status:** User-approved product and visual direction; implementation plan complete; implementation not started. + +## Purpose + +Build a complete, linked, synthetic application for finding people with recurrent psychiatric emergency-department presentations and making their current management plan easy to find and use. The same patient workspace also records concise ED presentation outcomes, exposes the responsible community mental health team (CMHT), and maintains a distinct printable Personal Safety Plan. + +The primary usability target is that an authorised ED clinician can find the correct synthetic patient, confirm whether a Current Plan exists, understand its first-minute continuity guidance, and reach the CMHT contact within 30 seconds. + +The product name is **ED Care Plans**, with the descriptor **Continuity for recurrent presentations**. Patient-facing and clinician-facing copy must not use “frequent flyer”. “Frequent presenter” is reserved for discussion of the service workflow, not as a label for a person. + +The canonical domain language is defined in [`docs/ed-care-plans-context.md`](../../ed-care-plans-context.md). + +## Evidence and governance grounding + +The design reflects public Australian guidance that: + +- Mental-health presentations in ED require individual care planning, appropriate transfer of information, communication with follow-up services, and patient, carer, and provider involvement. +- Comprehensive care plans are developed through shared decision-making, identify agreed goals and accountable actions, and are reviewed when they are ineffective or the person's circumstances change. +- Safety planning should be personal, practical, and accessible to the person during distress. +- Health information is sensitive information and must be collected, disclosed, printed, and communicated carefully. +- Comparable frequent-use programs apply different locally governed presentation thresholds and emphasise coordinated, non-stigmatising care rather than punitive labelling. + +Primary public references: + +- [Australian Commission on Safety and Quality in Health Care — Comprehensive Care Standard](https://www.safetyandquality.gov.au/standards/nsqhs-standards/comprehensive-care-standard/clinical-governance-and-quality-improvement-support-comprehensive-care) +- [Victorian Department of Health — Emergency departments and clinical care](https://www.health.vic.gov.au/practice-and-service-quality/emergency-departments-and-clinical-care) +- [NSW Agency for Clinical Innovation — Complex Care Coordination in NSW Emergency Departments](https://aci.health.nsw.gov.au/ie/projects/complex-care-coordination) +- [Lifeline/Beyond Blue — suicide safety planning](https://www.beyondblue.org.au/mental-health/suicide-prevention/suicide-safety-planning) +- [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) +- [Office of the Australian Information Commissioner — Guide to health privacy](https://www.oaic.gov.au/privacy/privacy-guidance-for-organisations-and-government-agencies/health-service-providers/guide-to-health-privacy/introduction-and-key-concepts) + +These sources ground the prototype's workflow and language. They do not validate the application for clinical use, establish a local identification policy, or substitute for WA Health governance review. + +## Product boundary + +### Included + +- A linked route suite under `/mockups/ed-care-plans`. +- Deterministic synthetic patients, clinicians, EDs, CMHTs, plans, presentations, and audit events. +- A multi-site fictional WA health-service context with three fictional EDs and several fictional CMHTs. +- Adults and older adults. +- Search by synthetic name, MRN, date of birth, and alias. +- Objective presentation-activity counts without an encoded eligibility threshold. +- Manual referral to an identification-review worklist. +- One longitudinal Management Plan per patient with versioning, named ownership, senior-clinician approval, review triggers, and history. +- A separate patient-owned Personal Safety Plan with its own versions and print view. +- Concise, append-only ED Presentation records with visible amendments. +- CMHT team, shared-mailbox, telephone, operating-hours, care-coordinator, and after-hours details. +- Explicit empty, stale, overdue, conflicting, offline, and unavailable states. +- Desktop, tablet, phone, keyboard, dark, reduced-motion, forced-colour, and print presentations. + +### Excluded + +- Real patient, clinician, service, site, caseload, or utilisation data. +- Persistent storage, local storage, cookies for patient state, databases, APIs, analytics, or network calls. +- EDIS, EMR, PAS, PSOLIS, pharmacy, ambulance, police, CMHT, email-provider, or identity-provider integration. +- Automatic identification, enrolment, diagnosis, risk scoring, clinical-severity scoring, treatment recommendation, allocation, or plan generation. +- Medication ordering or a parallel medication record. +- Automated email or messaging. +- A general staff inbox, chat, comments feed, or social activity. +- CAMHS, forensic, perinatal, eating-disorder, or other specialist-cohort workflows. +- Production authentication, authorisation, break-glass access, retention enforcement, or immutable audit infrastructure. +- Production deployment or any claim of clinical, privacy, security, legal, or operational readiness. + +## Resolved product decisions + +1. Each patient has one longitudinal Management Plan. Its approved versions describe continuity guidance across presentations rather than one episode of care. +2. Only a named senior-clinician approval action can make a Management Plan Version Current. Draft and Awaiting Approval versions never replace the existing Current Plan. +3. A Personal Safety Plan is a separate patient-owned document, co-produced with the patient and independently versioned. It does not require the same senior-clinician approval as the Management Plan. +4. ED Presentations are separate episode records linked to the patient and to the Management Plan Version available during that presentation. +5. The numeric presentation threshold is intentionally unspecified. The application must not encode, imply, default, or visually suggest an approved number until local governance resolves it. +6. Objective presentation activity may be displayed and sorted. Authorised users may manually refer a patient for identification review. +7. CMHT email actions launch the user's email client but must not pre-populate patient identifiers or clinical content and must not record that an email was sent or received. +8. The application remains a synthetic, reset-on-refresh prototype. “Created” and “stored” mean held in the current in-memory prototype session only. +9. (Added 21 August 2026, user decision.) The prototype is built so that real persistence could later be added without redesigning the domain: the reducer stays pure, prototype state stays plain serialisable data, and every change goes through one dispatched action. No storage layer, adapter, persistence flag, or migration scaffolding is built now, and this decision does not weaken any synthetic-only, privacy, or production-readiness boundary stated elsewhere in this specification. + +## Visual direction + +The approved primary shell is **Direction A — Clinical Snapshot**. + +### Desktop and tablet + +- A restrained Sky Graphite clinical rail carries `Home`, `Patients`, `Reviews`, `Team`, and `Governance`. +- The main header contains one prominent patient search and the context-appropriate primary action. +- The Home and Patients surfaces use a split layout: a compact patient directory or recent-patient list on the left and the selected patient workspace on the right. +- The Current Plan owns the visual centre. Status, version, approver, review state, first-minute guidance, CMHT contact, and Personal Safety Plan access appear without scrolling at an ordinary desktop viewport. +- Presentation history uses Direction B's longitudinal timeline inside the patient workspace. +- Governed approval, review, contact-verification, and manual-identification worklists use Direction C's queue treatment on the separate Reviews route. + +### Phone + +- Phone never compresses the desktop split view into two narrow columns. +- Search and recent patients form one full-width surface; selecting a patient opens a full-width workspace. +- The Current Plan summary, CMHT contact, Personal Safety Plan, and `Record ED presentation` action remain reachable without a desktop-only rail. +- Primary controls meet the repository's 48 px target convention. +- At 320 px and 390 px, meaningful top content remains below the effective safe-area inset while the background may paint edge-to-edge. + +### Repository design language + +- Reuse the repository's Clinical White / Sky Graphite token roles and shared components. +- Use true-white working surfaces, graphite navigation and command actions, and clinical blue for selection and focus. +- Reserve green for genuinely current or completed states, amber for review/staleness states, and red for unavailable, withdrawn, or blocking states. +- Every state has text and structure; colour is never the only differentiator. +- Prefer hairline borders, restrained surface shifts, and tabular numerals over nested card stacks, gradients, or heavy shadows. +- Support dark mode, forced colours, reduced motion, zoom, keyboard navigation, and print without separate information hierarchies. + +## Information architecture + +The linked application uses the following route families: + +| Route | Purpose | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `/mockups/ed-care-plans` | Search-first Home and Clinical Snapshot | +| `/mockups/ed-care-plans/patients` | Full patient directory and presentation-activity view | +| `/mockups/ed-care-plans/patients/[patientId]` | Patient overview and first-minute snapshot | +| `/mockups/ed-care-plans/patients/[patientId]/management-plan` | Full Current Plan, draft summary, review state, and version history entry points | +| `/mockups/ed-care-plans/patients/[patientId]/management-plan/edit` | Create or edit a draft version | +| `/mockups/ed-care-plans/patients/[patientId]/management-plan/review` | Compare, return for changes, and approve a submitted version | +| `/mockups/ed-care-plans/patients/[patientId]/safety-plan` | Current patient-owned Personal Safety Plan | +| `/mockups/ed-care-plans/patients/[patientId]/safety-plan/edit` | Co-produce or revise a Personal Safety Plan Version | +| `/mockups/ed-care-plans/patients/[patientId]/safety-plan/print` | Print-optimised patient copy | +| `/mockups/ed-care-plans/patients/[patientId]/presentations` | Longitudinal ED Presentation timeline | +| `/mockups/ed-care-plans/patients/[patientId]/presentations/new` | Record a concise ED Presentation | +| `/mockups/ed-care-plans/patients/[patientId]/presentations/[presentationId]` | View an episode, plan-use feedback, outcome, and amendments | +| `/mockups/ed-care-plans/patients/[patientId]/history` | Combined plan, presentation-amendment, print, and contact-action audit chronology | +| `/mockups/ed-care-plans/reviews` | Awaiting Approval, Review Suggested, contact verification, and manual identification queues | +| `/mockups/ed-care-plans/team` | Synthetic CMHT and plan-owner directory | +| `/mockups/ed-care-plans/governance` | Prototype boundary, roles, lifecycle rules, and unresolved identification policy | +| `/mockups/ed-care-plans/system-states` | Deterministic degraded-state specimens and scenario controls | + +The route suite is linked from the login-gated Developer hub rather than production navigation. Each route has a literal inbound link or an explicitly documented parameterised child relationship so repository reachability checks remain truthful. + +## Patient workspace + +Every patient workspace keeps the following identity and currency information visible: + +- Synthetic-data label. +- Synthetic name, MRN, date of birth, age cohort, preferred name, pronouns, and fictional home health service. +- Current Plan status, version, approver, approval date, owner, review date, and review state. +- Personal Safety Plan status and confirmation date. +- Primary CMHT, care coordinator, current availability, after-hours pathway, and last-verified date. +- Objective rolling presentation counts with the lookback period stated. + +The workspace uses four primary patient sections: `Overview`, `Management Plan`, `Personal Safety Plan`, and `ED Presentations`. `History` remains available as a secondary audit surface. + +## Management Plan content + +The collapsed Current Plan summary shows: + +- Preferred engagement approach. +- What usually helps. +- What may increase distress. +- Immediate continuity considerations. +- CMHT coordination expectation. +- Current owner, approver, and review state. + +The full plan adds: + +- Purpose and applicability. +- Patient goals and preferences. +- Usual presentation pattern and known contextual triggers. +- Assessment considerations and explicit “must assess afresh” boundary. +- Agreed ED approach and escalation/disposition guidance. +- Helpful and unhelpful interventions. +- Physical-health and medication-record reminders without reproducing orders. +- CMHT, primary-care, and consented support-person coordination. +- Patient and carer involvement state. +- Review triggers and the reason for the current version. + +Every view of the plan states that it supports continuity and never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgment, or legal obligations. + +## Management Plan lifecycle + +A Management Plan has one or more versions. Version state and review state remain separate: + +- Version states: `draft`, `awaiting_approval`, `current`, `superseded`, `withdrawn`. +- Review states for a Current version: `within_review`, `due_soon`, `overdue`. + +The workflow is: + +1. Create a draft from an empty plan or copy the Current version. +2. Edit required structured sections and a concise revision reason. +3. Submit the draft for senior-clinician approval. +4. Compare the submitted version against the Current version. +5. Return it to Draft with a reason or approve it with an explicit confirmation. +6. On approval, atomically make the submitted version Current and the previous Current version Superseded. +7. Withdraw a Current version only with an explicit reason and confirmation, leaving the patient with no Current Plan rather than silently restoring an older version. + +An overdue Current Plan remains visible with a prominent warning. It is not silently hidden, downgraded to Draft, or represented as expired. A replacement draft never suppresses the Current version while approval is pending. + +## ED Presentation record + +Each ED Presentation records only the continuity information this application owns: + +- Arrival date and time. +- Fictional ED site. +- Concise presenting indication. +- Concise assessment outcome. +- Disposition: discharged home, short stay, mental-health admission, medical admission, transfer, left before completion, or other. +- CMHT contact attempt and operational outcome. +- Management Plan Version available. +- Whether the plan was available, used, partially used, not applicable, or not used. +- Whether it was helpful, mixed, not helpful, or not assessed. +- Any deviation and its reason. +- Whether plan review is suggested and why. +- Recording synthetic clinician and recorded time. + +The record does not duplicate a full ED note, diagnosis list, medication chart, risk assessment, or narrative clinical history. + +ED Presentation records are append-only in the domain model. A correction creates a visible Presentation Amendment with author, time, reason, and replacement value. Plan-use feedback that suggests review creates a Review Trigger without automatically changing the plan. + +## Personal Safety Plan + +The Personal Safety Plan is written in the patient's voice and uses seven practical sections: + +1. My warning signs. +2. Making my surroundings safer. +3. My reasons for living. +4. Things I can do myself. +5. People and places that help me feel connected. +6. Family, friends, and supports I can contact. +7. Professional and emergency support. + +It has independent `draft`, `current`, and `superseded` versions plus `within_review`, `due_soon`, and `overdue` review states. The patient-confirmation state records `confirmed`, `discussed_not_confirmed`, `declined`, or `unavailable`. A clinician records the collaboration; senior-clinician approval is not required. + +The print route: + +- Uses plain language and large, scannable sections. +- Includes minimum necessary synthetic identifiers, version, last-confirmed date, personal supports, CMHT details, and urgent-help information. +- Includes a clear `000` emergency instruction and source-backed Australian and WA crisis contacts. +- Shows a synthetic-prototype watermark and a printed-at timestamp. +- Removes navigation, action controls, audit history, and unrelated presentation data. +- Uses browser print/PDF capability with repository print styles; no PDF dependency is added. + +## CMHT and contact actions + +Each CMHT record includes: + +- Fictional team name and catchment. +- Shared mailbox. +- Duty telephone. +- Operating hours and timezone. +- Named fictional care coordinator where applicable. +- After-hours label and telephone. +- Last verified date and verification state. + +`Email team` uses a `mailto:` URI containing only the shared mailbox and a generic non-clinical subject. It contains no name, MRN, date of birth, presentation reason, plan content, or other patient information. Opening the URI creates an `email_intent_opened` Audit Event; it never creates delivery, readership, response, or contact-completion evidence. + +`Call` uses a `tel:` URI and records no completed-call claim. The app displays the number and operating hours before invoking it. + +Current public crisis details must be source-verified when implemented. As verified on 20 August 2026, the prototype may present `000` for emergencies, MHERL for metropolitan Perth and Peel, and Rurallink for regional and remote WA, with the explicit statement that MHERL is not an emergency service. + +## Identification review + +The prototype includes an Identification Policy record with: + +- `status: pending_governance`. +- No threshold count. +- No lookback threshold. +- Manual referral enabled. +- A visible explanation that local clinical and privacy governance must define eligibility before operational use. + +Patient lists may display objective counts such as “7 ED presentations in 12 months” and sort by activity. They must not convert those counts into an automatic label, mandatory care pathway, severity claim, or risk score. + +An authorised synthetic user may manually add a patient to `Identification review` with a reason. The queue action initiates multidisciplinary review; it does not create or approve a plan. + +## Roles and permissions boundary + +The prototype illustrates, but does not enforce, these responsibilities: + +- ED clinician: find and read the Current Plan, record a presentation, capture plan-use feedback, and access contacts. +- ED mental-health liaison or CMHT clinician: create and edit drafts, co-produce the Personal Safety Plan, verify contacts, and respond to review triggers. +- Named senior clinician: compare, return, approve, withdraw, and record formal review of a Management Plan Version. +- Plan coordinator: manage review and identification worklists without making clinical-severity judgments. + +The displayed synthetic user and role explain why an action is available. This is interaction modelling only, not authentication, RBAC, relationship-based access, or break-glass evidence. + +## Patient, carer, communication, and cultural preferences + +The Management Plan records whether participation was `co_produced`, `discussed`, `declined`, or `patient_unavailable`. It may record consented support people and whether their involvement is current. + +The patient-owned summary can include: + +- Preferred name and pronouns. +- Communication preferences. +- What helps the person feel safe and heard. +- What may increase distress. +- Interpreter and accessibility needs. +- Sensory and environmental preferences. +- Cultural, spiritual, family, community, Aboriginal Liaison, or peer-support preferences. + +Non-participation is never labelled non-compliance. Sensitive details are shown only when needed for the approved synthetic scenario and are not made searchable. + +## Review triggers and queues + +A Review Trigger can arise from: + +- A clinician marking the plan not helpful or mixed. +- A materially different presentation or outcome. +- A mental-health or medical admission. +- Repeated plan deviations. +- A change in CMHT, care coordinator, patient preference, support involvement, or Personal Safety Plan. +- An overdue formal review. +- Unverified CMHT contact details. + +The Reviews route has four focused queues: + +1. Awaiting Approval. +2. Review Suggested. +3. Contact Verification. +4. Identification Review. + +These are action worklists, not performance dashboards. They do not rank patients by clinical severity or optimise for reducing attendance alone. + +## State architecture + +One client-side provider owns the complete synthetic application state. A pure reducer applies explicit domain actions and refuses invalid transitions. Routes read the same state rather than maintaining route-local copies. + +Primary entities are: + +- `Patient`. +- `ManagementPlan` and `ManagementPlanVersion`. +- `PersonalSafetyPlan` and `PersonalSafetyPlanVersion`. +- `EdPresentation` and `PresentationAmendment`. +- `CmhtContact`. +- `ReviewTrigger`. +- `IdentificationPolicy` and `IdentificationReview`. +- `AuditEvent`. + +All fixture IDs use a `SYN-` prefix, and every patient-facing route displays the fictional-data boundary. Refresh returns the application to deterministic fixtures. The provider performs no persistence or network access. + +## Error and degraded-state behaviour + +- **No Current Plan:** state this directly and keep any draft visibly separate. +- **Review overdue:** keep the Current Plan readable with a persistent caution and review action. +- **Withdrawn plan:** show the withdrawal reason and do not silently restore a superseded version. +- **Identity uncertainty:** require the user to return to search; never display a nearby patient's plan as a fallback. +- **Conflicting draft:** preserve both the saved synthetic version and the user's working copy, explain the conflict, and offer explicit reload or review actions. +- **Offline/unavailable:** show the last fixture-backed state only in the dedicated specimen scenario and make all mutation actions unavailable with a stated reason. +- **CMHT contact unverified:** keep details visible with a warning, last-verified date, and verification task; do not imply availability. +- **Email/call launch failure:** retain the displayed contact details and explain that the external application could not be opened. +- **Print failure:** keep the Safety Plan visible and offer retry through the browser print action. +- **Invalid transition:** leave state unchanged, show a specific explanation, and announce the result without claiming success. + +Errors use the repository's three-part content pattern: what happened, what it means, and what action is available. + +## Accessibility and responsive requirements + +- One clear page heading and landmark structure per route. +- Search, tabs, forms, tables, sheets, dialogs, and print actions use shared repository primitives where available. +- Every interactive control has a real action or an explicit unavailable reason. +- All primary targets meet the 48 px convention. +- No hover-only, colour-only, map-only, or drag-only interaction. +- Focus order follows visual order in desktop split and phone single-column layouts. +- Material state changes use restrained live announcements; routine fixture changes do not create announcement noise. +- Dense presentation or version data has a semantic list/table equivalent. +- At 200% zoom and 320 px width, content reflows without horizontal page scrolling. +- Forced colours preserves Current, Draft, Review, Withdrawn, and unavailable distinctions. +- Reduced motion removes decorative transitions without hiding state changes. +- The Personal Safety Plan print view remains readable in colour and monochrome. + +## Synthetic scenarios + +Fixtures cover at least these states without using real organisations or identifiable details: + +- Current Plan and Current Personal Safety Plan. +- Review-overdue Current Plan. +- No plan with objective presentation activity. +- Draft awaiting approval while an older Current Plan remains active. +- Returned-for-changes draft. +- Withdrawn plan. +- Presentation that found the plan helpful. +- Presentation that creates a Review Trigger. +- Amended presentation outcome. +- Unverified CMHT contact. +- Identification review created manually while the policy remains pending governance. +- Identity uncertainty, conflict, offline, launch-failure, empty, and print specimens. + +## Verification strategy + +Implementation verification is local and offline: + +- Pure reducer tests pin valid transitions, refused transitions, single-Current invariants, append-only presentation behaviour, amendments, and audit semantics. +- Domain/privacy tests reject non-synthetic identifiers, an encoded threshold, patient data in `mailto:` URIs, medication orders, automated recommendations, and delivery/readership overclaims. +- DOM tests cover search, Current-versus-Draft hierarchy, review warnings, route-local actions, error summaries, and accessible names. +- Route tests cover every linked route, static parameters, Developer-hub reachability, and literal inbound navigation requirements. +- Focused Chromium journeys cover search-to-plan, record-presentation, plan-use feedback, draft-to-approval, return-for-changes, version comparison, safety-plan edit and print, CMHT contact launch, presentation amendment, manual identification review, and degraded states. +- Visual checks cover desktop, 390 px, 320 px, dark mode, forced colours, reduced motion, 200% zoom, and print. +- Build verification confirms the Next.js route suite compiles and remains within the separate mockup bundle budget. + +No live Supabase, OpenAI, email, identity, hospital, CMHT, analytics, or provider verification is authorised or required for this prototype. + +## Acceptance criteria + +- An ED clinician can find a synthetic patient and the Current Plan within 10 seconds. +- The first-minute continuity guidance, Current status, version, approver, review state, and CMHT contact are understandable within 30 seconds. +- A clinician can record a concise ED Presentation within two minutes. +- The Current Personal Safety Plan is reachable and printable within three actions. +- A replacement draft never obscures or replaces the Current Plan before approval. +- Approving a version creates exactly one Current version and supersedes the prior Current version. +- An overdue Current Plan remains readable and unmistakably overdue. +- Presentation corrections are visible amendments rather than silent overwrites. +- Plan-use feedback can create a Review Trigger but cannot change a plan automatically. +- Objective presentation counts never become an automatic patient label or eligibility decision. +- No numeric identification threshold appears in code, fixture content, tests, or interface copy. +- CMHT email links contain no patient identifier or clinical content and never imply successful communication. +- The Personal Safety Plan remains independent from the clinician-facing Management Plan. +- Every record and screen is visibly synthetic, and refresh restores deterministic state. +- The primary journeys work at desktop, 390 px, and 320 px and remain operable by keyboard. +- Current, Draft, Review, Withdrawn, unavailable, and error states remain distinguishable without colour. +- The print view is readable in monochrome and contains only the intended patient-facing Safety Plan content. + +## Production-readiness boundary + +The prototype demonstrates an interaction and domain model only. Operational use would require, at minimum, WA Health clinical governance approval, an approved identification policy, patient and consumer co-design, privacy impact assessment, cultural-safety review, legal review, clinical-content validation, data-retention rules, authoritative record ownership, identity matching, RBAC and relationship-based access, break-glass controls, immutable audit, secure messaging, integration contracts, concurrency control, downtime procedures, cybersecurity review, accessibility acceptance, training, monitoring, incident response, and controlled deployment. + +Passing local tests or rendering a complete prototype does not satisfy any of those requirements. From 3997ea37b159885051167461ea8fd9ad76d00088 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:53:30 +0800 Subject: [PATCH 02/56] docs(ed-care-plans): revise clinical content after design review Five decisions from a proper brainstorming pass with the user, which the first Claude pass skipped by treating the Codex handover's "brainstorming is complete" note as binding. It was not; it was a prior agent's note. 1. Management Plan content cut from nineteen fields to eleven, in two tiers. Four field pairs said the same thing twice (helps/helpful, worse/unhelpful, engagement/agreed-approach, pattern/triggers), which would leave an author unsure where a sentence belongs and a reader seeing the same guidance twice. Nineteen required fields is also an authoring burden heavy enough that plans do not get written. 2. The first-minute summary is exactly five sections: how to approach, what helps, what makes it worse, what we have agreed to do, and what would make this presentation different. The last two were previously below the fold in the full plan despite being the safety-critical ones. whatWouldMakeThisDifferent is never collapsed, truncated or clipped, at any viewport or in print, and has its own acceptance criterion. 3. An ED Presentation now requires only site, disposition, plan availability, plan use, plan helpfulness and a free-text note, with the richer fields behind a disclosure. The previous fifteen-field record was two to three minutes of typing at shift end in a second system; unfilled, it would have left the Review Suggested queue permanently empty and made a third of the app scaffolding for a loop that never runs. 4. The review clock was a genuine hole: the spec defined within_review, due_soon and overdue but never their durations. Now a 12-month editable per-version default with a 28-day amber window, shared by both plan types. Deliberately unlike the identification threshold, which stays null and pending governance. 5. Identification Reviews can now be closed. Previously they could be opened but never closed, so the queue would have filled permanently and become useless. Closing records one of proceed_to_plan, not_needed_now or revisit_later plus a reason, and creates no plan on any decision. Adds IdentificationDecision, close-identification-review, identification_review_closed, MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS, FIRST_MINUTE_CONTENT_KEYS, REVIEW_INTERVAL_MONTHS, REVIEW_DUE_SOON_DAYS and deriveReviewState, and updates Tasks 1, 2, 5, 6 and 8 to match. Checked for stale references to the seventeen removed content keys: none remain in either document. Still no application code. No test, typecheck, lint, build or browser gate has run for this feature. Co-Authored-By: Claude Opus 5 --- ...2026-08-20-ed-care-plans-implementation.md | 104 +++++++++++++----- .../specs/2026-08-20-ed-care-plans-design.md | 68 ++++++++---- 2 files changed, 124 insertions(+), 48 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md b/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md index 0a375fbb7..2c6fc1f79 100644 --- a/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md +++ b/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md @@ -13,7 +13,8 @@ ## Revision history - **2026-08-20 (Codex):** original nine-task plan, written against the approved specification. -- **2026-08-21 (Claude session, user-approved):** four user decisions applied — (1) build the synthetic prototype now but keep the domain shaped for later real storage (see the future-persistence seam in Global Constraints); (2) keep the full multi-service workflow including named senior-clinician approval; (3) deliver Tasks 1–5 as Stage A, stop for user review, then Tasks 6–9 as Stage B; (4) local task commits authorised. Also corrected: the target worktree/branch, the stale "preflight already run" line, and the verified import homes of the shared UI primitives. +- **2026-08-21 (Claude session, second pass — design review with the user):** five content decisions applied after a proper brainstorming pass the first Claude pass had skipped. (1) Management Plan content cut from nineteen fields to eleven in two tiers, removing four duplicate field pairs and promoting the two safety-critical sections into the first-minute summary. (2) The first-minute summary is exactly five sections, with `whatWouldMakeThisDifferent` never collapsed. (3) The ED Presentation record now requires only a roughly thirty-second set, with the richer fields behind a disclosure, because the earlier full record would not have been completed and would have left the review loop empty. (4) The review clock, previously undefined in the specification, is a 12-month editable default with a 28-day amber window, shared by both plan types. (5) Identification Reviews can now be closed with a recorded decision — previously they could be opened but never closed, so the queue would have filled permanently. +- **2026-08-21 (Claude session, first pass, user-approved):** four user decisions applied — (1) build the synthetic prototype now but keep the domain shaped for later real storage (see the future-persistence seam in Global Constraints); (2) keep the full multi-service workflow including named senior-clinician approval; (3) deliver Tasks 1–5 as Stage A, stop for user review, then Tasks 6–9 as Stage B; (4) local task commits authorised. Also corrected: the target worktree/branch, the stale "preflight already run" line, and the verified import homes of the shared UI primitives. ## Global Constraints @@ -23,6 +24,11 @@ - No API, provider, production-data, deployment, migration, or live-canary action is authorised. - Every patient, clinician, team, presentation, plan, review, amendment, and audit identifier begins with `SYN-`. Public crisis telephone numbers are the only intentional non-fictional contact fixtures. - `IdentificationPolicy.status` is exactly `pending_governance`, `thresholdCount` is exactly `null`, and `thresholdLookbackMonths` is exactly `null`. Raw Presentation Activity may show counts over named observation windows; no count creates eligibility, a patient label, a risk state, or a plan. +- Management Plan content is exactly the eleven fields in `ManagementPlanContent`, in two tiers. The five first-minute keys plus `whyThisPlanExists` are required for approval; the other five may be empty and render as `Not recorded`. Do not reintroduce the superseded nineteen-field shape, and do not add a field that restates another (the duplicate pairs it removed were helps/helpful, worse/unhelpful, engagement/agreed-approach, and pattern/triggers). +- `whatWouldMakeThisDifferent` is the safety boundary. It is always visible on the summary card, visually distinct from the other four sections, and never collapsed, truncated, clipped, or placed behind a disclosure at any viewport or in print. +- The review clock is `REVIEW_INTERVAL_MONTHS = 12` and `REVIEW_DUE_SOON_DAYS = 28`, shared by both plan types. The interval is an editable per-version default, never an enforced rule, and is deliberately unlike the identification threshold, which stays null. +- An ED Presentation requires only site, disposition, plan availability, plan use, plan helpfulness, and the free-text note; arrival date and time default to `PROTOTYPE_NOW` and stay editable. A review reason is required whenever review is suggested, and a deviation reason whenever a deviation is recorded. Presenting indication, assessment outcome, CMHT contact attempt and outcome, and the deviation flag sit behind a disclosure and never block the save. +- An Identification Review closes by recording one `IdentificationDecision` plus a reason. Closing never creates or approves a plan; on `proceed_to_plan` the interface offers to start a draft and the user chooses. - Each patient has one longitudinal Management Plan. Only a named user whose role is `senior_clinician` can approve an `awaiting_approval` version. Approval atomically produces exactly one `current` version and marks the former Current version `superseded`. - A Draft or Awaiting Approval version never hides or replaces the Current Plan. Withdrawal leaves `currentVersionId: null` and never restores a superseded version. - A Personal Safety Plan is patient-owned, independently versioned, and does not use the Management Plan senior-approval transition. @@ -138,27 +144,46 @@ export type PrototypeScenario = | "print-failure"; export type ManagementPlanContent = { - purposeAndApplicability: string; - preferredEngagement: readonly string[]; - whatUsuallyHelps: readonly string[]; - whatMayIncreaseDistress: readonly string[]; - immediateContinuityConsiderations: readonly string[]; - patientGoalsAndPreferences: readonly string[]; - usualPresentationPattern: readonly string[]; - contextualTriggers: readonly string[]; - mustAssessAfresh: readonly string[]; + // First-minute tier. All five are required before a version can be approved, + // and together they are the entire Current Plan summary card, in this order. + howToApproach: readonly string[]; + whatHelps: readonly string[]; + whatMakesItWorse: readonly string[]; agreedEdApproach: readonly string[]; - escalationAndDispositionGuidance: readonly string[]; - helpfulInterventions: readonly string[]; - unhelpfulInterventions: readonly string[]; - physicalHealthReminders: readonly string[]; - medicationRecordReminders: readonly string[]; - coordination: readonly string[]; - communicationPreferences: readonly string[]; - accessibilityAndSensoryPreferences: readonly string[]; - culturalSpiritualAndSupportPreferences: readonly string[]; + whatWouldMakeThisDifferent: readonly string[]; + // Full-plan tier. Only whyThisPlanExists is required; the rest may be empty + // and render as `Not recorded` rather than being silently omitted. + whyThisPlanExists: string; + whatThePersonWants: readonly string[]; + practicalNeeds: readonly string[]; + physicalHealthAndMedication: readonly string[]; + whoElseIsInvolved: readonly string[]; + reviewTriggers: readonly string[]; }; +export const MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS = [ + "howToApproach", + "whatHelps", + "whatMakesItWorse", + "agreedEdApproach", + "whatWouldMakeThisDifferent", + "whyThisPlanExists", +] as const satisfies readonly (keyof ManagementPlanContent)[]; + +export const FIRST_MINUTE_CONTENT_KEYS = [ + "howToApproach", + "whatHelps", + "whatMakesItWorse", + "agreedEdApproach", + "whatWouldMakeThisDifferent", +] as const satisfies readonly (keyof ManagementPlanContent)[]; + +/** Review clock. Default next-review interval and the amber warning window, + * shared by the Management Plan and the Personal Safety Plan. The interval is + * an editable default per version, never an enforced rule. */ +export const REVIEW_INTERVAL_MONTHS = 12; +export const REVIEW_DUE_SOON_DAYS = 28; + export type SafetyPlanContent = { warningSigns: readonly string[]; saferSurroundings: readonly string[]; @@ -274,10 +299,18 @@ export type EdPresentation = { patientId: SyntheticId; arrivedAt: string; siteId: SyntheticId; + /** Optional detail. Empty string means the recorder did not fill it in; render + * as `Not recorded`, never as an invented or inferred value. */ presentingIndication: string; + /** Optional detail, as above. */ assessmentOutcome: string; + /** Required free text: anything worth flagging. May be an empty string only + * when the recorder explicitly had nothing to add. */ + note: string; disposition: Disposition; + /** Optional detail, as above. */ cmhtContactAttempt: "not_attempted" | "attempted"; + /** Optional detail, as above. */ cmhtContactOutcome: string; managementPlanVersionId: SyntheticId | null; planAvailability: PlanAvailability; @@ -315,6 +348,8 @@ export type ReviewTrigger = { resolution: string | null; }; +export type IdentificationDecision = "proceed_to_plan" | "not_needed_now" | "revisit_later"; + export type IdentificationReview = { id: SyntheticId; patientId: SyntheticId; @@ -322,6 +357,10 @@ export type IdentificationReview = { referredBy: SyntheticId; referredAt: string; status: "open" | "closed"; + decision: IdentificationDecision | null; + decisionReason: string | null; + decidedBy: SyntheticId | null; + decidedAt: string | null; }; export type AuditEventType = @@ -341,6 +380,7 @@ export type AuditEventType = | "email_intent_opened" | "call_intent_opened" | "identification_review_created" + | "identification_review_closed" | "cmht_contact_verified" | "review_trigger_resolved"; @@ -478,9 +518,12 @@ it("builds a generic CMHT email intent without patient information", () => { - [ ] Give every fictional CMHT a reserved-example shared mailbox, fictional Australian mobile, hours, timezone, coordinator, after-hours path, verification date, and verification state. Add the separately labelled official crisis-contact fixtures and source URLs specified in Global Constraints. - [ ] Add enough ED Presentation fixtures to derive each displayed activity count from presentation timestamps. Rowan must show `7 ED presentations in rolling 12 months`; this is observation-only copy and is never compared with policy. -- [ ] Implement in `domain.ts`: `searchPatients`, `getPatientById`, `getCurrentManagementPlanVersion`, `getOpenManagementDraft`, `getCurrentSafetyPlanVersion`, `countPresentationActivity`, `buildPatientSnapshot`, `buildCmhtMailto`, `buildCmhtTel`, `getReviewQueues`, `canPerformAction`, and `assertSingleCurrentVersion`. +- [ ] Write fixture plan content against the eleven-field two-tier shape. Every fixture Current version fills all five first-minute keys and `whyThisPlanExists`; at least one fixture leaves two full-plan keys empty so the `Not recorded` path has coverage. Fixture prose must be plausible clinical continuity guidance in the glossary's preferred language, and `whatWouldMakeThisDifferent` must always name concrete new findings that would void the plan rather than generic caution. +- [ ] Derive fixture `reviewDueAt` values from `PROTOTYPE_NOW` and `REVIEW_INTERVAL_MONTHS` so that the fixture set covers `within_review`, `due_soon` (inside `REVIEW_DUE_SOON_DAYS`), and `overdue` without hardcoding unrelated dates. +- [ ] Implement in `domain.ts`: `searchPatients`, `getPatientById`, `getCurrentManagementPlanVersion`, `getOpenManagementDraft`, `getCurrentSafetyPlanVersion`, `countPresentationActivity`, `buildPatientSnapshot`, `buildCmhtMailto`, `buildCmhtTel`, `getReviewQueues`, `canPerformAction`, `deriveReviewState`, and `assertSingleCurrentVersion`. - [ ] Make `searchPatients` trim and case-fold input and match only synthetic full name, preferred name, alias, MRN, and ISO/display DOB. Do not search plan, presentation, safety-plan, cultural, support-person, or clinical text. -- [ ] Make `getReviewQueues` return exactly `{ awaitingApproval, reviewSuggested, contactVerification, identificationReview }`, each ordered oldest-actionable-first and never severity-ranked. +- [ ] Make `getReviewQueues` return exactly `{ awaitingApproval, reviewSuggested, contactVerification, identificationReview }`, each ordered oldest-actionable-first and never severity-ranked. The `identificationReview` queue contains only referrals whose `status` is `open`. +- [ ] Make `deriveReviewState(reviewDueAt, now)` return `overdue` past the date, `due_soon` within `REVIEW_DUE_SOON_DAYS` of it, and `within_review` otherwise, with a test pinning both boundaries exactly. - [ ] Finish the remaining tests: all entity IDs are synthetic; Current selection is unique; Draft stays separate; raw activity derives from episodes; public contacts match the exact authorised list; no fixture uses stigmatising labels; safe URI builders contain no patient field. - [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts`. Expected GREEN: the new domain test file passes with zero failures. - [ ] Run `npx prettier --write src/components/ed-care-plans/mockups/types.ts src/components/ed-care-plans/mockups/fixtures.ts src/components/ed-care-plans/mockups/domain.ts tests/ed-care-plans-domain.test.ts` and rerun the same test command. @@ -573,6 +616,12 @@ export type EdCarePlansPrototypeAction = | { type: "record-safety-plan-print-intent"; patientId: SyntheticId } | { type: "record-contact-intent"; patientId: SyntheticId; cmhtId: SyntheticId; channel: "email" | "call" } | { type: "create-identification-review"; patientId: SyntheticId; reason: string } + | { + type: "close-identification-review"; + reviewId: SyntheticId; + decision: IdentificationDecision; + decisionReason: string; + } | { type: "verify-cmht-contact"; cmhtId: SyntheticId } | { type: "resolve-review-trigger"; triggerId: SyntheticId; resolution: string } | { type: "apply-scenario"; scenario: PrototypeScenario } @@ -587,6 +636,7 @@ export type EdCarePlansPrototypeAction = - [ ] Make `amend-presentation` append original/replacement/reason/actor/time evidence while keeping the episode immutable. Restrict amendable fields to the union in the action. - [ ] Make Safety Plan publication require an editable Draft, supersede the prior Current Safety Plan, and set the new version Current without consulting senior-approval state. - [ ] Make contact-intent actions append only intent audit events; make manual identification referral append an Identification Review and audit event without creating a Management Plan or Review Trigger. +- [ ] Make `close-identification-review` require an `open` review and a non-empty reason, set `status: "closed"` with the decision, reason, actor, and time, and append one `identification_review_closed` audit event. It must create no plan and no version on any decision, including `proceed_to_plan`. Closing an already-closed review leaves state unchanged and sets a specific `lastOutcome`. - [ ] Add `EdCarePlansPrototypeProvider`, `useEdCarePlansPrototype`, and an online/offline listener to `prototype-provider.tsx`. The provider calls `useReducer` once and performs no persistence. - [ ] Complete reducer tests for draft/current separation, non-senior approval refusal, return reason, withdrawal, formal review, Review Trigger creation/deduplication, Safety Plan independence, manual referral, intent-only audit language, degraded-state refusal, reset, and scenario reconstruction. - [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts`. Expected GREEN: both files pass with zero failures. @@ -771,9 +821,10 @@ it("requires named senior approval before an awaiting version becomes Current", ``` - [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "Management Plan|senior approval|return for changes|withdraw"`. Confirm RED for missing Management Plan surfaces. +- [ ] Render the Current summary card as exactly `FIRST_MINUTE_CONTENT_KEYS` in order, with `whatWouldMakeThisDifferent` visually distinct and never collapsed; add a DOM test and a print-media test pinning that it is present and unclipped. Render empty optional full-plan sections as `Not recorded`. - [ ] Implement the read page with Current summary first, full structured sections second, review state, open triggers, owner/approver metadata, separate proposed version, version list, and fresh-assessment boundary repeated near clinical guidance. -- [ ] Use `ManagementPlanForm` for both new Draft and edit Draft. Initialise from Current when creating a replacement; expose owner, next review date, revision reason, structured inputs for the six first-minute sections, and patient/communication/cultural preferences; preserve unchanged full-plan sections from the source version. -- [ ] Validate required fields in the form, render a linked error summary, focus the first invalid field, and dispatch `create-management-draft`/`save-management-draft` only after local validation. +- [ ] Use `ManagementPlanForm` for both new Draft and edit Draft. Initialise from Current when creating a replacement. Expose owner, next review date (defaulted from `REVIEW_INTERVAL_MONTHS`, editable), revision reason, the five first-minute sections, and the six full-plan sections with the optional five clearly marked optional. Preserve unchanged sections from the source version. +- [ ] Validate exactly `MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS` plus owner, next review date, and revision reason. Render a linked error summary, focus the first invalid field, and dispatch `create-management-draft`/`save-management-draft` only after local validation. Do not require the optional five. - [ ] Add `Save Draft` and `Submit for senior approval`. Submission uses `ConfirmDialog`, changes only Draft to Awaiting Approval, then navigates to the review route. The existing Current remains visible. - [ ] Implement `ManagementPlanDiff` as semantic sections with `Added`, `Changed`, `Removed`, and `Unchanged` labels; compare the submitted version against the Current version without clinical interpretation. - [ ] On the review page, show named author, owner, proposed approver, revision reason, current/proposed versions, patient participation, and change table. Do not allow edits while Awaiting Approval. @@ -824,8 +875,6 @@ npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype it("records plan-use feedback and creates a Review Suggested item without changing the plan", async () => { const user = userEvent.setup(); renderRoute(edCarePlansRoute.newPresentation("SYN-PATIENT-001")); - await user.type(screen.getByLabelText("Presenting indication"), "Escalating distress and reduced sleep."); - await user.type(screen.getByLabelText("Assessment outcome"), "Discharged with CMHT follow-up confirmed."); await user.selectOptions(screen.getByLabelText("Disposition"), "discharged_home"); await user.selectOptions(screen.getByLabelText("Was the Current Plan used?"), "partially_used"); await user.selectOptions(screen.getByLabelText("Was the plan helpful?"), "mixed"); @@ -841,9 +890,9 @@ it("records plan-use feedback and creates a Review Suggested item without changi - [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "ED Presentation|plan-use|amendment"`. Confirm RED because the episode surfaces do not exist. - [ ] Implement `PresentationTimeline` as a descending semantic list with date/site, indication, outcome, disposition, linked plan version, plan-use summary, CMHT attempt outcome, Review Suggested text, and visible amendment count. Use a line-and-node treatment visually and retain complete text equivalents. - [ ] Implement the list page with objective rolling counts, explicit observation windows, site filter, disposition filter, and `Record ED presentation` link. Do not add eligibility or severity labels. -- [ ] Implement `PresentationForm` with persistent labels for arrival date/time, fictional ED, concise indication, concise assessment outcome, disposition, CMHT contact attempt/outcome, plan availability, use, helpfulness, deviation/reason, and review suggestion/reason. +- [ ] Implement `PresentationForm` with the required set visible by default and persistent labels: fictional ED, disposition, plan availability, plan use, plan helpfulness, and `Anything worth flagging?` free text. Arrival date and time default to `PROTOTYPE_NOW` and stay editable. Put presenting indication, assessment outcome, CMHT contact attempt/outcome, and the deviation flag behind one `Add more detail` disclosure that is closed on open and never blocks the save. - [ ] Default the linked Management Plan Version to the Current version at form open; if none exists, show `No Current Plan was available` and submit `managementPlanVersionId: null`. Never link a Draft as the available plan. -- [ ] Require indication, outcome, disposition, site, plan availability/use/helpfulness, and review reason when feedback/deviation/disposition creates a trigger. Use an error summary and focus the first invalid field. +- [ ] Require site, disposition, plan availability, plan use, and plan helpfulness; require a review reason whenever review is suggested and a deviation reason whenever a deviation is recorded. Presenting indication and assessment outcome are never required. Use an error summary and focus the first invalid field. - [ ] On save, call `nextPresentationId(state)`, dispatch `record-presentation` with that ID, announce only local synthetic recording, and navigate to the matching detail route. The reducer validates the caller-provided synthetic ID before appending. - [ ] Implement the detail page with the original immutable episode, recording clinician/time, linked plan version, plan-use feedback, outcome, Review Trigger, and an `Amend recorded outcome` action. - [ ] The amendment Sheet permits only assessment outcome or disposition, shows the original value, and requires replacement plus reason. On save, display original and latest amendment together; do not replace the original DOM text. @@ -932,6 +981,7 @@ it("creates a manual Identification Review without creating a plan or applying e - [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "Identification Review|Awaiting Approval queue|Governance|System states|Audit history"`. Confirm RED because the operational surfaces do not exist. - [ ] Implement Reviews with `Tabs` for exactly `Awaiting Approval`, `Review Suggested`, `Contact Verification`, and `Identification Review`. Each item shows reason, source/time, owner, next action, and direct patient/plan route; no severity order or dashboard scoring. - [ ] Add manual-referral Sheet from Patients and patient Overview, require a reason, dispatch `create-identification-review`, and show the item in Reviews. The action does not create a plan or change Presentation Activity. +- [ ] Add referral closure to the Identification Review queue: a Sheet offering exactly `Proceed to a plan`, `Not needed at this stage`, and `Revisit later`, with a required reason. On confirm it dispatches `close-identification-review` and the item leaves the queue. On `Proceed to a plan` offer a link to start a draft; never create one automatically. Show the closed decision, reason, author, and time in the patient's History. - [ ] Add Review Trigger resolution with required resolution text and contact-verification action with last/next verified evidence. Both are role-gated and audited. - [ ] Implement Team as a directory of fictional CMHTs and plan owners showing catchment, shared mailbox, duty telephone, operating hours/timezone, coordinator, after-hours path, verification state/date, and current owned-plan count. - [ ] Implement Governance with the exact prototype boundary, illustrated role responsibilities, lifecycle rules, audit-evidence limits, privacy/print/contact rules, and an Identification Policy panel that displays `Pending local governance`, `No approved threshold count`, `No approved threshold lookback`, and `Manual referral enabled`. diff --git a/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md b/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md index c7e3cf241..d8315f77b 100644 --- a/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md +++ b/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md @@ -147,27 +147,32 @@ The workspace uses four primary patient sections: `Overview`, `Management Plan`, ## Management Plan content -The collapsed Current Plan summary shows: +Revised 21 August 2026 after user review. The earlier draft carried nineteen content sections. Four pairs of those sections said the same thing twice (`what usually helps` against `helpful interventions`; `what may increase distress` against `unhelpful interventions`; `preferred engagement` against `agreed ED approach`; `usual presentation pattern` against `contextual triggers`), which would leave an author unsure which box a sentence belongs in and a reader seeing the same guidance twice. The two safety-critical items — what has actually been agreed, and what would make this presentation different — sat below the fold in the full plan rather than in the first-minute summary. And nineteen required fields is an authoring burden heavy enough that plans would not get written at all. -- Preferred engagement approach. -- What usually helps. -- What may increase distress. -- Immediate continuity considerations. -- CMHT coordination expectation. -- Current owner, approver, and review state. - -The full plan adds: - -- Purpose and applicability. -- Patient goals and preferences. -- Usual presentation pattern and known contextual triggers. -- Assessment considerations and explicit “must assess afresh” boundary. -- Agreed ED approach and escalation/disposition guidance. -- Helpful and unhelpful interventions. -- Physical-health and medication-record reminders without reproducing orders. -- CMHT, primary-care, and consented support-person coordination. -- Patient and carer involvement state. -- Review triggers and the reason for the current version. +The content is therefore eleven fields in two tiers. + +### First-minute tier — the Current Plan summary card + +All five are required before a version can be approved. They are the entire summary card, in this order: + +1. **How to approach this person.** The engagement approach that works for them. +2. **What helps.** Concrete things that reduce distress. +3. **What makes it worse.** Concrete things to avoid. +4. **What we have agreed to do.** The agreed ED approach, including the usual disposition and the agreed threshold for admission. +5. **What would make this presentation different.** The explicit “this plan does not apply — assess afresh” boundary. This section is visually distinct from the other four and is never collapsed, truncated, or hidden behind a disclosure. + +The summary card also shows, as metadata rather than content: version, Current state, approver and approval date, owner, review state, primary CMHT contact with operating hours, and a link to the Personal Safety Plan. + +### Full-plan tier — read when there is time + +Only `Why this plan exists` is required; the remaining five may be empty and are then displayed as `Not recorded` rather than omitted silently. + +6. **Why this plan exists.** Purpose, applicability, and the person's usual presentation pattern and known contextual triggers. +7. **What the person wants.** Their goals, preferences, and communication preferences. +8. **Practical needs.** Interpreter, accessibility, sensory, cultural, spiritual, family, community, Aboriginal Liaison, and peer-support preferences, recorded consent-aware. +9. **Physical health and medication.** Allergies, physical-health reminders, and a pointer to the authoritative medication record. Never orders, doses, or a parallel medication list. +10. **Who else is involved.** CMHT, primary care, and consented support people, with whether their involvement is current. +11. **What should trigger the next review.** Distinct from the version's own revision reason, which is version metadata rather than plan content. Every view of the plan states that it supports continuity and never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgment, or legal obligations. @@ -178,6 +183,10 @@ A Management Plan has one or more versions. Version state and review state remai - Version states: `draft`, `awaiting_approval`, `current`, `superseded`, `withdrawn`. - Review states for a Current version: `within_review`, `due_soon`, `overdue`. +The review clock (decided 21 August 2026; the earlier draft defined the three states but never their durations). On approval the next review date defaults to **12 months** ahead and remains editable by the author on every version. A Current version is `due_soon` within **28 days** of that date and `overdue` after it. The default is a suggestion that saves the author work; it is editable per plan and is not a governance rule about a patient, which is why it is treated differently from the identification threshold below. + +The Personal Safety Plan uses the same 12-month default and 28-day warning window. + The workflow is: 1. Create a draft from an empty plan or copy the Current version. @@ -192,7 +201,15 @@ An overdue Current Plan remains visible with a prominent warning. It is not sile ## ED Presentation record -Each ED Presentation records only the continuity information this application owns: +Each ED Presentation records only the continuity information this application owns. Revised 21 August 2026 after user review: the earlier draft required all of the fields below, which is two to three minutes of typing at the end of a shift, in a second system, partly duplicating the hospital record. Work that heavy does not get done, and an empty feedback loop makes the Review Suggested queue and roughly a third of the application scaffolding for something that never runs. + +**Required — the roughly thirty-second set.** Fictional ED site; disposition; whether the plan was available; whether it was used; whether it helped; and one free-text note for anything worth flagging. Arrival date and time default to now and stay editable. + +**Required conditionally.** A review reason whenever review is suggested, and a deviation reason whenever a deviation is recorded. + +**Optional — behind a disclosure, never blocking the save.** Presenting indication, assessment outcome, CMHT contact attempt and outcome, and the deviation flag itself. Optional fields left empty display as `Not recorded`; they are never silently dropped and never invented. + +The complete field set is: - Arrival date and time. - Fictional ED site. @@ -266,6 +283,8 @@ Patient lists may display objective counts such as “7 ED presentations in 12 m An authorised synthetic user may manually add a patient to `Identification review` with a reason. The queue action initiates multidisciplinary review; it does not create or approve a plan. +Closing a review (added 21 August 2026; the earlier draft opened referrals but gave them no way to close, so the queue would have filled permanently and become useless). When the multidisciplinary group has discussed the patient, an authorised user records one decision — `proceed_to_plan`, `not_needed_now`, or `revisit_later` — plus a short reason, and the referral closes. The decision, its reason, its author, and its time remain visible in the patient's history, so a later reader can see that coordinated care was considered and what was concluded. On `proceed_to_plan` the interface offers to start a Management Plan draft; it never creates one automatically, and closing a referral never approves anything. + ## Roles and permissions boundary The prototype illustrates, but does not enforce, these responsibilities: @@ -291,6 +310,8 @@ The patient-owned summary can include: - Sensory and environmental preferences. - Cultural, spiritual, family, community, Aboriginal Liaison, or peer-support preferences. +These are not additional content fields. They live inside the eleven fields defined under **Management Plan content**: preferred name and pronouns are patient identity shown in the workspace header; communication preferences and what helps the person feel safe and heard belong to `What the person wants`; interpreter, accessibility, sensory, environmental, cultural, spiritual, family, community, Aboriginal Liaison, and peer-support preferences belong to `Practical needs`; and anything that an ED clinician must act on within the first minute belongs to `What helps` or `What makes it worse` instead. Do not add a twelfth field for them. + Non-participation is never labelled non-compliance. Sensitive details are shown only when needed for the approved synthetic scenario and are not made searchable. ## Review triggers and queues @@ -401,6 +422,11 @@ No live Supabase, OpenAI, email, identity, hospital, CMHT, analytics, or provide - A replacement draft never obscures or replaces the Current Plan before approval. - Approving a version creates exactly one Current version and supersedes the prior Current version. - An overdue Current Plan remains readable and unmistakably overdue. +- The Current Plan summary card is exactly the five first-minute sections, in the specified order. +- `What would make this presentation different` is visible on the summary card at every supported viewport, in dark mode, in forced colours, and in print, and is never collapsed, truncated, or clipped. +- An approved version defaults its next review date 12 months ahead, the author can change it, and the amber warning begins 28 days before it. +- An ED Presentation saves with only site, disposition, plan availability, plan use, plan helpfulness, and the note completed; the optional detail fields never block the save and render as `Not recorded` when empty. +- An Identification Review can be closed with a recorded decision and reason, leaves the queue when closed, remains visible in the patient's history, and creates no plan on any decision. - Presentation corrections are visible amendments rather than silent overwrites. - Plan-use feedback can create a Review Trigger but cannot change a plan automatically. - Objective presentation counts never become an automatic patient label or eligibility decision. From 8a2e6a6d1278f53a94f5b4caa9267cb3fbd398e7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:57:13 +0800 Subject: [PATCH 03/56] docs(care-plan): rename to Care Plan and apply the grilling-round design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames ED Care Plans to Care Plan across every path, identifier and document, and records the decisions from the grilling round. Nothing is built yet, so the rename is free today and irritating in a month. Ordering principle, from the user: "the plan is for clinicians to look up and see the management plan; it is rarely for changing or updating." Read primacy is now written into the spec as a rule — where reading and authoring compete for space, navigation depth, attention or effort, reading wins — and it reorders the build. The old Task 5 splits: the complete reading surface, including the pinned safety boundary and a clinician print view, now closes Stage A, and every authoring surface moves to Stage B. The plan is eleven tasks and twenty-one routes. Decisions applied: - Admission wording. agreedEdApproach must name who agreed the position and when, read as an agreed default rather than a ceiling on care, and never use a prohibitive construction. BANNED_ADMISSION_CONSTRUCTIONS is checked at the form boundary and by a fixture scan. Refusing to record the decision would not undo it, only make it unaccountable. - The safety boundary is pinned above all plan content as well as sitting in its numbered place, because on a phone the five sections are a long card and a hurried reader stops before the end. - whatMakesItWorse is written about what the service does, not what the person does. Fixtures model it, because fixtures are what real plans written in this tool will imitate. - A version may be approved at any participation state, but declined and patient_unavailable carry a permanent "written without this person's involvement" marker and raise a Review Trigger. - Withdrawal is senior-clinician only and renders distinctly from a patient who never had a plan. - Any clinical role may author a Personal Safety Plan, including an ED clinician mid-shift. Only the non-clinical coordinator cannot. - Sort-by-count exists only inside the Identification Review workflow. The activity view gains per-site breakdown and a one-line account per presentation. - Amendment extends to the one-line account and the plan-use answers. - The route family is gated in production so it works on the live site for a signed-in administrator, as Caring Contacts already is. - The required presentation note becomes "In one line: why they came and what happened", doing the work the optional indication and outcome fields would otherwise do. New Task 9, the Patient Plan. A deterministic offline transformation of an approved Management Plan Version into a patient-facing edition: eleven known fields to eight patient-voice headings through a curated dictionary. It emits a visible gap wherever it cannot convert confidently and never guesses, and it never auto-converts the agreed-ED- approach section under any circumstances — that is where a wording slip does the most harm. A clinician (any clinical role, not only a senior one) must approve before the patient receives it, and cannot approve while a gap is unfilled. Resources are typed and include housing, financial and transport categories, because those are frequently the actual reason someone keeps presenting. When a newer Management Plan Version is approved the patient copy is marked as needing updating and stays readable rather than regenerating or hiding, since the person may be holding a printed copy. No language model, network call or provider is involved; the pure-function shape means a model-backed version later is a swap, not a redesign. Both print views are built on the shared PrintOutput primitive, with genuinely general capabilities added there and consumed, not reimplemented locally. This branch's worktree was deleted by external tooling mid-session before this work was committed; the two prior commits survived and this change is a replay of the lost uncommitted work. Still no application code. No test, typecheck, lint, build or browser gate has run for this feature. Co-Authored-By: Claude Opus 5 --- ...-plans-context.md => care-plan-context.md} | 6 +- .../CLAUDE-START-HERE.md | 36 +- .../claude-build-handover-2026-08-21.md | 0 .../conversation-transcript-2026-08-21.md | 0 .../verification-log-2026-08-21.md | 0 ...=> 2026-08-20-care-plan-implementation.md} | 661 ++++++++++++------ ...sign.md => 2026-08-20-care-plan-design.md} | 185 ++++- 7 files changed, 617 insertions(+), 271 deletions(-) rename docs/{ed-care-plans-context.md => care-plan-context.md} (95%) rename docs/{ed-care-plans => care-plan}/CLAUDE-START-HERE.md (65%) rename docs/{ed-care-plans => care-plan}/claude-build-handover-2026-08-21.md (100%) rename docs/{ed-care-plans => care-plan}/conversation-transcript-2026-08-21.md (100%) rename docs/{ed-care-plans => care-plan}/verification-log-2026-08-21.md (100%) rename docs/superpowers/plans/{2026-08-20-ed-care-plans-implementation.md => 2026-08-20-care-plan-implementation.md} (58%) rename docs/superpowers/specs/{2026-08-20-ed-care-plans-design.md => 2026-08-20-care-plan-design.md} (59%) diff --git a/docs/ed-care-plans-context.md b/docs/care-plan-context.md similarity index 95% rename from docs/ed-care-plans-context.md rename to docs/care-plan-context.md index 36cc903d2..eb5774dd7 100644 --- a/docs/ed-care-plans-context.md +++ b/docs/care-plan-context.md @@ -1,6 +1,6 @@ -# ED Care Plans — domain glossary +# Care Plan — domain glossary -The ubiquitous language for the ED Care Plans context. This glossary defines the clinical-workflow concepts without describing schemas, routes, components, or implementation choices. +The ubiquitous language for the Care Plan context. This glossary defines the clinical-workflow concepts without describing schemas, routes, components, or implementation choices. ## People and services @@ -131,5 +131,5 @@ Confirmation that a CMHT's shared contact details and operating hours were check _Avoid_: Service availability guarantee **Audit Event**: -An attributed record that a meaningful workflow action occurred inside ED Care Plans. It describes only evidence the application actually has. +An attributed record that a meaningful workflow action occurred inside Care Plan. It describes only evidence the application actually has. _Avoid_: Activity feed, communication log diff --git a/docs/ed-care-plans/CLAUDE-START-HERE.md b/docs/care-plan/CLAUDE-START-HERE.md similarity index 65% rename from docs/ed-care-plans/CLAUDE-START-HERE.md rename to docs/care-plan/CLAUDE-START-HERE.md index 43bd90e6a..825995112 100644 --- a/docs/ed-care-plans/CLAUDE-START-HERE.md +++ b/docs/care-plan/CLAUDE-START-HERE.md @@ -1,15 +1,15 @@ -# ED Care Plans — Claude start here +# Care Plan — Claude start here **Handover date:** 21 August 2026 (Australia/Perth) **Implementation status:** Not started **Design status:** Approved **Planning status:** Complete -**Working directory:** `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` (superseded 21 Aug 2026; the original `D:\Worktrees\Database\ed-care-plans` is now planning-only and must not be written to) -**Branch:** `claude/ed-care-plans-impl-7f44cd` (based on `main` at `97f614223`) +**Working directory:** `D:\Repos\Database\.claude\worktrees\care-plan-impl-7f44cd` (superseded 21 Aug 2026; the original `D:\Worktrees\Database\care-plan` is now planning-only and must not be written to) +**Branch:** `claude/care-plan-impl-7f44cd` (based on `main` at `97f614223`) ## One-minute start -**Superseded 21 August 2026.** Do all ED Care Plans work in `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` on branch `claude/ed-care-plans-impl-7f44cd`, which is based on current `main`. The four planning documents were copied there and are now tracked with the code. The original planning worktree `D:\Worktrees\Database\ed-care-plans` is read-only reference; do not edit it. Do not edit the dirty shared checkout at `D:\Repos\Database`. +**Superseded 21 August 2026.** Do all Care Plan work in `D:\Repos\Database\.claude\worktrees\care-plan-impl-7f44cd` on branch `claude/care-plan-impl-7f44cd`, which is based on current `main`. The four planning documents were copied there and are now tracked with the code. The original planning worktree `D:\Worktrees\Database\care-plan` is read-only reference; do not edit it. Do not edit the dirty shared checkout at `D:\Repos\Database`. Four user decisions were recorded on 21 August 2026 and are binding: build the synthetic prototype now but keep the domain shaped for later real storage; keep the full multi-service workflow including named senior-clinician approval; deliver Tasks 1–5 first and stop for user review; local task commits are authorised (nothing pushed). See the Revision history and Delivery Stages sections of the implementation plan. @@ -18,34 +18,34 @@ 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-ed-care-plans-design.md`](../superpowers/specs/2026-08-20-ed-care-plans-design.md) — approved product and visual specification; this is the product authority. -5. [`ed-care-plans-context.md`](../ed-care-plans-context.md) — binding domain language. -6. [`2026-08-20-ed-care-plans-implementation.md`](../superpowers/plans/2026-08-20-ed-care-plans-implementation.md) — executable nine-task, file-by-file, test-first build plan. +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\ed-care-plans\.superpowers\brainstorm\17559-1787239654\content\ed-care-plans-directions.html` +`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 -Continue the approved ED Care Plans build from the handover in -D:\Worktrees\Database\ed-care-plans on branch codex/ed-care-plans. +Continue the approved Care Plan build from the handover in +D:\Worktrees\Database\care-plan on branch codex/care-plan. Work only in that isolated worktree. Do not touch D:\Repos\Database, which contains unrelated dirty work. Read AGENTS.md and CLAUDE.md, then read these files in order: -1. docs/ed-care-plans/CLAUDE-START-HERE.md -2. docs/ed-care-plans/claude-build-handover-2026-08-21.md -3. docs/superpowers/specs/2026-08-20-ed-care-plans-design.md -4. docs/ed-care-plans-context.md -5. docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md -6. docs/ed-care-plans/conversation-transcript-2026-08-21.md -7. docs/ed-care-plans/verification-log-2026-08-21.md +1. docs/care-plan/CLAUDE-START-HERE.md +2. docs/care-plan/claude-build-handover-2026-08-21.md +3. docs/superpowers/specs/2026-08-20-care-plan-design.md +4. docs/care-plan-context.md +5. docs/superpowers/plans/2026-08-20-care-plan-implementation.md +6. docs/care-plan/conversation-transcript-2026-08-21.md +7. docs/care-plan/verification-log-2026-08-21.md The design and implementation plan are approved. Do not re-brainstorm or encode a numeric presentation threshold. Execute the nine tasks in order using the named @@ -77,7 +77,7 @@ Captured on 21 August 2026: - `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 ED Care Plans. +- Commits/pushes/PRs/deployments: none for Care Plan. ## Exact first action diff --git a/docs/ed-care-plans/claude-build-handover-2026-08-21.md b/docs/care-plan/claude-build-handover-2026-08-21.md similarity index 100% rename from docs/ed-care-plans/claude-build-handover-2026-08-21.md rename to docs/care-plan/claude-build-handover-2026-08-21.md diff --git a/docs/ed-care-plans/conversation-transcript-2026-08-21.md b/docs/care-plan/conversation-transcript-2026-08-21.md similarity index 100% rename from docs/ed-care-plans/conversation-transcript-2026-08-21.md rename to docs/care-plan/conversation-transcript-2026-08-21.md diff --git a/docs/ed-care-plans/verification-log-2026-08-21.md b/docs/care-plan/verification-log-2026-08-21.md similarity index 100% rename from docs/ed-care-plans/verification-log-2026-08-21.md rename to docs/care-plan/verification-log-2026-08-21.md diff --git a/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md similarity index 58% rename from docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md rename to docs/superpowers/plans/2026-08-20-care-plan-implementation.md index 2c6fc1f79..4abbb181c 100644 --- a/docs/superpowers/plans/2026-08-20-ed-care-plans-implementation.md +++ b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md @@ -1,29 +1,42 @@ -# ED Care Plans Standalone Synthetic Application Implementation Plan +# Care Plan Standalone Synthetic Application Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan one task at a time, `superpowers:test-driven-development` for every behaviour change, `superpowers:requesting-code-review` after each task and for the final branch review, and `superpowers:verification-before-completion` before any completion claim. -**Goal:** Deliver the user-approved, complete, linked, reset-on-refresh ED Care Plans prototype under `/mockups/ed-care-plans`, using only deterministic synthetic data and the repository design system. +**Goal:** Deliver the user-approved, complete, linked, reset-on-refresh Care Plan prototype under `/mockups/care-plan`, using only deterministic synthetic data and the repository design system. **Architecture:** A Next.js 16 App Router route family delegates to one client-side route surface. A layout-scoped provider owns the complete in-memory domain state; a pure reducer enforces Management Plan, Personal Safety Plan, ED Presentation, identification-review, contact-intent, and audit invariants. Server page files validate the finite synthetic dynamic parameters and otherwise render the same client route surface. Shared repository primitives own controls, dialogs, sheets, fields, tabs, announcements, and page structure. Tailwind token utilities provide most styling; one route-scoped CSS Module owns the split shell, phone safe-area behaviour, and print-only rules. **Tech Stack:** Next.js 16.3 App Router, React 19, TypeScript, repository Clinical White / Sky Graphite tokens, Tailwind CSS utilities, CSS Modules, Vitest, Testing Library, and repository-wrapped Playwright Chromium. -**Spec:** [`docs/superpowers/specs/2026-08-20-ed-care-plans-design.md`](../specs/2026-08-20-ed-care-plans-design.md) is binding. [`docs/ed-care-plans-context.md`](../../ed-care-plans-context.md) supplies the binding domain language. If this plan conflicts with the specification, follow the specification and record an SDD ledger ruling. +**Spec:** [`docs/superpowers/specs/2026-08-20-care-plan-design.md`](../specs/2026-08-20-care-plan-design.md) is binding. [`docs/care-plan-context.md`](../../care-plan-context.md) supplies the binding domain language. If this plan conflicts with the specification, follow the specification and record an SDD ledger ruling. ## Revision history - **2026-08-20 (Codex):** original nine-task plan, written against the approved specification. +- **2026-08-21 (Claude session, third pass — grilling round):** renamed the product to **Care Plan** and every path, identifier, and test file with it. Recorded read primacy as the ordering principle for the whole product, and split the old Task 5 so the complete reading experience including print closes Stage A and all authoring moves to Stage B. Added: the pinned safety boundary; the admission-wording ban; the service-facing language rule for `whatMakesItWorse`; the always-visible marker and Review Trigger for a version 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 Review workflow with a richer activity view; amendment extended to the one-line account and the plan-use answers; production reachability behind the administrator developer gate; the required note reframed as `In one line: why they came and what happened`; a clinician print view of the plan summary built on the shared `PrintOutput` primitive; and a plain statement in the shell that reloading starts over. Added **Task 9, the Patient Plan** — a deterministic offline transformation that flags what it cannot convert rather than guessing, never auto-converts the agreed-approach section, requires clinician approval before the patient receives it, carries typed resources including housing and financial categories, goes stale rather than regenerating when the clinical plan moves on, and prints. The plan is now eleven tasks and twenty-one routes. - **2026-08-21 (Claude session, second pass — design review with the user):** five content decisions applied after a proper brainstorming pass the first Claude pass had skipped. (1) Management Plan content cut from nineteen fields to eleven in two tiers, removing four duplicate field pairs and promoting the two safety-critical sections into the first-minute summary. (2) The first-minute summary is exactly five sections, with `whatWouldMakeThisDifferent` never collapsed. (3) The ED Presentation record now requires only a roughly thirty-second set, with the richer fields behind a disclosure, because the earlier full record would not have been completed and would have left the review loop empty. (4) The review clock, previously undefined in the specification, is a 12-month editable default with a 28-day amber window, shared by both plan types. (5) Identification Reviews can now be closed with a recorded decision — previously they could be opened but never closed, so the queue would have filled permanently. - **2026-08-21 (Claude session, first pass, user-approved):** four user decisions applied — (1) build the synthetic prototype now but keep the domain shaped for later real storage (see the future-persistence seam in Global Constraints); (2) keep the full multi-service workflow including named senior-clinician approval; (3) deliver Tasks 1–5 as Stage A, stop for user review, then Tasks 6–9 as Stage B; (4) local task commits authorised. Also corrected: the target worktree/branch, the stale "preflight already run" line, and the verified import homes of the shared UI primitives. ## Global Constraints -- Work only in `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` on `claude/ed-care-plans-impl-7f44cd`, based on `main` at `97f614223`. Preserve the unrelated dirty checkout at `D:\Repos\Database` and the earlier planning worktree at `D:\Worktrees\Database\ed-care-plans`; copy from them, never write to them. +- Work only in `D:\Repos\Database\.claude\worktrees\care-plan-impl-7f44cd` on `claude/care-plan-impl-7f44cd`, based on `main` at `97f614223`. Preserve the unrelated dirty checkout at `D:\Repos\Database` and the earlier planning worktree at `D:\Worktrees\Database\care-plan`; copy from them, never write to them. - This worktree is fresh. Confirm dependencies are installed (`node_modules` present and `npm run check:installed-lock-parity` clean) before the first test command; run `npm ci --include=dev` only if they are not. - The application is synthetic and memory-only. Do not add `fetch`, route handlers, Server Actions, local/session storage, IndexedDB, cookies for prototype state, Supabase, OpenAI, analytics, email providers, or any other network or persistence path. - No API, provider, production-data, deployment, migration, or live-canary action is authorised. - Every patient, clinician, team, presentation, plan, review, amendment, and audit identifier begins with `SYN-`. Public crisis telephone numbers are the only intentional non-fictional contact fixtures. - `IdentificationPolicy.status` is exactly `pending_governance`, `thresholdCount` is exactly `null`, and `thresholdLookbackMonths` is exactly `null`. Raw Presentation Activity may show counts over named observation windows; no count creates eligibility, a patient label, a risk state, or a plan. +- **Read primacy.** Reading is the primary use; authoring is supporting machinery. Where reading and authoring compete for space, navigation depth, attention, or effort, reading wins. A reader without authoring permission sees a clean reading surface, not a wall of unavailable controls. Build order enforces this: the complete reading experience including print is finished and reviewed in Stage A before any authoring surface exists. +- **Pinned safety boundary.** A one-line form of `whatWouldMakeThisDifferent` renders directly beneath the patient identity block and above every other plan element, at every viewport and in print, in addition to its numbered place in the sequence. It links to the full section and never replaces it. +- **Admission wording.** `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` in `domain.ts` is checked at the form boundary and by a fixture test. +- **Service-facing language.** `whatMakesItWorse` describes what the service does — corridors, repeated history-taking, security presence, unexplained waits — not what the person does wrong. Every fixture models this; whatever the fixtures do is what every real plan written in this tool will imitate. +- **Participation is never invisible.** A version may be approved at any participation state, but `declined` and `patient_unavailable` carry a persistent `Written without this person's involvement` marker on every view, print, and queue entry, and approval raises an open Review Trigger. +- **Withdrawal** is `senior_clinician` only and afterwards renders `Plan withdrawn on by `, never a bare `No Current Plan`. A patient who never had a plan and a patient whose plan was withdrawn never render identically. +- **Safety Plan authorship** is open to every clinical role including `ed_clinician`; only the non-clinical `plan_coordinator` cannot author one. +- **Sort by presentation count exists only inside the Identification Review workflow.** It is not offered on the patient directory or any other surface, and wherever offered, the statement that counts do not determine eligibility is on the same screen. +- **Amendable fields** are disposition, assessment outcome, the one-line account, and the three plan-use answers as one group. +- **The Patient Plan transformation is deterministic and offline.** No language model, network call, provider, timer, randomness, or wall clock. It maps eleven known fields to eight known headings through a curated dictionary, emits a visible gap wherever it cannot convert confidently, and never auto-converts `whatWeAgreedWillHappen` under any circumstances. It is a pure function so a later model-backed implementation is a swap, not a redesign. +- **Print is built on the shared primitive.** `PrintOutput` and `BrowserPrintButton` in `src/components/ui/print-output.tsx`, plus the two Therapy Compass printed screens, are the basis for both print views. Genuinely general capabilities — per-section page breaks, monochrome state treatment, confidential footer, printed-at stamp — are added to the shared primitive with their own tests and consumed here, never reimplemented locally. +- **The shell states that nothing is saved** in plain words about state, not only about data, so an accidental reload during a demonstration is not a surprise. - Management Plan content is exactly the eleven fields in `ManagementPlanContent`, in two tiers. The five first-minute keys plus `whyThisPlanExists` are required for approval; the other five may be empty and render as `Not recorded`. Do not reintroduce the superseded nineteen-field shape, and do not add a field that restates another (the duplicate pairs it removed were helps/helpful, worse/unhelpful, engagement/agreed-approach, and pattern/triggers). - `whatWouldMakeThisDifferent` is the safety boundary. It is always visible on the summary card, visually distinct from the other four sections, and never collapsed, truncated, clipped, or placed behind a disclosure at any viewport or in print. - The review clock is `REVIEW_INTERVAL_MONTHS = 12` and `REVIEW_DUE_SOON_DAYS = 28`, shared by both plan types. The interval is an editable per-version default, never an enforced rule, and is deliberately unlike the identification threshold, which stays null. @@ -52,58 +65,61 @@ - Phone layouts are single-column at 320 px and 390 px, respect the effective top safe-area inset, preserve 48 px targets, and have no horizontal page scroll. Current Plan, CMHT, Safety Plan, and `Record ED presentation` remain directly reachable. - Support keyboard use, visible focus, dark mode, forced colours, reduced motion, 200% zoom, and monochrome print. Colour never carries state alone. - Before UI code, follow the already-read installed Next.js 16 guides for layouts/pages, navigation, server/client boundaries, dynamic `params: Promise<...>`, and CSS ordering. -- Use repository wrappers: `npm run test -- ...`, `npm run ensure`, and `npm run test:e2e:ed-care-plans-mockup`. Never call Playwright directly and never assume a localhost port. +- Use repository wrappers: `npm run test -- ...`, `npm run ensure`, and `npm run test:e2e:care-plan-mockup`. Never call Playwright directly and never assume a localhost port. - Apply TDD to every production behaviour: add the smallest failing test, run it and confirm the expected failure, implement the minimum, rerun to green, then refactor while green. - Before writing the first test in any task, the implementer reads `C:\Users\joshs\.codex\plugins\cache\openai-curated-remote\superpowers\6.3.0\skills\test-driven-development\writing-good-tests.md` and names the production change that would make each test fail. - SDD runs only one implementer at a time, followed by a fresh task reviewer. The controller creates the task brief and review package and records results in this plan's ignored SDD ledger. - **Local commits are authorised** (user, 21 August 2026): commit once at the end of each task, on this branch, in this worktree. Nothing else is authorised — no push, pull, merge, rebase, PR, deployment, provider access, or publication. -- **Future-persistence seam (user decision, 21 August 2026).** The prototype stays memory-only and reset-on-refresh, but must be built so that storage could later be added without redesigning the domain. Therefore: `prototypeReducer` stays a pure `(state, action) => state` function with no browser, timer, network, or module-level mutable state; `EdCarePlansPrototypeState` and every entity in it stays plain JSON-serialisable data (no `Date`, `Map`, `Set`, class instance, or function value); all IDs are allocated by the caller or by `nextSyntheticId`, never by `crypto`/`Math.random`/`Date.now`; and every state change goes through one dispatched action. Do NOT add a storage layer, adapter interface, persistence flag, or migration scaffolding now — that is speculative work the prototype does not need. The seam is the discipline, not extra code. +- **Future-persistence seam (user decision, 21 August 2026).** The prototype stays memory-only and reset-on-refresh, but must be built so that storage could later be added without redesigning the domain. Therefore: `prototypeReducer` stays a pure `(state, action) => state` function with no browser, timer, network, or module-level mutable state; `CarePlanPrototypeState` and every entity in it stays plain JSON-serialisable data (no `Date`, `Map`, `Set`, class instance, or function value); all IDs are allocated by the caller or by `nextSyntheticId`, never by `crypto`/`Math.random`/`Date.now`; and every state change goes through one dispatched action. Do NOT add a storage layer, adapter interface, persistence flag, or migration scaffolding now — that is speculative work the prototype does not need. The seam is the discipline, not extra code. ## File Map ### Domain and state -- Create `src/components/ed-care-plans/mockups/types.ts` — complete domain/entity/input/action types. -- Create `src/components/ed-care-plans/mockups/fixtures.ts` — deterministic patients, users, sites, CMHTs, plans, presentations, reviews, audit events, scenarios, and public crisis contacts. -- Create `src/components/ed-care-plans/mockups/domain.ts` — search, current-version, activity, queue, contact-URI, and permission selectors. -- Create `src/components/ed-care-plans/mockups/prototype-state.ts` — initial-state builder, pure reducer, transition guards, IDs, and deterministic timestamps. -- Create `src/components/ed-care-plans/mockups/prototype-provider.tsx` — layout-scoped React provider and hook. -- Create `src/components/ed-care-plans/mockups/routes.ts` — literal route registry, query builders, and finite dynamic-parameter guards. +- Create `src/components/care-plan/mockups/types.ts` — complete domain/entity/input/action types. +- Create `src/components/care-plan/mockups/fixtures.ts` — deterministic patients, users, sites, CMHTs, plans, presentations, reviews, audit events, scenarios, and public crisis contacts. +- Create `src/components/care-plan/mockups/domain.ts` — search, current-version, activity, queue, contact-URI, and permission selectors. +- Create `src/components/care-plan/mockups/prototype-state.ts` — initial-state builder, pure reducer, transition guards, IDs, and deterministic timestamps. +- Create `src/components/care-plan/mockups/prototype-provider.tsx` — layout-scoped React provider and hook. +- Create `src/components/care-plan/mockups/routes.ts` — literal route registry, query builders, and finite dynamic-parameter guards. ### Route family and shell -- Create `src/app/mockups/ed-care-plans/layout.tsx`, `loading.tsx`, and `route-page.tsx`. +- Create `src/app/mockups/care-plan/layout.tsx`, `loading.tsx`, and `route-page.tsx`. - Create the seventeen `page.tsx` files listed in Task 3. -- Create `src/components/ed-care-plans/mockups/routable-suite.tsx` — pathname/query interpretation and route-to-page composition. -- Create `src/components/ed-care-plans/mockups/ed-care-plans-shell-frame.tsx` — desktop rail, phone navigation, synthetic marker, patient search, role specimen, route header, and announcements. -- Create `src/components/ed-care-plans/mockups/ed-care-plans.module.css` — route-scoped layout/safe-area/print rules. -- Create `src/components/ed-care-plans/mockups/index.ts` — public exports used by route files and tests. +- Create `src/components/care-plan/mockups/routable-suite.tsx` — pathname/query interpretation and route-to-page composition. +- Create `src/components/care-plan/mockups/care-plan-shell-frame.tsx` — desktop rail, phone navigation, synthetic marker, patient search, role specimen, route header, and announcements. +- Create `src/components/care-plan/mockups/care-plan.module.css` — route-scoped layout/safe-area/print rules. +- Create `src/components/care-plan/mockups/index.ts` — public exports used by route files and tests. ### Product surfaces - Create `clinical-snapshot-page.tsx`, `patient-directory.tsx`, `patient-workspace.tsx`, `patient-navigation.tsx`, and `contact-actions.tsx`. -- Create `management-plan-pages.tsx`, `management-plan-form.tsx`, and `management-plan-diff.tsx`. +- Create `management-plan-read.tsx`, `management-plan-print.tsx`, `management-plan-form.tsx`, and `management-plan-diff.tsx`. +- Create `patient-plan-transform.ts`, `patient-plan-pages.tsx`, and `patient-plan-form.tsx`. - Create `presentation-pages.tsx`, `presentation-form.tsx`, and `presentation-timeline.tsx`. - Create `safety-plan-pages.tsx` and `safety-plan-form.tsx`. - Create `operations-pages.tsx`, `history-page.tsx`, and `system-states-page.tsx`. -- Create `prototype-ui.tsx` only for ED Care Plans-specific presentational patterns that combine repository primitives; do not duplicate shared primitives. +- Create `prototype-ui.tsx` only for Care Plan-specific presentational patterns that combine repository primitives; do not duplicate shared primitives. ### Existing integration files -- Modify `src/lib/developer-area/headers.ts` and related proxy comments so `/mockups/ed-care-plans/**` uses the existing signed-in-administrator developer gate. -- Modify `src/app/mockups/mockups-layout-client.tsx` so ED Care Plans owns its shell instead of inheriting global mockup search chrome. +- Modify `src/components/ui/print-output.tsx` to generalise shared print capabilities used by both print views. +- Modify `src/lib/developer-area/headers.ts` and related proxy comments so `/mockups/care-plan/**` uses the existing signed-in-administrator developer gate. +- Modify `src/app/mockups/mockups-layout-client.tsx` so Care Plan owns its shell instead of inheriting global mockup search chrome. - Modify `src/app/mockups/development/page.tsx` to add the literal inbound Developer-hub entry and selected deep links. - Modify `package.json`, `playwright.config.ts`, `docs/codebase-index.md`, `docs/site-map.md`, and generated documentation required by `npm run docs:update`. ### Tests and handoff evidence -- Create `tests/ed-care-plans-domain.test.ts`. -- Create `tests/ed-care-plans-prototype-state.test.ts`. -- Create `tests/ed-care-plans-route-files.test.ts`. -- Create `tests/ed-care-plans-linked-routes.dom.test.tsx`. -- Create `tests/ui-ed-care-plans-mockup.spec.ts`. +- Create `tests/care-plan-domain.test.ts`. +- Create `tests/care-plan-patient-plan.test.ts`. +- Create `tests/care-plan-prototype-state.test.ts`. +- Create `tests/care-plan-route-files.test.ts`. +- Create `tests/care-plan-linked-routes.dom.test.tsx`. +- Create `tests/ui-care-plan-mockup.spec.ts`. - Modify `tests/proxy.test.ts` and `tests/playwright-project-isolation.test.ts`. -- Create `docs/ed-care-plans/interaction-matrix.md`, `clinical-language-trace.md`, `accessibility-acceptance.md`, `implementation-handoff.md`, and `verification-report.md`. +- Create `docs/care-plan/interaction-matrix.md`, `clinical-language-trace.md`, `accessibility-acceptance.md`, `implementation-handoff.md`, and `verification-report.md`. ## Canonical Interfaces @@ -244,6 +260,72 @@ export type CmhtContact = { verificationState: "verified" | "review_due" | "unverified"; }; +export type PatientPlanVersionState = "draft" | "current" | "superseded"; + +export const PATIENT_PLAN_SECTION_KEYS = [ + "whyWeWroteThis", + "whatMattersToYou", + "whatHelpsYou", + "whatMakesThingsHarder", + "whatWeAgreedWillHappen", + "ifSomethingNewIsHappening", + "whoIsInvolved", + "thingsThatMightHelp", +] as const; + +export type PatientPlanSectionKey = (typeof PATIENT_PLAN_SECTION_KEYS)[number]; + +export type PatientPlanSection = { + key: PatientPlanSectionKey; + heading: string; + /** Converted content. Always empty when `gap` is true — the transformation + * never guesses, so a gap carries no partial text to be mistaken for one. */ + body: readonly string[]; + gap: boolean; + gapReason: string | null; +}; + +export type PatientResourceCategory = + | "care_team" + | "local_service" + | "housing" + | "financial" + | "transport" + | "carer_support" + | "alcohol_and_other_drugs" + | "cultural_or_peer" + | "crisis_contact" + | "self_help_reading"; + +export type PatientResource = { + id: SyntheticId; + category: PatientResourceCategory; + name: string; + detail: string; + contact: string | null; + sourceUrl: string | null; +}; + +export type PatientPlan = { + id: SyntheticId; + patientId: SyntheticId; + versionIds: readonly SyntheticId[]; + currentVersionId: SyntheticId | null; +}; + +export type PatientPlanVersion = { + id: SyntheticId; + planId: SyntheticId; + version: number; + state: PatientPlanVersionState; + derivedFromManagementVersionId: SyntheticId; + sections: readonly PatientPlanSection[]; + resources: readonly PatientResource[]; + approvedBy: SyntheticId | null; + approvedAt: string | null; + createdAt: string; +}; + export type ManagementPlan = { id: SyntheticId; patientId: SyntheticId; @@ -269,6 +351,10 @@ export type ManagementPlanVersion = { consentedSupportPeople: readonly string[]; returnedReason: string | null; withdrawalReason: string | null; + withdrawnBy: SyntheticId | null; + withdrawnAt: string | null; + /** When the person was shown their own plan, or null if they have not been. */ + sharedWithPatientAt: string | null; content: ManagementPlanContent; }; @@ -377,6 +463,12 @@ export type AuditEventType = | "safety_plan_draft_saved" | "safety_plan_made_current" | "safety_plan_print_intent_opened" + | "management_plan_print_intent_opened" + | "management_plan_shared_with_patient" + | "patient_plan_draft_created" + | "patient_plan_draft_saved" + | "patient_plan_approved" + | "patient_plan_print_intent_opened" | "email_intent_opened" | "call_intent_opened" | "identification_review_created" @@ -399,7 +491,7 @@ export type PrototypeOutcome = { message: string; }; -export type EdCarePlansPrototypeState = { +export type CarePlanPrototypeState = { scenario: PrototypeScenario; persistence: "memory-only"; activeUserId: SyntheticId; @@ -416,6 +508,9 @@ export type EdCarePlansPrototypeState = { managementPlanVersions: ManagementPlanVersion[]; personalSafetyPlans: PersonalSafetyPlan[]; personalSafetyPlanVersions: PersonalSafetyPlanVersion[]; + patientPlans: PatientPlan[]; + patientPlanVersions: PatientPlanVersion[]; + patientResources: PatientResource[]; edPresentations: EdPresentation[]; presentationAmendments: PresentationAmendment[]; reviewTriggers: ReviewTrigger[]; @@ -444,7 +539,7 @@ export type SafetyPlanDraftInput = { export type NewEdPresentationInput = Omit; ``` -`types.ts` must also define the discriminated `EdCarePlansPrototypeAction` union in Task 2 using only these canonical types. +`types.ts` must also define the discriminated `CarePlanPrototypeAction` union in Task 2 using only these canonical types. --- @@ -452,11 +547,13 @@ export type NewEdPresentationInput = Omit { @@ -504,7 +601,7 @@ it("builds a generic CMHT email intent without patient information", () => { }); ``` -- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts`. A module-resolution error is setup evidence, not the RED gate. Add only the requested export signatures with empty values/throwing bodies, rerun, and confirm an assertion now fails for the intended missing domain behaviour before implementing it. +- [ ] Run `npm run test -- tests/care-plan-domain.test.ts`. A module-resolution error is setup evidence, not the RED gate. Add only the requested export signatures with empty values/throwing bodies, rerun, and confirm an assertion now fails for the intended missing domain behaviour before implementing it. - [ ] Add the canonical unions and entity types to `types.ts`. Use readonly content arrays in preserved versions; reducer actions will replace whole versions rather than mutating nested arrays. - [ ] Add deterministic fixture constants to `fixtures.ts` with `PROTOTYPE_NOW = "2026-08-20T14:30:00+08:00"` and these stable entity identities: @@ -525,10 +622,10 @@ it("builds a generic CMHT email intent without patient information", () => { - [ ] Make `getReviewQueues` return exactly `{ awaitingApproval, reviewSuggested, contactVerification, identificationReview }`, each ordered oldest-actionable-first and never severity-ranked. The `identificationReview` queue contains only referrals whose `status` is `open`. - [ ] Make `deriveReviewState(reviewDueAt, now)` return `overdue` past the date, `due_soon` within `REVIEW_DUE_SOON_DAYS` of it, and `within_review` otherwise, with a test pinning both boundaries exactly. - [ ] Finish the remaining tests: all entity IDs are synthetic; Current selection is unique; Draft stays separate; raw activity derives from episodes; public contacts match the exact authorised list; no fixture uses stigmatising labels; safe URI builders contain no patient field. -- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts`. Expected GREEN: the new domain test file passes with zero failures. -- [ ] Run `npx prettier --write src/components/ed-care-plans/mockups/types.ts src/components/ed-care-plans/mockups/fixtures.ts src/components/ed-care-plans/mockups/domain.ts tests/ed-care-plans-domain.test.ts` and rerun the same test command. +- [ ] Run `npm run test -- tests/care-plan-domain.test.ts`. Expected GREEN: the new domain test file passes with zero failures. +- [ ] Run `npx prettier --write src/components/care-plan/mockups/types.ts src/components/care-plan/mockups/fixtures.ts src/components/care-plan/mockups/domain.ts tests/care-plan-domain.test.ts` and rerun the same test command. - [ ] Review the task diff for duplicate entity types, mutable preserved versions, non-synthetic IDs, hidden threshold logic, and real organisation names outside the authorised public crisis fixtures. -- [ ] Commit only Task 1 files with `feat(ed-care-plans): define synthetic clinical domain`. Do not push. +- [ ] Commit only Task 1 files with `feat(care-plan): define synthetic clinical domain`. Do not push. ## Task 2: Pure Lifecycle Reducer and Layout-Scoped Provider @@ -536,12 +633,12 @@ it("builds a generic CMHT email intent without patient information", () => { **Files:** -- Modify: `src/components/ed-care-plans/mockups/types.ts` -- Create: `src/components/ed-care-plans/mockups/prototype-state.ts` -- Create: `src/components/ed-care-plans/mockups/prototype-provider.tsx` -- Create: `tests/ed-care-plans-prototype-state.test.ts` +- Modify: `src/components/care-plan/mockups/types.ts` +- Create: `src/components/care-plan/mockups/prototype-state.ts` +- Create: `src/components/care-plan/mockups/prototype-provider.tsx` +- Create: `tests/care-plan-prototype-state.test.ts` -- [ ] Write `tests/ed-care-plans-prototype-state.test.ts` before production code. Start with the approval invariant and append-only presentation tests. +- [ ] Write `tests/care-plan-prototype-state.test.ts` before production code. Start with the approval invariant and append-only presentation tests. ```ts it("approves an awaiting version atomically and preserves exactly one Current Plan", () => { @@ -573,14 +670,14 @@ it("adds a visible amendment without changing the original ED Presentation", () }); ``` -- [ ] Run `npm run test -- tests/ed-care-plans-prototype-state.test.ts`. If the import cannot resolve, add only the exported reducer signatures with throwing bodies, rerun, and confirm the first behavioural assertion fails for the intended lifecycle reason before implementing the transition. +- [ ] Run `npm run test -- tests/care-plan-prototype-state.test.ts`. If the import cannot resolve, add only the exported reducer signatures with throwing bodies, rerun, and confirm the first behavioural assertion fails for the intended lifecycle reason before implementing the transition. - [ ] Define `createInitialPrototypeState(scenario: PrototypeScenario = "normal")` by deep-cloning fixture arrays, setting `persistence: "memory-only"`, selecting Rowan by default, and deriving scenario flags without browser APIs. - [ ] Define `getPrototypeMutationBlockReason(state, action)` so offline, unavailable permission, identity uncertainty, and version conflict leave clinical entities unchanged and set a specific `lastOutcome`. - [ ] Implement deterministic ID allocation as `nextSyntheticId(prefix, existingIds)` plus `nextPresentationId(state)`, and use `PROTOTYPE_NOW` plus stable per-action minute offsets. Do not use randomness or the wall clock in reducer tests. Reducer actions that receive an allocated ID must reject duplicates and the wrong synthetic prefix. - [ ] Implement the exact discriminated action set: ```ts -export type EdCarePlansPrototypeAction = +export type CarePlanPrototypeAction = | { type: "select-patient"; patientId: SyntheticId } | { type: "set-active-user"; userId: SyntheticId } | { type: "create-management-draft"; patientId: SyntheticId } @@ -614,6 +711,17 @@ export type EdCarePlansPrototypeAction = | { type: "save-safety-plan-draft"; versionId: SyntheticId; input: SafetyPlanDraftInput } | { type: "make-safety-plan-current"; versionId: SyntheticId } | { type: "record-safety-plan-print-intent"; patientId: SyntheticId } + | { type: "record-management-plan-print-intent"; patientId: SyntheticId } + | { type: "record-plan-shared-with-patient"; patientId: SyntheticId } + | { type: "create-patient-plan-draft"; patientId: SyntheticId } + | { + type: "save-patient-plan-draft"; + versionId: SyntheticId; + sections: readonly PatientPlanSection[]; + resources: readonly PatientResource[]; + } + | { type: "approve-patient-plan-version"; versionId: SyntheticId } + | { type: "record-patient-plan-print-intent"; patientId: SyntheticId } | { type: "record-contact-intent"; patientId: SyntheticId; cmhtId: SyntheticId; channel: "email" | "call" } | { type: "create-identification-review"; patientId: SyntheticId; reason: string } | { @@ -637,11 +745,11 @@ export type EdCarePlansPrototypeAction = - [ ] Make Safety Plan publication require an editable Draft, supersede the prior Current Safety Plan, and set the new version Current without consulting senior-approval state. - [ ] Make contact-intent actions append only intent audit events; make manual identification referral append an Identification Review and audit event without creating a Management Plan or Review Trigger. - [ ] Make `close-identification-review` require an `open` review and a non-empty reason, set `status: "closed"` with the decision, reason, actor, and time, and append one `identification_review_closed` audit event. It must create no plan and no version on any decision, including `proceed_to_plan`. Closing an already-closed review leaves state unchanged and sets a specific `lastOutcome`. -- [ ] Add `EdCarePlansPrototypeProvider`, `useEdCarePlansPrototype`, and an online/offline listener to `prototype-provider.tsx`. The provider calls `useReducer` once and performs no persistence. +- [ ] Add `CarePlanPrototypeProvider`, `useCarePlanPrototype`, and an online/offline listener to `prototype-provider.tsx`. The provider calls `useReducer` once and performs no persistence. - [ ] Complete reducer tests for draft/current separation, non-senior approval refusal, return reason, withdrawal, formal review, Review Trigger creation/deduplication, Safety Plan independence, manual referral, intent-only audit language, degraded-state refusal, reset, and scenario reconstruction. -- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts`. Expected GREEN: both files pass with zero failures. +- [ ] Run `npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts`. Expected GREEN: both files pass with zero failures. - [ ] Format the four Task 2 files, rerun the two tests, and inspect the diff for in-place array mutation, wall-clock/random IDs, overclaimed audit events, and transition paths that bypass permission checks. -- [ ] Commit only Task 2 files with `feat(ed-care-plans): enforce longitudinal plan lifecycles`. Do not push. +- [ ] Commit only Task 2 files with `feat(care-plan): enforce longitudinal plan lifecycles`. Do not push. ## Task 3: Gated Route Family, Literal Navigation, and Responsive Clinical Shell @@ -649,91 +757,99 @@ export type EdCarePlansPrototypeAction = **Files:** -- Create: `src/components/ed-care-plans/mockups/routes.ts` -- Create: `src/components/ed-care-plans/mockups/ed-care-plans-shell-frame.tsx` -- Create: `src/components/ed-care-plans/mockups/ed-care-plans.module.css` -- Create: `src/components/ed-care-plans/mockups/routable-suite.tsx` -- Create: `src/components/ed-care-plans/mockups/index.ts` -- Create: `src/app/mockups/ed-care-plans/layout.tsx` -- Create: `src/app/mockups/ed-care-plans/loading.tsx` -- Create: `src/app/mockups/ed-care-plans/route-page.tsx` +- Create: `src/components/care-plan/mockups/routes.ts` +- Create: `src/components/care-plan/mockups/care-plan-shell-frame.tsx` +- Create: `src/components/care-plan/mockups/care-plan.module.css` +- Create: `src/components/care-plan/mockups/routable-suite.tsx` +- Create: `src/components/care-plan/mockups/index.ts` +- Create: `src/app/mockups/care-plan/layout.tsx` +- Create: `src/app/mockups/care-plan/loading.tsx` +- Create: `src/app/mockups/care-plan/route-page.tsx` - Create: every `page.tsx` listed below - Modify: `src/lib/developer-area/headers.ts` - Modify: `src/proxy.ts` comments describing gated prefixes - Modify: `src/app/mockups/mockups-layout-client.tsx` - Modify: `src/app/mockups/development/page.tsx` - Modify: `tests/proxy.test.ts` -- Create: `tests/ed-care-plans-route-files.test.ts` -- Create: `tests/ed-care-plans-linked-routes.dom.test.tsx` +- Create: `tests/care-plan-route-files.test.ts` +- Create: `tests/care-plan-linked-routes.dom.test.tsx` - [ ] Add route-file tests first. Pin the exact route registry and every expected file path: ```ts -expect(ED_CARE_PLANS_ROUTES).toEqual({ - home: "/mockups/ed-care-plans", - patients: "/mockups/ed-care-plans/patients", - patient: "/mockups/ed-care-plans/patients/SYN-PATIENT-001", - managementPlan: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/management-plan", - managementPlanEdit: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/management-plan/edit", - managementPlanReview: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/management-plan/review", - safetyPlan: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/safety-plan", - safetyPlanEdit: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/safety-plan/edit", - safetyPlanPrint: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/safety-plan/print", - presentations: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/presentations", - newPresentation: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/presentations/new", - presentation: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/presentations/SYN-PRESENTATION-001", - history: "/mockups/ed-care-plans/patients/SYN-PATIENT-001/history", - reviews: "/mockups/ed-care-plans/reviews", - team: "/mockups/ed-care-plans/team", - governance: "/mockups/ed-care-plans/governance", - systemStates: "/mockups/ed-care-plans/system-states", +expect(CARE_PLAN_ROUTES).toEqual({ + home: "/mockups/care-plan", + patients: "/mockups/care-plan/patients", + patient: "/mockups/care-plan/patients/SYN-PATIENT-001", + managementPlan: "/mockups/care-plan/patients/SYN-PATIENT-001/management-plan", + managementPlanEdit: "/mockups/care-plan/patients/SYN-PATIENT-001/management-plan/edit", + managementPlanReview: "/mockups/care-plan/patients/SYN-PATIENT-001/management-plan/review", + managementPlanPrint: "/mockups/care-plan/patients/SYN-PATIENT-001/management-plan/print", + patientPlan: "/mockups/care-plan/patients/SYN-PATIENT-001/patient-plan", + patientPlanEdit: "/mockups/care-plan/patients/SYN-PATIENT-001/patient-plan/edit", + patientPlanPrint: "/mockups/care-plan/patients/SYN-PATIENT-001/patient-plan/print", + safetyPlan: "/mockups/care-plan/patients/SYN-PATIENT-001/safety-plan", + safetyPlanEdit: "/mockups/care-plan/patients/SYN-PATIENT-001/safety-plan/edit", + safetyPlanPrint: "/mockups/care-plan/patients/SYN-PATIENT-001/safety-plan/print", + presentations: "/mockups/care-plan/patients/SYN-PATIENT-001/presentations", + newPresentation: "/mockups/care-plan/patients/SYN-PATIENT-001/presentations/new", + presentation: "/mockups/care-plan/patients/SYN-PATIENT-001/presentations/SYN-PRESENTATION-001", + history: "/mockups/care-plan/patients/SYN-PATIENT-001/history", + reviews: "/mockups/care-plan/reviews", + team: "/mockups/care-plan/team", + governance: "/mockups/care-plan/governance", + systemStates: "/mockups/care-plan/system-states", }); ``` - [ ] In the same test file, recursively read only the new route/component namespaces and reject `fetch(`, storage APIs, cookies, OpenAI/Supabase/analytics imports, route handlers, and non-mockup application routes. - [ ] Add proxy tests that expect production access to pass through to `DeveloperAreaGate` for the base, patient deep route, and presentation deep route; expect similarly prefixed archive paths to remain blocked. -- [ ] Add initial DOM tests that render `EdCarePlansRouteSurface` with an injected `navigate` spy, then assert one `

`, the synthetic boundary, desktop rail links, phone navigation, active destination, and route headings. -- [ ] Run `npm run test -- tests/ed-care-plans-route-files.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx tests/proxy.test.ts`. The file-existence and proxy assertions must fail directly. If the DOM import cannot resolve, add only the route-surface export signature returning `null`, rerun, and confirm its landmark/navigation assertion fails before implementing the shell. -- [ ] Run `npm run workflow:design-sweep -- --files src/app/mockups/ed-care-plans,src/components/ed-care-plans/mockups,src/app/mockups/mockups-layout-client.tsx --write-evidence` before UI implementation, review the ignored `.local/workflow-evidence` output, and record the result in the SDD report. This is the repository UI skill's design-system preflight, not product verification. +- [ ] Add initial DOM tests that render `CarePlanRouteSurface` with an injected `navigate` spy, then assert one `

`, the synthetic boundary, desktop rail links, phone navigation, active destination, and route headings. +- [ ] Run `npm run test -- tests/care-plan-route-files.test.ts tests/care-plan-linked-routes.dom.test.tsx tests/proxy.test.ts`. The file-existence and proxy assertions must fail directly. If the DOM import cannot resolve, add only the route-surface export signature returning `null`, rerun, and confirm its landmark/navigation assertion fails before implementing the shell. +- [ ] Run `npm run workflow:design-sweep -- --files src/app/mockups/care-plan,src/components/care-plan/mockups,src/app/mockups/mockups-layout-client.tsx --write-evidence` before UI implementation, review the ignored `.local/workflow-evidence` output, and record the result in the SDD report. This is the repository UI skill's design-system preflight, not product verification. - [ ] Run `npm run ensure`, use only the repository-printed URL, and confirm `/api/local-project-id` identifies this Database project. Do not attach to or stop another project's server. -- [ ] Create `routes.ts` with `ED_CARE_PLANS_BASE`, the exact `ED_CARE_PLANS_ROUTES` object above, `edCarePlansRoute.patient(patientId)`, `managementPlan(patientId)`, `safetyPlan(patientId)`, `presentations(patientId)`, `presentation(patientId, presentationId)`, `scenario(name, route?)`, and `withQuery(route, key, value)`. +- [ ] Create `routes.ts` with `CARE_PLAN_BASE`, the exact `CARE_PLAN_ROUTES` object above, `carePlanRoute.patient(patientId)`, `managementPlan(patientId)`, `safetyPlan(patientId)`, `presentations(patientId)`, `presentation(patientId, presentationId)`, `scenario(name, route?)`, and `withQuery(route, key, value)`. - [ ] Export finite `SYNTHETIC_PATIENT_PARAMS` and `SYNTHETIC_PRESENTATION_PARAMS` aligned to fixtures plus `isSyntheticPatientId` and `isSyntheticPresentationForPatient`. Unknown dynamic parameters must call `notFound()` in the server page. -- [ ] Create these page files; static pages return ``, patient pages await `params: Promise<{ patientId: string }>`, and the episode page awaits both IDs: +- [ ] Create these twenty-one page files; static pages return ``, patient pages await `params: Promise<{ patientId: string }>`, and the episode page awaits both IDs: ```text -src/app/mockups/ed-care-plans/page.tsx -src/app/mockups/ed-care-plans/patients/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/management-plan/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/management-plan/edit/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/management-plan/review/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/safety-plan/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/safety-plan/edit/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/safety-plan/print/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/presentations/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/presentations/new/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/presentations/[presentationId]/page.tsx -src/app/mockups/ed-care-plans/patients/[patientId]/history/page.tsx -src/app/mockups/ed-care-plans/reviews/page.tsx -src/app/mockups/ed-care-plans/team/page.tsx -src/app/mockups/ed-care-plans/governance/page.tsx -src/app/mockups/ed-care-plans/system-states/page.tsx +src/app/mockups/care-plan/page.tsx +src/app/mockups/care-plan/patients/page.tsx +src/app/mockups/care-plan/patients/[patientId]/page.tsx +src/app/mockups/care-plan/patients/[patientId]/management-plan/page.tsx +src/app/mockups/care-plan/patients/[patientId]/management-plan/edit/page.tsx +src/app/mockups/care-plan/patients/[patientId]/management-plan/review/page.tsx +src/app/mockups/care-plan/patients/[patientId]/management-plan/print/page.tsx +src/app/mockups/care-plan/patients/[patientId]/patient-plan/page.tsx +src/app/mockups/care-plan/patients/[patientId]/patient-plan/edit/page.tsx +src/app/mockups/care-plan/patients/[patientId]/patient-plan/print/page.tsx +src/app/mockups/care-plan/patients/[patientId]/safety-plan/page.tsx +src/app/mockups/care-plan/patients/[patientId]/safety-plan/edit/page.tsx +src/app/mockups/care-plan/patients/[patientId]/safety-plan/print/page.tsx +src/app/mockups/care-plan/patients/[patientId]/presentations/page.tsx +src/app/mockups/care-plan/patients/[patientId]/presentations/new/page.tsx +src/app/mockups/care-plan/patients/[patientId]/presentations/[presentationId]/page.tsx +src/app/mockups/care-plan/patients/[patientId]/history/page.tsx +src/app/mockups/care-plan/reviews/page.tsx +src/app/mockups/care-plan/team/page.tsx +src/app/mockups/care-plan/governance/page.tsx +src/app/mockups/care-plan/system-states/page.tsx ``` - [ ] Add `generateStaticParams()` to every dynamic page from the finite parameter lists. Do not duplicate literal IDs across page files. -- [ ] Create `layout.tsx` that nests `DeveloperAreaGate` outside `EdCarePlansPrototypeProvider`. Create one `loading.tsx`/Suspense fallback that exposes `aria-busy` and no fake patient content. -- [ ] Add `/mockups/ed-care-plans` to `DEVELOPER_GATED_PATH_PREFIXES`, update the proxy's explanatory comment, and update proxy tests. Do not widen access to all `/mockups/**`. -- [ ] Add `isEdCarePlansMockup` to `mockups-layout-client.tsx`; use the same base-or-descendant test as Caring Contact and exclude the route family from both shared composer and shared chrome. -- [ ] Add an ED Care Plans surface to `DEVELOPMENT_SURFACES` with a literal home link and deep links for Patients, Reviews, Governance, and System states. -- [ ] Build `EdCarePlansShellFrame` with desktop `Home`, `Patients`, `Reviews`, `Team`, and `Governance` links; a phone `Home`, `Patients`, `Reviews`, and `More` navigation; one search slot; displayed synthetic user/role; `Synthetic prototype — fictional data only`; page title; and one action slot. +- [ ] Create `layout.tsx` that nests `DeveloperAreaGate` outside `CarePlanPrototypeProvider`. Create one `loading.tsx`/Suspense fallback that exposes `aria-busy` and no fake patient content. +- [ ] Add `/mockups/care-plan` to `DEVELOPER_GATED_PATH_PREFIXES`, update the proxy's explanatory comment, and update proxy tests. Do not widen access to all `/mockups/**`. +- [ ] Add `isCarePlanMockup` to `mockups-layout-client.tsx`; use the same base-or-descendant test as Caring Contact and exclude the route family from both shared composer and shared chrome. +- [ ] Add an Care Plan surface to `DEVELOPMENT_SURFACES` with a literal home link and deep links for Patients, Reviews, Governance, and System states. +- [ ] Build `CarePlanShellFrame` with desktop `Home`, `Patients`, `Reviews`, `Team`, and `Governance` links; a phone `Home`, `Patients`, `Reviews`, and `More` navigation; one search slot; displayed synthetic user/role; `Synthetic prototype — fictional data only`; page title; and one action slot. - [ ] Use `Sheet` for phone More navigation and keep the bottom dock outside print. Every link comes from `routes.ts`; every button has a handler. Set `aria-current="page"` from the resolved destination. -- [ ] In `ed-care-plans.module.css`, scope all selectors below `.appRoot`; implement desktop rail plus content, phone single column, top safe-area padding using `max(..., var(--safe-area-top))`, phone dock clearance, reduced-motion overrides, forced-colour borders, and print suppression through `data-print-hide`. -- [ ] Build `EdCarePlansRouteSurface({ pathname, query, navigate })` and `EdCarePlansRoutableSuite()`. The testable surface receives strings and a navigation callback; the router wrapper supplies `usePathname`, `useSearchParams`, and `router.push`. +- [ ] In `care-plan.module.css`, scope all selectors below `.appRoot`; implement desktop rail plus content, phone single column, top safe-area padding using `max(..., var(--safe-area-top))`, phone dock clearance, reduced-motion overrides, forced-colour borders, and print suppression through `data-print-hide`. +- [ ] Build `CarePlanRouteSurface({ pathname, query, navigate })` and `CarePlanRoutableSuite()`. The testable surface receives strings and a navigation callback; the router wrapper supplies `usePathname`, `useSearchParams`, and `router.push`. - [ ] For this task only, route content is a semantic `RoutePurposeSurface` containing the approved route heading and purpose copy. It is a working shell specimen, contains no unavailable controls, and is replaced route-by-route in Tasks 4–8. - [ ] Run the three-test RED command again. Expected GREEN: route files, DOM shell, and proxy boundary all pass. - [ ] Run `npm run typecheck`. Expected GREEN: Next 16 async params, client/server boundaries, CSS module, and route imports compile. - [ ] Format all Task 3 files, rerun the three tests and typecheck, and inspect literal links, gate scope, focus names, 48 px targets, and absence of raw patient content in query strings. -- [ ] Commit Task 3 with `feat(ed-care-plans): add gated clinical route shell`. Do not push. +- [ ] Commit Task 3 with `feat(care-plan): add gated clinical route shell`. Do not push. ## Task 4: Clinical Snapshot, Patient Search, Current Plan Hierarchy, and CMHT Actions @@ -741,21 +857,21 @@ src/app/mockups/ed-care-plans/system-states/page.tsx **Files:** -- Create: `src/components/ed-care-plans/mockups/prototype-ui.tsx` -- Create: `src/components/ed-care-plans/mockups/clinical-snapshot-page.tsx` -- Create: `src/components/ed-care-plans/mockups/patient-directory.tsx` -- Create: `src/components/ed-care-plans/mockups/patient-workspace.tsx` -- Create: `src/components/ed-care-plans/mockups/patient-navigation.tsx` -- Create: `src/components/ed-care-plans/mockups/contact-actions.tsx` -- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` -- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` +- Create: `src/components/care-plan/mockups/prototype-ui.tsx` +- Create: `src/components/care-plan/mockups/clinical-snapshot-page.tsx` +- Create: `src/components/care-plan/mockups/patient-directory.tsx` +- Create: `src/components/care-plan/mockups/patient-workspace.tsx` +- Create: `src/components/care-plan/mockups/patient-navigation.tsx` +- Create: `src/components/care-plan/mockups/contact-actions.tsx` +- Modify: `src/components/care-plan/mockups/routable-suite.tsx` +- Modify: `tests/care-plan-linked-routes.dom.test.tsx` - [ ] Add failing DOM tests for search, selection, Current hierarchy, overdue/no-plan states, patient tabs, and contact intents before creating any Task 4 component. ```tsx it("finds a synthetic patient and keeps Current Plan above an awaiting draft", async () => { const user = userEvent.setup(); - renderRoute(ED_CARE_PLANS_ROUTES.home, "scenario=overdue-plan"); + renderRoute(CARE_PLAN_ROUTES.home, "scenario=overdue-plan"); await user.type(screen.getByRole("searchbox", { name: "Search synthetic patients" }), "SYN-MRN-0002"); await user.click(screen.getByRole("button", { name: /Open Mira Example/i })); @@ -766,7 +882,7 @@ it("finds a synthetic patient and keeps Current Plan above an awaiting draft", a }); it("exposes only intent-safe CMHT launch links", () => { - renderRoute(ED_CARE_PLANS_ROUTES.patient); + renderRoute(CARE_PLAN_ROUTES.patient); expect(screen.getByRole("link", { name: "Email North River CMHT" })).toHaveAttribute( "href", "mailto:north-river.cmht@example.org?subject=ED+Care+Plans+%E2%80%94+team+contact+request", @@ -775,8 +891,8 @@ it("exposes only intent-safe CMHT launch links", () => { }); ``` -- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "synthetic patient|CMHT|Current Plan|No Current Plan|patient sections"`. Confirm RED because the product surfaces do not exist. -- [ ] Add ED Care Plans-specific `StatusMark`, `DefinitionRow`, `SectionFrame`, `SyntheticMarker`, and `ReviewWarning` compositions to `prototype-ui.tsx`. These combine tokens and shared primitives; they must not recreate Button, fields, tabs, dialogs, or sheets. +- [ ] Run `npm run test -- tests/care-plan-linked-routes.dom.test.tsx -t "synthetic patient|CMHT|Current Plan|No Current Plan|patient sections"`. Confirm RED because the product surfaces do not exist. +- [ ] Add Care Plan-specific `StatusMark`, `DefinitionRow`, `SectionFrame`, `SyntheticMarker`, and `ReviewWarning` compositions to `prototype-ui.tsx`. These combine tokens and shared primitives; they must not recreate Button, fields, tabs, dialogs, or sheets. - [ ] Implement `PatientDirectory` with `SearchField`, recent patients, objective rolling counts, explicit lookback labels, manual-referral entry point, keyboard-operable row buttons, and deterministic no-results content. Keep all search state local and all selected-patient state in the provider. - [ ] Make Home a desktop split of directory and selected workspace. On phone, present the directory first and use route navigation to the full-width patient workspace rather than retaining a compressed second column. - [ ] Implement `PatientWorkspace` identity band with fictional marker, name, MRN, DOB, age cohort, preferred name, pronouns, home health service, plan currency, Safety Plan currency, CMHT verification, and Presentation Activity. @@ -787,55 +903,57 @@ it("exposes only intent-safe CMHT launch links", () => { - [ ] In `launch-failure`, leave contact details visible, intercept the action, and show what happened/what it means/what the user can do. In `unverified-contact`, keep the details visible with warning copy and a Reviews link. - [ ] Wire Home, Patients, and patient Overview paths in `routable-suite.tsx`; remove their Task 3 route-purpose surfaces. - [ ] Complete DOM tests for all supported search fields, empty query/results, route selection, visible Current status, Awaiting Approval separation, no Current, overdue, withdrawn, contact audit intent, unverified warning, launch failure, and accessible patient navigation. -- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts tests/care-plan-linked-routes.dom.test.tsx`. Expected GREEN. - [ ] Run `npm run typecheck`, format Task 4 files, rerun the same checks, and inspect the 30-second snapshot hierarchy against the spec. -- [ ] Commit Task 4 with `feat(ed-care-plans): deliver searchable clinical snapshot`. Do not push. +- [ ] Commit Task 4 with `feat(care-plan): deliver searchable clinical snapshot`. Do not push. -## Task 5: Management Plan Read, Draft, Compare, Return, Approval, Review, and Withdrawal +## Task 5: Management Plan Reading, Pinned Safety Boundary, and Clinician Print -**Outcome:** The complete clinician-facing plan is readable, a replacement version can be drafted and submitted, a named senior clinician can compare and approve or return it, and withdrawal/review remain explicit and audited. +**Outcome:** The complete reading experience is finished. A clinician can open a patient's full Management Plan, read the five first-minute sections and the full-plan tier, see the pinned safety boundary before any plan content, and print a bedside or handover copy. This task closes Stage A; no authoring surface exists yet. + +**Read primacy:** the specification's read-primacy rule governs every choice in this task. Nothing here may reserve space, navigation depth, or attention for the authoring controls that arrive in Task 6. **Files:** -- Create: `src/components/ed-care-plans/mockups/management-plan-pages.tsx` -- Create: `src/components/ed-care-plans/mockups/management-plan-form.tsx` -- Create: `src/components/ed-care-plans/mockups/management-plan-diff.tsx` -- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` -- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` +- Create: `src/components/care-plan/mockups/management-plan-read.tsx` +- Create: `src/components/care-plan/mockups/management-plan-print.tsx` +- Modify: `src/components/ui/print-output.tsx` +- Modify: `src/components/care-plan/mockups/care-plan.module.css` +- Modify: `src/components/care-plan/mockups/routable-suite.tsx` +- Modify: `tests/care-plan-linked-routes.dom.test.tsx` -- [ ] Add failing DOM tests for full-plan content, create/edit Draft, submit, version comparison, permission refusal, return-for-changes, approval, formal review, and withdrawal. +- [ ] Add failing DOM tests before any component. Cover: the summary card renders exactly `FIRST_MINUTE_CONTENT_KEYS` in order; the pinned boundary appears above all plan content; empty optional sections render `Not recorded`; the participation marker appears for a `declined`/`patient_unavailable` version; a withdrawn plan renders its withdrawal line rather than a bare `No Current Plan`; the print route contains the five sections and omits navigation, actions, audit, and drafts. ```tsx -it("requires named senior approval before an awaiting version becomes Current", async () => { - const user = userEvent.setup(); - renderRoute(edCarePlansRoute.managementPlanReview("SYN-PATIENT-002"), "scenario=overdue-plan"); - await user.selectOptions(screen.getByRole("combobox", { name: "Prototype role" }), "SYN-USER-SENIOR-001"); - await user.click(screen.getByRole("button", { name: "Approve version 3" })); - const dialog = screen.getByRole("dialog", { name: "Approve Management Plan version 3" }); - await user.click(within(dialog).getByRole("button", { name: "Approve and make Current" })); +it("pins the safety boundary above all plan content", () => { + renderRoute(carePlanRoute.managementPlan("SYN-PATIENT-001")); + const pinned = screen.getByTestId("care-plan-safety-boundary-pinned"); + const firstSection = screen.getByRole("heading", { level: 3, name: "How to approach Rowan Sample" }); + expect(pinned.compareDocumentPosition(firstSection)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(pinned).toHaveTextContent(/assess afresh/i); +}); - expect(screen.getByRole("heading", { level: 2, name: "Current Plan" })).toBeInTheDocument(); - expect(screen.getByText(/Current version 3/i)).toBeInTheDocument(); - expect(screen.getByText(/Approved by Dr Taylor Fiction/i)).toBeInTheDocument(); +it("shows a withdrawn plan as withdrawn, never as no plan at all", () => { + renderRoute(carePlanRoute.managementPlan("SYN-PATIENT-004"), "scenario=withdrawn-plan"); + expect(screen.getByText(/Plan withdrawn on/i)).toBeInTheDocument(); + expect(screen.getByText(/Dr Taylor Fiction/)).toBeInTheDocument(); + expect(screen.queryByText(/^No Current Plan$/)).not.toBeInTheDocument(); }); ``` -- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "Management Plan|senior approval|return for changes|withdraw"`. Confirm RED for missing Management Plan surfaces. -- [ ] Render the Current summary card as exactly `FIRST_MINUTE_CONTENT_KEYS` in order, with `whatWouldMakeThisDifferent` visually distinct and never collapsed; add a DOM test and a print-media test pinning that it is present and unclipped. Render empty optional full-plan sections as `Not recorded`. -- [ ] Implement the read page with Current summary first, full structured sections second, review state, open triggers, owner/approver metadata, separate proposed version, version list, and fresh-assessment boundary repeated near clinical guidance. -- [ ] Use `ManagementPlanForm` for both new Draft and edit Draft. Initialise from Current when creating a replacement. Expose owner, next review date (defaulted from `REVIEW_INTERVAL_MONTHS`, editable), revision reason, the five first-minute sections, and the six full-plan sections with the optional five clearly marked optional. Preserve unchanged sections from the source version. -- [ ] Validate exactly `MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS` plus owner, next review date, and revision reason. Render a linked error summary, focus the first invalid field, and dispatch `create-management-draft`/`save-management-draft` only after local validation. Do not require the optional five. -- [ ] Add `Save Draft` and `Submit for senior approval`. Submission uses `ConfirmDialog`, changes only Draft to Awaiting Approval, then navigates to the review route. The existing Current remains visible. -- [ ] Implement `ManagementPlanDiff` as semantic sections with `Added`, `Changed`, `Removed`, and `Unchanged` labels; compare the submitted version against the Current version without clinical interpretation. -- [ ] On the review page, show named author, owner, proposed approver, revision reason, current/proposed versions, patient participation, and change table. Do not allow edits while Awaiting Approval. -- [ ] Return-for-changes opens a Sheet with required reason; on confirm, dispatches the return action and navigates to edit. Approval is available only to `senior_clinician`, opens a plain-language ConfirmDialog, and dispatches the atomic reducer action. -- [ ] Add formal-review and withdrawal actions on the Current plan. Formal review requires a reason plus next review date and updates review evidence without changing plan content or creating a new version. Withdrawal requires a reason, explicit confirmation, and shows `No Current Plan` afterward without restoring history. -- [ ] Show unavailable actions with the repository's stated-reason pattern when the active role, offline state, permission state, identity state, or version state blocks them. The reducer remains the final guard. -- [ ] Wire Management Plan read/edit/review paths in `routable-suite.tsx`; remove their Task 3 route-purpose surfaces. -- [ ] Complete DOM tests for field errors, save/submit, read-only Awaiting Approval, Current preservation, diff labels, non-senior refusal, return reason, approval metadata, exactly one Current, overdue formal review, withdrawal, offline/version-conflict refusal, and live announcements. -- [ ] Run `npm run test -- tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx`. Expected GREEN. -- [ ] Run `npm run typecheck`, format Task 5 files, rerun both checks, and inspect every action label and state transition against the glossary. -- [ ] Commit Task 5 with `feat(ed-care-plans): implement governed management plans`. Do not push. +- [ ] Run `npm run test -- tests/care-plan-linked-routes.dom.test.tsx -t "Management Plan|safety boundary|withdrawn|print"`. Confirm RED because the Management Plan reading surfaces do not exist. +- [ ] Implement the pinned safety boundary as a one-line summary of `whatWouldMakeThisDifferent`, rendered directly beneath the patient identity block and above every other plan element, at all viewports and in print. It links to the full section and never replaces it. Give it a stable `data-testid` and an accessible name. +- [ ] Implement the summary card as exactly `FIRST_MINUTE_CONTENT_KEYS` in order, with section 5 visually distinct and never collapsed, truncated, or clipped. Section 3's heading and helper copy frame it as what the service does, per the specification's language rule. +- [ ] Implement the full-plan tier beneath it: `whyThisPlanExists` then the optional five, each rendering `Not recorded` when empty rather than being omitted. +- [ ] Render version metadata without competing with content: version, Current state, approver and approval date, owner, review state derived through `deriveReviewState`, open Review Triggers, the `Written without this person's involvement` marker where applicable, whether the plan has been shared with the patient, and whether a current Patient Plan exists. A separate Awaiting Approval version is shown as clearly subordinate to the Current one. +- [ ] Implement the withdrawn state: `Plan withdrawn on by `, with superseded versions still readable. Never render a withdrawn plan identically to a patient who never had one. +- [ ] Read `src/components/ui/print-output.tsx` and the two Therapy Compass printed screens first. Build the print route on `PrintOutput` and `BrowserPrintButton`. Where a capability is genuinely general — per-section page-break control, a monochrome state treatment, a standard confidential-document footer, a printed-at stamp — add it to the shared primitive with its own focused test and consume it here. Do not reimplement print behaviour locally, and do not add a route-scoped rule that duplicates something the primitive should own. +- [ ] The print view carries identifiers, the pinned boundary, the five sections in order, version and approval metadata, the CMHT block, a `check the electronic record` warning, the printed-at stamp, the synthetic watermark, and a confidential footer. It omits navigation, actions, audit history, and drafts. The print button dispatches `record-management-plan-print-intent`, then calls `window.print()`. +- [ ] Wire the Management Plan read and print paths in `routable-suite.tsx`; remove their Task 3 route-purpose surfaces. Leave `/management-plan/edit` and `/management-plan/review` on their route-purpose specimens until Task 6. +- [ ] Complete the DOM tests, including a `@media print` assertion that the pinned boundary and all five sections are present and unclipped. +- [ ] Run `npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts tests/care-plan-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 5 files, rerun both checks, and inspect every heading and label against the glossary. +- [ ] Commit Task 5 with `feat(care-plan): add management plan reading and print`. Do not push. --- @@ -846,35 +964,82 @@ it("requires named senior approval before an awaiting version becomes Current", - [ ] Run the complete Stage A focused test set and record the decisive pass line: ```powershell -npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-route-files.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx tests/proxy.test.ts +npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts tests/care-plan-route-files.test.ts tests/care-plan-linked-routes.dom.test.tsx tests/proxy.test.ts ``` - [ ] Run `npm run typecheck`. Expected GREEN. - [ ] Run `npm run ensure`, confirm `/api/local-project-id` identifies this Database project, and use only the printed URL. Do not assume a port and do not disturb another project's server. -- [ ] Walk the Stage A journey in the running app as an ED clinician would: search a synthetic patient, open the Clinical Snapshot, confirm Current versus Draft hierarchy, read the first-minute guidance, see the CMHT contact block, then open a plan, create a draft, submit it, compare it, and approve it as the senior clinician. Check it at desktop width and at 390 px. +- [ ] Walk the Stage A journey in the running app as an ED clinician would: search a synthetic patient, open the Clinical Snapshot, confirm Current versus Draft hierarchy, read the first-minute guidance, check that the pinned safety boundary is above all plan content, open the full plan, see the CMHT contact block, and print the clinician summary. Check it at desktop width, 390 px, and 320 px, and check the print preview. - [ ] Report to the user in plain language: what works, what is deliberately not built yet, the exact test evidence, and the local URL to look at. State plainly that Chromium/browser, accessibility, print and responsive proof are Task 9 work and have not run. - [ ] Wait for the user's decision. Do not proceed to Task 6 on your own judgment; this checkpoint is one of the four things that stop an SDD controller. --- -## Task 6: ED Presentation Timeline, Concise Recording, Plan-Use Feedback, and Visible Amendments +## Task 6: Management Plan Drafting, Comparison, Approval, Review, and Withdrawal + +**Outcome:** A replacement version can be drafted and submitted, a named senior clinician can compare and approve or return it, and formal review and withdrawal are explicit, role-gated, and audited. Stage B begins here. + +**Files:** + +- Create: `src/components/care-plan/mockups/management-plan-form.tsx` +- Create: `src/components/care-plan/mockups/management-plan-diff.tsx` +- Modify: `src/components/care-plan/mockups/management-plan-read.tsx` +- Modify: `src/components/care-plan/mockups/routable-suite.tsx` +- Modify: `tests/care-plan-linked-routes.dom.test.tsx` + +- [ ] Add failing DOM tests for create/edit Draft, validation, submit, version comparison, non-senior refusal, return-for-changes, approval, formal review, and withdrawal. + +```tsx +it("requires named senior approval before an awaiting version becomes Current", async () => { + const user = userEvent.setup(); + renderRoute(carePlanRoute.managementPlanReview("SYN-PATIENT-002"), "scenario=overdue-plan"); + await user.selectOptions(screen.getByRole("combobox", { name: "Prototype role" }), "SYN-USER-SENIOR-001"); + await user.click(screen.getByRole("button", { name: "Approve version 3" })); + const dialog = screen.getByRole("dialog", { name: "Approve Management Plan version 3" }); + await user.click(within(dialog).getByRole("button", { name: "Approve and make Current" })); + + expect(screen.getByRole("heading", { level: 2, name: "Current Plan" })).toBeInTheDocument(); + expect(screen.getByText(/Current version 3/i)).toBeInTheDocument(); + expect(screen.getByText(/Approved by Dr Taylor Fiction/i)).toBeInTheDocument(); +}); +``` + +- [ ] Run `npm run test -- tests/care-plan-linked-routes.dom.test.tsx -t "senior approval|return for changes|withdraw|formal review"`. Confirm RED for the missing authoring surfaces. +- [ ] Use `ManagementPlanForm` for both new Draft and edit Draft. Initialise from Current when creating a replacement. Expose owner, next review date (defaulted from `REVIEW_INTERVAL_MONTHS`, editable), revision reason, participation state, the five first-minute sections, and the six full-plan sections with the optional five clearly marked optional. Preserve unchanged sections from the source version. +- [ ] Validate exactly `MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS` plus owner, next review date, revision reason, and participation state. Render a linked error summary and focus the first invalid field. Do not require the optional five. +- [ ] Reject prohibitive admission wording in `agreedEdApproach` at the form boundary: a field-level validation error naming the banned construction, with `BANNED_ADMISSION_CONSTRUCTIONS` exported from `domain.ts` and unit-tested. This is a wording guard, not clinical interpretation. +- [ ] Add `Save Draft` and `Submit for senior approval`. Submission uses `ConfirmDialog`, changes only Draft to Awaiting Approval, then navigates to the review route. The existing Current remains visible and unchanged throughout. +- [ ] Implement `ManagementPlanDiff` as semantic sections with `Added`, `Changed`, `Removed`, and `Unchanged` labels; compare the submitted version against the Current version without clinical interpretation. +- [ ] On the review page show named author, owner, proposed approver, revision reason, current and proposed versions, participation state, and the change table. Do not allow edits while Awaiting Approval. +- [ ] Return-for-changes opens a Sheet with a required reason; on confirm it dispatches the return action and navigates to edit. Approval is available only to `senior_clinician`, opens a plain-language `ConfirmDialog`, and dispatches the atomic reducer action. +- [ ] Approving a version whose participation is `declined` or `patient_unavailable` states that consequence in the confirmation dialog and raises the involvement Review Trigger. +- [ ] Add formal-review and withdrawal actions on the Current plan. Formal review requires a reason plus a next review date and updates review evidence without changing content or creating a version. Withdrawal is `senior_clinician` only, requires a reason and explicit confirmation, and afterwards the read page shows the withdrawal line built in Task 5. +- [ ] Add a `Record that this plan has been shared with the patient` action that sets `sharedWithPatientAt` and audits it. It does not create a Patient Plan; that is Task 9. +- [ ] Show unavailable actions with the repository's stated-reason pattern when role, offline, permission, identity, or version state blocks them. The reducer remains the final guard, and unavailable controls never crowd the reading surface. +- [ ] Wire the Management Plan edit and review paths in `routable-suite.tsx`; remove their Task 3 route-purpose surfaces. +- [ ] Complete DOM tests for field errors, banned wording, save/submit, read-only Awaiting Approval, Current preservation, diff labels, non-senior refusal for both approval and withdrawal, return reason, approval metadata, exactly one Current, overdue formal review, withdrawal display, offline/version-conflict refusal, and live announcements. +- [ ] Run `npm run test -- tests/care-plan-prototype-state.test.ts tests/care-plan-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 6 files, rerun both checks, and inspect every action label and state transition against the glossary. +- [ ] Commit Task 6 with `feat(care-plan): implement governed management plan authoring`. Do not push. + +## Task 7: ED Presentation Timeline, Concise Recording, Plan-Use Feedback, and Visible Amendments **Outcome:** Clinicians can see the longitudinal episode timeline, record a concise ED Presentation, capture plan availability/use/helpfulness, create a human Review Trigger when indicated, and correct outcome/disposition through append-only amendments. **Files:** -- Create: `src/components/ed-care-plans/mockups/presentation-pages.tsx` -- Create: `src/components/ed-care-plans/mockups/presentation-form.tsx` -- Create: `src/components/ed-care-plans/mockups/presentation-timeline.tsx` -- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` -- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` +- Create: `src/components/care-plan/mockups/presentation-pages.tsx` +- Create: `src/components/care-plan/mockups/presentation-form.tsx` +- Create: `src/components/care-plan/mockups/presentation-timeline.tsx` +- Modify: `src/components/care-plan/mockups/routable-suite.tsx` +- Modify: `tests/care-plan-linked-routes.dom.test.tsx` - [ ] Add failing DOM tests for the chronological timeline, new-presentation validation, current-version linkage, helpfulness feedback, Review Trigger creation, detail view, and amendment preservation. ```tsx it("records plan-use feedback and creates a Review Suggested item without changing the plan", async () => { const user = userEvent.setup(); - renderRoute(edCarePlansRoute.newPresentation("SYN-PATIENT-001")); + renderRoute(carePlanRoute.newPresentation("SYN-PATIENT-001")); await user.selectOptions(screen.getByLabelText("Disposition"), "discharged_home"); await user.selectOptions(screen.getByLabelText("Was the Current Plan used?"), "partially_used"); await user.selectOptions(screen.getByLabelText("Was the plan helpful?"), "mixed"); @@ -887,7 +1052,7 @@ it("records plan-use feedback and creates a Review Suggested item without changi }); ``` -- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "ED Presentation|plan-use|amendment"`. Confirm RED because the episode surfaces do not exist. +- [ ] Run `npm run test -- tests/care-plan-linked-routes.dom.test.tsx -t "ED Presentation|plan-use|amendment"`. Confirm RED because the episode surfaces do not exist. - [ ] Implement `PresentationTimeline` as a descending semantic list with date/site, indication, outcome, disposition, linked plan version, plan-use summary, CMHT attempt outcome, Review Suggested text, and visible amendment count. Use a line-and-node treatment visually and retain complete text equivalents. - [ ] Implement the list page with objective rolling counts, explicit observation windows, site filter, disposition filter, and `Record ED presentation` link. Do not add eligibility or severity labels. - [ ] Implement `PresentationForm` with the required set visible by default and persistent labels: fictional ED, disposition, plan availability, plan use, plan helpfulness, and `Anything worth flagging?` free text. Arrival date and time default to `PROTOTYPE_NOW` and stay editable. Put presenting indication, assessment outcome, CMHT contact attempt/outcome, and the deviation flag behind one `Add more detail` disclosure that is closed on open and never blocks the save. @@ -898,21 +1063,21 @@ it("records plan-use feedback and creates a Review Suggested item without changi - [ ] The amendment Sheet permits only assessment outcome or disposition, shows the original value, and requires replacement plus reason. On save, display original and latest amendment together; do not replace the original DOM text. - [ ] Wire presentation list/new/detail paths in `routable-suite.tsx`; validate that the episode belongs to the patient before rendering and show identity uncertainty rather than another patient's data on mismatch. - [ ] Complete DOM tests for required fields, no-Current linkage, Current linkage, helpful/no-trigger, mixed/not-helpful trigger, admission trigger, deviation trigger, deduplication, deterministic navigation, original-plus-amendment rendering, and mismatched identity refusal. -- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx`. Expected GREEN. -- [ ] Run `npm run typecheck`, format Task 6 files, rerun the checks, and inspect that no form field duplicates a full ED note, diagnosis list, medication chart, or risk assessment. -- [ ] Commit Task 6 with `feat(ed-care-plans): track ED presentation continuity`. Do not push. +- [ ] Run `npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts tests/care-plan-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 7 files, rerun the checks, and inspect that no form field duplicates a full ED note, diagnosis list, medication chart, or risk assessment. +- [ ] Commit Task 7 with `feat(care-plan): track ED presentation continuity`. Do not push. -## Task 7: Patient-Owned Personal Safety Plan, Independent Versioning, and Privacy-Aware Print +## Task 8: Patient-Owned Personal Safety Plan, Independent Versioning, and Privacy-Aware Print **Outcome:** The current Personal Safety Plan is clearly separate from the Management Plan, can be co-produced and versioned without senior approval, and prints a patient-facing seven-step copy with minimum necessary synthetic identifiers and verified crisis contacts. **Files:** -- Create: `src/components/ed-care-plans/mockups/safety-plan-pages.tsx` -- Create: `src/components/ed-care-plans/mockups/safety-plan-form.tsx` -- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` -- Modify: `src/components/ed-care-plans/mockups/ed-care-plans.module.css` -- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` +- Create: `src/components/care-plan/mockups/safety-plan-pages.tsx` +- Create: `src/components/care-plan/mockups/safety-plan-form.tsx` +- Modify: `src/components/care-plan/mockups/routable-suite.tsx` +- Modify: `src/components/care-plan/mockups/care-plan.module.css` +- Modify: `tests/care-plan-linked-routes.dom.test.tsx` - [ ] Add failing DOM tests for seven patient-voice sections, confirmation state, independent publication, no senior approval, print content minimisation, print intent, and print failure. @@ -920,7 +1085,7 @@ it("records plan-use feedback and creates a Review Suggested item without changi it("renders a print-only patient copy without ED Presentation or audit content", async () => { const print = vi.spyOn(window, "print").mockImplementation(() => undefined); const user = userEvent.setup(); - renderRoute(edCarePlansRoute.safetyPlanPrint("SYN-PATIENT-001")); + renderRoute(carePlanRoute.safetyPlanPrint("SYN-PATIENT-001")); expect(screen.getByRole("heading", { level: 1, name: "My Personal Safety Plan" })).toBeInTheDocument(); expect(screen.getAllByRole("heading", { level: 2 })).toHaveLength(7); @@ -931,7 +1096,7 @@ it("renders a print-only patient copy without ED Presentation or audit content", }); ``` -- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "Personal Safety Plan|print"`. Confirm RED because the Safety Plan surfaces do not exist. +- [ ] Run `npm run test -- tests/care-plan-linked-routes.dom.test.tsx -t "Personal Safety Plan|print"`. Confirm RED because the Safety Plan surfaces do not exist. - [ ] Implement the view page with patient-owned language, version, last-confirmed date, review state, patient confirmation state, collaboration author, and the seven exact headings from the specification. - [ ] Keep the clinician-facing plan boundary explicit: this document supports the person's own coping and support actions and is not a Management Plan or a replacement for fresh assessment. - [ ] Implement `SafetyPlanForm` for a new or existing Draft. Use a labelled repeatable textarea/list treatment for all seven content keys and structured personal-support name/relationship/phone entries. @@ -942,28 +1107,82 @@ it("renders a print-only patient copy without ED Presentation or audit content", - [ ] The print button dispatches `record-safety-plan-print-intent` and calls `window.print()`. In `print-failure`, do not call print; retain the complete plan and show retry instructions. - [ ] Wire Safety Plan view/edit/print paths in `routable-suite.tsx`; remove their Task 3 route-purpose surfaces. - [ ] Complete DOM tests for section labels, edit errors, independent Draft/Current transition, declined/unavailable language, minimum-necessary print content, exact public contacts/caveats, absence of ED/audit content, intent-only audit, and failure recovery. -- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx`. Expected GREEN. -- [ ] Run `npm run typecheck`, format Task 7 files, rerun the checks, and inspect print DOM and CSS for hidden interactive controls and monochrome state clarity. -- [ ] Commit Task 7 with `feat(ed-care-plans): add printable personal safety plans`. Do not push. +- [ ] Run `npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts tests/care-plan-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 8 files, rerun the checks, and inspect print DOM and CSS for hidden interactive controls and monochrome state clarity. +- [ ] Commit Task 8 with `feat(care-plan): add printable personal safety plans`. Do not push. + +## Task 9: Patient Plan — Deterministic Transformation, Clinician Approval, Resources, and Print + +**Outcome:** An approved Management Plan Version can be turned into a patient-facing edition in the person's own voice, with gaps the transformation refused to guess at, clinician approval before the patient receives it, resources chosen for that person, and a printable copy that stays truthful when the clinical plan moves on. + +**Files:** + +- Create: `src/components/care-plan/mockups/patient-plan-transform.ts` +- Create: `src/components/care-plan/mockups/patient-plan-pages.tsx` +- Create: `src/components/care-plan/mockups/patient-plan-form.tsx` +- Create: `tests/care-plan-patient-plan.test.ts` +- Modify: `src/components/care-plan/mockups/types.ts` +- Modify: `src/components/care-plan/mockups/fixtures.ts` +- Modify: `src/components/care-plan/mockups/prototype-state.ts` +- Modify: `src/components/care-plan/mockups/routable-suite.tsx` +- Modify: `tests/care-plan-linked-routes.dom.test.tsx` + +- [ ] Write `tests/care-plan-patient-plan.test.ts` first, against the pure transformation only. It has no DOM and no provider. + +```ts +it("never auto-converts the agreed approach and leaves it as a clinician gap", () => { + const version = getCurrentManagementPlanVersion(createInitialPrototypeState(), "SYN-PATIENT-001")!; + const draft = buildPatientPlanDraft(version, syntheticPatients[0]!, syntheticResources); + const agreed = draft.sections.find(({ key }) => key === "whatWeAgreedWillHappen")!; + + expect(agreed.gap).toBe(true); + expect(agreed.body).toEqual([]); + expect(agreed.gapReason).toMatch(/written by a clinician/i); +}); + +it("is a pure function of the version and never reaches outside itself", () => { + const version = getCurrentManagementPlanVersion(createInitialPrototypeState(), "SYN-PATIENT-001")!; + const a = buildPatientPlanDraft(version, syntheticPatients[0]!, syntheticResources); + const b = buildPatientPlanDraft(version, syntheticPatients[0]!, syntheticResources); + expect(a).toEqual(b); +}); +``` -## Task 8: Reviews, Team, Governance, Audit History, and Deterministic Degraded States +- [ ] Run `npm run test -- tests/care-plan-patient-plan.test.ts`. A module-resolution error is setup evidence, not RED. Add the export signature with a throwing body, rerun, and confirm the gap assertion fails for the intended reason before implementing. +- [ ] Add `PatientPlan`, `PatientPlanVersion`, `PatientPlanSection`, `PatientPlanSectionKey`, `PatientResource`, `PatientResourceCategory`, and `PATIENT_PLAN_SECTION_KEYS` to `types.ts`, and the four patient-plan actions and four audit event types to their existing unions. Add `patientPlans` and `patientPlanVersions` to prototype state. +- [ ] Implement `buildPatientPlanDraft(version, patient, resources)` in `patient-plan-transform.ts` as a pure function. It performs no network, storage, timer, random, wall-clock, or model call, and imports nothing from outside this namespace. `tests/care-plan-route-files.test.ts` already rejects those; extend it to name this module explicitly. +- [ ] Implement the transformation as a field-to-heading mapping over the eleven known clinical fields plus a curated `PLAIN_LANGUAGE_TERMS` dictionary, shifting to second person, present tense, and strengths-based framing. It never attempts free rewriting of arbitrary prose. +- [ ] Emit `gap: true` with a `gapReason` wherever conversion is not confident: any sentence containing a term absent from the dictionary, any clinical negation, and — unconditionally, regardless of content — `whatWeAgreedWillHappen`. Gaps carry an empty `body`; the transformation never guesses. +- [ ] Implement the reducer transitions: `create-patient-plan-draft` derives from the Current Management Plan Version and refuses when there is none; `save-patient-plan-draft` replaces sections and resources whole; `approve-patient-plan-version` requires any clinical role, requires zero unfilled gaps, supersedes the prior Current patient version, and stamps the approving clinician and time; `record-patient-plan-print-intent` appends intent evidence only. Approval must not consult senior-approval state. +- [ ] Implement staleness as a derived selector, never stored: a Current patient version whose `derivedFromManagementVersionId` is not the plan's `currentVersionId` is stale. Approving a newer Management Plan Version raises one deduplicated Review Trigger. Nothing is regenerated, hidden, or withdrawn automatically. +- [ ] Add synthetic `PatientResource` fixtures per patient covering care team, local service, housing, financial, transport, carer support, alcohol and other drugs, cultural or peer, the already-verified crisis contacts, and self-help reading. Only the crisis contacts are real; everything else is fictional and `SYN-` identified. Housing and financial entries are present for at least one patient because those are frequently the actual reason someone keeps presenting. +- [ ] Implement the view page with the eight headings in `PATIENT_PLAN_SECTION_KEYS` order, patient-voice language, the version, the approving clinician and date, the resources grouped by category, and the staleness notice when it applies. +- [ ] Implement `PatientPlanForm`: create from Current, show each gap prominently with its reason, allow editing of every section, and add or remove resources. `Approve patient copy` is unavailable with a stated reason while any gap is unfilled. +- [ ] Implement the print route on the shared `PrintOutput` primitive generalised in Task 5. It carries preferred name, version, approval date, the eight sections, the resources, the verified crisis contacts, the synthetic watermark, and a printed-at stamp. It omits navigation, clinical vocabulary, audit history, ED presentation data, and Management Plan internal metadata. +- [ ] Wire the three patient-plan paths in `routable-suite.tsx`; remove their Task 3 route-purpose surfaces. +- [ ] Complete tests for every section mapping, dictionary substitution, each gap trigger, gap-blocked approval, non-senior approval succeeding, supersession, staleness derivation and its trigger, resource grouping, print content minimisation, and the absence of any network, storage, or model reference in the namespace. +- [ ] Run `npm run test -- tests/care-plan-patient-plan.test.ts tests/care-plan-prototype-state.test.ts tests/care-plan-route-files.test.ts tests/care-plan-linked-routes.dom.test.tsx`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 9 files, rerun the checks, and read the generated patient copy end to end as a patient would. If any converted sentence reads as clinical, blaming, or hopeless, the dictionary or the gap rules are wrong — fix those, not the fixture. +- [ ] Commit Task 9 with `feat(care-plan): add patient-facing plan and resources`. Do not push. + +## Task 10: Reviews, Team, Governance, Audit History, and Deterministic Degraded States **Outcome:** All remaining routes are real operational surfaces: focused human worklists, CMHT/owner directory, combined evidence chronology, explicit pending identification governance, and reconstructable failure/degraded specimens. **Files:** -- Create: `src/components/ed-care-plans/mockups/operations-pages.tsx` -- Create: `src/components/ed-care-plans/mockups/history-page.tsx` -- Create: `src/components/ed-care-plans/mockups/system-states-page.tsx` -- Modify: `src/components/ed-care-plans/mockups/routable-suite.tsx` -- Modify: `tests/ed-care-plans-linked-routes.dom.test.tsx` +- Create: `src/components/care-plan/mockups/operations-pages.tsx` +- Create: `src/components/care-plan/mockups/history-page.tsx` +- Create: `src/components/care-plan/mockups/system-states-page.tsx` +- Modify: `src/components/care-plan/mockups/routable-suite.tsx` +- Modify: `tests/care-plan-linked-routes.dom.test.tsx` - [ ] Add failing DOM tests for all four Review queues, manual Identification Review, contact verification, team details, governance policy nulls, combined history, scenario deep links, and mutation refusal. ```tsx it("creates a manual Identification Review without creating a plan or applying eligibility", async () => { const user = userEvent.setup(); - renderRoute(ED_CARE_PLANS_ROUTES.patients, "scenario=no-current-plan"); + renderRoute(CARE_PLAN_ROUTES.patients, "scenario=no-current-plan"); await user.click(screen.getByRole("button", { name: "Refer Jordan Test for Identification Review" })); const dialog = screen.getByRole("dialog", { name: "Refer for Identification Review" }); await user.type( @@ -978,7 +1197,7 @@ it("creates a manual Identification Review without creating a plan or applying e }); ``` -- [ ] Run `npm run test -- tests/ed-care-plans-linked-routes.dom.test.tsx -t "Identification Review|Awaiting Approval queue|Governance|System states|Audit history"`. Confirm RED because the operational surfaces do not exist. +- [ ] Run `npm run test -- tests/care-plan-linked-routes.dom.test.tsx -t "Identification Review|Awaiting Approval queue|Governance|System states|Audit history"`. Confirm RED because the operational surfaces do not exist. - [ ] Implement Reviews with `Tabs` for exactly `Awaiting Approval`, `Review Suggested`, `Contact Verification`, and `Identification Review`. Each item shows reason, source/time, owner, next action, and direct patient/plan route; no severity order or dashboard scoring. - [ ] Add manual-referral Sheet from Patients and patient Overview, require a reason, dispatch `create-identification-review`, and show the item in Reviews. The action does not create a plan or change Presentation Activity. - [ ] Add referral closure to the Identification Review queue: a Sheet offering exactly `Proceed to a plan`, `Not needed at this stage`, and `Revisit later`, with a required reason. On confirm it dispatches `close-identification-review` and the item leaves the queue. On `Proceed to a plan` offer a link to start a draft; never create one automatically. Show the closed decision, reason, author, and time in the patient's History. @@ -992,11 +1211,11 @@ it("creates a manual Identification Review without creating a plan or applying e - [ ] Add prototype-role selection to the shell from the exact synthetic users. It changes only interaction-model permission state and never claims authentication or RBAC enforcement. - [ ] Wire Reviews, Team, Governance, History, and System states in `routable-suite.tsx`; remove all remaining Task 3 route-purpose surfaces. - [ ] Complete DOM tests for queue membership/order, manual referral, trigger resolution, contact verification, team contacts, governance null policy, history evidence language, role selection, every scenario URL, recovery, blocked mutation, and empty state. -- [ ] Run `npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-route-files.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx tests/proxy.test.ts`. Expected GREEN. -- [ ] Run `npm run typecheck`, format Task 8 files, rerun the checks, and inspect the full route map to ensure no route-purpose specimen remains. -- [ ] Commit Task 8 with `feat(ed-care-plans): complete review and governance workspace`. Do not push. +- [ ] Run `npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts tests/care-plan-route-files.test.ts tests/care-plan-linked-routes.dom.test.tsx tests/proxy.test.ts`. Expected GREEN. +- [ ] Run `npm run typecheck`, format Task 10 files, rerun the checks, and inspect the full route map to ensure no route-purpose specimen remains. +- [ ] Commit Task 10 with `feat(care-plan): complete review and governance workspace`. Do not push. -## Task 9: Browser Journeys, Responsive and Accessibility Proof, Documentation, and Handoff Gate +## Task 11: Browser Journeys, Responsive and Accessibility Proof, Documentation, and Handoff Gate **Outcome:** The complete route family has repository-wrapped Chromium proof at the required widths/modes, generated documentation is current, clinical/privacy constraints have deterministic evidence, and the final unpushed branch is ready for user review. @@ -1004,44 +1223,44 @@ Task 9 adds cross-layer acceptance evidence, not new clinical product behaviour. **Files:** -- Create: `tests/ui-ed-care-plans-mockup.spec.ts` +- Create: `tests/ui-care-plan-mockup.spec.ts` - Modify: `playwright.config.ts` - Modify: `tests/playwright-project-isolation.test.ts` - Modify: `package.json` - Modify: `docs/codebase-index.md` - Modify: `docs/site-map.md` - Modify: generated documentation produced by `npm run docs:update` -- Create: `docs/ed-care-plans/interaction-matrix.md` -- Create: `docs/ed-care-plans/clinical-language-trace.md` -- Create: `docs/ed-care-plans/accessibility-acceptance.md` -- Create: `docs/ed-care-plans/implementation-handoff.md` -- Create: `docs/ed-care-plans/verification-report.md` +- Create: `docs/care-plan/interaction-matrix.md` +- Create: `docs/care-plan/clinical-language-trace.md` +- Create: `docs/care-plan/accessibility-acceptance.md` +- Create: `docs/care-plan/implementation-handoff.md` +- Create: `docs/care-plan/verification-report.md` -- [ ] Modify `tests/playwright-project-isolation.test.ts` first so it requires `ui-ed-care-plans-mockup` in the mockup project and rejects it from production projects. +- [ ] Modify `tests/playwright-project-isolation.test.ts` first so it requires `ui-care-plan-mockup` in the mockup project and rejects it from production projects. - [ ] Run `npm run test -- tests/playwright-project-isolation.test.ts`. Confirm RED because the current Playwright matchers do not register the new spec. -- [ ] Add `ui-ed-care-plans-mockup` to the mockup test regex in both `testMatch` and `mockupSpecPattern`, then add the package script exactly: +- [ ] Add `ui-care-plan-mockup` to the mockup test regex in both `testMatch` and `mockupSpecPattern`, then add the package script exactly: ```json -"test:e2e:ed-care-plans-mockup": "node scripts/run-playwright.mjs --project=chromium-mockups tests/ui-ed-care-plans-mockup.spec.ts" +"test:e2e:care-plan-mockup": "node scripts/run-playwright.mjs --project=chromium-mockups tests/ui-care-plan-mockup.spec.ts" ``` -- [ ] Add the browser spec with `@mockup` and helper functions `gotoRoute`, `expectNoHorizontalOverflow`, `expectSyntheticBoundary`, `expectPhoneDockClearance`, and `expectSinglePageHeading`. Its stable route table must use `ED_CARE_PLANS_BASE` literals, not real patient data. -- [ ] Write browser journeys for: direct reconstruction of all 17 routes; search-to-Current Plan; Current plus Awaiting Approval hierarchy; create/submit/compare/return/approve version; record ED Presentation and Review Trigger; amend outcome; edit/publish/print Safety Plan; safe CMHT mailto/tel; manual Identification Review; resolve Review Trigger; verify contact; audit chronology; and all degraded scenarios. +- [ ] Add the browser spec with `@mockup` and helper functions `gotoRoute`, `expectNoHorizontalOverflow`, `expectSyntheticBoundary`, `expectPhoneDockClearance`, and `expectSinglePageHeading`. Its stable route table must use `CARE_PLAN_BASE` literals, not real patient data. +- [ ] Write browser journeys for: direct reconstruction of all 21 routes; the pinned safety boundary present above plan content at every width and in print; clinician plan print; patient-plan creation showing gaps, gap-blocked approval, approval by a non-senior clinician, staleness after a newer Management Plan Version is approved, and the patient print; search-to-Current Plan; Current plus Awaiting Approval hierarchy; create/submit/compare/return/approve version; record ED Presentation and Review Trigger; amend outcome; edit/publish/print Safety Plan; safe CMHT mailto/tel; manual Identification Review; resolve Review Trigger; verify contact; audit chronology; and all degraded scenarios. - [ ] Add layout checks at 320, 390, 768, 1024, and 1440 px. At each width assert no page overflow, heading/action wrapping, Current Plan readability, CMHT/Safety access, 48 px primary targets, and phone dock clearance where applicable. - [ ] Add keyboard traversal, Escape/focus restoration for Sheet/ConfirmDialog, reduced-motion, forced-colours, dark mode, 200% equivalent reflow, and print-media assertions. Browser automation is Chromium evidence; do not claim physical iPhone Safari or installed-PWA acceptance. -- [ ] Add optional screenshot capture behind `ED_CARE_PLANS_CAPTURE_EVIDENCE=1`, writing only ignored files under `.local/ed-care-plans/atlas`. Capture Home/patient/plan/review/presentation/Safety Print/Reviews/System states at 320, 390, and 1440 plus dark/forced-colour specimens. +- [ ] Add optional screenshot capture behind `CARE_PLAN_CAPTURE_EVIDENCE=1`, writing only ignored files under `.local/care-plan/atlas`. Capture Home/patient/plan/review/presentation/Safety Print/Reviews/System states at 320, 390, and 1440 plus dark/forced-colour specimens. - [ ] Rerun `npm run test -- tests/playwright-project-isolation.test.ts`. Expected GREEN: the new browser spec is collected only by `chromium-mockups`. -- [ ] Carry forward the reviewed Task 3 design-sweep evidence into the verification report. Do not rerun an unchanged design preflight; rerun it only if later work changed a shared UI foundation outside the ED Care Plans namespace. -- [ ] Run `npm run workflow:clinical-proof -- --files src/app/mockups/ed-care-plans,src/components/ed-care-plans/mockups,tests/ed-care-plans-domain.test.ts,tests/ed-care-plans-prototype-state.test.ts,tests/ed-care-plans-linked-routes.dom.test.tsx --write-evidence`. Record privacy, clinical-language, source, failure-mode, and rollback/prototype-boundary evidence from the ignored `.local/workflow-evidence` output. Do not run provider-backed workflows. +- [ ] Carry forward the reviewed Task 3 design-sweep evidence into the verification report. Do not rerun an unchanged design preflight; rerun it only if later work changed a shared UI foundation outside the Care Plan namespace. +- [ ] Run `npm run workflow:clinical-proof -- --files src/app/mockups/care-plan,src/components/care-plan/mockups,tests/care-plan-domain.test.ts,tests/care-plan-prototype-state.test.ts,tests/care-plan-linked-routes.dom.test.tsx --write-evidence`. Record privacy, clinical-language, source, failure-mode, and rollback/prototype-boundary evidence from the ignored `.local/workflow-evidence` output. Do not run provider-backed workflows. - [ ] Run `npm run ensure` and use only the printed URL after `/api/local-project-id` confirms this Database project. Leave unrelated local servers untouched. -- [ ] Run `npm run test:e2e:ed-care-plans-mockup`. Expected GREEN: all focused Chromium journeys pass. If a browser failure reveals a behaviour defect, first add the smallest failing reducer/DOM regression test, then fix production code and rerun the focused browser case through the wrapper. -- [ ] Run the browser command once with `ED_CARE_PLANS_CAPTURE_EVIDENCE=1` if the environment supports screenshot writing; record captured paths separately from pass/fail evidence. Screenshots are visual evidence, not correctness proof. +- [ ] Run `npm run test:e2e:care-plan-mockup`. Expected GREEN: all focused Chromium journeys pass. If a browser failure reveals a behaviour defect, first add the smallest failing reducer/DOM regression test, then fix production code and rerun the focused browser case through the wrapper. +- [ ] Run the browser command once with `CARE_PLAN_CAPTURE_EVIDENCE=1` if the environment supports screenshot writing; record captured paths separately from pass/fail evidence. Screenshots are visual evidence, not correctness proof. - [ ] Add the five handoff documents. `clinical-language-trace.md` maps every consequential label to the approved glossary/spec; `interaction-matrix.md` lists trigger/action/state/result for every control; `accessibility-acceptance.md` records viewport/input/media evidence; `implementation-handoff.md` records routes, reset boundary, fixtures, and limits; `verification-report.md` contains exact commands, exit codes, counts, failures, and unrun/provider-gated checks. -- [ ] Update `docs/codebase-index.md` with the ED Care Plans component/route family and add `/mockups/ed-care-plans` to the Developer-area gate description. Run `npm run docs:update`, then review `docs/site-map.md`, `docs/scripts-index.md`, and any other generated diff. +- [ ] Update `docs/codebase-index.md` with the Care Plan component/route family and add `/mockups/care-plan` to the Developer-area gate description. Run `npm run docs:update`, then review `docs/site-map.md`, `docs/scripts-index.md`, and any other generated diff. - [ ] Run the complete focused unit/DOM set: ```powershell -npm run test -- tests/ed-care-plans-domain.test.ts tests/ed-care-plans-prototype-state.test.ts tests/ed-care-plans-route-files.test.ts tests/ed-care-plans-linked-routes.dom.test.tsx tests/proxy.test.ts tests/playwright-project-isolation.test.ts +npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts tests/care-plan-patient-plan.test.ts tests/care-plan-route-files.test.ts tests/care-plan-linked-routes.dom.test.tsx tests/proxy.test.ts tests/playwright-project-isolation.test.ts ``` Expected GREEN: zero failed files and zero failed tests. @@ -1049,9 +1268,11 @@ Expected GREEN: zero failed files and zero failed tests. - [ ] Run the exact privacy/source scans and record their output: ```powershell -rg -n -i "frequent flyer|high utili[sz]er|problem patient|risk score|automatic enrol|automatically identif" src/components/ed-care-plans src/app/mockups/ed-care-plans -rg -n "localStorage|sessionStorage|indexedDB|document\.cookie|\bfetch\s*\(" src/components/ed-care-plans src/app/mockups/ed-care-plans -rg -n -i "\b(sent|delivered|read|replied|contact completed)\b" src/components/ed-care-plans src/app/mockups/ed-care-plans +rg -n -i "frequent flyer|high utili[sz]er|problem patient|risk score|automatic enrol|automatically identif" src/components/care-plan src/app/mockups/care-plan +rg -n "localStorage|sessionStorage|indexedDB|document\.cookie|\bfetch\s*\(" src/components/care-plan src/app/mockups/care-plan +rg -n -i "openai|anthropic|completion|llm|gpt|prompt" src/components/care-plan src/app/mockups/care-plan +rg -n -i "should not be admitted|do not admit|admission is not indicated" src/components/care-plan src/app/mockups/care-plan +rg -n -i "\b(sent|delivered|read|replied|contact completed)\b" src/components/care-plan src/app/mockups/care-plan ``` Expected result: the first two scans have no matches; the evidence-language scan has only explicit negations or test assertions and each match is manually classified. @@ -1064,7 +1285,7 @@ Expected result: the first two scans have no matches; the evidence-language scan - [ ] Run `npm run format`, inspect all formatting diffs for unrelated changes, and rerun only the checks whose inputs formatting changed. Run `git diff --check` and inspect `git status --short`. - [ ] Re-read the approved specification and mark every acceptance criterion with direct code/test/browser evidence in `verification-report.md`. Do not infer requirement completion from a green test suite alone. - [ ] Dispatch the most capable available final reviewer with the full branch review package, approved spec, SDD ledger rulings/parked findings, and exact verification report. Fix Critical/Important findings through one SDD fix wave and one scoped re-review. -- [ ] Commit Task 9 with `test(ed-care-plans): verify complete synthetic workflow`. Do not push, open a PR, deploy, or publish. +- [ ] Commit Task 11 with `test(care-plan): verify complete synthetic workflow`. Do not push, open a PR, deploy, or publish. ## Final Acceptance Matrix @@ -1088,6 +1309,16 @@ The final reviewer and controller must verify these observable facts, not merely | No encoded numeric identification rule | Policy null types/fixtures, governance UI, and source inspection | | Synthetic/offline boundary | Recursive namespace scan, route marker checks, reset-on-refresh browser journey | | Accessibility/responsiveness | Keyboard/focus/media/320/390/768/1024/1440 evidence | +| Read primacy | Stage A ships the whole reading journey with no authoring surface present | +| Pinned safety boundary | DOM position assertion plus 320/390/desktop/dark/forced-colour/print evidence | +| Admission wording | `BANNED_ADMISSION_CONSTRUCTIONS` unit test, form validation test, fixture scan | +| Participation marker | Reducer trigger assertion plus marker present on view, print, and queue entry | +| Withdrawal distinct from no plan | DOM assertion that the withdrawal line renders and the bare empty state does not | +| Sort confined to identification | Directory has no sort-by-count control; identification screen does | +| Patient Plan is deterministic | Pure-function equality test plus namespace scan for model/network references | +| Patient Plan gaps | Agreed-approach always a gap; approval blocked while any gap is unfilled | +| Patient Plan staleness | Derived-selector test plus browser journey after approving a newer version | +| Shared print primitive | Both print views import `PrintOutput`; generalised capabilities carry own tests | | Production boundary | Governance and handoff copy plus no-provider verification report | ## Execution Handoff diff --git a/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md b/docs/superpowers/specs/2026-08-20-care-plan-design.md similarity index 59% rename from docs/superpowers/specs/2026-08-20-ed-care-plans-design.md rename to docs/superpowers/specs/2026-08-20-care-plan-design.md index d8315f77b..d9d2ec3d4 100644 --- a/docs/superpowers/specs/2026-08-20-ed-care-plans-design.md +++ b/docs/superpowers/specs/2026-08-20-care-plan-design.md @@ -1,4 +1,4 @@ -# ED Care Plans — Standalone Synthetic Application Design +# Care Plan — Standalone Synthetic Application Design **Status:** User-approved product and visual direction; implementation plan complete; implementation not started. @@ -8,9 +8,9 @@ Build a complete, linked, synthetic application for finding people with recurren The primary usability target is that an authorised ED clinician can find the correct synthetic patient, confirm whether a Current Plan exists, understand its first-minute continuity guidance, and reach the CMHT contact within 30 seconds. -The product name is **ED Care Plans**, with the descriptor **Continuity for recurrent presentations**. Patient-facing and clinician-facing copy must not use “frequent flyer”. “Frequent presenter” is reserved for discussion of the service workflow, not as a label for a person. +The product name is **Care Plan**, with the descriptor **Continuity for recurrent presentations**. Patient-facing and clinician-facing copy must not use “frequent flyer”. “Frequent presenter” is reserved for discussion of the service workflow, not as a label for a person. -The canonical domain language is defined in [`docs/ed-care-plans-context.md`](../../ed-care-plans-context.md). +The canonical domain language is defined in [`docs/care-plan-context.md`](../../care-plan-context.md). ## Evidence and governance grounding @@ -33,11 +33,30 @@ Primary public references: These sources ground the prototype's workflow and language. They do not validate the application for clinical use, establish a local identification policy, or substitute for WA Health governance review. +## Naming + +The product is **Care Plan**, descriptor **Continuity for recurrent presentations** (renamed from ED Care Plans on 21 August 2026). The documents inside it keep their glossary names, which are deliberately not the product name so that "the app" and "the document" can never be confused in code, copy, or conversation: + +- **Management Plan** — the clinician-facing continuity document. The main object of the product. +- **Patient Plan** — the patient-facing edition of that Management Plan, in the person's own voice. +- **Personal Safety Plan** — the person's own distress plan, which is theirs rather than a clinical document. + +## Read primacy + +Decided 21 August 2026 by the user: _"the plan is for clinicians to look up and see the management plan; it is rarely for changing or updating — this is the main use."_ + +This is the ordering principle for the whole product, not a preference. Where reading and authoring compete for screen space, navigation depth, attention, or implementation effort, **reading wins**. Concretely: + +- The search-to-read journey is never more than two actions from any route. +- Authoring, comparison, approval, and withdrawal controls never occupy space that first-minute reading content needs, and never appear above it. +- A reader who has no authoring permission sees a clean reading surface, not a surface full of unavailable controls. +- Build order follows the same rule: the complete reading experience, including print, is finished and reviewed before any authoring surface is built. + ## Product boundary ### Included -- A linked route suite under `/mockups/ed-care-plans`. +- A linked route suite under `/mockups/care-plan`. - Deterministic synthetic patients, clinicians, EDs, CMHTs, plans, presentations, and audit events. - A multi-site fictional WA health-service context with three fictional EDs and several fictional CMHTs. - Adults and older adults. @@ -56,7 +75,8 @@ These sources ground the prototype's workflow and language. They do not validate - Real patient, clinician, service, site, caseload, or utilisation data. - Persistent storage, local storage, cookies for patient state, databases, APIs, analytics, or network calls. - EDIS, EMR, PAS, PSOLIS, pharmacy, ambulance, police, CMHT, email-provider, or identity-provider integration. -- Automatic identification, enrolment, diagnosis, risk scoring, clinical-severity scoring, treatment recommendation, allocation, or plan generation. +- Automatic identification, enrolment, diagnosis, risk scoring, clinical-severity scoring, treatment recommendation, allocation, or clinical plan generation. +- Any language model, AI service, or provider call, including for the Patient Plan transformation, which is deterministic and offline. - Medication ordering or a parallel medication record. - Automated email or messaging. - A general staff inbox, chat, comments feed, or social activity. @@ -74,7 +94,9 @@ These sources ground the prototype's workflow and language. They do not validate 6. Objective presentation activity may be displayed and sorted. Authorised users may manually refer a patient for identification review. 7. CMHT email actions launch the user's email client but must not pre-populate patient identifiers or clinical content and must not record that an email was sent or received. 8. The application remains a synthetic, reset-on-refresh prototype. “Created” and “stored” mean held in the current in-memory prototype session only. -9. (Added 21 August 2026, user decision.) The prototype is built so that real persistence could later be added without redesigning the domain: the reducer stays pure, prototype state stays plain serialisable data, and every change goes through one dispatched action. No storage layer, adapter, persistence flag, or migration scaffolding is built now, and this decision does not weaken any synthetic-only, privacy, or production-readiness boundary stated elsewhere in this specification. +9. (Added 21 August 2026.) Reading is the primary use. Authoring, approval, comparison, and withdrawal are supporting machinery and never take precedence over the reading surface in layout, navigation depth, or build order. +10. (Added 21 August 2026.) The Patient Plan is produced by a deterministic offline transformation that flags what it cannot convert, requires clinician approval before the patient receives it, and never auto-converts the agreed-approach section. +11. (Added 21 August 2026.) The prototype is built so that real persistence could later be added without redesigning the domain: the reducer stays pure, prototype state stays plain serialisable data, and every change goes through one dispatched action. No storage layer, adapter, persistence flag, or migration scaffolding is built now, and this decision does not weaken any synthetic-only, privacy, or production-readiness boundary stated elsewhere in this specification. ## Visual direction @@ -110,27 +132,31 @@ The approved primary shell is **Direction A — Clinical Snapshot**. The linked application uses the following route families: -| Route | Purpose | -| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `/mockups/ed-care-plans` | Search-first Home and Clinical Snapshot | -| `/mockups/ed-care-plans/patients` | Full patient directory and presentation-activity view | -| `/mockups/ed-care-plans/patients/[patientId]` | Patient overview and first-minute snapshot | -| `/mockups/ed-care-plans/patients/[patientId]/management-plan` | Full Current Plan, draft summary, review state, and version history entry points | -| `/mockups/ed-care-plans/patients/[patientId]/management-plan/edit` | Create or edit a draft version | -| `/mockups/ed-care-plans/patients/[patientId]/management-plan/review` | Compare, return for changes, and approve a submitted version | -| `/mockups/ed-care-plans/patients/[patientId]/safety-plan` | Current patient-owned Personal Safety Plan | -| `/mockups/ed-care-plans/patients/[patientId]/safety-plan/edit` | Co-produce or revise a Personal Safety Plan Version | -| `/mockups/ed-care-plans/patients/[patientId]/safety-plan/print` | Print-optimised patient copy | -| `/mockups/ed-care-plans/patients/[patientId]/presentations` | Longitudinal ED Presentation timeline | -| `/mockups/ed-care-plans/patients/[patientId]/presentations/new` | Record a concise ED Presentation | -| `/mockups/ed-care-plans/patients/[patientId]/presentations/[presentationId]` | View an episode, plan-use feedback, outcome, and amendments | -| `/mockups/ed-care-plans/patients/[patientId]/history` | Combined plan, presentation-amendment, print, and contact-action audit chronology | -| `/mockups/ed-care-plans/reviews` | Awaiting Approval, Review Suggested, contact verification, and manual identification queues | -| `/mockups/ed-care-plans/team` | Synthetic CMHT and plan-owner directory | -| `/mockups/ed-care-plans/governance` | Prototype boundary, roles, lifecycle rules, and unresolved identification policy | -| `/mockups/ed-care-plans/system-states` | Deterministic degraded-state specimens and scenario controls | - -The route suite is linked from the login-gated Developer hub rather than production navigation. Each route has a literal inbound link or an explicitly documented parameterised child relationship so repository reachability checks remain truthful. +| Route | Purpose | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| `/mockups/care-plan` | Search-first Home and Clinical Snapshot | +| `/mockups/care-plan/patients` | Full patient directory and presentation-activity view | +| `/mockups/care-plan/patients/[patientId]` | Patient overview and first-minute snapshot | +| `/mockups/care-plan/patients/[patientId]/management-plan` | Full Current Plan, draft summary, review state, and version history entry points | +| `/mockups/care-plan/patients/[patientId]/management-plan/edit` | Create or edit a draft version | +| `/mockups/care-plan/patients/[patientId]/management-plan/review` | Compare, return for changes, and approve a submitted version | +| `/mockups/care-plan/patients/[patientId]/management-plan/print` | Print-optimised clinician summary to carry to the bedside or send with a handover | +| `/mockups/care-plan/patients/[patientId]/patient-plan` | The patient-facing edition of the Management Plan, with its own version and approval state | +| `/mockups/care-plan/patients/[patientId]/patient-plan/edit` | Create the patient edition from the Current Plan, fill its flagged gaps, and approve it | +| `/mockups/care-plan/patients/[patientId]/patient-plan/print` | Print-optimised patient copy, including their resources | +| `/mockups/care-plan/patients/[patientId]/safety-plan` | Current patient-owned Personal Safety Plan | +| `/mockups/care-plan/patients/[patientId]/safety-plan/edit` | Co-produce or revise a Personal Safety Plan Version | +| `/mockups/care-plan/patients/[patientId]/safety-plan/print` | Print-optimised patient copy | +| `/mockups/care-plan/patients/[patientId]/presentations` | Longitudinal ED Presentation timeline | +| `/mockups/care-plan/patients/[patientId]/presentations/new` | Record a concise ED Presentation | +| `/mockups/care-plan/patients/[patientId]/presentations/[presentationId]` | View an episode, plan-use feedback, outcome, and amendments | +| `/mockups/care-plan/patients/[patientId]/history` | Combined plan, presentation-amendment, print, and contact-action audit chronology | +| `/mockups/care-plan/reviews` | Awaiting Approval, Review Suggested, contact verification, and manual identification queues | +| `/mockups/care-plan/team` | Synthetic CMHT and plan-owner directory | +| `/mockups/care-plan/governance` | Prototype boundary, roles, lifecycle rules, and unresolved identification policy | +| `/mockups/care-plan/system-states` | Deterministic degraded-state specimens and scenario controls | + +The route suite is reachable on the live site at `psychiatry.tools` for a signed-in administrator, by adding `/mockups/care-plan` to the developer-gated path prefixes, exactly as the Caring Contact prototype already is. Every other `/mockups/**` path continues to return 404 in production. This is deliberate (user decision, 21 August 2026): the application holds no real information, it sits behind an existing administrator sign-in, and being able to open it on a phone in a meeting is most of what a prototype is for. It is linked from the login-gated Developer hub rather than production navigation. Each route has a literal inbound link or an explicitly documented parameterised child relationship so repository reachability checks remain truthful. ## Patient workspace @@ -157,10 +183,18 @@ All five are required before a version can be approved. They are the entire summ 1. **How to approach this person.** The engagement approach that works for them. 2. **What helps.** Concrete things that reduce distress. -3. **What makes it worse.** Concrete things to avoid. -4. **What we have agreed to do.** The agreed ED approach, including the usual disposition and the agreed threshold for admission. +3. **What makes it worse.** Concrete things to avoid, written about what the service does rather than about what the person does. + + Written carelessly this section becomes a list of the patient's faults, which is the single most common way documents like this cause harm. It describes the corridor, the repeated history-taking, the security presence, the unexplained wait — things the department controls and can change. Where a person's own response genuinely must be recorded, it is written as a response to circumstances, never as a trait. Every fixture models this, because whatever the fixtures do is what every real plan written in this tool will imitate. + +4. **What we have agreed to do.** The agreed ED approach, including the usual disposition and any agreed position on admission. + + This section carries a wording rule, because it is the one most open to misuse. A continuity plan that states an agreed position on admission can be read at 3am as a pre-authorised refusal by a clinician who has never met the person, and the person is not present to argue with it. Refusing to write it down is worse — the decision still gets made, just unaccountably. So the section must: name who agreed the position and when; be phrased as an agreed default rather than a ceiling on care; and never use prohibitive constructions. `Should not be admitted`, `do not admit`, `admission is not indicated`, and equivalents are banned outright in fixture content, interface copy, and any example. Every rendering of this section sits adjacent to section 5. + 5. **What would make this presentation different.** The explicit “this plan does not apply — assess afresh” boundary. This section is visually distinct from the other four and is never collapsed, truncated, or hidden behind a disclosure. + It is additionally **pinned**: a one-line form of it appears directly beneath the patient's name, above all plan content, at every viewport and in print, as well as in its numbered place in the sequence. On a phone the five sections are a long card and a hurried reader stops before the end — which is precisely the reader this section exists for. The pinned line links to the full section; it never replaces it. + The summary card also shows, as metadata rather than content: version, Current state, approver and approval date, owner, review state, primary CMHT contact with operating hours, and a link to the Personal Safety Plan. ### Full-plan tier — read when there is time @@ -195,7 +229,7 @@ The workflow is: 4. Compare the submitted version against the Current version. 5. Return it to Draft with a reason or approve it with an explicit confirmation. 6. On approval, atomically make the submitted version Current and the previous Current version Superseded. -7. Withdraw a Current version only with an explicit reason and confirmation, leaving the patient with no Current Plan rather than silently restoring an older version. +7. Withdraw a Current version only with an explicit reason and confirmation, leaving the patient with no Current Plan rather than silently restoring an older version. Withdrawal is restricted to a named `senior_clinician`, the same role that approves, because removing a plan from use is as consequential as putting one into it. Afterwards the patient's record reads `Plan withdrawn on ` with the withdrawing clinician named, never a bare `No Current Plan`; superseded versions stay readable in history. A patient who has never had a plan and a patient whose plan was withdrawn must never look the same. An overdue Current Plan remains visible with a prominent warning. It is not silently hidden, downgraded to Draft, or represented as expired. A replacement draft never suppresses the Current version while approval is pending. @@ -203,7 +237,9 @@ An overdue Current Plan remains visible with a prominent warning. It is not sile Each ED Presentation records only the continuity information this application owns. Revised 21 August 2026 after user review: the earlier draft required all of the fields below, which is two to three minutes of typing at the end of a shift, in a second system, partly duplicating the hospital record. Work that heavy does not get done, and an empty feedback loop makes the Review Suggested queue and roughly a third of the application scaffolding for something that never runs. -**Required — the roughly thirty-second set.** Fictional ED site; disposition; whether the plan was available; whether it was used; whether it helped; and one free-text note for anything worth flagging. Arrival date and time default to now and stay editable. +**Required — the roughly thirty-second set.** Fictional ED site; disposition; whether the plan was available; whether it was used; whether it helped; and one required line of free text labelled `In one line: why they came and what happened`. Arrival date and time default to now and stay editable. + +That single line is deliberately doing the work the separate structured `presenting indication` and `assessment outcome` fields would otherwise do. It costs the recorder no more time, it is always filled in, and it is what the presentation-activity view needs in order to be useful. One line of prose from the clinician who was actually there beats two structured boxes that get skipped. **Required conditionally.** A review reason whenever review is suggested, and a deviation reason whenever a deviation is recorded. @@ -226,7 +262,7 @@ The complete field set is: The record does not duplicate a full ED note, diagnosis list, medication chart, risk assessment, or narrative clinical history. -ED Presentation records are append-only in the domain model. A correction creates a visible Presentation Amendment with author, time, reason, and replacement value. Plan-use feedback that suggests review creates a Review Trigger without automatically changing the plan. +ED Presentation records are append-only in the domain model. A correction creates a visible Presentation Amendment with author, time, reason, and replacement value. Amendable fields are the disposition, the assessment outcome, the one-line account, and the plan-use answers (availability, use, and helpfulness) as one group. Extending amendment to the plan-use answers does not let anyone rewrite history — every amendment is visible and attributed — and refusing it would mean a clinician who mis-taps `the plan helped` can never correct it, which is exactly the kind of friction that makes people stop using a tool. Plan-use feedback that suggests review creates a Review Trigger without automatically changing the plan. ## Personal Safety Plan @@ -240,7 +276,7 @@ The Personal Safety Plan is written in the patient's voice and uses seven practi 6. Family, friends, and supports I can contact. 7. Professional and emergency support. -It has independent `draft`, `current`, and `superseded` versions plus `within_review`, `due_soon`, and `overdue` review states. The patient-confirmation state records `confirmed`, `discussed_not_confirmed`, `declined`, or `unavailable`. A clinician records the collaboration; senior-clinician approval is not required. +It has independent `draft`, `current`, and `superseded` versions plus `within_review`, `due_soon`, and `overdue` review states. The patient-confirmation state records `confirmed`, `discussed_not_confirmed`, `declined`, or `unavailable`. A clinician records the collaboration; senior-clinician approval is not required. Any clinical role may create or revise one, including an ED clinician mid-shift: the emergency department at 2am is very often exactly when a safety plan gets made, and a tool that refuses it there is useless at the moment it matters most. Only `plan_coordinator`, which is deliberately a non-clinical coordination role, cannot author one. The print route: @@ -251,6 +287,12 @@ The print route: - Removes navigation, action controls, audit history, and unrelated presentation data. - Uses browser print/PDF capability with repository print styles; no PDF dependency is added. +## Printing the clinician plan + +The Management Plan summary prints as well as the Personal Safety Plan. An ED clinician may want the five first-minute sections on paper to carry to the bedside, and a community team may want them for a handover. The print view carries the patient identifiers, the pinned safety boundary, the five sections in order, the version and approval metadata, the CMHT contact block, a `check the electronic record` warning, a printed-at timestamp, the synthetic-prototype watermark, and a confidential-document footer. It omits navigation, actions, audit history, and drafts. + +Both print views, and any later one, are built on the repository's existing `PrintOutput` and `BrowserPrintButton` primitives in `src/components/ui/print-output.tsx`, alongside the two working printed screens in Therapy Compass. Where those primitives are missing something genuinely general — page-break control per section, a monochrome state treatment, a standard confidential footer — the general capability is added to the shared primitive and consumed from there, not reimplemented locally. Route-scoped print CSS is limited to what is genuinely specific to this application's layout. + ## CMHT and contact actions Each CMHT record includes: @@ -269,6 +311,63 @@ Each CMHT record includes: Current public crisis details must be source-verified when implemented. As verified on 20 August 2026, the prototype may present `000` for emergencies, MHERL for metropolitan Perth and Peel, and Rurallink for regional and remote WA, with the explicit statement that MHERL is not an emergency service. +## Patient Plan + +The Patient Plan is the patient-facing edition of an approved Management Plan Version, written in the person's own voice with recovery-focused language, together with a set of resources chosen for them. It is distinct from the Personal Safety Plan, which is the person's own plan for managing distress rather than a rendering of the clinical one. + +### How it is produced + +A deterministic, offline transformation. No language model, no network call, no provider, no clinical text leaving the machine — the application's provider-free boundary is unchanged. + +Naive term-substitution over clinical prose produces confident nonsense, so the transformation does not attempt to rewrite arbitrary sentences. It works because the Management Plan is already eleven fields with known meanings: + +- Each clinical field maps to a known patient-voice heading. +- A curated plain-language dictionary replaces clinical vocabulary with everyday words. +- Framing shifts to second person, present tense, strengths-based and recovery-focused. +- **Anything the transformation cannot convert with confidence becomes a visible gap for the clinician to write, never a guess.** The output is an incomplete draft by design. + +Section 4, the agreed ED approach and any position on admission, is **never** auto-converted under any circumstances. It is always presented as a gap for a person to word. It is the section where a wording slip does the most harm and the one a patient is most likely to read as a judgment about them. + +The transformation is a pure function of an approved Management Plan Version. Replacing it later with a language-model implementation must not require redesigning the surrounding screens, the version model, or the approval step. + +### Sections + +Eight, in this order: + +1. Why we wrote this together. +2. What matters to you. +3. What helps you. +4. What makes things harder. +5. What we agreed will happen when you come to the emergency department. +6. If something new is happening. +7. Who's involved in your care. +8. Things that might help. + +Physical health and medication is deliberately absent. The person has their own record, and duplicating medication detail onto a printed sheet that leaves the building is a privacy cost with no corresponding benefit. + +### Approval + +A Patient Plan Version has its own draft and current states and its own approval, separate from the Management Plan's. Any clinical role may approve one — requiring a senior clinician would mean people wait days for their own copy, which defeats the purpose — and the approving clinician is named on the version and on the print. Approval is mandatory: an automatic rewrite of a clinical document into patient-facing language can drift in meaning, and this is the copy that leaves the building. A Patient Plan Version cannot be approved while any flagged gap is unfilled. + +### Currency + +Each Patient Plan Version names the Management Plan Version it was derived from. When a newer Management Plan Version becomes Current, the Patient Plan is marked `Based on an earlier version — needs updating`, stays fully readable, and raises a Review Trigger. It is never silently regenerated, never hidden, and never withdrawn automatically: the person may be holding a printed copy of it, and the application's account of what they were given must stay truthful. + +### Resources + +Four kinds, all synthetic except the already-verified public crisis lines: + +- The person's own community team, care coordinator, and consented supports. +- Local services in the fictional health service, typed so the list can carry practical categories — housing, financial, transport, carer support, alcohol and other drugs, cultural and peer support — alongside clinical ones. Housing and money are frequently the actual reason someone keeps presenting, and a resource list that cannot mention them is the wrong list. +- The verified national and WA crisis contacts, with the same caveats used elsewhere in this specification. +- Self-help and psychoeducation reading. + +Resources are hand-authored per patient in this prototype. They are structured so that a later revision of this application can source them from the existing Services and Factsheets modes; that integration is recorded intent, not present scope, and no production module is imported now. + +### Print + +The patient copy prints with plain language, generous spacing, and monochrome safety, carrying the person's preferred name, the version and approval date, the eight sections, their resources, the verified crisis contacts, the synthetic watermark, and a printed-at timestamp. It omits navigation, clinical vocabulary, audit history, ED presentation data, and the Management Plan's internal metadata. + ## Identification review The prototype includes an Identification Policy record with: @@ -279,7 +378,9 @@ The prototype includes an Identification Policy record with: - Manual referral enabled. - A visible explanation that local clinical and privacy governance must define eligibility before operational use. -Patient lists may display objective counts such as “7 ED presentations in 12 months” and sort by activity. They must not convert those counts into an automatic label, mandatory care pathway, severity claim, or risk score. +Patient lists display objective activity: the count over a named window, which fictional EDs the person attended and how often at each, and a compact reverse-chronological line per presentation giving the date, the site, and the one-line account of why they came and what happened. They must not convert any of that into an automatic label, mandatory care pathway, severity claim, or risk score. + +Sorting by presentation count is available **only inside the Identification Review workflow**, where finding people who attend often is the stated and governed purpose of the screen. It is not offered on the general patient directory or on any other surface. The glossary bans `frequent presenter` as a label for a person; a sortable ranking of everyone by attendance is the same ranking without the word, and confining it to the one screen that has a reason for it is more honest than either banning it outright or leaving it lying around the application. Wherever the ranking is offered, the statement that counts do not determine eligibility is on the same screen. An authorised synthetic user may manually add a patient to `Identification review` with a reason. The queue action initiates multidisciplinary review; it does not create or approve a plan. @@ -300,6 +401,10 @@ The displayed synthetic user and role explain why an action is available. This i The Management Plan records whether participation was `co_produced`, `discussed`, `declined`, or `patient_unavailable`. It may record consented support people and whether their involvement is current. +A version may be approved at any participation state — sometimes a plan must be written for a person who cannot or will not engage, and blocking that would make the tool refuse the situations it exists for. But it is never invisible that this happened. A version whose participation is `declined` or `patient_unavailable` carries a persistent `Written without this person's involvement` marker on every view, print, and queue entry, and its approval raises an open Review Trigger so that involving the person stays on somebody's list. + +The Management Plan also records whether the person has been shown their plan, and whether a Patient Plan edition exists and is current. A plan written about someone who has never seen it is the thing this category of document is most criticised for, so the state is recorded rather than left unasked. + The patient-owned summary can include: - Preferred name and pronouns. @@ -350,7 +455,7 @@ Primary entities are: - `IdentificationPolicy` and `IdentificationReview`. - `AuditEvent`. -All fixture IDs use a `SYN-` prefix, and every patient-facing route displays the fictional-data boundary. Refresh returns the application to deterministic fixtures. The provider performs no persistence or network access. +All fixture IDs use a `SYN-` prefix, and every patient-facing route displays the fictional-data boundary. Refresh returns the application to deterministic fixtures. The shell states this in plain words about _state_, not only about data — something to the effect of `Nothing is saved. Reloading this page starts over.` — because the synthetic-data label does not warn a person demonstrating the tool that an accidental reload will discard the draft they are showing someone. The provider performs no persistence or network access. ## Error and degraded-state behaviour @@ -427,6 +532,16 @@ No live Supabase, OpenAI, email, identity, hospital, CMHT, analytics, or provide - An approved version defaults its next review date 12 months ahead, the author can change it, and the amber warning begins 28 days before it. - An ED Presentation saves with only site, disposition, plan availability, plan use, plan helpfulness, and the note completed; the optional detail fields never block the save and render as `Not recorded` when empty. - An Identification Review can be closed with a recorded decision and reason, leaves the queue when closed, remains visible in the patient's history, and creates no plan on any decision. +- The pinned safety boundary is visible above all plan content at 320 px, 390 px, desktop, dark mode, forced colours, and in print. +- No fixture, interface string, or example contains a prohibitive admission construction. +- A version approved at `declined` or `patient_unavailable` participation shows the `written without this person's involvement` marker everywhere and raises a Review Trigger. +- A withdrawn plan never renders identically to a patient who never had one. +- Sort-by-count exists only within the Identification Review workflow and nowhere else. +- The Management Plan summary prints, and both print views consume the shared `PrintOutput` primitive rather than reimplementing print behaviour. +- The Patient Plan transformation produces visible gaps rather than guesses, never auto-converts the agreed-approach section, and cannot be approved with a gap unfilled. +- A Patient Plan derived from a superseded Management Plan Version is marked as needing updating, stays readable, and is never regenerated or hidden automatically. +- No language model, network call, or provider is reachable from any part of the application, including the Patient Plan transformation. +- The shell states in plain words that nothing is saved and reloading starts over. - Presentation corrections are visible amendments rather than silent overwrites. - Plan-use feedback can create a Review Trigger but cannot change a plan automatically. - Objective presentation counts never become an automatic patient label or eligibility decision. From bfbee15b17f99f984be1d9a4829b0cb7e721e841 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:47:56 +0800 Subject: [PATCH 04/56] feat(care-plan): define synthetic clinical domain Co-Authored-By: Claude Opus 5 --- src/components/care-plan/mockups/domain.ts | 390 +++++ src/components/care-plan/mockups/fixtures.ts | 1374 ++++++++++++++++++ src/components/care-plan/mockups/types.ts | 524 +++++++ tests/care-plan-domain.test.ts | 615 ++++++++ 4 files changed, 2903 insertions(+) create mode 100644 src/components/care-plan/mockups/domain.ts create mode 100644 src/components/care-plan/mockups/fixtures.ts create mode 100644 src/components/care-plan/mockups/types.ts create mode 100644 tests/care-plan-domain.test.ts diff --git a/src/components/care-plan/mockups/domain.ts b/src/components/care-plan/mockups/domain.ts new file mode 100644 index 000000000..5ef99d52c --- /dev/null +++ b/src/components/care-plan/mockups/domain.ts @@ -0,0 +1,390 @@ +/** + * Care Plan — pure domain selectors. + * + * Every function here is a pure function of the arguments it is given. Nothing + * reads a clock, a network, storage, or a random source: `now` is always passed + * in, so the whole prototype is reproducible. Nothing mutates its inputs. + * + * These selectors read and derive. They never enrol a patient, create a plan, + * or turn an observation into an eligibility decision. + */ + +import { + REVIEW_DUE_SOON_DAYS, + type CmhtContact, + type EdPresentation, + type ManagementPlanVersion, + type Patient, + type PatientSnapshot, + type PatientSnapshotSource, + type PersonalSafetyPlanVersion, + type PresentationActivity, + type PrototypeCapability, + type PrototypeRole, + type ReviewQueueSource, + type ReviewQueues, + type ReviewState, + type SyntheticId, +} from "./types"; + +const PERTH_OFFSET_MINUTES = 8 * 60; +const PERTH_OFFSET_SUFFIX = "+08:00"; +const MILLISECONDS_PER_DAY = 86_400_000; + +/** Australia/Perth never observes daylight saving, so a fixed offset is exact. */ +function toPerthWallClock(iso: string): Date { + return new Date(Date.parse(iso) + PERTH_OFFSET_MINUTES * 60_000); +} + +function formatPerthIso(wallClock: Date): string { + return `${wallClock.toISOString().slice(0, 19)}${PERTH_OFFSET_SUFFIX}`; +} + +/** Shift an ISO timestamp by whole days, returning Perth-local ISO. */ +export function addIsoDays(iso: string, days: number): string { + return formatPerthIso(new Date(toPerthWallClock(iso).getTime() + days * MILLISECONDS_PER_DAY)); +} + +/** Shift an ISO timestamp by whole calendar months, clamping to the last day of + * a shorter target month so 31 January plus one month is 28 or 29 February. */ +export function addIsoMonths(iso: string, months: number): string { + const base = toPerthWallClock(iso); + const shifted = new Date(base.getTime()); + shifted.setUTCMonth(shifted.getUTCMonth() + months); + if (shifted.getUTCDate() !== base.getUTCDate()) shifted.setUTCDate(0); + return formatPerthIso(shifted); +} + +/** + * The named observation window for Presentation Activity. It describes what is + * counted and over what period. It is not a threshold: no count here creates + * eligibility, a label, a risk state, a care pathway, or a plan. + */ +export const PRESENTATION_ACTIVITY_WINDOW_MONTHS = 12; + +/** + * Prohibitive admission phrasings, banned outright in plan content, interface + * copy, and examples. A continuity plan that says the person should not be + * admitted can be read at 3am as a pre-authorised refusal by a clinician who + * has never met them, and the person is not there to argue with it. The agreed + * ED approach is written as an agreed default that names who agreed it and + * when, never as a ceiling on care. + * + * Compared case-insensitively against the candidate text. + */ +export const BANNED_ADMISSION_CONSTRUCTIONS: readonly string[] = [ + "should not be admitted", + "should not admit", + "must not be admitted", + "not to be admitted", + "do not admit", + "never admit", + "no admission", + "not for admission", + "avoid admission", + "admission should be avoided", + "admission is not indicated", + "admission is not appropriate", + "admission is not warranted", + "does not require admission", + "does not need admission", + "should be discharged", + "must be discharged", +]; + +const CAPABILITIES_BY_ROLE: Record = { + // ED clinician: find and read the Current Plan, record a presentation, + // capture plan-use feedback, and reach the team. + ed_clinician: [ + "read_plan", + "contact_cmht", + "record_presentation", + "author_safety_plan", + "approve_patient_plan", + "refer_for_identification_review", + ], + // Liaison and CMHT clinicians: create and edit drafts, co-produce the + // Personal Safety Plan, verify contacts, and respond to review triggers. + liaison_clinician: [ + "read_plan", + "contact_cmht", + "record_presentation", + "author_management_draft", + "submit_management_draft", + "author_safety_plan", + "approve_patient_plan", + "verify_cmht_contact", + "refer_for_identification_review", + "close_identification_review", + "manage_worklists", + ], + cmht_clinician: [ + "read_plan", + "contact_cmht", + "record_presentation", + "author_management_draft", + "submit_management_draft", + "author_safety_plan", + "approve_patient_plan", + "verify_cmht_contact", + "refer_for_identification_review", + "close_identification_review", + "manage_worklists", + ], + // Named senior clinician: additionally compare, return, approve, withdraw, + // and record formal review. + senior_clinician: [ + "read_plan", + "contact_cmht", + "record_presentation", + "author_management_draft", + "submit_management_draft", + "approve_management_version", + "withdraw_management_version", + "record_formal_review", + "author_safety_plan", + "approve_patient_plan", + "verify_cmht_contact", + "refer_for_identification_review", + "close_identification_review", + "manage_worklists", + ], + // Plan coordinator is non-clinical: worklists, without clinical authorship, + // approval, or any clinical-severity judgment. + plan_coordinator: [ + "read_plan", + "contact_cmht", + "refer_for_identification_review", + "close_identification_review", + "manage_worklists", + ], +}; + +/** Generic, non-clinical subject line. It carries no patient information. */ +export const CMHT_CONTACT_SUBJECT = "ED Care Plans — team contact request"; + +export type CmhtTelephoneChannel = "duty" | "after_hours"; + +function byIsoAscending(select: (item: T) => string): (left: T, right: T) => number { + return (left, right) => Date.parse(select(left)) - Date.parse(select(right)); +} + +function formatDisplayDateOfBirth(isoDate: string): string { + const [year, month, day] = isoDate.split("-"); + return year && month && day ? `${day}/${month}/${year}` : isoDate; +} + +/** The only fields patient search is allowed to read. Deliberately no plan, + * presentation, safety-plan, cultural, support-person, or clinical text. */ +function searchableIdentityFields(patient: Patient): readonly string[] { + return [ + patient.fullName, + patient.preferredName, + ...patient.aliases, + patient.mrn, + patient.dateOfBirth, + formatDisplayDateOfBirth(patient.dateOfBirth), + ]; +} + +export function searchPatients(patients: readonly Patient[], query: string): Patient[] { + const needle = query.trim().toLowerCase(); + if (needle === "") return []; + return patients.filter((patient) => + searchableIdentityFields(patient).some((field) => field.toLowerCase().includes(needle)), + ); +} + +export function getPatientById(patients: readonly Patient[], id: SyntheticId): Patient | null { + return patients.find((patient) => patient.id === id) ?? null; +} + +export function getCurrentManagementPlanVersion( + versions: readonly ManagementPlanVersion[], + planId: SyntheticId, +): ManagementPlanVersion | null { + return versions.find((version) => version.planId === planId && version.state === "current") ?? null; +} + +/** + * The version being worked on: a Draft, or a Draft already submitted and + * awaiting a senior decision. It never displaces the Current Plan; callers show + * both. When more than one exists the highest version number wins. + */ +export function getOpenManagementDraft( + versions: readonly ManagementPlanVersion[], + planId: SyntheticId, +): ManagementPlanVersion | null { + const open = versions.filter( + (version) => version.planId === planId && (version.state === "draft" || version.state === "awaiting_approval"), + ); + if (open.length === 0) return null; + return open.reduce((latest, version) => (version.version > latest.version ? version : latest)); +} + +export function getCurrentSafetyPlanVersion( + versions: readonly PersonalSafetyPlanVersion[], + planId: SyntheticId, +): PersonalSafetyPlanVersion | null { + return versions.find((version) => version.planId === planId && version.state === "current") ?? null; +} + +/** The most recent withdrawn version, used only to keep a withdrawn plan from + * rendering identically to a patient who never had one. */ +function getWithdrawnManagementVersion( + versions: readonly ManagementPlanVersion[], + planId: SyntheticId, +): ManagementPlanVersion | null { + const withdrawn = versions.filter((version) => version.planId === planId && version.state === "withdrawn"); + if (withdrawn.length === 0) return null; + return withdrawn.reduce((latest, version) => (version.version > latest.version ? version : latest)); +} + +/** + * Objective counts over a named window. The window is half-open: a presentation + * exactly on the window start falls outside it, and one at the window end falls + * inside. Counts are an observation; they decide nothing. + */ +export function countPresentationActivity( + presentations: readonly EdPresentation[], + patientId: SyntheticId, + now: string, + windowMonths: number = PRESENTATION_ACTIVITY_WINDOW_MONTHS, +): PresentationActivity { + const windowEnd = now; + const windowStart = addIsoMonths(now, -windowMonths); + const startMs = Date.parse(windowStart); + const endMs = Date.parse(windowEnd); + + const inWindow = presentations.filter((presentation) => { + if (presentation.patientId !== patientId) return false; + const arrivedMs = Date.parse(presentation.arrivedAt); + return arrivedMs > startMs && arrivedMs <= endMs; + }); + + const countsBySite = new Map(); + for (const presentation of inWindow) { + countsBySite.set(presentation.siteId, (countsBySite.get(presentation.siteId) ?? 0) + 1); + } + + const bySite = [...countsBySite.entries()] + .map(([siteId, count]) => ({ siteId, count })) + .sort((left, right) => right.count - left.count || left.siteId.localeCompare(right.siteId)); + + return { patientId, windowMonths, windowStart, windowEnd, total: inWindow.length, bySite }; +} + +export function buildPatientSnapshot( + source: PatientSnapshotSource, + patientId: SyntheticId, + now: string, +): PatientSnapshot | null { + const patient = getPatientById(source.patients, patientId); + if (patient === null) return null; + + const managementPlan = source.managementPlans.find((plan) => plan.id === patient.managementPlanId) ?? null; + const currentManagementVersion = + managementPlan === null ? null : getCurrentManagementPlanVersion(source.managementPlanVersions, managementPlan.id); + const openManagementDraft = + managementPlan === null ? null : getOpenManagementDraft(source.managementPlanVersions, managementPlan.id); + const withdrawnManagementVersion = + managementPlan === null || currentManagementVersion !== null + ? null + : getWithdrawnManagementVersion(source.managementPlanVersions, managementPlan.id); + + const personalSafetyPlan = + source.personalSafetyPlans.find((plan) => plan.id === patient.personalSafetyPlanId) ?? null; + const currentSafetyPlanVersion = + personalSafetyPlan === null + ? null + : getCurrentSafetyPlanVersion(source.personalSafetyPlanVersions, personalSafetyPlan.id); + + const reviewDueAt = currentManagementVersion?.reviewDueAt ?? null; + + return { + patient, + cmht: source.cmhtContacts.find((contact) => contact.id === patient.cmhtId) ?? null, + managementPlan, + currentManagementVersion, + openManagementDraft, + withdrawnManagementVersion, + currentSafetyPlanVersion, + reviewState: reviewDueAt === null ? null : deriveReviewState(reviewDueAt, now), + presentationActivity: countPresentationActivity(source.edPresentations, patient.id, now), + presentations: source.edPresentations + .filter((presentation) => presentation.patientId === patient.id) + .sort((left, right) => Date.parse(right.arrivedAt) - Date.parse(left.arrivedAt)), + }; +} + +/** + * Opens an external mail application with the team's shared mailbox and a + * generic subject. It carries no name, MRN, date of birth, presentation + * content, or plan content, and it is never evidence that anything was sent. + */ +export function buildCmhtMailto(contact: CmhtContact): string { + return `mailto:${contact.sharedMailbox}?${new URLSearchParams({ subject: CMHT_CONTACT_SUBJECT }).toString()}`; +} + +/** Opens an external telephone application. It is never evidence of a call. */ +export function buildCmhtTel(contact: CmhtContact, channel: CmhtTelephoneChannel = "duty"): string { + return `tel:${channel === "duty" ? contact.dutyTelephoneUri : contact.afterHoursTelephoneUri}`; +} + +/** + * The four action worklists, each ordered oldest-actionable-first. They are + * never ranked by severity, clinical urgency, or presentation count. + */ +export function getReviewQueues(source: ReviewQueueSource): ReviewQueues { + return { + awaitingApproval: source.managementPlanVersions + .filter((version) => version.state === "awaiting_approval") + .sort(byIsoAscending((version) => version.submittedAt ?? version.createdAt)), + reviewSuggested: source.reviewTriggers + .filter((trigger) => trigger.status === "open") + .sort(byIsoAscending((trigger) => trigger.createdAt)), + contactVerification: source.cmhtContacts + .filter((contact) => contact.verificationState !== "verified") + .sort(byIsoAscending((contact) => contact.verifiedAt)), + identificationReview: source.identificationReviews + .filter((review) => review.status === "open") + .sort(byIsoAscending((review) => review.referredAt)), + }; +} + +/** Interaction modelling only: it explains why an action is offered. It is not + * authentication, authorisation, or any protection of data. */ +export function canPerformAction(role: PrototypeRole, capability: PrototypeCapability): boolean { + return CAPABILITIES_BY_ROLE[role].includes(capability); +} + +/** + * The currency of a Current Plan against its review date. Past the date is + * `overdue`; on or within `REVIEW_DUE_SOON_DAYS` of it is `due_soon`. An + * overdue plan stays Current until it is reviewed, replaced, or withdrawn. + */ +export function deriveReviewState(reviewDueAt: string, now: string): ReviewState { + const dueMs = Date.parse(reviewDueAt); + const nowMs = Date.parse(now); + if (nowMs > dueMs) return "overdue"; + if (dueMs - nowMs <= REVIEW_DUE_SOON_DAYS * MILLISECONDS_PER_DAY) return "due_soon"; + return "within_review"; +} + +/** Approval must produce exactly one Current version per plan. This guards that + * invariant wherever a version set is assembled. */ +export function assertSingleCurrentVersion(versions: readonly ManagementPlanVersion[]): void { + const currentIdsByPlan = new Map(); + for (const version of versions) { + if (version.state !== "current") continue; + const existing = currentIdsByPlan.get(version.planId) ?? []; + existing.push(version.id); + currentIdsByPlan.set(version.planId, existing); + } + + for (const [planId, ids] of currentIdsByPlan) { + if (ids.length > 1) { + throw new Error(`Management plan ${planId} has more than one current version: ${ids.join(", ")}`); + } + } +} diff --git a/src/components/care-plan/mockups/fixtures.ts b/src/components/care-plan/mockups/fixtures.ts new file mode 100644 index 000000000..3a16266d9 --- /dev/null +++ b/src/components/care-plan/mockups/fixtures.ts @@ -0,0 +1,1374 @@ +/** + * Care Plan — deterministic synthetic fixtures. + * + * Every person, clinician, team, hospital, plan, presentation, review, and + * identifier below is invented. The only intentionally real details are the + * verified public crisis telephone numbers in `publicCrisisContacts`. + * + * Nothing here reads a clock, a network, a random source, or a browser API. + * Every timestamp is derived from `PROTOTYPE_NOW` so the whole prototype is + * reproducible byte for byte. + * + * The prose is written to be imitated. Whatever these fixtures do is what every + * real plan written in this tool will copy, so `whatMakesItWorse` describes what + * the service does rather than what the person does wrong, `agreedEdApproach` + * names who agreed the position and when and never uses a prohibitive + * construction, and `whatWouldMakeThisDifferent` names concrete new findings. + */ + +import { addIsoDays, addIsoMonths } from "./domain"; +import { + REVIEW_DUE_SOON_DAYS, + REVIEW_INTERVAL_MONTHS, + type CmhtContact, + type EdPresentation, + type EdSite, + type IdentificationPolicy, + type IdentificationReview, + type ManagementPlan, + type ManagementPlanVersion, + type Patient, + type PersonalSafetyPlan, + type PersonalSafetyPlanVersion, + type PresentationAmendment, + type PrototypeUser, + type PublicCrisisContact, + type ReviewTrigger, +} from "./types"; + +/** The single fixed "now" for the whole prototype. Australia/Perth, no DST. */ +export const PROTOTYPE_NOW = "2026-08-20T14:30:00+08:00"; + +export const SYNTHETIC_DATA_MARKER = "Synthetic prototype — fictional people, teams, and hospitals"; + +/** Replace the time of day on a derived date, so fixture timestamps read like + * real working hours instead of every event landing on the same minute. */ +function atPerthTime(iso: string, hourAndMinute: string): string { + return `${iso.slice(0, 11)}${hourAndMinute}:00+08:00`; +} + +const daysAgo = (days: number, hourAndMinute: string): string => + atPerthTime(addIsoDays(PROTOTYPE_NOW, -days), hourAndMinute); +const monthsAgo = (months: number, hourAndMinute: string): string => + atPerthTime(addIsoMonths(PROTOTYPE_NOW, -months), hourAndMinute); + +/** Every review date is its own approval date plus the shared review interval. */ +const reviewDueFrom = (approvedAt: string): string => addIsoMonths(approvedAt, REVIEW_INTERVAL_MONTHS); + +// Approval dates chosen so the fixture set covers all three review states +// against PROTOTYPE_NOW without hardcoding an unrelated calendar date. +const ROWAN_V1_APPROVED_AT = monthsAgo(20, "10:00"); +const ROWAN_V2_APPROVED_AT = monthsAgo(3, "11:05"); +const MIRA_V1_APPROVED_AT = atPerthTime( + addIsoDays(addIsoMonths(PROTOTYPE_NOW, -REVIEW_INTERVAL_MONTHS), -(REVIEW_DUE_SOON_DAYS + 7)), + "15:40", +); +const EVELYN_V1_APPROVED_AT = monthsAgo(16, "13:25"); +const ROWAN_SAFETY_CREATED_AT = atPerthTime( + addIsoDays(addIsoMonths(PROTOTYPE_NOW, -REVIEW_INTERVAL_MONTHS), Math.floor(REVIEW_DUE_SOON_DAYS / 2)), + "10:20", +); + +export const identificationPolicy = { + id: "SYN-IDENTIFICATION-POLICY-001", + status: "pending_governance", + thresholdCount: null, + thresholdLookbackMonths: null, + manualReferralEnabled: true, + explanation: + "No eligibility rule exists in this prototype. Local clinical and privacy governance must decide who is offered a Management Plan, and on what basis, before this is used operationally. Presentation activity is shown as an objective observation over a named window only: it never creates eligibility, a label for a person, a severity claim, a care pathway, or a plan. Any patient may be referred for Identification Review by an authorised clinician with a stated reason.", +} satisfies IdentificationPolicy; + +export const syntheticUsers = [ + { + id: "SYN-USER-ED-001", + displayName: "Dr Casey Example", + title: "Emergency Physician, North River Hospital ED", + role: "ed_clinician", + }, + { + id: "SYN-USER-LIAISON-001", + displayName: "Morgan Sample", + title: "ED Mental Health Liaison Clinician", + role: "liaison_clinician", + }, + { + id: "SYN-USER-SENIOR-001", + displayName: "Dr Taylor Fiction", + title: "Consultant Psychiatrist, North River Health Service", + role: "senior_clinician", + }, + { + id: "SYN-USER-COORD-001", + displayName: "Riley Demo", + title: "Care Planning Coordinator", + role: "plan_coordinator", + }, +] satisfies readonly PrototypeUser[]; + +export const syntheticPatients = [ + { + id: "SYN-PATIENT-001", + fullName: "Rowan Sample", + preferredName: "Rowan", + aliases: ["Ro Sample"], + mrn: "SYN-MRN-0001", + dateOfBirth: "1986-04-12", + ageCohort: "adult", + pronouns: "they/them", + homeHealthService: "North River Health Service", + cmhtId: "SYN-CMHT-001", + managementPlanId: "SYN-MGMT-PLAN-001", + personalSafetyPlanId: "SYN-SAFETY-PLAN-001", + }, + { + id: "SYN-PATIENT-002", + fullName: "Mira Example", + preferredName: "Mira", + aliases: ["Mira Example-Hale"], + mrn: "SYN-MRN-0002", + dateOfBirth: "1948-09-22", + ageCohort: "older_adult", + pronouns: "she/her", + homeHealthService: "Coastal Plains Health Service", + cmhtId: "SYN-CMHT-002", + managementPlanId: "SYN-MGMT-PLAN-002", + personalSafetyPlanId: "SYN-SAFETY-PLAN-002", + }, + { + id: "SYN-PATIENT-003", + fullName: "Jordan Test", + preferredName: "Jordan", + aliases: [], + mrn: "SYN-MRN-0003", + dateOfBirth: "1994-02-03", + ageCohort: "adult", + pronouns: "he/him", + homeHealthService: "Wandoo District Health Service", + cmhtId: "SYN-CMHT-003", + managementPlanId: "SYN-MGMT-PLAN-003", + personalSafetyPlanId: "SYN-SAFETY-PLAN-003", + }, + { + id: "SYN-PATIENT-004", + fullName: "Evelyn Demo", + preferredName: "Evie", + aliases: [], + mrn: "SYN-MRN-0004", + dateOfBirth: "1979-11-30", + ageCohort: "adult", + pronouns: "she/her", + homeHealthService: "North River Health Service", + cmhtId: "SYN-CMHT-001", + managementPlanId: "SYN-MGMT-PLAN-004", + personalSafetyPlanId: "SYN-SAFETY-PLAN-004", + }, + { + id: "SYN-PATIENT-005", + fullName: "Alex Fiction", + preferredName: "Alex", + aliases: [], + mrn: "SYN-MRN-0005", + dateOfBirth: "2001-06-18", + ageCohort: "adult", + pronouns: "they/them", + homeHealthService: "North River Health Service", + cmhtId: "SYN-CMHT-001", + managementPlanId: "SYN-MGMT-PLAN-005", + personalSafetyPlanId: "SYN-SAFETY-PLAN-005", + }, +] satisfies readonly Patient[]; + +export const syntheticEdSites = [ + { id: "SYN-ED-001", name: "North River Hospital ED", healthService: "North River Health Service" }, + { id: "SYN-ED-002", name: "Coastal Plains Hospital ED", healthService: "Coastal Plains Health Service" }, + { id: "SYN-ED-003", name: "Wandoo District Hospital ED", healthService: "Wandoo District Health Service" }, +] satisfies readonly EdSite[]; + +export const syntheticCmhtContacts = [ + { + id: "SYN-CMHT-001", + name: "North River CMHT", + catchment: "North River metropolitan catchment", + sharedMailbox: "north-river.cmht@example.org", + dutyTelephoneDisplay: "0491 570 210", + dutyTelephoneUri: "+61491570210", + operatingHours: "Monday to Friday, 8:30 am to 5:00 pm AWST", + timezone: "Australia/Perth", + careCoordinator: "Sam Placeholder", + afterHoursLabel: + "Outside those hours, the Mental Health Emergency Response Line (MHERL) covers metropolitan Perth. MHERL is a telephone triage and support line and is not an emergency service; call 000 in an emergency.", + afterHoursTelephoneDisplay: "1300 555 788", + afterHoursTelephoneUri: "1300555788", + verifiedAt: daysAgo(21, "09:30"), + verificationState: "verified", + }, + { + id: "SYN-CMHT-002", + name: "Coastal Plains Older Adult CMHT", + catchment: "Coastal Plains and Peel older-adult catchment", + sharedMailbox: "coastal-plains.olderadult.cmht@example.org", + dutyTelephoneDisplay: "0491 570 211", + dutyTelephoneUri: "+61491570211", + operatingHours: "Monday to Friday, 8:30 am to 4:30 pm AWST", + timezone: "Australia/Perth", + careCoordinator: "Devon Sample", + afterHoursLabel: + "Outside those hours, the Mental Health Emergency Response Line (MHERL) covers the Peel region. MHERL is a telephone triage and support line and is not an emergency service; call 000 in an emergency.", + afterHoursTelephoneDisplay: "1800 676 822", + afterHoursTelephoneUri: "1800676822", + verifiedAt: monthsAgo(7, "09:30"), + verificationState: "review_due", + }, + { + id: "SYN-CMHT-003", + name: "Wandoo District CMHT", + catchment: "Wandoo District regional catchment", + sharedMailbox: "wandoo-district.cmht@example.org", + dutyTelephoneDisplay: "0491 570 212", + dutyTelephoneUri: "+61491570212", + operatingHours: "Monday to Friday, 8:00 am to 4:30 pm AWST", + timezone: "Australia/Perth", + careCoordinator: null, + afterHoursLabel: + "Outside those hours, Rurallink covers regional and remote Western Australia from 4:30 pm to 8:30 am on weeknights and 24 hours on weekends and public holidays. Rurallink is a telephone triage and support line and is not an emergency service; call 000 in an emergency.", + afterHoursTelephoneDisplay: "1800 552 002", + afterHoursTelephoneUri: "1800552002", + verifiedAt: monthsAgo(19, "09:30"), + verificationState: "unverified", + }, +] satisfies readonly CmhtContact[]; + +/** + * The only non-fictional contact details in the prototype. Verified against the + * public sources below on 20 August 2026. If a number or an availability window + * has changed since, correct it here rather than anywhere it is displayed. + */ +export const publicCrisisContacts = [ + { + id: "SYN-CRISIS-CONTACT-001", + name: "Emergency services", + telephoneDisplay: "000", + telephoneUri: "000", + coverage: "Australia-wide", + availability: "24 hours, every day", + isEmergencyService: true, + caveat: null, + sourceUrl: "https://www.triplezero.gov.au/", + verifiedOn: "2026-08-20", + }, + { + id: "SYN-CRISIS-CONTACT-002", + name: "Mental Health Emergency Response Line (MHERL) — Perth metropolitan", + telephoneDisplay: "1300 555 788", + telephoneUri: "1300555788", + coverage: "Metropolitan Perth", + availability: "24 hours, every day", + isEmergencyService: false, + caveat: "MHERL is a telephone triage and support line. It is not an emergency service; call 000 in an emergency.", + sourceUrl: + "https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/MHERL", + verifiedOn: "2026-08-20", + }, + { + id: "SYN-CRISIS-CONTACT-003", + name: "Mental Health Emergency Response Line (MHERL) — Peel", + telephoneDisplay: "1800 676 822", + telephoneUri: "1800676822", + coverage: "Peel region", + availability: "24 hours, every day", + isEmergencyService: false, + caveat: "MHERL is a telephone triage and support line. It is not an emergency service; call 000 in an emergency.", + sourceUrl: + "https://emhs.health.wa.gov.au/Hospitals-and-Services/Mental-Health-Alcohol-and-Other-Drugs/Inpatient-and-Other-Services/MHERL", + verifiedOn: "2026-08-20", + }, + { + id: "SYN-CRISIS-CONTACT-004", + name: "Rurallink", + telephoneDisplay: "1800 552 002", + telephoneUri: "1800552002", + coverage: "Regional and remote Western Australia", + availability: "4:30 pm to 8:30 am on weeknights, and 24 hours on weekends and public holidays", + isEmergencyService: false, + caveat: + "Rurallink is a telephone triage and support line. It is not an emergency service; call 000 in an emergency.", + sourceUrl: "https://www.wacountry.health.wa.gov.au/", + verifiedOn: "2026-08-20", + }, +] satisfies readonly PublicCrisisContact[]; + +export const syntheticManagementPlans = [ + { + id: "SYN-MGMT-PLAN-001", + patientId: "SYN-PATIENT-001", + versionIds: ["SYN-MGMT-VERSION-001", "SYN-MGMT-VERSION-002"], + currentVersionId: "SYN-MGMT-VERSION-002", + }, + { + id: "SYN-MGMT-PLAN-002", + patientId: "SYN-PATIENT-002", + versionIds: ["SYN-MGMT-VERSION-003", "SYN-MGMT-VERSION-004"], + currentVersionId: "SYN-MGMT-VERSION-003", + }, + { + id: "SYN-MGMT-PLAN-003", + patientId: "SYN-PATIENT-003", + versionIds: [], + currentVersionId: null, + }, + { + id: "SYN-MGMT-PLAN-004", + patientId: "SYN-PATIENT-004", + versionIds: ["SYN-MGMT-VERSION-005"], + currentVersionId: null, + }, + { + id: "SYN-MGMT-PLAN-005", + patientId: "SYN-PATIENT-005", + versionIds: ["SYN-MGMT-VERSION-006"], + currentVersionId: null, + }, +] satisfies readonly ManagementPlan[]; + +export const syntheticManagementPlanVersions = [ + { + id: "SYN-MGMT-VERSION-001", + planId: "SYN-MGMT-PLAN-001", + version: 1, + state: "superseded", + reviewState: null, + authorId: "SYN-USER-LIAISON-001", + ownerId: "SYN-USER-LIAISON-001", + approverId: "SYN-USER-SENIOR-001", + createdAt: monthsAgo(21, "14:10"), + submittedAt: monthsAgo(20, "09:20"), + approvedAt: ROWAN_V1_APPROVED_AT, + reviewDueAt: reviewDueFrom(ROWAN_V1_APPROVED_AT), + revisionReason: "First agreed version, written with Rowan after the December 2024 joint review.", + participationState: "discussed", + consentedSupportPeople: ["Jess Sample — sister, contactable with Rowan's agreement on the day"], + returnedReason: null, + withdrawalReason: null, + withdrawnBy: null, + withdrawnAt: null, + sharedWithPatientAt: null, + content: { + howToApproach: [ + "Introduce yourself by name and role, and say how long you expect the assessment to take.", + "Ask Rowan what they would like to happen before offering options.", + ], + whatHelps: [ + "A quiet space away from the main corridor, offered early rather than after distress has escalated.", + "Telling Rowan the expected wait, and updating them when it changes.", + ], + whatMakesItWorse: [ + "Waiting in the main corridor within sight of the ambulance bay; the noise and passing traffic reliably increase distress.", + "Being asked to repeat the whole history to each new staff member.", + ], + agreedEdApproach: [ + "Agreed with Rowan, the North River CMHT and Dr Taylor Fiction on 20 December 2024.", + "The usual agreed default is assessment in ED and discharge home the same day where that is safe, with a CMHT follow-up call within one working day.", + "Admission remains available whenever the treating team judges it necessary. This plan records the approach that has usually helped and does not set a ceiling on care.", + ], + whatWouldMakeThisDifferent: [ + "New or worsening physical symptoms: chest pain, breathlessness, fever, head injury, seizure, or a fall.", + "Any first presentation of confusion, disorientation, or a change in conscious state, which has not been part of Rowan's usual pattern.", + "A stated plan with means and preparation, or an attempt before arrival. This plan describes usual continuity care and does not apply to that presentation.", + ], + whyThisPlanExists: + "Written so that Rowan does not have to renegotiate the same approach while distressed. It supports continuity and never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", + whatThePersonWants: ["To be told what is happening and roughly how long it will take."], + practicalNeeds: [], + physicalHealthAndMedication: [ + "Documented allergy to penicillin; check the hospital allergy record before prescribing.", + ], + whoElseIsInvolved: ["North River CMHT is the durable service contact."], + reviewTriggers: ["Any change of care coordinator, CMHT, or living arrangements."], + }, + }, + { + id: "SYN-MGMT-VERSION-002", + planId: "SYN-MGMT-PLAN-001", + version: 2, + state: "current", + reviewState: "within_review", + authorId: "SYN-USER-LIAISON-001", + ownerId: "SYN-USER-LIAISON-001", + approverId: "SYN-USER-SENIOR-001", + createdAt: monthsAgo(4, "15:45"), + submittedAt: monthsAgo(3, "08:50"), + approvedAt: ROWAN_V2_APPROVED_AT, + reviewDueAt: reviewDueFrom(ROWAN_V2_APPROVED_AT), + revisionReason: + "Updated at the May 2026 joint review: added the short-stay step and the sensory preferences Rowan asked for.", + participationState: "co_produced", + consentedSupportPeople: ["Jess Sample — sister, contactable with Rowan's agreement on the day"], + returnedReason: null, + withdrawalReason: null, + withdrawnBy: null, + withdrawnAt: null, + sharedWithPatientAt: atPerthTime(addIsoDays(ROWAN_V2_APPROVED_AT, 2), "12:15"), + content: { + howToApproach: [ + "Introduce yourself by name and role, and say how long you expect the assessment to take. Rowan settles quickly when the plan for the next hour is said out loud.", + "Ask Rowan what they would like to happen before offering options. They will usually say directly what they need.", + "Use short sentences and one question at a time, and leave a pause for the answer before adding another question.", + "Rowan uses they/them pronouns. Getting this right early makes the rest of the conversation easier.", + ], + whatHelps: [ + "A quiet, low-stimulus space away from the main corridor, offered early rather than after distress has escalated.", + "A drink, a blanket, and a charged phone. Rowan settles considerably once they can contact their sister Jess.", + "Telling Rowan the expected wait, and updating them when it changes, even when the news is that the wait is longer.", + "Offering the same familiar approach Rowan has already agreed with the North River CMHT, rather than starting a fresh negotiation at the bedside.", + ], + whatMakesItWorse: [ + "Waiting in the main corridor within sight of the ambulance bay. The noise and passing traffic reliably increase distress.", + "Being asked to repeat the whole history to each new staff member. Read this plan and the triage note first, then confirm only what has changed.", + "Security staff standing close by when no safety concern has arisen. Their presence reads as an accusation and makes the conversation harder.", + "Unexplained waits. A wait with a reason attached is tolerable; a silent one is not.", + ], + agreedEdApproach: [ + "Agreed with Rowan, the North River CMHT and Dr Taylor Fiction on 20 May 2026, and confirmed at each review since.", + "The usual agreed default is assessment in ED, a period in the short-stay area where that helps, and discharge home the same day with a CMHT follow-up call within one working day.", + "Admission remains available whenever the treating team judges it necessary. This plan records the approach that has usually helped; it does not set a ceiling on care and does not bind the clinician in front of Rowan.", + "Where the agreed default is not followed, record the reason in the presentation so the CMHT can review it with Rowan.", + ], + whatWouldMakeThisDifferent: [ + "New or worsening physical symptoms: chest pain, breathlessness, fever, head injury, seizure, or a fall. Assess these on their own merits rather than attributing them to distress.", + "Any first presentation of confusion, disorientation, or a change in conscious state. This has not been part of Rowan's usual pattern and needs medical assessment.", + "A stated plan with means and preparation, or an attempt before arrival. This plan describes usual continuity care and does not apply to that presentation.", + "Pregnancy, a medicine started or stopped in the last two weeks, or a suspected overdose of any substance.", + "A safeguarding concern about Rowan, or about a child or dependent adult in their household.", + ], + whyThisPlanExists: + "Rowan has attended North River Hospital ED several times over the last year, usually in the evening and usually after a stretch of poor sleep and a conflict at home. Each attendance has followed a similar course, so Rowan, the North River CMHT and the ED agreed a shared approach that Rowan does not have to renegotiate while distressed. This plan supports continuity. It never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", + whatThePersonWants: [ + "To be told what is happening and roughly how long it will take, rather than being left to guess.", + "To go home the same day where that is safe, with a CMHT call the next working day.", + "To have Jess contacted only with Rowan's agreement on the day, not automatically.", + "To be addressed as Rowan, and referred to as they/them.", + ], + practicalNeeds: [ + "No interpreter needed. When distressed, Rowan takes in written information more easily than spoken instructions, so write the plan down.", + "Sensory: bright overhead lighting and repeated overhead paging are difficult. A side room with the door ajar works better than a closed door.", + "Rowan has asked that peer support be offered when someone is available.", + ], + physicalHealthAndMedication: [ + "Documented allergy to penicillin. Check the hospital allergy record before prescribing anything.", + "Long-standing asthma. Take breathlessness seriously and assess it physically rather than reading it as anxiety.", + "The authoritative medicines record is the hospital medication chart and the community pharmacy record. This plan deliberately holds no doses and no medicines list.", + ], + whoElseIsInvolved: [ + "North River CMHT is the durable service contact. Care coordinator Sam Placeholder, involvement current.", + "General practitioner at a North River practice, involvement current.", + "Jess Sample, sister. Rowan consents to her being contacted when Rowan agrees on the day.", + ], + reviewTriggers: [ + "Two or more presentations where the plan was recorded as not helpful.", + "Any change of care coordinator, CMHT, or living arrangements.", + "A mental-health or medical admission.", + "Rowan asking for the plan to be changed.", + ], + }, + }, + { + id: "SYN-MGMT-VERSION-003", + planId: "SYN-MGMT-PLAN-002", + version: 1, + state: "current", + reviewState: "overdue", + authorId: "SYN-USER-LIAISON-001", + ownerId: "SYN-USER-LIAISON-001", + approverId: "SYN-USER-SENIOR-001", + createdAt: atPerthTime(addIsoDays(MIRA_V1_APPROVED_AT, -12), "11:30"), + submittedAt: atPerthTime(addIsoDays(MIRA_V1_APPROVED_AT, -4), "09:15"), + approvedAt: MIRA_V1_APPROVED_AT, + reviewDueAt: reviewDueFrom(MIRA_V1_APPROVED_AT), + revisionReason: "First agreed version, written after the July 2025 joint review with Mira and her son Daniel.", + participationState: "discussed", + consentedSupportPeople: ["Daniel Example — son, contactable at any time with Mira's agreement"], + returnedReason: null, + withdrawalReason: null, + withdrawnBy: null, + withdrawnAt: null, + sharedWithPatientAt: null, + content: { + howToApproach: [ + "Speak a little more slowly, and check that Mira has her hearing aids in before you start. She will say if she has not.", + "Sit down to talk. Mira finds standing conversations at the bedside hard to follow.", + "Offer to ring her son Daniel early. She is much steadier once he knows where she is.", + ], + whatHelps: [ + "A chair with arms and a warm blanket. Mira is often cold and sore by the time she is seen.", + "Written notes of what has been decided, in large print, so she can read them again later.", + "Contacting the Coastal Plains Older Adult CMHT during their hours; they usually know what has changed recently.", + ], + whatMakesItWorse: [ + "Long waits in the corridor without a chair. Mira's back pain builds and she can no longer concentrate on the assessment.", + "Repeating the whole history to each new clinician. Read this plan and the referral first, then confirm what has changed.", + "Loud overhead paging and bright lights at night, which make it hard for her to follow what is being asked.", + ], + agreedEdApproach: [ + "Agreed with Mira, her son Daniel, and the Coastal Plains Older Adult CMHT on 16 July 2025.", + "The usual agreed default is medical review first, given her falls and her physical health, then mental-health assessment, then home with a CMHT visit within two working days.", + "Admission is available whenever the treating team judges it necessary. This plan records the approach that has usually helped and does not limit the care offered on the day.", + ], + whatWouldMakeThisDifferent: [ + "A fall with a head strike, new limb weakness, slurred speech, or facial droop. Treat as a medical emergency rather than attributing it to distress.", + "New confusion or drowsiness that differs from her usual presentation, which may point to delirium, infection, or a medicine effect.", + "A stated plan to end her life with means or preparation, or an attempt before arrival.", + "Any concern that Mira is not safe at home, including a safeguarding concern about her or about her care.", + ], + whyThisPlanExists: + "Mira is known to the Coastal Plains Older Adult CMHT and has attended Coastal Plains Hospital ED a few times over the past year, usually after a fall or a night of severe pain and poor sleep. The team agreed a shared approach so that the physical and the mental-health assessment happen in a consistent order. This plan supports continuity. It never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", + whatThePersonWants: [ + "To be seen sitting down, and to have Daniel told where she is.", + "To go home the same day where that is safe, with the CMHT visiting rather than telephoning.", + ], + practicalNeeds: [], + physicalHealthAndMedication: [ + "Chronic back pain and a history of falls. Assess pain early; it is often the reason she cannot engage with the mental-health assessment.", + "The authoritative medicines record is the hospital medication chart and her community pharmacy record. This plan holds no doses and no medicines list.", + ], + whoElseIsInvolved: [ + "Coastal Plains Older Adult CMHT is the durable service contact. Care coordinator Devon Sample, involvement current.", + "Daniel Example, son. Mira consents to him being contacted at any time.", + ], + reviewTriggers: [], + }, + }, + { + id: "SYN-MGMT-VERSION-004", + planId: "SYN-MGMT-PLAN-002", + version: 2, + state: "awaiting_approval", + reviewState: null, + authorId: "SYN-USER-LIAISON-001", + ownerId: "SYN-USER-LIAISON-001", + approverId: null, + createdAt: daysAgo(9, "14:05"), + submittedAt: daysAgo(6, "16:10"), + approvedAt: null, + reviewDueAt: addIsoMonths(PROTOTYPE_NOW, REVIEW_INTERVAL_MONTHS), + revisionReason: + "Submitted after the August 2026 presentation: the medical-first order needs stating more plainly, and the CMHT contact details have changed.", + participationState: "patient_unavailable", + consentedSupportPeople: ["Daniel Example — son, contactable at any time with Mira's agreement"], + returnedReason: null, + withdrawalReason: null, + withdrawnBy: null, + withdrawnAt: null, + sharedWithPatientAt: null, + content: { + howToApproach: [ + "Speak a little more slowly, and check that Mira has her hearing aids in before you start.", + "Sit down to talk, and offer to ring her son Daniel early.", + "Say plainly that the physical assessment comes first, so the order does not read as her mental health being dismissed.", + ], + whatHelps: [ + "A chair with arms and a warm blanket, offered on arrival rather than after she asks.", + "Written notes in large print of what has been decided.", + "Analgesia offered early, before the mental-health assessment begins.", + ], + whatMakesItWorse: [ + "Long waits in the corridor without a chair, which build her back pain until she cannot concentrate.", + "Repeating the whole history to each new clinician instead of reading the plan and the referral first.", + "Deferring pain relief until after the mental-health assessment, which has repeatedly made the assessment unusable.", + ], + agreedEdApproach: [ + "Agreed with the Coastal Plains Older Adult CMHT and Dr Taylor Fiction on 14 August 2026. Mira was unwell and unable to take part, so this version is written without her involvement and must be discussed with her at the next contact.", + "The usual agreed default is medical review and analgesia first, then mental-health assessment, then home with a CMHT visit within two working days.", + "Admission is available whenever the treating team judges it necessary. This plan records the approach that has usually helped and does not limit the care offered on the day.", + ], + whatWouldMakeThisDifferent: [ + "A fall with a head strike, new limb weakness, slurred speech, or facial droop. Treat as a medical emergency rather than attributing it to distress.", + "New confusion or drowsiness that differs from her usual presentation, which may point to delirium, infection, or a medicine effect.", + "A stated plan to end her life with means or preparation, or an attempt before arrival.", + "Any concern that Mira is not safe at home, including a safeguarding concern about her or about her care.", + ], + whyThisPlanExists: + "Mira attends Coastal Plains Hospital ED after falls and nights of severe pain and poor sleep. The order of assessment is the part that has repeatedly gone wrong, so this version states it plainly. This plan supports continuity. It never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", + whatThePersonWants: [ + "To be seen sitting down, and to have Daniel told where she is.", + "To have her pain treated before she is asked to talk about how she is coping.", + ], + practicalNeeds: ["Large-print written information. Hearing aids in place before the conversation starts."], + physicalHealthAndMedication: [ + "Chronic back pain and a history of falls. Assess pain early.", + "The authoritative medicines record is the hospital medication chart and her community pharmacy record.", + ], + whoElseIsInvolved: [ + "Coastal Plains Older Adult CMHT is the durable service contact. Care coordinator Devon Sample, involvement current.", + "Daniel Example, son. Mira consents to him being contacted at any time.", + ], + reviewTriggers: [ + "Any presentation where analgesia was not offered before the mental-health assessment.", + "Any change of care coordinator or living arrangements.", + ], + }, + }, + { + id: "SYN-MGMT-VERSION-005", + planId: "SYN-MGMT-PLAN-004", + version: 1, + state: "withdrawn", + reviewState: null, + authorId: "SYN-USER-LIAISON-001", + ownerId: "SYN-USER-LIAISON-001", + approverId: "SYN-USER-SENIOR-001", + createdAt: monthsAgo(17, "10:40"), + submittedAt: monthsAgo(16, "09:05"), + approvedAt: EVELYN_V1_APPROVED_AT, + reviewDueAt: reviewDueFrom(EVELYN_V1_APPROVED_AT), + revisionReason: "First agreed version, written with Evelyn after the April 2025 review.", + participationState: "co_produced", + consentedSupportPeople: [], + returnedReason: null, + withdrawalReason: + "Withdrawn at the July 2026 review. Evelyn's circumstances have changed substantially since this version was agreed, and she asked that it not be used until a new plan is written with her. Superseded content stays readable in history.", + withdrawnBy: "SYN-USER-SENIOR-001", + withdrawnAt: daysAgo(47, "16:20"), + sharedWithPatientAt: monthsAgo(15, "11:00"), + content: { + howToApproach: [ + "Ask Evie how she would like to be addressed; she prefers Evie rather than her full name.", + "Offer a female clinician where one is available, and say when one is not.", + ], + whatHelps: [ + "A room with a door that closes, and a clear statement of who else will come in and when.", + "Being told the plan for the next hour, and being told again when it changes.", + ], + whatMakesItWorse: [ + "Being assessed in a curtained bay where the conversation can be overheard by people waiting nearby.", + "Repeated handovers between staff without the incoming clinician reading the plan first.", + ], + agreedEdApproach: [ + "Agreed with Evelyn, the North River CMHT and Dr Taylor Fiction on 20 April 2025.", + "The usual agreed default was assessment in ED and discharge home the same day where safe, with a CMHT call the next working day.", + "Admission remained available whenever the treating team judged it necessary; this version never set a ceiling on care.", + ], + whatWouldMakeThisDifferent: [ + "Any new physical symptom, head injury, or suspected overdose, which is assessed on its own merits.", + "A stated plan with means and preparation, or an attempt before arrival, which this plan does not cover.", + "Any safeguarding concern about Evelyn or about someone in her household.", + ], + whyThisPlanExists: + "Written so that Evelyn did not have to explain the same preferences at every attendance. It supported continuity and never replaced fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", + whatThePersonWants: ["To be called Evie, and to have privacy for the conversation."], + practicalNeeds: ["A room with a door rather than a curtained bay."], + physicalHealthAndMedication: [ + "The authoritative medicines record is the hospital medication chart and her community pharmacy record.", + ], + whoElseIsInvolved: ["North River CMHT was the durable service contact at the time this version was agreed."], + reviewTriggers: ["Any change of living arrangements or care coordinator."], + }, + }, + { + id: "SYN-MGMT-VERSION-006", + planId: "SYN-MGMT-PLAN-005", + version: 1, + state: "draft", + reviewState: null, + authorId: "SYN-USER-LIAISON-001", + ownerId: "SYN-USER-LIAISON-001", + approverId: null, + createdAt: daysAgo(26, "13:15"), + submittedAt: daysAgo(19, "10:05"), + approvedAt: null, + reviewDueAt: addIsoMonths(PROTOTYPE_NOW, REVIEW_INTERVAL_MONTHS), + revisionReason: "First draft, written after the July 2026 liaison contact.", + participationState: "declined", + consentedSupportPeople: [], + returnedReason: + "Returned for changes on 5 August 2026: Alex declined to take part in writing this, which must be stated in the plan itself rather than only in the metadata, and the agreed ED approach needs to name who agreed it and when.", + withdrawalReason: null, + withdrawnBy: null, + withdrawnAt: null, + sharedWithPatientAt: null, + content: { + howToApproach: [ + "Alex asked not to take part in writing this plan. Say so plainly when you use it, and offer again to write it together.", + "Keep the first conversation short and say what will happen next.", + ], + whatHelps: ["A seat in a quieter area, and a clear time for the next update."], + whatMakesItWorse: [ + "Being left in the waiting room with no update; Alex has left before assessment on two occasions after a long silent wait.", + ], + agreedEdApproach: [ + "Agreed between the North River CMHT and the ED liaison team on 24 July 2026. Alex declined to take part, so this is a service-side agreement only and must be revisited with them.", + "The usual agreed default is assessment in ED with an early update on expected waiting time, then a decision made on the day.", + ], + whatWouldMakeThisDifferent: [ + "Any new physical symptom, head injury, or suspected overdose, which is assessed on its own merits.", + "A stated plan with means and preparation, or an attempt before arrival, which this draft does not cover.", + "Any indication that Alex has nowhere safe to go on discharge, which changes the disposition question entirely.", + ], + whyThisPlanExists: + "A draft written after two presentations that ended before assessment was complete. It is not approved and must not be used as a Current Plan.", + whatThePersonWants: [], + practicalNeeds: [], + physicalHealthAndMedication: [], + whoElseIsInvolved: ["North River CMHT, first contact made; involvement not yet established."], + reviewTriggers: [], + }, + }, +] satisfies readonly ManagementPlanVersion[]; + +export const syntheticPersonalSafetyPlans = [ + { + id: "SYN-SAFETY-PLAN-001", + patientId: "SYN-PATIENT-001", + versionIds: ["SYN-SAFETY-VERSION-001"], + currentVersionId: "SYN-SAFETY-VERSION-001", + }, + { + id: "SYN-SAFETY-PLAN-002", + patientId: "SYN-PATIENT-002", + versionIds: ["SYN-SAFETY-VERSION-002"], + currentVersionId: "SYN-SAFETY-VERSION-002", + }, + { + id: "SYN-SAFETY-PLAN-003", + patientId: "SYN-PATIENT-003", + versionIds: ["SYN-SAFETY-VERSION-003"], + currentVersionId: null, + }, + { + id: "SYN-SAFETY-PLAN-004", + patientId: "SYN-PATIENT-004", + versionIds: ["SYN-SAFETY-VERSION-004"], + currentVersionId: "SYN-SAFETY-VERSION-004", + }, + { + id: "SYN-SAFETY-PLAN-005", + patientId: "SYN-PATIENT-005", + versionIds: [], + currentVersionId: null, + }, +] satisfies readonly PersonalSafetyPlan[]; + +export const syntheticPersonalSafetyPlanVersions = [ + { + id: "SYN-SAFETY-VERSION-001", + planId: "SYN-SAFETY-PLAN-001", + version: 1, + state: "current", + reviewState: "due_soon", + authorId: "SYN-USER-LIAISON-001", + createdAt: ROWAN_SAFETY_CREATED_AT, + confirmedAt: atPerthTime(addIsoDays(ROWAN_SAFETY_CREATED_AT, 1), "09:45"), + reviewDueAt: reviewDueFrom(ROWAN_SAFETY_CREATED_AT), + patientConfirmation: "confirmed", + collaborationNote: "Written with Rowan over two sessions. Rowan chose the wording and asked for a printed copy.", + content: { + warningSigns: [ + "Two or three nights of broken sleep in a row.", + "Not answering messages from Jess for more than a day.", + "Thoughts that everyone would manage better without me.", + ], + saferSurroundings: [ + "Jess keeps the spare medicines at her place.", + "I put my keys and phone charger by the door so leaving the flat is easy.", + ], + reasonsForLiving: ["My nephew.", "The allotment, especially in spring.", "Finishing the course I started."], + selfStrategies: [ + "Cold water on my wrists and face.", + "Walking to the end of the street and back, twice.", + "Putting on the podcast I know well so the room is not silent.", + ], + connectionPeopleAndPlaces: ["The late-opening library.", "The allotment.", "Jess's kitchen."], + personalSupports: [ + { name: "Jess Sample", relationship: "Sister", phone: "0491 570 220" }, + { name: "Ari Placeholder", relationship: "Friend", phone: "0491 570 222" }, + ], + professionalAndEmergencySupport: [ + "North River CMHT, Monday to Friday 8:30 am to 5:00 pm, 0491 570 210.", + "Mental Health Emergency Response Line (MHERL), 1300 555 788, 24 hours. Not an emergency service.", + "In an emergency, call 000.", + ], + }, + }, + { + id: "SYN-SAFETY-VERSION-002", + planId: "SYN-SAFETY-PLAN-002", + version: 1, + state: "current", + reviewState: "within_review", + authorId: "SYN-USER-LIAISON-001", + createdAt: monthsAgo(5, "11:20"), + confirmedAt: null, + reviewDueAt: reviewDueFrom(monthsAgo(5, "11:20")), + patientConfirmation: "discussed_not_confirmed", + collaborationNote: + "Discussed with Mira at the ward review. She wanted to think about it and has not yet confirmed the wording; ask her again at the next contact.", + content: { + warningSigns: ["Pain keeping me awake for several nights.", "Not wanting to answer the phone to Daniel."], + saferSurroundings: ["Daniel holds the spare key and checks in on Sundays."], + reasonsForLiving: ["Daniel and the grandchildren.", "The garden."], + selfStrategies: ["Sitting in the sun on the back step.", "The radio on in the kitchen."], + connectionPeopleAndPlaces: ["The Thursday craft group at the community hall."], + personalSupports: [{ name: "Daniel Example", relationship: "Son", phone: "0491 570 221" }], + professionalAndEmergencySupport: [ + "Coastal Plains Older Adult CMHT, Monday to Friday 8:30 am to 4:30 pm, 0491 570 211.", + "Mental Health Emergency Response Line (MHERL) Peel, 1800 676 822, 24 hours. Not an emergency service.", + "In an emergency, call 000.", + ], + }, + }, + { + id: "SYN-SAFETY-VERSION-003", + planId: "SYN-SAFETY-PLAN-003", + version: 1, + state: "draft", + reviewState: null, + authorId: "SYN-USER-ED-001", + createdAt: daysAgo(5, "23:10"), + confirmedAt: null, + reviewDueAt: null, + patientConfirmation: "unavailable", + collaborationNote: + "Started in ED while Jordan was waiting for transport home. He left before it could be finished, so this stays a draft and is not his plan yet.", + content: { + warningSigns: ["Long stretches awake at night."], + saferSurroundings: [], + reasonsForLiving: [], + selfStrategies: ["Driving out to the river."], + connectionPeopleAndPlaces: [], + personalSupports: [], + professionalAndEmergencySupport: [ + "Wandoo District CMHT, Monday to Friday 8:00 am to 4:30 pm, 0491 570 212.", + "Rurallink, 1800 552 002, 4:30 pm to 8:30 am weeknights and 24 hours at weekends. Not an emergency service.", + "In an emergency, call 000.", + ], + }, + }, + { + id: "SYN-SAFETY-VERSION-004", + planId: "SYN-SAFETY-PLAN-004", + version: 1, + state: "current", + reviewState: "within_review", + authorId: "SYN-USER-LIAISON-001", + createdAt: monthsAgo(2, "15:05"), + confirmedAt: null, + reviewDueAt: reviewDueFrom(monthsAgo(2, "15:05")), + patientConfirmation: "declined", + collaborationNote: + "Evie declined to write a safety plan and asked that the crisis numbers alone be kept on file. That is what this version holds, and it is recorded as her decision rather than as a gap.", + content: { + warningSigns: [], + saferSurroundings: [], + reasonsForLiving: [], + selfStrategies: [], + connectionPeopleAndPlaces: [], + personalSupports: [], + professionalAndEmergencySupport: [ + "North River CMHT, Monday to Friday 8:30 am to 5:00 pm, 0491 570 210.", + "Mental Health Emergency Response Line (MHERL), 1300 555 788, 24 hours. Not an emergency service.", + "In an emergency, call 000.", + ], + }, + }, +] satisfies readonly PersonalSafetyPlanVersion[]; + +export const syntheticEdPresentations = [ + { + id: "SYN-PRESENTATION-001", + patientId: "SYN-PATIENT-001", + arrivedAt: daysAgo(9, "21:40"), + siteId: "SYN-ED-001", + presentingIndication: "Escalating distress after a night without sleep", + assessmentOutcome: "Admitted to the mental health unit for a short period of respite and review", + note: "Came in by taxi late evening after two sleepless nights; agreed with the team that a short admission would help, and was admitted.", + disposition: "mental_health_admission", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Out of hours; message left on the shared mailbox for the morning", + managementPlanVersionId: "SYN-MGMT-VERSION-002", + planAvailability: "available", + planUse: "used", + planHelpfulness: "helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: true, + reviewReason: + "First admission since the plan was agreed. The plan's usual default is same-day discharge, so the team should look at whether it still describes what happens.", + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(9, "23:15"), + }, + { + id: "SYN-PRESENTATION-002", + patientId: "SYN-PATIENT-001", + arrivedAt: daysAgo(34, "19:05"), + siteId: "SYN-ED-001", + presentingIndication: "Distress after a conflict at home", + assessmentOutcome: "Settled with a quiet room and a call to the CMHT; home the same evening", + note: "Arrived upset after an argument at home; settled once moved out of the corridor, and went home with the CMHT to call the next morning.", + disposition: "discharged_home", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Spoke to the duty clinician, who arranged a call for the next working day", + managementPlanVersionId: "SYN-MGMT-VERSION-002", + planAvailability: "available", + planUse: "used", + planHelpfulness: "helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-LIAISON-001", + recordedAt: daysAgo(34, "21:30"), + }, + { + id: "SYN-PRESENTATION-003", + patientId: "SYN-PATIENT-001", + arrivedAt: daysAgo(62, "17:20"), + siteId: "SYN-ED-002", + presentingIndication: "Distress while staying with family away from home", + assessmentOutcome: "Assessed and discharged home the same evening", + note: "Presented while staying with family in the Coastal Plains area; the department did not have the quiet space the plan asks for, so the assessment happened in a curtained bay.", + disposition: "discharged_home", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: "SYN-MGMT-VERSION-002", + planAvailability: "available", + planUse: "partially_used", + planHelpfulness: "mixed", + deviationOccurred: true, + deviationReason: "No side room was free, so the low-stimulus space the plan asks for could not be offered.", + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(62, "20:00"), + }, + { + id: "SYN-PRESENTATION-004", + patientId: "SYN-PATIENT-001", + arrivedAt: daysAgo(108, "22:15"), + siteId: "SYN-ED-001", + presentingIndication: "Distress and thoughts of self-harm", + assessmentOutcome: "Assessed, safety plan reviewed, home the same night", + note: "Late arrival with thoughts of self-harm; the safety plan was reviewed together and Rowan went home with their sister.", + disposition: "discharged_home", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Out of hours; message left on the shared mailbox", + managementPlanVersionId: "SYN-MGMT-VERSION-001", + planAvailability: "available", + planUse: "used", + planHelpfulness: "helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(108, "23:50"), + }, + { + id: "SYN-PRESENTATION-005", + patientId: "SYN-PATIENT-001", + arrivedAt: daysAgo(167, "18:50"), + siteId: "SYN-ED-001", + presentingIndication: "", + assessmentOutcome: "", + note: "Brought in by a friend after a difficult day; settled with a quiet room and went home after two hours.", + disposition: "discharged_home", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: "SYN-MGMT-VERSION-001", + planAvailability: "available", + planUse: "used", + planHelpfulness: "helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(167, "21:05"), + }, + { + id: "SYN-PRESENTATION-006", + patientId: "SYN-PATIENT-001", + arrivedAt: daysAgo(244, "20:10"), + siteId: "SYN-ED-002", + presentingIndication: "Distress after a long wait at another service", + assessmentOutcome: "Assessed and discharged home the same night", + note: "Waited four hours in a corridor before being seen; by then the conversation was very hard and the plan's approach could not be followed.", + disposition: "discharged_home", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: "SYN-MGMT-VERSION-001", + planAvailability: "available", + planUse: "used", + planHelpfulness: "not_helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: true, + reviewReason: + "The plan asks for an early quiet space and regular updates about the wait. Neither happened, and the department should look at whether the plan can be followed at that time of night.", + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(244, "23:40"), + }, + { + id: "SYN-PRESENTATION-007", + patientId: "SYN-PATIENT-001", + arrivedAt: daysAgo(331, "16:35"), + siteId: "SYN-ED-001", + presentingIndication: "Distress after stopping sleep medicine", + assessmentOutcome: "Assessed and discharged home with a CMHT call arranged", + note: "Came in after a week of poor sleep; assessed, reassured, and went home with a CMHT call arranged for the next day.", + disposition: "discharged_home", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Spoke to the duty clinician", + managementPlanVersionId: "SYN-MGMT-VERSION-001", + planAvailability: "available", + planUse: "used", + planHelpfulness: "helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-LIAISON-001", + recordedAt: daysAgo(331, "19:00"), + }, + { + id: "SYN-PRESENTATION-008", + patientId: "SYN-PATIENT-001", + arrivedAt: daysAgo(430, "15:20"), + siteId: "SYN-ED-001", + presentingIndication: "Distress after a bereavement", + assessmentOutcome: "Assessed and discharged home the same afternoon", + note: "Presented in the afternoon after a bereavement; assessed and went home the same afternoon with follow-up arranged.", + disposition: "discharged_home", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Spoke to the duty clinician", + managementPlanVersionId: "SYN-MGMT-VERSION-001", + planAvailability: "available", + planUse: "used", + planHelpfulness: "helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(430, "18:10"), + }, + { + id: "SYN-PRESENTATION-009", + patientId: "SYN-PATIENT-002", + arrivedAt: daysAgo(21, "13:25"), + siteId: "SYN-ED-002", + presentingIndication: "Fall at home with back pain and low mood", + assessmentOutcome: "No fracture on imaging; mental-health assessment deferred until analgesia given", + note: "Fell at home in the morning; the mental-health assessment was attempted before pain relief and had to be repeated later, which Mira found exhausting.", + disposition: "discharged_home", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Spoke to the care coordinator, who arranged a home visit", + managementPlanVersionId: "SYN-MGMT-VERSION-003", + planAvailability: "available", + planUse: "partially_used", + planHelpfulness: "mixed", + deviationOccurred: true, + deviationReason: + "The plan asks for medical review and pain relief first. The mental-health assessment was started before analgesia because the department was busy.", + reviewSuggested: true, + reviewReason: + "The order of assessment is the part of the plan that keeps going wrong. It needs to be stated more plainly, and the CMHT contact details have changed.", + recordedBy: "SYN-USER-LIAISON-001", + recordedAt: daysAgo(21, "17:50"), + }, + { + id: "SYN-PRESENTATION-010", + patientId: "SYN-PATIENT-002", + arrivedAt: daysAgo(75, "10:15"), + siteId: "SYN-ED-002", + presentingIndication: "Severe back pain and several nights without sleep", + assessmentOutcome: "Analgesia given, assessed, and discharged home with a CMHT visit arranged", + note: "Came in by ambulance with severe pain after several sleepless nights; pain relief first, then assessment, then home with a CMHT visit arranged.", + disposition: "discharged_home", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Spoke to the care coordinator", + managementPlanVersionId: "SYN-MGMT-VERSION-003", + planAvailability: "available", + planUse: "used", + planHelpfulness: "helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(75, "14:20"), + }, + { + id: "SYN-PRESENTATION-011", + patientId: "SYN-PATIENT-002", + arrivedAt: daysAgo(190, "09:40"), + siteId: "SYN-ED-002", + presentingIndication: "Fall at home, no injury found", + assessmentOutcome: "Observed for four hours and discharged home", + note: "Fell getting out of bed; observed for four hours, no injury found, and went home with her son.", + disposition: "short_stay", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Left a message on the shared mailbox", + managementPlanVersionId: "SYN-MGMT-VERSION-003", + planAvailability: "available", + planUse: "used", + planHelpfulness: "helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(190, "14:05"), + }, + { + id: "SYN-PRESENTATION-012", + patientId: "SYN-PATIENT-003", + arrivedAt: daysAgo(5, "22:50"), + siteId: "SYN-ED-003", + presentingIndication: "Distress after a long drive, thoughts of self-harm", + assessmentOutcome: "Assessed and discharged home with a Wandoo District CMHT referral", + note: "Drove in from out of town late at night with thoughts of self-harm; assessed, referred to the CMHT, and left before transport home was arranged.", + disposition: "discharged_home", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Out of hours; referral emailed to the shared mailbox", + managementPlanVersionId: null, + planAvailability: "not_applicable", + planUse: "not_applicable", + planHelpfulness: "not_assessed", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(5, "23:55"), + }, + { + id: "SYN-PRESENTATION-013", + patientId: "SYN-PATIENT-003", + arrivedAt: daysAgo(48, "20:05"), + siteId: "SYN-ED-003", + presentingIndication: "", + assessmentOutcome: "", + note: "Arrived in the evening in distress and left after two hours before the assessment was finished.", + disposition: "left_before_completion", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: null, + planAvailability: "not_applicable", + planUse: "not_applicable", + planHelpfulness: "not_assessed", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(48, "22:30"), + }, + { + id: "SYN-PRESENTATION-014", + patientId: "SYN-PATIENT-003", + arrivedAt: daysAgo(121, "18:30"), + siteId: "SYN-ED-003", + presentingIndication: "Alcohol use and low mood", + assessmentOutcome: "Assessed once sober and discharged home the following morning", + note: "Brought in by a neighbour in the evening; assessed once sober the next morning and went home with a follow-up letter.", + disposition: "short_stay", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: null, + planAvailability: "not_applicable", + planUse: "not_applicable", + planHelpfulness: "not_assessed", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(120, "09:10"), + }, + { + id: "SYN-PRESENTATION-015", + patientId: "SYN-PATIENT-003", + arrivedAt: daysAgo(260, "14:45"), + siteId: "SYN-ED-003", + presentingIndication: "Chest pain, later found to be non-cardiac", + assessmentOutcome: "Cardiac cause excluded; distress discussed and follow-up offered", + note: "Came in with chest pain in the afternoon; cardiac causes excluded, and the distress underneath it was discussed before discharge.", + disposition: "discharged_home", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: null, + planAvailability: "not_applicable", + planUse: "not_applicable", + planHelpfulness: "not_assessed", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(260, "19:20"), + }, + { + id: "SYN-PRESENTATION-016", + patientId: "SYN-PATIENT-004", + arrivedAt: daysAgo(16, "11:10"), + siteId: "SYN-ED-001", + presentingIndication: "Distress after a change in housing", + assessmentOutcome: "Assessed and discharged home with a social work referral", + note: "Came in mid-morning after being told she has to move; assessed and referred to social work, and went home the same day.", + disposition: "discharged_home", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Spoke to the duty clinician", + managementPlanVersionId: null, + planAvailability: "unavailable", + planUse: "not_applicable", + planHelpfulness: "not_assessed", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(16, "14:35"), + }, + { + id: "SYN-PRESENTATION-017", + patientId: "SYN-PATIENT-004", + arrivedAt: daysAgo(140, "19:55"), + siteId: "SYN-ED-001", + presentingIndication: "Distress and difficulty sleeping", + assessmentOutcome: "Assessed and discharged home with a CMHT call arranged", + note: "Evening presentation after several difficult weeks; assessed and went home with a CMHT call arranged.", + disposition: "discharged_home", + cmhtContactAttempt: "attempted", + cmhtContactOutcome: "Left a message on the shared mailbox", + managementPlanVersionId: "SYN-MGMT-VERSION-005", + planAvailability: "available", + planUse: "used", + planHelpfulness: "mixed", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-LIAISON-001", + recordedAt: daysAgo(140, "22:15"), + }, + { + id: "SYN-PRESENTATION-018", + patientId: "SYN-PATIENT-005", + arrivedAt: daysAgo(3, "20:25"), + siteId: "SYN-ED-001", + presentingIndication: "", + assessmentOutcome: "", + note: "Arrived in the evening, waited about three hours without an update, and left before the assessment was complete.", + disposition: "left_before_completion", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: null, + planAvailability: "not_applicable", + planUse: "not_applicable", + planHelpfulness: "not_assessed", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(3, "23:40"), + }, +] satisfies readonly EdPresentation[]; + +export const syntheticPresentationAmendments = [ + { + id: "SYN-AMENDMENT-001", + presentationId: "SYN-PRESENTATION-002", + field: "assessmentOutcome", + originalValue: "Settled with a quiet room; home the same evening", + replacementValue: "Settled with a quiet room and a call to the CMHT; home the same evening", + reason: "The CMHT call was made but not recorded at the time, and it is the part the community team needs to see.", + authorId: "SYN-USER-LIAISON-001", + amendedAt: daysAgo(33, "08:45"), + }, +] satisfies readonly PresentationAmendment[]; + +export const syntheticReviewTriggers = [ + { + id: "SYN-TRIGGER-001", + patientId: "SYN-PATIENT-002", + managementPlanId: "SYN-MGMT-PLAN-002", + source: "plan_use_feedback", + sourceId: "SYN-PRESENTATION-009", + reason: + "The plan was available and used, and the clinician recorded that it helped only in part: the assessment order the plan asks for was not followed.", + status: "open", + createdAt: daysAgo(21, "18:05"), + resolvedAt: null, + resolution: null, + }, + { + id: "SYN-TRIGGER-002", + patientId: "SYN-PATIENT-001", + managementPlanId: "SYN-MGMT-PLAN-001", + source: "presentation_outcome", + sourceId: "SYN-PRESENTATION-001", + reason: + "First mental-health admission since this version was agreed. The plan's usual default is same-day discharge, so the team should check whether it still describes what happens.", + status: "open", + createdAt: daysAgo(9, "23:20"), + resolvedAt: null, + resolution: null, + }, + { + id: "SYN-TRIGGER-003", + patientId: "SYN-PATIENT-001", + managementPlanId: "SYN-MGMT-PLAN-001", + source: "plan_use_feedback", + sourceId: "SYN-PRESENTATION-006", + reason: "The plan was recorded as not helpful after a four-hour corridor wait with no updates.", + status: "resolved", + createdAt: daysAgo(244, "23:45"), + resolvedAt: monthsAgo(3, "09:30"), + resolution: + "Discussed at the May 2026 review with Rowan. The version 2 plan now names the early quiet space and the update on waiting time explicitly.", + }, +] satisfies readonly ReviewTrigger[]; + +export const syntheticIdentificationReviews = [ + { + id: "SYN-IDENT-REVIEW-001", + patientId: "SYN-PATIENT-003", + reason: + "Jordan has attended Wandoo District Hospital ED several times over the past year, twice leaving before assessment was complete, and has no coordinated plan. Referred so the team can consider together whether one would help him.", + referredBy: "SYN-USER-ED-001", + referredAt: daysAgo(12, "10:15"), + status: "open", + decision: null, + decisionReason: null, + decidedBy: null, + decidedAt: null, + }, + { + id: "SYN-IDENT-REVIEW-002", + patientId: "SYN-PATIENT-005", + reason: + "Two presentations that ended before assessment was complete. Referred to consider whether coordinated planning would help Alex.", + referredBy: "SYN-USER-LIAISON-001", + referredAt: daysAgo(60, "09:00"), + status: "closed", + decision: "revisit_later", + decisionReason: + "The team agreed a plan may help but that Alex should be asked first, and they declined to take part at this stage. Agreed to revisit after the next liaison contact.", + decidedBy: "SYN-USER-COORD-001", + decidedAt: daysAgo(50, "14:30"), + }, + { + id: "SYN-IDENT-REVIEW-003", + patientId: "SYN-PATIENT-004", + reason: + "Evelyn's plan was withdrawn in July and she has attended once since. Referred so the team can decide whether to write a new plan with her.", + referredBy: "SYN-USER-LIAISON-001", + referredAt: daysAgo(40, "11:45"), + status: "open", + decision: null, + decisionReason: null, + decidedBy: null, + decidedAt: null, + }, +] satisfies readonly IdentificationReview[]; diff --git a/src/components/care-plan/mockups/types.ts b/src/components/care-plan/mockups/types.ts new file mode 100644 index 000000000..16535fb10 --- /dev/null +++ b/src/components/care-plan/mockups/types.ts @@ -0,0 +1,524 @@ +/** + * Care Plan — synthetic prototype domain vocabulary. + * + * Every type here is data only: plain, JSON-serialisable values with no `Date`, + * `Map`, `Set`, class instance, or function member, so the memory-only prototype + * could later gain storage without redesigning the domain. Nothing in this file + * reads a clock, a network, or a browser API. + * + * Australian English, `en-AU`, `Australia/Perth`, ISO source timestamps. + */ + +export type SyntheticId = `SYN-${string}`; + +export type PrototypeRole = + "ed_clinician" | "liaison_clinician" | "cmht_clinician" | "senior_clinician" | "plan_coordinator"; + +export type ManagementPlanVersionState = "draft" | "awaiting_approval" | "current" | "superseded" | "withdrawn"; +export type SafetyPlanVersionState = "draft" | "current" | "superseded"; +export type ReviewState = "within_review" | "due_soon" | "overdue"; +export type ParticipationState = "co_produced" | "discussed" | "declined" | "patient_unavailable"; +export type PatientConfirmationState = "confirmed" | "discussed_not_confirmed" | "declined" | "unavailable"; + +export type Disposition = + | "discharged_home" + | "short_stay" + | "mental_health_admission" + | "medical_admission" + | "transfer" + | "left_before_completion" + | "other"; + +export type PlanAvailability = "available" | "unavailable" | "not_applicable"; +export type PlanUse = "used" | "partially_used" | "not_used" | "not_applicable"; +export type PlanHelpfulness = "helpful" | "mixed" | "not_helpful" | "not_assessed"; + +export type PrototypeScenario = + | "normal" + | "empty" + | "no-current-plan" + | "overdue-plan" + | "withdrawn-plan" + | "unverified-contact" + | "identity-uncertain" + | "version-conflict" + | "offline" + | "permission-unavailable" + | "launch-failure" + | "print-failure"; + +export type ManagementPlanContent = { + // First-minute tier. All five are required before a version can be approved, + // and together they are the entire Current Plan summary card, in this order. + howToApproach: readonly string[]; + whatHelps: readonly string[]; + whatMakesItWorse: readonly string[]; + agreedEdApproach: readonly string[]; + whatWouldMakeThisDifferent: readonly string[]; + // Full-plan tier. Only whyThisPlanExists is required; the rest may be empty + // and render as `Not recorded` rather than being silently omitted. + whyThisPlanExists: string; + whatThePersonWants: readonly string[]; + practicalNeeds: readonly string[]; + physicalHealthAndMedication: readonly string[]; + whoElseIsInvolved: readonly string[]; + reviewTriggers: readonly string[]; +}; + +export const MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS = [ + "howToApproach", + "whatHelps", + "whatMakesItWorse", + "agreedEdApproach", + "whatWouldMakeThisDifferent", + "whyThisPlanExists", +] as const satisfies readonly (keyof ManagementPlanContent)[]; + +export const FIRST_MINUTE_CONTENT_KEYS = [ + "howToApproach", + "whatHelps", + "whatMakesItWorse", + "agreedEdApproach", + "whatWouldMakeThisDifferent", +] as const satisfies readonly (keyof ManagementPlanContent)[]; + +/** Review clock. Default next-review interval and the amber warning window, + * shared by the Management Plan and the Personal Safety Plan. The interval is + * an editable default per version, never an enforced rule. */ +export const REVIEW_INTERVAL_MONTHS = 12; +export const REVIEW_DUE_SOON_DAYS = 28; + +export type SafetyPlanContent = { + warningSigns: readonly string[]; + saferSurroundings: readonly string[]; + reasonsForLiving: readonly string[]; + selfStrategies: readonly string[]; + connectionPeopleAndPlaces: readonly string[]; + personalSupports: readonly { name: string; relationship: string; phone: string }[]; + professionalAndEmergencySupport: readonly string[]; +}; + +export type IdentificationPolicy = { + id: SyntheticId; + status: "pending_governance"; + thresholdCount: null; + thresholdLookbackMonths: null; + manualReferralEnabled: true; + explanation: string; +}; + +export type PrototypeUser = { + id: SyntheticId; + displayName: string; + title: string; + role: PrototypeRole; +}; + +export type Patient = { + id: SyntheticId; + fullName: string; + preferredName: string; + aliases: readonly string[]; + mrn: SyntheticId; + dateOfBirth: string; + ageCohort: "adult" | "older_adult"; + pronouns: string; + homeHealthService: string; + cmhtId: SyntheticId; + managementPlanId: SyntheticId; + personalSafetyPlanId: SyntheticId; +}; + +export type EdSite = { id: SyntheticId; name: string; healthService: string }; + +export type CmhtContact = { + id: SyntheticId; + name: string; + catchment: string; + sharedMailbox: string; + dutyTelephoneDisplay: string; + dutyTelephoneUri: string; + operatingHours: string; + timezone: "Australia/Perth"; + careCoordinator: string | null; + afterHoursLabel: string; + afterHoursTelephoneDisplay: string; + afterHoursTelephoneUri: string; + verifiedAt: string; + verificationState: "verified" | "review_due" | "unverified"; +}; + +/** + * The only intentionally non-fictional contact details in the prototype: the + * verified public crisis lines. Everything else in the fixtures is invented. + */ +export type PublicCrisisContact = { + id: SyntheticId; + name: string; + telephoneDisplay: string; + telephoneUri: string; + coverage: string; + availability: string; + isEmergencyService: boolean; + /** Stated limitation shown wherever the number is shown, or null when none applies. */ + caveat: string | null; + sourceUrl: string; + verifiedOn: string; +}; + +export type PatientPlanVersionState = "draft" | "current" | "superseded"; + +export const PATIENT_PLAN_SECTION_KEYS = [ + "whyWeWroteThis", + "whatMattersToYou", + "whatHelpsYou", + "whatMakesThingsHarder", + "whatWeAgreedWillHappen", + "ifSomethingNewIsHappening", + "whoIsInvolved", + "thingsThatMightHelp", +] as const; + +export type PatientPlanSectionKey = (typeof PATIENT_PLAN_SECTION_KEYS)[number]; + +export type PatientPlanSection = { + key: PatientPlanSectionKey; + heading: string; + /** Converted content. Always empty when `gap` is true — the transformation + * never guesses, so a gap carries no partial text to be mistaken for one. */ + body: readonly string[]; + gap: boolean; + gapReason: string | null; +}; + +export type PatientResourceCategory = + | "care_team" + | "local_service" + | "housing" + | "financial" + | "transport" + | "carer_support" + | "alcohol_and_other_drugs" + | "cultural_or_peer" + | "crisis_contact" + | "self_help_reading"; + +export type PatientResource = { + id: SyntheticId; + category: PatientResourceCategory; + name: string; + detail: string; + contact: string | null; + sourceUrl: string | null; +}; + +export type PatientPlan = { + id: SyntheticId; + patientId: SyntheticId; + versionIds: readonly SyntheticId[]; + currentVersionId: SyntheticId | null; +}; + +export type PatientPlanVersion = { + id: SyntheticId; + planId: SyntheticId; + version: number; + state: PatientPlanVersionState; + derivedFromManagementVersionId: SyntheticId; + sections: readonly PatientPlanSection[]; + resources: readonly PatientResource[]; + approvedBy: SyntheticId | null; + approvedAt: string | null; + createdAt: string; +}; + +export type ManagementPlan = { + id: SyntheticId; + patientId: SyntheticId; + versionIds: readonly SyntheticId[]; + currentVersionId: SyntheticId | null; +}; + +export type ManagementPlanVersion = { + id: SyntheticId; + planId: SyntheticId; + version: number; + state: ManagementPlanVersionState; + reviewState: ReviewState | null; + authorId: SyntheticId; + ownerId: SyntheticId; + approverId: SyntheticId | null; + createdAt: string; + submittedAt: string | null; + approvedAt: string | null; + reviewDueAt: string | null; + revisionReason: string; + participationState: ParticipationState; + consentedSupportPeople: readonly string[]; + returnedReason: string | null; + withdrawalReason: string | null; + withdrawnBy: SyntheticId | null; + withdrawnAt: string | null; + /** When the person was shown their own plan, or null if they have not been. */ + sharedWithPatientAt: string | null; + content: ManagementPlanContent; +}; + +export type PersonalSafetyPlan = { + id: SyntheticId; + patientId: SyntheticId; + versionIds: readonly SyntheticId[]; + currentVersionId: SyntheticId | null; +}; + +export type PersonalSafetyPlanVersion = { + id: SyntheticId; + planId: SyntheticId; + version: number; + state: SafetyPlanVersionState; + reviewState: ReviewState | null; + authorId: SyntheticId; + createdAt: string; + confirmedAt: string | null; + reviewDueAt: string | null; + patientConfirmation: PatientConfirmationState; + collaborationNote: string; + content: SafetyPlanContent; +}; + +export type EdPresentation = { + id: SyntheticId; + patientId: SyntheticId; + arrivedAt: string; + siteId: SyntheticId; + /** Optional detail. Empty string means the recorder did not fill it in; render + * as `Not recorded`, never as an invented or inferred value. */ + presentingIndication: string; + /** Optional detail, as above. */ + assessmentOutcome: string; + /** Required free text: anything worth flagging. May be an empty string only + * when the recorder explicitly had nothing to add. */ + note: string; + disposition: Disposition; + /** Optional detail, as above. */ + cmhtContactAttempt: "not_attempted" | "attempted"; + /** Optional detail, as above. */ + cmhtContactOutcome: string; + managementPlanVersionId: SyntheticId | null; + planAvailability: PlanAvailability; + planUse: PlanUse; + planHelpfulness: PlanHelpfulness; + deviationOccurred: boolean; + deviationReason: string | null; + reviewSuggested: boolean; + reviewReason: string | null; + recordedBy: SyntheticId; + recordedAt: string; +}; + +export type PresentationAmendment = { + id: SyntheticId; + presentationId: SyntheticId; + field: "assessmentOutcome" | "disposition"; + originalValue: string; + replacementValue: string; + reason: string; + authorId: SyntheticId; + amendedAt: string; +}; + +export type ReviewTrigger = { + id: SyntheticId; + patientId: SyntheticId; + managementPlanId: SyntheticId; + source: "plan_use_feedback" | "presentation_outcome" | "plan_deviation" | "formal_review" | "contact_verification"; + sourceId: SyntheticId; + reason: string; + status: "open" | "resolved"; + createdAt: string; + resolvedAt: string | null; + resolution: string | null; +}; + +export type IdentificationDecision = "proceed_to_plan" | "not_needed_now" | "revisit_later"; + +export type IdentificationReview = { + id: SyntheticId; + patientId: SyntheticId; + reason: string; + referredBy: SyntheticId; + referredAt: string; + status: "open" | "closed"; + decision: IdentificationDecision | null; + decisionReason: string | null; + decidedBy: SyntheticId | null; + decidedAt: string | null; +}; + +export type AuditEventType = + | "management_draft_created" + | "management_draft_saved" + | "management_version_submitted" + | "management_version_returned" + | "management_version_approved" + | "management_version_withdrawn" + | "management_review_recorded" + | "presentation_recorded" + | "presentation_amended" + | "safety_plan_draft_created" + | "safety_plan_draft_saved" + | "safety_plan_made_current" + | "safety_plan_print_intent_opened" + | "management_plan_print_intent_opened" + | "management_plan_shared_with_patient" + | "patient_plan_draft_created" + | "patient_plan_draft_saved" + | "patient_plan_approved" + | "patient_plan_print_intent_opened" + | "email_intent_opened" + | "call_intent_opened" + | "identification_review_created" + | "identification_review_closed" + | "cmht_contact_verified" + | "review_trigger_resolved"; + +export type AuditEvent = { + id: SyntheticId; + type: AuditEventType; + patientId: SyntheticId | null; + objectId: SyntheticId; + actorId: SyntheticId; + occurredAt: string; + evidence: string; +}; + +export type PrototypeOutcome = { + kind: "success" | "blocked" | "info" | "error"; + message: string; +}; + +export type CarePlanPrototypeState = { + scenario: PrototypeScenario; + persistence: "memory-only"; + activeUserId: SyntheticId; + selectedPatientId: SyntheticId | null; + connectivity: { online: boolean }; + permission: { available: boolean }; + identity: { certain: boolean }; + versionConflict: { active: boolean }; + users: PrototypeUser[]; + patients: Patient[]; + edSites: EdSite[]; + cmhtContacts: CmhtContact[]; + managementPlans: ManagementPlan[]; + managementPlanVersions: ManagementPlanVersion[]; + personalSafetyPlans: PersonalSafetyPlan[]; + personalSafetyPlanVersions: PersonalSafetyPlanVersion[]; + patientPlans: PatientPlan[]; + patientPlanVersions: PatientPlanVersion[]; + patientResources: PatientResource[]; + edPresentations: EdPresentation[]; + presentationAmendments: PresentationAmendment[]; + reviewTriggers: ReviewTrigger[]; + identificationPolicy: IdentificationPolicy; + identificationReviews: IdentificationReview[]; + auditEvents: AuditEvent[]; + lastOutcome: PrototypeOutcome | null; +}; + +export type ManagementDraftInput = { + ownerId: SyntheticId; + reviewDueAt: string; + revisionReason: string; + participationState: ParticipationState; + consentedSupportPeople: readonly string[]; + content: ManagementPlanContent; +}; + +export type SafetyPlanDraftInput = { + reviewDueAt: string; + patientConfirmation: PatientConfirmationState; + collaborationNote: string; + content: SafetyPlanContent; +}; + +export type NewEdPresentationInput = Omit; + +/** + * Objective Presentation Activity over one explicitly named observation window. + * Counts describe what happened. They never create eligibility, a label, a risk + * state, a severity claim, or a plan — that is the Identification Policy's job, + * and it stays `pending_governance` with no numeric rule. + */ +export type PresentationActivity = { + patientId: SyntheticId; + windowMonths: number; + windowStart: string; + windowEnd: string; + total: number; + bySite: readonly { siteId: SyntheticId; count: number }[]; +}; + +/** The four action worklists on the Reviews route. Worklists, not dashboards: + * they are ordered oldest-actionable-first and never ranked by severity. */ +export type ReviewQueues = { + awaitingApproval: readonly ManagementPlanVersion[]; + reviewSuggested: readonly ReviewTrigger[]; + contactVerification: readonly CmhtContact[]; + identificationReview: readonly IdentificationReview[]; +}; + +export type ReviewQueueSource = { + managementPlanVersions: readonly ManagementPlanVersion[]; + reviewTriggers: readonly ReviewTrigger[]; + cmhtContacts: readonly CmhtContact[]; + identificationReviews: readonly IdentificationReview[]; +}; + +export type PatientSnapshotSource = { + patients: readonly Patient[]; + cmhtContacts: readonly CmhtContact[]; + managementPlans: readonly ManagementPlan[]; + managementPlanVersions: readonly ManagementPlanVersion[]; + personalSafetyPlans: readonly PersonalSafetyPlan[]; + personalSafetyPlanVersions: readonly PersonalSafetyPlanVersion[]; + edPresentations: readonly EdPresentation[]; +}; + +/** Everything the reading surface needs about one patient, assembled once. */ +export type PatientSnapshot = { + patient: Patient; + cmht: CmhtContact | null; + managementPlan: ManagementPlan | null; + currentManagementVersion: ManagementPlanVersion | null; + openManagementDraft: ManagementPlanVersion | null; + /** Set only when there is no Current version and the plan was withdrawn, so a + * withdrawn plan never renders as though the person never had one. */ + withdrawnManagementVersion: ManagementPlanVersion | null; + currentSafetyPlanVersion: PersonalSafetyPlanVersion | null; + reviewState: ReviewState | null; + presentationActivity: PresentationActivity; + /** Reverse-chronological, newest first. */ + presentations: readonly EdPresentation[]; +}; + +/** + * Illustrative responsibilities, not authentication, RBAC, relationship-based + * access, or break-glass evidence. The displayed synthetic role explains why an + * action is offered; nothing here protects data. + */ +export type PrototypeCapability = + | "read_plan" + | "contact_cmht" + | "record_presentation" + | "author_management_draft" + | "submit_management_draft" + | "approve_management_version" + | "withdraw_management_version" + | "record_formal_review" + | "author_safety_plan" + | "approve_patient_plan" + | "verify_cmht_contact" + | "refer_for_identification_review" + | "close_identification_review" + | "manage_worklists"; diff --git a/tests/care-plan-domain.test.ts b/tests/care-plan-domain.test.ts new file mode 100644 index 000000000..6d48567b6 --- /dev/null +++ b/tests/care-plan-domain.test.ts @@ -0,0 +1,615 @@ +import { describe, expect, it } from "vitest"; + +import { + BANNED_ADMISSION_CONSTRUCTIONS, + PRESENTATION_ACTIVITY_WINDOW_MONTHS, + assertSingleCurrentVersion, + buildCmhtMailto, + buildCmhtTel, + buildPatientSnapshot, + canPerformAction, + countPresentationActivity, + deriveReviewState, + getCurrentManagementPlanVersion, + getCurrentSafetyPlanVersion, + getOpenManagementDraft, + getPatientById, + getReviewQueues, + searchPatients, +} from "@/components/care-plan/mockups/domain"; +import { + PROTOTYPE_NOW, + identificationPolicy, + publicCrisisContacts, + syntheticCmhtContacts, + syntheticEdPresentations, + syntheticEdSites, + syntheticIdentificationReviews, + syntheticManagementPlanVersions, + syntheticManagementPlans, + syntheticPatients, + syntheticPersonalSafetyPlanVersions, + syntheticPersonalSafetyPlans, + syntheticPresentationAmendments, + syntheticReviewTriggers, + syntheticUsers, +} from "@/components/care-plan/mockups/fixtures"; +import { + FIRST_MINUTE_CONTENT_KEYS, + MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS, + type EdPresentation, + type ManagementPlanContent, + type ManagementPlanVersion, + type PatientSnapshotSource, + type PrototypeRole, + type ReviewQueues, +} from "@/components/care-plan/mockups/types"; + +const fixtureBundle = { + syntheticUsers, + syntheticPatients, + syntheticEdSites, + syntheticCmhtContacts, + syntheticManagementPlans, + syntheticManagementPlanVersions, + syntheticPersonalSafetyPlans, + syntheticPersonalSafetyPlanVersions, + syntheticEdPresentations, + syntheticPresentationAmendments, + syntheticReviewTriggers, + syntheticIdentificationReviews, + identificationPolicy, + publicCrisisContacts, +}; + +const snapshotSource: PatientSnapshotSource = { + patients: syntheticPatients, + cmhtContacts: syntheticCmhtContacts, + managementPlans: syntheticManagementPlans, + managementPlanVersions: syntheticManagementPlanVersions, + personalSafetyPlans: syntheticPersonalSafetyPlans, + personalSafetyPlanVersions: syntheticPersonalSafetyPlanVersions, + edPresentations: syntheticEdPresentations, +}; + +const serialisedFixtures = JSON.stringify(fixtureBundle); + +function collectStrings(value: unknown, keyFilter: (key: string) => boolean, into: string[], key = ""): void { + if (typeof value === "string") { + if (keyFilter(key)) into.push(value); + return; + } + if (Array.isArray(value)) { + for (const entry of value) collectStrings(entry, keyFilter, into, key); + return; + } + if (value !== null && typeof value === "object") { + for (const [childKey, childValue] of Object.entries(value)) { + collectStrings(childValue, keyFilter, into, childKey); + } + } +} + +describe("Care Plan identification policy", () => { + it("keeps identification policy governance-pending without a numeric rule", () => { + expect(identificationPolicy).toEqual({ + id: "SYN-IDENTIFICATION-POLICY-001", + status: "pending_governance", + thresholdCount: null, + thresholdLookbackMonths: null, + manualReferralEnabled: true, + explanation: expect.stringMatching(/local clinical and privacy governance/i), + }); + }); +}); + +describe("Care Plan patient search", () => { + it.each([ + ["Rowan", "SYN-PATIENT-001"], + ["SYN-MRN-0001", "SYN-PATIENT-001"], + ["1986-04-12", "SYN-PATIENT-001"], + ["Ro", "SYN-PATIENT-001"], + ])("finds a patient by supported synthetic identity field", (query, patientId) => { + expect(searchPatients(syntheticPatients, query).map(({ id }) => id)).toContain(patientId); + }); + + it("matches the Australian display date of birth and a recorded alias", () => { + expect(searchPatients(syntheticPatients, "12/04/1986").map(({ id }) => id)).toEqual(["SYN-PATIENT-001"]); + expect(searchPatients(syntheticPatients, "Ro Sample").map(({ id }) => id)).toEqual(["SYN-PATIENT-001"]); + }); + + it("trims and case-folds the query before matching", () => { + expect(searchPatients(syntheticPatients, " rOwAn ").map(({ id }) => id)).toEqual(["SYN-PATIENT-001"]); + }); + + it("returns nothing for an empty query rather than the whole directory", () => { + expect(searchPatients(syntheticPatients, " ")).toEqual([]); + }); + + it("never searches plan, presentation, safety-plan or other clinical text", () => { + const rowan = getPatientById(syntheticPatients, "SYN-PATIENT-001"); + expect(rowan?.fullName).toBe("Rowan Sample"); + + const currentVersion = getCurrentManagementPlanVersion(syntheticManagementPlanVersions, "SYN-MGMT-PLAN-001"); + const clinicalPhrase = currentVersion?.content.whatHelps[0]?.split(" ").slice(0, 3).join(" ") ?? ""; + expect(clinicalPhrase.length).toBeGreaterThan(4); + expect(searchPatients(syntheticPatients, clinicalPhrase)).toEqual([]); + + expect(searchPatients(syntheticPatients, "corridor")).toEqual([]); + expect(searchPatients(syntheticPatients, "North River Hospital ED")).toEqual([]); + }); + + it("returns null for an unknown patient identifier", () => { + expect(getPatientById(syntheticPatients, "SYN-PATIENT-404")).toBeNull(); + }); +}); + +describe("Care Plan management plan version selection", () => { + it("keeps exactly one Current version per plan", () => { + expect(() => assertSingleCurrentVersion(syntheticManagementPlanVersions)).not.toThrow(); + + const current = syntheticManagementPlanVersions.find(({ state }) => state === "current"); + expect(current).toBeDefined(); + const duplicated: ManagementPlanVersion[] = [ + current as ManagementPlanVersion, + { ...(current as ManagementPlanVersion), id: "SYN-MGMT-VERSION-999" }, + ]; + expect(() => assertSingleCurrentVersion(duplicated)).toThrow(/more than one current version/i); + }); + + it("keeps a submitted draft separate from the Current version it would replace", () => { + const current = getCurrentManagementPlanVersion(syntheticManagementPlanVersions, "SYN-MGMT-PLAN-002"); + const open = getOpenManagementDraft(syntheticManagementPlanVersions, "SYN-MGMT-PLAN-002"); + + expect(current?.state).toBe("current"); + expect(open?.state).toBe("awaiting_approval"); + expect(open?.id).not.toBe(current?.id); + expect(open?.version).toBeGreaterThan(current?.version ?? 0); + }); + + it("reports no Current version for a plan whose only version was withdrawn", () => { + expect(getCurrentManagementPlanVersion(syntheticManagementPlanVersions, "SYN-MGMT-PLAN-004")).toBeNull(); + expect(getOpenManagementDraft(syntheticManagementPlanVersions, "SYN-MGMT-PLAN-004")).toBeNull(); + }); + + it("returns the Current Personal Safety Plan version independently of management approval", () => { + const safety = getCurrentSafetyPlanVersion(syntheticPersonalSafetyPlanVersions, "SYN-SAFETY-PLAN-004"); + expect(safety?.state).toBe("current"); + expect(getCurrentManagementPlanVersion(syntheticManagementPlanVersions, "SYN-MGMT-PLAN-004")).toBeNull(); + + expect(getCurrentSafetyPlanVersion(syntheticPersonalSafetyPlanVersions, "SYN-SAFETY-PLAN-003")).toBeNull(); + }); +}); + +describe("Care Plan review clock", () => { + const reviewDueAt = "2026-09-01T09:00:00+08:00"; + + it.each([ + ["2026-08-04T08:59:59.999+08:00", "within_review"], + ["2026-08-04T09:00:00+08:00", "due_soon"], + ["2026-09-01T09:00:00+08:00", "due_soon"], + ["2026-09-01T09:00:00.001+08:00", "overdue"], + ])("pins the review-state boundaries at %s", (now, expected) => { + expect(deriveReviewState(reviewDueAt, now)).toBe(expected); + }); + + it("uses the stored review state that the review clock derives for every Current version", () => { + const currentVersions = syntheticManagementPlanVersions.filter(({ state }) => state === "current"); + expect(currentVersions.length).toBeGreaterThan(1); + + for (const version of currentVersions) { + expect(version.reviewDueAt).not.toBeNull(); + expect(version.reviewState).toBe(deriveReviewState(version.reviewDueAt as string, PROTOTYPE_NOW)); + } + + expect(currentVersions.map(({ reviewState }) => reviewState).sort()).toEqual(["overdue", "within_review"]); + }); + + it("covers a due-soon review state somewhere in the fixture set", () => { + const safetyStates = syntheticPersonalSafetyPlanVersions + .filter(({ reviewDueAt: due }) => due !== null) + .map(({ reviewDueAt: due }) => deriveReviewState(due as string, PROTOTYPE_NOW)); + const managementStates = syntheticManagementPlanVersions + .filter(({ reviewDueAt: due }) => due !== null) + .map(({ reviewDueAt: due }) => deriveReviewState(due as string, PROTOTYPE_NOW)); + + expect([...safetyStates, ...managementStates]).toContain("due_soon"); + }); +}); + +describe("Care Plan presentation activity", () => { + it("derives Rowan's rolling twelve-month count from presentation timestamps alone", () => { + const activity = countPresentationActivity(syntheticEdPresentations, "SYN-PATIENT-001", PROTOTYPE_NOW); + const allRowanPresentations = syntheticEdPresentations.filter(({ patientId }) => patientId === "SYN-PATIENT-001"); + + expect(activity.total).toBe(7); + expect(activity.windowMonths).toBe(PRESENTATION_ACTIVITY_WINDOW_MONTHS); + expect(activity.windowStart).toBe("2025-08-20T14:30:00+08:00"); + expect(activity.windowEnd).toBe(PROTOTYPE_NOW); + expect(allRowanPresentations.length).toBe(8); + expect(activity.bySite.reduce((sum, { count }) => sum + count, 0)).toBe(activity.total); + }); + + it("excludes a presentation that sits exactly on the window start", () => { + const template = syntheticEdPresentations[0] as EdPresentation; + const onBoundary: EdPresentation = { + ...template, + id: "SYN-PRESENTATION-901", + patientId: "SYN-PATIENT-001", + arrivedAt: "2025-08-20T14:30:00+08:00", + }; + const justInside: EdPresentation = { + ...onBoundary, + id: "SYN-PRESENTATION-902", + arrivedAt: "2025-08-20T14:30:00.001+08:00", + }; + + expect(countPresentationActivity([onBoundary], "SYN-PATIENT-001", PROTOTYPE_NOW).total).toBe(0); + expect(countPresentationActivity([justInside], "SYN-PATIENT-001", PROTOTYPE_NOW).total).toBe(1); + }); + + it("counts only the named patient's own episodes", () => { + expect(countPresentationActivity(syntheticEdPresentations, "SYN-PATIENT-404", PROTOTYPE_NOW).total).toBe(0); + }); +}); + +describe("Care Plan contact actions", () => { + it("builds a generic CMHT email intent without patient information", () => { + const contact = syntheticCmhtContacts[0]!; + const href = buildCmhtMailto(contact); + expect(href).toBe("mailto:north-river.cmht@example.org?subject=ED+Care+Plans+%E2%80%94+team+contact+request"); + expect(href).not.toMatch(/Rowan|SYN-MRN|1986|presentation|management plan/i); + }); + + it("carries no patient field in any contact URI it can build", () => { + const patientFields = syntheticPatients.flatMap((patient) => [ + patient.fullName, + patient.preferredName, + patient.mrn, + patient.dateOfBirth, + ...patient.aliases, + ]); + + for (const contact of syntheticCmhtContacts) { + const uris = [buildCmhtMailto(contact), buildCmhtTel(contact), buildCmhtTel(contact, "after_hours")]; + for (const uri of uris) { + for (const field of patientFields) { + expect(uri.toLowerCase()).not.toContain(field.toLowerCase()); + } + } + } + }); + + it("builds telephone intents from the displayed duty and after-hours numbers", () => { + const contact = syntheticCmhtContacts[0]!; + expect(buildCmhtTel(contact)).toBe(`tel:${contact.dutyTelephoneUri}`); + expect(buildCmhtTel(contact, "after_hours")).toBe(`tel:${contact.afterHoursTelephoneUri}`); + expect(buildCmhtTel(contact)).toMatch(/^tel:\+?\d+$/); + }); + + it("publishes only the authorised public crisis contacts", () => { + expect( + publicCrisisContacts.map(({ name, telephoneDisplay, isEmergencyService }) => ({ + name, + telephoneDisplay, + isEmergencyService, + })), + ).toEqual([ + { name: "Emergency services", telephoneDisplay: "000", isEmergencyService: true }, + { + name: "Mental Health Emergency Response Line (MHERL) — Perth metropolitan", + telephoneDisplay: "1300 555 788", + isEmergencyService: false, + }, + { + name: "Mental Health Emergency Response Line (MHERL) — Peel", + telephoneDisplay: "1800 676 822", + isEmergencyService: false, + }, + { name: "Rurallink", telephoneDisplay: "1800 552 002", isEmergencyService: false }, + ]); + + for (const contact of publicCrisisContacts) { + expect(contact.verifiedOn).toBe("2026-08-20"); + expect(contact.sourceUrl).toMatch(/^https:\/\//); + if (contact.name.includes("MHERL")) { + expect(contact.caveat).toMatch(/not an emergency service/i); + } + } + }); + + it("uses only fictional Australian numbers and the authorised public crisis lines", () => { + const authorisedNationalNumbers = new Set([ + "0491570210", + "0491570211", + "0491570212", + "0491570220", + "0491570221", + "0491570222", + "1300555788", + "1800676822", + "1800552002", + ]); + const matches = serialisedFixtures.match(/\+61[\d\s]{6,}|\b0[2-9][\s\d]{7,}|\b1[38]00[\s\d]{5,}/g) ?? []; + expect(matches.length).toBeGreaterThan(5); + + for (const match of matches) { + const national = match.replace(/\s/g, "").replace(/^\+61/, "0"); + expect(authorisedNationalNumbers).toContain(national); + } + }); +}); + +describe("Care Plan review queues", () => { + const buildQueues = (): ReviewQueues => + getReviewQueues({ + managementPlanVersions: syntheticManagementPlanVersions, + reviewTriggers: syntheticReviewTriggers, + cmhtContacts: syntheticCmhtContacts, + identificationReviews: syntheticIdentificationReviews, + }); + + it("returns exactly the four action worklists", () => { + expect(Object.keys(buildQueues())).toEqual([ + "awaitingApproval", + "reviewSuggested", + "contactVerification", + "identificationReview", + ]); + }); + + it("lists only submitted versions awaiting a senior decision", () => { + expect(buildQueues().awaitingApproval.map(({ id }) => id)).toEqual(["SYN-MGMT-VERSION-004"]); + }); + + it("orders open review triggers oldest-first and never by apparent severity", () => { + const { reviewSuggested } = buildQueues(); + expect(reviewSuggested.map(({ id }) => id)).toEqual(["SYN-TRIGGER-001", "SYN-TRIGGER-002"]); + expect(reviewSuggested.every(({ status }) => status === "open")).toBe(true); + + const resolved = syntheticReviewTriggers.find(({ id }) => id === "SYN-TRIGGER-003"); + expect(resolved?.status).toBe("resolved"); + expect(Date.parse(resolved?.createdAt ?? "")).toBeLessThan(Date.parse(reviewSuggested[0]?.createdAt ?? "")); + + const severeSounding = reviewSuggested.find(({ source }) => source === "presentation_outcome"); + expect(severeSounding?.id).toBe("SYN-TRIGGER-002"); + }); + + it("lists only teams whose contact details still need verification, oldest verification first", () => { + const { contactVerification } = buildQueues(); + expect(contactVerification.map(({ id }) => id)).toEqual(["SYN-CMHT-003", "SYN-CMHT-002"]); + expect(contactVerification.every(({ verificationState }) => verificationState !== "verified")).toBe(true); + }); + + it("lists only open identification referrals, oldest first", () => { + const { identificationReview } = buildQueues(); + expect(identificationReview.map(({ id }) => id)).toEqual(["SYN-IDENT-REVIEW-003", "SYN-IDENT-REVIEW-001"]); + expect(identificationReview.every(({ status }) => status === "open")).toBe(true); + }); +}); + +describe("Care Plan role capabilities", () => { + const clinicalRoles: PrototypeRole[] = ["ed_clinician", "liaison_clinician", "cmht_clinician", "senior_clinician"]; + + it("restricts approval and withdrawal to the named senior clinician", () => { + expect(canPerformAction("senior_clinician", "approve_management_version")).toBe(true); + expect(canPerformAction("senior_clinician", "withdraw_management_version")).toBe(true); + + for (const role of ["ed_clinician", "liaison_clinician", "cmht_clinician", "plan_coordinator"] as PrototypeRole[]) { + expect(canPerformAction(role, "approve_management_version")).toBe(false); + expect(canPerformAction(role, "withdraw_management_version")).toBe(false); + } + }); + + it("opens Personal Safety Plan authorship to every clinical role and no one else", () => { + for (const role of clinicalRoles) { + expect(canPerformAction(role, "author_safety_plan")).toBe(true); + } + expect(canPerformAction("plan_coordinator", "author_safety_plan")).toBe(false); + }); + + it("lets every role read a plan and reach the team, and keeps recording clinical", () => { + for (const role of [...clinicalRoles, "plan_coordinator"] as PrototypeRole[]) { + expect(canPerformAction(role, "read_plan")).toBe(true); + expect(canPerformAction(role, "contact_cmht")).toBe(true); + } + expect(canPerformAction("ed_clinician", "record_presentation")).toBe(true); + expect(canPerformAction("plan_coordinator", "record_presentation")).toBe(false); + expect(canPerformAction("ed_clinician", "author_management_draft")).toBe(false); + expect(canPerformAction("liaison_clinician", "author_management_draft")).toBe(true); + }); + + it("gives the non-clinical coordinator the worklists without clinical authorship", () => { + expect(canPerformAction("plan_coordinator", "manage_worklists")).toBe(true); + expect(canPerformAction("plan_coordinator", "close_identification_review")).toBe(true); + expect(canPerformAction("plan_coordinator", "approve_patient_plan")).toBe(false); + }); +}); + +describe("Care Plan patient snapshot", () => { + it("assembles the reading surface for a patient with a Current Plan", () => { + const snapshot = buildPatientSnapshot(snapshotSource, "SYN-PATIENT-001", PROTOTYPE_NOW); + + expect(snapshot?.patient.fullName).toBe("Rowan Sample"); + expect(snapshot?.cmht?.id).toBe("SYN-CMHT-001"); + expect(snapshot?.currentManagementVersion?.state).toBe("current"); + expect(snapshot?.openManagementDraft).toBeNull(); + expect(snapshot?.withdrawnManagementVersion).toBeNull(); + expect(snapshot?.reviewState).toBe("within_review"); + expect(snapshot?.currentSafetyPlanVersion?.state).toBe("current"); + expect(snapshot?.presentationActivity.total).toBe(7); + expect(snapshot?.presentations.map(({ arrivedAt }) => arrivedAt)).toEqual( + [...(snapshot?.presentations ?? [])].map(({ arrivedAt }) => arrivedAt).sort((a, b) => (a < b ? 1 : -1)), + ); + }); + + it("distinguishes a withdrawn plan from a patient who never had one", () => { + const withdrawn = buildPatientSnapshot(snapshotSource, "SYN-PATIENT-004", PROTOTYPE_NOW); + expect(withdrawn?.currentManagementVersion).toBeNull(); + expect(withdrawn?.withdrawnManagementVersion?.state).toBe("withdrawn"); + expect(withdrawn?.withdrawnManagementVersion?.withdrawalReason).toMatch(/\S/); + expect(withdrawn?.withdrawnManagementVersion?.withdrawnBy).toBe("SYN-USER-SENIOR-001"); + expect(withdrawn?.reviewState).toBeNull(); + + const neverHadOne = buildPatientSnapshot(snapshotSource, "SYN-PATIENT-003", PROTOTYPE_NOW); + expect(neverHadOne?.currentManagementVersion).toBeNull(); + expect(neverHadOne?.withdrawnManagementVersion).toBeNull(); + expect(neverHadOne?.presentationActivity.total).toBeGreaterThan(0); + }); + + it("returns null for a patient the prototype does not hold", () => { + expect(buildPatientSnapshot(snapshotSource, "SYN-PATIENT-404", PROTOTYPE_NOW)).toBeNull(); + }); +}); + +describe("Care Plan fixture safety", () => { + it("prefixes every synthetic identifier with SYN- and never repeats one", () => { + const identifiers: string[] = []; + collectStrings(fixtureBundle, (key) => /(^id$|Id$|Ids$|By$)/.test(key), identifiers); + expect(identifiers.length).toBeGreaterThan(50); + for (const identifier of identifiers) { + expect(identifier).toMatch(/^SYN-/); + } + + const primaryIds: string[] = []; + collectStrings(fixtureBundle, (key) => key === "id", primaryIds); + expect(new Set(primaryIds).size).toBe(primaryIds.length); + }); + + it("fills every required content key on every Current version", () => { + const currentVersions = syntheticManagementPlanVersions.filter(({ state }) => state === "current"); + + for (const version of currentVersions) { + for (const key of MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS) { + const value = version.content[key]; + if (typeof value === "string") { + expect(value.trim()).not.toBe(""); + } else { + expect(value.length).toBeGreaterThan(0); + } + } + } + }); + + it("leaves at least two optional full-plan keys empty so the Not recorded path is exercised", () => { + const optionalKeys = [ + "whatThePersonWants", + "practicalNeeds", + "physicalHealthAndMedication", + "whoElseIsInvolved", + "reviewTriggers", + ] as const; + + const emptyCounts = syntheticManagementPlanVersions + .filter(({ state }) => state === "current") + .map((version) => optionalKeys.filter((key) => (version.content[key] as readonly string[]).length === 0).length); + + expect(Math.max(...emptyCounts)).toBeGreaterThanOrEqual(2); + }); + + it("never writes a prohibitive admission construction into an agreed ED approach", () => { + expect(BANNED_ADMISSION_CONSTRUCTIONS.length).toBeGreaterThan(3); + + const badExample = "The team agreed she should not be admitted for this presentation."; + expect(BANNED_ADMISSION_CONSTRUCTIONS.some((phrase) => badExample.toLowerCase().includes(phrase))).toBe(true); + + for (const version of syntheticManagementPlanVersions) { + const agreed = version.content.agreedEdApproach.join(" ").toLowerCase(); + for (const phrase of BANNED_ADMISSION_CONSTRUCTIONS) { + expect(agreed).not.toContain(phrase); + } + } + }); + + it("names who agreed the ED approach and when, on every version that has one", () => { + for (const version of syntheticManagementPlanVersions) { + if (version.content.agreedEdApproach.length === 0) continue; + const agreed = version.content.agreedEdApproach.join(" "); + expect(agreed).toMatch(/agreed (with|by|at|on)|agreed .* on \d{1,2} \w+ 20\d{2}/i); + expect(agreed).toMatch(/20\d{2}/); + } + }); + + it("keeps every fixture free of stigmatising or utilisation labels", () => { + const bannedLabels = [ + "frequent flyer", + "frequent presenter", + "frequent-presenter", + "high utiliser", + "high utilizer", + "problem patient", + "difficult patient", + "drug seeking", + "drug-seeking", + "attention seeking", + "attention-seeking", + "manipulative", + "malingering", + "compliance", + "compliant", + "bed blocker", + "risk score", + "chief complaint", + "next of kin", + ]; + const haystack = serialisedFixtures.toLowerCase(); + + for (const label of bannedLabels) { + expect(haystack).not.toContain(label); + } + }); + + it("writes What makes it worse about the service rather than about the person", () => { + const currentVersions = syntheticManagementPlanVersions.filter(({ state }) => state === "current"); + expect(currentVersions.length).toBeGreaterThan(0); + + for (const version of currentVersions) { + const worse = version.content.whatMakesItWorse; + expect(worse.length).toBeGreaterThan(0); + expect(worse.join(" ")).toMatch(/corridor|wait|history|security|handover|noise|light|room|staff|department/i); + for (const line of worse) { + expect(line).not.toMatch(/\b(he|she|they) (is|are|becomes|gets) (aggressive|demanding|difficult|hostile)\b/i); + } + } + }); + + it("names concrete new findings in What would make this different, not generic caution", () => { + for (const version of syntheticManagementPlanVersions) { + const boundary = version.content.whatWouldMakeThisDifferent; + if (boundary.length === 0) continue; + expect(boundary.length).toBeGreaterThanOrEqual(3); + for (const line of boundary) { + expect(line.length).toBeGreaterThan(25); + expect(line).not.toMatch(/^\s*(use|exercise|apply)\s+(clinical\s+)?(judgement|judgment|caution)\s*\.?\s*$/i); + } + } + }); + + it("orders the first-minute keys exactly as the summary card renders them", () => { + expect(FIRST_MINUTE_CONTENT_KEYS).toEqual([ + "howToApproach", + "whatHelps", + "whatMakesItWorse", + "agreedEdApproach", + "whatWouldMakeThisDifferent", + ]); + expect(MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS).toEqual([...FIRST_MINUTE_CONTENT_KEYS, "whyThisPlanExists"]); + + const contentKeys: (keyof ManagementPlanContent)[] = [ + "howToApproach", + "whatHelps", + "whatMakesItWorse", + "agreedEdApproach", + "whatWouldMakeThisDifferent", + "whyThisPlanExists", + "whatThePersonWants", + "practicalNeeds", + "physicalHealthAndMedication", + "whoElseIsInvolved", + "reviewTriggers", + ]; + for (const version of syntheticManagementPlanVersions) { + expect(Object.keys(version.content)).toEqual(contentKeys); + } + }); +}); From e5b3f4c123a08d7e50316a2d40df1a1234c898a8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:14:15 +0800 Subject: [PATCH 05/56] fix(care-plan): apply product rename and widen amendable fields Rename the CMHT contact subject to "Care Plan - team contact request", widen PresentationAmendment.field to the spec's amendable set via a new AmendableField union, and use the verified Rurallink source URL. Adds a plan-use amendment fixture and a covering test so the widened union is exercised at runtime, not only in types. Co-Authored-By: Claude Opus 5 --- src/components/care-plan/mockups/domain.ts | 2 +- src/components/care-plan/mockups/fixtures.ts | 14 ++++++++++- src/components/care-plan/mockups/types.ts | 8 ++++++- tests/care-plan-domain.test.ts | 25 +++++++++++++++++++- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/components/care-plan/mockups/domain.ts b/src/components/care-plan/mockups/domain.ts index 5ef99d52c..a979b5453 100644 --- a/src/components/care-plan/mockups/domain.ts +++ b/src/components/care-plan/mockups/domain.ts @@ -161,7 +161,7 @@ const CAPABILITIES_BY_ROLE: Record { it("builds a generic CMHT email intent without patient information", () => { const contact = syntheticCmhtContacts[0]!; const href = buildCmhtMailto(contact); - expect(href).toBe("mailto:north-river.cmht@example.org?subject=ED+Care+Plans+%E2%80%94+team+contact+request"); + expect(href).toBe("mailto:north-river.cmht@example.org?subject=Care+Plan+%E2%80%94+team+contact+request"); expect(href).not.toMatch(/Rowan|SYN-MRN|1986|presentation|management plan/i); }); @@ -585,6 +585,29 @@ describe("Care Plan fixture safety", () => { } }); + it("records one attributed amendment per amendable field, matching the corrected record", () => { + const amendableFields = [ + "assessmentOutcome", + "disposition", + "note", + "planAvailability", + "planUse", + "planHelpfulness", + ]; + expect(syntheticPresentationAmendments.length).toBeGreaterThan(1); + expect(syntheticPresentationAmendments.map(({ field }) => field)).toContain("planHelpfulness"); + + for (const amendment of syntheticPresentationAmendments) { + expect(amendableFields).toContain(amendment.field); + + const presentation = syntheticEdPresentations.find(({ id }) => id === amendment.presentationId); + expect(presentation).toBeDefined(); + expect(String(presentation?.[amendment.field])).toBe(amendment.replacementValue); + expect(amendment.originalValue).not.toBe(amendment.replacementValue); + expect(amendment.reason.trim()).not.toBe(""); + } + }); + it("orders the first-minute keys exactly as the summary card renders them", () => { expect(FIRST_MINUTE_CONTENT_KEYS).toEqual([ "howToApproach", From 3423449d24bc1e66192cb8eca955fe8c1987e807 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:25:54 +0800 Subject: [PATCH 06/56] docs(care-plan): fix four defects the Task 1 implementer surfaced All four are controller mistakes in the planning documents, not in the implementation. The implementer followed the plan verbatim, which is why they showed up as working code rather than as questions. 1. The product rename missed the URL-encoded form. Replacing "ED Care Plans" and "ed-care-plans" left "ED+Care+Plans" untouched inside a mailto subject that Task 1's brief pinned in a verbatim test. The generic contact subject is now "Care Plan - team contact request". 2. PresentationAmendment.field contradicted the spec. 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. Adds AmendableField with all six values; the two amend-presentation action variants collapse to one carrying a string replacement, and the reducer will validate that a disposition replacement parses. The plan-use answers group in the UI at Task 7, but each changed answer still records its own attributed amendment, so stored evidence stays one field per record. 3. The rename over-reached and rewrote real identifiers. It rewrote this worktree's path, this branch name, the superseded Codex worktree path and codex/ed-care-plans, so the plan's first Global Constraint sent a future implementer to 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. 4. CLAUDE-START-HERE.md carried a stale copy/paste prompt naming nine tasks and the superseded Codex branch. Replaced with a resume prompt that points at the SDD ledger and states the eleven-task shape and the Stage A checkpoint. Also records two verified WA crisis source deep links, and pins the ACMA range reserved for fiction so no later task renumbers it to something that looks more fictional but is really allocatable. No application behaviour changes here; Task 1's matching code changes are committed separately. Co-Authored-By: Claude Opus 5 --- docs/care-plan/CLAUDE-START-HERE.md | 59 ++++++++----------- .../2026-08-20-care-plan-implementation.md | 33 ++++++----- .../specs/2026-08-20-care-plan-design.md | 2 +- 3 files changed, 42 insertions(+), 52 deletions(-) diff --git a/docs/care-plan/CLAUDE-START-HERE.md b/docs/care-plan/CLAUDE-START-HERE.md index 825995112..572c52bf5 100644 --- a/docs/care-plan/CLAUDE-START-HERE.md +++ b/docs/care-plan/CLAUDE-START-HERE.md @@ -4,12 +4,12 @@ **Implementation status:** Not started **Design status:** Approved **Planning status:** Complete -**Working directory:** `D:\Repos\Database\.claude\worktrees\care-plan-impl-7f44cd` (superseded 21 Aug 2026; the original `D:\Worktrees\Database\care-plan` is now planning-only and must not be written to) -**Branch:** `claude/care-plan-impl-7f44cd` (based on `main` at `97f614223`) +**Working directory:** `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` (superseded 21 Aug 2026; the original `D:\Worktrees\Database\ed-care-plans` is now planning-only and must not be written to) +**Branch:** `claude/ed-care-plans-impl-7f44cd` (based on `main` at `97f614223`) ## One-minute start -**Superseded 21 August 2026.** Do all Care Plan work in `D:\Repos\Database\.claude\worktrees\care-plan-impl-7f44cd` on branch `claude/care-plan-impl-7f44cd`, which is based on current `main`. The four planning documents were copied there and are now tracked with the code. The original planning worktree `D:\Worktrees\Database\care-plan` is read-only reference; do not edit it. Do not edit the dirty shared checkout at `D:\Repos\Database`. +**Superseded 21 August 2026.** Do all Care Plan work in `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` on branch `claude/ed-care-plans-impl-7f44cd`, which is based on current `main`. The four planning documents were copied there and are now tracked with the code. The original planning worktree `D:\Worktrees\Database\ed-care-plans` is read-only reference; do not edit it. Do not edit the dirty shared checkout at `D:\Repos\Database`. Four user decisions were recorded on 21 August 2026 and are binding: build the synthetic prototype now but keep the domain shaped for later real storage; keep the full multi-service workflow including named senior-clinician approval; deliver Tasks 1–5 first and stop for user review; local task commits are authorised (nothing pushed). See the Revision history and Delivery Stages sections of the implementation plan. @@ -33,39 +33,26 @@ The previous localhost visual server is no longer running. Open the HTML file di ## Copy/paste prompt for Claude ```text -Continue the approved Care Plan build from the handover in -D:\Worktrees\Database\care-plan on branch codex/care-plan. - -Work only in that isolated worktree. Do not touch D:\Repos\Database, which contains -unrelated dirty work. Read AGENTS.md and CLAUDE.md, then read these files in order: - -1. docs/care-plan/CLAUDE-START-HERE.md -2. docs/care-plan/claude-build-handover-2026-08-21.md -3. docs/superpowers/specs/2026-08-20-care-plan-design.md -4. docs/care-plan-context.md -5. docs/superpowers/plans/2026-08-20-care-plan-implementation.md -6. docs/care-plan/conversation-transcript-2026-08-21.md -7. docs/care-plan/verification-log-2026-08-21.md - -The design and implementation plan are approved. Do not re-brainstorm or encode a -numeric presentation threshold. Execute the nine tasks in order using the named -Superpowers subagent-driven-development and test-driven-development workflow. Keep -the app completely synthetic, reset-on-refresh, and provider-free. Use repository -test and browser wrappers only. - -Before editing, verify the branch, HEAD, upstream, status, and the current gap to -origin/main. Preserve every existing untracked handover/specification file. Do not -merge, rebase, pull, or move the base without my explicit authorization. - -Routine local implementation and offline verification are intended. No commit, -push, pull, merge, rebase, PR, deployment, provider/API access, migration, or live -data access is authorized by this handover. Ask separately before creating local -commits because the requested SDD workflow normally uses them as checkpoints. - -Begin with Task 1 in the implementation plan. Use a failing focused test first, -record the decisive red and green lines, obtain the task reviews required by the -plan, and continue task by task. Stop only for a material authorization boundary or -a conflict that cannot be resolved safely from the repository. +Resume the Care Plan build in D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd +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 .superpowers/sdd/2026-08-20-care-plan-implementation/progress.md — the SDD +ledger. Tasks with a "Task N: complete" line are done; resume at the first without +one. The ledger also carries every controller ruling. If the ledger is missing, the +worktree was deleted again: recover from git log, because every task commits at its +end. + +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 diff --git a/docs/superpowers/plans/2026-08-20-care-plan-implementation.md b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md index 4abbb181c..7db86901c 100644 --- a/docs/superpowers/plans/2026-08-20-care-plan-implementation.md +++ b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md @@ -19,7 +19,7 @@ ## Global Constraints -- Work only in `D:\Repos\Database\.claude\worktrees\care-plan-impl-7f44cd` on `claude/care-plan-impl-7f44cd`, based on `main` at `97f614223`. Preserve the unrelated dirty checkout at `D:\Repos\Database` and the earlier planning worktree at `D:\Worktrees\Database\care-plan`; copy from them, never write to them. +- Work only in `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` on `claude/ed-care-plans-impl-7f44cd`, based on `main` at `97f614223`. Preserve the unrelated dirty checkout at `D:\Repos\Database` and the earlier planning worktree at `D:\Worktrees\Database\ed-care-plans`; copy from them, never write to them. - This worktree is fresh. Confirm dependencies are installed (`node_modules` present and `npm run check:installed-lock-parity` clean) before the first test command; run `npm ci --include=dev` only if they are not. - The application is synthetic and memory-only. Do not add `fetch`, route handlers, Server Actions, local/session storage, IndexedDB, cookies for prototype state, Supabase, OpenAI, analytics, email providers, or any other network or persistence path. - No API, provider, production-data, deployment, migration, or live-canary action is authorised. @@ -47,7 +47,7 @@ - A Personal Safety Plan is patient-owned, independently versioned, and does not use the Management Plan senior-approval transition. - An ED Presentation is append-only. Corrections append a `PresentationAmendment`; they never overwrite the original field. Plan-use feedback may append a `ReviewTrigger` and never edits a plan. - CMHT email and telephone actions record only `email_intent_opened` or `call_intent_opened`. The `mailto:` subject is generic and contains no name, MRN, date of birth, presentation content, or plan content. Never claim sent, delivered, read, answered, or completed contact. -- Use the official public WA crisis details verified on 20 August 2026: `000`; MHERL Perth `1300 555 788`; MHERL Peel `1800 676 822`; Rurallink `1800 552 002`, available 4:30 pm–8:30 am weeknights and 24 hours on weekends/public holidays. Display that MHERL is not an emergency service. Retain the official source URLs in fixture metadata. +- Use the official public WA crisis details verified on 20 August 2026: `000`; MHERL Perth `1300 555 788`; MHERL Peel `1800 676 822`; Rurallink `1800 552 002`, available 4:30 pm–8:30 am weeknights and 24 hours on weekends/public holidays. Display that MHERL is not an emergency service. Retain the official source URLs in fixture metadata: 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`. Where no official deep link exists, the organisation root is correct — never invent a slug. - Use Australian English, `en-AU`, `Australia/Perth`, ISO source timestamps, plain non-stigmatising language, and the glossary's preferred terms. - Reuse the existing repository primitives where their contracts apply; every button must have a real action or a stated unavailable reason. Verified import homes in this worktree: - `Button` — `src/components/ui/button.tsx` @@ -410,10 +410,19 @@ export type EdPresentation = { recordedAt: string; }; +/** The spec's amendable set: disposition, assessment outcome, the one-line + * account, and the three plan-use answers. The plan-use answers are presented + * to the user as one group; each changed answer still appends its own + * attributed amendment, so the stored evidence stays one field per record. */ +export type AmendableField = + "assessmentOutcome" | "disposition" | "note" | "planAvailability" | "planUse" | "planHelpfulness"; + export type PresentationAmendment = { id: SyntheticId; presentationId: SyntheticId; - field: "assessmentOutcome" | "disposition"; + field: AmendableField; + /** Display strings for every field, including disposition. The reducer + * validates that a disposition replacement parses to a `Disposition`. */ originalValue: string; replacementValue: string; reason: string; @@ -596,7 +605,7 @@ it.each([ it("builds a generic CMHT email intent without patient information", () => { const contact = syntheticCmhtContacts[0]!; const href = buildCmhtMailto(contact); - expect(href).toBe("mailto:north-river.cmht@example.org?subject=ED+Care+Plans+%E2%80%94+team+contact+request"); + expect(href).toBe("mailto:north-river.cmht@example.org?subject=Care+Plan+%E2%80%94+team+contact+request"); expect(href).not.toMatch(/Rowan|SYN-MRN|1986|presentation|management plan/i); }); ``` @@ -613,6 +622,7 @@ it("builds a generic CMHT email intent without patient information", () => { | CMHTs | `SYN-CMHT-001` North River CMHT; `002` Coastal Plains Older Adult CMHT; `003` Wandoo District CMHT | | Scenarios | Normal Current Plan; overdue Current plus Awaiting Approval; no Current Plan; withdrawn Current; mixed-helpfulness Review Trigger; unverified contact | +- [ ] Use the ACMA range reserved for fiction, `0491 570 006` to `0491 570 156`, for every fictional Australian telephone number. This is the Australian equivalent of the `555` convention and is correct — no later task should "fix" it to a different-looking number. - [ ] Give every fictional CMHT a reserved-example shared mailbox, fictional Australian mobile, hours, timezone, coordinator, after-hours path, verification date, and verification state. Add the separately labelled official crisis-contact fixtures and source URLs specified in Global Constraints. - [ ] Add enough ED Presentation fixtures to derive each displayed activity count from presentation timestamps. Rowan must show `7 ED presentations in rolling 12 months`; this is observation-only copy and is never compared with policy. - [ ] Write fixture plan content against the eleven-field two-tier shape. Every fixture Current version fills all five first-minute keys and `whyThisPlanExists`; at least one fixture leaves two full-plan keys empty so the `Not recorded` path has coverage. Fixture prose must be plausible clinical continuity guidance in the glossary's preferred language, and `whatWouldMakeThisDifferent` must always name concrete new findings that would void the plan rather than generic caution. @@ -696,17 +706,10 @@ export type CarePlanPrototypeAction = | { type: "amend-presentation"; presentationId: SyntheticId; - field: "assessmentOutcome"; + field: AmendableField; replacementValue: string; reason: string; } - | { - type: "amend-presentation"; - presentationId: SyntheticId; - field: "disposition"; - replacementValue: Disposition; - reason: string; - } | { type: "create-safety-plan-draft"; patientId: SyntheticId } | { type: "save-safety-plan-draft"; versionId: SyntheticId; input: SafetyPlanDraftInput } | { type: "make-safety-plan-current"; versionId: SyntheticId } @@ -741,7 +744,7 @@ export type CarePlanPrototypeAction = - [ ] Make approval validate: actor role, version state, named approver, complete required content, and an existing plan. In one returned state, supersede the previous Current version, make the submitted version Current, set approver/approval date/review state, update `currentVersionId`, and append one audit event. - [ ] Make return-for-changes require a non-empty reason and return Awaiting Approval to Draft without touching Current. Make withdrawal require a non-empty reason and leave no Current version. - [ ] Make `record-presentation` append an episode and audit event. If helpfulness is `mixed` or `not_helpful`, `reviewSuggested` is true, disposition is a mental-health/medical admission, or a material deviation is recorded, append one deduplicated open Review Trigger. -- [ ] Make `amend-presentation` append original/replacement/reason/actor/time evidence while keeping the episode immutable. Restrict amendable fields to the union in the action. +- [ ] Make `amend-presentation` append original/replacement/reason/actor/time evidence while keeping the episode immutable. Restrict amendable fields to `AmendableField`, and reject a `disposition` replacement that does not parse to a `Disposition`. - [ ] Make Safety Plan publication require an editable Draft, supersede the prior Current Safety Plan, and set the new version Current without consulting senior-approval state. - [ ] Make contact-intent actions append only intent audit events; make manual identification referral append an Identification Review and audit event without creating a Management Plan or Review Trigger. - [ ] Make `close-identification-review` require an `open` review and a non-empty reason, set `status: "closed"` with the decision, reason, actor, and time, and append one `identification_review_closed` audit event. It must create no plan and no version on any decision, including `proceed_to_plan`. Closing an already-closed review leaves state unchanged and sets a specific `lastOutcome`. @@ -885,7 +888,7 @@ it("exposes only intent-safe CMHT launch links", () => { renderRoute(CARE_PLAN_ROUTES.patient); expect(screen.getByRole("link", { name: "Email North River CMHT" })).toHaveAttribute( "href", - "mailto:north-river.cmht@example.org?subject=ED+Care+Plans+%E2%80%94+team+contact+request", + "mailto:north-river.cmht@example.org?subject=Care+Plan+%E2%80%94+team+contact+request", ); expect(screen.getByRole("link", { name: "Call North River CMHT" })).toHaveAttribute("href", "tel:+61491570101"); }); @@ -1060,7 +1063,7 @@ it("records plan-use feedback and creates a Review Suggested item without changi - [ ] Require site, disposition, plan availability, plan use, and plan helpfulness; require a review reason whenever review is suggested and a deviation reason whenever a deviation is recorded. Presenting indication and assessment outcome are never required. Use an error summary and focus the first invalid field. - [ ] On save, call `nextPresentationId(state)`, dispatch `record-presentation` with that ID, announce only local synthetic recording, and navigate to the matching detail route. The reducer validates the caller-provided synthetic ID before appending. - [ ] Implement the detail page with the original immutable episode, recording clinician/time, linked plan version, plan-use feedback, outcome, Review Trigger, and an `Amend recorded outcome` action. -- [ ] The amendment Sheet permits only assessment outcome or disposition, shows the original value, and requires replacement plus reason. On save, display original and latest amendment together; do not replace the original DOM text. +- [ ] The amendment Sheet permits the six `AmendableField` values, shows each original value, and requires a replacement plus one reason. The three plan-use answers are presented as a single group; changing more than one in that group appends one amendment per changed answer under the same reason. On save, display original and latest amendment together; do not replace the original DOM text. - [ ] Wire presentation list/new/detail paths in `routable-suite.tsx`; validate that the episode belongs to the patient before rendering and show identity uncertainty rather than another patient's data on mismatch. - [ ] Complete DOM tests for required fields, no-Current linkage, Current linkage, helpful/no-trigger, mixed/not-helpful trigger, admission trigger, deviation trigger, deduplication, deterministic navigation, original-plus-amendment rendering, and mismatched identity refusal. - [ ] Run `npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts tests/care-plan-linked-routes.dom.test.tsx`. Expected GREEN. diff --git a/docs/superpowers/specs/2026-08-20-care-plan-design.md b/docs/superpowers/specs/2026-08-20-care-plan-design.md index d9d2ec3d4..3bb72292e 100644 --- a/docs/superpowers/specs/2026-08-20-care-plan-design.md +++ b/docs/superpowers/specs/2026-08-20-care-plan-design.md @@ -262,7 +262,7 @@ The complete field set is: The record does not duplicate a full ED note, diagnosis list, medication chart, risk assessment, or narrative clinical history. -ED Presentation records are append-only in the domain model. A correction creates a visible Presentation Amendment with author, time, reason, and replacement value. Amendable fields are the disposition, the assessment outcome, the one-line account, and the plan-use answers (availability, use, and helpfulness) as one group. Extending amendment to the plan-use answers does not let anyone rewrite history — every amendment is visible and attributed — and refusing it would mean a clinician who mis-taps `the plan helped` can never correct it, which is exactly the kind of friction that makes people stop using a tool. Plan-use feedback that suggests review creates a Review Trigger without automatically changing the plan. +ED Presentation records are append-only in the domain model. A correction creates a visible Presentation Amendment with author, time, reason, and replacement value. Amendable fields are the disposition, the assessment outcome, the one-line account, and the plan-use answers (availability, use, and helpfulness). The three plan-use answers are presented to the user as one group under a single reason; each changed answer still records its own attributed amendment, so stored evidence stays one field per record. Extending amendment to the plan-use answers does not let anyone rewrite history — every amendment is visible and attributed — and refusing it would mean a clinician who mis-taps `the plan helped` can never correct it, which is exactly the kind of friction that makes people stop using a tool. Plan-use feedback that suggests review creates a Review Trigger without automatically changing the plan. ## Personal Safety Plan From 2f5a6f32bd34c5f7badac16e6e4ee7a0faa6307e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:03:48 +0800 Subject: [PATCH 07/56] fix(care-plan): move fictional numbers into the reserved fiction range Renumber the six CMHT telephone numbers and the three personal-support contacts into 0491 570 006-156, the ACMA range reserved for fiction, using the allocation that also satisfies Task 4's pinned duty number. Rewrite the three after-hours labels so a fictional mobile is no longer presented under the name of a real crisis service; the verified public crisis lines are unchanged. Replace the literal-list number test with a numeric range assertion so a number added later cannot sit outside the reserved span. Co-Authored-By: Claude Opus 5 --- src/components/care-plan/mockups/fixtures.ts | 44 ++++++++++---------- tests/care-plan-domain.test.ts | 37 +++++++++------- 2 files changed, 43 insertions(+), 38 deletions(-) diff --git a/src/components/care-plan/mockups/fixtures.ts b/src/components/care-plan/mockups/fixtures.ts index d3eb411a8..5e28c5b83 100644 --- a/src/components/care-plan/mockups/fixtures.ts +++ b/src/components/care-plan/mockups/fixtures.ts @@ -191,15 +191,15 @@ export const syntheticCmhtContacts = [ name: "North River CMHT", catchment: "North River metropolitan catchment", sharedMailbox: "north-river.cmht@example.org", - dutyTelephoneDisplay: "0491 570 210", - dutyTelephoneUri: "+61491570210", + dutyTelephoneDisplay: "0491 570 101", + dutyTelephoneUri: "+61491570101", operatingHours: "Monday to Friday, 8:30 am to 5:00 pm AWST", timezone: "Australia/Perth", careCoordinator: "Sam Placeholder", afterHoursLabel: - "Outside those hours, the Mental Health Emergency Response Line (MHERL) covers metropolitan Perth. MHERL is a telephone triage and support line and is not an emergency service; call 000 in an emergency.", - afterHoursTelephoneDisplay: "1300 555 788", - afterHoursTelephoneUri: "1300555788", + "Outside those hours the North River after-hours mental health triage line answers calls for this catchment. It is a telephone triage and support line and is not an emergency service; call 000 in an emergency. The verified public crisis lines are listed separately.", + afterHoursTelephoneDisplay: "0491 570 102", + afterHoursTelephoneUri: "+61491570102", verifiedAt: daysAgo(21, "09:30"), verificationState: "verified", }, @@ -208,15 +208,15 @@ export const syntheticCmhtContacts = [ name: "Coastal Plains Older Adult CMHT", catchment: "Coastal Plains and Peel older-adult catchment", sharedMailbox: "coastal-plains.olderadult.cmht@example.org", - dutyTelephoneDisplay: "0491 570 211", - dutyTelephoneUri: "+61491570211", + dutyTelephoneDisplay: "0491 570 111", + dutyTelephoneUri: "+61491570111", operatingHours: "Monday to Friday, 8:30 am to 4:30 pm AWST", timezone: "Australia/Perth", careCoordinator: "Devon Sample", afterHoursLabel: - "Outside those hours, the Mental Health Emergency Response Line (MHERL) covers the Peel region. MHERL is a telephone triage and support line and is not an emergency service; call 000 in an emergency.", - afterHoursTelephoneDisplay: "1800 676 822", - afterHoursTelephoneUri: "1800676822", + "Outside those hours the Coastal Plains after-hours mental health triage line answers calls for this catchment. It is a telephone triage and support line and is not an emergency service; call 000 in an emergency. The verified public crisis lines are listed separately.", + afterHoursTelephoneDisplay: "0491 570 112", + afterHoursTelephoneUri: "+61491570112", verifiedAt: monthsAgo(7, "09:30"), verificationState: "review_due", }, @@ -225,15 +225,15 @@ export const syntheticCmhtContacts = [ name: "Wandoo District CMHT", catchment: "Wandoo District regional catchment", sharedMailbox: "wandoo-district.cmht@example.org", - dutyTelephoneDisplay: "0491 570 212", - dutyTelephoneUri: "+61491570212", + dutyTelephoneDisplay: "0491 570 121", + dutyTelephoneUri: "+61491570121", operatingHours: "Monday to Friday, 8:00 am to 4:30 pm AWST", timezone: "Australia/Perth", careCoordinator: null, afterHoursLabel: - "Outside those hours, Rurallink covers regional and remote Western Australia from 4:30 pm to 8:30 am on weeknights and 24 hours on weekends and public holidays. Rurallink is a telephone triage and support line and is not an emergency service; call 000 in an emergency.", - afterHoursTelephoneDisplay: "1800 552 002", - afterHoursTelephoneUri: "1800552002", + "Outside those hours the Wandoo District after-hours mental health triage line answers calls for this regional catchment. It is a telephone triage and support line and is not an emergency service; call 000 in an emergency. The verified public crisis lines are listed separately.", + afterHoursTelephoneDisplay: "0491 570 122", + afterHoursTelephoneUri: "+61491570122", verifiedAt: monthsAgo(19, "09:30"), verificationState: "unverified", }, @@ -780,11 +780,11 @@ export const syntheticPersonalSafetyPlanVersions = [ ], connectionPeopleAndPlaces: ["The late-opening library.", "The allotment.", "Jess's kitchen."], personalSupports: [ - { name: "Jess Sample", relationship: "Sister", phone: "0491 570 220" }, - { name: "Ari Placeholder", relationship: "Friend", phone: "0491 570 222" }, + { name: "Jess Sample", relationship: "Sister", phone: "0491 570 131" }, + { name: "Ari Placeholder", relationship: "Friend", phone: "0491 570 132" }, ], professionalAndEmergencySupport: [ - "North River CMHT, Monday to Friday 8:30 am to 5:00 pm, 0491 570 210.", + "North River CMHT, Monday to Friday 8:30 am to 5:00 pm, 0491 570 101.", "Mental Health Emergency Response Line (MHERL), 1300 555 788, 24 hours. Not an emergency service.", "In an emergency, call 000.", ], @@ -809,9 +809,9 @@ export const syntheticPersonalSafetyPlanVersions = [ reasonsForLiving: ["Daniel and the grandchildren.", "The garden."], selfStrategies: ["Sitting in the sun on the back step.", "The radio on in the kitchen."], connectionPeopleAndPlaces: ["The Thursday craft group at the community hall."], - personalSupports: [{ name: "Daniel Example", relationship: "Son", phone: "0491 570 221" }], + personalSupports: [{ name: "Daniel Example", relationship: "Son", phone: "0491 570 141" }], professionalAndEmergencySupport: [ - "Coastal Plains Older Adult CMHT, Monday to Friday 8:30 am to 4:30 pm, 0491 570 211.", + "Coastal Plains Older Adult CMHT, Monday to Friday 8:30 am to 4:30 pm, 0491 570 111.", "Mental Health Emergency Response Line (MHERL) Peel, 1800 676 822, 24 hours. Not an emergency service.", "In an emergency, call 000.", ], @@ -838,7 +838,7 @@ export const syntheticPersonalSafetyPlanVersions = [ connectionPeopleAndPlaces: [], personalSupports: [], professionalAndEmergencySupport: [ - "Wandoo District CMHT, Monday to Friday 8:00 am to 4:30 pm, 0491 570 212.", + "Wandoo District CMHT, Monday to Friday 8:00 am to 4:30 pm, 0491 570 121.", "Rurallink, 1800 552 002, 4:30 pm to 8:30 am weeknights and 24 hours at weekends. Not an emergency service.", "In an emergency, call 000.", ], @@ -865,7 +865,7 @@ export const syntheticPersonalSafetyPlanVersions = [ connectionPeopleAndPlaces: [], personalSupports: [], professionalAndEmergencySupport: [ - "North River CMHT, Monday to Friday 8:30 am to 5:00 pm, 0491 570 210.", + "North River CMHT, Monday to Friday 8:30 am to 5:00 pm, 0491 570 101.", "Mental Health Emergency Response Line (MHERL), 1300 555 788, 24 hours. Not an emergency service.", "In an emergency, call 000.", ], diff --git a/tests/care-plan-domain.test.ts b/tests/care-plan-domain.test.ts index 73dc798aa..5e41fcbc7 100644 --- a/tests/care-plan-domain.test.ts +++ b/tests/care-plan-domain.test.ts @@ -318,24 +318,29 @@ describe("Care Plan contact actions", () => { } }); - it("uses only fictional Australian numbers and the authorised public crisis lines", () => { - const authorisedNationalNumbers = new Set([ - "0491570210", - "0491570211", - "0491570212", - "0491570220", - "0491570221", - "0491570222", - "1300555788", - "1800676822", - "1800552002", - ]); + it("keeps every fictional telephone number inside the reserved fiction range", () => { + // 0491 570 006 to 0491 570 156 is the ACMA range reserved for drama and fiction, + // and (0X) 5550 XXXX is its landline equivalent. A number outside those spans is + // ordinary allocatable stock that could reach a real person, and these numbers + // print onto a patient-facing safety plan. Asserted as a range, not a list, so a + // number added by a later task cannot slip past this. + const authorisedPublicNumbers = new Set(["000", "1300555788", "1800676822", "1800552002"]); const matches = serialisedFixtures.match(/\+61[\d\s]{6,}|\b0[2-9][\s\d]{7,}|\b1[38]00[\s\d]{5,}/g) ?? []; - expect(matches.length).toBeGreaterThan(5); + expect(matches.length).toBeGreaterThan(10); + + const fictional = matches + .map((match) => match.replace(/\s/g, "").replace(/^\+61/, "0")) + .filter((national) => !authorisedPublicNumbers.has(national)); + expect(fictional.length).toBeGreaterThan(8); + + for (const national of fictional) { + if (/^0[2-8]5550\d{4}$/.test(national)) continue; - for (const match of matches) { - const national = match.replace(/\s/g, "").replace(/^\+61/, "0"); - expect(authorisedNationalNumbers).toContain(national); + const reservedMobile = /^0491570(\d{3})$/.exec(national); + expect(reservedMobile, `${national} is not a reserved fictional number`).not.toBeNull(); + const suffix = Number(reservedMobile?.[1]); + expect(suffix, `${national} is below 0491 570 006`).toBeGreaterThanOrEqual(6); + expect(suffix, `${national} is above 0491 570 156`).toBeLessThanOrEqual(156); } }); }); From 59d101fe91805e8ec4571880398e54671e9c14ec Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:28:51 +0800 Subject: [PATCH 08/56] fix(care-plan): restore the real crisis lines on the after-hours path The CMHT after-hours pathway is the public crisis service, not a fictional team line: a reader dialling it from a printed safety plan must reach a real service. Restore MHERL, MHERL Peel, and Rurallink with their real numbers and names, each keeping the not-an-emergency-service caveat, the 000 pointer, and Rurallink's hours. Duty numbers and personal supports stay in the reserved fiction range. Split the number test into two rules: every Australian mobile must sit inside 0491 570 006-156, checked numerically, and every other number must appear in an explicit four-entry public allowlist. Also pins that the three dialable crisis lines are still present, and guards the mobile sweep against passing vacuously. Co-Authored-By: Claude Opus 5 --- src/components/care-plan/mockups/fixtures.ts | 18 +++---- tests/care-plan-domain.test.ts | 57 +++++++++++++------- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/src/components/care-plan/mockups/fixtures.ts b/src/components/care-plan/mockups/fixtures.ts index 5e28c5b83..53a29ae8e 100644 --- a/src/components/care-plan/mockups/fixtures.ts +++ b/src/components/care-plan/mockups/fixtures.ts @@ -197,9 +197,9 @@ export const syntheticCmhtContacts = [ timezone: "Australia/Perth", careCoordinator: "Sam Placeholder", afterHoursLabel: - "Outside those hours the North River after-hours mental health triage line answers calls for this catchment. It is a telephone triage and support line and is not an emergency service; call 000 in an emergency. The verified public crisis lines are listed separately.", - afterHoursTelephoneDisplay: "0491 570 102", - afterHoursTelephoneUri: "+61491570102", + "Outside those hours the Mental Health Emergency Response Line (MHERL) covers metropolitan Perth. MHERL is a real, verified public service: it is a telephone triage and support line and is not an emergency service, so call 000 in an emergency.", + afterHoursTelephoneDisplay: "1300 555 788", + afterHoursTelephoneUri: "1300555788", verifiedAt: daysAgo(21, "09:30"), verificationState: "verified", }, @@ -214,9 +214,9 @@ export const syntheticCmhtContacts = [ timezone: "Australia/Perth", careCoordinator: "Devon Sample", afterHoursLabel: - "Outside those hours the Coastal Plains after-hours mental health triage line answers calls for this catchment. It is a telephone triage and support line and is not an emergency service; call 000 in an emergency. The verified public crisis lines are listed separately.", - afterHoursTelephoneDisplay: "0491 570 112", - afterHoursTelephoneUri: "+61491570112", + "Outside those hours the Mental Health Emergency Response Line (MHERL) covers the Peel region. MHERL is a real, verified public service: it is a telephone triage and support line and is not an emergency service, so call 000 in an emergency.", + afterHoursTelephoneDisplay: "1800 676 822", + afterHoursTelephoneUri: "1800676822", verifiedAt: monthsAgo(7, "09:30"), verificationState: "review_due", }, @@ -231,9 +231,9 @@ export const syntheticCmhtContacts = [ timezone: "Australia/Perth", careCoordinator: null, afterHoursLabel: - "Outside those hours the Wandoo District after-hours mental health triage line answers calls for this regional catchment. It is a telephone triage and support line and is not an emergency service; call 000 in an emergency. The verified public crisis lines are listed separately.", - afterHoursTelephoneDisplay: "0491 570 122", - afterHoursTelephoneUri: "+61491570122", + "Outside those hours Rurallink covers regional and remote Western Australia, from 4:30 pm to 8:30 am on weeknights and 24 hours on weekends and public holidays. Rurallink is a real, verified public service: it is a telephone triage and support line and is not an emergency service, so call 000 in an emergency.", + afterHoursTelephoneDisplay: "1800 552 002", + afterHoursTelephoneUri: "1800552002", verifiedAt: monthsAgo(19, "09:30"), verificationState: "unverified", }, diff --git a/tests/care-plan-domain.test.ts b/tests/care-plan-domain.test.ts index 5e41fcbc7..7ef4124d0 100644 --- a/tests/care-plan-domain.test.ts +++ b/tests/care-plan-domain.test.ts @@ -319,29 +319,48 @@ describe("Care Plan contact actions", () => { }); it("keeps every fictional telephone number inside the reserved fiction range", () => { - // 0491 570 006 to 0491 570 156 is the ACMA range reserved for drama and fiction, - // and (0X) 5550 XXXX is its landline equivalent. A number outside those spans is - // ordinary allocatable stock that could reach a real person, and these numbers - // print onto a patient-facing safety plan. Asserted as a range, not a list, so a - // number added by a later task cannot slip past this. - const authorisedPublicNumbers = new Set(["000", "1300555788", "1800676822", "1800552002"]); - const matches = serialisedFixtures.match(/\+61[\d\s]{6,}|\b0[2-9][\s\d]{7,}|\b1[38]00[\s\d]{5,}/g) ?? []; - expect(matches.length).toBeGreaterThan(10); - - const fictional = matches - .map((match) => match.replace(/\s/g, "").replace(/^\+61/, "0")) - .filter((national) => !authorisedPublicNumbers.has(national)); - expect(fictional.length).toBeGreaterThan(8); - - for (const national of fictional) { - if (/^0[2-8]5550\d{4}$/.test(national)) continue; + // Two rules, deliberately not one list. + // + // Fictional numbers: 0491 570 006 to 0491 570 156 is the ACMA range reserved for + // drama and fiction, and (0X) 5550 XXXX is its landline equivalent. A mobile + // outside that span is ordinary allocatable stock that could reach a real person, + // and these numbers print onto a patient-facing safety plan. Checked as a numeric + // range, so a number added by a later task cannot slip past a list of literals. + // + // Real numbers: the after-hours pathway genuinely is the public crisis service, + // because a reader dialling it at 2am must reach a real service rather than a dead + // number. Those are an explicit allowlist, so a fifth real number cannot be added + // silently. + const authorisedPublicNumbers = ["000", "1300555788", "1800676822", "1800552002"]; + + const found = (serialisedFixtures.match(/\+61[\d\s]{6,}|\b0[2-9][\s\d]{7,}|\b1[38]00[\s\d]{5,}/g) ?? []).map( + (match) => match.replace(/\s/g, "").replace(/^\+61/, "0"), + ); + expect(found.length).toBeGreaterThan(10); - const reservedMobile = /^0491570(\d{3})$/.exec(national); - expect(reservedMobile, `${national} is not a reserved fictional number`).not.toBeNull(); - const suffix = Number(reservedMobile?.[1]); + // Guards against a broken sweep passing vacuously over an empty match set. + const mobiles = found.filter((national) => /^04\d{8}$/.test(national)); + expect(mobiles.length).toBeGreaterThan(8); + + for (const national of mobiles) { + const reserved = /^0491570(\d{3})$/.exec(national); + expect(reserved, `${national} is outside the 0491 570 block reserved for fiction`).not.toBeNull(); + const suffix = Number(reserved?.[1]); expect(suffix, `${national} is below 0491 570 006`).toBeGreaterThanOrEqual(6); expect(suffix, `${national} is above 0491 570 156`).toBeLessThanOrEqual(156); } + + for (const national of found) { + if (/^04\d{8}$/.test(national)) continue; + if (/^0[2-8]5550\d{4}$/.test(national)) continue; + expect(authorisedPublicNumbers, `${national} is not an authorised public crisis line`).toContain(national); + } + + // The after-hours pathway must stay real: a later change must not quietly replace a + // working crisis line with a dead fictional one. + for (const publicNumber of ["1300555788", "1800676822", "1800552002"]) { + expect(found, `${publicNumber} is no longer present as a verified public crisis line`).toContain(publicNumber); + } }); }); From 9d72974ac41a03ad16a2505de5e3407069f6491b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:26:13 +0800 Subject: [PATCH 09/56] fix(care-plan): make the clinical language guards able to fail Task review round 1. Fixture prose claimed episode counts the records did not support: add the two missing left-before-completion episodes for Jordan and Alex, move Alex's episode before the referral that cites it, and drop an unverifiable time-scoped count from Evelyn's referral. Add a test deriving each patient's counts from the episode records and checking no prose contradicts them. Rebuild the service-facing, concrete-findings and agreed-and-when guards as predicates with negative controls, each proven to reject the sentence that previously slipped through, and run them over every version rather than only the current ones. Replace the glossary-banned ED Presentation synonyms and test for them. Remove stored reviewState from both version types so it cannot drift from the date it describes; review state is always derived. Return overdue from deriveReviewState for an unreadable date, so failure degrades conservatively rather than showing the most reassuring state. Co-Authored-By: Claude Opus 5 --- src/components/care-plan/mockups/domain.ts | 7 + src/components/care-plan/mockups/fixtures.ts | 66 +++-- src/components/care-plan/mockups/types.ts | 2 - tests/care-plan-domain.test.ts | 257 ++++++++++++++++--- 4 files changed, 280 insertions(+), 52 deletions(-) diff --git a/src/components/care-plan/mockups/domain.ts b/src/components/care-plan/mockups/domain.ts index a979b5453..d598a9ca1 100644 --- a/src/components/care-plan/mockups/domain.ts +++ b/src/components/care-plan/mockups/domain.ts @@ -362,10 +362,17 @@ export function canPerformAction(role: PrototypeRole, capability: PrototypeCapab * The currency of a Current Plan against its review date. Past the date is * `overdue`; on or within `REVIEW_DUE_SOON_DAYS` of it is `due_soon`. An * overdue plan stays Current until it is reviewed, replaced, or withdrawn. + * + * Review state is always derived here and never stored on a version, so a + * stored value cannot drift away from the date it claims to describe. */ export function deriveReviewState(reviewDueAt: string, now: string): ReviewState { const dueMs = Date.parse(reviewDueAt); const nowMs = Date.parse(now); + // Degrade conservatively. An unreadable date must not resolve to the most + // reassuring state on a clinical currency indicator, so it reads as overdue + // and sends someone to look at the plan. + if (Number.isNaN(dueMs) || Number.isNaN(nowMs)) return "overdue"; if (nowMs > dueMs) return "overdue"; if (dueMs - nowMs <= REVIEW_DUE_SOON_DAYS * MILLISECONDS_PER_DAY) return "due_soon"; return "within_review"; diff --git a/src/components/care-plan/mockups/fixtures.ts b/src/components/care-plan/mockups/fixtures.ts index 53a29ae8e..70a74a3f6 100644 --- a/src/components/care-plan/mockups/fixtures.ts +++ b/src/components/care-plan/mockups/fixtures.ts @@ -338,7 +338,6 @@ export const syntheticManagementPlanVersions = [ planId: "SYN-MGMT-PLAN-001", version: 1, state: "superseded", - reviewState: null, authorId: "SYN-USER-LIAISON-001", ownerId: "SYN-USER-LIAISON-001", approverId: "SYN-USER-SENIOR-001", @@ -393,7 +392,6 @@ export const syntheticManagementPlanVersions = [ planId: "SYN-MGMT-PLAN-001", version: 2, state: "current", - reviewState: "within_review", authorId: "SYN-USER-LIAISON-001", ownerId: "SYN-USER-LIAISON-001", approverId: "SYN-USER-SENIOR-001", @@ -443,7 +441,7 @@ export const syntheticManagementPlanVersions = [ "A safeguarding concern about Rowan, or about a child or dependent adult in their household.", ], whyThisPlanExists: - "Rowan has attended North River Hospital ED several times over the last year, usually in the evening and usually after a stretch of poor sleep and a conflict at home. Each attendance has followed a similar course, so Rowan, the North River CMHT and the ED agreed a shared approach that Rowan does not have to renegotiate while distressed. This plan supports continuity. It never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", + "Rowan has attended North River Hospital ED several times over the last year, usually in the evening and usually after a stretch of poor sleep and a conflict at home. Each presentation has followed a similar course, so Rowan, the North River CMHT and the ED agreed a shared approach that Rowan does not have to renegotiate while distressed. This plan supports continuity. It never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", whatThePersonWants: [ "To be told what is happening and roughly how long it will take, rather than being left to guess.", "To go home the same day where that is safe, with a CMHT call the next working day.", @@ -466,7 +464,7 @@ export const syntheticManagementPlanVersions = [ "Jess Sample, sister. Rowan consents to her being contacted when Rowan agrees on the day.", ], reviewTriggers: [ - "Two or more presentations where the plan was recorded as not helpful.", + "Repeated presentations where the plan was recorded as not helpful.", "Any change of care coordinator, CMHT, or living arrangements.", "A mental-health or medical admission.", "Rowan asking for the plan to be changed.", @@ -478,7 +476,6 @@ export const syntheticManagementPlanVersions = [ planId: "SYN-MGMT-PLAN-002", version: 1, state: "current", - reviewState: "overdue", authorId: "SYN-USER-LIAISON-001", ownerId: "SYN-USER-LIAISON-001", approverId: "SYN-USER-SENIOR-001", @@ -544,7 +541,6 @@ export const syntheticManagementPlanVersions = [ planId: "SYN-MGMT-PLAN-002", version: 2, state: "awaiting_approval", - reviewState: null, authorId: "SYN-USER-LIAISON-001", ownerId: "SYN-USER-LIAISON-001", approverId: null, @@ -614,7 +610,6 @@ export const syntheticManagementPlanVersions = [ planId: "SYN-MGMT-PLAN-004", version: 1, state: "withdrawn", - reviewState: null, authorId: "SYN-USER-LIAISON-001", ownerId: "SYN-USER-LIAISON-001", approverId: "SYN-USER-SENIOR-001", @@ -655,7 +650,7 @@ export const syntheticManagementPlanVersions = [ "Any safeguarding concern about Evelyn or about someone in her household.", ], whyThisPlanExists: - "Written so that Evelyn did not have to explain the same preferences at every attendance. It supported continuity and never replaced fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", + "Written so that Evelyn did not have to explain the same preferences at every presentation. It supported continuity and never replaced fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", whatThePersonWants: ["To be called Evie, and to have privacy for the conversation."], practicalNeeds: ["A room with a door rather than a curtained bay."], physicalHealthAndMedication: [ @@ -670,7 +665,6 @@ export const syntheticManagementPlanVersions = [ planId: "SYN-MGMT-PLAN-005", version: 1, state: "draft", - reviewState: null, authorId: "SYN-USER-LIAISON-001", ownerId: "SYN-USER-LIAISON-001", approverId: null, @@ -755,7 +749,6 @@ export const syntheticPersonalSafetyPlanVersions = [ planId: "SYN-SAFETY-PLAN-001", version: 1, state: "current", - reviewState: "due_soon", authorId: "SYN-USER-LIAISON-001", createdAt: ROWAN_SAFETY_CREATED_AT, confirmedAt: atPerthTime(addIsoDays(ROWAN_SAFETY_CREATED_AT, 1), "09:45"), @@ -795,7 +788,6 @@ export const syntheticPersonalSafetyPlanVersions = [ planId: "SYN-SAFETY-PLAN-002", version: 1, state: "current", - reviewState: "within_review", authorId: "SYN-USER-LIAISON-001", createdAt: monthsAgo(5, "11:20"), confirmedAt: null, @@ -822,7 +814,6 @@ export const syntheticPersonalSafetyPlanVersions = [ planId: "SYN-SAFETY-PLAN-003", version: 1, state: "draft", - reviewState: null, authorId: "SYN-USER-ED-001", createdAt: daysAgo(5, "23:10"), confirmedAt: null, @@ -849,7 +840,6 @@ export const syntheticPersonalSafetyPlanVersions = [ planId: "SYN-SAFETY-PLAN-004", version: 1, state: "current", - reviewState: "within_review", authorId: "SYN-USER-LIAISON-001", createdAt: monthsAgo(2, "15:05"), confirmedAt: null, @@ -1208,6 +1198,28 @@ export const syntheticEdPresentations = [ recordedBy: "SYN-USER-ED-001", recordedAt: daysAgo(260, "19:20"), }, + { + id: "SYN-PRESENTATION-019", + patientId: "SYN-PATIENT-003", + arrivedAt: daysAgo(160, "21:15"), + siteId: "SYN-ED-003", + presentingIndication: "", + assessmentOutcome: "", + note: "Came in late at night, waited without an update, and left before the assessment was finished.", + disposition: "left_before_completion", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: null, + planAvailability: "not_applicable", + planUse: "not_applicable", + planHelpfulness: "not_assessed", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(160, "23:05"), + }, { id: "SYN-PRESENTATION-016", patientId: "SYN-PATIENT-004", @@ -1255,7 +1267,7 @@ export const syntheticEdPresentations = [ { id: "SYN-PRESENTATION-018", patientId: "SYN-PATIENT-005", - arrivedAt: daysAgo(3, "20:25"), + arrivedAt: daysAgo(68, "20:25"), siteId: "SYN-ED-001", presentingIndication: "", assessmentOutcome: "", @@ -1272,7 +1284,29 @@ export const syntheticEdPresentations = [ reviewSuggested: false, reviewReason: null, recordedBy: "SYN-USER-ED-001", - recordedAt: daysAgo(3, "23:40"), + recordedAt: daysAgo(68, "23:40"), + }, + { + id: "SYN-PRESENTATION-020", + patientId: "SYN-PATIENT-005", + arrivedAt: daysAgo(95, "19:40"), + siteId: "SYN-ED-001", + presentingIndication: "", + assessmentOutcome: "", + note: "Arrived in distress in the evening and left after a long wait, before the assessment was complete.", + disposition: "left_before_completion", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: null, + planAvailability: "not_applicable", + planUse: "not_applicable", + planHelpfulness: "not_assessed", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + recordedBy: "SYN-USER-ED-001", + recordedAt: daysAgo(95, "22:20"), }, ] satisfies readonly EdPresentation[]; @@ -1374,7 +1408,7 @@ export const syntheticIdentificationReviews = [ id: "SYN-IDENT-REVIEW-003", patientId: "SYN-PATIENT-004", reason: - "Evelyn's plan was withdrawn in July and she has attended once since. Referred so the team can decide whether to write a new plan with her.", + "Evelyn's plan was withdrawn in July and she has presented again since. Referred so the team can decide whether to write a new plan with her.", referredBy: "SYN-USER-LIAISON-001", referredAt: daysAgo(40, "11:45"), status: "open", diff --git a/src/components/care-plan/mockups/types.ts b/src/components/care-plan/mockups/types.ts index d89fc405b..38e74119f 100644 --- a/src/components/care-plan/mockups/types.ts +++ b/src/components/care-plan/mockups/types.ts @@ -244,7 +244,6 @@ export type ManagementPlanVersion = { planId: SyntheticId; version: number; state: ManagementPlanVersionState; - reviewState: ReviewState | null; authorId: SyntheticId; ownerId: SyntheticId; approverId: SyntheticId | null; @@ -276,7 +275,6 @@ export type PersonalSafetyPlanVersion = { planId: SyntheticId; version: number; state: SafetyPlanVersionState; - reviewState: ReviewState | null; authorId: SyntheticId; createdAt: string; confirmedAt: string | null; diff --git a/tests/care-plan-domain.test.ts b/tests/care-plan-domain.test.ts index 7ef4124d0..a78575dcc 100644 --- a/tests/care-plan-domain.test.ts +++ b/tests/care-plan-domain.test.ts @@ -90,6 +90,48 @@ function collectStrings(value: unknown, keyFilter: (key: string) => boolean, int } } +// --- Clinical-language guards ------------------------------------------------- +// Each guard is a predicate so the tests can prove it rejects a bad line before +// asserting that every fixture line passes it. A guard that cannot fail reports +// confidence it has not earned. + +const PERSON_REFERENCE = + /\b(he|she|they|him|her|them|his|hers|their|theirs|the patient|the person|rowan|mira|jordan|evelyn|evie|alex)\b/i; +const OPENS_WITH_PERSON = + /^\s*(he|she|they|his|her|their|the patient|the person|rowan|mira|jordan|evelyn|evie|alex)\b/i; +const BLAMING_TERM = + /\b(aggressive|aggression|agitated|abusive|hostile|threatening|violent|demanding|difficult|manipulative|uncooperative|non-cooperative|disruptive|attention[- ]seeking|drug[- ]seeking|refuses|refusing|non-compliant|escalates|kicks off|lashes out|acts out|plays up|challenging behaviour|behavioural)\b/i; + +/** + * `What makes it worse` must describe what the service does. A line fails when + * the person is its subject, or when it pairs any reference to the person with a + * word that attributes bad behaviour to them. A response to circumstances — "her + * back pain builds and she cannot concentrate" — is allowed, as the spec requires. + */ +function describesTheService(line: string): boolean { + if (OPENS_WITH_PERSON.test(line)) return false; + return !(PERSON_REFERENCE.test(line) && BLAMING_TERM.test(line)); +} + +const CONCRETE_FINDING = + /\b(chest pain|breathless|fever|head injury|head strike|seizure|fall|confusion|disorient|conscious state|drowsi|limb weakness|slurred speech|facial droop|delirium|infection|medicine|medication|overdose|pregnan|attempt|means and preparation|means or preparation|safeguarding|self-harm|physical symptom|nowhere safe)\b/i; + +/** + * `What would make this different` must name a finding a clinician could observe, + * not an instruction to be careful. Generic caution passes no clinical decision to + * anyone, and this is the section that voids the plan. + */ +function namesConcreteFindings(line: string): boolean { + return line.trim().length > 25 && CONCRETE_FINDING.test(line); +} + +/** + * `What we have agreed` must name who agreed the position and when, in one clause, + * so neither half can be satisfied by an unrelated sentence elsewhere in the block. + */ +const AGREED_WITH_NAMED_PARTIES_AND_DATE = + /\bagreed\s+(?:with|between|by)\s+[^.;]{3,140}?\bon\s+\d{1,2}\s+[A-Za-z]+\s+20\d{2}/i; + describe("Care Plan identification policy", () => { it("keeps identification policy governance-pending without a numeric rule", () => { expect(identificationPolicy).toEqual({ @@ -193,27 +235,41 @@ describe("Care Plan review clock", () => { expect(deriveReviewState(reviewDueAt, now)).toBe(expected); }); - it("uses the stored review state that the review clock derives for every Current version", () => { - const currentVersions = syntheticManagementPlanVersions.filter(({ state }) => state === "current"); - expect(currentVersions.length).toBeGreaterThan(1); + it("treats an unreadable review date as overdue rather than as reassuring", () => { + // Failure degrades conservatively: a date the application cannot read must + // send someone to look at the plan, not present as the safest state. + expect(deriveReviewState("not a date", PROTOTYPE_NOW)).toBe("overdue"); + expect(deriveReviewState("", PROTOTYPE_NOW)).toBe("overdue"); + expect(deriveReviewState("2026-09-01T09:00:00+08:00", "not a date")).toBe("overdue"); + }); - for (const version of currentVersions) { - expect(version.reviewDueAt).not.toBeNull(); - expect(version.reviewState).toBe(deriveReviewState(version.reviewDueAt as string, PROTOTYPE_NOW)); - } + // Review state is never stored on a version, so these pin that each fixture's + // own reviewDueAt still derives the state its scenario is meant to demonstrate. + it.each([ + ["SYN-MGMT-VERSION-002", "within_review"], + ["SYN-MGMT-VERSION-003", "overdue"], + ])("derives the intended review state for management version %s", (id, expected) => { + const version = syntheticManagementPlanVersions.find((candidate) => candidate.id === id); + expect(version?.reviewDueAt).toBeTruthy(); + expect(deriveReviewState(version?.reviewDueAt as string, PROTOTYPE_NOW)).toBe(expected); + }); - expect(currentVersions.map(({ reviewState }) => reviewState).sort()).toEqual(["overdue", "within_review"]); + it.each([ + ["SYN-SAFETY-VERSION-001", "due_soon"], + ["SYN-SAFETY-VERSION-002", "within_review"], + ["SYN-SAFETY-VERSION-004", "within_review"], + ])("derives the intended review state for safety plan version %s", (id, expected) => { + const version = syntheticPersonalSafetyPlanVersions.find((candidate) => candidate.id === id); + expect(version?.reviewDueAt).toBeTruthy(); + expect(deriveReviewState(version?.reviewDueAt as string, PROTOTYPE_NOW)).toBe(expected); }); - it("covers a due-soon review state somewhere in the fixture set", () => { - const safetyStates = syntheticPersonalSafetyPlanVersions - .filter(({ reviewDueAt: due }) => due !== null) - .map(({ reviewDueAt: due }) => deriveReviewState(due as string, PROTOTYPE_NOW)); - const managementStates = syntheticManagementPlanVersions - .filter(({ reviewDueAt: due }) => due !== null) - .map(({ reviewDueAt: due }) => deriveReviewState(due as string, PROTOTYPE_NOW)); + it("covers all three review states across the fixture set", () => { + const derived = [...syntheticManagementPlanVersions, ...syntheticPersonalSafetyPlanVersions] + .filter(({ reviewDueAt }) => reviewDueAt !== null) + .map(({ reviewDueAt }) => deriveReviewState(reviewDueAt as string, PROTOTYPE_NOW)); - expect([...safetyStates, ...managementStates]).toContain("due_soon"); + expect(new Set(derived)).toEqual(new Set(["within_review", "due_soon", "overdue"])); }); }); @@ -545,12 +601,22 @@ describe("Care Plan fixture safety", () => { } }); - it("names who agreed the ED approach and when, on every version that has one", () => { + it("names who agreed the ED approach and when, in one clause, on every version", () => { + // The guard must reject a named party with no date, and a date that merely + // appears somewhere else in the block. + expect(AGREED_WITH_NAMED_PARTIES_AND_DATE.test("Agreed with the team.")).toBe(false); + expect(AGREED_WITH_NAMED_PARTIES_AND_DATE.test("Agreed with Rowan and the CMHT. Reviewed again in 2026.")).toBe( + false, + ); + expect(AGREED_WITH_NAMED_PARTIES_AND_DATE.test("Agreed on 20 May 2026.")).toBe(false); + expect( + AGREED_WITH_NAMED_PARTIES_AND_DATE.test("Agreed with Rowan, the North River CMHT and Dr Taylor on 20 May 2026."), + ).toBe(true); + for (const version of syntheticManagementPlanVersions) { if (version.content.agreedEdApproach.length === 0) continue; - const agreed = version.content.agreedEdApproach.join(" "); - expect(agreed).toMatch(/agreed (with|by|at|on)|agreed .* on \d{1,2} \w+ 20\d{2}/i); - expect(agreed).toMatch(/20\d{2}/); + const namesBoth = version.content.agreedEdApproach.some((line) => AGREED_WITH_NAMED_PARTIES_AND_DATE.test(line)); + expect(namesBoth, `${version.id} does not name who agreed the ED approach and when`).toBe(true); } }); @@ -579,32 +645,85 @@ describe("Care Plan fixture safety", () => { const haystack = serialisedFixtures.toLowerCase(); for (const label of bannedLabels) { - expect(haystack).not.toContain(label); + expect(haystack, `fixtures use the banned label "${label}"`).not.toContain(label); } }); - it("writes What makes it worse about the service rather than about the person", () => { - const currentVersions = syntheticManagementPlanVersions.filter(({ state }) => state === "current"); - expect(currentVersions.length).toBeGreaterThan(0); + it("uses the glossary's preferred terms and none of its banned synonyms", () => { + // docs/care-plan-context.md bans these as names for the domain concepts. The + // noun forms have no legitimate use here; "visit" is banned only in the ED + // sense, because a CMHT home visit is a different thing the glossary does not + // govern. + const bannedNouns = [ + "attendance", + "attendances", + "encounter", + "encounters", + "frequent-presenter flag", + "automatic enrolment", + "treatment order", + "ed note", + "expired plan", + "deleted plan", + "latest plan", + "active draft", + "contact completed", + "message sent", + "activity feed", + "communication log", + "effectiveness verdict", + "compliance score", + "case-management inbox", + ]; + const haystack = serialisedFixtures.toLowerCase(); + for (const term of bannedNouns) { + expect(haystack, `fixtures use the glossary-banned term "${term}"`).not.toContain(term); + } - for (const version of currentVersions) { - const worse = version.content.whatMakesItWorse; - expect(worse.length).toBeGreaterThan(0); - expect(worse.join(" ")).toMatch(/corridor|wait|history|security|handover|noise|light|room|staff|department/i); - for (const line of worse) { - expect(line).not.toMatch(/\b(he|she|they) (is|are|becomes|gets) (aggressive|demanding|difficult|hostile)\b/i); + const edSenseOfVisit = [ + /\b(?:ed|emergency department|emergency|hospital)\s+visits?\b/i, + /\bvisits?\s+to\s+(?:the\s+)?(?:ed|emergency)/i, + /\b(?:each|every|per)\s+visit\b/i, + ]; + for (const pattern of edSenseOfVisit) { + expect(pattern.test(serialisedFixtures), `fixtures use "visit" for an ED Presentation: ${pattern}`).toBe(false); + } + }); + + it("writes What makes it worse about the service rather than about the person", () => { + // Negative controls. Each of these passed the previous keyword-based guard. + expect(describesTheService("Rowan gets aggressive when kept waiting too long")).toBe(false); + expect(describesTheService("She becomes demanding and difficult when the wait is long.")).toBe(false); + expect(describesTheService("The patient refuses to engage and is often hostile in the waiting room.")).toBe(false); + expect(describesTheService("Long waits make Rowan aggressive towards staff.")).toBe(false); + // Positive controls, including a response written as a response to circumstances. + expect(describesTheService("Waiting in the main corridor within sight of the ambulance bay.")).toBe(true); + expect(describesTheService("Long waits in the corridor without a chair. Her back pain builds.")).toBe(true); + + // Every version, not only the Current ones: a draft becomes a Current Plan. + for (const version of syntheticManagementPlanVersions) { + expect(version.content.whatMakesItWorse.length).toBeGreaterThan(0); + for (const line of version.content.whatMakesItWorse) { + expect(describesTheService(line), `${version.id}: ${line}`).toBe(true); } } }); it("names concrete new findings in What would make this different, not generic caution", () => { + // Negative controls. The first passed the previous exact-string guard. + expect(namesConcreteFindings("Use your clinical judgement at all times in every case.")).toBe(false); + expect(namesConcreteFindings("Exercise caution and stay alert to anything out of the ordinary here.")).toBe(false); + expect(namesConcreteFindings("Chest pain.")).toBe(false); + // Positive control. + expect( + namesConcreteFindings("New or worsening physical symptoms: chest pain, breathlessness, fever, or a seizure."), + ).toBe(true); + for (const version of syntheticManagementPlanVersions) { const boundary = version.content.whatWouldMakeThisDifferent; - if (boundary.length === 0) continue; - expect(boundary.length).toBeGreaterThanOrEqual(3); + expect(boundary.length, `${version.id} names too few voiding findings`).toBeGreaterThanOrEqual(3); for (const line of boundary) { - expect(line.length).toBeGreaterThan(25); - expect(line).not.toMatch(/^\s*(use|exercise|apply)\s+(clinical\s+)?(judgement|judgment|caution)\s*\.?\s*$/i); + expect(namesConcreteFindings(line), `${version.id}: ${line}`).toBe(true); } } }); @@ -632,6 +751,76 @@ describe("Care Plan fixture safety", () => { } }); + it("keeps every count claimed in fixture prose consistent with the episode records", () => { + // Task 4 renders the objective count beside this prose. If the two disagree, + // the objective count stops being the thing that settles the question. + const numberWords: Record = { + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10, + once: 1, + twice: 2, + }; + const claimPatterns = [ + /\b(one|two|three|four|five|six|seven|eight|nine|ten)\s+(?:ed\s+)?presentations?\b/gi, + /\bon\s+(one|two|three|four|five)\s+occasions?\b/gi, + /\b(once|twice)\s+(?:leaving|left|presenting|attending)\b/gi, + ]; + let claimsChecked = 0; + + for (const patient of syntheticPatients) { + const episodes = syntheticEdPresentations.filter(({ patientId }) => patientId === patient.id); + const total = episodes.length; + const endedEarly = episodes.filter(({ disposition }) => disposition === "left_before_completion").length; + + const prose: string[] = []; + for (const version of syntheticManagementPlanVersions.filter( + ({ planId }) => planId === patient.managementPlanId, + )) { + collectStrings(version.content, () => true, prose); + prose.push(version.revisionReason, version.returnedReason ?? "", version.withdrawalReason ?? ""); + } + for (const version of syntheticPersonalSafetyPlanVersions.filter( + ({ planId }) => planId === patient.personalSafetyPlanId, + )) { + prose.push(version.collaborationNote); + } + for (const review of syntheticIdentificationReviews.filter(({ patientId }) => patientId === patient.id)) { + prose.push(review.reason, review.decisionReason ?? ""); + } + for (const trigger of syntheticReviewTriggers.filter(({ patientId }) => patientId === patient.id)) { + prose.push(trigger.reason, trigger.resolution ?? ""); + } + + for (const sentence of prose) { + // A claim about episodes that ended early is measured against that subset; + // any other episode count is measured against the patient's whole record. + const aboutEarlyExit = /left before|leaving before|ended before assessment/i.test(sentence); + const expected = aboutEarlyExit ? endedEarly : total; + + for (const pattern of claimPatterns) { + for (const match of sentence.matchAll(pattern)) { + const claimed = numberWords[(match[1] as string).toLowerCase()]; + claimsChecked += 1; + expect(claimed, `${patient.id} prose claims a count the records do not support: "${sentence}"`).toBe( + expected, + ); + } + } + } + } + + // Guards against the sweep going quiet and passing over nothing. + expect(claimsChecked).toBeGreaterThanOrEqual(4); + }); + it("orders the first-minute keys exactly as the summary card renders them", () => { expect(FIRST_MINUTE_CONTENT_KEYS).toEqual([ "howToApproach", From 7b286457248d68f883bec115c399891f0a952de7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:28:47 +0800 Subject: [PATCH 10/56] docs(care-plan): derive review state instead of storing it Task 1 review found ManagementPlanVersion.reviewState and PersonalSafetyPlanVersion.reviewState restating what deriveReviewState(reviewDueAt, now) computes, with a consistency test covering only the first of the two. A stored copy of a currency indicator can drift from the date it claims to describe, and this one is read to decide whether a clinical plan is still trustworthy. Removes both fields from the canonical block rather than adding the missing test, since nothing consumes them yet. Records that an unparseable date derives overdue, so a malformed value surfaces rather than reassuring the reader. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-20-care-plan-implementation.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-care-plan-implementation.md b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md index 7db86901c..99c7d7319 100644 --- a/docs/superpowers/plans/2026-08-20-care-plan-implementation.md +++ b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md @@ -132,6 +132,12 @@ export type PrototypeRole = export type ManagementPlanVersionState = "draft" | "awaiting_approval" | "current" | "superseded" | "withdrawn"; export type SafetyPlanVersionState = "draft" | "current" | "superseded"; export type ReviewState = "within_review" | "due_soon" | "overdue"; +/** Review state is ALWAYS derived from `reviewDueAt` via `deriveReviewState`, + * never stored on a version. A stored copy of a currency indicator can drift + * from the date it claims to describe, and this one is read to decide whether + * a clinical plan is still trustworthy. `deriveReviewState` treats an + * unparseable date as `overdue`, so a malformed value surfaces rather than + * reassuring the reader. */ export type ParticipationState = "co_produced" | "discussed" | "declined" | "patient_unavailable"; export type PatientConfirmationState = "confirmed" | "discussed_not_confirmed" | "declined" | "unavailable"; export type Disposition = @@ -338,7 +344,6 @@ export type ManagementPlanVersion = { planId: SyntheticId; version: number; state: ManagementPlanVersionState; - reviewState: ReviewState | null; authorId: SyntheticId; ownerId: SyntheticId; approverId: SyntheticId | null; @@ -370,7 +375,6 @@ export type PersonalSafetyPlanVersion = { planId: SyntheticId; version: number; state: SafetyPlanVersionState; - reviewState: ReviewState | null; authorId: SyntheticId; createdAt: string; confirmedAt: string | null; From 8652e73fff6b905f359b1af0715e6725c3edbdaa Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:17:24 +0800 Subject: [PATCH 11/56] fix(care-plan): guard fixture prose against impossible chronology Task review round 2. Evelyn's referral claimed she had presented since her plan was withdrawn, but it was written seven days after the withdrawal and 24 days before the only post-withdrawal presentation. Move the referral to two days after that presentation, so the sentence is true and the referral follows the event that prompted it. Add a chronology guard over IdentificationReview.reason and ManagementPlanVersion.withdrawalReason: a reason citing a presentation must have at least one that falls before the record's own timestamp, and a reason saying "since" a withdrawal must have one between the two. Proven by reverting the date and watching it fail. Co-Authored-By: Claude Opus 5 --- src/components/care-plan/mockups/fixtures.ts | 2 +- tests/care-plan-domain.test.ts | 62 ++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/components/care-plan/mockups/fixtures.ts b/src/components/care-plan/mockups/fixtures.ts index 70a74a3f6..ac472d982 100644 --- a/src/components/care-plan/mockups/fixtures.ts +++ b/src/components/care-plan/mockups/fixtures.ts @@ -1410,7 +1410,7 @@ export const syntheticIdentificationReviews = [ reason: "Evelyn's plan was withdrawn in July and she has presented again since. Referred so the team can decide whether to write a new plan with her.", referredBy: "SYN-USER-LIAISON-001", - referredAt: daysAgo(40, "11:45"), + referredAt: daysAgo(14, "11:45"), status: "open", decision: null, decisionReason: null, diff --git a/tests/care-plan-domain.test.ts b/tests/care-plan-domain.test.ts index a78575dcc..fe7a6896e 100644 --- a/tests/care-plan-domain.test.ts +++ b/tests/care-plan-domain.test.ts @@ -751,6 +751,68 @@ describe("Care Plan fixture safety", () => { } }); + it("never claims in a reason that something had happened before it had", () => { + // The count sweep checks how many; this checks when. A reason string is written + // at a moment in time and cannot cite an episode that had not happened yet. + const arrivedBefore = (arrivals: readonly string[], writtenAt: string): boolean => + arrivals.some((arrival) => Date.parse(arrival) < Date.parse(writtenAt)); + const arrivedBetween = (arrivals: readonly string[], from: string, to: string): boolean => + arrivals.some((arrival) => Date.parse(arrival) > Date.parse(from) && Date.parse(arrival) < Date.parse(to)); + + // Negative controls carrying the exact dates of the defect this closes: a + // referral written on 11 July citing a presentation that happened on 4 August. + expect(arrivedBefore(["2026-08-04T11:10:00+08:00"], "2026-07-11T11:45:00+08:00")).toBe(false); + expect( + arrivedBetween(["2026-08-04T11:10:00+08:00"], "2026-07-04T16:20:00+08:00", "2026-07-11T11:45:00+08:00"), + ).toBe(false); + // Positive control: the corrected referral date. + expect( + arrivedBetween(["2026-08-04T11:10:00+08:00"], "2026-07-04T16:20:00+08:00", "2026-08-06T11:45:00+08:00"), + ).toBe(true); + + const citesAPresentation = /presented|presentation|attended|came in|brought in|has been in/i; + const citesSinceWithdrawal = /withdraw\w*[^.]*\bsince\b|\bsince\b[^.]*withdraw/i; + const arrivalsFor = (patientId: string): string[] => + syntheticEdPresentations + .filter((presentation) => presentation.patientId === patientId) + .map((presentation) => presentation.arrivedAt); + let claimsChecked = 0; + + for (const review of syntheticIdentificationReviews) { + if (!citesAPresentation.test(review.reason)) continue; + claimsChecked += 1; + const arrivals = arrivalsFor(review.patientId); + expect( + arrivedBefore(arrivals, review.referredAt), + `${review.id} cites a presentation that had not happened when it was written`, + ).toBe(true); + + if (!citesSinceWithdrawal.test(review.reason)) continue; + const patient = syntheticPatients.find(({ id }) => id === review.patientId); + const withdrawn = syntheticManagementPlanVersions.find( + (version) => version.planId === patient?.managementPlanId && version.state === "withdrawn", + ); + expect(withdrawn?.withdrawnAt, `${review.id} says "since the withdrawal" with no withdrawn version`).toBeTruthy(); + expect( + arrivedBetween(arrivals, withdrawn?.withdrawnAt as string, review.referredAt), + `${review.id} says a presentation happened since the withdrawal, but none falls between them`, + ).toBe(true); + } + + for (const version of syntheticManagementPlanVersions) { + if (version.withdrawalReason === null || !citesAPresentation.test(version.withdrawalReason)) continue; + claimsChecked += 1; + const plan = syntheticManagementPlans.find(({ id }) => id === version.planId); + expect( + arrivedBefore(arrivalsFor(plan?.patientId as string), version.withdrawnAt as string), + `${version.id} cites a presentation that had not happened when it was withdrawn`, + ).toBe(true); + } + + // Guards against the sweep going quiet and passing over nothing. + expect(claimsChecked).toBeGreaterThanOrEqual(3); + }); + it("keeps every count claimed in fixture prose consistent with the episode records", () => { // Task 4 renders the objective count beside this prose. If the two disagree, // the objective count stops being the thing that settles the question. From e796775c9d633101b373877acba58f2d78c938c0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:11:09 +0800 Subject: [PATCH 12/56] feat(care-plan): enforce longitudinal plan lifecycles One pure reducer now enforces the whole Management Plan, Personal Safety Plan, ED Presentation, and Identification Review lifecycle, and one layout-scoped provider shares that state across every route. The reducer is a plain (state, action) => state function: no clock, network, storage, browser API, randomness, or module-level mutable state. Timestamps derive from PROTOTYPE_NOW and identifiers from the identifiers already in state, so an action sequence reproduces byte for byte. Permission and degraded-state checks share one funnel, so no transition can reach a clinical record without being rechecked against canPerformAction. Approval is atomic and leaves exactly one Current Plan; withdrawal leaves none and restores nothing; ED Presentations stay append-only with attributed corrections beside them; contact and print actions record an intent and never claim an outcome. Co-Authored-By: Claude Opus 5 --- .../care-plan/mockups/prototype-provider.tsx | 61 + .../care-plan/mockups/prototype-state.ts | 1394 +++++++++++++++++ src/components/care-plan/mockups/types.ts | 51 + tests/care-plan-prototype-state.test.ts | 946 +++++++++++ 4 files changed, 2452 insertions(+) create mode 100644 src/components/care-plan/mockups/prototype-provider.tsx create mode 100644 src/components/care-plan/mockups/prototype-state.ts create mode 100644 tests/care-plan-prototype-state.test.ts diff --git a/src/components/care-plan/mockups/prototype-provider.tsx b/src/components/care-plan/mockups/prototype-provider.tsx new file mode 100644 index 000000000..980cbe525 --- /dev/null +++ b/src/components/care-plan/mockups/prototype-provider.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { createContext, useContext, useEffect, useMemo, useReducer, useRef, type ReactNode } from "react"; + +import { createInitialPrototypeState, prototypeReducer } from "./prototype-state"; +import type { CarePlanPrototypeAction, CarePlanPrototypeState } from "./types"; + +type CarePlanPrototypeContextValue = { + state: CarePlanPrototypeState; + dispatch: (action: CarePlanPrototypeAction) => void; +}; + +const CarePlanPrototypeContext = createContext(null); + +/** + * One provider owns the whole synthetic application state, so every route reads + * the same record rather than keeping a copy of its own. It is mounted once, in + * the route-family layout, and it performs no persistence and no network access + * of any kind: nothing is saved, and reloading the page starts over. + */ +export function CarePlanPrototypeProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(prototypeReducer, undefined, () => createInitialPrototypeState()); + + // The reducer must stay a pure function of the state it is given, so the + // browser's own connectivity is read here and only here, and reaches the + // state as an ordinary dispatched action like everything else. + const scenarioRef = useRef(state.scenario); + useEffect(() => { + scenarioRef.current = state.scenario; + }, [state.scenario]); + + useEffect(() => { + // Only the offline specimen is entered and left by these events. A spurious + // event while another specimen is displayed changes nothing, so a flicker in + // the network connection cannot discard work that is only held in memory. + const handleOnline = () => { + if (scenarioRef.current === "offline") dispatch({ type: "apply-scenario", scenario: "normal" }); + }; + const handleOffline = () => { + if (scenarioRef.current !== "offline") dispatch({ type: "apply-scenario", scenario: "offline" }); + }; + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + const value = useMemo(() => ({ state, dispatch }), [state]); + + return {children}; +} + +export function useCarePlanPrototype(): CarePlanPrototypeContextValue { + const value = useContext(CarePlanPrototypeContext); + if (value === null) { + throw new Error("Care Plan prototype surfaces must be rendered inside CarePlanPrototypeProvider."); + } + return value; +} diff --git a/src/components/care-plan/mockups/prototype-state.ts b/src/components/care-plan/mockups/prototype-state.ts new file mode 100644 index 000000000..52a476606 --- /dev/null +++ b/src/components/care-plan/mockups/prototype-state.ts @@ -0,0 +1,1394 @@ +/** + * Care Plan — the pure lifecycle reducer. + * + * `prototypeReducer` is a plain `(state, action) => state` function. It reads no + * clock, network, storage, browser API, or random source, and it holds no + * module-level mutable state: every timestamp is derived from `PROTOTYPE_NOW` + * and every identifier from the identifiers already in the state it was given, + * so the same action sequence always produces the same state byte for byte. + * + * The reducer is the final guard, not the interface. Every changing action + * rechecks the acting role through `canPerformAction` and every degraded state + * through `getPrototypeMutationBlockReason`, so a control that should never have + * been offered still cannot change a clinical record. Nothing here mutates an + * array or an object in place; every change replaces the whole value. + * + * Australian English, `en-AU`, `Australia/Perth`, ISO source timestamps. + */ + +import { + addIsoMonths, + assertSingleCurrentVersion, + canPerformAction, + getCurrentManagementPlanVersion, + getCurrentSafetyPlanVersion, + getOpenManagementDraft, +} from "./domain"; +import { + PROTOTYPE_NOW, + identificationPolicy, + syntheticCmhtContacts, + syntheticEdPresentations, + syntheticEdSites, + syntheticIdentificationReviews, + syntheticManagementPlanVersions, + syntheticManagementPlans, + syntheticPatients, + syntheticPersonalSafetyPlanVersions, + syntheticPersonalSafetyPlans, + syntheticPresentationAmendments, + syntheticReviewTriggers, + syntheticUsers, +} from "./fixtures"; +import { + MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS, + REVIEW_INTERVAL_MONTHS, + type AmendableField, + type AuditEvent, + type AuditEventType, + type CarePlanPrototypeAction, + type CarePlanPrototypeState, + type CmhtContact, + type Disposition, + type EdPresentation, + type EdSite, + type IdentificationDecision, + type IdentificationPolicy, + type IdentificationReview, + type ManagementPlan, + type ManagementPlanContent, + type ManagementPlanVersion, + type Patient, + type PersonalSafetyPlan, + type PersonalSafetyPlanVersion, + type PlanAvailability, + type PlanHelpfulness, + type PlanUse, + type PresentationAmendment, + type PrototypeCapability, + type PrototypeOutcome, + type PrototypeRole, + type PrototypeScenario, + type PrototypeUser, + type ReviewTrigger, + type SafetyPlanContent, + type SyntheticId, +} from "./types"; + +// --- Deterministic clock and identifiers -------------------------------------- + +const PERTH_OFFSET_SUFFIX = "+08:00"; +const MILLISECONDS_PER_MINUTE = 60_000; +const PERTH_OFFSET_MILLISECONDS = 8 * 60 * MILLISECONDS_PER_MINUTE; + +/** Australia/Perth never observes daylight saving, so a fixed offset is exact. */ +function addIsoMinutes(iso: string, minutes: number): string { + const shifted = new Date(Date.parse(iso) + PERTH_OFFSET_MILLISECONDS + minutes * MILLISECONDS_PER_MINUTE); + return `${shifted.toISOString().slice(0, 19)}${PERTH_OFFSET_SUFFIX}`; +} + +/** + * The prototype clock. `PROTOTYPE_NOW` advanced by one minute for every audit + * event already recorded, plus a stable offset for each further record the same + * action appends. It is a function of the state it is given, so it never reads a + * wall clock and never returns the same timestamp twice in one sequence. + */ +function prototypeTimestamp(state: CarePlanPrototypeState, offsetMinutes = 0): string { + return addIsoMinutes(PROTOTYPE_NOW, state.auditEvents.length + 1 + offsetMinutes); +} + +/** + * The next identifier in a `SYN-` series, derived from the identifiers already + * present. Deterministic by construction: no counter, clock, or random source. + */ +export function nextSyntheticId(prefix: string, existingIds: readonly string[]): SyntheticId { + const pattern = new RegExp(`^${prefix}-(\\d+)$`); + const highest = existingIds.reduce((largest, id) => { + const matched = pattern.exec(id); + return matched === null ? largest : Math.max(largest, Number(matched[1])); + }, 0); + return `${prefix}-${String(highest + 1).padStart(3, "0")}` as SyntheticId; +} + +export function nextPresentationId(state: CarePlanPrototypeState): SyntheticId { + return nextSyntheticId( + "SYN-PRESENTATION", + state.edPresentations.map(({ id }) => id), + ); +} + +// --- Initial state ------------------------------------------------------------- + +/** + * Every value in the state tree is plain JSON, so a round trip is an exact and + * obviously deterministic copy. Copying rather than sharing matters here: two + * versions of one clinical record must never share the array a later in-place + * edit could reach through. + */ +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function cloneFixtures(values: readonly T[]): T[] { + return JSON.parse(JSON.stringify(values)) as T[]; +} + +/** + * Which synthetic patient each specimen scenario opens on, so the scenario name + * describes what is actually on screen. Rowan is the default: a Current Plan + * that is within review. + */ +const SCENARIO_PATIENT: Record = { + normal: "SYN-PATIENT-001", + empty: null, + "no-current-plan": "SYN-PATIENT-003", + "overdue-plan": "SYN-PATIENT-002", + "withdrawn-plan": "SYN-PATIENT-004", + "unverified-contact": "SYN-PATIENT-003", + // A record is selected so the interface has something to refuse to show. The + // uncertainty flag is what stops a nearby person's plan being displayed. + "identity-uncertain": "SYN-PATIENT-001", + "version-conflict": "SYN-PATIENT-002", + offline: "SYN-PATIENT-001", + "permission-unavailable": "SYN-PATIENT-001", + "launch-failure": "SYN-PATIENT-001", + "print-failure": "SYN-PATIENT-001", +}; + +/** + * The deterministic starting world. It deep-clones the fixtures so nothing in a + * session can reach back and change them, and derives the four degraded-state + * 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. + */ +export function createInitialPrototypeState(scenario: PrototypeScenario = "normal"): CarePlanPrototypeState { + return { + scenario, + persistence: "memory-only", + activeUserId: "SYN-USER-ED-001", + selectedPatientId: SCENARIO_PATIENT[scenario], + connectivity: { online: scenario !== "offline" }, + permission: { available: scenario !== "permission-unavailable" }, + identity: { certain: scenario !== "identity-uncertain" }, + versionConflict: { active: scenario === "version-conflict" }, + users: cloneFixtures(syntheticUsers), + patients: cloneFixtures(syntheticPatients), + edSites: cloneFixtures(syntheticEdSites), + cmhtContacts: cloneFixtures(syntheticCmhtContacts), + managementPlans: cloneFixtures(syntheticManagementPlans), + managementPlanVersions: cloneFixtures(syntheticManagementPlanVersions), + personalSafetyPlans: cloneFixtures(syntheticPersonalSafetyPlans), + personalSafetyPlanVersions: cloneFixtures(syntheticPersonalSafetyPlanVersions), + patientPlans: [], + patientPlanVersions: [], + patientResources: [], + edPresentations: cloneFixtures(syntheticEdPresentations), + presentationAmendments: cloneFixtures(syntheticPresentationAmendments), + reviewTriggers: cloneFixtures(syntheticReviewTriggers), + identificationPolicy: cloneJson(identificationPolicy), + identificationReviews: cloneFixtures(syntheticIdentificationReviews), + auditEvents: [], + lastOutcome: null, + }; +} + +// --- Permission and degraded-state guard --------------------------------------- + +const ROLE_LABEL: Record = { + ed_clinician: "emergency department clinician", + liaison_clinician: "emergency department mental health liaison clinician", + cmht_clinician: "community mental health team clinician", + senior_clinician: "named senior clinician", + plan_coordinator: "care planning coordinator", +}; + +/** + * The capability each action needs. `null` means the action changes no clinical + * record — choosing who is signed in, which record is open, or which specimen + * scenario is displayed — so it carries no permission or degraded-state gate. + */ +const CAPABILITY_BY_ACTION: Record = { + "select-patient": null, + "set-active-user": null, + "create-management-draft": "author_management_draft", + "save-management-draft": "author_management_draft", + "submit-management-draft": "submit_management_draft", + "return-management-version": "approve_management_version", + "approve-management-version": "approve_management_version", + "withdraw-current-management-version": "withdraw_management_version", + "record-formal-management-review": "record_formal_review", + "record-presentation": "record_presentation", + "amend-presentation": "record_presentation", + "create-safety-plan-draft": "author_safety_plan", + "save-safety-plan-draft": "author_safety_plan", + "make-safety-plan-current": "author_safety_plan", + "record-safety-plan-print-intent": "read_plan", + "record-contact-intent": "contact_cmht", + "create-identification-review": "refer_for_identification_review", + "close-identification-review": "close_identification_review", + "verify-cmht-contact": "verify_cmht_contact", + "resolve-review-trigger": "manage_worklists", + "apply-scenario": null, + "clear-outcome": null, + reset: null, +}; + +/** + * The single funnel every changing action passes through, so no transition can + * reach a clinical record without being rechecked here. Each refusal names + * itself, so the interface can say what happened, what it means, and what is + * available, and every one of them leaves the clinical record untouched. + * + * Returns the reason the action cannot proceed, or `null` when it may. + */ +export function getPrototypeMutationBlockReason( + state: CarePlanPrototypeState, + action: CarePlanPrototypeAction, +): string | null { + const capability = CAPABILITY_BY_ACTION[action.type]; + if (capability === null) return null; + + if (!state.connectivity.online) { + return "This device is offline, so nothing was changed. What is shown is the last synthetic state held in memory."; + } + if (!state.permission.available) { + return "Permission for this action could not be confirmed, so nothing was changed."; + } + if (!state.identity.certain) { + return "This record has not been confirmed as the right person, so nothing was changed. Return to search and choose the record again."; + } + if (state.versionConflict.active) { + return "A newer version of this record exists in the synthetic prototype, so nothing was changed. Compare the two versions before deciding."; + } + + const actor = state.users.find(({ id }) => id === state.activeUserId) ?? null; + if (actor === null) { + return "No synthetic user is selected, so no action can be attributed and nothing was changed."; + } + if (!canPerformAction(actor.role, capability)) { + return `${actor.displayName} is signed in with the ${ROLE_LABEL[actor.role]} role, which does not carry this action. Nothing was changed.`; + } + return null; +} + +// --- Small shared helpers ------------------------------------------------------- + +function isBlank(value: string): boolean { + return value.trim().length === 0; +} + +/** Free text a clinician typed, placed inside an audit sentence. Without this + * a reason with no full stop runs straight into the sentence after it. */ +function asSentence(text: string): string { + const trimmed = text.trim(); + return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; +} + +function isParsableTimestamp(value: string): boolean { + return !isBlank(value) && !Number.isNaN(Date.parse(value)); +} + +function refuse( + state: CarePlanPrototypeState, + message: string, + kind: PrototypeOutcome["kind"] = "error", +): CarePlanPrototypeState { + return { ...state, lastOutcome: { kind, message } }; +} + +type AuditDraft = { + type: AuditEventType; + patientId: SyntheticId | null; + objectId: SyntheticId; + evidence: string; +}; + +/** + * Appends one attributed audit event. `evidence` describes only what this + * application actually did — never that a message reached anyone, that a call + * was answered, or that a page was printed. + */ +function withAudit(state: CarePlanPrototypeState, draft: AuditDraft, offsetMinutes = 0): AuditEvent[] { + return [ + ...state.auditEvents, + { + id: nextSyntheticId( + "SYN-AUDIT", + state.auditEvents.map(({ id }) => id), + ), + type: draft.type, + patientId: draft.patientId, + objectId: draft.objectId, + actorId: state.activeUserId, + occurredAt: prototypeTimestamp(state, offsetMinutes), + evidence: draft.evidence, + }, + ]; +} + +function findPatient(state: CarePlanPrototypeState, patientId: SyntheticId): Patient | null { + return state.patients.find(({ id }) => id === patientId) ?? null; +} + +function findManagementPlan(state: CarePlanPrototypeState, patient: Patient): ManagementPlan | null { + return state.managementPlans.find(({ id }) => id === patient.managementPlanId) ?? null; +} + +function findSafetyPlan(state: CarePlanPrototypeState, patient: Patient): PersonalSafetyPlan | null { + return state.personalSafetyPlans.find(({ id }) => id === patient.personalSafetyPlanId) ?? null; +} + +function patientOfManagementPlan(state: CarePlanPrototypeState, planId: SyntheticId): SyntheticId | null { + return state.managementPlans.find(({ id }) => id === planId)?.patientId ?? null; +} + +function patientOfSafetyPlan(state: CarePlanPrototypeState, planId: SyntheticId): SyntheticId | null { + return state.personalSafetyPlans.find(({ id }) => id === planId)?.patientId ?? null; +} + +function nextVersionNumber(versions: readonly { planId: SyntheticId; version: number }[], planId: SyntheticId): number { + return ( + versions + .filter((version) => version.planId === planId) + .reduce((highest, { version }) => Math.max(highest, version), 0) + 1 + ); +} + +const EMPTY_MANAGEMENT_CONTENT: ManagementPlanContent = { + howToApproach: [], + whatHelps: [], + whatMakesItWorse: [], + agreedEdApproach: [], + whatWouldMakeThisDifferent: [], + whyThisPlanExists: "", + whatThePersonWants: [], + practicalNeeds: [], + physicalHealthAndMedication: [], + whoElseIsInvolved: [], + reviewTriggers: [], +}; + +const EMPTY_SAFETY_CONTENT: SafetyPlanContent = { + warningSigns: [], + saferSurroundings: [], + reasonsForLiving: [], + selfStrategies: [], + connectionPeopleAndPlaces: [], + personalSupports: [], + professionalAndEmergencySupport: [], +}; + +function missingRequiredContentKeys(content: ManagementPlanContent): string[] { + return MANAGEMENT_PLAN_REQUIRED_CONTENT_KEYS.filter((key) => { + const value = content[key]; + return typeof value === "string" ? isBlank(value) : !value.some((line) => !isBlank(line)); + }); +} + +const DISPOSITIONS: readonly Disposition[] = [ + "discharged_home", + "short_stay", + "mental_health_admission", + "medical_admission", + "transfer", + "left_before_completion", + "other", +]; +const PLAN_AVAILABILITIES: readonly PlanAvailability[] = ["available", "unavailable", "not_applicable"]; +const PLAN_USES: readonly PlanUse[] = ["used", "partially_used", "not_used", "not_applicable"]; +const PLAN_HELPFULNESS_VALUES: readonly PlanHelpfulness[] = ["helpful", "mixed", "not_helpful", "not_assessed"]; +const IDENTIFICATION_DECISIONS: readonly IdentificationDecision[] = [ + "proceed_to_plan", + "not_needed_now", + "revisit_later", +]; + +const AMENDABLE_FIELDS: readonly AmendableField[] = [ + "assessmentOutcome", + "disposition", + "note", + "planAvailability", + "planUse", + "planHelpfulness", +]; + +/** The amendable fields whose replacement must parse to a recorded answer. The + * two free-text fields are absent because any prose is a legitimate correction. */ +const AMENDABLE_FIELD_ANSWERS: Partial> = { + disposition: DISPOSITIONS, + planAvailability: PLAN_AVAILABILITIES, + planUse: PLAN_USES, + planHelpfulness: PLAN_HELPFULNESS_VALUES, +}; + +/** + * The value a correction is replacing: the latest amendment for that field when + * the field has already been corrected, otherwise the recorded original. The + * episode itself is never rewritten, so the stored original always survives. + */ +function effectiveFieldValue( + state: CarePlanPrototypeState, + presentation: EdPresentation, + field: AmendableField, +): string { + const latest = state.presentationAmendments + .filter((amendment) => amendment.presentationId === presentation.id && amendment.field === field) + .at(-1); + return latest ? latest.replacementValue : presentation[field]; +} + +/** + * Whether one episode gives the team a reason to reconsider the Current Plan, + * and what to record as that reason. A trigger never changes a plan; it asks a + * person to look. The first matching reason wins, so one episode raises at most + * one trigger. + */ +function reviewTriggerReasonFor( + presentation: EdPresentation, +): { source: ReviewTrigger["source"]; reason: string } | null { + if (presentation.reviewSuggested && presentation.reviewReason !== null && !isBlank(presentation.reviewReason)) { + return { source: "plan_use_feedback", reason: presentation.reviewReason.trim() }; + } + if (presentation.planHelpfulness === "not_helpful") { + return { + source: "plan_use_feedback", + reason: + "The plan-use feedback recorded for this episode says the Current Plan did not help. The team should look at whether it still describes what happens.", + }; + } + if (presentation.planHelpfulness === "mixed") { + return { + source: "plan_use_feedback", + reason: + "The plan-use feedback recorded for this episode says the Current Plan helped only in part. The team should look at whether it still describes what happens.", + }; + } + if ( + presentation.deviationOccurred && + presentation.deviationReason !== null && + !isBlank(presentation.deviationReason) + ) { + return { source: "plan_deviation", reason: presentation.deviationReason.trim() }; + } + if (presentation.disposition === "mental_health_admission" || presentation.disposition === "medical_admission") { + return { + source: "presentation_outcome", + reason: + "This episode ended in an admission. The team should check whether the Current Plan still describes what happens.", + }; + } + return null; +} + +// --- The reducer ---------------------------------------------------------------- + +export function prototypeReducer( + state: CarePlanPrototypeState, + action: CarePlanPrototypeAction, +): CarePlanPrototypeState { + const blocked = getPrototypeMutationBlockReason(state, action); + if (blocked !== null) return refuse(state, blocked, "blocked"); + + switch (action.type) { + case "select-patient": { + const patient = findPatient(state, action.patientId); + if (patient === null) return refuse(state, "That synthetic patient record does not exist."); + return { ...state, selectedPatientId: patient.id, lastOutcome: null }; + } + + case "set-active-user": { + const user = state.users.find(({ id }) => id === action.userId) ?? null; + if (user === null) return refuse(state, "That synthetic user does not exist."); + return { ...state, activeUserId: user.id, lastOutcome: null }; + } + + case "create-management-draft": { + const patient = findPatient(state, action.patientId); + if (patient === null) return refuse(state, "That synthetic patient record does not exist."); + const plan = findManagementPlan(state, patient); + if (plan === null) { + return refuse(state, `${patient.preferredName} has no Management Plan record to add a version to.`); + } + if (getOpenManagementDraft(state.managementPlanVersions, plan.id) !== null) { + return refuse( + state, + `A version of ${patient.preferredName}'s Management Plan is already open for editing or awaiting approval. Continue that one rather than starting another.`, + ); + } + + const current = getCurrentManagementPlanVersion(state.managementPlanVersions, plan.id); + const createdAt = prototypeTimestamp(state); + const draft: ManagementPlanVersion = { + id: nextSyntheticId( + "SYN-MGMT-VERSION", + state.managementPlanVersions.map(({ id }) => id), + ), + planId: plan.id, + version: nextVersionNumber(state.managementPlanVersions, plan.id), + state: "draft", + authorId: state.activeUserId, + ownerId: state.activeUserId, + approverId: null, + createdAt, + submittedAt: null, + approvedAt: null, + reviewDueAt: addIsoMonths(createdAt, REVIEW_INTERVAL_MONTHS), + revisionReason: "", + // Nobody has recorded this person's part in this edition yet. Saying they + // took part would put an unearned statement on the record, so the draft + // starts at the answer that carries the visible marker until an author + // replaces it with what actually happened. + participationState: "patient_unavailable", + consentedSupportPeople: [], + returnedReason: null, + withdrawalReason: null, + withdrawnBy: null, + withdrawnAt: null, + sharedWithPatientAt: null, + content: cloneJson(current === null ? EMPTY_MANAGEMENT_CONTENT : current.content), + }; + + return { + ...state, + managementPlanVersions: [...state.managementPlanVersions, draft], + managementPlans: state.managementPlans.map((candidate) => + candidate.id === plan.id ? { ...candidate, versionIds: [...candidate.versionIds, draft.id] } : candidate, + ), + auditEvents: withAudit(state, { + type: "management_draft_created", + patientId: patient.id, + objectId: draft.id, + evidence: + current === null + ? `Draft version ${draft.version} started from an empty Management Plan.` + : `Draft version ${draft.version} started from the content of Current Plan version ${current.version}.`, + }), + lastOutcome: { + kind: "success", + message: `Draft version ${draft.version} created. The Current Plan is unchanged and is still the version in use.`, + }, + }; + } + + case "save-management-draft": { + const version = state.managementPlanVersions.find(({ id }) => id === action.versionId) ?? null; + if (version === null) return refuse(state, "That Management Plan Version does not exist."); + if (version.state !== "draft") { + return refuse( + state, + version.state === "awaiting_approval" + ? `Version ${version.version} is awaiting approval and read-only. Nothing was changed.` + : `Version ${version.version} is ${version.state} and can no longer be edited. Nothing was changed.`, + ); + } + if (!isParsableTimestamp(action.input.reviewDueAt)) { + return refuse( + state, + "The next review date could not be read as a date, so nothing was saved. A date that cannot be read shows as overdue and tells a reader nothing.", + ); + } + if (!state.users.some(({ id }) => id === action.input.ownerId)) { + return refuse(state, "The named plan owner is not a synthetic user in this prototype, so nothing was saved."); + } + + const saved: ManagementPlanVersion = { + ...version, + ownerId: action.input.ownerId, + reviewDueAt: action.input.reviewDueAt, + revisionReason: action.input.revisionReason, + participationState: action.input.participationState, + consentedSupportPeople: [...action.input.consentedSupportPeople], + content: cloneJson(action.input.content), + }; + + return { + ...state, + managementPlanVersions: state.managementPlanVersions.map((candidate) => + candidate.id === saved.id ? saved : candidate, + ), + auditEvents: withAudit(state, { + type: "management_draft_saved", + patientId: patientOfManagementPlan(state, version.planId), + objectId: saved.id, + evidence: `Draft version ${saved.version} saved. Next review date recorded as ${saved.reviewDueAt}.`, + }), + lastOutcome: { + kind: "success", + message: `Draft version ${saved.version} saved. It is not approved for use.`, + }, + }; + } + + case "submit-management-draft": { + const version = state.managementPlanVersions.find(({ id }) => id === action.versionId) ?? null; + if (version === null) return refuse(state, "That Management Plan Version does not exist."); + if (version.state !== "draft") { + return refuse( + state, + `Only a Draft can be submitted for approval. Version ${version.version} is ${version.state}.`, + ); + } + + const submitted: ManagementPlanVersion = { + ...version, + state: "awaiting_approval", + submittedAt: prototypeTimestamp(state), + returnedReason: null, + }; + + return { + ...state, + managementPlanVersions: state.managementPlanVersions.map((candidate) => + candidate.id === submitted.id ? submitted : candidate, + ), + auditEvents: withAudit(state, { + type: "management_version_submitted", + patientId: patientOfManagementPlan(state, version.planId), + objectId: submitted.id, + evidence: `Version ${submitted.version} submitted for senior-clinician approval.`, + }), + lastOutcome: { + kind: "success", + message: `Version ${submitted.version} submitted for approval. The Current Plan stays in use until a senior clinician approves it.`, + }, + }; + } + + case "return-management-version": { + const version = state.managementPlanVersions.find(({ id }) => id === action.versionId) ?? null; + if (version === null) return refuse(state, "That Management Plan Version does not exist."); + if (version.state !== "awaiting_approval") { + return refuse( + state, + `Only a version awaiting approval can be returned for changes. Version ${version.version} is ${version.state}.`, + ); + } + if (isBlank(action.reason)) { + return refuse( + state, + "A returned version needs a reason, so the author knows what to change. Nothing was changed.", + ); + } + + const returned: ManagementPlanVersion = { + ...version, + state: "draft", + returnedReason: action.reason.trim(), + }; + + return { + ...state, + // Returning touches only the submitted version. The Current Plan was + // never displaced by it and is not touched now. + managementPlanVersions: state.managementPlanVersions.map((candidate) => + candidate.id === returned.id ? returned : candidate, + ), + auditEvents: withAudit(state, { + type: "management_version_returned", + patientId: patientOfManagementPlan(state, version.planId), + objectId: returned.id, + evidence: `Version ${returned.version} returned for changes: ${asSentence(returned.returnedReason ?? "")}`, + }), + lastOutcome: { + kind: "success", + message: `Version ${returned.version} returned to Draft for changes. The Current Plan is unchanged.`, + }, + }; + } + + case "approve-management-version": { + const version = state.managementPlanVersions.find(({ id }) => id === action.versionId) ?? null; + if (version === null) return refuse(state, "That Management Plan Version does not exist."); + if (version.state !== "awaiting_approval") { + return refuse( + state, + `Only a version awaiting approval can be approved. Version ${version.version} is ${version.state}.`, + ); + } + const plan = state.managementPlans.find(({ id }) => id === version.planId) ?? null; + if (plan === null) { + return refuse(state, "That version has no Management Plan record, so it cannot become the Current Plan."); + } + + const approver = state.users.find(({ id }) => id === state.activeUserId) ?? null; + if (approver === null || isBlank(approver.displayName)) { + return refuse(state, "Approval must name the senior clinician approving it. Nothing was changed."); + } + + const missing = missingRequiredContentKeys(version.content); + if (missing.length > 0) { + return refuse( + state, + `Version ${version.version} cannot become the Current Plan while required sections are empty: ${missing.join(", ")}.`, + ); + } + + const approvedAt = prototypeTimestamp(state); + const managementPlanVersions = state.managementPlanVersions.map((candidate) => { + if (candidate.id === version.id) { + return { + ...candidate, + state: "current" as const, + approverId: approver.id, + approvedAt, + // Review state is always derived from this date, never stored. + reviewDueAt: candidate.reviewDueAt ?? addIsoMonths(approvedAt, REVIEW_INTERVAL_MONTHS), + }; + } + if (candidate.planId === version.planId && candidate.state === "current") { + return { ...candidate, state: "superseded" as const }; + } + return candidate; + }); + // Two Current versions would show two plans both approved for use now. + assertSingleCurrentVersion(managementPlanVersions); + + return { + ...state, + managementPlanVersions, + managementPlans: state.managementPlans.map((candidate) => + candidate.id === plan.id ? { ...candidate, currentVersionId: version.id } : candidate, + ), + auditEvents: withAudit(state, { + type: "management_version_approved", + patientId: plan.patientId, + objectId: version.id, + evidence: `Version ${version.version} approved by ${approver.displayName} and is now the Current Plan. The previously Current version is Superseded and stays readable in history.`, + }), + lastOutcome: { + kind: "success", + message: `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}.`, + }, + }; + } + + case "withdraw-current-management-version": { + const patient = findPatient(state, action.patientId); + if (patient === null) return refuse(state, "That synthetic patient record does not exist."); + const plan = findManagementPlan(state, patient); + if (plan === null) return refuse(state, `${patient.preferredName} has no Management Plan record.`); + const current = getCurrentManagementPlanVersion(state.managementPlanVersions, plan.id); + if (current === null) return refuse(state, `${patient.preferredName} has no Current Plan to withdraw.`); + if (isBlank(action.reason)) { + return refuse( + state, + "Withdrawing a plan needs a recorded reason, so a later reader can see why it was taken out of use. Nothing was changed.", + ); + } + + const withdrawnAt = prototypeTimestamp(state); + const actor = state.users.find(({ id }) => id === state.activeUserId); + + return { + ...state, + managementPlanVersions: state.managementPlanVersions.map((candidate) => + candidate.id === current.id + ? { + ...candidate, + state: "withdrawn" as const, + withdrawalReason: action.reason.trim(), + withdrawnBy: state.activeUserId, + withdrawnAt, + } + : candidate, + ), + // No Current Plan afterwards, and no Superseded version put back in use. + managementPlans: state.managementPlans.map((candidate) => + candidate.id === plan.id ? { ...candidate, currentVersionId: null } : candidate, + ), + auditEvents: withAudit(state, { + type: "management_version_withdrawn", + patientId: patient.id, + objectId: current.id, + evidence: `Version ${current.version} withdrawn by ${actor?.displayName ?? state.activeUserId}: ${asSentence(action.reason)} ${patient.preferredName} now has no Current Plan.`, + }), + lastOutcome: { + kind: "success", + message: `Version ${current.version} withdrawn. ${patient.preferredName} now has no Current Plan, and no earlier version was put back into use.`, + }, + }; + } + + case "record-formal-management-review": { + const patient = findPatient(state, action.patientId); + if (patient === null) return refuse(state, "That synthetic patient record does not exist."); + const plan = findManagementPlan(state, patient); + if (plan === null) return refuse(state, `${patient.preferredName} has no Management Plan record.`); + const current = getCurrentManagementPlanVersion(state.managementPlanVersions, plan.id); + if (current === null) { + return refuse(state, `${patient.preferredName} has no Current Plan to record a formal review against.`); + } + if (isBlank(action.reason)) { + return refuse(state, "A formal review needs a recorded account of what was reviewed. Nothing was changed."); + } + if (!isParsableTimestamp(action.nextReviewDueAt)) { + return refuse( + state, + "The next review date could not be read as a date, so the review was not recorded and the existing date is unchanged.", + ); + } + + return { + ...state, + // A formal review moves the review date on the version already in use. + // It creates no new version, and the plan's content is unchanged. + managementPlanVersions: state.managementPlanVersions.map((candidate) => + candidate.id === current.id ? { ...candidate, reviewDueAt: action.nextReviewDueAt } : candidate, + ), + auditEvents: withAudit(state, { + type: "management_review_recorded", + patientId: patient.id, + objectId: current.id, + evidence: `Formal review recorded against Current Plan version ${current.version}: ${asSentence(action.reason)} Next review date set to ${action.nextReviewDueAt}.`, + }), + lastOutcome: { + kind: "success", + message: `Formal review recorded. Version ${current.version} stays the Current Plan and its next review date has moved.`, + }, + }; + } + + case "record-presentation": { + if (!action.presentationId.startsWith("SYN-PRESENTATION-")) { + return refuse( + state, + "That episode identifier does not belong to the synthetic presentation series, so nothing was recorded.", + ); + } + if (state.edPresentations.some(({ id }) => id === action.presentationId)) { + return refuse( + state, + "An ED Presentation with that identifier already exists. These records are append-only, so nothing was recorded.", + ); + } + + const input = action.input; + const patient = findPatient(state, input.patientId); + if (patient === null) return refuse(state, "That synthetic patient record does not exist."); + if (!state.edSites.some(({ id }) => id === input.siteId)) { + return refuse(state, "That synthetic emergency department does not exist, so nothing was recorded."); + } + if (!DISPOSITIONS.includes(input.disposition)) { + return refuse(state, "That disposition is not one of the recorded answers, so nothing was recorded."); + } + if (!isParsableTimestamp(input.arrivedAt)) { + return refuse(state, "The arrival date and time could not be read as a date, so nothing was recorded."); + } + if ( + input.managementPlanVersionId !== null && + !state.managementPlanVersions.some(({ id }) => id === input.managementPlanVersionId) + ) { + return refuse(state, "That Management Plan Version does not exist, so nothing was recorded."); + } + if (input.reviewSuggested && (input.reviewReason === null || isBlank(input.reviewReason))) { + return refuse( + state, + "Suggesting a plan review needs a reason, so the team knows what to look at. Nothing was recorded.", + ); + } + if (input.deviationOccurred && (input.deviationReason === null || isBlank(input.deviationReason))) { + return refuse( + state, + "Recording that the agreed approach was not followed needs a reason. Nothing was recorded.", + ); + } + + const presentation: EdPresentation = { + ...input, + id: action.presentationId, + recordedBy: state.activeUserId, + recordedAt: prototypeTimestamp(state), + }; + + // A Review Trigger asks the team to reconsider the Current Plan, so it + // needs one to reconsider. A person with no Current Plan — never had one, + // or had one withdrawn — gets no trigger rather than an item in the + // worklist that points at nothing. + const plan = findManagementPlan(state, patient); + const current = plan === null ? null : getCurrentManagementPlanVersion(state.managementPlanVersions, plan.id); + const candidate = plan === null || current === null ? null : reviewTriggerReasonFor(presentation); + // One open trigger per plan per source. A team reconsiders the plan once; + // a queue that fills with the same reason stops being read at all. + 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: patient.id, + 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, + edPresentations: [...state.edPresentations, presentation], + reviewTriggers: newTrigger === null ? state.reviewTriggers : [...state.reviewTriggers, newTrigger], + auditEvents: withAudit(state, { + type: "presentation_recorded", + patientId: patient.id, + objectId: presentation.id, + evidence: `ED Presentation recorded for arrival on ${presentation.arrivedAt}, with disposition ${presentation.disposition}.`, + }), + lastOutcome: { + kind: "success", + message: + newTrigger === null + ? "ED Presentation recorded. The Management Plan is unchanged." + : "ED Presentation recorded, and an open Review Trigger was raised for the team to look at. The Management Plan is unchanged.", + }, + }; + } + + case "amend-presentation": { + const presentation = state.edPresentations.find(({ id }) => id === action.presentationId) ?? null; + if (presentation === null) return refuse(state, "That ED Presentation does not exist."); + if (!AMENDABLE_FIELDS.includes(action.field)) { + return refuse(state, "That part of an ED Presentation cannot be corrected, so nothing was changed."); + } + if (isBlank(action.reason)) { + return refuse( + state, + "A correction needs a reason, so a later reader can see why the record changed. Nothing was changed.", + ); + } + if (isBlank(action.replacementValue)) { + return refuse(state, "A correction needs a replacement value. Nothing was changed."); + } + + const answers = AMENDABLE_FIELD_ANSWERS[action.field]; + if (answers !== undefined && !answers.includes(action.replacementValue)) { + return refuse( + state, + `"${action.replacementValue}" is not one of the recorded answers for ${action.field}, so nothing was changed.`, + ); + } + + const originalValue = effectiveFieldValue(state, presentation, action.field); + if (originalValue === action.replacementValue) { + return refuse( + state, + "The replacement is the same as the value already recorded, so there is nothing to correct.", + "info", + ); + } + + const amendment: PresentationAmendment = { + id: nextSyntheticId( + "SYN-AMENDMENT", + state.presentationAmendments.map(({ id }) => id), + ), + presentationId: presentation.id, + field: action.field, + originalValue, + replacementValue: action.replacementValue, + reason: action.reason.trim(), + authorId: state.activeUserId, + amendedAt: prototypeTimestamp(state), + }; + + 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], + auditEvents: withAudit(state, { + type: "presentation_amended", + patientId: presentation.patientId, + objectId: presentation.id, + evidence: `Correction recorded for ${amendment.field}. Recorded as "${amendment.originalValue}"; corrected to "${amendment.replacementValue}". Reason: ${asSentence(amendment.reason)} The original ED Presentation record is unchanged.`, + }), + lastOutcome: { + kind: "success", + message: "Correction recorded beside the original. The original ED Presentation record is unchanged.", + }, + }; + } + + case "create-safety-plan-draft": { + const patient = findPatient(state, action.patientId); + if (patient === null) return refuse(state, "That synthetic patient record does not exist."); + const plan = findSafetyPlan(state, patient); + if (plan === null) return refuse(state, `${patient.preferredName} has no Personal Safety Plan record.`); + if (state.personalSafetyPlanVersions.some((version) => version.planId === plan.id && version.state === "draft")) { + return refuse( + state, + `A Personal Safety Plan draft for ${patient.preferredName} is already open. Continue that one rather than starting another.`, + ); + } + + const current = getCurrentSafetyPlanVersion(state.personalSafetyPlanVersions, plan.id); + const createdAt = prototypeTimestamp(state); + const draft: PersonalSafetyPlanVersion = { + id: nextSyntheticId( + "SYN-SAFETY-VERSION", + state.personalSafetyPlanVersions.map(({ id }) => id), + ), + planId: plan.id, + version: nextVersionNumber(state.personalSafetyPlanVersions, plan.id), + state: "draft", + authorId: state.activeUserId, + createdAt, + confirmedAt: null, + reviewDueAt: addIsoMonths(createdAt, REVIEW_INTERVAL_MONTHS), + // As with a Management Plan draft, nothing is yet known about this + // person's part in this edition, so the record claims none. + patientConfirmation: "unavailable", + collaborationNote: "", + content: cloneJson(current === null ? EMPTY_SAFETY_CONTENT : current.content), + }; + + return { + ...state, + personalSafetyPlanVersions: [...state.personalSafetyPlanVersions, draft], + personalSafetyPlans: state.personalSafetyPlans.map((candidate) => + candidate.id === plan.id ? { ...candidate, versionIds: [...candidate.versionIds, draft.id] } : candidate, + ), + auditEvents: withAudit(state, { + type: "safety_plan_draft_created", + patientId: patient.id, + objectId: draft.id, + evidence: `Personal Safety Plan draft version ${draft.version} started.`, + }), + lastOutcome: { + kind: "success", + message: `Personal Safety Plan draft version ${draft.version} created. It is not in use until it is made current.`, + }, + }; + } + + case "save-safety-plan-draft": { + const version = state.personalSafetyPlanVersions.find(({ id }) => id === action.versionId) ?? null; + if (version === null) return refuse(state, "That Personal Safety Plan Version does not exist."); + if (version.state !== "draft") { + return refuse(state, `Only a draft can be edited. Version ${version.version} is ${version.state}.`); + } + if (!isParsableTimestamp(action.input.reviewDueAt)) { + return refuse( + state, + "The next review date could not be read as a date, so nothing was saved. A date that cannot be read shows as overdue and tells a reader nothing.", + ); + } + + const saved: PersonalSafetyPlanVersion = { + ...version, + reviewDueAt: action.input.reviewDueAt, + patientConfirmation: action.input.patientConfirmation, + collaborationNote: action.input.collaborationNote, + content: cloneJson(action.input.content), + }; + + return { + ...state, + personalSafetyPlanVersions: state.personalSafetyPlanVersions.map((candidate) => + candidate.id === saved.id ? saved : candidate, + ), + auditEvents: withAudit(state, { + type: "safety_plan_draft_saved", + patientId: patientOfSafetyPlan(state, version.planId), + objectId: saved.id, + evidence: `Personal Safety Plan draft version ${saved.version} saved, with the person's involvement recorded as ${saved.patientConfirmation}.`, + }), + lastOutcome: { + kind: "success", + message: `Personal Safety Plan draft version ${saved.version} saved.`, + }, + }; + } + + case "make-safety-plan-current": { + const version = state.personalSafetyPlanVersions.find(({ id }) => id === action.versionId) ?? null; + if (version === null) return refuse(state, "That Personal Safety Plan Version does not exist."); + if (version.state !== "draft") { + return refuse( + state, + `Only a draft can be made the current Personal Safety Plan. Version ${version.version} is ${version.state}.`, + ); + } + const plan = state.personalSafetyPlans.find(({ id }) => id === version.planId) ?? null; + if (plan === null) return refuse(state, "That version has no Personal Safety Plan record."); + + const madeCurrentAt = prototypeTimestamp(state); + + return { + ...state, + // The Personal Safety Plan is the patient's own. It is versioned on its + // own and never waits on Management Plan senior approval. + personalSafetyPlanVersions: state.personalSafetyPlanVersions.map((candidate) => { + if (candidate.id === version.id) { + return { + ...candidate, + state: "current" as const, + confirmedAt: + candidate.patientConfirmation === "confirmed" && candidate.confirmedAt === null + ? madeCurrentAt + : candidate.confirmedAt, + }; + } + if (candidate.planId === version.planId && candidate.state === "current") { + return { ...candidate, state: "superseded" as const }; + } + return candidate; + }), + personalSafetyPlans: state.personalSafetyPlans.map((candidate) => + candidate.id === plan.id ? { ...candidate, currentVersionId: version.id } : candidate, + ), + auditEvents: withAudit(state, { + type: "safety_plan_made_current", + patientId: plan.patientId, + objectId: version.id, + evidence: `Personal Safety Plan version ${version.version} is now the current one. Any earlier current version is superseded and stays readable in history.`, + }), + lastOutcome: { + kind: "success", + message: `Personal Safety Plan version ${version.version} is now the current one.`, + }, + }; + } + + 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."); + const plan = findSafetyPlan(state, patient); + const current = plan === null ? null : getCurrentSafetyPlanVersion(state.personalSafetyPlanVersions, plan.id); + if (current === null) { + return refuse(state, `${patient.preferredName} has no current Personal Safety Plan to print.`); + } + + return { + ...state, + auditEvents: withAudit(state, { + type: "safety_plan_print_intent_opened", + patientId: patient.id, + objectId: current.id, + evidence: `The browser print view was opened for Personal Safety Plan version ${current.version}. This records the request only, and is not evidence that anything reached a printer.`, + }), + 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-contact-intent": { + const patient = findPatient(state, action.patientId); + if (patient === null) return refuse(state, "That synthetic patient record does not exist."); + const contact = state.cmhtContacts.find(({ id }) => id === action.cmhtId) ?? null; + if (contact === null) return refuse(state, "That community mental health team record does not exist."); + + const isEmail = action.channel === "email"; + + return { + ...state, + // A contact action records an attempt to open an external application. + // It is never evidence that anyone was reached. + auditEvents: withAudit(state, { + type: isEmail ? "email_intent_opened" : "call_intent_opened", + patientId: patient.id, + objectId: contact.id, + evidence: isEmail + ? `An external email application was asked to open, addressed to the ${contact.name} shared mailbox with a generic subject and no patient information. This application transmitted nothing and holds no evidence of delivery, readership, or reply.` + : `An external telephone application was asked to open with the ${contact.name} duty number displayed. This application holds no evidence of a connection, a conversation, or any outcome.`, + }), + lastOutcome: { + kind: "info", + message: isEmail + ? "An email application was asked to open. This prototype records only that request, not what happens next." + : "A telephone application was asked to open. This prototype records only that request, not what happens next.", + }, + }; + } + + case "create-identification-review": { + const patient = findPatient(state, action.patientId); + if (patient === null) return refuse(state, "That synthetic patient record does not exist."); + if (isBlank(action.reason)) { + return refuse( + state, + "A referral for Identification Review needs a stated reason. No numeric rule exists, so the reason is the whole referral. Nothing was changed.", + ); + } + if (state.identificationReviews.some((review) => review.patientId === patient.id && review.status === "open")) { + return refuse( + state, + `An Identification Review for ${patient.preferredName} is already open, so a second referral was not added.`, + ); + } + + const review: IdentificationReview = { + id: nextSyntheticId( + "SYN-IDENT-REVIEW", + state.identificationReviews.map(({ id }) => id), + ), + patientId: patient.id, + reason: action.reason.trim(), + referredBy: state.activeUserId, + referredAt: prototypeTimestamp(state), + status: "open", + decision: null, + decisionReason: null, + decidedBy: null, + decidedAt: null, + }; + + return { + ...state, + // A referral asks a group of people to consider coordinated care. It + // enrols nobody, creates no plan, and raises no Review Trigger. + identificationReviews: [...state.identificationReviews, review], + auditEvents: withAudit(state, { + type: "identification_review_created", + patientId: patient.id, + objectId: review.id, + evidence: `Referred for Identification Review with a stated reason: ${asSentence(review.reason)} No plan was created and no eligibility was decided.`, + }), + lastOutcome: { + kind: "success", + message: `${patient.preferredName} was referred for Identification Review. No plan was created, and being referred decides nothing.`, + }, + }; + } + + case "close-identification-review": { + const review = state.identificationReviews.find(({ id }) => id === action.reviewId) ?? null; + if (review === null) return refuse(state, "That Identification Review does not exist."); + if (review.status !== "open") { + return refuse( + state, + "That Identification Review was already closed, so nothing was changed. Its recorded decision and reason stay as they are.", + "info", + ); + } + if (!IDENTIFICATION_DECISIONS.includes(action.decision)) { + return refuse(state, "That is not one of the recorded Identification Review decisions. Nothing was changed."); + } + if (isBlank(action.decisionReason)) { + return refuse( + state, + "Closing an Identification Review needs a short reason, so a later reader can see what was concluded. Nothing was changed.", + ); + } + + const closed: IdentificationReview = { + ...review, + status: "closed", + decision: action.decision, + decisionReason: action.decisionReason.trim(), + decidedBy: state.activeUserId, + decidedAt: prototypeTimestamp(state), + }; + + return { + ...state, + // Closing records a conclusion. Even on `proceed_to_plan` it creates no + // Management Plan and no version: someone still has to choose to write one. + identificationReviews: state.identificationReviews.map((candidate) => + candidate.id === closed.id ? closed : candidate, + ), + auditEvents: withAudit(state, { + type: "identification_review_closed", + patientId: review.patientId, + objectId: closed.id, + evidence: `Identification Review closed with the decision ${action.decision}: ${asSentence(closed.decisionReason ?? "")} No plan and no version were created by closing it.`, + }), + lastOutcome: { + kind: "success", + message: + action.decision === "proceed_to_plan" + ? "Identification Review closed. Nothing was created; starting a Management Plan draft is a separate decision." + : "Identification Review closed. The decision and its reason stay visible in the person's history.", + }, + }; + } + + case "verify-cmht-contact": { + const contact = state.cmhtContacts.find(({ id }) => id === action.cmhtId) ?? null; + if (contact === null) return refuse(state, "That community mental health team record does not exist."); + + const verifiedAt = prototypeTimestamp(state); + + return { + ...state, + cmhtContacts: state.cmhtContacts.map((candidate) => + candidate.id === contact.id + ? { ...candidate, verifiedAt, verificationState: "verified" as const } + : candidate, + ), + auditEvents: withAudit(state, { + type: "cmht_contact_verified", + patientId: null, + objectId: contact.id, + evidence: `The shared mailbox, duty number, and operating hours displayed for ${contact.name} were checked on ${verifiedAt}. Checking the details is not a guarantee that the service is available.`, + }), + lastOutcome: { + kind: "success", + message: `${contact.name} contact details recorded as checked. That says the details were checked, not that the service is available.`, + }, + }; + } + + case "resolve-review-trigger": { + const trigger = state.reviewTriggers.find(({ id }) => id === action.triggerId) ?? null; + if (trigger === null) return refuse(state, "That Review Trigger does not exist."); + if (trigger.status !== "open") { + return refuse(state, "That Review Trigger was already resolved, so nothing was changed.", "info"); + } + if (isBlank(action.resolution)) { + return refuse(state, "Resolving a Review Trigger needs an account of what was decided. Nothing was changed."); + } + + return { + ...state, + reviewTriggers: state.reviewTriggers.map((candidate) => + candidate.id === trigger.id + ? { + ...candidate, + status: "resolved" as const, + resolvedAt: prototypeTimestamp(state), + resolution: action.resolution.trim(), + } + : candidate, + ), + auditEvents: withAudit(state, { + type: "review_trigger_resolved", + patientId: trigger.patientId, + objectId: trigger.id, + evidence: `Review Trigger resolved: ${asSentence(action.resolution)} Resolving it changed no plan.`, + }), + lastOutcome: { kind: "success", message: "Review Trigger resolved. No plan was changed." }, + }; + } + + case "apply-scenario": { + // Switching to the scenario already displayed is a no-op, so a repeated or + // spurious switch cannot discard work that is only held in memory. + if (action.scenario === state.scenario) return state; + return createInitialPrototypeState(action.scenario); + } + + case "clear-outcome": + return { ...state, lastOutcome: null }; + + case "reset": + return createInitialPrototypeState(); + } +} diff --git a/src/components/care-plan/mockups/types.ts b/src/components/care-plan/mockups/types.ts index 38e74119f..c66df5496 100644 --- a/src/components/care-plan/mockups/types.ts +++ b/src/components/care-plan/mockups/types.ts @@ -526,3 +526,54 @@ export type PrototypeCapability = | "refer_for_identification_review" | "close_identification_review" | "manage_worklists"; + +/** + * Every state change in the prototype travels through one of these actions, so + * the reducer is the single place a lifecycle rule can be enforced. Each action + * joins this union in the task that implements it: an action nobody dispatches + * 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 Management Plan + * print intent and patient-sharing record, and the four Patient Plan actions. + */ +export type CarePlanPrototypeAction = + | { type: "select-patient"; patientId: SyntheticId } + | { type: "set-active-user"; userId: SyntheticId } + | { type: "create-management-draft"; patientId: SyntheticId } + | { type: "save-management-draft"; versionId: SyntheticId; input: ManagementDraftInput } + | { type: "submit-management-draft"; versionId: SyntheticId } + | { type: "return-management-version"; versionId: SyntheticId; reason: string } + | { type: "approve-management-version"; versionId: SyntheticId } + | { type: "withdraw-current-management-version"; patientId: SyntheticId; reason: string } + | { + type: "record-formal-management-review"; + patientId: SyntheticId; + reason: string; + nextReviewDueAt: string; + } + | { type: "record-presentation"; presentationId: SyntheticId; input: NewEdPresentationInput } + | { + type: "amend-presentation"; + presentationId: SyntheticId; + field: AmendableField; + replacementValue: string; + reason: string; + } + | { type: "create-safety-plan-draft"; patientId: SyntheticId } + | { type: "save-safety-plan-draft"; versionId: SyntheticId; input: SafetyPlanDraftInput } + | { type: "make-safety-plan-current"; versionId: SyntheticId } + | { type: "record-safety-plan-print-intent"; patientId: SyntheticId } + | { type: "record-contact-intent"; patientId: SyntheticId; cmhtId: SyntheticId; channel: "email" | "call" } + | { type: "create-identification-review"; patientId: SyntheticId; reason: string } + | { + type: "close-identification-review"; + reviewId: SyntheticId; + decision: IdentificationDecision; + decisionReason: string; + } + | { type: "verify-cmht-contact"; cmhtId: SyntheticId } + | { type: "resolve-review-trigger"; triggerId: SyntheticId; resolution: string } + | { type: "apply-scenario"; scenario: PrototypeScenario } + | { type: "clear-outcome" } + | { type: "reset" }; diff --git a/tests/care-plan-prototype-state.test.ts b/tests/care-plan-prototype-state.test.ts new file mode 100644 index 000000000..f4d978b1c --- /dev/null +++ b/tests/care-plan-prototype-state.test.ts @@ -0,0 +1,946 @@ +import { describe, expect, it } from "vitest"; + +import { assertSingleCurrentVersion, getOpenManagementDraft } from "@/components/care-plan/mockups/domain"; +import { PROTOTYPE_NOW } from "@/components/care-plan/mockups/fixtures"; +import { + createInitialPrototypeState, + getPrototypeMutationBlockReason, + nextPresentationId, + nextSyntheticId, + prototypeReducer, +} from "@/components/care-plan/mockups/prototype-state"; +import type { + CarePlanPrototypeAction, + CarePlanPrototypeState, + ManagementDraftInput, + NewEdPresentationInput, + PrototypeScenario, +} from "@/components/care-plan/mockups/types"; + +const ED_CLINICIAN = "SYN-USER-ED-001"; +const LIAISON = "SYN-USER-LIAISON-001"; +const SENIOR = "SYN-USER-SENIOR-001"; +const COORDINATOR = "SYN-USER-COORD-001"; + +const ROWAN = "SYN-PATIENT-001"; +const MIRA = "SYN-PATIENT-002"; +const JORDAN = "SYN-PATIENT-003"; +const EVELYN = "SYN-PATIENT-004"; + +const ROWAN_PLAN = "SYN-MGMT-PLAN-001"; +const MIRA_PLAN = "SYN-MGMT-PLAN-002"; +const ROWAN_CURRENT_VERSION = "SYN-MGMT-VERSION-002"; +const MIRA_AWAITING_VERSION = "SYN-MGMT-VERSION-004"; + +function withUser(state: CarePlanPrototypeState, userId: string): CarePlanPrototypeState { + return prototypeReducer(state, { type: "set-active-user", userId: userId as `SYN-${string}` }); +} + +function run(scenario: PrototypeScenario, userId: string, ...actions: CarePlanPrototypeAction[]) { + return actions.reduce( + (state, action) => prototypeReducer(state, action), + withUser(createInitialPrototypeState(scenario), userId), + ); +} + +function presentationInput(overrides: Partial = {}): NewEdPresentationInput { + return { + patientId: ROWAN, + arrivedAt: PROTOTYPE_NOW, + siteId: "SYN-ED-001", + presentingIndication: "", + assessmentOutcome: "", + note: "Arrived in the evening and went home the same night after assessment.", + disposition: "discharged_home", + cmhtContactAttempt: "not_attempted", + cmhtContactOutcome: "", + managementPlanVersionId: ROWAN_CURRENT_VERSION, + planAvailability: "available", + planUse: "used", + planHelpfulness: "helpful", + deviationOccurred: false, + deviationReason: null, + reviewSuggested: false, + reviewReason: null, + ...overrides, + }; +} + +function draftInput(overrides: Partial = {}): ManagementDraftInput { + return { + ownerId: LIAISON, + reviewDueAt: "2027-08-20T14:30:00+08:00", + revisionReason: "Contact details for the community team changed.", + participationState: "co_produced", + consentedSupportPeople: [], + content: { + howToApproach: ["Introduce yourself by name and say how long the wait is likely to be."], + whatHelps: ["A quiet space away from the main corridor, offered on arrival."], + whatMakesItWorse: ["Repeating the whole history to each new clinician instead of reading the plan first."], + agreedEdApproach: ["Agreed with the community team and the consultant on 14 August 2026."], + whatWouldMakeThisDifferent: ["New confusion or drowsiness, or a head injury before arrival."], + whyThisPlanExists: "To keep the approach consistent between departments.", + whatThePersonWants: [], + practicalNeeds: [], + physicalHealthAndMedication: [], + whoElseIsInvolved: [], + reviewTriggers: [], + }, + ...overrides, + }; +} + +/** 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 = [ + /\bmessage sent\b/i, + /\bemail sent\b/i, + /\bwas sent\b/i, + /\bdelivered\b/i, + /\bcontact completed\b/i, + /\bcall answered\b/i, + /\bspoke (?:to|with)\b/i, + /\bwas printed\b/i, + /\bthey replied\b/i, +]; + +function overclaims(line: string): boolean { + return OVERCLAIMING_LANGUAGE.some((pattern) => pattern.test(line)); +} + +// --- The two contracts the task brief names first ------------------------------- + +describe("Care Plan Management Plan lifecycle", () => { + it("approves an awaiting version atomically and preserves exactly one Current Plan", () => { + let state = createInitialPrototypeState("overdue-plan"); + state = prototypeReducer(state, { type: "set-active-user", userId: SENIOR }); + const awaitingId = getOpenManagementDraft(state.managementPlanVersions, MIRA_PLAN)!.id; + const next = prototypeReducer(state, { type: "approve-management-version", versionId: awaitingId }); + const versions = next.managementPlanVersions.filter(({ planId }) => planId === MIRA_PLAN); + + expect(versions.filter(({ state }) => state === "current")).toHaveLength(1); + expect(versions.find(({ id }) => id === awaitingId)?.state).toBe("current"); + expect(versions.find(({ version }) => version === 1)?.state).toBe("superseded"); + expect(next.managementPlans.find(({ id }) => id === MIRA_PLAN)?.currentVersionId).toBe(awaitingId); + expect(() => assertSingleCurrentVersion(next.managementPlanVersions)).not.toThrow(); + expect(next.auditEvents.filter(({ type }) => type === "management_version_approved")).toHaveLength(1); + }); + + it("adds a visible amendment without changing the original ED Presentation", () => { + const state = createInitialPrototypeState(); + const original = state.edPresentations.find(({ id }) => id === "SYN-PRESENTATION-001")!; + const next = prototypeReducer(state, { + type: "amend-presentation", + presentationId: original.id, + field: "assessmentOutcome", + replacementValue: "Discharged after senior review and follow-up confirmation.", + reason: "Clarify the recorded outcome.", + }); + + expect(next.edPresentations.find(({ id }) => id === original.id)?.assessmentOutcome).toBe( + original.assessmentOutcome, + ); + expect(next.presentationAmendments.at(-1)).toMatchObject({ + presentationId: original.id, + field: "assessmentOutcome", + }); + }); +}); + +// --- Approval, return, withdrawal, formal review --------------------------------- + +describe("Care Plan approval guards", () => { + it("refuses approval by a user whose role is not a named senior clinician", () => { + const state = withUser(createInitialPrototypeState("overdue-plan"), LIAISON); + const next = prototypeReducer(state, { type: "approve-management-version", versionId: MIRA_AWAITING_VERSION }); + + // Negative controls: if the reducer wrongly permitted this, each of these fails. + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + expect(next.managementPlans).toEqual(state.managementPlans); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.kind).toBe("blocked"); + expect(next.lastOutcome?.message).toMatch(/does not carry this action/i); + }); + + it("refuses approval of a version whose required sections are empty", () => { + let state = withUser(createInitialPrototypeState(), LIAISON); + state = prototypeReducer(state, { type: "create-management-draft", patientId: JORDAN }); + const draftId = getOpenManagementDraft(state.managementPlanVersions, "SYN-MGMT-PLAN-003")!.id; + state = prototypeReducer(state, { type: "submit-management-draft", versionId: draftId }); + const submitted = state; + const next = prototypeReducer(withUser(submitted, SENIOR), { + type: "approve-management-version", + versionId: draftId, + }); + + expect(next.managementPlanVersions.find(({ id }) => id === draftId)?.state).toBe("awaiting_approval"); + expect(next.managementPlans.find(({ id }) => id === "SYN-MGMT-PLAN-003")?.currentVersionId).toBeNull(); + expect(next.lastOutcome?.message).toMatch(/required sections are empty/i); + }); + + it("keeps a new Draft separate from the Current Plan", () => { + const state = run("normal", LIAISON, { type: "create-management-draft", patientId: ROWAN }); + const plan = state.managementPlans.find(({ id }) => id === ROWAN_PLAN)!; + const draft = getOpenManagementDraft(state.managementPlanVersions, ROWAN_PLAN)!; + + expect(draft.state).toBe("draft"); + expect(plan.currentVersionId).toBe(ROWAN_CURRENT_VERSION); + expect(state.managementPlanVersions.find(({ id }) => id === ROWAN_CURRENT_VERSION)?.state).toBe("current"); + expect(plan.versionIds).toContain(draft.id); + expect(draft.id).toBe("SYN-MGMT-VERSION-007"); + }); + + it("refuses a second open draft while one is already being written", () => { + const first = run("normal", LIAISON, { type: "create-management-draft", patientId: ROWAN }); + const second = prototypeReducer(first, { type: "create-management-draft", patientId: ROWAN }); + + expect(second.managementPlanVersions).toEqual(first.managementPlanVersions); + expect(second.auditEvents).toEqual(first.auditEvents); + expect(second.lastOutcome?.message).toMatch(/already open/i); + }); + + it("refuses to save a draft whose next review date cannot be read as a date", () => { + const state = run("normal", LIAISON, { type: "create-management-draft", patientId: ROWAN }); + const draftId = getOpenManagementDraft(state.managementPlanVersions, ROWAN_PLAN)!.id; + const next = prototypeReducer(state, { + type: "save-management-draft", + versionId: draftId, + input: draftInput({ reviewDueAt: "next winter" }), + }); + + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.message).toMatch(/could not be read as a date/i); + }); + + it("refuses to edit a version that is awaiting approval", () => { + const state = withUser(createInitialPrototypeState("overdue-plan"), LIAISON); + const next = prototypeReducer(state, { + type: "save-management-draft", + versionId: MIRA_AWAITING_VERSION, + input: draftInput(), + }); + + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + expect(next.lastOutcome?.message).toMatch(/awaiting approval and read-only/i); + }); + + it("returns an Awaiting Approval version to Draft without touching the Current Plan", () => { + const state = withUser(createInitialPrototypeState("overdue-plan"), SENIOR); + const next = prototypeReducer(state, { + type: "return-management-version", + versionId: MIRA_AWAITING_VERSION, + reason: "Name who agreed the assessment order and on what date.", + }); + + expect(next.managementPlanVersions.find(({ id }) => id === MIRA_AWAITING_VERSION)?.state).toBe("draft"); + expect(next.managementPlanVersions.find(({ id }) => id === MIRA_AWAITING_VERSION)?.returnedReason).toMatch( + /Name who agreed/, + ); + expect(next.managementPlanVersions.find(({ id }) => id === "SYN-MGMT-VERSION-003")?.state).toBe("current"); + expect(next.managementPlans.find(({ id }) => id === MIRA_PLAN)?.currentVersionId).toBe("SYN-MGMT-VERSION-003"); + }); + + it("refuses to return a version without a reason", () => { + const state = withUser(createInitialPrototypeState("overdue-plan"), SENIOR); + const next = prototypeReducer(state, { + type: "return-management-version", + versionId: MIRA_AWAITING_VERSION, + reason: " ", + }); + + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.message).toMatch(/needs a reason/i); + }); + + it("leaves no Current Plan after withdrawal and never restores a superseded version", () => { + const state = withUser(createInitialPrototypeState(), SENIOR); + const next = prototypeReducer(state, { + type: "withdraw-current-management-version", + patientId: ROWAN, + reason: "Rowan asked for the plan to be taken out of use while a new one is written with them.", + }); + const versions = next.managementPlanVersions.filter(({ planId }) => planId === ROWAN_PLAN); + + expect(next.managementPlans.find(({ id }) => id === ROWAN_PLAN)?.currentVersionId).toBeNull(); + expect(versions.filter(({ state }) => state === "current")).toHaveLength(0); + expect(versions.find(({ id }) => id === ROWAN_CURRENT_VERSION)).toMatchObject({ + state: "withdrawn", + withdrawnBy: SENIOR, + }); + expect(versions.find(({ id }) => id === "SYN-MGMT-VERSION-001")?.state).toBe("superseded"); + }); + + it("refuses withdrawal without a reason and refuses it from a non-senior role", () => { + const senior = withUser(createInitialPrototypeState(), SENIOR); + const withoutReason = prototypeReducer(senior, { + type: "withdraw-current-management-version", + patientId: ROWAN, + reason: "", + }); + expect(withoutReason.managementPlanVersions).toEqual(senior.managementPlanVersions); + expect(withoutReason.managementPlans).toEqual(senior.managementPlans); + expect(withoutReason.lastOutcome?.message).toMatch(/needs a recorded reason/i); + + const liaison = withUser(createInitialPrototypeState(), LIAISON); + const wrongRole = prototypeReducer(liaison, { + type: "withdraw-current-management-version", + patientId: ROWAN, + reason: "A stated reason that should still not be enough.", + }); + expect(wrongRole.managementPlanVersions).toEqual(liaison.managementPlanVersions); + expect(wrongRole.managementPlans).toEqual(liaison.managementPlans); + expect(wrongRole.lastOutcome?.kind).toBe("blocked"); + }); + + it("records a formal review by moving the review date without creating a version", () => { + const state = withUser(createInitialPrototypeState(), SENIOR); + const next = prototypeReducer(state, { + type: "record-formal-management-review", + patientId: ROWAN, + reason: "Reviewed with Rowan and the community team; the plan still describes what happens.", + nextReviewDueAt: "2027-08-20T09:00:00+08:00", + }); + + expect(next.managementPlanVersions).toHaveLength(state.managementPlanVersions.length); + expect(next.managementPlanVersions.find(({ id }) => id === ROWAN_CURRENT_VERSION)).toMatchObject({ + state: "current", + reviewDueAt: "2027-08-20T09:00:00+08:00", + }); + expect(next.auditEvents.at(-1)?.type).toBe("management_review_recorded"); + }); + + it("refuses a formal review whose next review date cannot be read as a date", () => { + const state = withUser(createInitialPrototypeState(), SENIOR); + const next = prototypeReducer(state, { + type: "record-formal-management-review", + patientId: ROWAN, + reason: "Reviewed at the August meeting.", + nextReviewDueAt: "sometime next year", + }); + + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.message).toMatch(/could not be read as a date/i); + }); +}); + +// --- ED Presentations: append-only, and what they raise --------------------------- + +describe("Care Plan ED Presentation recording", () => { + it("appends an episode and one audit event", () => { + const state = createInitialPrototypeState(); + const id = nextPresentationId(state); + const next = prototypeReducer(state, { + type: "record-presentation", + presentationId: id, + input: presentationInput(), + }); + + expect(id).toBe("SYN-PRESENTATION-021"); + expect(next.edPresentations).toHaveLength(state.edPresentations.length + 1); + expect(next.edPresentations.at(-1)).toMatchObject({ id, recordedBy: ED_CLINICIAN }); + expect(next.auditEvents.filter(({ type }) => type === "presentation_recorded")).toHaveLength(1); + }); + + it("raises one open Review Trigger when the plan-use feedback says it helped only in part", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { + type: "record-presentation", + presentationId: nextPresentationId(state), + input: presentationInput({ planUse: "partially_used", planHelpfulness: "mixed" }), + }); + const added = next.reviewTriggers.filter((trigger) => !state.reviewTriggers.some(({ id }) => id === trigger.id)); + + expect(added).toHaveLength(1); + expect(added[0]).toMatchObject({ managementPlanId: ROWAN_PLAN, source: "plan_use_feedback", status: "open" }); + // A trigger asks a person to look; it never changes the plan. + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + expect(next.managementPlans).toEqual(state.managementPlans); + }); + + it("does not raise a second open Review Trigger for a reason already open on the plan", () => { + // Rowan's plan already carries an open `presentation_outcome` trigger in the + // fixtures, so an admission must not add another of the same kind. + const state = createInitialPrototypeState(); + const openOutcomeTriggersBefore = state.reviewTriggers.filter( + (trigger) => + trigger.managementPlanId === ROWAN_PLAN && + trigger.source === "presentation_outcome" && + trigger.status === "open", + ); + expect(openOutcomeTriggersBefore).toHaveLength(1); + + const next = prototypeReducer(state, { + type: "record-presentation", + presentationId: nextPresentationId(state), + input: presentationInput({ disposition: "mental_health_admission" }), + }); + + expect(next.reviewTriggers).toEqual(state.reviewTriggers); + expect(next.edPresentations).toHaveLength(state.edPresentations.length + 1); + }); + + it("deduplicates across two consecutive episodes recorded for the same reason", () => { + const first = createInitialPrototypeState(); + const second = prototypeReducer(first, { + type: "record-presentation", + presentationId: nextPresentationId(first), + input: presentationInput({ planHelpfulness: "not_helpful" }), + }); + const third = prototypeReducer(second, { + type: "record-presentation", + presentationId: nextPresentationId(second), + input: presentationInput({ planHelpfulness: "not_helpful" }), + }); + + expect(second.reviewTriggers).toHaveLength(first.reviewTriggers.length + 1); + expect(third.reviewTriggers).toEqual(second.reviewTriggers); + }); + + it("raises no Review Trigger for an episode that gives no reason to reconsider the plan", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { + type: "record-presentation", + presentationId: nextPresentationId(state), + input: presentationInput(), + }); + + expect(next.reviewTriggers).toEqual(state.reviewTriggers); + }); + + it("raises no Review Trigger for a person who has no Current Plan to reconsider", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { + type: "record-presentation", + presentationId: nextPresentationId(state), + input: presentationInput({ + patientId: JORDAN, + siteId: "SYN-ED-003", + managementPlanVersionId: null, + planAvailability: "not_applicable", + planUse: "not_applicable", + planHelpfulness: "not_helpful", + }), + }); + + expect(next.reviewTriggers).toEqual(state.reviewTriggers); + expect(next.edPresentations).toHaveLength(state.edPresentations.length + 1); + }); + + it("refuses an episode identifier that is already used or is not a synthetic presentation identifier", () => { + const state = createInitialPrototypeState(); + + const duplicate = prototypeReducer(state, { + type: "record-presentation", + presentationId: "SYN-PRESENTATION-001", + input: presentationInput(), + }); + expect(duplicate.edPresentations).toEqual(state.edPresentations); + expect(duplicate.auditEvents).toEqual(state.auditEvents); + expect(duplicate.lastOutcome?.message).toMatch(/already exists/i); + + const wrongPrefix = prototypeReducer(state, { + type: "record-presentation", + presentationId: "SYN-AMENDMENT-900", + input: presentationInput(), + }); + expect(wrongPrefix.edPresentations).toEqual(state.edPresentations); + expect(wrongPrefix.auditEvents).toEqual(state.auditEvents); + expect(wrongPrefix.lastOutcome?.message).toMatch(/synthetic presentation series/i); + }); + + it("refuses an episode that suggests review or records a deviation without a reason", () => { + const state = createInitialPrototypeState(); + + const noReviewReason = prototypeReducer(state, { + type: "record-presentation", + presentationId: nextPresentationId(state), + input: presentationInput({ reviewSuggested: true, reviewReason: null }), + }); + expect(noReviewReason.edPresentations).toEqual(state.edPresentations); + expect(noReviewReason.reviewTriggers).toEqual(state.reviewTriggers); + expect(noReviewReason.lastOutcome?.message).toMatch(/needs a reason/i); + + const noDeviationReason = prototypeReducer(state, { + type: "record-presentation", + presentationId: nextPresentationId(state), + input: presentationInput({ deviationOccurred: true, deviationReason: " " }), + }); + expect(noDeviationReason.edPresentations).toEqual(state.edPresentations); + expect(noDeviationReason.lastOutcome?.message).toMatch(/needs a reason/i); + }); + + it("refuses a correction whose replacement is not a recorded disposition", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { + type: "amend-presentation", + presentationId: "SYN-PRESENTATION-001", + field: "disposition", + replacementValue: "sent home", + reason: "Correct the disposition recorded at the end of the shift.", + }); + + expect(next.presentationAmendments).toEqual(state.presentationAmendments); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.message).toMatch(/not one of the recorded answers/i); + + const accepted = prototypeReducer(state, { + type: "amend-presentation", + presentationId: "SYN-PRESENTATION-001", + field: "disposition", + replacementValue: "short_stay", + reason: "Correct the disposition recorded at the end of the shift.", + }); + expect(accepted.presentationAmendments.at(-1)).toMatchObject({ + field: "disposition", + originalValue: "mental_health_admission", + replacementValue: "short_stay", + }); + expect(accepted.edPresentations.find(({ id }) => id === "SYN-PRESENTATION-001")?.disposition).toBe( + "mental_health_admission", + ); + }); + + it("refuses a correction without a reason", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { + type: "amend-presentation", + presentationId: "SYN-PRESENTATION-001", + field: "note", + replacementValue: "A corrected one-line account of the episode.", + reason: " ", + }); + + expect(next.presentationAmendments).toEqual(state.presentationAmendments); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.message).toMatch(/needs a reason/i); + }); + + it("corrects the value a reader currently sees, not the value already replaced", () => { + const first = createInitialPrototypeState(); + const second = prototypeReducer(first, { + type: "amend-presentation", + presentationId: "SYN-PRESENTATION-001", + field: "planHelpfulness", + replacementValue: "mixed", + reason: "On reflection only part of the plan could be followed.", + }); + const third = prototypeReducer(second, { + type: "amend-presentation", + presentationId: "SYN-PRESENTATION-001", + field: "planHelpfulness", + replacementValue: "not_helpful", + reason: "Discussed with the person afterwards; the plan did not help on the night.", + }); + + expect(third.presentationAmendments.at(-1)).toMatchObject({ + originalValue: "mixed", + replacementValue: "not_helpful", + }); + // Two corrections, both preserved, and the episode itself still untouched. + expect(third.presentationAmendments).toHaveLength(first.presentationAmendments.length + 2); + expect(third.edPresentations.find(({ id }) => id === "SYN-PRESENTATION-001")?.planHelpfulness).toBe("helpful"); + }); +}); + +// --- Personal Safety Plan -------------------------------------------------------- + +describe("Care Plan Personal Safety Plan", () => { + it("lets an emergency department clinician publish a draft without any senior approval", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { + type: "make-safety-plan-current", + versionId: "SYN-SAFETY-VERSION-003", + }); + + expect(state.activeUserId).toBe(ED_CLINICIAN); + expect(next.personalSafetyPlanVersions.find(({ id }) => id === "SYN-SAFETY-VERSION-003")?.state).toBe("current"); + expect(next.personalSafetyPlans.find(({ id }) => id === "SYN-SAFETY-PLAN-003")?.currentVersionId).toBe( + "SYN-SAFETY-VERSION-003", + ); + // The Management Plan is untouched, and no senior approval was consulted. + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + expect(next.auditEvents.at(-1)?.type).toBe("safety_plan_made_current"); + }); + + it("supersedes the previous current Personal Safety Plan when a new one is published", () => { + let state = withUser(createInitialPrototypeState(), LIAISON); + state = prototypeReducer(state, { type: "create-safety-plan-draft", patientId: ROWAN }); + const draft = state.personalSafetyPlanVersions.find( + (version) => version.planId === "SYN-SAFETY-PLAN-001" && version.state === "draft", + )!; + const next = prototypeReducer(state, { type: "make-safety-plan-current", versionId: draft.id }); + const versions = next.personalSafetyPlanVersions.filter(({ planId }) => planId === "SYN-SAFETY-PLAN-001"); + + expect(versions.filter(({ state }) => state === "current")).toHaveLength(1); + expect(versions.find(({ id }) => id === "SYN-SAFETY-VERSION-001")?.state).toBe("superseded"); + expect(next.personalSafetyPlans.find(({ id }) => id === "SYN-SAFETY-PLAN-001")?.currentVersionId).toBe(draft.id); + }); + + it("refuses to publish a version that is not a draft, and refuses authorship by the non-clinical role", () => { + const state = createInitialPrototypeState(); + const notADraft = prototypeReducer(state, { + type: "make-safety-plan-current", + versionId: "SYN-SAFETY-VERSION-001", + }); + expect(notADraft.personalSafetyPlanVersions).toEqual(state.personalSafetyPlanVersions); + expect(notADraft.personalSafetyPlans).toEqual(state.personalSafetyPlans); + expect(notADraft.lastOutcome?.message).toMatch(/only a draft/i); + + const coordinator = withUser(createInitialPrototypeState(), COORDINATOR); + const wrongRole = prototypeReducer(coordinator, { + type: "make-safety-plan-current", + versionId: "SYN-SAFETY-VERSION-003", + }); + expect(wrongRole.personalSafetyPlanVersions).toEqual(coordinator.personalSafetyPlanVersions); + expect(wrongRole.lastOutcome?.kind).toBe("blocked"); + }); + + it("records only that a print view was opened", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { type: "record-safety-plan-print-intent", patientId: ROWAN }); + + expect(next.auditEvents).toHaveLength(1); + expect(next.auditEvents[0]).toMatchObject({ + type: "safety_plan_print_intent_opened", + objectId: "SYN-SAFETY-VERSION-001", + }); + expect(overclaims(next.auditEvents[0]!.evidence)).toBe(false); + expect(next.personalSafetyPlanVersions).toEqual(state.personalSafetyPlanVersions); + }); +}); + +// --- Identification review and contact actions ------------------------------------ + +describe("Care Plan identification review", () => { + it("records a manual referral without creating a plan, a version, or a Review Trigger", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { + type: "create-identification-review", + patientId: MIRA, + reason: "Asked the team to consider whether a coordinated plan would help after the last two episodes.", + }); + + expect(next.identificationReviews).toHaveLength(state.identificationReviews.length + 1); + expect(next.identificationReviews.at(-1)).toMatchObject({ + id: "SYN-IDENT-REVIEW-004", + patientId: MIRA, + status: "open", + referredBy: ED_CLINICIAN, + decision: null, + }); + expect(next.managementPlans).toEqual(state.managementPlans); + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + expect(next.reviewTriggers).toEqual(state.reviewTriggers); + expect(next.auditEvents.at(-1)?.type).toBe("identification_review_created"); + }); + + it("refuses a referral with no stated reason, because no numeric rule exists", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { type: "create-identification-review", patientId: MIRA, reason: "" }); + + expect(next.identificationReviews).toEqual(state.identificationReviews); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.message).toMatch(/needs a stated reason/i); + }); + + it("closes an open review with a decision and creates no plan on any decision", () => { + const state = withUser(createInitialPrototypeState(), LIAISON); + const next = prototypeReducer(state, { + type: "close-identification-review", + reviewId: "SYN-IDENT-REVIEW-001", + decision: "proceed_to_plan", + decisionReason: "The team agreed a coordinated plan would help, and Jordan wants to take part in writing it.", + }); + + expect(next.identificationReviews.find(({ id }) => id === "SYN-IDENT-REVIEW-001")).toMatchObject({ + status: "closed", + decision: "proceed_to_plan", + decidedBy: LIAISON, + }); + // `proceed_to_plan` is a conclusion, not an action: nothing was created. + expect(next.managementPlans).toEqual(state.managementPlans); + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + expect(next.auditEvents.filter(({ type }) => type === "identification_review_closed")).toHaveLength(1); + }); + + it("leaves an already-closed review unchanged and says so", () => { + const state = withUser(createInitialPrototypeState(), LIAISON); + const next = prototypeReducer(state, { + type: "close-identification-review", + reviewId: "SYN-IDENT-REVIEW-002", + decision: "not_needed_now", + decisionReason: "A second attempt to close the same referral.", + }); + + expect(next.identificationReviews).toEqual(state.identificationReviews); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.message).toMatch(/already closed/i); + }); + + it("refuses to close a review without a reason", () => { + const state = withUser(createInitialPrototypeState(), LIAISON); + const next = prototypeReducer(state, { + type: "close-identification-review", + reviewId: "SYN-IDENT-REVIEW-001", + decision: "revisit_later", + decisionReason: "\t", + }); + + expect(next.identificationReviews).toEqual(state.identificationReviews); + expect(next.lastOutcome?.message).toMatch(/needs a short reason/i); + }); +}); + +describe("Care Plan contact and worklist actions", () => { + it("records an email or telephone action as an intent only", () => { + const state = createInitialPrototypeState(); + const emailed = prototypeReducer(state, { + type: "record-contact-intent", + patientId: ROWAN, + cmhtId: "SYN-CMHT-001", + channel: "email", + }); + const called = prototypeReducer(emailed, { + type: "record-contact-intent", + patientId: ROWAN, + cmhtId: "SYN-CMHT-001", + channel: "call", + }); + + expect(emailed.auditEvents.at(-1)?.type).toBe("email_intent_opened"); + expect(called.auditEvents.at(-1)?.type).toBe("call_intent_opened"); + for (const event of called.auditEvents) { + expect(overclaims(event.evidence), `audit evidence overclaims: ${event.evidence}`).toBe(false); + } + expect(overclaims(called.lastOutcome?.message ?? "")).toBe(false); + // Nothing about the clinical record changed. + expect(called.cmhtContacts).toEqual(state.cmhtContacts); + expect(called.managementPlanVersions).toEqual(state.managementPlanVersions); + }); + + it("proves the overclaiming guard rejects the phrases it is meant to catch", () => { + expect(overclaims("An email was sent to the team.")).toBe(true); + expect(overclaims("The message sent to the shared mailbox was delivered.")).toBe(true); + expect(overclaims("Spoke to the duty clinician, who confirmed the appointment.")).toBe(true); + expect(overclaims("The Personal Safety Plan was printed for the person to take home.")).toBe(true); + expect(overclaims("An external email application was asked to open with a generic subject.")).toBe(false); + }); + + it("records a contact verification as a check of the displayed details", () => { + const state = withUser(createInitialPrototypeState("unverified-contact"), LIAISON); + const next = prototypeReducer(state, { type: "verify-cmht-contact", cmhtId: "SYN-CMHT-003" }); + + expect(next.cmhtContacts.find(({ id }) => id === "SYN-CMHT-003")?.verificationState).toBe("verified"); + expect(next.auditEvents.at(-1)?.type).toBe("cmht_contact_verified"); + expect(next.auditEvents.at(-1)?.evidence).toMatch(/not a guarantee that the service is available/i); + }); + + it("resolves an open Review Trigger without changing any plan", () => { + const state = withUser(createInitialPrototypeState(), LIAISON); + const next = prototypeReducer(state, { + type: "resolve-review-trigger", + triggerId: "SYN-TRIGGER-002", + resolution: "Discussed at the August meeting; the agreed approach still describes what happens.", + }); + + expect(next.reviewTriggers.find(({ id }) => id === "SYN-TRIGGER-002")).toMatchObject({ + status: "resolved", + resolution: expect.stringMatching(/still describes what happens/), + }); + expect(next.managementPlanVersions).toEqual(state.managementPlanVersions); + + const again = prototypeReducer(next, { + type: "resolve-review-trigger", + triggerId: "SYN-TRIGGER-002", + resolution: "A second attempt.", + }); + expect(again.reviewTriggers).toEqual(next.reviewTriggers); + expect(again.lastOutcome?.message).toMatch(/already resolved/i); + }); +}); + +// --- Degraded states, scenarios, and determinism ------------------------------------ + +describe("Care Plan degraded states", () => { + const degraded: readonly [PrototypeScenario, RegExp][] = [ + ["offline", /offline/i], + ["permission-unavailable", /permission/i], + ["identity-uncertain", /not been confirmed as the right person/i], + ["version-conflict", /newer version/i], + ]; + + it.each(degraded)("refuses every clinical change in the %s scenario", (scenario, expected) => { + const state = createInitialPrototypeState(scenario); + const next = prototypeReducer(state, { + type: "amend-presentation", + presentationId: "SYN-PRESENTATION-001", + field: "note", + replacementValue: "A corrected one-line account of the episode.", + reason: "Correct the recorded account.", + }); + + // Negative controls: each of these fails if the guard stops refusing. + expect(next.presentationAmendments).toEqual(state.presentationAmendments); + expect(next.edPresentations).toEqual(state.edPresentations); + expect(next.auditEvents).toEqual([]); + expect(next.lastOutcome?.kind).toBe("blocked"); + expect(next.lastOutcome?.message).toMatch(expected); + }); + + it("still allows the actions that change no clinical record", () => { + const state = createInitialPrototypeState("offline"); + expect(getPrototypeMutationBlockReason(state, { type: "select-patient", patientId: EVELYN })).toBeNull(); + expect(getPrototypeMutationBlockReason(state, { type: "clear-outcome" })).toBeNull(); + expect(getPrototypeMutationBlockReason(state, { type: "reset" })).toBeNull(); + + const selected = prototypeReducer(state, { type: "select-patient", patientId: EVELYN }); + expect(selected.selectedPatientId).toBe(EVELYN); + expect(selected.connectivity.online).toBe(false); + }); + + it("names a blocking reason for every clinical action and none for the rest", () => { + const offline = createInitialPrototypeState("offline"); + expect( + getPrototypeMutationBlockReason(offline, { + type: "approve-management-version", + versionId: MIRA_AWAITING_VERSION, + }), + ).toMatch(/offline/i); + expect( + getPrototypeMutationBlockReason(createInitialPrototypeState(), { + type: "approve-management-version", + versionId: MIRA_AWAITING_VERSION, + }), + ).toMatch(/does not carry this action/i); + expect( + getPrototypeMutationBlockReason(withUser(createInitialPrototypeState(), SENIOR), { + type: "approve-management-version", + versionId: MIRA_AWAITING_VERSION, + }), + ).toBeNull(); + }); +}); + +describe("Care Plan scenarios, reset, and determinism", () => { + it("builds each scenario's deterministic world without a browser API", () => { + expect(createInitialPrototypeState()).toMatchObject({ + scenario: "normal", + persistence: "memory-only", + activeUserId: ED_CLINICIAN, + selectedPatientId: ROWAN, + connectivity: { online: true }, + permission: { available: true }, + identity: { certain: true }, + versionConflict: { active: false }, + patientPlans: [], + patientPlanVersions: [], + patientResources: [], + auditEvents: [], + lastOutcome: null, + }); + expect(createInitialPrototypeState("empty").selectedPatientId).toBeNull(); + expect(createInitialPrototypeState("overdue-plan").selectedPatientId).toBe(MIRA); + expect(createInitialPrototypeState("withdrawn-plan").selectedPatientId).toBe(EVELYN); + expect(createInitialPrototypeState("no-current-plan").selectedPatientId).toBe(JORDAN); + }); + + it("does not share mutable fixture state between two prototype states", () => { + const first = createInitialPrototypeState(); + const second = createInitialPrototypeState(); + expect(first.patients).not.toBe(second.patients); + expect(first.patients[0]).not.toBe(second.patients[0]); + expect(first.patients).toEqual(second.patients); + }); + + it("reconstructs the world when the specimen scenario changes, and no-ops when it does not", () => { + const worked = prototypeReducer(createInitialPrototypeState(), { + type: "record-presentation", + presentationId: nextPresentationId(createInitialPrototypeState()), + input: presentationInput(), + }); + expect(worked.edPresentations).toHaveLength(21); + + const switched = prototypeReducer(worked, { type: "apply-scenario", scenario: "withdrawn-plan" }); + expect(switched).toEqual(createInitialPrototypeState("withdrawn-plan")); + + const same = prototypeReducer(worked, { type: "apply-scenario", scenario: "normal" }); + expect(same).toBe(worked); + }); + + it("returns to the deterministic fixture world on reset", () => { + const worked = run("normal", SENIOR, { + type: "withdraw-current-management-version", + patientId: ROWAN, + reason: "Taken out of use while a new version is written with Rowan.", + }); + expect(prototypeReducer(worked, { type: "reset" })).toEqual(createInitialPrototypeState()); + }); + + it("clears the last outcome without touching anything else", () => { + const blocked = prototypeReducer(createInitialPrototypeState(), { + type: "approve-management-version", + versionId: MIRA_AWAITING_VERSION, + }); + expect(blocked.lastOutcome?.kind).toBe("blocked"); + const cleared = prototypeReducer(blocked, { type: "clear-outcome" }); + expect(cleared.lastOutcome).toBeNull(); + expect({ ...cleared, lastOutcome: null }).toEqual({ ...blocked, lastOutcome: null }); + }); + + it("allocates identifiers from the identifiers already present", () => { + expect(nextSyntheticId("SYN-AUDIT", [])).toBe("SYN-AUDIT-001"); + expect(nextSyntheticId("SYN-TRIGGER", ["SYN-TRIGGER-001", "SYN-TRIGGER-003"])).toBe("SYN-TRIGGER-004"); + expect(nextSyntheticId("SYN-MGMT-VERSION", ["SYN-SAFETY-VERSION-009"])).toBe("SYN-MGMT-VERSION-001"); + expect(nextSyntheticId("SYN-TRIGGER", ["SYN-TRIGGER-0012"])).toBe("SYN-TRIGGER-013"); + }); + + it("produces the same state twice from the same action sequence, from PROTOTYPE_NOW alone", () => { + const sequence: CarePlanPrototypeAction[] = [ + { type: "create-management-draft", patientId: ROWAN }, + { type: "save-management-draft", versionId: "SYN-MGMT-VERSION-007", input: draftInput() }, + { type: "submit-management-draft", versionId: "SYN-MGMT-VERSION-007" }, + { type: "record-contact-intent", patientId: ROWAN, cmhtId: "SYN-CMHT-001", channel: "email" }, + ]; + const first = run("normal", LIAISON, ...sequence); + const second = run("normal", LIAISON, ...sequence); + + expect(JSON.stringify(first)).toBe(JSON.stringify(second)); + expect(first.auditEvents.map(({ id }) => id)).toEqual([ + "SYN-AUDIT-001", + "SYN-AUDIT-002", + "SYN-AUDIT-003", + "SYN-AUDIT-004", + ]); + // Every timestamp is `PROTOTYPE_NOW` advanced by whole minutes, so no wall + // clock and no random source can have been consulted. + expect(PROTOTYPE_NOW).toBe("2026-08-20T14:30:00+08:00"); + expect(first.auditEvents.map(({ occurredAt }) => occurredAt)).toEqual([ + "2026-08-20T14:31:00+08:00", + "2026-08-20T14:32:00+08:00", + "2026-08-20T14:33:00+08:00", + "2026-08-20T14:34:00+08:00", + ]); + }); + + it("never changes the state object it was given", () => { + const state = withUser(createInitialPrototypeState(), SENIOR); + const before = JSON.stringify(state); + + prototypeReducer(state, { type: "approve-management-version", versionId: MIRA_AWAITING_VERSION }); + prototypeReducer(state, { + type: "withdraw-current-management-version", + patientId: ROWAN, + reason: "A reason recorded for the withdrawal.", + }); + prototypeReducer(state, { + type: "record-presentation", + presentationId: nextPresentationId(state), + input: presentationInput({ planHelpfulness: "not_helpful" }), + }); + + expect(JSON.stringify(state)).toBe(before); + }); +}); From 9600e1250c75090677a4af641ec56fd2ff695a54 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:16:59 +0800 Subject: [PATCH 13/56] docs(care-plan): fix three plan defects Task 2 surfaced All three are controller mistakes in the plan, not implementation faults. 1. ReviewTrigger.source could not express the participation trigger the Global Constraints require. Approving a version at declined or patient_unavailable participation is supposed to raise an open trigger so involving the person stays on somebody list, but the canonical union had no value for it and redefining canonical types is forbidden. Adds a participation source. The on-screen marker is not a substitute: a marker is read only by whoever opens that plan, while a trigger reaches the Reviews queue where somebody owns it. 2. The plan told Task 2 to add an online/offline listener. The spec is binding and says the offline state exists only in the dedicated specimen scenario. Nothing in a memory-only prototype depends on the network, so a real connectivity event must not change state, and driving it through apply-scenario reconstructs fixtures and discards whatever the user was working on because their wifi blipped. Instruction removed with the reasoning recorded. 3. Task 2 example test named fixtures that predate Task 1. The shipped identifier is SYN-MGMT-PLAN-002, getOpenManagementDraft takes (versions, planId), and Mira former Current is version 1. Snippet corrected so it no longer misleads a later reader. Co-Authored-By: Claude Opus 5 --- .../2026-08-20-care-plan-implementation.md | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-08-20-care-plan-implementation.md b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md index 99c7d7319..b7b5edd9a 100644 --- a/docs/superpowers/plans/2026-08-20-care-plan-implementation.md +++ b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md @@ -438,7 +438,17 @@ export type ReviewTrigger = { id: SyntheticId; patientId: SyntheticId; managementPlanId: SyntheticId; - source: "plan_use_feedback" | "presentation_outcome" | "plan_deviation" | "formal_review" | "contact_verification"; + source: + | "plan_use_feedback" + | "presentation_outcome" + | "plan_deviation" + | "formal_review" + | "contact_verification" + /** Raised when a version is approved at `declined` or `patient_unavailable` + * 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"; sourceId: SyntheticId; reason: string; status: "open" | "resolved"; @@ -658,14 +668,14 @@ it("builds a generic CMHT email intent without patient information", () => { it("approves an awaiting version atomically and preserves exactly one Current Plan", () => { let state = createInitialPrototypeState("overdue-plan"); state = prototypeReducer(state, { type: "set-active-user", userId: "SYN-USER-SENIOR-001" }); - const awaitingId = getOpenManagementDraft(state, "SYN-PATIENT-002")!.id; + const awaitingId = getOpenManagementDraft(state.managementPlanVersions, "SYN-MGMT-PLAN-002")!.id; const next = prototypeReducer(state, { type: "approve-management-version", versionId: awaitingId }); - const versions = next.managementPlanVersions.filter(({ planId }) => planId === "SYN-MANAGEMENT-PLAN-002"); + const versions = next.managementPlanVersions.filter(({ planId }) => planId === "SYN-MGMT-PLAN-002"); expect(versions.filter(({ state }) => state === "current")).toHaveLength(1); expect(versions.find(({ id }) => id === awaitingId)?.state).toBe("current"); - expect(versions.find(({ version }) => version === 2)?.state).toBe("superseded"); - expect(next.managementPlans.find(({ id }) => id === "SYN-MANAGEMENT-PLAN-002")?.currentVersionId).toBe(awaitingId); + expect(versions.find(({ version }) => version === 1)?.state).toBe("superseded"); + expect(next.managementPlans.find(({ id }) => id === "SYN-MGMT-PLAN-002")?.currentVersionId).toBe(awaitingId); }); it("adds a visible amendment without changing the original ED Presentation", () => { @@ -746,13 +756,15 @@ export type CarePlanPrototypeAction = - [ ] Enforce role permissions through `canPerformAction`. The reducer must independently recheck permission; unavailable UI is not the transition guard. - [ ] Make approval validate: actor role, version state, named approver, complete required content, and an existing plan. In one returned state, supersede the previous Current version, make the submitted version Current, set approver/approval date/review state, update `currentVersionId`, and append one audit event. +- [ ] Make approval raise one open `ReviewTrigger` with `source: "participation"` when the approved version's `participationState` is `declined` or `patient_unavailable`, deduplicated against an existing open participation trigger for the same plan. The persistent on-screen marker is not a substitute: a marker is seen only by whoever opens that plan, whereas a trigger reaches the Reviews queue where somebody owns it. - [ ] Make return-for-changes require a non-empty reason and return Awaiting Approval to Draft without touching Current. Make withdrawal require a non-empty reason and leave no Current version. - [ ] Make `record-presentation` append an episode and audit event. If helpfulness is `mixed` or `not_helpful`, `reviewSuggested` is true, disposition is a mental-health/medical admission, or a material deviation is recorded, append one deduplicated open Review Trigger. - [ ] Make `amend-presentation` append original/replacement/reason/actor/time evidence while keeping the episode immutable. Restrict amendable fields to `AmendableField`, and reject a `disposition` replacement that does not parse to a `Disposition`. - [ ] Make Safety Plan publication require an editable Draft, supersede the prior Current Safety Plan, and set the new version Current without consulting senior-approval state. - [ ] Make contact-intent actions append only intent audit events; make manual identification referral append an Identification Review and audit event without creating a Management Plan or Review Trigger. - [ ] Make `close-identification-review` require an `open` review and a non-empty reason, set `status: "closed"` with the decision, reason, actor, and time, and append one `identification_review_closed` audit event. It must create no plan and no version on any decision, including `proceed_to_plan`. Closing an already-closed review leaves state unchanged and sets a specific `lastOutcome`. -- [ ] Add `CarePlanPrototypeProvider`, `useCarePlanPrototype`, and an online/offline listener to `prototype-provider.tsx`. The provider calls `useReducer` once and performs no persistence. +- [ ] Add `CarePlanPrototypeProvider` and `useCarePlanPrototype` to `prototype-provider.tsx`. The provider calls `useReducer` once and performs no persistence. +- [ ] Do **not** add an online/offline listener. An earlier revision of this plan asked for one; the specification is binding and says the offline state exists "only in the dedicated specimen scenario". Nothing in a memory-only prototype depends on the network, so a real connectivity event must not change state — and driving it through `apply-scenario` reconstructs fixtures, discarding whatever the user was working on because their wifi blipped. Connectivity is a scenario flag set from the System states route in Task 10, and nowhere else. - [ ] Complete reducer tests for draft/current separation, non-senior approval refusal, return reason, withdrawal, formal review, Review Trigger creation/deduplication, Safety Plan independence, manual referral, intent-only audit language, degraded-state refusal, reset, and scenario reconstruction. - [ ] Run `npm run test -- tests/care-plan-domain.test.ts tests/care-plan-prototype-state.test.ts`. Expected GREEN: both files pass with zero failures. - [ ] Format the four Task 2 files, rerun the two tests, and inspect the diff for in-place array mutation, wall-clock/random IDs, overclaimed audit events, and transition paths that bypass permission checks. From 0e1fb4a4fd15913e1c41883fff8f2a4e37121d4b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:00:50 +0800 Subject: [PATCH 14/56] fix(care-plan): raise a participation trigger, drop the connectivity listener Fix round 1 against plan 9600e1250. ReviewTrigger.source gains "participation" from the corrected canonical block, and approving a version whose participationState is declined or patient_unavailable now raises one open trigger with that source, keyed to the approved version and deduplicated against an open participation trigger already on the plan. Participation still never blocks approval; what it does is put going through the plan with the person into the Reviews queue, where somebody owns it. A marker on the plan is read only by whoever opens that plan. Approval still appends exactly one audit event, and the two reasons are worded separately so neither declining nor being unavailable reads as blame. The provider loses its online/offline listener, ref and effect. Nothing in a memory-only prototype depends on the network, so a real connectivity event must not change state, and routing it through apply-scenario rebuilt the fixtures and discarded whatever the user was working on. connectivity.online is a specimen flag now set from the System states route alone; the reasoning is recorded in the file so it is not re-added. Co-Authored-By: Claude Opus 5 --- .../care-plan/mockups/prototype-provider.tsx | 35 +---- .../care-plan/mockups/prototype-state.ts | 40 +++++- src/components/care-plan/mockups/types.ts | 12 +- tests/care-plan-prototype-state.test.ts | 123 ++++++++++++++++++ 4 files changed, 180 insertions(+), 30 deletions(-) diff --git a/src/components/care-plan/mockups/prototype-provider.tsx b/src/components/care-plan/mockups/prototype-provider.tsx index 980cbe525..0f5d62ed2 100644 --- a/src/components/care-plan/mockups/prototype-provider.tsx +++ b/src/components/care-plan/mockups/prototype-provider.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useEffect, useMemo, useReducer, useRef, type ReactNode } from "react"; +import { createContext, useContext, useMemo, useReducer, type ReactNode } from "react"; import { createInitialPrototypeState, prototypeReducer } from "./prototype-state"; import type { CarePlanPrototypeAction, CarePlanPrototypeState } from "./types"; @@ -17,36 +17,15 @@ const CarePlanPrototypeContext = createContext createInitialPrototypeState()); - - // The reducer must stay a pure function of the state it is given, so the - // browser's own connectivity is read here and only here, and reaches the - // state as an ordinary dispatched action like everything else. - const scenarioRef = useRef(state.scenario); - useEffect(() => { - scenarioRef.current = state.scenario; - }, [state.scenario]); - - useEffect(() => { - // Only the offline specimen is entered and left by these events. A spurious - // event while another specimen is displayed changes nothing, so a flicker in - // the network connection cannot discard work that is only held in memory. - const handleOnline = () => { - if (scenarioRef.current === "offline") dispatch({ type: "apply-scenario", scenario: "normal" }); - }; - const handleOffline = () => { - if (scenarioRef.current !== "offline") dispatch({ type: "apply-scenario", scenario: "offline" }); - }; - window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); - }; - }, []); - const value = useMemo(() => ({ state, dispatch }), [state]); return {children}; diff --git a/src/components/care-plan/mockups/prototype-state.ts b/src/components/care-plan/mockups/prototype-state.ts index 52a476606..9824cdefe 100644 --- a/src/components/care-plan/mockups/prototype-state.ts +++ b/src/components/care-plan/mockups/prototype-state.ts @@ -746,12 +746,47 @@ export function prototypeReducer( // Two Current versions would show two plans both approved for use now. assertSingleCurrentVersion(managementPlanVersions); + // A version may be approved at any participation state — the person's + // absence must never block their plan being written. What it does raise is + // an open trigger, so going through the plan with them lands in the Reviews + // queue where somebody owns it. The persistent on-screen marker is not + // enough on its own: a marker is read only by whoever opens that plan. + const involvementNotRecorded = + version.participationState === "declined" || version.participationState === "patient_unavailable"; + const participationAlreadyOpen = state.reviewTriggers.some( + (trigger) => + trigger.managementPlanId === plan.id && trigger.source === "participation" && trigger.status === "open", + ); + const participationTrigger: ReviewTrigger | null = + involvementNotRecorded && !participationAlreadyOpen + ? { + id: nextSyntheticId( + "SYN-TRIGGER", + state.reviewTriggers.map(({ id }) => id), + ), + patientId: plan.patientId, + managementPlanId: plan.id, + source: "participation", + sourceId: version.id, + reason: + version.participationState === "declined" + ? `Version ${version.version} was approved after this person chose not to take part in writing it. Offer again at the next contact, and record what they decide.` + : `Version ${version.version} was approved while this person was not available to take part. Go through the plan with them at the next contact, and record what they say.`, + status: "open", + createdAt: prototypeTimestamp(state, 1), + resolvedAt: null, + resolution: null, + } + : 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], auditEvents: withAudit(state, { type: "management_version_approved", patientId: plan.patientId, @@ -760,7 +795,10 @@ export function prototypeReducer( }), lastOutcome: { kind: "success", - message: `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}.`, + message: + participationTrigger === null + ? `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}.` + : `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}. It was written without this person's involvement, so an open Review Trigger was raised for the team.`, }, }; } diff --git a/src/components/care-plan/mockups/types.ts b/src/components/care-plan/mockups/types.ts index c66df5496..ceb87df64 100644 --- a/src/components/care-plan/mockups/types.ts +++ b/src/components/care-plan/mockups/types.ts @@ -335,7 +335,17 @@ export type ReviewTrigger = { id: SyntheticId; patientId: SyntheticId; managementPlanId: SyntheticId; - source: "plan_use_feedback" | "presentation_outcome" | "plan_deviation" | "formal_review" | "contact_verification"; + source: + | "plan_use_feedback" + | "presentation_outcome" + | "plan_deviation" + | "formal_review" + | "contact_verification" + /** Raised when a version is approved at `declined` or `patient_unavailable` + * 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"; sourceId: SyntheticId; reason: string; status: "open" | "resolved"; diff --git a/tests/care-plan-prototype-state.test.ts b/tests/care-plan-prototype-state.test.ts index f4d978b1c..b9f7985a8 100644 --- a/tests/care-plan-prototype-state.test.ts +++ b/tests/care-plan-prototype-state.test.ts @@ -326,6 +326,129 @@ describe("Care Plan approval guards", () => { }); }); +// --- Participation: approving a plan written without the person ------------------- + +describe("Care Plan participation triggers", () => { + it("raises one open participation Review Trigger when a version is approved without the person's involvement", () => { + const before = withUser(createInitialPrototypeState("overdue-plan"), SENIOR); + expect(before.managementPlanVersions.find(({ id }) => id === MIRA_AWAITING_VERSION)?.participationState).toBe( + "patient_unavailable", + ); + + const next = prototypeReducer(before, { type: "approve-management-version", versionId: MIRA_AWAITING_VERSION }); + const added = next.reviewTriggers.filter((trigger) => !before.reviewTriggers.some(({ id }) => id === trigger.id)); + + expect(added).toHaveLength(1); + expect(added[0]).toMatchObject({ + source: "participation", + status: "open", + patientId: MIRA, + managementPlanId: MIRA_PLAN, + sourceId: MIRA_AWAITING_VERSION, + resolvedAt: null, + resolution: null, + }); + // The version is still approved: participation never blocks approval. + expect(next.managementPlanVersions.find(({ id }) => id === MIRA_AWAITING_VERSION)?.state).toBe("current"); + expect(next.auditEvents.filter(({ type }) => type === "management_version_approved")).toHaveLength(1); + expect(next.lastOutcome?.kind).toBe("success"); + }); + + it.each(["co_produced", "discussed"] as const)( + "raises no participation Review Trigger when involvement is recorded as %s", + (participationState) => { + const state = run( + "normal", + LIAISON, + { type: "create-management-draft", patientId: ROWAN }, + { + type: "save-management-draft", + versionId: "SYN-MGMT-VERSION-007", + input: draftInput({ participationState }), + }, + { type: "submit-management-draft", versionId: "SYN-MGMT-VERSION-007" }, + { type: "set-active-user", userId: SENIOR }, + ); + const next = prototypeReducer(state, { + type: "approve-management-version", + versionId: "SYN-MGMT-VERSION-007", + }); + + expect(next.managementPlanVersions.find(({ id }) => id === "SYN-MGMT-VERSION-007")?.state).toBe("current"); + // Negative control: the guard is not simply always-on. + expect(next.reviewTriggers).toEqual(state.reviewTriggers); + }, + ); + + it("does not raise a second participation Review Trigger while one is already open on the plan", () => { + const first = run( + "normal", + LIAISON, + { type: "create-management-draft", patientId: ROWAN }, + { + type: "save-management-draft", + versionId: "SYN-MGMT-VERSION-007", + input: draftInput({ participationState: "declined" }), + }, + { type: "submit-management-draft", versionId: "SYN-MGMT-VERSION-007" }, + { type: "set-active-user", userId: SENIOR }, + { type: "approve-management-version", versionId: "SYN-MGMT-VERSION-007" }, + ); + expect( + first.reviewTriggers.filter( + (trigger) => + trigger.managementPlanId === ROWAN_PLAN && trigger.source === "participation" && trigger.status === "open", + ), + ).toHaveLength(1); + + const secondRound: CarePlanPrototypeAction[] = [ + { type: "set-active-user", userId: LIAISON }, + { type: "create-management-draft", patientId: ROWAN }, + { + type: "save-management-draft", + versionId: "SYN-MGMT-VERSION-008", + input: draftInput({ participationState: "patient_unavailable" }), + }, + { type: "submit-management-draft", versionId: "SYN-MGMT-VERSION-008" }, + { type: "set-active-user", userId: SENIOR }, + { type: "approve-management-version", versionId: "SYN-MGMT-VERSION-008" }, + ]; + const second = secondRound.reduce((state, action) => prototypeReducer(state, action), first); + + expect(second.managementPlanVersions.find(({ id }) => id === "SYN-MGMT-VERSION-008")?.state).toBe("current"); + expect(second.reviewTriggers).toEqual(first.reviewTriggers); + }); + + it("writes both participation trigger reasons without blaming the person", () => { + const blaming = /\b(difficult|refus\w*|uncooperative|non-compliant|declined to engage|failed to)\b/i; + // Negative control: the guard rejects the wording it exists to keep out. + expect(blaming.test("The patient refuses to engage with the plan.")).toBe(true); + + const unavailable = prototypeReducer(withUser(createInitialPrototypeState("overdue-plan"), SENIOR), { + type: "approve-management-version", + versionId: MIRA_AWAITING_VERSION, + }).reviewTriggers.find(({ source }) => source === "participation")!; + expect(unavailable.reason).toMatch(/not available to take part/i); + expect(unavailable.reason).not.toMatch(blaming); + + const declined = run( + "normal", + LIAISON, + { type: "create-management-draft", patientId: ROWAN }, + { + type: "save-management-draft", + versionId: "SYN-MGMT-VERSION-007", + input: draftInput({ participationState: "declined" }), + }, + { type: "submit-management-draft", versionId: "SYN-MGMT-VERSION-007" }, + { type: "set-active-user", userId: SENIOR }, + { type: "approve-management-version", versionId: "SYN-MGMT-VERSION-007" }, + ).reviewTriggers.find(({ source }) => source === "participation")!; + expect(declined.reason).toMatch(/chose not to take part/i); + expect(declined.reason).not.toMatch(blaming); + }); +}); + // --- ED Presentations: append-only, and what they raise --------------------------- describe("Care Plan ED Presentation recording", () => { From bdcf2f0db4d3a653f1a36c0e8c8e0177d2f26779 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:39:42 +0800 Subject: [PATCH 15/56] docs(care-plan): rule on three findings from the Task 2 review 1. Review Triggers must reach the withdrawn-plan cohort. The reducer gated trigger creation on a live Current version, so a person whose plan was withdrawn who then presents and is admitted produced nothing for the Reviews queue. That is the cohort the queue most exists for. The line is now has-ever-had-a-version, not has-a-current-version; only a patient who never had one raises none, and for them the pathway is Identification Review rather than plan review. 2. 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. 3. Printing the Personal Safety Plan is exempt from the offline block. It is the one action you most want when systems are down and it appends an audit event rather than changing a clinical record. Identity uncertainty still blocks it, because printing the wrong persons safety plan is a real harm. Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-08-20-care-plan-implementation.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-08-20-care-plan-implementation.md b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md index b7b5edd9a..2db81839f 100644 --- a/docs/superpowers/plans/2026-08-20-care-plan-implementation.md +++ b/docs/superpowers/plans/2026-08-20-care-plan-implementation.md @@ -697,6 +697,7 @@ it("adds a visible amendment without changing the original ED Presentation", () - [ ] Run `npm run test -- tests/care-plan-prototype-state.test.ts`. If the import cannot resolve, add only the exported reducer signatures with throwing bodies, rerun, and confirm the first behavioural assertion fails for the intended lifecycle reason before implementing the transition. - [ ] Define `createInitialPrototypeState(scenario: PrototypeScenario = "normal")` by deep-cloning fixture arrays, setting `persistence: "memory-only"`, selecting Rowan by default, and deriving scenario flags without browser APIs. - [ ] Define `getPrototypeMutationBlockReason(state, action)` so offline, unavailable permission, identity uncertainty, and version conflict leave clinical entities unchanged and set a specific `lastOutcome`. +- [ ] Exempt `record-safety-plan-print-intent` from the offline and connectivity blocks. Printing the person's own safety plan is the one action you most want available when systems are down, and it appends an audit event rather than changing any clinical record. Identity uncertainty still blocks it, because printing the wrong person's safety plan is a real harm. - [ ] Implement deterministic ID allocation as `nextSyntheticId(prefix, existingIds)` plus `nextPresentationId(state)`, and use `PROTOTYPE_NOW` plus stable per-action minute offsets. Do not use randomness or the wall clock in reducer tests. Reducer actions that receive an allocated ID must reject duplicates and the wrong synthetic prefix. - [ ] Implement the exact discriminated action set: @@ -755,10 +756,11 @@ export type CarePlanPrototypeAction = ``` - [ ] Enforce role permissions through `canPerformAction`. The reducer must independently recheck permission; unavailable UI is not the transition guard. -- [ ] Make approval validate: actor role, version state, named approver, complete required content, and an existing plan. In one returned state, supersede the previous Current version, make the submitted version Current, set approver/approval date/review state, update `currentVersionId`, and append one audit event. +- [ ] Make approval validate: actor role, version state, named approver, complete required content, a non-empty `revisionReason`, and an existing plan. A version must not become the Current Plan without a stated reason for existing; the reducer is the final guard, not the form, so this cannot be left to Task 6. In one returned state, supersede the previous Current version, make the submitted version Current, set approver/approval date/review state, update `currentVersionId`, and append one audit event. - [ ] Make approval raise one open `ReviewTrigger` with `source: "participation"` when the approved version's `participationState` is `declined` or `patient_unavailable`, deduplicated against an existing open participation trigger for the same plan. The persistent on-screen marker is not a substitute: a marker is seen only by whoever opens that plan, whereas a trigger reaches the Reviews queue where somebody owns it. - [ ] Make return-for-changes require a non-empty reason and return Awaiting Approval to Draft without touching Current. Make withdrawal require a non-empty reason and leave no Current version. - [ ] Make `record-presentation` append an episode and audit event. If helpfulness is `mixed` or `not_helpful`, `reviewSuggested` is true, disposition is a mental-health/medical admission, or a material deviation is recorded, append one deduplicated open Review Trigger. +- [ ] Raise that trigger whenever the patient **has ever had** a Management Plan version, including when the Current version has been withdrawn and `currentVersionId` is `null`. Only a patient who has never had any version raises none — for them the pathway is Identification Review, not plan review. A person whose plan was withdrawn and who then presents and is admitted is precisely who the Reviews queue exists for; gating the trigger on a live Current version drops that cohort silently. Pin the withdrawn case with its own test rather than leaving it an untested consequence of the never-had-one test. - [ ] Make `amend-presentation` append original/replacement/reason/actor/time evidence while keeping the episode immutable. Restrict amendable fields to `AmendableField`, and reject a `disposition` replacement that does not parse to a `Disposition`. - [ ] Make Safety Plan publication require an editable Draft, supersede the prior Current Safety Plan, and set the new version Current without consulting senior-approval state. - [ ] Make contact-intent actions append only intent audit events; make manual identification referral append an Identification Review and audit event without creating a Management Plan or Review Trigger. From def541e6a21d79b13513a6aa0545a6a59adaad91 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:34:19 +0800 Subject: [PATCH 16/56] fix(care-plan): reach the withdrawn cohort and cover the safety-plan save Fix round 2 against the Task 2 review and rulings bdcf2f0db. A Review Trigger was gated on a live Current version, so someone whose plan had been withdrawn, who then presented and was admitted, produced nothing for the Reviews queue. The line is now whether the patient has ever had a Management Plan version. Only a patient who has never had one raises none, because for them the pathway is Identification Review rather than plan review. Jordan and Evelyn are now separate pinned tests rather than one test and an untested consequence. save-safety-plan-draft was dispatched by no test at all. Its state guard, its unparseable review date refusal and its content copy now have three, and mutations redden each of them. Approval additionally 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. record-safety-plan-print-intent is exempt from the connectivity block alone. Printing the person's own safety plan is what you most want when systems are down, and it appends an audit event rather than changing a record; identity uncertainty, permission and version conflict still block it, because printing the wrong person's safety plan is a real harm. The participation trigger reason no longer asserts that an unavailable person was absent. A new draft starts at that state before anybody records anything, so it now says only that no involvement is recorded. Co-Authored-By: Claude Opus 5 --- .../care-plan/mockups/prototype-state.ts | 44 ++++- tests/care-plan-prototype-state.test.ts | 175 +++++++++++++++++- 2 files changed, 208 insertions(+), 11 deletions(-) diff --git a/src/components/care-plan/mockups/prototype-state.ts b/src/components/care-plan/mockups/prototype-state.ts index 9824cdefe..8474b03ba 100644 --- a/src/components/care-plan/mockups/prototype-state.ts +++ b/src/components/care-plan/mockups/prototype-state.ts @@ -236,6 +236,15 @@ const CAPABILITY_BY_ACTION: Record { @@ -771,7 +789,11 @@ export function prototypeReducer( reason: version.participationState === "declined" ? `Version ${version.version} was approved after this person chose not to take part in writing it. Offer again at the next contact, and record what they decide.` - : `Version ${version.version} was approved while this person was not available to take part. Go through the plan with them at the next contact, and record what they say.`, + : // A new draft starts at this state before anybody records + // anything, so the reason says only what is known: that no + // involvement is on the record. It does not assert that the + // person was absent. + `Version ${version.version} was approved with no involvement recorded for this person. Go through the plan with them at the next contact, and record what they say.`, status: "open", createdAt: prototypeTimestamp(state, 1), resolvedAt: null, @@ -798,7 +820,7 @@ export function prototypeReducer( message: participationTrigger === null ? `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}.` - : `Version ${version.version} is now the Current Plan, approved by ${approver.displayName}. It was written without this person's involvement, so an open Review Trigger was raised for the team.`, + : `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.`, }, }; } @@ -941,13 +963,17 @@ export function prototypeReducer( recordedAt: prototypeTimestamp(state), }; - // A Review Trigger asks the team to reconsider the Current Plan, so it - // needs one to reconsider. A person with no Current Plan — never had one, - // or had one withdrawn — gets no trigger rather than an item in the - // worklist that points at nothing. + // A Review Trigger asks the team to reconsider this person's Management + // Plan, so the line is whether they have ever had a version — not whether + // one is Current right now. Someone whose plan was withdrawn, who then + // presents and is admitted, is precisely who the Reviews queue exists for; + // gating on a live Current version drops that cohort silently. Only a + // person who has never had any version raises none, because for them the + // pathway is Identification Review rather than plan review. const plan = findManagementPlan(state, patient); - const current = plan === null ? null : getCurrentManagementPlanVersion(state.managementPlanVersions, plan.id); - const candidate = plan === null || current === null ? null : reviewTriggerReasonFor(presentation); + const hasEverHadAVersion = + plan !== null && state.managementPlanVersions.some((existing) => existing.planId === plan.id); + const candidate = hasEverHadAVersion ? reviewTriggerReasonFor(presentation) : null; // One open trigger per plan per source. A team reconsiders the plan once; // a queue that fills with the same reason stops being read at all. const alreadyOpen = diff --git a/tests/care-plan-prototype-state.test.ts b/tests/care-plan-prototype-state.test.ts index b9f7985a8..5213cadc0 100644 --- a/tests/care-plan-prototype-state.test.ts +++ b/tests/care-plan-prototype-state.test.ts @@ -15,6 +15,7 @@ import type { ManagementDraftInput, NewEdPresentationInput, PrototypeScenario, + SafetyPlanDraftInput, } from "@/components/care-plan/mockups/types"; const ED_CLINICIAN = "SYN-USER-ED-001"; @@ -29,6 +30,7 @@ const EVELYN = "SYN-PATIENT-004"; const ROWAN_PLAN = "SYN-MGMT-PLAN-001"; const MIRA_PLAN = "SYN-MGMT-PLAN-002"; +const EVELYN_PLAN = "SYN-MGMT-PLAN-004"; const ROWAN_CURRENT_VERSION = "SYN-MGMT-VERSION-002"; const MIRA_AWAITING_VERSION = "SYN-MGMT-VERSION-004"; @@ -90,6 +92,24 @@ function draftInput(overrides: Partial = {}): ManagementDr }; } +function safetyDraftInput(overrides: Partial = {}): SafetyPlanDraftInput { + return { + reviewDueAt: "2027-08-20T14:30:00+08:00", + patientConfirmation: "confirmed", + collaborationNote: "Written together over one session. Jordan chose the wording and asked for a printed copy.", + content: { + warningSigns: ["Two or three nights of broken sleep in a row."], + saferSurroundings: ["Ask my brother to hold my medicines for a few days."], + reasonsForLiving: ["My dog, and my brother."], + selfStrategies: ["Walk to the river and back before I decide anything."], + connectionPeopleAndPlaces: ["The community garden on Saturday mornings."], + personalSupports: [{ name: "Sam Placeholder", relationship: "brother", phone: "0491 570 991" }], + professionalAndEmergencySupport: ["Wandoo District CMHT during working hours."], + }, + ...overrides, + }; +} + /** 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 = [ @@ -178,6 +198,31 @@ describe("Care Plan approval guards", () => { expect(next.lastOutcome?.message).toMatch(/required sections are empty/i); }); + it("refuses approval of a version with no stated reason for existing", () => { + const state = run( + "normal", + LIAISON, + { type: "create-management-draft", patientId: ROWAN }, + { type: "submit-management-draft", versionId: "SYN-MGMT-VERSION-007" }, + { type: "set-active-user", userId: SENIOR }, + ); + // The content came across from the Current Plan, so the reason is the only + // thing missing. + expect(state.managementPlanVersions.find(({ id }) => id === "SYN-MGMT-VERSION-007")?.revisionReason).toBe(""); + + const next = prototypeReducer(state, { + type: "approve-management-version", + versionId: "SYN-MGMT-VERSION-007", + }); + + expect(next.managementPlanVersions.find(({ id }) => id === "SYN-MGMT-VERSION-007")?.state).toBe( + "awaiting_approval", + ); + expect(next.managementPlans.find(({ id }) => id === ROWAN_PLAN)?.currentVersionId).toBe(ROWAN_CURRENT_VERSION); + expect(next.reviewTriggers).toEqual(state.reviewTriggers); + expect(next.lastOutcome?.message).toMatch(/stated reason/i); + }); + it("keeps a new Draft separate from the Current Plan", () => { const state = run("normal", LIAISON, { type: "create-management-draft", patientId: ROWAN }); const plan = state.managementPlans.find(({ id }) => id === ROWAN_PLAN)!; @@ -428,7 +473,9 @@ describe("Care Plan participation triggers", () => { type: "approve-management-version", versionId: MIRA_AWAITING_VERSION, }).reviewTriggers.find(({ source }) => source === "participation")!; - expect(unavailable.reason).toMatch(/not available to take part/i); + expect(unavailable.reason).toMatch(/no involvement recorded/i); + // Nobody recorded anything, so the record must not assert that they were absent. + expect(unavailable.reason).not.toMatch(/was not available/i); expect(unavailable.reason).not.toMatch(blaming); const declined = run( @@ -533,7 +580,7 @@ describe("Care Plan ED Presentation recording", () => { expect(next.reviewTriggers).toEqual(state.reviewTriggers); }); - it("raises no Review Trigger for a person who has no Current Plan to reconsider", () => { + it("raises no Review Trigger for a person who has never had a Management Plan version", () => { const state = createInitialPrototypeState(); const next = prototypeReducer(state, { type: "record-presentation", @@ -552,6 +599,37 @@ describe("Care Plan ED Presentation recording", () => { expect(next.edPresentations).toHaveLength(state.edPresentations.length + 1); }); + it("raises a Review Trigger for a person whose plan was withdrawn and who then presents", () => { + // The cohort the Reviews queue exists for. Evelyn has no Current version + // because hers was withdrawn, but there is still a plan to reconsider, and + // gating the trigger on a live Current version would drop her silently. + const state = createInitialPrototypeState(); + expect(state.managementPlans.find(({ id }) => id === EVELYN_PLAN)?.currentVersionId).toBeNull(); + expect(state.managementPlanVersions.some(({ planId }) => planId === EVELYN_PLAN)).toBe(true); + + const next = prototypeReducer(state, { + type: "record-presentation", + presentationId: nextPresentationId(state), + input: presentationInput({ + patientId: EVELYN, + managementPlanVersionId: null, + planAvailability: "unavailable", + planUse: "not_applicable", + planHelpfulness: "not_assessed", + disposition: "mental_health_admission", + }), + }); + const added = next.reviewTriggers.filter((trigger) => !state.reviewTriggers.some(({ id }) => id === trigger.id)); + + expect(added).toHaveLength(1); + expect(added[0]).toMatchObject({ + patientId: EVELYN, + managementPlanId: EVELYN_PLAN, + source: "presentation_outcome", + status: "open", + }); + }); + it("refuses an episode identifier that is already used or is not a synthetic presentation identifier", () => { const state = createInitialPrototypeState(); @@ -702,6 +780,61 @@ describe("Care Plan Personal Safety Plan", () => { expect(next.personalSafetyPlans.find(({ id }) => id === "SYN-SAFETY-PLAN-001")?.currentVersionId).toBe(draft.id); }); + it("saves a draft and copies its content rather than sharing the arrays it was handed", () => { + const state = createInitialPrototypeState(); + const input = safetyDraftInput(); + const next = prototypeReducer(state, { + type: "save-safety-plan-draft", + versionId: "SYN-SAFETY-VERSION-003", + input, + }); + const saved = next.personalSafetyPlanVersions.find(({ id }) => id === "SYN-SAFETY-VERSION-003")!; + + expect(saved).toMatchObject({ + state: "draft", + reviewDueAt: "2027-08-20T14:30:00+08:00", + patientConfirmation: "confirmed", + }); + expect(saved.content.warningSigns).toEqual(input.content.warningSigns); + expect(saved.collaborationNote).toMatch(/chose the wording/); + expect(next.auditEvents.at(-1)).toMatchObject({ + type: "safety_plan_draft_saved", + objectId: "SYN-SAFETY-VERSION-003", + patientId: JORDAN, + }); + + // Two records must never share one array. Changing the input after the save + // must not reach the saved record. + (input.content.warningSigns as string[]).push("A line added after the save."); + expect(saved.content.warningSigns).toHaveLength(1); + }); + + it("refuses a draft whose next review date cannot be read as a date", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { + type: "save-safety-plan-draft", + versionId: "SYN-SAFETY-VERSION-003", + input: safetyDraftInput({ reviewDueAt: "when we next meet" }), + }); + + expect(next.personalSafetyPlanVersions).toEqual(state.personalSafetyPlanVersions); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.message).toMatch(/could not be read as a date/i); + }); + + it("refuses to edit a version that is no longer a draft", () => { + const state = createInitialPrototypeState(); + const next = prototypeReducer(state, { + type: "save-safety-plan-draft", + versionId: "SYN-SAFETY-VERSION-001", + input: safetyDraftInput(), + }); + + expect(next.personalSafetyPlanVersions).toEqual(state.personalSafetyPlanVersions); + expect(next.auditEvents).toEqual(state.auditEvents); + expect(next.lastOutcome?.message).toMatch(/only a draft can be edited/i); + }); + it("refuses to publish a version that is not a draft, and refuses authorship by the non-clinical role", () => { const state = createInitialPrototypeState(); const notADraft = prototypeReducer(state, { @@ -913,6 +1046,44 @@ describe("Care Plan degraded states", () => { expect(next.lastOutcome?.message).toMatch(expected); }); + it("records a Personal Safety Plan print intent while offline, and blocks it on every other degraded state", () => { + const offline = createInitialPrototypeState("offline"); + const printed = prototypeReducer(offline, { type: "record-safety-plan-print-intent", patientId: ROWAN }); + + // The one action you most want available when systems are down. It appends + // an audit event and changes no clinical record. + expect(printed.auditEvents).toHaveLength(1); + expect(printed.auditEvents[0]).toMatchObject({ type: "safety_plan_print_intent_opened" }); + expect(printed.personalSafetyPlanVersions).toEqual(offline.personalSafetyPlanVersions); + + // Connectivity is the only exemption. Printing the wrong person safety plan + // is a real harm, so identity uncertainty still blocks it. + const uncertain = prototypeReducer(createInitialPrototypeState("identity-uncertain"), { + type: "record-safety-plan-print-intent", + patientId: ROWAN, + }); + expect(uncertain.auditEvents).toEqual([]); + expect(uncertain.lastOutcome?.kind).toBe("blocked"); + expect(uncertain.lastOutcome?.message).toMatch(/not been confirmed as the right person/i); + + const noPermission = prototypeReducer(createInitialPrototypeState("permission-unavailable"), { + type: "record-safety-plan-print-intent", + patientId: ROWAN, + }); + expect(noPermission.auditEvents).toEqual([]); + expect(noPermission.lastOutcome?.kind).toBe("blocked"); + + // And the exemption is for this action alone: everything else stays blocked. + expect( + getPrototypeMutationBlockReason(offline, { + type: "record-contact-intent", + patientId: ROWAN, + cmhtId: "SYN-CMHT-001", + channel: "email", + }), + ).toMatch(/offline/i); + }); + it("still allows the actions that change no clinical record", () => { const state = createInitialPrototypeState("offline"); expect(getPrototypeMutationBlockReason(state, { type: "select-patient", patientId: EVELYN })).toBeNull(); From 9227ec4575ae7d6bab3b68c40cfc0462f2ea12f5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:09:32 +0800 Subject: [PATCH 17/56] docs(care-plan): commit the SDD ledger and session handoff The worktree at .claude/worktrees/ed-care-plans-impl-7f44cd was destroyed for the third time today, mid-task, through an explicit git worktree lock. Nothing committed was lost - every recovery was one git worktree add - but the git-ignored SDD workspace died with it, taking the ledger, briefs, reports and review packages. So the ledger is now a tracked file. The superpowers skill puts it in git-ignored scratch, which is right when scratch is safe; it is not safe here. Reconstructed from the controlling session's context and the commit history, which agree. Adds: - docs/care-plan/sdd-ledger.md - progress, all 25 controller rulings with what each costs if wrong, 19 deferred minors for the whole-branch review, and four systemic lessons (shell layers corrupting source, guards that cannot fail, inaccurate reports, commit every task). - docs/care-plan/session-handoff-2026-08-21.md - state, the thirteen binding product decisions, the environment hazard, the prompt to start the next session, and the authorisation boundary. Rewrites CLAUDE-START-HERE.md to point at both and at the relocated worktree. Work has moved to D:\Worktrees\Database\care-plan. That parent directory has been untouched all day and a sibling worktree there survived all three destructions. Do not recreate a worktree under .claude/worktrees/. State: Tasks 1 and 2 complete, reviewed and committed - 121 tests passing, typecheck and lint clean at last run. Task 3 not started, no partial work. Nothing pushed. Co-Authored-By: Claude Opus 5 --- docs/care-plan/CLAUDE-START-HERE.md | 18 +- docs/care-plan/sdd-ledger.md | 299 +++++++++++++++++++ docs/care-plan/session-handoff-2026-08-21.md | 217 ++++++++++++++ 3 files changed, 527 insertions(+), 7 deletions(-) create mode 100644 docs/care-plan/sdd-ledger.md create mode 100644 docs/care-plan/session-handoff-2026-08-21.md diff --git a/docs/care-plan/CLAUDE-START-HERE.md b/docs/care-plan/CLAUDE-START-HERE.md index 572c52bf5..39d6d901f 100644 --- a/docs/care-plan/CLAUDE-START-HERE.md +++ b/docs/care-plan/CLAUDE-START-HERE.md @@ -1,17 +1,21 @@ # Care Plan — Claude start here -**Handover date:** 21 August 2026 (Australia/Perth) -**Implementation status:** Not started +**Last updated:** 21 August 2026 (Australia/Perth) +**Implementation status:** Tasks 1 and 2 complete, reviewed and committed. Task 3 not started. **Design status:** Approved -**Planning status:** Complete -**Working directory:** `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` (superseded 21 Aug 2026; the original `D:\Worktrees\Database\ed-care-plans` is now planning-only and must not be written to) -**Branch:** `claude/ed-care-plans-impl-7f44cd` (based on `main` at `97f614223`) +**Working directory:** `D:\Worktrees\Database\care-plan` +**Branch:** `claude/ed-care-plans-impl-7f44cd` ## One-minute start -**Superseded 21 August 2026.** Do all Care Plan work in `D:\Repos\Database\.claude\worktrees\ed-care-plans-impl-7f44cd` on branch `claude/ed-care-plans-impl-7f44cd`, which is based on current `main`. The four planning documents were copied there and are now tracked with the code. The original planning worktree `D:\Worktrees\Database\ed-care-plans` is read-only reference; do not edit it. Do not edit the dirty shared checkout at `D:\Repos\Database`. +**This file is orientation. The live state of the build lives in two others — read those first:** -Four user decisions were recorded on 21 August 2026 and are binding: build the synthetic prototype now but keep the domain shaped for later real storage; keep the full multi-service workflow including named senior-clinician approval; deliver Tasks 1–5 first and stop for user review; local task commits are authorised (nothing pushed). See the Revision history and Delivery Stages sections of the implementation plan. +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.** `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, the third time through an explicit `git worktree lock` while a task was running. Nothing committed was ever lost, because every task commits at its end; keep that habit. Do not edit the shared checkout at `D:\Repos\Database`, and do not edit the original planning worktree at `D:\Worktrees\Database\ed-care-plans`, which is read-only reference. + +**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: diff --git a/docs/care-plan/sdd-ledger.md b/docs/care-plan/sdd-ledger.md new file mode 100644 index 000000000..dcb65cc26 --- /dev/null +++ b/docs/care-plan/sdd-ledger.md @@ -0,0 +1,299 @@ +# 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` (relocated 21 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. +- **The work has been relocated to `D:\Worktrees\Database\care-plan`.** That + parent directory has been untouched all day; a sibling worktree there survived + all three events. +- The only repo mechanism that removes worktrees is `scripts/clean-worktree.mjs`, + which is chained into `npm run verify:preflight`. It protects "the current + worktree", meaning the one it runs in — not this one. It states it never passes + `--force`, so it does not explain the third deletion through a lock. **The cause + is not conclusively identified.** Treat the old location as unsafe and do not + return to it. +- **Commit at the end of every task, without exception.** That rule is what made + all three recoveries cheap. + +--- + +## 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 | **not started** | — | blocked by worktree destruction; no partial work exists | +| 4–11 | not started | — | — | + +Stage A is Tasks 1–5. **Task 5 ends with a mandatory stop for user review** before +Task 6 begins. + +### 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 — not started + +Blocked by the third worktree destruction. No partial work exists; nothing to +reconcile. Restart from the brief unchanged. + +--- + +## 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. + +--- + +## 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. + +--- + +## 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. 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..96b503e6e --- /dev/null +++ b/docs/care-plan/session-handoff-2026-08-21.md @@ -0,0 +1,217 @@ +# 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. Nothing has been pushed. + +--- + +## 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. + +--- + +## 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. + +- **The work has been relocated to `D:\Worktrees\Database\care-plan`.** That parent + directory has been untouched all day and a sibling worktree there survived all + three events. Work there. Do not recreate a worktree under `.claude/worktrees/`. +- **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. +- The cause is not conclusively identified. `scripts/clean-worktree.mjs`, chained + into `npm run verify:preflight`, is the only repo mechanism that removes + worktrees and it protects only the worktree it runs in — but it states it never + passes `--force`, so it does not explain a deletion through a lock. +- **Commit at the end of every task.** That single habit is why three destructions + cost nothing but time. + +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. Nothing else — no push, 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. From 0447f35a8a6d05e5c0bf5049d5bb9252d5fa61f6 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:36:57 +0800 Subject: [PATCH 18/56] docs(care-plan): record the relocated worktree verification state Re-running the suites after relocation gave Test Files 2 passed (2) / Tests 121 passed (121), so the recovery is sound. But npm run typecheck fails there, and the handoff must not imply otherwise. The failures are three unrelated pre-existing files - universal-search and two use-in-page-section-nav DOM tests - with module-resolution errors typical of a half-finished install, and there are zero errors in any care-plan source or test file. Typecheck exited 0 in the previous worktree at the same commit. The npm ci was backgrounded and never reported completion. Records all of that so the next session re-runs the install to completion before reporting any gate as green, rather than either trusting a stale clean result or mistaking an install artefact for a regression. Co-Authored-By: Claude Opus 5 --- docs/care-plan/session-handoff-2026-08-21.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/care-plan/session-handoff-2026-08-21.md b/docs/care-plan/session-handoff-2026-08-21.md index 96b503e6e..220876e7b 100644 --- a/docs/care-plan/session-handoff-2026-08-21.md +++ b/docs/care-plan/session-handoff-2026-08-21.md @@ -40,7 +40,22 @@ 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. +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)`. + +**Known environment state in the relocated worktree — read before trusting a gate.** +The `npm ci` there was backgrounded and never reported completion; `node_modules` held +523 packages when this handoff was written. The install is **definitely** incomplete, +not merely suspected: the `prettier` binary is absent entirely (`'prettier' is not +recognized`), so no format check could run. `npm run typecheck` +consequently **fails**, but on three unrelated pre-existing files only — +`tests/universal-search.test.ts` (TS7006) and two `use-in-page-section-nav*` DOM tests +(TS7016, unable to resolve `lucide-react` types even though its `.d.ts` files are present, +which is the signature of a half-finished install). **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 this is an install artefact, not a regression. +Re-run `npm ci --include=dev` to completion and re-check before reporting any gate as +green. Note that a full install on this machine has been measured at roughly 58 minutes. --- From 809f360bf122fac28dfd664ca4841702bb8f01f8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:47:08 +0800 Subject: [PATCH 19/56] docs(care-plan): add the complete cross-session work ledger A single chronological record of everything done on Care Plan across both sessions - the Codex design session and the Claude build session - with every artefact, every commit and its time, every decision and where it came from, the current state, and an explicit list of what has NOT been done so no later reader infers coverage that does not exist. The three existing documents each answer a different question and this adds the fourth: - complete-work-ledger.md - what happened, in order (this file) - sdd-ledger.md - decisions taken, with what each costs if wrong - session-handoff-2026-08-21.md - where to pick up, and the prompt to do it - CLAUDE-START-HERE.md - orientation, pointing at the other three Records that no UI exists yet, so no browser, accessibility, responsive, print, build or bundle check has ever run for this feature, and that the current typecheck failure in the relocated worktree is an unfinished dependency install rather than a regression. Co-Authored-By: Claude Opus 5 --- docs/care-plan/complete-work-ledger.md | 188 +++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/care-plan/complete-work-ledger.md diff --git a/docs/care-plan/complete-work-ledger.md b/docs/care-plan/complete-work-ledger.md new file mode 100644 index 000000000..c280fd076 --- /dev/null +++ b/docs/care-plan/complete-work-ledger.md @@ -0,0 +1,188 @@ +# 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` | + +**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. + +The cause is not conclusively identified. `scripts/clean-worktree.mjs`, chained into +`npm run verify:preflight`, is the only repository mechanism that removes worktrees and +protects only the one it runs in, but it never passes `--force`, so it does not explain a +deletion through a lock. There were 82 registered worktrees on this repository at the time. + +## 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". From ff796ec84767a0feb3e19657d250b0bb09ce8cff Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:24:46 +0800 Subject: [PATCH 20/56] style(care-plan): format the handoff documents Written while Prettier was unavailable in the relocated worktree, so never formatted. The pre-push guard caught it. Co-Authored-By: Claude Opus 5 --- docs/care-plan/complete-work-ledger.md | 44 ++++++++++---------- docs/care-plan/sdd-ledger.md | 10 ++--- docs/care-plan/session-handoff-2026-08-21.md | 42 ++++++++++--------- 3 files changed, 49 insertions(+), 47 deletions(-) diff --git a/docs/care-plan/complete-work-ledger.md b/docs/care-plan/complete-work-ledger.md index c280fd076..0932c9c5c 100644 --- a/docs/care-plan/complete-work-ledger.md +++ b/docs/care-plan/complete-work-ledger.md @@ -1,9 +1,9 @@ # 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*. +(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_. --- @@ -15,15 +15,15 @@ Task title "Management plan", task id `01a01fb2-575f-7c11-a245-332db7a85a25`, wo **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` | +| 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` | +| 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` | **Decided:** the product concept; the domain language, with an explicit `_Avoid_` list per term; one longitudinal Management Plan per patient with named senior-clinician @@ -84,8 +84,8 @@ confined to the identification workflow; amendment extended to the one-line acco 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"* +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. @@ -146,16 +146,16 @@ files because the git-ignored workspace had just been destroyed with everything ## 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 | +| | | +| ---------------- | -------------------------------------------------------------- | +| 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 +## 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 diff --git a/docs/care-plan/sdd-ledger.md b/docs/care-plan/sdd-ledger.md index dcb65cc26..9abad1e57 100644 --- a/docs/care-plan/sdd-ledger.md +++ b/docs/care-plan/sdd-ledger.md @@ -47,12 +47,12 @@ minutes. ## Progress -| Task | State | Commits | Evidence | -| --- | --- | --- | --- | -| 1. Domain, fixtures, selectors | **complete, review clean** | `8a2e6a6d1..8652e73ff` | 58/58 passing, typecheck clean | +| 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 | **not started** | — | blocked by worktree destruction; no partial work exists | -| 4–11 | not started | — | — | +| 3. Routes, gate, shell | **not started** | — | blocked by worktree destruction; no partial work exists | +| 4–11 | not started | — | — | Stage A is Tasks 1–5. **Task 5 ends with a mandatory stop for user review** before Task 6 begins. diff --git a/docs/care-plan/session-handoff-2026-08-21.md b/docs/care-plan/session-handoff-2026-08-21.md index 220876e7b..a9c704b13 100644 --- a/docs/care-plan/session-handoff-2026-08-21.md +++ b/docs/care-plan/session-handoff-2026-08-21.md @@ -19,14 +19,14 @@ committed**; Task 3 has not started. Nothing has been pushed. ## 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 | +| 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 @@ -43,19 +43,21 @@ reports `i/lf w/lf` on every Care Plan file, matching untouched repository files 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)`. -**Known environment state in the relocated worktree — read before trusting a gate.** -The `npm ci` there was backgrounded and never reported completion; `node_modules` held -523 packages when this handoff was written. The install is **definitely** incomplete, -not merely suspected: the `prettier` binary is absent entirely (`'prettier' is not -recognized`), so no format check could run. `npm run typecheck` -consequently **fails**, but on three unrelated pre-existing files only — +**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 even though its `.d.ts` files are present, -which is the signature of a half-finished install). **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 this is an install artefact, not a regression. -Re-run `npm ci --include=dev` to completion and re-check before reporting any gate as -green. Note that a full install on this machine has been measured at roughly 58 minutes. +(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. --- From 4e34a0a73a6930877f77f75c037e6a2edacc839e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:30:53 +0800 Subject: [PATCH 21/56] docs(care-plan): correct the relocation advice after a fourth destruction I advised moving the work to D:\Worktrees\Database\care-plan because that parent had been untouched all day. It was destroyed there too, within the hour, by the same method: .git pointer first, then 1,301 files. So the advice was wrong and the documents said so. No directory on this machine is safe. The only thing that has ever protected this work is committing and pushing, which is now done - origin/claude/ed-care-plans-impl-7f44cd is the authoritative copy. Co-Authored-By: Claude Opus 5 --- docs/care-plan/sdd-ledger.md | 8 +++++--- docs/care-plan/session-handoff-2026-08-21.md | 11 ++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/care-plan/sdd-ledger.md b/docs/care-plan/sdd-ledger.md index 9abad1e57..4087c9606 100644 --- a/docs/care-plan/sdd-ledger.md +++ b/docs/care-plan/sdd-ledger.md @@ -31,9 +31,11 @@ minutes. 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. -- **The work has been relocated to `D:\Worktrees\Database\care-plan`.** That - parent directory has been untouched all day; a sibling worktree there survived - all three events. +- **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. - The only repo mechanism that removes worktrees is `scripts/clean-worktree.mjs`, which is chained into `npm run verify:preflight`. It protects "the current worktree", meaning the one it runs in — not this one. It states it never passes diff --git a/docs/care-plan/session-handoff-2026-08-21.md b/docs/care-plan/session-handoff-2026-08-21.md index a9c704b13..8b6930a5b 100644 --- a/docs/care-plan/session-handoff-2026-08-21.md +++ b/docs/care-plan/session-handoff-2026-08-21.md @@ -69,9 +69,14 @@ through an explicit `git worktree lock`, while a task was running: the `.git` po file went first, making git resolve to the main checkout on the wrong branch, then 3,836 tracked files were deleted over three minutes. -- **The work has been relocated to `D:\Worktrees\Database\care-plan`.** That parent - directory has been untouched all day and a sibling worktree there survived all - three events. Work there. Do not recreate a worktree under `.claude/worktrees/`. +- **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 From f01b8583cb5f9407a8d05dce2d58112576d7ce9b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:57:49 +0800 Subject: [PATCH 22/56] docs(care-plan): the worktree destructions had a known cause, already fixed The three handover documents said the cause was not conclusively identified. That was wrong, and the answer was in front of me the whole time - both fixes are already on main and this branch predates them: 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) The old scripts/guard-push.mjs linked a borrowed worktree's real node_modules into a scratch checkout as a Windows junction, then force-deleted that checkout recursively. A git worktree lock cannot stop it because it is a filesystem delete, not a git worktree operation - which is exactly why the third destruction went through a lock. Any concurrent session pushing from a stale base ran it against whichever worktree it had borrowed from. This branch is 122 commits behind origin/main and has neither fix, so 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 before any further build work, and the documents now say so. Also corrects two claims that went stale: the branch is no longer unpushed, and the authorisation boundary now records that the push happened with the user's explicit agreement after they were told the repository is public. Co-Authored-By: Claude Opus 5 --- docs/care-plan/complete-work-ledger.md | 25 +++++++++++--- docs/care-plan/sdd-ledger.md | 28 ++++++++++++---- docs/care-plan/session-handoff-2026-08-21.md | 34 ++++++++++++++++---- 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/docs/care-plan/complete-work-ledger.md b/docs/care-plan/complete-work-ledger.md index 0932c9c5c..40b5a802d 100644 --- a/docs/care-plan/complete-work-ledger.md +++ b/docs/care-plan/complete-work-ledger.md @@ -175,10 +175,27 @@ Nothing committed was ever lost. Every recovery was one `git worktree add`. What 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. -The cause is not conclusively identified. `scripts/clean-worktree.mjs`, chained into -`npm run verify:preflight`, is the only repository mechanism that removes worktrees and -protects only the one it runs in, but it never passes `--force`, so it does not explain a -deletion through a lock. There were 82 registered worktrees on this repository at the 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. ## Honest limits diff --git a/docs/care-plan/sdd-ledger.md b/docs/care-plan/sdd-ledger.md index 4087c9606..6b3a9ee22 100644 --- a/docs/care-plan/sdd-ledger.md +++ b/docs/care-plan/sdd-ledger.md @@ -36,15 +36,31 @@ minutes. 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. -- The only repo mechanism that removes worktrees is `scripts/clean-worktree.mjs`, - which is chained into `npm run verify:preflight`. It protects "the current - worktree", meaning the one it runs in — not this one. It states it never passes - `--force`, so it does not explain the third deletion through a lock. **The cause - is not conclusively identified.** Treat the old location as unsafe and do not - return to it. - **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 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. + --- ## Progress diff --git a/docs/care-plan/session-handoff-2026-08-21.md b/docs/care-plan/session-handoff-2026-08-21.md index 8b6930a5b..a127a27a1 100644 --- a/docs/care-plan/session-handoff-2026-08-21.md +++ b/docs/care-plan/session-handoff-2026-08-21.md @@ -13,7 +13,8 @@ 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. Nothing has been pushed. +committed**; Task 3 has not started. The branch is pushed to +`origin/claude/ed-care-plans-impl-7f44cd`, which is the authoritative copy. --- @@ -81,13 +82,31 @@ file went first, making git resolve to the main checkout on the wrong branch, th - **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. -- The cause is not conclusively identified. `scripts/clean-worktree.mjs`, chained - into `npm run verify:preflight`, is the only repo mechanism that removes - worktrees and it protects only the worktree it runs in — but it states it never - passes `--force`, so it does not explain a deletion through a lock. - **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. @@ -105,8 +124,9 @@ grilling round. They are binding and must not be reopened. 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. Nothing else — no push, PR, merge, rebase, deployment, - or provider access. +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** From 94ffdcdf7462ab0bcc108d52552096272b365849 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:29:40 +0800 Subject: [PATCH 23/56] docs(care-plan): record the origin/main merge and the new worktree The merge landed with zero conflicts and both worktree-safety fixes (a04330ea0, cdfcbaccd) are now ancestors of HEAD, so this checkout runs the hardened guard-push. 121/121 tests and typecheck are green after it. Co-Authored-By: Claude Opus 5 --- docs/care-plan/sdd-ledger.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/care-plan/sdd-ledger.md b/docs/care-plan/sdd-ledger.md index 6b3a9ee22..fe5ab704b 100644 --- a/docs/care-plan/sdd-ledger.md +++ b/docs/care-plan/sdd-ledger.md @@ -4,7 +4,7 @@ **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` (relocated 21 Aug 2026 — see Environment hazard) +**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 @@ -52,14 +52,26 @@ This is **already fixed upstream** by two commits this branch does not yet conta 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 +**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. -**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. +### 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. + +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`. --- From 9d3a104daa39765183f101e5835a3cab2f1aac30 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 22 Aug 2026 04:56:32 +0800 Subject: [PATCH 24/56] feat(care-plan): add gated clinical route shell --- docs/care-plan/sdd-ledger.md | 47 ++- docs/site-map.md | 21 ++ src/app/mockups/care-plan/governance/page.tsx | 5 + src/app/mockups/care-plan/layout.tsx | 25 ++ src/app/mockups/care-plan/loading.tsx | 16 + src/app/mockups/care-plan/page.tsx | 12 + .../patients/[patientId]/history/page.tsx | 15 + .../[patientId]/management-plan/edit/page.tsx | 15 + .../[patientId]/management-plan/page.tsx | 15 + .../management-plan/print/page.tsx | 15 + .../management-plan/review/page.tsx | 15 + .../care-plan/patients/[patientId]/page.tsx | 15 + .../[patientId]/patient-plan/edit/page.tsx | 15 + .../[patientId]/patient-plan/page.tsx | 15 + .../[patientId]/patient-plan/print/page.tsx | 15 + .../presentations/[presentationId]/page.tsx | 26 ++ .../[patientId]/presentations/new/page.tsx | 15 + .../[patientId]/presentations/page.tsx | 15 + .../[patientId]/safety-plan/edit/page.tsx | 15 + .../patients/[patientId]/safety-plan/page.tsx | 15 + .../[patientId]/safety-plan/print/page.tsx | 15 + src/app/mockups/care-plan/patients/page.tsx | 5 + src/app/mockups/care-plan/reviews/page.tsx | 5 + src/app/mockups/care-plan/route-page.tsx | 31 ++ .../mockups/care-plan/system-states/page.tsx | 5 + src/app/mockups/care-plan/team/page.tsx | 5 + src/app/mockups/development/page.tsx | 23 +- src/app/mockups/mockups-layout-client.tsx | 6 + .../mockups/care-plan-error-boundary.tsx | 48 +++ .../mockups/care-plan-shell-frame.tsx | 242 +++++++++++++++ .../care-plan/mockups/care-plan.module.css | 278 +++++++++++++++++ src/components/care-plan/mockups/index.ts | 20 ++ .../care-plan/mockups/routable-suite.tsx | 290 ++++++++++++++++++ src/components/care-plan/mockups/routes.ts | 153 +++++++++ src/lib/developer-area/headers.ts | 11 +- src/proxy.ts | 16 +- tests/care-plan-linked-routes.dom.test.tsx | 265 ++++++++++++++++ tests/care-plan-route-files.test.ts | 280 +++++++++++++++++ tests/proxy.test.ts | 32 ++ 39 files changed, 2062 insertions(+), 15 deletions(-) create mode 100644 src/app/mockups/care-plan/governance/page.tsx create mode 100644 src/app/mockups/care-plan/layout.tsx create mode 100644 src/app/mockups/care-plan/loading.tsx create mode 100644 src/app/mockups/care-plan/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/history/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/management-plan/edit/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/management-plan/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/management-plan/print/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/management-plan/review/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/patient-plan/edit/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/patient-plan/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/patient-plan/print/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/presentations/[presentationId]/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/presentations/new/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/presentations/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/safety-plan/edit/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/safety-plan/page.tsx create mode 100644 src/app/mockups/care-plan/patients/[patientId]/safety-plan/print/page.tsx create mode 100644 src/app/mockups/care-plan/patients/page.tsx create mode 100644 src/app/mockups/care-plan/reviews/page.tsx create mode 100644 src/app/mockups/care-plan/route-page.tsx create mode 100644 src/app/mockups/care-plan/system-states/page.tsx create mode 100644 src/app/mockups/care-plan/team/page.tsx create mode 100644 src/components/care-plan/mockups/care-plan-error-boundary.tsx create mode 100644 src/components/care-plan/mockups/care-plan-shell-frame.tsx create mode 100644 src/components/care-plan/mockups/care-plan.module.css create mode 100644 src/components/care-plan/mockups/index.ts create mode 100644 src/components/care-plan/mockups/routable-suite.tsx create mode 100644 src/components/care-plan/mockups/routes.ts create mode 100644 tests/care-plan-linked-routes.dom.test.tsx create mode 100644 tests/care-plan-route-files.test.ts diff --git a/docs/care-plan/sdd-ledger.md b/docs/care-plan/sdd-ledger.md index fe5ab704b..4533d856e 100644 --- a/docs/care-plan/sdd-ledger.md +++ b/docs/care-plan/sdd-ledger.md @@ -66,6 +66,27 @@ conflict and did not. `git merge-base --is-ancestor` confirms both `a04330ea0` a `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 @@ -81,7 +102,7 @@ is the only damage it carried — its tracked tree was clean and byte-identical | ------------------------------- | -------------------------- | ---------------------- | --------------------------------------------------------------------- | | 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 | **not started** | — | blocked by worktree destruction; no partial work exists | +| 3. Routes, gate, shell | **in progress** | BASE `d421bc2dc` | restarted 22 Aug 2026 after the merge; no partial work existed | | 4–11 | not started | — | — | Stage A is Tasks 1–5. **Task 5 ends with a mandatory stop for user review** before @@ -105,10 +126,11 @@ Task 6 begins. 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 — not started +### Task 3 — in progress (started 22 August 2026) -Blocked by the third worktree destruction. No partial work exists; nothing to -reconcile. Restart from the brief unchanged. +The third worktree destruction blocked it; no partial work exists and there was nothing +to reconcile, so it restarts from the brief unchanged. BASE for the review package is +`d421bc2dc`. --- @@ -277,6 +299,23 @@ text against itself. Six conflicts found, six ruled. 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. + --- ## Deferred minors — for the whole-branch review to triage diff --git a/docs/site-map.md b/docs/site-map.md index 32b4292cb..30d4fd979 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1153,6 +1153,27 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/calculators-popup-sheet` - Route discovered from app directory Source: `src/app/mockups/calculators-popup-sheet/page.tsx`. - `/mockups/calculators-search` - Route discovered from app directory Source: `src/app/mockups/calculators-search/page.tsx`. - `/mockups/calculators-search-page` - Route discovered from app directory Source: `src/app/mockups/calculators-search-page/page.tsx`. +- `/mockups/care-plan` - Route discovered from app directory Source: `src/app/mockups/care-plan/page.tsx`. +- `/mockups/care-plan/governance` - Route discovered from app directory Source: `src/app/mockups/care-plan/governance/page.tsx`. +- `/mockups/care-plan/patients` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/page.tsx`. +- `/mockups/care-plan/patients/[patientId]` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/history` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/history/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/management-plan` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/management-plan/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/management-plan/edit` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/management-plan/edit/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/management-plan/print` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/management-plan/print/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/management-plan/review` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/management-plan/review/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/patient-plan` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/patient-plan/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/patient-plan/edit` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/patient-plan/edit/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/patient-plan/print` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/patient-plan/print/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/presentations` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/presentations/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/presentations/[presentationId]` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/presentations/[presentationId]/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/presentations/new` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/presentations/new/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/safety-plan` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/safety-plan/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/safety-plan/edit` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/safety-plan/edit/page.tsx`. +- `/mockups/care-plan/patients/[patientId]/safety-plan/print` - Route discovered from app directory Source: `src/app/mockups/care-plan/patients/[patientId]/safety-plan/print/page.tsx`. +- `/mockups/care-plan/reviews` - Route discovered from app directory Source: `src/app/mockups/care-plan/reviews/page.tsx`. +- `/mockups/care-plan/system-states` - Route discovered from app directory Source: `src/app/mockups/care-plan/system-states/page.tsx`. +- `/mockups/care-plan/team` - Route discovered from app directory Source: `src/app/mockups/care-plan/team/page.tsx`. - `/mockups/caring-contacts` - Route discovered from app directory Source: `src/app/mockups/caring-contacts/page.tsx`. - `/mockups/caring-contacts/contacts/[contactId]` - Route discovered from app directory Source: `src/app/mockups/caring-contacts/contacts/[contactId]/page.tsx`. - `/mockups/caring-contacts/guidance` - Route discovered from app directory Source: `src/app/mockups/caring-contacts/guidance/page.tsx`. diff --git a/src/app/mockups/care-plan/governance/page.tsx b/src/app/mockups/care-plan/governance/page.tsx new file mode 100644 index 000000000..540f2d1f3 --- /dev/null +++ b/src/app/mockups/care-plan/governance/page.tsx @@ -0,0 +1,5 @@ +import { CarePlanRoutePage } from "../route-page"; + +export default function CarePlanGovernancePage() { + return ; +} diff --git a/src/app/mockups/care-plan/layout.tsx b/src/app/mockups/care-plan/layout.tsx new file mode 100644 index 000000000..7e215939e --- /dev/null +++ b/src/app/mockups/care-plan/layout.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; + +import { CarePlanErrorBoundary } from "@/components/care-plan/mockups/care-plan-error-boundary"; +import { CarePlanPrototypeProvider } from "@/components/care-plan/mockups/prototype-provider"; +import { DeveloperAreaGate } from "@/components/developer-area/developer-area-gate"; + +/** + * One provider for the whole route family, so every route reads the same + * in-memory record instead of a copy of its own. + * + * The order matters. `DeveloperAreaGate` is outermost, so an unauthorised + * visitor meets the sign-in screen and never reaches prototype content. The + * error boundary sits between the gate and the provider because the provider is + * where a broken invariant throws, and a boundary cannot catch a throw from a + * component it does not wrap. + */ +export default function CarePlanMockupLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/src/app/mockups/care-plan/loading.tsx b/src/app/mockups/care-plan/loading.tsx new file mode 100644 index 000000000..f8cac327b --- /dev/null +++ b/src/app/mockups/care-plan/loading.tsx @@ -0,0 +1,16 @@ +/** + * The waiting state for the whole route family. It shows the shape of the shell + * and nothing else: inventing a name or a plan line here would put fabricated + * clinical content on screen before any record has been read. + */ +export default function LoadingCarePlanMockup() { + return ( +
+

Loading the synthetic Care Plan prototype

+
+
+
+
+
+ ); +} diff --git a/src/app/mockups/care-plan/page.tsx b/src/app/mockups/care-plan/page.tsx new file mode 100644 index 000000000..f8890508e --- /dev/null +++ b/src/app/mockups/care-plan/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { CarePlanRoutePage } from "./route-page"; + +export const metadata: Metadata = { + title: "Care Plan prototype · Clinical KB", + description: "A fully synthetic, memory-only prototype for continuity planning in recurrent emergency care.", +}; + +export default function CarePlanHomePage() { + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/history/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/history/page.tsx new file mode 100644 index 000000000..1c91f10a3 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/history/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanPatientHistoryPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/management-plan/edit/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/management-plan/edit/page.tsx new file mode 100644 index 000000000..44a0e1217 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/management-plan/edit/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanManagementPlanDraftPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/management-plan/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/management-plan/page.tsx new file mode 100644 index 000000000..33d75d835 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/management-plan/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanManagementPlanPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/management-plan/print/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/management-plan/print/page.tsx new file mode 100644 index 000000000..4d134d7fd --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/management-plan/print/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanManagementPlanPrintPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/management-plan/review/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/management-plan/review/page.tsx new file mode 100644 index 000000000..62ce3672b --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/management-plan/review/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanManagementPlanReviewPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/page.tsx new file mode 100644 index 000000000..5fd416755 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanPatientPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/patient-plan/edit/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/patient-plan/edit/page.tsx new file mode 100644 index 000000000..74a71474c --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/patient-plan/edit/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanPatientPlanDraftPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/patient-plan/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/patient-plan/page.tsx new file mode 100644 index 000000000..2b4402a34 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/patient-plan/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanPatientPlanPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/patient-plan/print/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/patient-plan/print/page.tsx new file mode 100644 index 000000000..298f2ff6f --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/patient-plan/print/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanPatientPlanPrintPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/presentations/[presentationId]/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/presentations/[presentationId]/page.tsx new file mode 100644 index 000000000..5aa0e6f76 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/presentations/[presentationId]/page.tsx @@ -0,0 +1,26 @@ +import { notFound } from "next/navigation"; + +import { + SYNTHETIC_PRESENTATION_PARAMS, + isSyntheticPresentationForPatient, +} from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PRESENTATION_PARAMS]; +} + +/** + * An episode belongs to exactly one patient, so a real episode identifier under + * the wrong patient is still an address that does not exist and must not render. + */ +export default async function CarePlanPresentationPage({ + params, +}: { + params: Promise<{ patientId: string; presentationId: string }>; +}) { + const { patientId, presentationId } = await params; + if (!isSyntheticPresentationForPatient(patientId, presentationId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/presentations/new/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/presentations/new/page.tsx new file mode 100644 index 000000000..591a65ca2 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/presentations/new/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanNewPresentationPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/presentations/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/presentations/page.tsx new file mode 100644 index 000000000..9fef8d5d4 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/presentations/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanPresentationsPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/safety-plan/edit/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/safety-plan/edit/page.tsx new file mode 100644 index 000000000..9925597a3 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/safety-plan/edit/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanSafetyPlanDraftPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/safety-plan/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/safety-plan/page.tsx new file mode 100644 index 000000000..5da26fb2c --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/safety-plan/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanSafetyPlanPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/[patientId]/safety-plan/print/page.tsx b/src/app/mockups/care-plan/patients/[patientId]/safety-plan/print/page.tsx new file mode 100644 index 000000000..6311d3974 --- /dev/null +++ b/src/app/mockups/care-plan/patients/[patientId]/safety-plan/print/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { SYNTHETIC_PATIENT_PARAMS, isSyntheticPatientId } from "@/components/care-plan/mockups/routes"; + +import { CarePlanRoutePage } from "../../../../route-page"; + +export function generateStaticParams() { + return [...SYNTHETIC_PATIENT_PARAMS]; +} + +export default async function CarePlanSafetyPlanPrintPage({ params }: { params: Promise<{ patientId: string }> }) { + const { patientId } = await params; + if (!isSyntheticPatientId(patientId)) notFound(); + return ; +} diff --git a/src/app/mockups/care-plan/patients/page.tsx b/src/app/mockups/care-plan/patients/page.tsx new file mode 100644 index 000000000..f444dc686 --- /dev/null +++ b/src/app/mockups/care-plan/patients/page.tsx @@ -0,0 +1,5 @@ +import { CarePlanRoutePage } from "../route-page"; + +export default function CarePlanPatientsPage() { + return ; +} diff --git a/src/app/mockups/care-plan/reviews/page.tsx b/src/app/mockups/care-plan/reviews/page.tsx new file mode 100644 index 000000000..5eed0e7bd --- /dev/null +++ b/src/app/mockups/care-plan/reviews/page.tsx @@ -0,0 +1,5 @@ +import { CarePlanRoutePage } from "../route-page"; + +export default function CarePlanReviewsPage() { + return ; +} diff --git a/src/app/mockups/care-plan/route-page.tsx b/src/app/mockups/care-plan/route-page.tsx new file mode 100644 index 000000000..10cc51e99 --- /dev/null +++ b/src/app/mockups/care-plan/route-page.tsx @@ -0,0 +1,31 @@ +import { Suspense } from "react"; + +import { CarePlanRoutableSuite } from "@/components/care-plan/mockups/routable-suite"; + +/** + * Every Care Plan page renders this. The suite reads the URL to decide what to + * show, so one component serves all twenty-one routes and each page file stays a + * thin registration with no duplicated identifiers. + */ +function CarePlanRouteFallback() { + return ( +
+
+
+
+
+
+ ); +} + +export function CarePlanRoutePage() { + return ( + }> + + + ); +} diff --git a/src/app/mockups/care-plan/system-states/page.tsx b/src/app/mockups/care-plan/system-states/page.tsx new file mode 100644 index 000000000..96eebce8d --- /dev/null +++ b/src/app/mockups/care-plan/system-states/page.tsx @@ -0,0 +1,5 @@ +import { CarePlanRoutePage } from "../route-page"; + +export default function CarePlanSystemStatesPage() { + return ; +} diff --git a/src/app/mockups/care-plan/team/page.tsx b/src/app/mockups/care-plan/team/page.tsx new file mode 100644 index 000000000..26aea68dc --- /dev/null +++ b/src/app/mockups/care-plan/team/page.tsx @@ -0,0 +1,5 @@ +import { CarePlanRoutePage } from "../route-page"; + +export default function CarePlanTeamPage() { + return ; +} diff --git a/src/app/mockups/development/page.tsx b/src/app/mockups/development/page.tsx index 56650bfab..cd43cd5a8 100644 --- a/src/app/mockups/development/page.tsx +++ b/src/app/mockups/development/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { ArrowRight, FlaskConical, ShieldAlert } from "lucide-react"; +import { CARE_PLAN_ROUTES } from "@/components/care-plan/mockups/routes"; import { CARING_CONTACT_MOCKUP_ROUTES } from "@/components/caring-contacts/mockups/routes"; export const metadata: Metadata = { @@ -9,16 +10,30 @@ export const metadata: Metadata = { description: "In-progress surfaces, reachable only to a signed-in developer account in a production deploy.", }; -// The index of what is being built. This page and the Caring Contact routes it -// links to require a signed-in administrator account in production -// (`DeveloperAreaGate`, applied in the `development` and `caring-contacts` -// layouts) — everywhere else /mockups/** stays 404'd, unchanged. +// The index of what is being built. This page and the two prototypes it links +// to require a signed-in administrator account in production +// (`DeveloperAreaGate`, applied in the `development`, `caring-contacts` and +// `care-plan` layouts) — everywhere else /mockups/** stays 404'd, unchanged. // // Ward Flow is listed here as a convenience bookmark only: `/ward-management` // is an already-merged, normal, publicly reachable route (see // `src/lib/tools-catalog.ts`), not a mockup, and carries none of this page's // gating. const DEVELOPMENT_SURFACES = [ + { + id: "care-plan", + name: "Care Plan", + summary: + "Linked prototype of continuity planning for recurrent emergency presentations: the Management Plan and its versions, the patient edition, the Personal Safety Plan, the presentation timeline, and the review queues. Fully synthetic and memory-only — nothing is saved.", + href: CARE_PLAN_ROUTES.home, + status: "Synthetic prototype", + entries: [ + { label: "Patients", href: CARE_PLAN_ROUTES.patients }, + { label: "Reviews", href: CARE_PLAN_ROUTES.reviews }, + { label: "Governance", href: CARE_PLAN_ROUTES.governance }, + { label: "System states", href: CARE_PLAN_ROUTES.systemStates }, + ], + }, { id: "caring-contacts", name: "Caring Contact", diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index a0650f387..baeddb5b9 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -86,6 +86,10 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { // search mode, and its synthetic patient context must never enter shared search. const isCaringContactMockup = pathname === "/mockups/caring-contacts" || pathname.startsWith("/mockups/caring-contacts/"); + // Care Plan owns a complete clinical shell with its own rail, phone dock and a + // single search slot of its own. It is not a search mode, and its synthetic + // patient context must never enter shared search. + const isCarePlanMockup = pathname === "/mockups/care-plan" || pathname.startsWith("/mockups/care-plan/"); return ( { + state: CarePlanErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error & { digest?: string }): CarePlanErrorBoundaryState { + return { error }; + } + + render() { + const { error } = this.state; + if (!error) return this.props.children; + + return ( + this.setState({ error: null })} + title="Care Plan could not be displayed" + description="The synthetic prototype stopped because a record broke a rule it must never break. Nothing was saved, and trying again starts this page over." + logLabel="Care Plan prototype invariant failure:" + showReload + /> + ); + } +} diff --git a/src/components/care-plan/mockups/care-plan-shell-frame.tsx b/src/components/care-plan/mockups/care-plan-shell-frame.tsx new file mode 100644 index 000000000..0ecfa4f5f --- /dev/null +++ b/src/components/care-plan/mockups/care-plan-shell-frame.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { + ChevronRight, + ClipboardCheck, + FlaskConical, + Home, + MoreHorizontal, + ShieldCheck, + Users, + UsersRound, + type LucideIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { Sheet } from "@/components/ui/sheet"; +import { SearchField } from "@/components/ui/text-field"; + +import styles from "./care-plan.module.css"; +import { + CARE_PLAN_MORE_DESTINATIONS, + CARE_PLAN_PRIMARY_DESTINATIONS, + CARE_PLAN_SYSTEM_STATES_DESTINATION, + type CarePlanDestination, +} from "./routes"; +import type { PrototypeScenario } from "./types"; + +const DESTINATION_ICON: Record = { + Home, + Patients: Users, + Reviews: ClipboardCheck, + Team: UsersRound, + Governance: ShieldCheck, + "System states": FlaskConical, +}; + +/** The four destinations the phone dock has room for. */ +const PHONE_DESTINATIONS = CARE_PLAN_PRIMARY_DESTINATIONS.filter( + ({ label }) => label === "Home" || label === "Patients" || label === "Reviews", +); + +export type CarePlanShellFrameProps = { + /** The rail and dock entry that owns the current route. */ + activeDestination: CarePlanDestination; + /** The single first-level heading for the route. */ + title: string; + /** The named specimen state reconstructed from the URL, for inspection only. */ + scenario: PrototypeScenario; + /** The synthetic clinician the prototype is signed in as. */ + activeUser: { displayName: string; title: string }; + /** Called when the one search slot is submitted. */ + onSearchSubmit: () => void; + /** The one route-owned action slot beside the page title. */ + headerAction?: ReactNode; + children: ReactNode; +}; + +/** + * The responsive Clinical Shell every Care Plan route renders inside: a desktop + * rail beside a scrolling column, a phone dock with a More sheet, one search + * slot, and the standing statement that this is synthetic and saves nothing. + * + * Every destination is a real `next/link` built from the route registry, so a + * route can be reached, bookmarked and reconstructed rather than only reached by + * clicking through from somewhere else. + */ +export function CarePlanShellFrame({ + activeDestination, + title, + scenario, + activeUser, + onSearchSubmit, + headerAction, + children, +}: CarePlanShellFrameProps) { + const [moreOpen, setMoreOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const moreTriggerRef = useRef(null); + const titleRef = useRef(null); + + // Move focus to the heading whenever the route changes. Without it a keyboard + // or screen-reader user stays parked on the link they activated and has to + // travel the whole rail again to reach the new page. + useEffect(() => { + titleRef.current?.focus({ preventScroll: true }); + }, [title]); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSearchSubmit(); + } + + const moreIsActive = CARE_PLAN_MORE_DESTINATIONS.some(({ label }) => label === activeDestination); + + return ( +
+
+ + +
+
+
+ Synthetic prototype — fictional data only + Nothing is saved. Reloading this page starts over. +
+ +
+ setSearchTerm(event.target.value)} + onClear={() => setSearchTerm("")} + placeholder="Search patients" + fieldClassName={styles.searchField} + /> + + +
+ +
+
+

+ {title} +

+ {headerAction ?
{headerAction}
: null} +
+ {children} +
+
+
+ + + + setMoreOpen(false)} + title="More" + description="The Care Plan destinations the phone dock has no room for" + closeLabel="Close more destinations" + returnFocusRef={moreTriggerRef} + mobileSize="content" + > +
+ {CARE_PLAN_MORE_DESTINATIONS.map(({ label, href, description }) => { + const Icon = DESTINATION_ICON[label]; + return ( + setMoreOpen(false)} + className="flex min-h-tap items-center gap-3 border-b border-[color:var(--border)] bg-[color:var(--surface)] px-4 py-3 text-left last:border-b-0 hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-0.125rem] focus-visible:outline-[color:var(--focus)]" + > +
+
+ +

+ {title} +

+
+ ); +} diff --git a/src/components/care-plan/mockups/care-plan.module.css b/src/components/care-plan/mockups/care-plan.module.css new file mode 100644 index 000000000..abe2d6621 --- /dev/null +++ b/src/components/care-plan/mockups/care-plan.module.css @@ -0,0 +1,278 @@ +/* + * Layout for the Care Plan prototype shell. Every selector is scoped below + * `.appRoot` so this file can never reach a surface outside the prototype, and + * colour comes from design tokens only. + * + * The shape is one column on a phone with a fixed bottom dock, and a fixed rail + * beside a scrolling column from the medium breakpoint upwards. Print drops all + * navigation chrome through `data-print-hide`, because a printed plan is carried + * to a bedside and nothing on paper can be tapped. + */ + +.appRoot { + min-height: 100dvh; + background: var(--background); + color: var(--text); +} + +.appRoot .layout { + display: flex; + min-height: 100dvh; + flex-direction: column; +} + +.appRoot .rail { + display: none; + flex-direction: column; + gap: 0.75rem; + border-inline-end: 1px solid var(--border); + background: var(--surface-chrome); + padding: 1rem 0.75rem; +} + +.appRoot .railBrand { + display: grid; + gap: 0.125rem; + padding: 0.25rem 0.5rem 0.75rem; + border-block-end: 1px solid var(--border); +} + +.appRoot .railNav, +.appRoot .railTools { + display: grid; + gap: 0.25rem; +} + +.appRoot .railTools { + margin-block-start: auto; + border-block-start: 1px solid var(--border); + padding-block-start: 0.75rem; +} + +.appRoot .navItem { + display: flex; + min-height: var(--spacing-tap); + align-items: center; + gap: 0.75rem; + border-radius: var(--radius-md); + padding-inline: 0.75rem; + color: var(--text-muted); + font-size: 0.875rem; + font-weight: 600; + transition: background-color var(--duration-fast) var(--ease-out-soft); +} + +.appRoot .navItem:hover { + background: var(--surface-subtle); + color: var(--text); +} + +.appRoot .navItem[aria-current="page"] { + background: var(--clinical-accent-soft); + color: var(--clinical-accent); +} + +.appRoot .column { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.appRoot .header { + display: grid; + gap: 0.75rem; + border-block-end: 1px solid var(--border); + background: var(--surface-chrome); + padding-block-start: max(0.75rem, var(--safe-area-top)); + padding-block-end: 0.75rem; + padding-inline: 1rem; +} + +.appRoot .headerIdentity { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem 0.75rem; +} + +.appRoot .marker { + border: 1px solid var(--clinical-accent-border); + border-radius: var(--radius-sm); + background: var(--clinical-accent-soft); + padding: 0.125rem 0.5rem; + color: var(--clinical-accent); + font-size: 0.75rem; + font-weight: 700; +} + +.appRoot .memoryNotice { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; +} + +.appRoot .searchSlot { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5rem; +} + +.appRoot .searchField { + min-width: 12rem; + flex: 1 1 14rem; +} + +.appRoot .main { + min-width: 0; + flex: 1; + padding: 1.25rem 1rem; + padding-block-end: calc(4.5rem + var(--safe-area-bottom)); +} + +.appRoot .pageHead { + display: flex; + flex-direction: column; + gap: 0.75rem; + border-block-end: 1px solid var(--border); + padding-block-end: 1rem; +} + +.appRoot .pageTitle { + color: var(--text-heading); + font-size: var(--text-hero); + font-weight: 600; + line-height: var(--text-hero--line-height); + outline: none; +} + +.appRoot .purposeSurface { + display: grid; + gap: 0.5rem; + margin-block-start: 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background: var(--surface-raised); + padding: var(--pad-panel); +} + +.appRoot .purposeText { + max-width: var(--measure); + color: var(--text); + font-size: 1rem; + line-height: 1.6; +} + +.appRoot .purposeFollowUp { + max-width: var(--measure); + color: var(--text-muted); + font-size: 0.875rem; + line-height: 1.6; +} + +.appRoot .dock { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: var(--z-chrome); + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-block-start: 1px solid var(--border); + background: var(--surface-chrome); + padding-block-end: var(--safe-area-bottom); +} + +.appRoot .dockItem { + display: flex; + min-height: var(--spacing-tap); + min-width: 0; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.125rem; + padding: 0.25rem; + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; +} + +.appRoot .dockItem[aria-current="page"] { + color: var(--clinical-accent); +} + +/* + * The More sheet is portalled outside `.appRoot`, so it cannot inherit anything + * from this file. Its list is styled with utilities at the call site instead. + */ + +@media (min-width: 48rem) { + .appRoot .layout { + flex-direction: row; + } + + .appRoot .rail { + position: sticky; + top: 0; + display: flex; + height: 100dvh; + width: 15rem; + flex-shrink: 0; + } + + .appRoot .header { + padding-inline: 1.5rem; + } + + .appRoot .main { + padding: 1.75rem 1.5rem; + padding-block-end: 2rem; + } + + .appRoot .pageHead { + flex-direction: row; + align-items: flex-end; + justify-content: space-between; + } + + .appRoot .dock { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .appRoot .navItem { + transition: none; + } +} + +@media (forced-colors: active) { + .appRoot .rail, + .appRoot .dock, + .appRoot .header { + border-color: CanvasText; + } + + .appRoot .navItem[aria-current="page"], + .appRoot .dockItem[aria-current="page"] { + border: 1px solid CanvasText; + } + + .appRoot .marker { + border-color: CanvasText; + } +} + +@media print { + .appRoot [data-print-hide="true"] { + display: none; + } + + .appRoot .main { + padding: 0; + } + + .appRoot .purposeSurface { + border-color: CanvasText; + } +} diff --git a/src/components/care-plan/mockups/index.ts b/src/components/care-plan/mockups/index.ts new file mode 100644 index 000000000..3efa3f4b9 --- /dev/null +++ b/src/components/care-plan/mockups/index.ts @@ -0,0 +1,20 @@ +export { CarePlanErrorBoundary } from "./care-plan-error-boundary"; +export { CarePlanShellFrame } from "./care-plan-shell-frame"; +export { CarePlanPrototypeProvider, useCarePlanPrototype } from "./prototype-provider"; +export { CarePlanRoutableSuite, CarePlanRouteSurface, resolveCarePlanRoute, scenarioFromQuery } from "./routable-suite"; +export { + CARE_PLAN_BASE, + CARE_PLAN_MORE_DESTINATIONS, + CARE_PLAN_PRIMARY_DESTINATIONS, + CARE_PLAN_ROUTES, + CARE_PLAN_SYSTEM_STATES_DESTINATION, + SYNTHETIC_PATIENT_PARAMS, + SYNTHETIC_PRESENTATION_PARAMS, + carePlanRoute, + isSyntheticPatientId, + isSyntheticPresentationForPatient, + withQuery, +} from "./routes"; +export type { CarePlanDestination, CarePlanRouteKey } from "./routes"; +export { PROTOTYPE_NOW, SYNTHETIC_DATA_MARKER } from "./fixtures"; +export type * from "./types"; diff --git a/src/components/care-plan/mockups/routable-suite.tsx b/src/components/care-plan/mockups/routable-suite.tsx new file mode 100644 index 000000000..4bbb5f9bd --- /dev/null +++ b/src/components/care-plan/mockups/routable-suite.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useMemo } from "react"; + +import { CarePlanShellFrame } from "./care-plan-shell-frame"; +import styles from "./care-plan.module.css"; +import { useCarePlanPrototype } from "./prototype-provider"; +import { CARE_PLAN_BASE, CARE_PLAN_ROUTES, type CarePlanDestination } from "./routes"; +import type { PrototypeScenario } from "./types"; + +/** + * The approved heading and purpose of each route in the family. Task 3 renders + * only this — a working shell specimen with no unavailable controls — and Tasks 4 + * to 8 replace one route's purpose surface at a time with its real content. + */ +type CarePlanRouteDefinition = { + key: string; + heading: string; + purpose: string; + destination: CarePlanDestination; +}; + +const ROUTE_DEFINITIONS = { + home: { + key: "home", + heading: "Home", + purpose: "Search-first Home and Clinical Snapshot", + destination: "Home", + }, + patients: { + key: "patients", + heading: "Patients", + purpose: "Full patient directory and presentation-activity view", + destination: "Patients", + }, + patient: { + key: "patient", + heading: "Patient overview", + purpose: "Patient overview and first-minute snapshot", + destination: "Patients", + }, + managementPlan: { + key: "managementPlan", + heading: "Management Plan", + purpose: "Full Current Plan, draft summary, review state, and version history entry points", + destination: "Patients", + }, + managementPlanEdit: { + key: "managementPlanEdit", + heading: "Draft Management Plan Version", + purpose: "Create or edit a draft version", + destination: "Patients", + }, + managementPlanReview: { + key: "managementPlanReview", + heading: "Review submitted version", + purpose: "Compare, return for changes, and approve a submitted version", + destination: "Patients", + }, + managementPlanPrint: { + key: "managementPlanPrint", + heading: "Print Management Plan", + purpose: "Print-optimised clinician summary to carry to the bedside or send with a handover", + destination: "Patients", + }, + patientPlan: { + key: "patientPlan", + heading: "Patient Plan", + purpose: "The patient-facing edition of the Management Plan, with its own version and approval state", + destination: "Patients", + }, + patientPlanEdit: { + key: "patientPlanEdit", + heading: "Draft Patient Plan", + purpose: "Create the patient edition from the Current Plan, fill its flagged gaps, and approve it", + destination: "Patients", + }, + patientPlanPrint: { + key: "patientPlanPrint", + heading: "Print Patient Plan", + purpose: "Print-optimised patient copy, including their resources", + destination: "Patients", + }, + safetyPlan: { + key: "safetyPlan", + heading: "Personal Safety Plan", + purpose: "Current patient-owned Personal Safety Plan", + destination: "Patients", + }, + safetyPlanEdit: { + key: "safetyPlanEdit", + heading: "Draft Personal Safety Plan Version", + purpose: "Co-produce or revise a Personal Safety Plan Version", + destination: "Patients", + }, + safetyPlanPrint: { + key: "safetyPlanPrint", + heading: "Print Personal Safety Plan", + purpose: "Print-optimised patient copy", + destination: "Patients", + }, + presentations: { + key: "presentations", + heading: "ED Presentations", + purpose: "Longitudinal ED Presentation timeline", + destination: "Patients", + }, + newPresentation: { + key: "newPresentation", + heading: "Record ED Presentation", + purpose: "Record a concise ED Presentation", + destination: "Patients", + }, + presentation: { + key: "presentation", + heading: "ED Presentation", + purpose: "View an episode, plan-use feedback, outcome, and amendments", + destination: "Patients", + }, + history: { + key: "history", + heading: "History", + purpose: "Combined plan, presentation-amendment, print, and contact-action audit chronology", + destination: "Patients", + }, + reviews: { + key: "reviews", + heading: "Reviews", + purpose: "Awaiting Approval, Review Suggested, contact verification, and manual identification queues", + destination: "Reviews", + }, + team: { + key: "team", + heading: "Team", + purpose: "Synthetic CMHT and plan-owner directory", + destination: "Team", + }, + governance: { + key: "governance", + heading: "Governance", + purpose: "Prototype boundary, roles, lifecycle rules, and unresolved identification policy", + destination: "Governance", + }, + systemStates: { + key: "systemStates", + heading: "System states", + purpose: "Deterministic degraded-state specimens and scenario controls", + destination: "System states", + }, +} as const satisfies Record; + +const PATIENT_PLAN_SEGMENTS = { + "management-plan": { + base: ROUTE_DEFINITIONS.managementPlan, + edit: ROUTE_DEFINITIONS.managementPlanEdit, + review: ROUTE_DEFINITIONS.managementPlanReview, + print: ROUTE_DEFINITIONS.managementPlanPrint, + }, + "patient-plan": { + base: ROUTE_DEFINITIONS.patientPlan, + edit: ROUTE_DEFINITIONS.patientPlanEdit, + print: ROUTE_DEFINITIONS.patientPlanPrint, + }, + "safety-plan": { + base: ROUTE_DEFINITIONS.safetyPlan, + edit: ROUTE_DEFINITIONS.safetyPlanEdit, + print: ROUTE_DEFINITIONS.safetyPlanPrint, + }, +} as const; + +/** + * Resolves a URL to its route definition from the path shape alone, so a route + * reached by typing its address behaves exactly like one reached by a link. + */ +export function resolveCarePlanRoute(pathname: string): CarePlanRouteDefinition { + const trimmed = pathname.split("?")[0] ?? pathname; + if (!trimmed.startsWith(CARE_PLAN_BASE)) return ROUTE_DEFINITIONS.home; + const segments = trimmed.slice(CARE_PLAN_BASE.length).split("/").filter(Boolean); + + if (segments.length === 0) return ROUTE_DEFINITIONS.home; + + const [first, , third, fourth] = segments; + + if (first === "reviews") return ROUTE_DEFINITIONS.reviews; + if (first === "team") return ROUTE_DEFINITIONS.team; + if (first === "governance") return ROUTE_DEFINITIONS.governance; + if (first === "system-states") return ROUTE_DEFINITIONS.systemStates; + if (first !== "patients") return ROUTE_DEFINITIONS.home; + + if (segments.length === 1) return ROUTE_DEFINITIONS.patients; + if (segments.length === 2) return ROUTE_DEFINITIONS.patient; + + if (third === "history") return ROUTE_DEFINITIONS.history; + + if (third === "presentations") { + if (!fourth) return ROUTE_DEFINITIONS.presentations; + return fourth === "new" ? ROUTE_DEFINITIONS.newPresentation : ROUTE_DEFINITIONS.presentation; + } + + const planSegment = PATIENT_PLAN_SEGMENTS[third as keyof typeof PATIENT_PLAN_SEGMENTS]; + if (!planSegment) return ROUTE_DEFINITIONS.patient; + if (!fourth) return planSegment.base; + if (fourth === "edit" && "edit" in planSegment) return planSegment.edit; + if (fourth === "review" && "review" in planSegment) return planSegment.review; + if (fourth === "print") return planSegment.print; + return planSegment.base; +} + +const SCENARIO_VALUES: readonly PrototypeScenario[] = [ + "normal", + "empty", + "no-current-plan", + "overdue-plan", + "withdrawn-plan", + "unverified-contact", + "identity-uncertain", + "version-conflict", + "offline", + "permission-unavailable", + "launch-failure", + "print-failure", +]; + +/** + * A URL may name a deterministic specimen state and nothing else — never a name, + * a contact detail, or any other record content. + */ +export function scenarioFromQuery(query: string): PrototypeScenario { + const candidate = new URLSearchParams(query).get("scenario"); + return SCENARIO_VALUES.find((scenario) => scenario === candidate) ?? "normal"; +} + +function RoutePurposeSurface({ purpose }: { purpose: string }) { + return ( +
+

+ What this route is for +

+

{purpose}

+

+ This route is reachable, addressable and part of the shell. Its reading and authoring content is built in a + later stage of the prototype. +

+
+ ); +} + +export type CarePlanRouteSurfaceProps = { + pathname: string; + query?: string; + navigate: (href: string) => void; +}; + +/** + * The whole route family rendered from plain strings and one navigation + * callback, so every route can be exercised without a router. + */ +export function CarePlanRouteSurface({ pathname, query = "", navigate }: CarePlanRouteSurfaceProps) { + const { state } = useCarePlanPrototype(); + const route = useMemo(() => resolveCarePlanRoute(pathname), [pathname]); + const scenario = useMemo(() => scenarioFromQuery(query), [query]); + const activeUser = state.users.find((user) => user.id === state.activeUserId); + + return ( + navigate(CARE_PLAN_ROUTES.patients)} + > + + + ); +} + +/** The router wrapper: the only place Care Plan reads Next.js navigation. */ +export function CarePlanRoutableSuite() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + return ( + router.push(href)} /> + ); +} diff --git a/src/components/care-plan/mockups/routes.ts b/src/components/care-plan/mockups/routes.ts new file mode 100644 index 000000000..70662ee58 --- /dev/null +++ b/src/components/care-plan/mockups/routes.ts @@ -0,0 +1,153 @@ +import { syntheticEdPresentations, syntheticPatients } from "./fixtures"; +import type { PrototypeScenario } from "./types"; + +/** + * Every Care Plan URL is built here and nowhere else, so a link can never drift + * from the route it is supposed to reach and no page file has to repeat a + * synthetic identifier. The whole family lives under one gated prefix; the + * developer-area gate in `src/proxy.ts` matches this exact string. + */ +export const CARE_PLAN_BASE = "/mockups/care-plan"; + +/** + * The one place a literal synthetic identifier appears in a URL. `CARE_PLAN_ROUTES` + * is the reconstructable example of each route — the address you can type into the + * browser and land on a working page — while the builders below rebuild the same + * shapes for any other synthetic record. + */ +const EXAMPLE_PATIENT_ID = "SYN-PATIENT-001"; +const EXAMPLE_PRESENTATION_ID = "SYN-PRESENTATION-001"; + +function patientPath(patientId: string) { + return `${CARE_PLAN_BASE}/patients/${patientId}`; +} + +export const CARE_PLAN_ROUTES = { + home: CARE_PLAN_BASE, + patients: `${CARE_PLAN_BASE}/patients`, + patient: patientPath(EXAMPLE_PATIENT_ID), + managementPlan: `${patientPath(EXAMPLE_PATIENT_ID)}/management-plan`, + managementPlanEdit: `${patientPath(EXAMPLE_PATIENT_ID)}/management-plan/edit`, + managementPlanReview: `${patientPath(EXAMPLE_PATIENT_ID)}/management-plan/review`, + managementPlanPrint: `${patientPath(EXAMPLE_PATIENT_ID)}/management-plan/print`, + patientPlan: `${patientPath(EXAMPLE_PATIENT_ID)}/patient-plan`, + patientPlanEdit: `${patientPath(EXAMPLE_PATIENT_ID)}/patient-plan/edit`, + patientPlanPrint: `${patientPath(EXAMPLE_PATIENT_ID)}/patient-plan/print`, + safetyPlan: `${patientPath(EXAMPLE_PATIENT_ID)}/safety-plan`, + safetyPlanEdit: `${patientPath(EXAMPLE_PATIENT_ID)}/safety-plan/edit`, + safetyPlanPrint: `${patientPath(EXAMPLE_PATIENT_ID)}/safety-plan/print`, + presentations: `${patientPath(EXAMPLE_PATIENT_ID)}/presentations`, + newPresentation: `${patientPath(EXAMPLE_PATIENT_ID)}/presentations/new`, + presentation: `${patientPath(EXAMPLE_PATIENT_ID)}/presentations/${EXAMPLE_PRESENTATION_ID}`, + history: `${patientPath(EXAMPLE_PATIENT_ID)}/history`, + reviews: `${CARE_PLAN_BASE}/reviews`, + team: `${CARE_PLAN_BASE}/team`, + governance: `${CARE_PLAN_BASE}/governance`, + systemStates: `${CARE_PLAN_BASE}/system-states`, +} as const; + +export type CarePlanRouteKey = keyof typeof CARE_PLAN_ROUTES; + +/** + * Query strings carry a named specimen state and nothing else. No name, contact + * detail, clinical text, or any other record content is ever put in a URL, so a + * link can be shared, bookmarked, or logged without carrying content with it. + */ +export function withQuery(route: string, key: string, value: string) { + const separator = route.includes("?") ? "&" : "?"; + return `${route}${separator}${key}=${encodeURIComponent(value)}`; +} + +export const carePlanRoute = { + patient(patientId: string) { + return patientPath(patientId); + }, + managementPlan(patientId: string) { + return `${patientPath(patientId)}/management-plan`; + }, + patientPlan(patientId: string) { + return `${patientPath(patientId)}/patient-plan`; + }, + safetyPlan(patientId: string) { + return `${patientPath(patientId)}/safety-plan`; + }, + presentations(patientId: string) { + return `${patientPath(patientId)}/presentations`; + }, + presentation(patientId: string, presentationId: string) { + return `${patientPath(patientId)}/presentations/${presentationId}`; + }, + history(patientId: string) { + return `${patientPath(patientId)}/history`; + }, + scenario(scenario: PrototypeScenario, route: string = CARE_PLAN_ROUTES.systemStates) { + return withQuery(route, "scenario", scenario); + }, + withQuery, +} as const; + +/** + * The finite parameter lists the dynamic pages prerender from. They are derived + * from the fixtures rather than written out again, so a fixture change cannot + * leave a page file quietly pointing at a record that no longer exists. + */ +export const SYNTHETIC_PATIENT_PARAMS: readonly { patientId: string }[] = syntheticPatients.map((patient) => ({ + patientId: patient.id, +})); + +export const SYNTHETIC_PRESENTATION_PARAMS: readonly { patientId: string; presentationId: string }[] = + syntheticEdPresentations.map((presentation) => ({ + patientId: presentation.patientId, + presentationId: presentation.id, + })); + +const PATIENT_IDS = new Set(SYNTHETIC_PATIENT_PARAMS.map(({ patientId }) => patientId)); +const PRESENTATION_PAIRS = new Set( + SYNTHETIC_PRESENTATION_PARAMS.map(({ patientId, presentationId }) => `${patientId}/${presentationId}`), +); + +export function isSyntheticPatientId(patientId: string): boolean { + return PATIENT_IDS.has(patientId); +} + +/** + * An episode belongs to exactly one patient, so a valid episode identifier under + * the wrong patient is still an unknown address and the page must not render it. + */ +export function isSyntheticPresentationForPatient(patientId: string, presentationId: string): boolean { + return PRESENTATION_PAIRS.has(`${patientId}/${presentationId}`); +} + +export type CarePlanDestination = "Home" | "Patients" | "Reviews" | "Team" | "Governance" | "System states"; + +export const CARE_PLAN_PRIMARY_DESTINATIONS: readonly { label: CarePlanDestination; href: string }[] = [ + { label: "Home", href: CARE_PLAN_ROUTES.home }, + { label: "Patients", href: CARE_PLAN_ROUTES.patients }, + { label: "Reviews", href: CARE_PLAN_ROUTES.reviews }, + { label: "Team", href: CARE_PLAN_ROUTES.team }, + { label: "Governance", href: CARE_PLAN_ROUTES.governance }, +]; + +/** The three destinations the phone dock does not have room for. */ +export const CARE_PLAN_MORE_DESTINATIONS: readonly { + label: CarePlanDestination; + href: string; + description: string; +}[] = [ + { label: "Team", href: CARE_PLAN_ROUTES.team, description: "Community teams and the clinicians who own each plan" }, + { + label: "Governance", + href: CARE_PLAN_ROUTES.governance, + description: "What this prototype does, who may do it, and what it deliberately does not decide", + }, + { + label: "System states", + href: CARE_PLAN_ROUTES.systemStates, + description: "Deterministic specimens of every degraded state", + }, +]; + +export const CARE_PLAN_SYSTEM_STATES_DESTINATION = { + label: "System states" as const, + href: CARE_PLAN_ROUTES.systemStates, +}; diff --git a/src/lib/developer-area/headers.ts b/src/lib/developer-area/headers.ts index 766eb3a42..d9670ba80 100644 --- a/src/lib/developer-area/headers.ts +++ b/src/lib/developer-area/headers.ts @@ -18,4 +18,13 @@ export const DEVELOPER_AREA_HEADER = "x-developer-area"; * just the area root. */ export const DEVELOPER_AREA_PATH_HEADER = "x-developer-area-path"; -export const DEVELOPER_GATED_PATH_PREFIXES = ["/mockups/development", "/mockups/caring-contacts"] as const; +/** Exact prefixes only. A path that merely begins with the same characters — + * `/mockups/care-plan-archive`, say — is not a match and stays behind the + * blanket production block, because `isDeveloperGatedPath` requires either an + * exact hit or a following `/`. Add a prefix here one subtree at a time; never + * widen this to `/mockups`. */ +export const DEVELOPER_GATED_PATH_PREFIXES = [ + "/mockups/development", + "/mockups/caring-contacts", + "/mockups/care-plan", +] as const; diff --git a/src/proxy.ts b/src/proxy.ts index 776b7e606..4407a649b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -103,7 +103,9 @@ export async function proxy(request: NextRequest) { headers.set("content-security-policy", csp); // Untrusted: strip unconditionally so a client cannot set this header itself // and spoof past the parent `/mockups` layout's production gate on a route - // that is not actually one of the two developer-gated subtrees below. + // that is not actually one of the developer-gated subtrees listed in + // DEVELOPER_GATED_PATH_PREFIXES (`/mockups/development`, the Caring Contact + // prototype, and the Care Plan prototype). headers.delete(DEVELOPER_AREA_HEADER); headers.delete(DEVELOPER_AREA_PATH_HEADER); if (isDeveloperGatedPath(pathname)) { @@ -213,11 +215,13 @@ export function shouldBlockProductionMockups( ) { if (!pathname.startsWith("/mockups") || environment.NODE_ENV !== "production") return false; - // `/mockups/development` and the Caring Contact prototype it links to carry - // their own signed-in-administrator gate (`DeveloperAreaGate`, applied in - // `src/app/mockups/layout.tsx` via the x-developer-area header set above) — - // let them through this blanket block so that gate can run instead of a bare - // 404. Every other /mockups/** path is unaffected. + // `/mockups/development` and the two prototypes it links to — Caring Contact + // and Care Plan — carry their own signed-in-administrator gate + // (`DeveloperAreaGate`, applied in each subtree's layout via the + // x-developer-area header set above), so let them through this blanket block + // and let that gate run instead of a bare 404. The match is exact-or-slash, so + // a look-alike path such as `/mockups/care-plan-archive` is NOT let through. + // Every other /mockups/** path is unaffected. if (isDeveloperGatedPath(pathname)) return false; // Mockups remain unavailable in every normal production process. The one diff --git a/tests/care-plan-linked-routes.dom.test.tsx b/tests/care-plan-linked-routes.dom.test.tsx new file mode 100644 index 000000000..3b088f6d5 --- /dev/null +++ b/tests/care-plan-linked-routes.dom.test.tsx @@ -0,0 +1,265 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CarePlanErrorBoundary } from "@/components/care-plan/mockups/care-plan-error-boundary"; +import { CarePlanPrototypeProvider } from "@/components/care-plan/mockups/prototype-provider"; +import { CarePlanRouteSurface } from "@/components/care-plan/mockups/routable-suite"; +import { CARE_PLAN_ROUTES } from "@/components/care-plan/mockups/routes"; + +function renderRoute(pathname: string, query = "") { + const navigate = vi.fn(); + render( + + + , + ); + return navigate; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Care Plan route shell", () => { + it("gives every route exactly one first-level heading", () => { + for (const route of Object.values(CARE_PLAN_ROUTES)) { + const { unmount } = render( + + + , + ); + expect(screen.getAllByRole("heading", { level: 1 }), `${route} must have one

`).toHaveLength(1); + unmount(); + } + }); + + it("moves keyboard focus to the route heading so a routed page announces itself", () => { + renderRoute(CARE_PLAN_ROUTES.patients); + const heading = screen.getByRole("heading", { level: 1, name: "Patients" }); + expect(heading).toHaveAttribute("tabindex", "-1"); + expect(heading).toHaveFocus(); + }); + + it("states the synthetic boundary and that nothing is saved", () => { + renderRoute(CARE_PLAN_ROUTES.home); + expect(screen.getByText("Synthetic prototype — fictional data only")).toBeInTheDocument(); + expect(screen.getByText("Nothing is saved. Reloading this page starts over.")).toBeInTheDocument(); + }); + + it("names the signed-in synthetic clinician and their role", () => { + renderRoute(CARE_PLAN_ROUTES.home); + const identity = screen.getByTestId("care-plan-active-user"); + expect(within(identity).getByText("Dr Casey Example")).toBeInTheDocument(); + expect(within(identity).getByText("Emergency Physician, North River Hospital ED")).toBeInTheDocument(); + }); + + it("renders the desktop rail as real links built from the route registry", () => { + renderRoute(CARE_PLAN_ROUTES.home); + const rail = screen.getByRole("navigation", { name: "Care Plan sections" }); + for (const [label, href] of [ + ["Home", CARE_PLAN_ROUTES.home], + ["Patients", CARE_PLAN_ROUTES.patients], + ["Reviews", CARE_PLAN_ROUTES.reviews], + ["Team", CARE_PLAN_ROUTES.team], + ["Governance", CARE_PLAN_ROUTES.governance], + ] as const) { + expect(within(rail).getByRole("link", { name: label })).toHaveAttribute("href", href); + } + expect( + within(screen.getByRole("navigation", { name: "Care Plan prototype tools" })).getByRole("link", { + name: "System states", + }), + ).toHaveAttribute("href", CARE_PLAN_ROUTES.systemStates); + }); + + it("renders the phone dock and reaches the remaining destinations through More", async () => { + const user = userEvent.setup(); + renderRoute(CARE_PLAN_ROUTES.home); + const dock = screen.getByRole("navigation", { name: "Care Plan phone navigation" }); + for (const [label, href] of [ + ["Home", CARE_PLAN_ROUTES.home], + ["Patients", CARE_PLAN_ROUTES.patients], + ["Reviews", CARE_PLAN_ROUTES.reviews], + ] as const) { + expect(within(dock).getByRole("link", { name: label })).toHaveAttribute("href", href); + } + + await user.click(within(dock).getByRole("button", { name: "More" })); + const sheet = screen.getByRole("dialog", { name: "More" }); + expect(within(sheet).getByRole("link", { name: /^Team/ })).toHaveAttribute("href", CARE_PLAN_ROUTES.team); + expect(within(sheet).getByRole("link", { name: /^Governance/ })).toHaveAttribute( + "href", + CARE_PLAN_ROUTES.governance, + ); + expect(within(sheet).getByRole("link", { name: /^System states/ })).toHaveAttribute( + "href", + CARE_PLAN_ROUTES.systemStates, + ); + }); + + it("keeps the phone dock out of a printed page", () => { + renderRoute(CARE_PLAN_ROUTES.home); + expect(screen.getByRole("navigation", { name: "Care Plan phone navigation" })).toHaveAttribute( + "data-print-hide", + "true", + ); + }); + + it.each([ + [CARE_PLAN_ROUTES.home, "Home"], + [CARE_PLAN_ROUTES.patients, "Patients"], + [CARE_PLAN_ROUTES.patient, "Patients"], + [CARE_PLAN_ROUTES.managementPlan, "Patients"], + [CARE_PLAN_ROUTES.presentation, "Patients"], + [CARE_PLAN_ROUTES.reviews, "Reviews"], + [CARE_PLAN_ROUTES.team, "Team"], + [CARE_PLAN_ROUTES.governance, "Governance"], + ])("marks %s as the current destination %s", (pathname, destination) => { + renderRoute(pathname); + const rail = screen.getByRole("navigation", { name: "Care Plan sections" }); + expect(within(rail).getByRole("link", { name: destination })).toHaveAttribute("aria-current", "page"); + const others = within(rail) + .getAllByRole("link") + .filter((link) => link.textContent?.trim() !== destination); + for (const link of others) expect(link).not.toHaveAttribute("aria-current"); + }); + + it("marks the system-states route current on its own rail link", () => { + renderRoute(CARE_PLAN_ROUTES.systemStates); + expect( + within(screen.getByRole("navigation", { name: "Care Plan prototype tools" })).getByRole("link", { + name: "System states", + }), + ).toHaveAttribute("aria-current", "page"); + }); + + it.each([ + [CARE_PLAN_ROUTES.home, "Home", "Search-first Home and Clinical Snapshot"], + [CARE_PLAN_ROUTES.patients, "Patients", "Full patient directory and presentation-activity view"], + [CARE_PLAN_ROUTES.patient, "Patient overview", "Patient overview and first-minute snapshot"], + [ + CARE_PLAN_ROUTES.managementPlan, + "Management Plan", + "Full Current Plan, draft summary, review state, and version history entry points", + ], + [CARE_PLAN_ROUTES.managementPlanEdit, "Draft Management Plan Version", "Create or edit a draft version"], + [ + CARE_PLAN_ROUTES.managementPlanReview, + "Review submitted version", + "Compare, return for changes, and approve a submitted version", + ], + [ + CARE_PLAN_ROUTES.managementPlanPrint, + "Print Management Plan", + "Print-optimised clinician summary to carry to the bedside or send with a handover", + ], + [ + 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.safetyPlan, "Personal Safety Plan", "Current patient-owned Personal Safety Plan"], + [ + CARE_PLAN_ROUTES.safetyPlanEdit, + "Draft Personal Safety Plan Version", + "Co-produce or revise a Personal Safety Plan Version", + ], + [CARE_PLAN_ROUTES.safetyPlanPrint, "Print Personal Safety Plan", "Print-optimised patient copy"], + [CARE_PLAN_ROUTES.presentations, "ED Presentations", "Longitudinal ED Presentation timeline"], + [CARE_PLAN_ROUTES.newPresentation, "Record ED Presentation", "Record a concise ED Presentation"], + [CARE_PLAN_ROUTES.presentation, "ED Presentation", "View an episode, plan-use feedback, outcome, and amendments"], + [ + CARE_PLAN_ROUTES.history, + "History", + "Combined plan, presentation-amendment, print, and contact-action audit chronology", + ], + [ + CARE_PLAN_ROUTES.reviews, + "Reviews", + "Awaiting Approval, Review Suggested, contact verification, and manual identification queues", + ], + [CARE_PLAN_ROUTES.team, "Team", "Synthetic CMHT and plan-owner directory"], + [ + CARE_PLAN_ROUTES.governance, + "Governance", + "Prototype boundary, roles, lifecycle rules, and unresolved identification policy", + ], + [CARE_PLAN_ROUTES.systemStates, "System states", "Deterministic degraded-state specimens and scenario controls"], + ])("maps %s to the %s route purpose surface", (pathname, heading, purpose) => { + renderRoute(pathname); + expect(screen.getByRole("heading", { level: 1, name: heading })).toBeInTheDocument(); + const surface = screen.getByTestId("care-plan-route-purpose"); + expect(within(surface).getByText(purpose)).toBeInTheDocument(); + }); + + it("offers exactly one search slot and navigates it without putting record content in the URL", async () => { + const user = userEvent.setup(); + const navigate = renderRoute(CARE_PLAN_ROUTES.home); + 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); + }); + + it("reads the named specimen scenario from the URL and nothing else", () => { + renderRoute(CARE_PLAN_ROUTES.home); + expect(document.querySelector("[data-care-plan-scenario]")).toHaveAttribute("data-care-plan-scenario", "normal"); + + document.body.innerHTML = ""; + renderRoute(CARE_PLAN_ROUTES.systemStates, "scenario=overdue-plan"); + expect(document.querySelector("[data-care-plan-scenario]")).toHaveAttribute( + "data-care-plan-scenario", + "overdue-plan", + ); + + document.body.innerHTML = ""; + renderRoute(CARE_PLAN_ROUTES.systemStates, "scenario=not-a-scenario"); + expect(document.querySelector("[data-care-plan-scenario]")).toHaveAttribute("data-care-plan-scenario", "normal"); + }); + + it("shows no unavailable authoring controls on a reading surface", () => { + renderRoute(CARE_PLAN_ROUTES.managementPlan); + expect(screen.queryByTitle(/coming soon/i)).toBeNull(); + expect(document.querySelector("[aria-disabled='true']")).toBeNull(); + }); +}); + +describe("Care Plan error boundary", () => { + function Thrower(): never { + throw new Error("Two versions are recorded as Current for SYN-MGMT-PLAN-001."); + } + + it("catches an invariant violation and shows the shared recovery panel instead of a blank tree", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + , + ); + expect(screen.getByRole("heading", { level: 1, name: "Care Plan could not be displayed" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument(); + }); + + it("renders its children untouched when nothing throws", () => { + render( + +

Route content

+
, + ); + expect(screen.getByText("Route content")).toBeInTheDocument(); + expect(screen.queryByRole("heading", { level: 1, name: "Care Plan could not be displayed" })).toBeNull(); + }); +}); diff --git a/tests/care-plan-route-files.test.ts b/tests/care-plan-route-files.test.ts new file mode 100644 index 000000000..5be5d75e4 --- /dev/null +++ b/tests/care-plan-route-files.test.ts @@ -0,0 +1,280 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { syntheticEdPresentations, syntheticPatients } from "@/components/care-plan/mockups/fixtures"; +import { + CARE_PLAN_BASE, + CARE_PLAN_ROUTES, + SYNTHETIC_PATIENT_PARAMS, + SYNTHETIC_PRESENTATION_PARAMS, + carePlanRoute, + isSyntheticPatientId, + isSyntheticPresentationForPatient, +} from "@/components/care-plan/mockups/routes"; +import { DEVELOPER_GATED_PATH_PREFIXES } from "@/lib/developer-area/headers"; + +const APP_ROOT = "src/app/mockups/care-plan"; +const COMPONENT_ROOT = "src/components/care-plan/mockups"; + +const pageFiles = [ + `${APP_ROOT}/page.tsx`, + `${APP_ROOT}/patients/page.tsx`, + `${APP_ROOT}/patients/[patientId]/page.tsx`, + `${APP_ROOT}/patients/[patientId]/management-plan/page.tsx`, + `${APP_ROOT}/patients/[patientId]/management-plan/edit/page.tsx`, + `${APP_ROOT}/patients/[patientId]/management-plan/review/page.tsx`, + `${APP_ROOT}/patients/[patientId]/management-plan/print/page.tsx`, + `${APP_ROOT}/patients/[patientId]/patient-plan/page.tsx`, + `${APP_ROOT}/patients/[patientId]/patient-plan/edit/page.tsx`, + `${APP_ROOT}/patients/[patientId]/patient-plan/print/page.tsx`, + `${APP_ROOT}/patients/[patientId]/safety-plan/page.tsx`, + `${APP_ROOT}/patients/[patientId]/safety-plan/edit/page.tsx`, + `${APP_ROOT}/patients/[patientId]/safety-plan/print/page.tsx`, + `${APP_ROOT}/patients/[patientId]/presentations/page.tsx`, + `${APP_ROOT}/patients/[patientId]/presentations/new/page.tsx`, + `${APP_ROOT}/patients/[patientId]/presentations/[presentationId]/page.tsx`, + `${APP_ROOT}/patients/[patientId]/history/page.tsx`, + `${APP_ROOT}/reviews/page.tsx`, + `${APP_ROOT}/team/page.tsx`, + `${APP_ROOT}/governance/page.tsx`, + `${APP_ROOT}/system-states/page.tsx`, +] as const; + +function readNamespaceSources(): { path: string; source: string }[] { + return [APP_ROOT, COMPONENT_ROOT].flatMap((root) => { + const absoluteRoot = resolve(process.cwd(), root); + return readdirSync(absoluteRoot, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.(?:ts|tsx|css)$/.test(entry.name)) + .map((entry) => { + const path = resolve(entry.parentPath, entry.name); + return { path, source: readFileSync(path, "utf8") }; + }); + }); +} + +describe("Care Plan route registry", () => { + it("pins the exact approved URL for every route in the family", () => { + expect(CARE_PLAN_ROUTES).toEqual({ + home: "/mockups/care-plan", + patients: "/mockups/care-plan/patients", + patient: "/mockups/care-plan/patients/SYN-PATIENT-001", + managementPlan: "/mockups/care-plan/patients/SYN-PATIENT-001/management-plan", + managementPlanEdit: "/mockups/care-plan/patients/SYN-PATIENT-001/management-plan/edit", + managementPlanReview: "/mockups/care-plan/patients/SYN-PATIENT-001/management-plan/review", + managementPlanPrint: "/mockups/care-plan/patients/SYN-PATIENT-001/management-plan/print", + patientPlan: "/mockups/care-plan/patients/SYN-PATIENT-001/patient-plan", + patientPlanEdit: "/mockups/care-plan/patients/SYN-PATIENT-001/patient-plan/edit", + patientPlanPrint: "/mockups/care-plan/patients/SYN-PATIENT-001/patient-plan/print", + safetyPlan: "/mockups/care-plan/patients/SYN-PATIENT-001/safety-plan", + safetyPlanEdit: "/mockups/care-plan/patients/SYN-PATIENT-001/safety-plan/edit", + safetyPlanPrint: "/mockups/care-plan/patients/SYN-PATIENT-001/safety-plan/print", + presentations: "/mockups/care-plan/patients/SYN-PATIENT-001/presentations", + newPresentation: "/mockups/care-plan/patients/SYN-PATIENT-001/presentations/new", + presentation: "/mockups/care-plan/patients/SYN-PATIENT-001/presentations/SYN-PRESENTATION-001", + history: "/mockups/care-plan/patients/SYN-PATIENT-001/history", + reviews: "/mockups/care-plan/reviews", + team: "/mockups/care-plan/team", + governance: "/mockups/care-plan/governance", + systemStates: "/mockups/care-plan/system-states", + }); + expect(CARE_PLAN_BASE).toBe("/mockups/care-plan"); + }); + + it("rebuilds every deep route from a patient identifier without a second literal", () => { + expect(carePlanRoute.patient("SYN-PATIENT-003")).toBe("/mockups/care-plan/patients/SYN-PATIENT-003"); + expect(carePlanRoute.managementPlan("SYN-PATIENT-003")).toBe( + "/mockups/care-plan/patients/SYN-PATIENT-003/management-plan", + ); + expect(carePlanRoute.safetyPlan("SYN-PATIENT-003")).toBe("/mockups/care-plan/patients/SYN-PATIENT-003/safety-plan"); + expect(carePlanRoute.presentations("SYN-PATIENT-003")).toBe( + "/mockups/care-plan/patients/SYN-PATIENT-003/presentations", + ); + expect(carePlanRoute.presentation("SYN-PATIENT-003", "SYN-PRESENTATION-012")).toBe( + "/mockups/care-plan/patients/SYN-PATIENT-003/presentations/SYN-PRESENTATION-012", + ); + }); + + it("carries only a named specimen scenario in a query string, never record content", () => { + expect(carePlanRoute.scenario("overdue-plan")).toBe("/mockups/care-plan/system-states?scenario=overdue-plan"); + expect(carePlanRoute.scenario("offline", CARE_PLAN_ROUTES.reviews)).toBe( + "/mockups/care-plan/reviews?scenario=offline", + ); + expect(carePlanRoute.withQuery(CARE_PLAN_ROUTES.reviews, "scenario", "empty")).toBe( + "/mockups/care-plan/reviews?scenario=empty", + ); + expect(carePlanRoute.withQuery("/mockups/care-plan/reviews?scenario=empty", "view", "awaiting")).toBe( + "/mockups/care-plan/reviews?scenario=empty&view=awaiting", + ); + }); +}); + +describe("Care Plan finite synthetic parameters", () => { + it("derives the patient parameter list from the fixtures rather than a hand-written copy", () => { + expect(SYNTHETIC_PATIENT_PARAMS).toEqual(syntheticPatients.map((patient) => ({ patientId: patient.id }))); + expect(SYNTHETIC_PATIENT_PARAMS.length).toBeGreaterThan(0); + for (const { patientId } of SYNTHETIC_PATIENT_PARAMS) expect(patientId.startsWith("SYN-")).toBe(true); + }); + + it("derives every episode parameter pair from the fixtures", () => { + expect(SYNTHETIC_PRESENTATION_PARAMS).toEqual( + syntheticEdPresentations.map((presentation) => ({ + patientId: presentation.patientId, + presentationId: presentation.id, + })), + ); + for (const { patientId, presentationId } of SYNTHETIC_PRESENTATION_PARAMS) { + expect(patientId.startsWith("SYN-")).toBe(true); + expect(presentationId.startsWith("SYN-")).toBe(true); + } + }); + + it("recognises only known synthetic identifiers", () => { + expect(isSyntheticPatientId("SYN-PATIENT-001")).toBe(true); + expect(isSyntheticPatientId("SYN-PATIENT-999")).toBe(false); + expect(isSyntheticPatientId("../../etc/passwd")).toBe(false); + expect(isSyntheticPresentationForPatient("SYN-PATIENT-001", "SYN-PRESENTATION-001")).toBe(true); + // Episode 009 belongs to another patient, so this pairing must not resolve. + expect(isSyntheticPresentationForPatient("SYN-PATIENT-001", "SYN-PRESENTATION-009")).toBe(false); + expect(isSyntheticPresentationForPatient("SYN-PATIENT-001", "SYN-PRESENTATION-999")).toBe(false); + }); +}); + +describe("Care Plan route registration", () => { + it("registers all twenty-one approved pages plus the shared layout, loading and route page", () => { + for (const file of pageFiles) { + expect(existsSync(resolve(process.cwd(), file)), `${file} is missing`).toBe(true); + } + expect(pageFiles.length).toBe(21); + for (const file of [`${APP_ROOT}/layout.tsx`, `${APP_ROOT}/loading.tsx`, `${APP_ROOT}/route-page.tsx`]) { + expect(existsSync(resolve(process.cwd(), file)), `${file} is missing`).toBe(true); + } + }); + + it("registers no page beyond the twenty-one approved routes", () => { + const found = readdirSync(resolve(process.cwd(), APP_ROOT), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name === "page.tsx") + .map((entry) => resolve(entry.parentPath, entry.name)); + expect(found.length).toBe(pageFiles.length); + }); + + it("validates every dynamic parameter and repeats no synthetic identifier in a page file", () => { + const dynamicPages = pageFiles.filter((file) => file.includes("[")); + expect(dynamicPages.length).toBe(15); + for (const file of dynamicPages) { + const source = readFileSync(resolve(process.cwd(), file), "utf8"); + expect(source, `${file} must refuse an unknown parameter`).toContain("notFound()"); + expect(source, `${file} must prerender its finite parameter list`).toContain("generateStaticParams"); + expect(source, `${file} must check the parameter against the fixtures`).toMatch( + /isSyntheticPatientId|isSyntheticPresentationForPatient/, + ); + } + for (const file of pageFiles) { + const source = readFileSync(resolve(process.cwd(), file), "utf8"); + expect(source, `${file} must not repeat a synthetic identifier`).not.toMatch(/SYN-/); + } + }); + + it("nests the developer gate outside the prototype provider in the route-family layout", () => { + const layout = readFileSync(resolve(process.cwd(), `${APP_ROOT}/layout.tsx`), "utf8"); + const gateIndex = layout.indexOf(""); + const boundaryIndex = layout.indexOf(""); + const providerIndex = layout.indexOf(""); + expect(gateIndex).toBeGreaterThanOrEqual(0); + expect(boundaryIndex).toBeGreaterThan(gateIndex); + expect(providerIndex).toBeGreaterThan(boundaryIndex); + }); + + it("exposes a busy loading fallback that shows no fabricated record content", () => { + const loading = readFileSync(resolve(process.cwd(), `${APP_ROOT}/loading.tsx`), "utf8"); + expect(loading).toContain('aria-busy="true"'); + expect(loading).not.toMatch(/SYN-PATIENT-|Rowan|Mira|Jordan|Evelyn|Alex Fiction/); + }); + + it("gates only the Care Plan prefix, leaving similarly prefixed paths outside the developer area", () => { + expect(DEVELOPER_GATED_PATH_PREFIXES).toContain("/mockups/care-plan"); + // Never widened to the whole mockup tree: each entry names one subtree. + // `tests/proxy.test.ts` proves the look-alike prefixes stay blocked. + for (const prefix of DEVELOPER_GATED_PATH_PREFIXES) { + expect(prefix.startsWith("/mockups/"), prefix).toBe(true); + expect(prefix.slice("/mockups/".length).includes("/"), prefix).toBe(false); + } + }); + + it("keeps the Care Plan shell independent from the shared mockup search chrome", () => { + const source = readFileSync(resolve(process.cwd(), "src/app/mockups/mockups-layout-client.tsx"), "utf8"); + expect(source).toContain('pathname === "/mockups/care-plan"'); + expect(source).toContain('pathname.startsWith("/mockups/care-plan/")'); + expect(source.match(/!isCarePlanMockup/g)?.length).toBe(2); + }); + + it("links the Care Plan surface from the developer index", () => { + const source = readFileSync(resolve(process.cwd(), "src/app/mockups/development/page.tsx"), "utf8"); + expect(source).toContain("CARE_PLAN_ROUTES"); + for (const entry of ["Patients", "Reviews", "Governance", "System states"]) { + expect(source).toContain(entry); + } + }); +}); + +describe("Care Plan synthetic, memory-only boundary", () => { + const banned: readonly { label: string; pattern: RegExp }[] = [ + { label: "network fetch", pattern: /\bfetch\s*\(/ }, + { label: "XMLHttpRequest", pattern: /\bXMLHttpRequest\b/ }, + { label: "WebSocket or EventSource", pattern: /\b(?:WebSocket|EventSource)\b/ }, + { label: "browser storage", pattern: /\b(?:localStorage|sessionStorage|indexedDB)\b/ }, + { label: "cookies", pattern: /document\.cookie|\bcookies\s*\(/ }, + { label: "Server Action", pattern: /["']use server["']/ }, + { + label: "provider or analytics import", + pattern: /from\s+["'][^"']*(?:openai|supabase|analytics|sentry)[^"']*["']/i, + }, + { label: "timers", pattern: /\b(?:setTimeout|setInterval|requestIdleCallback)\s*\(/ }, + { label: "randomness", pattern: /Math\.random\s*\(|crypto\.randomUUID\s*\(/ }, + { label: "wall-clock read", pattern: /Date\.now\s*\(|new Date\s*\(\s*\)/ }, + { label: "presentation-count sorting", pattern: /sort[^\n]{0,40}presentation count/i }, + { label: "stigmatising language", pattern: /frequent flyer|high utili[sz]er|problem patient/i }, + { label: "numeric identification threshold", pattern: /identification threshold|threshold\s*[:=]\s*\d/i }, + ]; + + it("keeps every new Care Plan source file free of persistence, providers and non-determinism", () => { + const files = readNamespaceSources(); + expect(files.length).toBeGreaterThan(20); + for (const { path, source } of files) { + for (const { label, pattern } of banned) { + expect(pattern.test(source), `${path} 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), + ); + expect(handlers).toEqual([]); + for (const path of ["src/app/care-plan", "src/app/patients", "src/app/reviews", "src/app/governance"]) { + expect(existsSync(resolve(process.cwd(), path)), `${path} must not exist`).toBe(false); + } + }); + + it("scopes every Care Plan stylesheet selector below the app root", () => { + const css = readFileSync(resolve(process.cwd(), `${COMPONENT_ROOT}/care-plan.module.css`), "utf8"); + const selectors = css + .replace(/\/\*[\s\S]*?\*\//g, "") + // Drop at-rule headers (`@media …{`) but keep the rules inside them, so a + // selector that escapes `.appRoot` cannot hide in a media or print block. + .replace(/@[^{]*\{/g, "") + .split("}") + .flatMap((block) => { + const head = block.split("{")[0]?.trim(); + return head ? [head] : []; + }) + .filter((head) => head.length > 0 && !head.startsWith("@")) + .flatMap((head) => head.split(",").map((part) => part.trim())) + .filter((part) => part.length > 0); + expect(selectors.length).toBeGreaterThan(5); + for (const selector of selectors) { + expect(selector.startsWith(".appRoot"), `${selector} is not scoped below .appRoot`).toBe(true); + } + }); +}); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index 68700bba8..5401632c8 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -154,6 +154,28 @@ describe("production mockup boundary", () => { expect(shouldBlockProductionMockups("/applications", { NODE_ENV: "production" })).toBe(false); }); + it("lets the Care Plan subtree reach its own developer gate, and keeps look-alike prefixes blocked", () => { + // The gated prefix is exactly `/mockups/care-plan`. Its base, a patient deep + // route and an episode deep route must reach DeveloperAreaGate rather than a + // bare 404; every neighbouring path that merely begins with the same + // characters stays behind the blanket production block. + for (const path of [ + "/mockups/care-plan", + "/mockups/care-plan/patients/SYN-PATIENT-001/management-plan", + "/mockups/care-plan/patients/SYN-PATIENT-001/presentations/SYN-PRESENTATION-001", + ]) { + expect(shouldBlockProductionMockups(path, { NODE_ENV: "production" }), path).toBe(false); + } + for (const path of [ + "/mockups/care-plan-archive", + "/mockups/care-plan-archive/patients/SYN-PATIENT-001", + "/mockups/care-plans", + "/mockups/care-plan-2024/system-states", + ]) { + expect(shouldBlockProductionMockups(path, { NODE_ENV: "production" }), path).toBe(true); + } + }); + it("lets the developer-gated hub and Caring Contact subtree through the blanket block, without opting in the flag", () => { // These two subtrees carry their own signed-in-administrator gate // (DeveloperAreaGate) instead of the flat 404 — no NEXT_PUBLIC_MOCKUPS_ENABLED @@ -179,6 +201,16 @@ describe("developer-area header (x-developer-area)", () => { expect(developmentResponse.headers.get("x-middleware-request-x-developer-area")).toBe("1"); expect(developmentResponse.headers.get("x-middleware-request-x-developer-area-path")).toBe("/mockups/development"); + const carePlanRequest = requestFor("/mockups/care-plan/patients/SYN-PATIENT-001/presentations"); + const carePlanResponse = await proxy(carePlanRequest); + expect(carePlanResponse.headers.get("x-middleware-request-x-developer-area")).toBe("1"); + expect(carePlanResponse.headers.get("x-middleware-request-x-developer-area-path")).toBe( + "/mockups/care-plan/patients/SYN-PATIENT-001/presentations", + ); + + const carePlanLookAlikeResponse = await proxy(requestFor("/mockups/care-plan-archive")); + expect(carePlanLookAlikeResponse.headers.get("x-middleware-request-x-developer-area")).toBeNull(); + const otherMockupRequest = requestFor("/mockups/tools-workflow-board"); otherMockupRequest.headers.set("x-developer-area", "1"); otherMockupRequest.headers.set("x-developer-area-path", "/mockups/development"); From bb68ea8da2626990ca21d3e3b3f80e1f3dcc12e2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:22:57 +0800 Subject: [PATCH 25/56] fix(care-plan): keep the synthetic marker in print and key route focus on the address --- docs/care-plan/sdd-ledger.md | 21 +++++ .../mockups/care-plan-shell-frame.tsx | 46 ++++++++--- .../care-plan/mockups/care-plan.module.css | 10 +++ .../care-plan/mockups/routable-suite.tsx | 1 + tests/care-plan-linked-routes.dom.test.tsx | 80 ++++++++++++++++++- tests/care-plan-route-files.test.ts | 58 ++++++++++++-- 6 files changed, 199 insertions(+), 17 deletions(-) diff --git a/docs/care-plan/sdd-ledger.md b/docs/care-plan/sdd-ledger.md index 4533d856e..5180c2b1f 100644 --- a/docs/care-plan/sdd-ledger.md +++ b/docs/care-plan/sdd-ledger.md @@ -347,6 +347,27 @@ Recorded, not fixed. None blocks a later task. - 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. + --- ## Systemic lessons — carry into every later dispatch diff --git a/src/components/care-plan/mockups/care-plan-shell-frame.tsx b/src/components/care-plan/mockups/care-plan-shell-frame.tsx index 0ecfa4f5f..cc664d7dd 100644 --- a/src/components/care-plan/mockups/care-plan-shell-frame.tsx +++ b/src/components/care-plan/mockups/care-plan-shell-frame.tsx @@ -42,6 +42,13 @@ const PHONE_DESTINATIONS = CARE_PLAN_PRIMARY_DESTINATIONS.filter( ); export type CarePlanShellFrameProps = { + /** + * The address being displayed. The shell persists across navigation, so this + * — not the heading — is what tells it the route actually changed: two + * patients' Management Plans share one heading, and keying on the heading + * would leave that navigation silent for a screen-reader user. + */ + pathname: string; /** The rail and dock entry that owns the current route. */ activeDestination: CarePlanDestination; /** The single first-level heading for the route. */ @@ -67,6 +74,7 @@ export type CarePlanShellFrameProps = { * clicking through from somewhere else. */ export function CarePlanShellFrame({ + pathname, activeDestination, title, scenario, @@ -80,12 +88,22 @@ export function CarePlanShellFrame({ const moreTriggerRef = useRef(null); const titleRef = useRef(null); - // Move focus to the heading whenever the route changes. Without it a keyboard - // or screen-reader user stays parked on the link they activated and has to - // travel the whole rail again to reach the new page. + // Move focus to the heading whenever the address changes. Without it a + // keyboard or screen-reader user stays parked on the link they activated and + // has to travel the whole rail again to reach the new page. + // + // The dependency is the pathname, not the heading. Moving between two + // patients' Management Plans keeps the same heading text, and keying on the + // heading would make the commonest navigation in this product announce + // nothing at all. + // + // This focus move is also the *only* route announcement. A hand-rolled + // `aria-live` region repeating the heading would make every navigation + // announce twice, once from the live region and once from the newly focused + // heading, so there deliberately is not one. useEffect(() => { titleRef.current?.focus({ preventScroll: true }); - }, [title]); + }, [pathname]); function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -141,13 +159,23 @@ export function CarePlanShellFrame({
-
+ {/* + The header is deliberately NOT print-hidden. It is the only place + that says this content is fictional, and a printed Care Plan leaves + the screen — it is carried to a bedside or sent with a handover. Paper + showing a clinical heading with nothing marking it synthetic is the + exact failure this guards against, so only the chrome inside the + header (the search slot) is dropped for print. + */} +
- Synthetic prototype — fictional data only + + Synthetic prototype — fictional data only + Nothing is saved. Reloading this page starts over.
-
+ - -

- {title} -

); } diff --git a/src/components/care-plan/mockups/care-plan.module.css b/src/components/care-plan/mockups/care-plan.module.css index abe2d6621..b7cccf493 100644 --- a/src/components/care-plan/mockups/care-plan.module.css +++ b/src/components/care-plan/mockups/care-plan.module.css @@ -272,6 +272,16 @@ padding: 0; } + /* + * The header survives print because it carries the synthetic marker. Strip its + * screen chrome so it reads as a printed caption rather than an app bar. + */ + .appRoot .header { + border-block-end: none; + background: none; + padding: 0 0 0.5rem; + } + .appRoot .purposeSurface { border-color: CanvasText; } diff --git a/src/components/care-plan/mockups/routable-suite.tsx b/src/components/care-plan/mockups/routable-suite.tsx index 4bbb5f9bd..7bc60c01a 100644 --- a/src/components/care-plan/mockups/routable-suite.tsx +++ b/src/components/care-plan/mockups/routable-suite.tsx @@ -268,6 +268,7 @@ export function CarePlanRouteSurface({ pathname, query = "", navigate }: CarePla return ( { expect(heading).toHaveFocus(); }); + // The commonest navigation in this product is patient to patient on the same + // route, and both patients resolve to the same heading. If focus keys on the + // heading text rather than the address, a screen-reader user moves to a + // different patient's plan and hears nothing at all. + it("moves focus again when only the patient changes and the heading text does not", () => { + const navigate = vi.fn(); + const surface = (pathname: string) => ( + + + + ); + const { rerender } = render(surface(carePlanRoute.managementPlan("SYN-PATIENT-001"))); + + const heading = screen.getByRole("heading", { level: 1, name: "Management Plan" }); + expect(heading).toHaveFocus(); + + // Simulate the user tabbing away, then navigating to another patient. + (document.activeElement as HTMLElement | null)?.blur(); + expect(heading).not.toHaveFocus(); + + rerender(surface(carePlanRoute.managementPlan("SYN-PATIENT-002"))); + + const headingAfter = screen.getByRole("heading", { level: 1, name: "Management Plan" }); + // Same node: the shell persists across navigation rather than remounting, + // which is also what lets the search field keep what was typed into it. + expect(headingAfter).toBe(heading); + expect(headingAfter).toHaveFocus(); + }); + + it("keeps the typed search term across a navigation because the shell persists", async () => { + const user = userEvent.setup(); + const navigate = vi.fn(); + const surface = (pathname: string) => ( + + + + ); + const { rerender } = render(surface(CARE_PLAN_ROUTES.home)); + + await user.type(screen.getByRole("searchbox", { name: "Search patients" }), "Rowan"); + rerender(surface(CARE_PLAN_ROUTES.patients)); + expect(screen.getByRole("searchbox", { name: "Search patients" })).toHaveValue("Rowan"); + }); + + it("announces a route change once, through the heading, not through a second live region", () => { + renderRoute(CARE_PLAN_ROUTES.patients); + const heading = screen.getByRole("heading", { level: 1, name: "Patients" }); + expect(heading).toHaveFocus(); + // A hand-rolled aria-live region repeating the heading would make every + // route change announce twice. + const liveRegions = Array.from(document.querySelectorAll("[aria-live]")).filter( + (node) => node.textContent?.trim() === "Patients", + ); + expect(liveRegions).toEqual([]); + }); + it("states the synthetic boundary and that nothing is saved", () => { renderRoute(CARE_PLAN_ROUTES.home); expect(screen.getByText("Synthetic prototype — fictional data only")).toBeInTheDocument(); @@ -106,6 +162,28 @@ describe("Care Plan route shell", () => { ); }); + // A printed Care Plan leaves the screen: it is carried to a bedside or sent + // with a handover. Paper that shows a clinical heading with nothing saying the + // content is fictional is the failure this asserts against, so the marker must + // survive print on every route — most of all the three print routes. + it.each([ + CARE_PLAN_ROUTES.managementPlanPrint, + CARE_PLAN_ROUTES.patientPlanPrint, + CARE_PLAN_ROUTES.safetyPlanPrint, + CARE_PLAN_ROUTES.home, + ])("keeps the synthetic marker on the printed page for %s", (pathname) => { + renderRoute(pathname); + const marker = screen.getByTestId("care-plan-synthetic-marker"); + expect(marker).toHaveTextContent("Synthetic prototype — fictional data only"); + expect(marker.closest("[data-print-hide='true']")).toBeNull(); + + const memoryNotice = screen.getByText("Nothing is saved. Reloading this page starts over."); + expect(memoryNotice.closest("[data-print-hide='true']")).toBeNull(); + + // The search slot is chrome, and chrome still goes. + expect(screen.getByRole("search").closest("[data-print-hide='true']")).not.toBeNull(); + }); + it.each([ [CARE_PLAN_ROUTES.home, "Home"], [CARE_PLAN_ROUTES.patients, "Patients"], diff --git a/tests/care-plan-route-files.test.ts b/tests/care-plan-route-files.test.ts index 5be5d75e4..03a6024a4 100644 --- a/tests/care-plan-route-files.test.ts +++ b/tests/care-plan-route-files.test.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { BANNED_ADMISSION_CONSTRUCTIONS } from "@/components/care-plan/mockups/domain"; import { syntheticEdPresentations, syntheticPatients } from "@/components/care-plan/mockups/fixtures"; import { CARE_PLAN_BASE, @@ -210,9 +211,26 @@ describe("Care Plan route registration", () => { it("links the Care Plan surface from the developer index", () => { const source = readFileSync(resolve(process.cwd(), "src/app/mockups/development/page.tsx"), "utf8"); - expect(source).toContain("CARE_PLAN_ROUTES"); - for (const entry of ["Patients", "Reviews", "Governance", "System states"]) { - expect(source).toContain(entry); + // Scoped to the Care Plan surface object only. Asserting against the whole + // file could not fail: "Patients" and "System states" already appear in the + // pre-existing Caring Contact block, so deleting every Care Plan deep link + // would still have passed. + const start = source.indexOf('id: "care-plan"'); + expect(start, "the developer index has no Care Plan surface").toBeGreaterThanOrEqual(0); + const fromCarePlan = source.slice(start); + const nextSurface = fromCarePlan.indexOf('id: "', 1); + const surface = nextSurface === -1 ? fromCarePlan : fromCarePlan.slice(0, nextSurface); + expect(surface).not.toContain('id: "caring-contacts"'); + + expect(surface).toContain("CARE_PLAN_ROUTES.home"); + for (const [label, accessor] of [ + ["Patients", "CARE_PLAN_ROUTES.patients"], + ["Reviews", "CARE_PLAN_ROUTES.reviews"], + ["Governance", "CARE_PLAN_ROUTES.governance"], + ["System states", "CARE_PLAN_ROUTES.systemStates"], + ] as const) { + expect(surface, `the Care Plan surface is missing the ${label} deep link`).toContain(accessor); + expect(surface, `the Care Plan surface is missing the ${label} label`).toContain(`"${label}"`); } }); }); @@ -233,14 +251,44 @@ describe("Care Plan synthetic, memory-only boundary", () => { { label: "randomness", pattern: /Math\.random\s*\(|crypto\.randomUUID\s*\(/ }, { label: "wall-clock read", pattern: /Date\.now\s*\(|new Date\s*\(\s*\)/ }, { label: "presentation-count sorting", pattern: /sort[^\n]{0,40}presentation count/i }, - { label: "stigmatising language", pattern: /frequent flyer|high utili[sz]er|problem patient/i }, + // Blanket-banned: these describe a person, and no concept in + // `docs/care-plan-context.md` makes any of them acceptable. Concept-scoped + // `_Avoid_` terms (Edit, Copy, …) are deliberately NOT here — those ban a + // word for one concept, not everywhere, and the glossary's own Draft entry + // says a draft "can be edited". + { + label: "stigmatising label for a person", + pattern: /frequent flyer|high utili[sz]er|problem patient|frequent[- ]presenter|frequent[- ]attender/i, + }, + { label: "quantified risk or severity verdict", pattern: /risk score|severity score|acuity score/i }, { label: "numeric identification threshold", pattern: /identification threshold|threshold\s*[:=]\s*\d/i }, + ...BANNED_ADMISSION_CONSTRUCTIONS.map((phrase) => ({ + label: `prohibitive admission construction ("${phrase}")`, + pattern: new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), + })), ]; + /** + * `BANNED_ADMISSION_CONSTRUCTIONS` is declared inside the scanned namespace, + * so its own array literal would match every one of its own patterns. Remove + * exactly that declaration — and nothing else — so prose anywhere else in + * `domain.ts` is still scanned. Fails closed if the declaration changes shape. + */ + function withoutTheBannedPhraseDeclaration(path: string, source: string) { + if (!path.endsWith("domain.ts")) return source; + const start = source.indexOf("export const BANNED_ADMISSION_CONSTRUCTIONS"); + expect(start, "domain.ts no longer declares BANNED_ADMISSION_CONSTRUCTIONS").toBeGreaterThanOrEqual(0); + const end = source.indexOf("];", start); + expect(end, "the BANNED_ADMISSION_CONSTRUCTIONS declaration no longer ends with `];`").toBeGreaterThan(start); + return `${source.slice(0, start)}${source.slice(end)}`; + } + it("keeps every new Care Plan source file free of persistence, providers and non-determinism", () => { const files = readNamespaceSources(); expect(files.length).toBeGreaterThan(20); - for (const { path, source } of files) { + expect(BANNED_ADMISSION_CONSTRUCTIONS.length).toBeGreaterThan(10); + for (const { path, source: raw } of files) { + const source = withoutTheBannedPhraseDeclaration(path, raw); for (const { label, pattern } of banned) { expect(pattern.test(source), `${path} contains ${label}`).toBe(false); } From a219158d605125449ff912bbece5ee64e07bdb0f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 22 Aug 2026 05:27:19 +0800 Subject: [PATCH 26/56] docs(care-plan): close Task 3 in the ledger with its rulings Task 3 complete at 209/209 with one fix round and a clean scoped re-review. Records the glossary-scope ruling that kept the spec's own route copy, and the accepted removal of the duplicate live region. Co-Authored-By: Claude Opus 5 --- docs/care-plan/sdd-ledger.md | 64 ++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/docs/care-plan/sdd-ledger.md b/docs/care-plan/sdd-ledger.md index 5180c2b1f..66061ee9e 100644 --- a/docs/care-plan/sdd-ledger.md +++ b/docs/care-plan/sdd-ledger.md @@ -102,7 +102,7 @@ is the only damage it carried — its tracked tree was clean and byte-identical | ------------------------------- | -------------------------- | ---------------------- | --------------------------------------------------------------------- | | 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 | **in progress** | BASE `d421bc2dc` | restarted 22 Aug 2026 after the merge; no partial work existed | +| 3. Routes, gate, shell | **complete, review clean** | `d421bc2dc..bb68ea8da` | 209/209 passing, typecheck + lint clean, 39 mutations / 39 red suites | | 4–11 | not started | — | — | Stage A is Tasks 1–5. **Task 5 ends with a mandatory stop for user review** before @@ -126,11 +126,34 @@ Task 6 begins. 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 — in progress (started 22 August 2026) - -The third worktree destruction blocked it; no partial work exists and there was nothing -to reconcile, so it restarts from the brief unchanged. BASE for the review package is -`d421bc2dc`. +### 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. --- @@ -316,6 +339,35 @@ text against itself. Six conflicts found, six ruled. 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. + --- ## Deferred minors — for the whole-branch review to triage From 0c9b32c6495e348472722e624a1894bff7e860f0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:19:49 +0800 Subject: [PATCH 27/56] feat(care-plan): deliver searchable clinical snapshot --- .../mockups/care-plan-shell-frame.tsx | 36 +- .../care-plan/mockups/care-plan.module.css | 496 ++++++++++++++++++ .../mockups/clinical-snapshot-page.tsx | 96 ++++ .../care-plan/mockups/contact-actions.tsx | 119 +++++ .../care-plan/mockups/patient-directory.tsx | 151 ++++++ .../care-plan/mockups/patient-navigation.tsx | 57 ++ .../care-plan/mockups/patient-workspace.tsx | 218 ++++++++ .../care-plan/mockups/prototype-ui.tsx | 324 ++++++++++++ .../care-plan/mockups/routable-suite.tsx | 41 +- tests/care-plan-linked-routes.dom.test.tsx | 398 +++++++++++++- 10 files changed, 1915 insertions(+), 21 deletions(-) create mode 100644 src/components/care-plan/mockups/clinical-snapshot-page.tsx create mode 100644 src/components/care-plan/mockups/contact-actions.tsx create mode 100644 src/components/care-plan/mockups/patient-directory.tsx create mode 100644 src/components/care-plan/mockups/patient-navigation.tsx create mode 100644 src/components/care-plan/mockups/patient-workspace.tsx create mode 100644 src/components/care-plan/mockups/prototype-ui.tsx diff --git a/src/components/care-plan/mockups/care-plan-shell-frame.tsx b/src/components/care-plan/mockups/care-plan-shell-frame.tsx index cc664d7dd..fcd67ff3d 100644 --- a/src/components/care-plan/mockups/care-plan-shell-frame.tsx +++ b/src/components/care-plan/mockups/care-plan-shell-frame.tsx @@ -59,6 +59,13 @@ export type CarePlanShellFrameProps = { activeUser: { displayName: string; title: string }; /** Called when the one search slot is submitted. */ onSearchSubmit: () => void; + /** + * The route owns an in-flow search of its own, so the shell stands its + * composer down. One page never carries two search fields: Home and Patients + * put search inside the patient directory, where the results appear, and every + * other route uses this one. + */ + routeOwnsSearch?: boolean; /** The one route-owned action slot beside the page title. */ headerAction?: ReactNode; children: ReactNode; @@ -80,6 +87,7 @@ export function CarePlanShellFrame({ scenario, activeUser, onSearchSubmit, + routeOwnsSearch = false, headerAction, children, }: CarePlanShellFrameProps) { @@ -175,19 +183,21 @@ export function CarePlanShellFrame({ Nothing is saved. Reloading this page starts over. - - setSearchTerm(event.target.value)} - onClear={() => setSearchTerm("")} - placeholder="Search patients" - fieldClassName={styles.searchField} - /> - - + {routeOwnsSearch ? null : ( +
+ setSearchTerm(event.target.value)} + onClear={() => setSearchTerm("")} + placeholder="Search patients" + fieldClassName={styles.searchField} + /> + + + )}
diff --git a/src/components/care-plan/mockups/care-plan.module.css b/src/components/care-plan/mockups/care-plan.module.css index b7cccf493..174e9203f 100644 --- a/src/components/care-plan/mockups/care-plan.module.css +++ b/src/components/care-plan/mockups/care-plan.module.css @@ -286,3 +286,499 @@ border-color: CanvasText; } } + +/* + * Clinical Snapshot — patient directory, patient workspace, and the Current Plan + * summary card. Colour comes from design tokens only, and no rule here clips, + * truncates, or collapses plan content: the fifth first-minute section and the + * pinned safety boundary must survive every viewport, dark mode, forced colours, + * and print. + */ + +.appRoot .snapshotSplit { + display: grid; + align-items: start; + gap: 1.25rem; + margin-block-start: 1.25rem; +} + +.appRoot .snapshotWorkspaceColumn { + min-width: 0; +} + +.appRoot .directory { + display: grid; + gap: 0.875rem; + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background: var(--surface-raised); + padding: var(--pad-panel); +} + +.appRoot .directoryHeading { + color: var(--text-heading); + font-size: 0.875rem; + font-weight: 700; + letter-spacing: var(--tracking-label); + text-transform: uppercase; +} + +.appRoot .directoryResults { + display: grid; + gap: 0.5rem; +} + +.appRoot .directoryList { + display: grid; + gap: 0.375rem; + list-style: none; + margin: 0; + padding: 0; +} + +.appRoot .directoryRow { + display: grid; + width: 100%; + min-height: var(--spacing-tap); + gap: 0.125rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: 0.5rem 0.75rem; + text-align: start; +} + +.appRoot .directoryRow:hover { + border-color: var(--border-strong); + background: var(--surface-subtle); +} + +.appRoot .directoryRow:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.appRoot .directoryRowSelected { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); +} + +.appRoot .directoryRowName { + color: var(--text-heading); + font-size: 0.9375rem; + font-weight: 700; +} + +.appRoot .directoryRowDetail { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.appRoot .directoryRowMarks { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + margin-block-start: 0.25rem; +} + +.appRoot .directoryRowCount { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 600; +} + +.appRoot .directoryFooter { + display: grid; + gap: 0.375rem; + border-block-start: 1px solid var(--border); + padding-block-start: 0.75rem; +} + +.appRoot .directoryNote { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.6; +} + +.appRoot .workspace { + display: grid; + gap: 1rem; +} + +.appRoot .identityBand { + display: grid; + gap: 0.625rem; + justify-items: start; + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background: var(--surface-raised); + padding: var(--pad-panel); +} + +.appRoot .patientName { + color: var(--text-heading); + font-size: var(--text-xl); + font-weight: 700; +} + +.appRoot .definitionGrid { + display: grid; + width: 100%; + gap: 0.375rem 1rem; +} + +.appRoot .definitionRow { + display: grid; + gap: 0.0625rem; +} + +.appRoot .definitionTerm { + color: var(--text-muted); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: var(--tracking-label); + text-transform: uppercase; +} + +.appRoot .definitionDetail { + color: var(--text); + font-size: 0.875rem; + line-height: 1.5; + margin: 0; +} + +.appRoot .statusMark { + display: inline-flex; + align-items: center; + border-width: 1px; + border-style: solid; + border-radius: var(--radius-sm); + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; +} + +.appRoot .patientNav { + display: flex; + width: 100%; + flex-wrap: wrap; + gap: 0.375rem; + border-block-start: 1px solid var(--border); + padding-block-start: 0.625rem; +} + +.appRoot .patientNavItem { + display: inline-flex; + min-height: var(--spacing-tap); + align-items: center; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding-inline: 0.75rem; + color: var(--text); + font-size: 0.8125rem; + font-weight: 600; +} + +.appRoot .patientNavItem:hover { + background: var(--surface-subtle); +} + +.appRoot .patientNavItem[aria-current="page"] { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); + color: var(--clinical-accent); +} + +.appRoot .patientNavSecondary { + margin-inline-start: auto; + color: var(--text-muted); +} + +/* + * The pinned safety boundary. It sits directly beneath the identity band and + * above every other plan element, at every viewport and in print, and it links + * to the full section rather than replacing it. + */ +.appRoot .pinnedBoundary { + display: grid; + gap: 0.25rem; + border: 2px solid var(--warning-border); + border-radius: var(--radius-lg); + background: var(--warning-soft); + padding: 0.75rem 1rem; +} + +.appRoot .pinnedBoundaryText { + color: var(--text); + font-size: 0.9375rem; + line-height: 1.5; +} + +.appRoot .pinnedBoundaryLink, +.appRoot .inlineLink { + color: var(--clinical-accent); + font-weight: 700; + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.appRoot .sectionFrame { + display: grid; + gap: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background: var(--surface-raised); + padding: var(--pad-panel); +} + +.appRoot .sectionFrameBoundary { + border-color: var(--clinical-accent-border); +} + +.appRoot .sectionFrameSecondary { + background: var(--surface-inset); +} + +.appRoot .sectionHead { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.appRoot .sectionActions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.appRoot .sectionHeading { + color: var(--text-heading); + font-size: var(--text-lg-minus); + font-weight: 700; +} + +.appRoot .subsectionHeading { + color: var(--text-heading); + font-size: 0.9375rem; + font-weight: 700; +} + +.appRoot .sectionDescription { + color: var(--text-muted); + font-size: 0.875rem; + line-height: 1.6; +} + +.appRoot .sectionEmpty { + color: var(--text-muted); + font-size: 0.875rem; + font-style: italic; +} + +.appRoot .metadataBlock { + display: grid; + gap: 0.625rem; + border-block-end: 1px solid var(--border); + padding-block-end: 0.75rem; +} + +.appRoot .metadataMarks { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.appRoot .boundaryStatement { + color: var(--text); + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.6; +} + +.appRoot .firstMinuteSections { + display: grid; + gap: 0.875rem; +} + +.appRoot .firstMinuteSection { + display: grid; + gap: 0.375rem; +} + +.appRoot .firstMinuteSectionBoundary { + border: 2px solid var(--clinical-accent-border); + border-radius: var(--radius-lg); + background: var(--clinical-accent-soft); + padding: 0.75rem; +} + +.appRoot .contentList { + display: grid; + gap: 0.375rem; + margin: 0; + padding-inline-start: 1.125rem; + color: var(--text); + font-size: 0.9375rem; + line-height: 1.55; +} + +.appRoot .reviewWarning { + border-width: 1px; + border-style: solid; + border-radius: var(--radius-lg); + padding: 0.625rem 0.875rem; + font-size: 0.875rem; + line-height: 1.55; +} + +.appRoot .noCurrentPlan { + color: var(--text-heading); + font-size: var(--text-lg-minus); + font-weight: 700; +} + +.appRoot .withdrawnNotice { + color: var(--text); + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.55; +} + +.appRoot .identityUncertain { + border: 2px solid var(--danger-border); + border-radius: var(--radius-lg); + background: var(--danger-soft); + padding: 0.875rem 1rem; + color: var(--text); + font-size: 0.9375rem; + line-height: 1.55; +} + +.appRoot .contactWarning { + border: 1px solid var(--warning-border); + border-radius: var(--radius-lg); + background: var(--warning-soft); + padding: 0.625rem 0.875rem; + color: var(--text); + font-size: 0.875rem; + line-height: 1.55; +} + +.appRoot .contactVerified { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + color: var(--text-muted); + font-size: 0.8125rem; +} + +.appRoot .contactActions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.appRoot .contactAction { + display: inline-flex; + min-height: var(--spacing-tap); + align-items: center; + border: 1px solid var(--border-lux); + border-radius: var(--radius-lg); + background: var(--surface); + padding-inline: 1rem; + color: var(--text); + font-size: 0.875rem; + font-weight: 600; +} + +.appRoot .contactAction:hover { + border-color: var(--border-strong); + background: var(--surface-subtle); +} + +.appRoot .contactBoundary { + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.6; +} + +.appRoot .launchFailure { + border: 1px solid var(--danger-border); + border-radius: var(--radius-lg); + background: var(--danger-soft); + padding: 0.625rem 0.875rem; + color: var(--text); + font-size: 0.875rem; + line-height: 1.55; +} + +@media (min-width: 40rem) { + .appRoot .definitionGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* + * The desktop split. Below this width the directory and the workspace are one + * full-width column, in that order: a compressed second column at 320px would + * make the first-minute content unreadable exactly where it matters most. + */ +@media (min-width: 64rem) { + .appRoot .snapshotSplit { + grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr); + } + + .appRoot .directory { + position: sticky; + top: 1rem; + } +} + +@media (forced-colors: active) { + .appRoot .directoryRow, + .appRoot .sectionFrame, + .appRoot .identityBand, + .appRoot .directory, + .appRoot .pinnedBoundary, + .appRoot .firstMinuteSectionBoundary, + .appRoot .contactAction, + .appRoot .patientNavItem, + .appRoot .statusMark { + border-color: CanvasText; + } + + .appRoot .directoryRow[aria-current="true"], + .appRoot .patientNavItem[aria-current="page"] { + border-width: 2px; + } +} + +@media print { + .appRoot .snapshotSplit { + grid-template-columns: minmax(0, 1fr); + } + + .appRoot .directory { + display: none; + } + + .appRoot .sectionFrame, + .appRoot .identityBand, + .appRoot .pinnedBoundary, + .appRoot .firstMinuteSectionBoundary { + border-color: CanvasText; + background: none; + break-inside: avoid; + } + + /* The pinned boundary and the fifth section print in full, never clipped. */ + .appRoot .pinnedBoundary, + .appRoot .firstMinuteSection { + max-height: none; + overflow: visible; + } +} diff --git a/src/components/care-plan/mockups/clinical-snapshot-page.tsx b/src/components/care-plan/mockups/clinical-snapshot-page.tsx new file mode 100644 index 000000000..91bd294c4 --- /dev/null +++ b/src/components/care-plan/mockups/clinical-snapshot-page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useMemo } from "react"; + +import { EmptyState } from "@/components/ui-primitives"; + +import styles from "./care-plan.module.css"; +import { buildPatientSnapshot, getCurrentManagementPlanVersion } from "./domain"; +import { PROTOTYPE_NOW } from "./fixtures"; +import { PatientDirectory } from "./patient-directory"; +import { PatientWorkspace } from "./patient-workspace"; +import { useCarePlanPrototype } from "./prototype-provider"; +import { CARE_PLAN_ROUTES } from "./routes"; +import type { CmhtContact, Patient, PatientSnapshot, PrototypeScenario, SyntheticId } from "./types"; + +export type ClinicalSnapshotVariant = "home" | "patients" | "patient"; + +/** + * The Clinical Snapshot surface behind Home, Patients, and a patient's Overview. + * + * Home and Patients own their own in-flow directory search — the shell composer + * stands down on those two routes, so there is never a second search field on + * one page. A patient route derives its patient from the address rather than + * from the selection, so a deep link always opens the record it names. + */ +export function ClinicalSnapshotSurface({ + variant, + patientId, + scenario, +}: { + variant: ClinicalSnapshotVariant; + patientId?: string; + scenario: PrototypeScenario; +}) { + const { state, dispatch } = useCarePlanPrototype(); + + const resolvedPatientId: SyntheticId | null = + variant === "patient" ? ((patientId as SyntheticId | undefined) ?? null) : state.selectedPatientId; + + const snapshot: PatientSnapshot | null = useMemo( + () => (resolvedPatientId === null ? null : buildPatientSnapshot(state, resolvedPatientId, PROTOTYPE_NOW)), + [state, resolvedPatientId], + ); + + function planStatusFor(patient: Patient): { label: string; tone: "success" | "warning" | "neutral" } { + const current = getCurrentManagementPlanVersion(state.managementPlanVersions, patient.managementPlanId); + if (current !== null) return { label: `Current version ${current.version}`, tone: "success" }; + const withdrawn = state.managementPlanVersions.some( + (version) => version.planId === patient.managementPlanId && version.state === "withdrawn", + ); + return withdrawn ? { label: "Withdrawn", tone: "warning" } : { label: "No plan in use", tone: "neutral" }; + } + + function recordContactIntent(contact: CmhtContact, channel: "email" | "call") { + if (snapshot === null) return; + dispatch({ type: "record-contact-intent", patientId: snapshot.patient.id, cmhtId: contact.id, channel }); + } + + const workspace = + snapshot === null ? ( + + ) : ( + + ); + + if (variant === "patient") return workspace; + + return ( +
+ dispatch({ type: "select-patient", patientId: id })} + reviewsHref={CARE_PLAN_ROUTES.reviews} + listAllWhenEmpty={variant === "patients"} + planStatusFor={planStatusFor} + /> +
{workspace}
+
+ ); +} diff --git a/src/components/care-plan/mockups/contact-actions.tsx b/src/components/care-plan/mockups/contact-actions.tsx new file mode 100644 index 000000000..ea4a37bee --- /dev/null +++ b/src/components/care-plan/mockups/contact-actions.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { useState, type MouseEvent } from "react"; + +import styles from "./care-plan.module.css"; +import { buildCmhtMailto, buildCmhtTel } from "./domain"; +import { DefinitionRow, SectionFrame, StatusMark, formatPerthDate } from "./prototype-ui"; +import type { CmhtContact, PrototypeScenario } from "./types"; + +type ContactChannel = "email" | "call"; + +const CHANNEL_NOUN: Record = { + email: "email application", + call: "telephone application", +}; + +/** + * The team contact block. Everything a clinician needs to reach the team stays + * on screen — mailbox, duty number, hours, coordinator, and the after-hours + * route — and the two launch controls are ordinary external anchors. + * + * The prototype records only that an external application was asked to open. It + * never claims that anything was sent, delivered, read, answered, or completed, + * because it transmits nothing and could not know. + */ +export function ContactActions({ + contact, + scenario, + reviewsHref, + onIntent, +}: { + contact: CmhtContact; + scenario: PrototypeScenario; + reviewsHref: string; + onIntent: (channel: ContactChannel) => void; +}) { + const [failedChannel, setFailedChannel] = useState(null); + + function handleLaunch(channel: ContactChannel) { + return (event: MouseEvent) => { + // 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 + // the reader nowhere at all. + if (scenario === "launch-failure") { + event.preventDefault(); + setFailedChannel(channel); + } else { + setFailedChannel(null); + } + onIntent(channel); + }; + } + + const unverified = contact.verificationState !== "verified"; + + return ( + + {unverified ? ( +

+ These contact details have not been verified. Last verified on{" "} + {formatPerthDate(contact.verifiedAt)}. They are shown so you can still try them; nothing here confirms the + team can be contacted on them. Verification is queued in{" "} + + Reviews + + . +

+ ) : ( +

+ Last verified on{" "} + {formatPerthDate(contact.verifiedAt)}. +

+ )} + +
+ {contact.sharedMailbox} + {contact.dutyTelephoneDisplay} + {contact.operatingHours} + {contact.careCoordinator ?? undefined} + + {contact.afterHoursLabel} {contact.afterHoursTelephoneDisplay}. + +
+ + + +

+ These controls open an application on this device. This prototype transmits nothing and holds no evidence of + delivery, readership, or reply. +

+ + {failedChannel === null ? null : ( +

+ {`The external ${CHANNEL_NOUN[failedChannel]} could not be opened.`}{" "} + {failedChannel === "email" + ? "Nothing was sent, and no message exists." + : "No call was placed, and no call exists."}{" "} + {`Use the details above directly: ${contact.sharedMailbox}, or ${contact.dutyTelephoneDisplay}.`} +

+ )} +
+ ); +} diff --git a/src/components/care-plan/mockups/patient-directory.tsx b/src/components/care-plan/mockups/patient-directory.tsx new file mode 100644 index 000000000..03bc09ce8 --- /dev/null +++ b/src/components/care-plan/mockups/patient-directory.tsx @@ -0,0 +1,151 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState, type FormEvent } from "react"; + +import { SearchField } from "@/components/ui/text-field"; +import { EmptyState, cn } from "@/components/ui-primitives"; + +import styles from "./care-plan.module.css"; +import { PRESENTATION_ACTIVITY_WINDOW_MONTHS, countPresentationActivity, searchPatients } from "./domain"; +import { StatusMark, formatPerthDate } from "./prototype-ui"; +import type { EdPresentation, Patient, SyntheticId } from "./types"; + +/** How many recent patients the resting state offers before anything is typed. */ +const RECENT_PATIENT_LIMIT = 3; + +export type PatientDirectoryProps = { + patients: readonly Patient[]; + presentations: readonly EdPresentation[]; + /** The prototype's single fixed clock, passed in. Nothing here reads a clock. */ + now: string; + selectedPatientId: SyntheticId | null; + onSelectPatient: (patientId: SyntheticId) => void; + reviewsHref: string; + /** + * The Patients route lists the whole synthetic directory at rest; Home offers + * a short recent list instead, so the first screen is search rather than a + * roll of everybody who has a record. + */ + listAllWhenEmpty?: boolean; + /** Rendered per row so a reader can see which plan state they are opening. */ + planStatusFor: (patient: Patient) => { label: string; tone: "success" | "warning" | "neutral" }; +}; + +/** + * Search-first patient directory. + * + * Search state is local to this component; the selected patient lives in the + * prototype provider, so every route reads one selection rather than keeping a + * copy of its own. + * + * There is deliberately no control that sorts or ranks this list by presentation + * count. Ranking everyone by how often they attend is the banned label without + * the word for it; that ordering exists only inside the Identification Review + * workflow, where finding people who attend often is the stated, governed + * purpose of the screen. + */ +export function PatientDirectory({ + patients, + presentations, + now, + selectedPatientId, + onSelectPatient, + reviewsHref, + listAllWhenEmpty = false, + planStatusFor, +}: PatientDirectoryProps) { + const [query, setQuery] = useState(""); + const trimmed = query.trim(); + + /** Most recently seen first. Recency is not a ranking by how often someone + * attends: it puts the record you are most likely to want within one tap. */ + const recentPatients = useMemo(() => { + const lastSeen = new Map(); + for (const presentation of presentations) { + const arrived = Date.parse(presentation.arrivedAt); + const known = lastSeen.get(presentation.patientId); + if (known === undefined || arrived > known) lastSeen.set(presentation.patientId, arrived); + } + return [...patients] + .filter((patient) => lastSeen.has(patient.id)) + .sort((left, right) => (lastSeen.get(right.id) ?? 0) - (lastSeen.get(left.id) ?? 0)) + .slice(0, RECENT_PATIENT_LIMIT); + }, [patients, presentations]); + + const results = useMemo(() => (trimmed === "" ? [] : searchPatients(patients, trimmed)), [patients, trimmed]); + + const restingList = listAllWhenEmpty ? patients : recentPatients; + const restingHeading = listAllWhenEmpty ? "All synthetic patients" : "Recent patients"; + const showingResults = trimmed !== ""; + const listed = showingResults ? results : restingList; + + return ( +
+
event.preventDefault()}> + setQuery(event.target.value)} + onClear={() => setQuery("")} + placeholder="Name, MRN, or date of birth" + /> + + +
+

{showingResults ? "Search results" : restingHeading}

+ {showingResults && results.length === 0 ? ( + + ) : ( +
    + {listed.map((patient) => { + const activity = countPresentationActivity(presentations, patient.id, now); + const planStatus = planStatusFor(patient); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+

+ {`Presentation counts are an objective observation in the ${PRESENTATION_ACTIVITY_WINDOW_MONTHS} months to ${formatPerthDate(now)}. Counts describe what happened. They do not determine eligibility for a Management Plan.`} +

+

+ No eligibility rule exists in this prototype. An authorised clinician refers a person with a stated reason.{" "} + + Refer someone for Identification Review + + . +

+
+
+ ); +} diff --git a/src/components/care-plan/mockups/patient-navigation.tsx b/src/components/care-plan/mockups/patient-navigation.tsx new file mode 100644 index 000000000..e918966ad --- /dev/null +++ b/src/components/care-plan/mockups/patient-navigation.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; + +import { cn } from "@/components/ui-primitives"; + +import styles from "./care-plan.module.css"; +import { carePlanRoute } from "./routes"; + +export type PatientSectionKey = "overview" | "managementPlan" | "safetyPlan" | "presentations" | "history"; + +/** + * The four primary patient sections plus `History`, which stays available as a + * secondary audit surface rather than competing with them for attention. + * + * Every href is rebuilt from the selected patient's identifier through the route + * registry, so no synthetic identifier is written out here and a link can never + * point at a different patient than the workspace around it. + */ +const PRIMARY_SECTIONS: readonly { key: PatientSectionKey; label: string; href: (id: string) => string }[] = [ + { key: "overview", label: "Overview", href: carePlanRoute.patient }, + { key: "managementPlan", label: "Management Plan", href: carePlanRoute.managementPlan }, + { key: "safetyPlan", label: "Personal Safety Plan", href: carePlanRoute.safetyPlan }, + { key: "presentations", label: "ED Presentations", href: carePlanRoute.presentations }, +]; + +const SECONDARY_SECTION = { key: "history" as const, label: "History", href: carePlanRoute.history }; + +export function PatientNavigation({ + patientId, + activeSection, +}: { + patientId: string; + activeSection: PatientSectionKey; +}) { + return ( + + ); +} diff --git a/src/components/care-plan/mockups/patient-workspace.tsx b/src/components/care-plan/mockups/patient-workspace.tsx new file mode 100644 index 000000000..5f4ab2c6f --- /dev/null +++ b/src/components/care-plan/mockups/patient-workspace.tsx @@ -0,0 +1,218 @@ +"use client"; + +import Link from "next/link"; + +import { InlineNotice } from "@/components/ui-primitives"; + +import styles from "./care-plan.module.css"; +import { PRESENTATION_ACTIVITY_WINDOW_MONTHS } from "./domain"; +import { PatientNavigation, type PatientSectionKey } from "./patient-navigation"; +import { ContactActions } from "./contact-actions"; +import { + CurrentPlanSummary, + DefinitionRow, + MANAGEMENT_VERSION_STATE_LABEL, + ParticipationMarker, + PinnedSafetyBoundary, + ReviewWarning, + SectionFrame, + StatusMark, + SyntheticMarker, + formatPerthDate, +} from "./prototype-ui"; +import { carePlanRoute } from "./routes"; +import type { + CmhtContact, + PatientSnapshot, + PrototypeOutcome, + PrototypeScenario, + PrototypeUser, + SyntheticId, +} from "./types"; + +const AGE_COHORT_LABEL = { adult: "Adult", older_adult: "Older adult" } as const; + +const OUTCOME_TONE = { success: "success", info: "info", blocked: "warning", error: "danger" } as const; + +function displayName(users: readonly PrototypeUser[], id: SyntheticId | null): string | undefined { + if (id === null) return undefined; + return users.find((user) => user.id === id)?.displayName; +} + +export type PatientWorkspaceProps = { + snapshot: PatientSnapshot; + users: readonly PrototypeUser[]; + scenario: PrototypeScenario; + outcome: PrototypeOutcome | null; + activeSection: PatientSectionKey; + reviewsHref: string; + onRecordContactIntent: (contact: CmhtContact, channel: "email" | "call") => void; + /** Home renders the workspace beside the directory and offers the full record. */ + showFullRecordLink?: boolean; +}; + +/** + * One patient's Clinical Snapshot: who this is, whether the plan is current, the + * five first-minute sections, anything being written that is not yet in use, and + * how to reach the team. + * + * Reading comes first. Task 4 builds no authoring surface at all, so a reader + * without authoring permission sees a clean reading surface rather than a wall of + * controls they cannot use. + */ +export function PatientWorkspace({ + snapshot, + users, + scenario, + outcome, + activeSection, + reviewsHref, + onRecordContactIntent, + showFullRecordLink = false, +}: PatientWorkspaceProps) { + const { + patient, + cmht, + currentManagementVersion, + openManagementDraft, + withdrawnManagementVersion, + currentSafetyPlanVersion, + reviewState, + presentationActivity, + } = snapshot; + + const safetyPlanStatus = + currentSafetyPlanVersion === null + ? "No current version" + : `Current version ${currentSafetyPlanVersion.version}, confirmed ${formatPerthDate(currentSafetyPlanVersion.confirmedAt)}`; + + const planCurrency = + currentManagementVersion !== null + ? `Current version ${currentManagementVersion.version}` + : withdrawnManagementVersion !== null + ? `Withdrawn on ${formatPerthDate(withdrawnManagementVersion.withdrawnAt)}` + : "None in use"; + + // Identity uncertainty is the one state where showing the record is the harm. + // A nearby patient's plan is never displayed as a fallback. + const identityUncertain = scenario === "identity-uncertain"; + + return ( +
+
+ +

{patient.fullName}

+
+ {patient.mrn} + {formatPerthDate(patient.dateOfBirth)} + {AGE_COHORT_LABEL[patient.ageCohort]} + {patient.preferredName} + {patient.pronouns} + {patient.homeHealthService} + {planCurrency} + {safetyPlanStatus} + + {cmht === null + ? undefined + : cmht.verificationState === "verified" + ? `Verified on ${formatPerthDate(cmht.verifiedAt)}` + : `Not verified since ${formatPerthDate(cmht.verifiedAt)}`} + + + {`${presentationActivity.total} recorded in the ${PRESENTATION_ACTIVITY_WINDOW_MONTHS} months to ${formatPerthDate(presentationActivity.windowEnd)}. Counts describe what happened and decide nothing.`} + +
+ {showFullRecordLink ? ( + + {`Open the full record for ${patient.fullName}`} + + ) : null} + +
+ + {identityUncertain ? ( +

+ This record has not been confirmed as the right person. No plan content is shown, because + showing a nearby person’s plan would be worse than showing none. Return to search and choose the record + again. +

+ ) : ( + <> + {currentManagementVersion === null ? null : ( + + )} + + {outcome === null ? null : ( +
+ {outcome.message} +
+ )} + + {currentManagementVersion !== null && reviewState !== null ? ( + + ) : null} + + {currentManagementVersion !== null ? ( + + ) : withdrawnManagementVersion !== null ? ( + +

+ {`Plan withdrawn on ${formatPerthDate(withdrawnManagementVersion.withdrawnAt)} by ${ + displayName(users, withdrawnManagementVersion.withdrawnBy) ?? "an unrecorded clinician" + } — ${withdrawnManagementVersion.withdrawalReason ?? "No reason was recorded."}`} +

+

+ {`${patient.preferredName} previously had an agreed plan. Superseded and withdrawn versions stay readable in History; no older version has been restored in its place.`} +

+
+ ) : ( + +

No Current Plan

+

+ {`Nothing has been agreed for ${patient.preferredName} in this prototype. Assess and treat as you would for anyone else.`} +

+
+ )} + + {openManagementDraft === null ? null : ( + +
+ + +
+

+ {`This is not a plan in use. ${ + currentManagementVersion === null + ? `There is no Current Plan for ${patient.fullName}, and a version being written is not one.` + : `Current version ${currentManagementVersion.version} remains in use until this version is approved.` + }`} +

+

{openManagementDraft.revisionReason}

+
+ )} + + {cmht === null ? null : ( + onRecordContactIntent(cmht, channel)} + /> + )} + + )} +
+ ); +} diff --git a/src/components/care-plan/mockups/prototype-ui.tsx b/src/components/care-plan/mockups/prototype-ui.tsx new file mode 100644 index 000000000..fe7d1699a --- /dev/null +++ b/src/components/care-plan/mockups/prototype-ui.tsx @@ -0,0 +1,324 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; + +import { cn, semanticChipTone, type SemanticChipTone } from "@/components/ui-primitives"; + +import styles from "./care-plan.module.css"; +import { SYNTHETIC_DATA_MARKER } from "./fixtures"; +import { + FIRST_MINUTE_CONTENT_KEYS, + type ManagementPlanContent, + type ManagementPlanVersion, + type ParticipationState, + type ReviewState, +} from "./types"; + +/** + * Care Plan-specific compositions of the shared repository primitives. Nothing + * here recreates a button, field, tab, dialog or sheet: those come from + * `@/components/ui/*`. What lives here is the small vocabulary the clinical + * surfaces repeat — a status mark, a labelled fact, a titled region, the + * synthetic marker, the review warning, the pinned safety boundary, and the + * Current Plan summary card itself. + * + * Nothing in this file reads a clock, a network, storage, or a random source. + */ + +/** + * Stated on every view of a plan. A continuity document that does not say this + * can be read as a substitute for assessing the person in front of you, which is + * the single most consequential way a plan like this causes harm. + */ +export const PLAN_CONTINUITY_BOUNDARY = + "This plan supports continuity. It never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations."; + +/** The five first-minute headings, in the one approved order. */ +export const FIRST_MINUTE_SECTION_LABEL: Record<(typeof FIRST_MINUTE_CONTENT_KEYS)[number], string> = { + howToApproach: "How to approach this person", + whatHelps: "What helps", + whatMakesItWorse: "What makes it worse", + agreedEdApproach: "What we have agreed to do", + whatWouldMakeThisDifferent: "What would make this presentation different", +}; + +export const FIRST_MINUTE_SECTION_ID_PREFIX = "care-plan-first-minute"; + +export function firstMinuteSectionId(key: (typeof FIRST_MINUTE_CONTENT_KEYS)[number]): string { + return `${FIRST_MINUTE_SECTION_ID_PREFIX}-${key}`; +} + +/** + * Australian date, from the Perth-local ISO strings the fixtures already carry. + * The date part of a `+08:00` timestamp is already the Perth wall-clock date, so + * this needs no clock, no locale table, and no timezone library. An unreadable + * value returns `Not recorded` rather than a plausible-looking wrong date. + */ +export function formatPerthDate(iso: string | null): string { + if (iso === null) return NOT_RECORDED; + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + if (match === null) return NOT_RECORDED; + return `${match[3]}/${match[2]}/${match[1]}`; +} + +export const NOT_RECORDED = "Not recorded"; + +export const REVIEW_STATE_LABEL: Record = { + within_review: "Within review", + due_soon: "Review due soon", + overdue: "Review overdue", +}; + +const REVIEW_STATE_TONE: Record = { + within_review: "success", + due_soon: "warning", + overdue: "danger", +}; + +export const MANAGEMENT_VERSION_STATE_LABEL: Record = { + draft: "Draft", + awaiting_approval: "Awaiting Approval", + current: "Current", + superseded: "Superseded", + withdrawn: "Withdrawn", +}; + +/** + * A small labelled status mark. The label is always words: colour never carries + * the state on its own, so the mark reads identically in forced colours, in + * greyscale print, and to a reader who cannot distinguish the tones. + */ +export function StatusMark({ + tone = "neutral", + label, + className, +}: { + tone?: SemanticChipTone; + label: string; + className?: string; +}) { + return {label}; +} + +/** One labelled fact inside a `
`. Never omitted when empty: an absent value + * is shown as `Not recorded` so a reader can tell "nothing here" from "not + * asked". */ +export function DefinitionRow({ term, children }: { term: string; children?: ReactNode }) { + return ( +
+
{term}
+
{children ?? NOT_RECORDED}
+
+ ); +} + +export type SectionFrameProps = { + /** Used to build the heading id the region is labelled by. */ + id: string; + heading: string; + headingLevel?: 2 | 3; + /** Small supporting line under the heading, before the content. */ + description?: ReactNode; + /** Route-owned controls belonging to this region, rendered beside the heading. */ + actions?: ReactNode; + tone?: "default" | "boundary" | "secondary"; + testId?: string; + className?: string; + children: ReactNode; +}; + +/** A titled landmark region. Every clinical block on the snapshot is one of + * these, so the page is navigable by region as well as by heading. */ +export function SectionFrame({ + id, + heading, + headingLevel = 2, + description, + actions, + tone = "default", + testId, + className, + children, +}: SectionFrameProps) { + const Heading = headingLevel === 2 ? "h2" : "h3"; + return ( +
+
+ + {heading} + + {actions ?
{actions}
: null} +
+ {description ?

{description}

: null} + {children} +
+ ); +} + +/** The standing fictional-data statement. Repeated on the patient workspace + * because a workspace is what gets printed and carried away from the screen. */ +export function SyntheticMarker({ className }: { className?: string }) { + return {SYNTHETIC_DATA_MARKER}; +} + +/** + * The review-currency warning. An overdue or nearly-due plan stays fully + * readable: the warning sits above the content and never replaces, collapses or + * downgrades it, because a plan that is late for review is still the plan the + * team agreed. + */ +export function ReviewWarning({ reviewState, reviewDueAt }: { reviewState: ReviewState; reviewDueAt: string | null }) { + if (reviewState === "within_review") return null; + const overdue = reviewState === "overdue"; + return ( +

+ {REVIEW_STATE_LABEL[reviewState]}.{" "} + {overdue + ? `This plan was due for review on ${formatPerthDate(reviewDueAt)}. It remains the Current Plan and is still the agreed approach; arrange a review.` + : `This plan is due for review on ${formatPerthDate(reviewDueAt)}.`} +

+ ); +} + +const PARTICIPATION_MARKER_STATES: readonly ParticipationState[] = ["declined", "patient_unavailable"]; + +/** + * A version may be approved without the person taking part — sometimes a plan + * has to be written for someone who cannot or will not engage. It is never + * invisible that this happened, on any view, print, or queue entry. + */ +export function ParticipationMarker({ participationState }: { participationState: ParticipationState }) { + if (!PARTICIPATION_MARKER_STATES.includes(participationState)) return null; + return ; +} + +/** + * The pinned form of section 5, rendered directly beneath the patient identity + * block and above every other plan element, at every viewport and in print. + * + * On a phone the five sections are a long card and a hurried reader stops before + * the end — which is exactly the reader this section exists for. The pinned line + * links to the full section; it never replaces it, and the full section is never + * collapsed, truncated, or placed behind a disclosure. + */ +export function PinnedSafetyBoundary({ content }: { content: ManagementPlanContent }) { + const count = content.whatWouldMakeThisDifferent.length; + return ( + + ); +} + +function ContentList({ items }: { items: readonly string[] }) { + if (items.length === 0) return

{NOT_RECORDED}

; + return ( +
    + {items.map((item) => ( +
  • {item}
  • + ))} +
+ ); +} + +export type CurrentPlanSummaryProps = { + version: ManagementPlanVersion; + ownerName: string; + approverName: string | null; + reviewState: ReviewState | null; + cmhtName: string | null; + cmhtOperatingHours: string | null; + safetyPlanHref: string; + safetyPlanStatus: string; +}; + +/** + * The whole Current Plan summary card: the five first-minute sections, in the + * one approved order, and everything else presented as metadata rather than as a + * sixth section. The five are generated from `FIRST_MINUTE_CONTENT_KEYS`, so the + * card cannot drift from the domain vocabulary or quietly grow a sixth field. + * + * Section 5 is visually distinct and is never collapsed, truncated, clipped, or + * placed behind a disclosure. + */ +export function CurrentPlanSummary({ + version, + ownerName, + approverName, + reviewState, + cmhtName, + cmhtOperatingHours, + safetyPlanHref, + safetyPlanStatus, +}: CurrentPlanSummaryProps) { + return ( + +
+
+ + {reviewState === null ? null : ( + + )} + +
+
+ {ownerName} + {approverName ?? undefined} + {formatPerthDate(version.approvedAt)} + {formatPerthDate(version.reviewDueAt)} + + {cmhtName === null ? undefined : `${cmhtName}${cmhtOperatingHours ? ` — ${cmhtOperatingHours}` : ""}`} + + + + Personal Safety Plan + + {` — ${safetyPlanStatus}`} + +
+

{PLAN_CONTINUITY_BOUNDARY}

+
+ +
+ {FIRST_MINUTE_CONTENT_KEYS.map((key, index) => ( +
+

+ {`${index + 1}. ${FIRST_MINUTE_SECTION_LABEL[key]}`} +

+ +
+ ))} +
+
+ ); +} diff --git a/src/components/care-plan/mockups/routable-suite.tsx b/src/components/care-plan/mockups/routable-suite.tsx index 7bc60c01a..00c87569c 100644 --- a/src/components/care-plan/mockups/routable-suite.tsx +++ b/src/components/care-plan/mockups/routable-suite.tsx @@ -5,8 +5,9 @@ import { useMemo } from "react"; import { CarePlanShellFrame } from "./care-plan-shell-frame"; import styles from "./care-plan.module.css"; +import { ClinicalSnapshotSurface, type ClinicalSnapshotVariant } from "./clinical-snapshot-page"; import { useCarePlanPrototype } from "./prototype-provider"; -import { CARE_PLAN_BASE, CARE_PLAN_ROUTES, type CarePlanDestination } from "./routes"; +import { CARE_PLAN_BASE, CARE_PLAN_ROUTES, isSyntheticPatientId, type CarePlanDestination } from "./routes"; import type { PrototypeScenario } from "./types"; /** @@ -250,6 +251,33 @@ function RoutePurposeSurface({ purpose }: { purpose: string }) { ); } +/** + * The synthetic patient a patient-scoped address names, or `null` when the + * address names none or names one the fixtures do not contain. The page files + * already refuse an unknown parameter with `notFound()`; this is the same check + * again on the reading side, so a hand-typed address can never make a surface + * ask the state for a record that does not exist. + */ +export function carePlanPatientIdFromPathname(pathname: string): string | null { + const trimmed = pathname.split("?")[0] ?? pathname; + if (!trimmed.startsWith(CARE_PLAN_BASE)) return null; + const segments = trimmed.slice(CARE_PLAN_BASE.length).split("/").filter(Boolean); + if (segments[0] !== "patients") return null; + const candidate = segments[1]; + return candidate !== undefined && isSyntheticPatientId(candidate) ? candidate : null; +} + +/** + * The three routes Task 4 gave real content. Each owns a Clinical Snapshot + * variant instead of the Task 3 purpose surface, and Home and Patients own their + * own directory search rather than borrowing the shell composer. + */ +const SNAPSHOT_VARIANT_BY_ROUTE_KEY: Partial> = { + home: "home", + patients: "patients", + patient: "patient", +}; + export type CarePlanRouteSurfaceProps = { pathname: string; query?: string; @@ -265,6 +293,8 @@ export function CarePlanRouteSurface({ pathname, query = "", navigate }: CarePla const route = useMemo(() => resolveCarePlanRoute(pathname), [pathname]); const scenario = useMemo(() => scenarioFromQuery(query), [query]); const activeUser = state.users.find((user) => user.id === state.activeUserId); + const snapshotVariant = SNAPSHOT_VARIANT_BY_ROUTE_KEY[route.key]; + const patientId = useMemo(() => carePlanPatientIdFromPathname(pathname), [pathname]); return ( navigate(CARE_PLAN_ROUTES.patients)} + // Home and Patients own an in-flow directory search, so the shell stands + // its own composer down rather than putting two search fields on one page. + routeOwnsSearch={snapshotVariant === "home" || snapshotVariant === "patients"} > - + {snapshotVariant === undefined ? ( + + ) : ( + + )} ); } diff --git a/tests/care-plan-linked-routes.dom.test.tsx b/tests/care-plan-linked-routes.dom.test.tsx index 1af5b1e15..3fca62688 100644 --- a/tests/care-plan-linked-routes.dom.test.tsx +++ b/tests/care-plan-linked-routes.dom.test.tsx @@ -70,6 +70,9 @@ describe("Care Plan route shell", () => { expect(headingAfter).toHaveFocus(); }); + // Exercised on two shell-owned routes. Home and Patients own an in-flow + // directory search of their own from Task 4 onwards, so the shell composer is + // not rendered there and there is never a second search field on one page. it("keeps the typed search term across a navigation because the shell persists", async () => { const user = userEvent.setup(); const navigate = vi.fn(); @@ -78,10 +81,10 @@ describe("Care Plan route shell", () => { ); - const { rerender } = render(surface(CARE_PLAN_ROUTES.home)); + const { rerender } = render(surface(CARE_PLAN_ROUTES.reviews)); await user.type(screen.getByRole("searchbox", { name: "Search patients" }), "Rowan"); - rerender(surface(CARE_PLAN_ROUTES.patients)); + rerender(surface(CARE_PLAN_ROUTES.team)); expect(screen.getByRole("searchbox", { name: "Search patients" })).toHaveValue("Rowan"); }); @@ -212,10 +215,10 @@ describe("Care Plan route shell", () => { ).toHaveAttribute("aria-current", "page"); }); + // Home, Patients and the patient Overview are deliberately absent: Task 4 + // replaced their purpose surface with the real Clinical Snapshot, and the + // block below asserts they no longer render a purpose surface at all. it.each([ - [CARE_PLAN_ROUTES.home, "Home", "Search-first Home and Clinical Snapshot"], - [CARE_PLAN_ROUTES.patients, "Patients", "Full patient directory and presentation-activity view"], - [CARE_PLAN_ROUTES.patient, "Patient overview", "Patient overview and first-minute snapshot"], [ CARE_PLAN_ROUTES.managementPlan, "Management Plan", @@ -283,7 +286,7 @@ describe("Care Plan route shell", () => { it("offers exactly one search slot and navigates it without putting record content in the URL", async () => { const user = userEvent.setup(); - const navigate = renderRoute(CARE_PLAN_ROUTES.home); + 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"); @@ -313,6 +316,389 @@ describe("Care Plan route shell", () => { expect(screen.queryByTitle(/coming soon/i)).toBeNull(); expect(document.querySelector("[aria-disabled='true']")).toBeNull(); }); + + it("replaces the route-purpose surface on the three Clinical Snapshot routes", () => { + for (const route of [CARE_PLAN_ROUTES.home, CARE_PLAN_ROUTES.patients, CARE_PLAN_ROUTES.patient]) { + const { unmount } = render( + + + , + ); + expect(screen.queryByTestId("care-plan-route-purpose"), `${route} still shows a purpose surface`).toBeNull(); + unmount(); + } + }); +}); + +describe("Care Plan patient directory", () => { + async function searchAndOpen(query: string, openName: RegExp) { + const user = userEvent.setup(); + renderRoute(CARE_PLAN_ROUTES.home); + await user.type(screen.getByRole("searchbox", { name: "Search synthetic patients" }), query); + await user.click(screen.getByRole("button", { name: openName })); + return user; + } + + // The brief's worked example, corrected against the fixtures: Mira's plan + // carries Current version 1 and Awaiting Approval version 2, not 2 and 3. + it("finds a synthetic patient and keeps Current Plan above an awaiting draft", async () => { + const user = userEvent.setup(); + renderRoute(CARE_PLAN_ROUTES.home, "scenario=overdue-plan"); + await user.type(screen.getByRole("searchbox", { name: "Search synthetic patients" }), "SYN-MRN-0002"); + await user.click(screen.getByRole("button", { name: /Open Mira Example/i })); + + const workspace = screen.getByRole("region", { name: "Mira Example clinical snapshot" }); + expect(within(workspace).getByRole("heading", { level: 2, name: "Current Plan" })).toBeInTheDocument(); + expect(within(workspace).getByText(/Awaiting Approval version 2/i)).toBeInTheDocument(); + expect(within(workspace).getByText(/Current version 1 remains in use/i)).toBeInTheDocument(); + + // Hierarchy, not merely presence: the awaiting version must never be able to + // drift above the version a clinician is meant to act on. + const current = within(workspace).getByRole("heading", { level: 2, name: "Current Plan" }); + const awaiting = within(workspace).getByText(/Awaiting Approval version 2/i); + expect(current.compareDocumentPosition(awaiting) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it.each([ + ["full name", "Mira Example"], + ["preferred name", "Mira"], + ["alias", "Mira Example-Hale"], + ["MRN", "SYN-MRN-0002"], + ["ISO date of birth", "1948-09-22"], + ["displayed date of birth", "22/09/1948"], + ])("searches the supported %s identity field", async (_label, query) => { + const user = userEvent.setup(); + renderRoute(CARE_PLAN_ROUTES.home); + await user.type(screen.getByRole("searchbox", { name: "Search synthetic patients" }), query); + expect(screen.getByRole("button", { name: /Open Mira Example/i })).toBeInTheDocument(); + }); + + it("does not search plan or presentation content", async () => { + const user = userEvent.setup(); + renderRoute(CARE_PLAN_ROUTES.home); + // A distinctive phrase from Mira's Current Plan content. Identity search + // must not become a full-text search of clinical text. + await user.type(screen.getByRole("searchbox", { name: "Search synthetic patients" }), "hearing aids"); + expect(screen.queryByRole("button", { name: /Open Mira Example/i })).toBeNull(); + expect(screen.getByTestId("care-plan-directory-no-results")).toBeInTheDocument(); + }); + + it("shows deterministic no-results content and no patient rows", async () => { + const user = userEvent.setup(); + renderRoute(CARE_PLAN_ROUTES.home); + await user.type(screen.getByRole("searchbox", { name: "Search synthetic patients" }), "SYN-MRN-9999"); + const results = screen.getByTestId("care-plan-directory-results"); + expect(within(results).queryAllByRole("button", { name: /^Open / })).toEqual([]); + const empty = screen.getByTestId("care-plan-directory-no-results"); + expect(empty).toHaveTextContent("No synthetic patient matches SYN-MRN-9999"); + expect(empty).toHaveTextContent(/Check the MRN, or try a different spelling/i); + }); + + it("offers recent patients before anything is typed", () => { + renderRoute(CARE_PLAN_ROUTES.home); + expect(screen.getByRole("heading", { name: "Recent patients" })).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /^Open / }).length).toBeGreaterThan(0); + }); + + it("states the lookback window and that counts decide nothing", () => { + renderRoute(CARE_PLAN_ROUTES.home); + const directory = screen.getByRole("region", { name: "Synthetic patient directory" }); + expect(within(directory).getByText(/in the 12 months to 20\/08\/2026/i)).toBeInTheDocument(); + expect( + within(directory).getByText( + /Counts describe what happened\. They do not determine eligibility for a Management Plan\./i, + ), + ).toBeInTheDocument(); + }); + + // Ranking everyone by attendance is the banned label without the word. It + // exists only inside Identification Review, which is not this surface. + it("offers no way to sort or rank the directory by presentation count", () => { + renderRoute(CARE_PLAN_ROUTES.home); + const directory = screen.getByRole("region", { name: "Synthetic patient directory" }); + expect(within(directory).queryAllByRole("button", { name: /sort|rank|most|highest/i })).toEqual([]); + expect(within(directory).queryAllByRole("combobox")).toEqual([]); + expect(within(directory).queryAllByRole("columnheader")).toEqual([]); + }); + + it("offers a manual Identification Review entry point rather than an automatic rule", () => { + renderRoute(CARE_PLAN_ROUTES.home); + const directory = screen.getByRole("region", { name: "Synthetic patient directory" }); + expect(within(directory).getByRole("link", { name: /Refer someone for Identification Review/i })).toHaveAttribute( + "href", + CARE_PLAN_ROUTES.reviews, + ); + }); + + it("keeps exactly one search field on Home, owned by the directory", () => { + renderRoute(CARE_PLAN_ROUTES.home); + expect(screen.getAllByRole("searchbox")).toHaveLength(1); + expect(screen.getByRole("searchbox", { name: "Search synthetic patients" })).toBeInTheDocument(); + }); + + it("keeps the whole snapshot free of unavailable authoring controls", async () => { + await searchAndOpen("SYN-MRN-0002", /Open Mira Example/i); + expect(screen.queryByTitle(/coming soon/i)).toBeNull(); + expect(document.querySelector("[aria-disabled='true']")).toBeNull(); + }); +}); + +describe("Care Plan clinical snapshot", () => { + const FIRST_MINUTE_HEADINGS = [ + "1. How to approach this person", + "2. What helps", + "3. What makes it worse", + "4. What we have agreed to do", + "5. What would make this presentation different", + ]; + + it("renders exactly the five first-minute sections, in order", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + const sections = screen.getByTestId("care-plan-first-minute-sections"); + expect( + within(sections) + .getAllByRole("heading", { level: 3 }) + .map((node) => node.textContent), + ).toEqual(FIRST_MINUTE_HEADINGS); + }); + + // Presence is not enough. The specification requires section 5 to be visually + // distinct from the other four and never collapsed, truncated, clipped, or put + // behind a disclosure — all of which leave it in the DOM. + it("keeps the fifth section visually distinct and never collapsed, truncated or hidden", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + const boundary = screen + .getByRole("heading", { level: 3, name: "5. What would make this presentation different" }) + .closest("section"); + const ordinary = screen.getByRole("heading", { level: 3, name: "2. What helps" }).closest("section"); + expect(boundary).not.toBeNull(); + expect(ordinary).not.toBeNull(); + + const boundaryClasses = (boundary?.className ?? "").split(/\s+/).filter(Boolean); + const ordinaryClasses = (ordinary?.className ?? "").split(/\s+/).filter(Boolean); + const distinguishing = boundaryClasses.filter((token) => !ordinaryClasses.includes(token)); + expect(distinguishing.length, "section 5 must carry a treatment the other four do not").toBeGreaterThan(0); + + // …and that treatment must not be a way of hiding or shortening it. + for (const suppression of [/^sr-only$/, /^hidden$/, /^truncate$/, /line-clamp/, /^max-h-/, /^overflow-hidden$/]) { + for (const token of boundaryClasses) { + expect(token, `section 5 must not be suppressed by ${suppression}`).not.toMatch(suppression); + } + } + expect(boundary?.closest("details"), "section 5 must not sit behind a disclosure").toBeNull(); + expect(boundary?.closest("[hidden]")).toBeNull(); + expect(boundary?.getAttribute("aria-hidden")).toBeNull(); + }); + + it("uses none of the removed nineteen-field vocabulary on the summary card", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + const card = screen.getByRole("region", { name: "Current Plan" }); + for (const removed of [ + /preferred engagement/i, + /may increase distress/i, + /immediate continuity considerations/i, + /helpful interventions/i, + /usual presentation pattern/i, + ]) { + expect(card.textContent ?? "", `${removed} is a removed field name`).not.toMatch(removed); + } + }); + + it("pins the safety boundary beneath identity, above all plan content, and keeps it in print", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + const identity = screen.getByTestId("care-plan-identity-band"); + const pinned = screen.getByTestId("care-plan-pinned-safety-boundary"); + const card = screen.getByRole("region", { name: "Current Plan" }); + + expect(identity.compareDocumentPosition(pinned) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(pinned.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(pinned.closest("[data-print-hide='true']")).toBeNull(); + // It links to the full section; it never replaces it. + expect(within(pinned).getByRole("link", { name: /What would make this presentation different/i })).toHaveAttribute( + "href", + "#care-plan-first-minute-whatWouldMakeThisDifferent", + ); + expect( + screen.getByRole("heading", { level: 3, name: "5. What would make this presentation different" }), + ).toHaveAttribute("id", "care-plan-first-minute-whatWouldMakeThisDifferent"); + }); + + it("states the fresh-assessment boundary on every view of the plan", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + expect( + within(screen.getByRole("region", { name: "Current Plan" })).getByText( + "This plan supports continuity. It never replaces fresh triage, physical assessment, mental-state assessment, immediate risk assessment, clinical judgement, or legal obligations.", + ), + ).toBeInTheDocument(); + }); + + it("presents version, owner, approver, dates and links as metadata", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + const metadata = screen.getByTestId("care-plan-current-plan-metadata"); + expect(metadata).toHaveTextContent("Current version 2"); + expect(metadata).toHaveTextContent("Morgan Sample"); + expect(metadata).toHaveTextContent("Dr Taylor Fiction"); + expect(metadata).toHaveTextContent("20/05/2026"); + expect(metadata).toHaveTextContent("20/05/2027"); + expect(metadata).toHaveTextContent("Within review"); + expect(metadata).toHaveTextContent("North River CMHT"); + expect(within(metadata).getByRole("link", { name: /Personal Safety Plan/i })).toHaveAttribute( + "href", + carePlanRoute.safetyPlan("SYN-PATIENT-001"), + ); + // Metadata is not a sixth content section. + expect(within(metadata).queryAllByRole("heading", { level: 3 })).toEqual([]); + }); + + it("derives an overdue review from the date and keeps the content readable below the warning", () => { + renderRoute(carePlanRoute.patient("SYN-PATIENT-002")); + const warning = screen.getByTestId("care-plan-review-warning"); + expect(warning).toHaveTextContent(/Review overdue/i); + expect(warning).toHaveTextContent("16/07/2026"); + const card = screen.getByRole("region", { name: "Current Plan" }); + expect(warning.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(card).getByText(/check that Mira has her hearing aids in/i)).toBeInTheDocument(); + }); + + it("says No Current Plan and keeps a Draft visibly separate without promoting it", () => { + renderRoute(carePlanRoute.patient("SYN-PATIENT-005")); + const workspace = screen.getByRole("region", { name: "Alex Fiction clinical snapshot" }); + expect(within(workspace).getByText("No Current Plan")).toBeInTheDocument(); + expect(within(workspace).queryByRole("region", { name: "Current Plan" })).toBeNull(); + const draft = within(workspace).getByRole("region", { name: "Version in progress" }); + expect(draft).toHaveTextContent(/Draft version 1/i); + expect(draft).toHaveTextContent(/This is not a plan in use\./i); + expect(within(draft).queryByRole("heading", { level: 2, name: "Current Plan" })).toBeNull(); + }); + + it("names the withdrawal date, clinician and reason instead of a bare No Current Plan", () => { + renderRoute(carePlanRoute.patient("SYN-PATIENT-004")); + const withdrawn = screen.getByTestId("care-plan-withdrawn-notice"); + expect(withdrawn).toHaveTextContent("Plan withdrawn on 04/07/2026 by Dr Taylor Fiction"); + expect(withdrawn).toHaveTextContent(/Evelyn's circumstances have changed substantially/i); + expect(screen.queryByText("No Current Plan")).toBeNull(); + }); + + it("marks a version written without the person's involvement", () => { + renderRoute(carePlanRoute.patient("SYN-PATIENT-005")); + expect( + within(screen.getByRole("region", { name: "Version in progress" })).getByText( + "Written without this person's involvement", + ), + ).toBeInTheDocument(); + }); + + it("keeps identity and currency facts visible in the identity band", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + const band = screen.getByTestId("care-plan-identity-band"); + expect(band).toHaveTextContent("Rowan Sample"); + expect(band).toHaveTextContent("SYN-MRN-0001"); + expect(band).toHaveTextContent("12/04/1986"); + expect(band).toHaveTextContent("Adult"); + expect(band).toHaveTextContent("Rowan"); + expect(band).toHaveTextContent("they/them"); + expect(band).toHaveTextContent("North River Health Service"); + expect(band).toHaveTextContent(/Current Plan.*version 2/i); + expect(band).toHaveTextContent(/Personal Safety Plan/i); + expect(band).toHaveTextContent(/verified/i); + expect(band).toHaveTextContent(/in the 12 months to 20\/08\/2026/i); + expect(within(band).getByText("Synthetic prototype — fictional people, teams, and hospitals")).toBeInTheDocument(); + }); + + // Never display a nearby patient's plan as a fallback. + it("refuses to show plan content when the record is not confirmed as the right person", () => { + renderRoute(CARE_PLAN_ROUTES.patient, "scenario=identity-uncertain"); + expect(screen.queryByRole("region", { name: "Current Plan" })).toBeNull(); + expect(screen.getByTestId("care-plan-identity-uncertain")).toHaveTextContent(/Return to search/i); + }); + + it("links the four primary patient sections and History from the selected patient", () => { + renderRoute(carePlanRoute.patient("SYN-PATIENT-002")); + const nav = screen.getByRole("navigation", { name: "Patient sections" }); + for (const [label, href] of [ + ["Overview", carePlanRoute.patient("SYN-PATIENT-002")], + ["Management Plan", carePlanRoute.managementPlan("SYN-PATIENT-002")], + ["Personal Safety Plan", carePlanRoute.safetyPlan("SYN-PATIENT-002")], + ["ED Presentations", carePlanRoute.presentations("SYN-PATIENT-002")], + ["History", carePlanRoute.history("SYN-PATIENT-002")], + ] as const) { + expect(within(nav).getByRole("link", { name: label })).toHaveAttribute("href", href); + } + expect(within(nav).getByRole("link", { name: "Overview" })).toHaveAttribute("aria-current", "page"); + }); +}); + +describe("Care Plan CMHT contact actions", () => { + it("exposes only intent-safe CMHT launch links", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + expect(screen.getByRole("link", { name: "Email North River CMHT" })).toHaveAttribute( + "href", + "mailto:north-river.cmht@example.org?subject=Care+Plan+%E2%80%94+team+contact+request", + ); + expect(screen.getByRole("link", { name: "Call North River CMHT" })).toHaveAttribute("href", "tel:+61491570101"); + }); + + it("puts no patient information in the mailto", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + const href = screen.getByRole("link", { name: "Email North River CMHT" }).getAttribute("href") ?? ""; + for (const forbidden of ["Rowan", "Sample", "SYN-MRN-0001", "1986-04-12", "12/04/1986", "SYN-PATIENT-001"]) { + expect(href.toLowerCase(), `${forbidden} must not travel in a mailto`).not.toContain(forbidden.toLowerCase()); + } + expect(href).not.toMatch(/[?&]body=/); + }); + + it("shows the displayed contact details, hours, coordinator and after-hours route", () => { + renderRoute(CARE_PLAN_ROUTES.patient); + const contacts = screen.getByRole("region", { name: "Community mental health team" }); + expect(contacts).toHaveTextContent("north-river.cmht@example.org"); + expect(contacts).toHaveTextContent("0491 570 101"); + expect(contacts).toHaveTextContent("Monday to Friday, 8:30 am to 5:00 pm AWST"); + expect(contacts).toHaveTextContent("Sam Placeholder"); + expect(contacts).toHaveTextContent("1300 555 788"); + expect(contacts).toHaveTextContent("30/07/2026"); + }); + + it("records a contact intent and claims only that an application was requested", async () => { + const user = userEvent.setup(); + renderRoute(CARE_PLAN_ROUTES.patient); + await user.click(screen.getByRole("link", { name: "Email North River CMHT" })); + const outcome = screen.getByTestId("care-plan-outcome"); + expect(outcome).toHaveTextContent( + "An email application was asked to open. This prototype records only that request, not what happens next.", + ); + for (const overclaim of [/sent/i, /delivered/i, /received/i, /replied/i, /notified/i]) { + expect(outcome.textContent ?? "").not.toMatch(overclaim); + } + }); + + 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" }); + // Details stay visible: withholding them would send nobody anywhere. + expect(contacts).toHaveTextContent("wandoo-district.cmht@example.org"); + expect(within(contacts).getByRole("link", { name: "Call Wandoo District CMHT" })).toHaveAttribute( + "href", + "tel:+61491570121", + ); + const warning = within(contacts).getByTestId("care-plan-contact-verification-warning"); + expect(warning).toHaveTextContent(/have not been verified/i); + expect(warning).toHaveTextContent("20/01/2025"); + expect(warning.textContent ?? "").not.toMatch(/available now|currently available|reachable/i); + expect(within(warning).getByRole("link", { name: /Reviews/i })).toHaveAttribute("href", CARE_PLAN_ROUTES.reviews); + }); + + it("keeps details visible and explains a launch failure in three parts", async () => { + const user = userEvent.setup(); + renderRoute(CARE_PLAN_ROUTES.patient, "scenario=launch-failure"); + await user.click(screen.getByRole("link", { name: "Email North River CMHT" })); + const failure = screen.getByTestId("care-plan-launch-failure"); + expect(failure).toHaveTextContent(/could not be opened/i); + expect(failure).toHaveTextContent(/Nothing was sent/i); + expect(failure).toHaveTextContent(/north-river\.cmht@example\.org/); + // The contact details themselves are still on screen. + const contacts = screen.getByRole("region", { name: "Community mental health team" }); + expect(within(contacts).getByRole("link", { name: "Call North River CMHT" })).toBeInTheDocument(); + }); }); describe("Care Plan error boundary", () => { From 773da7bf3d576926c7c52fbb8fb4d02dd11e720f Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:45:57 +0800 Subject: [PATCH 28/56] fix(care-plan): make the snapshot's safety guards able to fail --- .../care-plan/mockups/care-plan.module.css | 12 ++ .../mockups/clinical-snapshot-page.tsx | 35 +++- .../care-plan/mockups/patient-navigation.tsx | 8 +- .../care-plan/mockups/patient-workspace.tsx | 25 ++- .../care-plan/mockups/prototype-ui.tsx | 53 ++++-- tests/care-plan-linked-routes.dom.test.tsx | 169 +++++++++++++++++- tests/care-plan-route-files.test.ts | 126 +++++++++++++ 7 files changed, 403 insertions(+), 25 deletions(-) diff --git a/src/components/care-plan/mockups/care-plan.module.css b/src/components/care-plan/mockups/care-plan.module.css index 174e9203f..e6cd03826 100644 --- a/src/components/care-plan/mockups/care-plan.module.css +++ b/src/components/care-plan/mockups/care-plan.module.css @@ -578,6 +578,17 @@ font-style: italic; } +/* + * The Current Plan card is the one thing on this screen a clinician is meant to + * act on, so it carries a stronger edge than the ordinary section frame and than + * the inset frame a version-in-progress gets. The hierarchy is not carried by + * colour alone: every state on this card is also a worded status mark. + */ +.appRoot .currentPlanCard { + border-color: var(--clinical-accent-border); + box-shadow: var(--e1); +} + .appRoot .metadataBlock { display: grid; gap: 0.625rem; @@ -767,6 +778,7 @@ } .appRoot .sectionFrame, + .appRoot .currentPlanCard, .appRoot .identityBand, .appRoot .pinnedBoundary, .appRoot .firstMinuteSectionBoundary { diff --git a/src/components/care-plan/mockups/clinical-snapshot-page.tsx b/src/components/care-plan/mockups/clinical-snapshot-page.tsx index 91bd294c4..5f244232b 100644 --- a/src/components/care-plan/mockups/clinical-snapshot-page.tsx +++ b/src/components/care-plan/mockups/clinical-snapshot-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { EmptyState } from "@/components/ui-primitives"; @@ -56,6 +56,34 @@ export function ClinicalSnapshotSurface({ dispatch({ type: "record-contact-intent", patientId: snapshot.patient.id, cmhtId: contact.id, channel }); } + const workspaceRef = useRef(null); + const hasSettled = useRef(false); + const directorySurface = variant !== "patient"; + + // Choosing a patient from the directory changes no address, so the shell's + // route-heading focus never fires. Without this the workspace appears in the + // next column on a desktop, and below the whole directory list on a phone, + // with no focus move and nothing announced. Focusing the region announces its + // accessible name — the same mechanism the shell uses for the route heading, + // rather than a second live region that would double-announce. + // + // The first pass moves nothing: on mount the shell owns focus, and stealing it + // would make every page load jump past the heading that says where you are. + // + // The effect's dependency on `resolvedPatientId` is what makes it a + // selection-change effect rather than a render effect — an extra + // "did the id change" guard inside the body would be unreachable, and a + // positive control confirmed removing one changed no behaviour at all. + useEffect(() => { + if (!directorySurface) return; + if (!hasSettled.current) { + hasSettled.current = true; + return; + } + if (resolvedPatientId === null) return; + workspaceRef.current?.focus(); + }, [directorySurface, resolvedPatientId]); + const workspace = snapshot === null ? ( ) : ( ` as the address the reader is already on, which it is not. + */ export function PatientNavigation({ patientId, activeSection, }: { patientId: string; - activeSection: PatientSectionKey; + activeSection: PatientSectionKey | null; }) { return ( + {/* + The displayed clinician, and the control that changes which one is + displayed. It is interaction modelling: it explains why an action is + offered on the surfaces below, and the reducer rechecks the role on + every change either way. It authenticates nobody and protects nothing. + */}

{activeUser.displayName}

{activeUser.title}

+ setValues((previous) => ({ ...previous, ownerId: event.target.value }))} + options={state.users.map((user) => ({ + value: user.id, + label: `${user.displayName} — ${PROTOTYPE_ROLE_LABEL[user.role]}`, + }))} + /> + setValues((previous) => ({ ...previous, reviewDate: event.target.value }))} + /> +