diff --git a/docs/branch-review-records/15df886bae0a49c72881baed6a778a36a808d0e509b1e14d099c299ec5e1e334.record.md b/docs/branch-review-records/15df886bae0a49c72881baed6a778a36a808d0e509b1e14d099c299ec5e1e334.record.md new file mode 100644 index 000000000..425302e48 --- /dev/null +++ b/docs/branch-review-records/15df886bae0a49c72881baed6a778a36a808d0e509b1e14d099c299ec5e1e334.record.md @@ -0,0 +1 @@ +| 2026-08-14 | codex/therapy-global-convergence-20260814 | 0011a058fd1d68e7fb98f3411d505b71bee104cf | Therapy global mode convergence: navigation, shared shell, search, favourites, sources, print (supersedes 2026-08-14) | All three P2 findings resolved; no remaining Therapy-scoped review findings. Additional favourites-map, cross-mode search, shared-switch target, and stale-contract regressions corrected. Broad gate remains partial only for Docker-gated drift manifest and unchanged baseline checks. | typecheck PASS; focused Vitest 221 PASS plus repair contracts 68 PASS; phone contracts 129 PASS; focused phone 13 PASS; full UI 423 PASS/4 repaired then focused PASS; Therapy accessibility PASS; lint/docs/ledger PASS; RAG fixtures PASS; medication index PASS; drift manifest BLOCKED Docker unavailable; unchanged session hook and lexicon baseline failures remain | diff --git a/docs/branch-review-records/24698bda67158357da68ef84d907b338f132a96e1006edbe2c5a8b7fc5f035ea.record.md b/docs/branch-review-records/24698bda67158357da68ef84d907b338f132a96e1006edbe2c5a8b7fc5f035ea.record.md new file mode 100644 index 000000000..c0eb258e9 --- /dev/null +++ b/docs/branch-review-records/24698bda67158357da68ef84d907b338f132a96e1006edbe2c5a8b7fc5f035ea.record.md @@ -0,0 +1 @@ +| 2026-08-16 | codex/therapy-global-convergence-20260814 | 9e21ea498fde13e98a9cd749dae16aba0b4c83ab | PR #1992 unblocking review-and-fix | P1/P2 PR regressions fixed: clear/write ordering, failed Therapy retry intent, offline-only route verification, hidden-mode canonicalisation, and global bundle leak; stale-load finding pre-existing | exact-head CI diagnosis; TS/TSX transpile 8 PASS; focused mutation models 3 PASS; static repair contracts 9 PASS; full Node 24 gates delegated to post-push CI; Lighthouse not run by authorization | diff --git a/docs/branch-review-records/2d205ab081f71c1df3d01466069b2308dbb1c7065b10703f617accee66359995.record.md b/docs/branch-review-records/2d205ab081f71c1df3d01466069b2308dbb1c7065b10703f617accee66359995.record.md new file mode 100644 index 000000000..c14d70f9f --- /dev/null +++ b/docs/branch-review-records/2d205ab081f71c1df3d01466069b2308dbb1c7065b10703f617accee66359995.record.md @@ -0,0 +1 @@ +| 2026-08-14 | codex/therapy-global-convergence-20260814 | 0011a058fd1d68e7fb98f3411d505b71bee104cf | Therapy global mode convergence: navigation, shared shell, search, favourites, sources, print | P2 findings: four focused contract failures, missing Therapy tabpanel ownership, stale information-page adoption registry; implementation review otherwise safe and scoped | format:changed PASS; therapy-data-index PASS 205; design-system-contract PASS; focused Vitest 201 PASS/4 FAIL; typecheck BLOCKED by coordinator; phone/full UI UNRUN | diff --git a/docs/branch-review-records/717f0912d48a9b7713ba93f8837614b5689529953f9db376b31bd3cde17b3a95.record.md b/docs/branch-review-records/717f0912d48a9b7713ba93f8837614b5689529953f9db376b31bd3cde17b3a95.record.md new file mode 100644 index 000000000..850a1d018 --- /dev/null +++ b/docs/branch-review-records/717f0912d48a9b7713ba93f8837614b5689529953f9db376b31bd3cde17b3a95.record.md @@ -0,0 +1 @@ +| 2026-08-16 | codex/therapy-global-convergence-20260814 | a985d7cb75ebaacd94df5cf55589e10908334510 | PR #1992 exact-head CI follow-up and base sync | Fixed stale Answer cross-mode suggestions on unsubmitted shared home; incorporated main 8f8d111abf1d302ca94899d15be7843071a8537b | Production UI shard 2 diagnosis; TS/TSX transpile 3 PASS; focused submission-state cases 6 PASS; exact-head Node 24 matrix delegated to post-push CI; Lighthouse not run by authorization | diff --git a/docs/branch-review-records/7879b60be6973aafdc9ee3f5b52b54a5f528e653b6decf4b65799d25e4ed6414.record.md b/docs/branch-review-records/7879b60be6973aafdc9ee3f5b52b54a5f528e653b6decf4b65799d25e4ed6414.record.md new file mode 100644 index 000000000..f8759bc88 --- /dev/null +++ b/docs/branch-review-records/7879b60be6973aafdc9ee3f5b52b54a5f528e653b6decf4b65799d25e4ed6414.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/therapy-global-convergence-20260814 | 1efc557287e05ed7a0d584a23df4505fe7794941 | Therapy global convergence release handoff | Accepted with production content gate; human clinical sign-off and live provider checks remain | focused Therapy 69 pass; format/lint/typecheck/build pass; drift replay pass; verify:pr-local partial from unrelated/baseline failures; live checks blocked missing env | diff --git a/docs/branch-review-records/fc9303a43ee12deed2f6e7bc96f9b216041ad3983f040dbc0dc0f0cf4df50c84.record.md b/docs/branch-review-records/fc9303a43ee12deed2f6e7bc96f9b216041ad3983f040dbc0dc0f0cf4df50c84.record.md new file mode 100644 index 000000000..4d52bdc3a --- /dev/null +++ b/docs/branch-review-records/fc9303a43ee12deed2f6e7bc96f9b216041ad3983f040dbc0dc0f0cf4df50c84.record.md @@ -0,0 +1 @@ +| 2026-08-15 | codex/therapy-global-convergence-20260814 | 0011a058fd1d68e7fb98f3411d505b71bee104cf | Therapy versus other modes: remaining shared-site differences and issues | P1: production-visible Therapy exposes 205/205 records awaiting review and permits clinical outputs. P2: save failures are silent, catalogue/universal ranking differs, provenance is not source-resolvable, print and visual controls remain Therapy-specific, all rich routes fetch the 2.53 MB catalogue, and release state is stale. | Read-only source/diff review; local catalogue diagnostics (205 total, 0 reviewed; one shared uploaded source; 47 null references); ranking parity diagnostic (multiple top-five mismatches); git overlap review (42 commits behind, 4 overlapping files). Tests not run for review-only task. No provider calls. | diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 3f3bc2b29..0f6aae5f1 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -325,7 +325,7 @@ sequenceDiagram One shared composer (`master-search-header.tsx`) serves every mode. Placement: - **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/dsm`, `/specifiers`, `/formulation`, `/factsheets`, `/therapy-compass`, `/tools`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. (`/applications` is a redirect to `/tools`, not a composer surface.) -- **Information (detail) pages**: catalogue/record routes under each mode (`/services/[slug]`, `/forms/[slug]`, `/medications/[slug]`, `/specifiers/[slug]`, `/formulation/[slug]`, `/factsheets/[slug]`, `/dsm/diagnoses/[slug]`, …). Route detection: `src/lib/information-pages.ts` (`isInformationPage`). Shared outer chrome: `src/components/information-page-shell.tsx` (`InformationPageShell`, breadcrumbs, optional footer). Specifier/formulation mode shells re-export that primitive. Intentional opt-outs: document viewer, therapy-compass CSS workspace, differentials presentation workflow. +- **Information (detail) pages**: catalogue/record routes under each mode (`/services/[slug]`, `/forms/[slug]`, `/medications/[slug]`, `/specifiers/[slug]`, `/formulation/[slug]`, `/factsheets/[slug]`, `/therapy-compass/[slug]`, `/dsm/diagnoses/[slug]`, …). Route detection: `src/lib/information-pages.ts` (`isInformationPage`). Shared outer chrome: `src/components/information-page-shell.tsx` (`InformationPageShell`, breadcrumbs, optional footer). Specifier/formulation mode shells re-export that primitive. Intentional opt-outs: document viewer and the differentials presentation workflow. - **Result and detail views**: fixed bottom dock on phone (compact variant on submitted searches), sticky top from `sm` up. - **Results routing**: standalone routes own their submitted searches via `?q=…&run=1` (`/services` → `ServicesNavigatorPage`, `/forms` → `FormsSearchResultsPage`, `/differentials` → `DifferentialsHome` results view, `/formulation` → local mechanism results, `/favourites` filters the command library in place). Answer, Documents, and Prescribing submitted searches render inside `ClinicalDashboard` — intentional, since they need retrieval/answer state. Bare `/?mode=` always renders the shared home with that mode preselected; only a submitted deep link (`q` plus `run=1`) resolves to the mode's own search surface (proxy early-redirect still covers favourites/differentials/specifiers for those submitted aliases). - **Intentionally composer-free routes**: `/differentials/presentations/*` and `/differentials/compare` (comparison workflow owns its chrome), `/documents/[id]` viewer (has its own in-document ask composer), `/documents/source/*` (document flow owns mobile chrome). Do not re-flag these in search-consistency audits. diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 6e0d103d0..f87b12245 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -955,7 +955,7 @@ tests to run. Registered public components: 54 Components with a valid design-sync preview: 54 -Components with product imports: 32 +Components with product imports: 33 This generated snapshot is a local source-derived inventory. It does not assert remote design-project publication. @@ -966,7 +966,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `AnswerFooter` | answer | yes | yes | no | yes | no | 0 | | `AsyncButton` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `Breadcrumb` | layout | yes | yes | inherited-global-root | yes | no | 1 | -| `Button` | controls | yes | yes | inherited-global-root | yes | no | 2 | +| `Button` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `Checkbox` | controls | yes | yes | no | yes | no | 0 | | `Chip` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `Citation` | source | yes | yes | no | yes | no | 0 | @@ -999,20 +999,20 @@ This generated snapshot is a local source-derived inventory. It does not assert | `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 | | `SearchField` | controls | yes | yes | no | yes | no | 0 | -| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 10 | +| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 12 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | | `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | -| `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | +| `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 2 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | -| `SourceStatusBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | +| `SourceStatusBadge` | source | yes | yes | inherited-global-root | yes | no | 2 | | `StageList` | feedback | yes | yes | no | yes | no | 0 | | `StatusMark` | source | yes | yes | inherited-global-root | yes | no | 2 | -| `Tabs` | controls | yes | yes | no | yes | no | 0 | +| `Tabs` | controls | yes | yes | inherited-global-root | yes | no | 2 | | `TextField` | controls | yes | yes | inherited-global-root | yes | no | 4 | | `TextLink` | controls | yes | yes | no | yes | no | 0 | | `ToastRegion` | feedback | yes | yes | no | yes | no | 0 | -| `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 2 | +| `ToggleSwitch` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `Tooltip` | feedback | yes | yes | no | yes | no | 0 | | `VerificationNotice` | answer | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 1eafffd1c..a4d386987 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -280,11 +280,13 @@ "directImportFiles": [ "src/components/AccessibleTable.tsx", "src/components/clinical-dashboard/signed-image.tsx", - "src/components/ui/confirm-dialog.tsx" + "src/components/ui/confirm-dialog.tsx", + "src/components/ui/print-output.tsx" ], "productImportFiles": [ "src/components/AccessibleTable.tsx", - "src/components/clinical-dashboard/signed-image.tsx" + "src/components/clinical-dashboard/signed-image.tsx", + "src/components/ui/print-output.tsx" ], "designSync": { "listedInSourceMap": true, @@ -1405,6 +1407,8 @@ "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/services/services-navigator-page.tsx", "src/components/specifiers/specifiers-home-page.tsx", + "src/components/therapy-compass/screens/compare-screen.tsx", + "src/components/therapy-compass/screens/sheets-screen.tsx", "src/components/tools/tools-search-results-page.tsx" ], "productImportFiles": [ @@ -1417,6 +1421,8 @@ "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/services/services-navigator-page.tsx", "src/components/specifiers/specifiers-home-page.tsx", + "src/components/therapy-compass/screens/compare-screen.tsx", + "src/components/therapy-compass/screens/sheets-screen.tsx", "src/components/tools/tools-search-results-page.tsx" ], "designSync": { @@ -1429,6 +1435,7 @@ "tests/design-sync-visual-exports.test.ts", "tests/factsheets-search-page.dom.test.tsx", "tests/search-results-header-band.dom.test.tsx", + "tests/therapy-compass-responsive-contract.test.ts", "tests/ui-v2-components.dom.test.tsx" ], "baseline": { @@ -1562,12 +1569,14 @@ "tests/image-lightbox-geometry.test.ts", "tests/in-page-nav-route-sections.dom.test.tsx", "tests/mobile-sheet-safe-area-contract.test.ts", + "tests/mode-nav-contract.test.ts", "tests/mode-nav.dom.test.tsx", "tests/rag-answer-fallback.test.ts", "tests/settings-dialog-actions.dom.test.tsx", "tests/sheet-focus-stress.dom.test.tsx", "tests/sheet-focus.dom.test.tsx", "tests/sheet.dom.test.tsx", + "tests/therapy-global-convergence-contract.test.ts", "tests/ui-accessibility.spec.ts", "tests/ui-overlay-css-contract.test.ts", "tests/ui-phone-scroll-page-owned.spec.ts", @@ -1635,8 +1644,14 @@ "source": "src/components/ui-primitives.tsx", "sourceExported": true, "entryExported": true, - "directImportFiles": ["src/components/clinical-dashboard/document-admin.tsx"], - "productImportFiles": ["src/components/clinical-dashboard/document-admin.tsx"], + "directImportFiles": [ + "src/components/clinical-dashboard/document-admin.tsx", + "src/components/therapy-compass/screens/detail-screen.tsx" + ], + "productImportFiles": [ + "src/components/clinical-dashboard/document-admin.tsx", + "src/components/therapy-compass/screens/detail-screen.tsx" + ], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, @@ -1691,8 +1706,14 @@ "source": "src/components/ui-primitives.tsx", "sourceExported": true, "entryExported": true, - "directImportFiles": ["src/components/clinical-dashboard/document-admin.tsx"], - "productImportFiles": ["src/components/clinical-dashboard/document-admin.tsx"], + "directImportFiles": [ + "src/components/clinical-dashboard/document-admin.tsx", + "src/components/therapy-compass/screens/detail-screen.tsx" + ], + "productImportFiles": [ + "src/components/clinical-dashboard/document-admin.tsx", + "src/components/therapy-compass/screens/detail-screen.tsx" + ], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, @@ -1779,15 +1800,26 @@ "source": "src/components/ui/tabs.tsx", "sourceExported": true, "entryExported": true, - "directImportFiles": [], - "productImportFiles": [], + "directImportFiles": [ + "src/components/therapy-compass/screens/brief-screen.tsx", + "src/components/therapy-compass/screens/compare-screen.tsx" + ], + "productImportFiles": [ + "src/components/therapy-compass/screens/brief-screen.tsx", + "src/components/therapy-compass/screens/compare-screen.tsx" + ], "designSync": { "listedInSourceMap": true, "listedInDtsProps": true, "preview": ".design-sync/previews/Tabs.tsx", "previewValid": true }, - "testFiles": ["tests/design-sync-visual-exports.test.ts", "tests/ui-v2-components.dom.test.tsx"], + "testFiles": [ + "tests/design-sync-visual-exports.test.ts", + "tests/therapy-compass-responsive-contract.test.ts", + "tests/therapy-tabs.dom.test.tsx", + "tests/ui-v2-components.dom.test.tsx" + ], "baseline": { "targetLayer": "v2", "liveLayer": "v2", @@ -1796,8 +1828,8 @@ }, "built": true, "locallyRegistered": true, - "v2ShellMounted": false, - "v2MountMode": "none", + "v2ShellMounted": true, + "v2MountMode": "inherited-global-root", "proofDeclared": true, "baselineCommitted": false }, @@ -1903,11 +1935,13 @@ "entryExported": true, "directImportFiles": [ "src/components/clinical-dashboard/patient-profile-panel.tsx", - "src/components/forms/forms-search-results-page.tsx" + "src/components/forms/forms-search-results-page.tsx", + "src/components/therapy-compass/screens/sheets-screen.tsx" ], "productImportFiles": [ "src/components/clinical-dashboard/patient-profile-panel.tsx", - "src/components/forms/forms-search-results-page.tsx" + "src/components/forms/forms-search-results-page.tsx", + "src/components/therapy-compass/screens/sheets-screen.tsx" ], "designSync": { "listedInSourceMap": true, @@ -1915,7 +1949,11 @@ "preview": ".design-sync/previews/ToggleSwitch.tsx", "previewValid": true }, - "testFiles": ["tests/design-sync-visual-exports.test.ts", "tests/ui-primitives.dom.test.tsx"], + "testFiles": [ + "tests/design-sync-visual-exports.test.ts", + "tests/therapy-compass-responsive-contract.test.ts", + "tests/ui-primitives.dom.test.tsx" + ], "baseline": { "targetLayer": "v2", "liveLayer": "v2", @@ -3400,7 +3438,7 @@ "summary": { "registeredComponentCount": 54, "previewCount": 54, - "productImportedComponentCount": 32, + "productImportedComponentCount": 33, "rootCount": 59, "productionRouteCount": 51, "nextUiEntryCount": 103 diff --git a/docs/site-map.md b/docs/site-map.md index c0282f85c..d480350f8 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -60,7 +60,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/medications` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/tools` - Tools mode. Search kind: `tools`. Query example: `/tools?q=medications&focus=1&run=1`. - `/calculators` - Calculators mode. Search kind: `calculators`. Query example: `/calculators?q=PHQ-9&focus=1&run=1`. -- `/therapy-compass` - Therapy mode. Search kind: `tools`. Query example: `/therapy-compass?q=behavioural+activation&focus=1&run=1`. +- `/therapy-compass` - Therapy mode. Search kind: `therapies`. Query example: `/therapy-compass/search?q=behavioural+activation&focus=1&run=1`. - `/factsheets` - Factsheets mode. Search kind: `tools`. Query example: `/factsheets/search?q=sertraline&focus=1&run=1`. ## Mode page index diff --git a/src/app/(search-app)/page.tsx b/src/app/(search-app)/page.tsx index bf41496f6..36222ccfa 100644 --- a/src/app/(search-app)/page.tsx +++ b/src/app/(search-app)/page.tsx @@ -35,8 +35,17 @@ export default async function Home({ searchParams }: HomeProps) { await connection(); const params = searchParams ? await searchParams : {}; const requestedMode = firstSearchParam(params.mode); - const initialSearchMode: AppModeId = - isAppModeId(requestedMode) && isAppModeVisible(requestedMode) ? requestedMode : "answer"; + let initialSearchMode: AppModeId = "answer"; + if (isAppModeId(requestedMode) && isAppModeVisible(requestedMode)) { + initialSearchMode = requestedMode; + } else if (requestedMode) { + // Hidden or malformed mode links must not leave an impossible mode in the + // browser URL. Canonicalising to Answer also lets same-path navigations clear + // submitted search state rather than retaining results under a rejected mode. + const canonicalParams = searchParamsFromRecord(params); + canonicalParams.set("mode", "answer"); + redirect(`/?${canonicalParams.toString()}`); + } // `/` is the single home page for every mode: the mode pill retargets the // composer rather than navigating, so a bare `/?mode=` must RENDER home diff --git a/src/app/(search-app)/therapy-compass/layout.tsx b/src/app/(search-app)/therapy-compass/layout.tsx index e44b5c3be..89d0c498f 100644 --- a/src/app/(search-app)/therapy-compass/layout.tsx +++ b/src/app/(search-app)/therapy-compass/layout.tsx @@ -1,6 +1,28 @@ +import { Suspense } from "react"; import type { ReactNode } from "react"; +import { notFound } from "next/navigation"; -// Search chrome and TherapyCompassWorkspace are owned by the shared (search-app) layout. +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; +import { TherapyCompassRouteLayout } from "@/components/therapy-compass/therapy-compass-route-layout"; +import { isAppModeVisible } from "@/lib/app-modes"; + +// Therapy-only state belongs at the deepest shared route segment. Keeping this +// provider out of the global search shell prevents every other mode from +// downloading Therapy's client graph, while the client boundary can still read +// current pathname/search params on each navigation. export default function TherapyCompassLayout({ children }: { children: ReactNode }) { - return children; + const offlineReviewBuild = process.env.PLAYWRIGHT_OFFLINE_MODE === "true"; + if ( + process.env.NODE_ENV === "production" && + !offlineReviewBuild && + !isAppModeVisible("therapy-compass", "production") + ) { + notFound(); + } + + return ( + }> + {children} + + ); } diff --git a/src/app/api/account/favourites/route.ts b/src/app/api/account/favourites/route.ts index d59fa5238..36f61f40c 100644 --- a/src/app/api/account/favourites/route.ts +++ b/src/app/api/account/favourites/route.ts @@ -9,7 +9,7 @@ export const runtime = "nodejs"; const mutationSchema = z .object({ - contentType: z.enum(["service", "form", "differential"]), + contentType: z.enum(["service", "form", "differential", "therapy"]), contentKey: z.string().trim().min(1).max(180), saved: z.boolean(), }) diff --git a/src/app/globals.css b/src/app/globals.css index f16a7f9e2..68bc6ce28 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4375,69 +4375,10 @@ html.theme-transitioning *:after { /* ============================================================================= * Therapy Compass residuals — irreducible rules that cannot become utilities. - * Print isolation, printable paper tokens, CSS-var comparison grids, clinician - * toggle ::before track, and thin scrollbars. Everything else in the old + * Printable paper tokens and thin scrollbars. Everything else in the old * therapy-compass.css parallel system is token-backed Tailwind on the components. * ============================================================================= */ -.therapy-compare-grid { - display: grid; - grid-template-columns: var(--tc-compare-columns); - border-bottom: 1px solid var(--border); -} - -.therapy-compare-cell, -.therapy-compare-row-label { - padding: var(--tc-compare-cell-padding); -} - -[data-therapy-clinician-track] { - position: relative; - width: var(--spacing-tap); - height: var(--spacing-tap); - flex: none; - border: 0; - border-radius: 999px; - background: transparent; - padding: 0; - cursor: pointer; -} - -[data-therapy-clinician-track]::before { - position: absolute; - top: 50%; - left: 50%; - width: 42px; - height: 24px; - border-radius: 12px; - background: var(--border-strong); - content: ""; - transform: translate(-50%, -50%); - transition: background-color 150ms ease; -} - -[data-therapy-clinician-track][data-active="true"]::before { - background: var(--clinical-accent); -} - -[data-therapy-clinician-knob] { - position: absolute; - z-index: 1; - top: 50%; - left: calc(50% - 18px); - width: 18px; - height: 18px; - border-radius: 50%; - background: var(--surface-raised); - box-shadow: var(--e1); - transform: translate(0, -50%); - transition: transform 150ms var(--ease-spring); -} - -[data-therapy-clinician-track][data-active="true"] [data-therapy-clinician-knob] { - transform: translate(18px, -50%); -} - [data-therapy-paper] { /* Fixed print palette — deliberately NOT theme-reactive. Accent mirrors Clinical Sky. */ --tc-paper-background: #ffffff; @@ -4494,25 +4435,26 @@ html.theme-transitioning *:after { } @media print { - @page therapy-compass-sheet { + @page shared-clinical-output { size: a4 portrait; margin: 14mm; } + body:has([data-print-output]), body:has([data-therapy-root]) { background: #ffffff !important; } - body:has([data-therapy-root]) * { + body:has([data-print-output]) * { visibility: hidden !important; } - body:has([data-therapy-root]) [data-therapy-paper], - body:has([data-therapy-root]) [data-therapy-paper] * { + body:has([data-print-output]) [data-print-output], + body:has([data-print-output]) [data-print-output] * { visibility: visible !important; } - body:has([data-therapy-root]) [data-therapy-paper] { + body:has([data-print-output]) [data-print-output] { position: absolute !important; top: 0; left: 0; @@ -4521,10 +4463,24 @@ html.theme-transitioning *:after { margin: 0 !important; border: 0 !important; box-shadow: none !important; - page: therapy-compass-sheet; + page: shared-clinical-output; } - body:has([data-therapy-root]) [data-therapy-no-print] { + body:has([data-print-output]) [data-print-hide], + body:has([data-print-output]) [data-therapy-no-print] { display: none !important; } + + [data-print-provenance] { + display: block !important; + margin-top: 8mm; + border-top: 1px solid var(--border); + padding-top: var(--spacing-icon-xs); + color: var(--text-muted); + font-size: 9pt; + } +} + +[data-print-provenance] { + display: none; } diff --git a/src/components/account-data-provider.tsx b/src/components/account-data-provider.tsx index df3472568..e3db987a6 100644 --- a/src/components/account-data-provider.tsx +++ b/src/components/account-data-provider.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useAuthSession } from "@/lib/supabase/client"; import { @@ -8,19 +8,27 @@ import { savedDifferentialsStorageKey, savedFormsStorageKey, savedServicesStorageKey, + savedTherapiesStorageKey, subscribeSavedRegistrySlugs, writeSavedRegistrySlugs, } from "@/lib/saved-registry-storage"; -export type FavouriteContentType = "service" | "form" | "differential"; +export type FavouriteContentType = "service" | "form" | "differential" | "therapy"; type FavouritesByType = Record; +type FavouriteMutationState = { + confirmed: boolean; + desired: boolean; + pending: number; + tail: Promise; +}; -const emptyFavourites: FavouritesByType = { service: [], form: [], differential: [] }; +const emptyFavourites: FavouritesByType = { service: [], form: [], differential: [], therapy: [] }; const storageKeyByType = { service: savedServicesStorageKey, form: savedFormsStorageKey, differential: savedDifferentialsStorageKey, + therapy: savedTherapiesStorageKey, } satisfies Record; const demoAccountData = process.env.NEXT_PUBLIC_DEMO_MODE === "true"; @@ -29,6 +37,7 @@ function readDemoFavourites(): FavouritesByType { service: readSavedRegistrySlugs(savedServicesStorageKey), form: readSavedRegistrySlugs(savedFormsStorageKey), differential: readSavedRegistrySlugs(savedDifferentialsStorageKey), + therapy: readSavedRegistrySlugs(savedTherapiesStorageKey), }; } @@ -53,13 +62,16 @@ const AccountDataContext = createContext(null); function normalizedFavourites(value: unknown): FavouritesByType { const rows = Array.isArray(value) ? value : []; - const result: FavouritesByType = { service: [], form: [], differential: [] }; + const result: FavouritesByType = { service: [], form: [], differential: [], therapy: [] }; for (const row of rows) { if (!row || typeof row !== "object") continue; const contentType = (row as { contentType?: unknown }).contentType; const contentKey = (row as { contentKey?: unknown }).contentKey; if ( - (contentType === "service" || contentType === "form" || contentType === "differential") && + (contentType === "service" || + contentType === "form" || + contentType === "differential" || + contentType === "therapy") && typeof contentKey === "string" && contentKey.trim() ) { @@ -72,6 +84,9 @@ function normalizedFavourites(value: unknown): FavouritesByType { export function AccountDataProvider({ children }: { children: ReactNode }) { const auth = useAuthSession(); const [favourites, setFavourites] = useState(emptyFavourites); + const favouritesRef = useRef(favourites); + const favouriteMutationsRef = useRef(new Map()); + const favouriteClearTailRef = useRef>(Promise.resolve()); const [ready, setReady] = useState(auth.status !== "authenticated"); const [loadError, setLoadError] = useState(null); const [error, setError] = useState(null); @@ -79,6 +94,22 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { // exactly the same clearing and abort semantics as an auth transition. const [reloadAttempt, setReloadAttempt] = useState(0); const reload = useCallback(() => setReloadAttempt((attempt) => attempt + 1), []); + const replaceFavourites = useCallback((next: FavouritesByType) => { + favouritesRef.current = next; + setFavourites(next); + }, []); + const applyFavourite = useCallback( + (contentType: FavouriteContentType, contentKey: string, saved: boolean) => { + const current = favouritesRef.current; + replaceFavourites({ + ...current, + [contentType]: saved + ? [contentKey, ...current[contentType].filter((item) => item !== contentKey)] + : current[contentType].filter((item) => item !== contentKey), + }); + }, + [replaceFavourites], + ); // Depended on by identity rather than through `auth`, which would re-run the // load effect on every auth-object render. It is a useCallback upstream, so // this stays stable. @@ -86,7 +117,7 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { useEffect(() => { if (auth.status !== "authenticated") { - const refreshDemoFavourites = () => setFavourites(demoAccountData ? readDemoFavourites() : emptyFavourites); + const refreshDemoFavourites = () => replaceFavourites(demoAccountData ? readDemoFavourites() : emptyFavourites); let cancelled = false; queueMicrotask(() => { if (cancelled) return; @@ -120,13 +151,13 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { // paths below already do this. if (response.status === 401) markSessionExpired(); if (!response.ok) throw new Error(payload.message ?? payload.error ?? "Saved items could not be loaded."); - setFavourites(normalizedFavourites(payload.favourites)); + replaceFavourites(normalizedFavourites(payload.favourites)); setLoadError(null); setError(null); }) .catch((cause) => { if (cause instanceof DOMException && cause.name === "AbortError") return; - setFavourites(emptyFavourites); + replaceFavourites(emptyFavourites); setLoadError(cause instanceof Error ? cause.message : "Saved items could not be loaded."); setError(null); }) @@ -135,13 +166,13 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { }); return () => controller.abort(); - }, [auth.authEpoch, auth.authorizationHeader, auth.status, markSessionExpired, reloadAttempt]); + }, [auth.authEpoch, auth.authorizationHeader, auth.status, markSessionExpired, reloadAttempt, replaceFavourites]); const setFavourite = useCallback( async (contentType: FavouriteContentType, contentKey: string, saved: boolean) => { if (auth.status !== "authenticated") { if (demoAccountData) { - const current = favourites[contentType]; + const current = favouritesRef.current[contentType]; return writeSavedRegistrySlugs( storageKeyByType[contentType], saved @@ -155,32 +186,59 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { const key = contentKey.trim(); if (!key) return false; - const previous = favourites; - setFavourites((current) => ({ - ...current, - [contentType]: saved - ? [key, ...current[contentType].filter((item) => item !== key)] - : current[contentType].filter((item) => item !== key), - })); + const mutationKey = `${contentType}:${key}`; + let mutation = favouriteMutationsRef.current.get(mutationKey); + if (!mutation) { + mutation = { + confirmed: favouritesRef.current[contentType].includes(key), + desired: saved, + pending: 0, + tail: Promise.resolve(), + }; + favouriteMutationsRef.current.set(mutationKey, mutation); + } - const response = await fetch("/api/account/favourites", { - method: "PUT", - headers: { "Content-Type": "application/json", ...auth.authorizationHeader }, - body: JSON.stringify({ contentType, contentKey: key, saved }), - }).catch(() => null); - if (!response?.ok) { - setFavourites(previous); - const payload = await response?.json().catch(() => ({})); - // Mutation failures must not poison loadError: the library already loaded, - // and Retry-on-GET would mis-describe a failed write as an unread library. - setError(payload?.message ?? payload?.error ?? "Saved items could not be updated."); - if (response?.status === 401) auth.markSessionExpired(); - return false; + mutation.desired = saved; + mutation.pending += 1; + applyFavourite(contentType, key, saved); + + // Per-item writes remain independent, but every write observes the latest + // clear-all barrier. A write that begins after Clear therefore cannot race + // ahead of its DELETE and recreate a favourite out of order. + const request = Promise.all([mutation.tail, favouriteClearTailRef.current]).then(async () => { + const response = await fetch("/api/account/favourites", { + method: "PUT", + headers: { "Content-Type": "application/json", ...auth.authorizationHeader }, + body: JSON.stringify({ contentType, contentKey: key, saved }), + }).catch(() => null); + if (!response?.ok) { + const payload = await response?.json().catch(() => ({})); + // Mutation failures must not poison loadError: the library already loaded, + // and Retry-on-GET would mis-describe a failed write as an unread library. + setError(payload?.message ?? payload?.error ?? "Saved items could not be updated."); + if (response?.status === 401) auth.markSessionExpired(); + return false; + } + mutation.confirmed = saved; + setError(null); + return true; + }); + mutation.tail = request.then( + () => undefined, + () => undefined, + ); + + const succeeded = await request; + mutation.pending -= 1; + if (mutation.pending === 0) { + applyFavourite(contentType, key, mutation.confirmed); + favouriteMutationsRef.current.delete(mutationKey); + } else { + applyFavourite(contentType, key, mutation.desired); } - setError(null); - return true; + return succeeded; }, - [auth, favourites], + [applyFavourite, auth], ); const clearFavourites = useCallback(async () => { @@ -188,21 +246,36 @@ export function AccountDataProvider({ children }: { children: ReactNode }) { if (!demoAccountData) return false; return (Object.values(storageKeyByType) as string[]).every((key) => writeSavedRegistrySlugs(key, [])); } - const previous = favourites; - setFavourites(emptyFavourites); - const response = await fetch("/api/account/favourites", { - method: "DELETE", - headers: auth.authorizationHeader, - }).catch(() => null); - if (!response?.ok) { - setFavourites(previous); - setError("Saved items could not be cleared."); - if (response?.status === 401) auth.markSessionExpired(); - return false; - } - setError(null); - return true; - }, [auth, favourites]); + + // Clear is a global mutation boundary. Wait for every already-enqueued PUT, + // and make later PUTs wait for this DELETE through favouriteClearTailRef. + const pendingMutationTails = [...favouriteMutationsRef.current.values()].map((mutation) => mutation.tail); + const request = Promise.all([favouriteClearTailRef.current, ...pendingMutationTails]).then(async () => { + const response = await fetch("/api/account/favourites", { + method: "DELETE", + headers: auth.authorizationHeader, + }).catch(() => null); + if (!response?.ok) { + setError("Saved items could not be cleared."); + if (response?.status === 401) auth.markSessionExpired(); + return false; + } + + // Mutations queued behind the clear must roll back against the now-empty + // server state if their later PUT fails. + for (const mutation of favouriteMutationsRef.current.values()) { + mutation.confirmed = false; + } + replaceFavourites(emptyFavourites); + setError(null); + return true; + }); + favouriteClearTailRef.current = request.then( + () => undefined, + () => undefined, + ); + return request; + }, [auth, replaceFavourites]); const value = useMemo( () => ({ diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx index cb2a829e6..58f15da44 100644 --- a/src/components/clinical-dashboard/dashboard-nav.tsx +++ b/src/components/clinical-dashboard/dashboard-nav.tsx @@ -74,11 +74,11 @@ export function buildMobileSectionFabState({ }): MobileSectionFabState { const modeSearch = appModeSearchConfig(searchMode); if (!hasAnswer) { - if (modeSearch.resultKind === "tools") { + if (modeSearch.resultKind === "tools" || modeSearch.resultKind === "therapies") { return { - statusLabel: "Tools", + statusLabel: modeSearch.statusLabel, statusTone: "neutral", - nextStep: "Launch a clinical tool", + nextStep: modeSearch.nextStep, badgeLabel: null, badgeTone: "neutral", }; diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index bb7c10199..c1be627b2 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -48,7 +48,7 @@ import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { useAuthSession } from "@/lib/supabase/client"; type FavouriteType = - "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form" | "Differential"; + "Medication" | "Document" | "Table" | "Saved search" | "Source" | "Service" | "Form" | "Differential" | "Therapy"; // Previously imported from `favourites-library-nav`, which this redesign // retired along with the sidebar and the two phone rails it exported. type ViewMode = "all" | "source-backed" | "pinned" | "recent"; @@ -97,6 +97,7 @@ const typeAppearance: Record = { Service: { kind: "category", tone: "service" }, Form: { kind: "category", tone: "form" }, Differential: { kind: "information", tone: "accent" }, + Therapy: { kind: "information", tone: "accent" }, }; const lastUsedByItemId: Record = { @@ -116,6 +117,7 @@ const typeByPrototypeType: Record services: "Service", forms: "Form", differentials: "Differential", + therapies: "Therapy", }; const fallbackIconByType: Record = { @@ -125,6 +127,7 @@ const fallbackIconByType: Record = { services: appModeIcons.services, forms: appModeIcons.forms, differentials: appModeIcons.differentials, + therapies: appModeIcons["therapy-compass"], }; function lastUsedScore(lastUsed: string): number { diff --git a/src/components/clinical-dashboard/favourites-prototype-data.ts b/src/components/clinical-dashboard/favourites-prototype-data.ts index a2f0a8f24..fef6ef4b5 100644 --- a/src/components/clinical-dashboard/favourites-prototype-data.ts +++ b/src/components/clinical-dashboard/favourites-prototype-data.ts @@ -1,7 +1,8 @@ import { BrainCircuit, ClipboardList, FileText, Folder, LayoutList, Pill, Quote, Search } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; -export type FavouriteType = "medications" | "documents" | "sources" | "services" | "forms" | "differentials" | "sets"; +export type FavouriteType = + "medications" | "documents" | "sources" | "services" | "forms" | "differentials" | "therapies" | "sets"; export type FavouriteTabId = "all" | FavouriteType; export type FavouriteItem = { @@ -34,6 +35,7 @@ export const favouriteTabs: Array<{ { id: "all", label: "All", shortLabel: "All", icon: LayoutList }, { id: "medications", label: "Medications", shortLabel: "Meds", icon: Pill }, { id: "differentials", label: "Differentials", shortLabel: "Diffs", icon: BrainCircuit }, + { id: "therapies", label: "Therapies", shortLabel: "Therapies", icon: appModeIcons["therapy-compass"] }, { id: "documents", label: "Documents", shortLabel: "Docs", icon: FileText }, { id: "sources", label: "Sources", shortLabel: "Sources", icon: Quote }, { id: "services", label: "Services", shortLabel: "Services", icon: appModeIcons.services }, diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index f1926ccd0..931262d85 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -375,6 +375,7 @@ export function MasterSearchHeader({ selectedSearch.kind === "documents" || selectedSearch.kind === "forms" || selectedSearch.kind === "services" || + selectedSearch.kind === "therapies" || selectedSearch.kind === "tools" || selectedSearch.kind === "calculators" || selectedSearch.kind === "favourites" || diff --git a/src/components/clinical-dashboard/shared-search-app-shell.tsx b/src/components/clinical-dashboard/shared-search-app-shell.tsx index 286a4fde2..6712523b7 100644 --- a/src/components/clinical-dashboard/shared-search-app-shell.tsx +++ b/src/components/clinical-dashboard/shared-search-app-shell.tsx @@ -1,18 +1,11 @@ "use client"; -import dynamic from "next/dynamic"; -import { Suspense, type ReactNode } from "react"; +import { type ReactNode } from "react"; import { usePathname } from "next/navigation"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; -import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; import { searchShellPropsForPathname } from "@/lib/search-shell-props"; -const TherapyCompassWorkspace = dynamic( - () => import("@/components/therapy-compass/workspace").then((module) => module.TherapyCompassWorkspace), - { loading: () => }, -); - /** * Owns one GlobalSearchShell across mode homes so navigating between * /services, /dsm, /, etc. does not remount the shared composer chrome. @@ -20,16 +13,5 @@ const TherapyCompassWorkspace = dynamic( export function SharedSearchAppShell({ children }: { children: ReactNode }) { const pathname = usePathname() ?? "/"; const shellProps = searchShellPropsForPathname(pathname); - // The Therapy home is already a complete lightweight mode home. Only richer - // child routes need the catalogue provider and Therapy navigation workspace; - // keeping that graph off the home also keeps it off every non-Therapy route. - const content = pathname.startsWith("/therapy-compass/") ? ( - }> - {children} - - ) : ( - children - ); - - return {content}; + return {children}; } diff --git a/src/components/clinical-dashboard/universal-search-also-matches-state.ts b/src/components/clinical-dashboard/universal-search-also-matches-state.ts new file mode 100644 index 000000000..03779297a --- /dev/null +++ b/src/components/clinical-dashboard/universal-search-also-matches-state.ts @@ -0,0 +1,17 @@ +import type { AppModeId } from "@/lib/app-modes"; + +/** + * Answer threads may be restored from session storage on the shared home. The + * cross-mode panel belongs only to a submitted Answer URL, not to a persisted + * answer object that happens to still be available after navigation. + * + * `locationSearch === null` is the server-render path. The Answer panel is not + * mounted there until client-side answer state exists, so allowing it preserves + * hydration while the client applies the actual URL contract before fetching. + */ +export function shouldRunUniversalAlsoMatches(modeId: AppModeId, locationSearch: string | null) { + if (modeId !== "answer" || locationSearch === null) return true; + const params = new URLSearchParams(locationSearch); + const query = (params.get("q") ?? params.get("query") ?? "").trim(); + return params.get("run") === "1" && query.length > 0; +} diff --git a/src/components/clinical-dashboard/universal-search-also-matches.tsx b/src/components/clinical-dashboard/universal-search-also-matches.tsx index 3c5bd8cd2..e5af851b6 100644 --- a/src/components/clinical-dashboard/universal-search-also-matches.tsx +++ b/src/components/clinical-dashboard/universal-search-also-matches.tsx @@ -5,6 +5,7 @@ import { ChevronDown, Layers } from "lucide-react"; import { useEffect, useId, useState } from "react"; import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favourites-access"; +import { shouldRunUniversalAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches-state"; import { useUniversalSearch } from "@/components/clinical-dashboard/use-universal-search"; import { cn, textMuted } from "@/components/ui-primitives"; import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; @@ -39,6 +40,13 @@ export function UniversalSearchAlsoMatches({ const { favouritesAccessible } = useFavouritesAccess(auth.status === "authenticated", clientDemoMode); const trimmedQuery = query.trim(); const panelId = useId(); + // Answer threads can be restored on an unsubmitted shared home. Keep their + // prior cross-mode panel tied to a submitted URL rather than the mere + // presence of a persisted answer object. + const submissionActive = shouldRunUniversalAlsoMatches( + modeId, + typeof window === "undefined" ? null : window.location.search, + ); // Collapsed by default on phones so this cross-mode panel does not push the // primary results down; desktop always shows the grid (see the sm: rules below), // so the toggle state only governs the narrow-viewport disclosure. @@ -67,7 +75,7 @@ export function UniversalSearchAlsoMatches({ // Prescribing hides this panel entirely (see early return below). Keep the // fetch disabled too so wide viewports never fire `/api/search/universal` // for results that cannot render. - const searchActive = modeId !== "prescribing" && (isWide || modeId === "answer" || expanded); + const searchActive = modeId !== "prescribing" && submissionActive && (isWide || modeId === "answer" || expanded); const universal = useUniversalSearch({ query: trimmedQuery, enabled: trimmedQuery.length >= 2 && searchActive, @@ -119,7 +127,7 @@ export function UniversalSearchAlsoMatches({ // Medication search is already a tightly scoped clinical result surface. Do // not add cross-mode suggestions above its prescribing results: they displace // the medication count, patient details, and primary matches on phones. - if (modeId === "prescribing") return null; + if (modeId === "prescribing" || !submissionActive) return null; if (!viewportReady || trimmedQuery.length < 2) return null; if (modeId === "answer" && currentGroups.length === 0) return null; if (isWide && !searchPending && currentGroups.length === 0) return null; diff --git a/src/components/clinical-dashboard/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index d241532a8..3e5e55ddb 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -2,7 +2,7 @@ import { BrainCircuit, ClipboardList } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useAccountData } from "@/components/account-data-provider"; import type { FavouriteItem } from "@/components/clinical-dashboard/favourites-prototype-data"; @@ -28,6 +28,12 @@ function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): F }; } +type TherapyFavouritesLoadState = { + key: string; + items: FavouriteItem[]; + status: Extract; +}; + export type SavedRegistryFavouritesResult = { items: FavouriteItem[]; /** Combined account + registry status for the band/empty state. When unaffected @@ -51,9 +57,67 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { const savedServices = favourites.service; const savedForms = favourites.form; const savedDifferentials = favourites.differential; + const savedTherapies = favourites.therapy; + const savedTherapyKey = savedTherapies.join("\u0000"); const services = useRegistryRecords("service", { enabled: savedServices.length > 0, view: "search" }); const forms = useRegistryRecords("form", { enabled: savedForms.length > 0, view: "search" }); + const [therapyLoadAttempt, setTherapyLoadAttempt] = useState(0); + const [therapyLoad, setTherapyLoad] = useState({ + key: "", + items: [], + status: "ready", + }); + const currentTherapyLoad: TherapyFavouritesLoadState = + therapyLoad.key === savedTherapyKey + ? therapyLoad + : { + key: savedTherapyKey, + items: [], + status: savedTherapies.length > 0 ? "loading" : "ready", + }; + // Reset during render when the saved-slug set changes, matching the repository + // hook pattern and avoiding a stale frame from the previous account/library. + if (therapyLoad.key !== savedTherapyKey) { + setTherapyLoad(currentTherapyLoad); + } + + useEffect(() => { + if (!savedTherapyKey) return undefined; + let active = true; + void import("@/lib/therapies") + .then(({ findTherapyRecord }) => { + if (!active) return; + const items = savedTherapies.flatMap((slug) => { + const therapy = findTherapyRecord(slug); + if (!therapy) return []; + return [ + { + id: `therapies:${therapy.slug}`, + title: therapy.name, + type: "therapies", + set: "Saved therapies", + meta: therapy.bestUsedFor ?? therapy.category ?? "Saved therapy record", + sourceMeta: therapy.reviewStatus === "reviewed" ? "Reviewed therapy" : "Source review required", + primaryAction: "Open", + href: `/therapy-compass/${therapy.slug}`, + icon: appModeIcons["therapy-compass"], + keywords: [therapy.name, therapy.category, therapy.modality, ...therapy.tags] + .filter(Boolean) + .join(" ") + .toLowerCase(), + } satisfies FavouriteItem, + ]; + }); + setTherapyLoad({ key: savedTherapyKey, items, status: "ready" }); + }) + .catch(() => { + if (active) setTherapyLoad({ key: savedTherapyKey, items: [], status: "error" }); + }); + return () => { + active = false; + }; + }, [savedTherapies, savedTherapyKey, therapyLoadAttempt]); const items = useMemo(() => { const savedServiceSet = new Set(savedServices); @@ -80,13 +144,17 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { icon: BrainCircuit, keywords: slug.replaceAll("-", " "), })); - return [...serviceItems, ...formItems, ...differentialItems]; - }, [services.records, forms.records, savedServices, savedForms, savedDifferentials]); + return [...serviceItems, ...formItems, ...differentialItems, ...currentTherapyLoad.items]; + }, [services.records, forms.records, savedServices, savedForms, savedDifferentials, currentTherapyLoad.items]); // Only a registry that was actually requested can report a fault: a disabled // hook sits in its initial state forever and must not be read as a failure. // Unauthorized outranks error because it is the one the reader can act on. - const requested = [savedServices.length > 0 ? services.status : null, savedForms.length > 0 ? forms.status : null]; + const requested = [ + savedServices.length > 0 ? services.status : null, + savedForms.length > 0 ? forms.status : null, + savedTherapies.length > 0 ? currentTherapyLoad.status : null, + ]; const rawRegistryStatus: SavedFavouritesBandStatus = requested.includes("unauthorized") ? "unauthorized" : requested.includes("error") || requested.includes("not_found") @@ -111,7 +179,24 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { if (isAuthenticated) reloadAccount(); if (savedServices.length > 0) refetchServices(); if (savedForms.length > 0) refetchForms(); - }, [isAuthenticated, reloadAccount, savedServices.length, savedForms.length, refetchServices, refetchForms]); + if (savedTherapies.length > 0) { + setTherapyLoad((current) => ({ + key: savedTherapyKey, + items: current.key === savedTherapyKey ? current.items : [], + status: "loading", + })); + setTherapyLoadAttempt((attempt) => attempt + 1); + } + }, [ + isAuthenticated, + reloadAccount, + savedServices.length, + savedForms.length, + savedTherapies.length, + savedTherapyKey, + refetchServices, + refetchForms, + ]); return { items, status, registryStatus, refetch }; } diff --git a/src/components/information-page-shell.tsx b/src/components/information-page-shell.tsx index c728d3243..b41b85613 100644 --- a/src/components/information-page-shell.tsx +++ b/src/components/information-page-shell.tsx @@ -15,8 +15,8 @@ import { cn, pageContainer } from "@/components/ui-primitives"; * - `narrow`: patient-facing reading width (`max-w-[64rem]`). * - `bleed`: children own horizontal padding (full-bleed headers, factsheet action bars). * - * Opt out (different product chrome): DocumentViewer, therapy-compass workspace, - * differentials presentation workflow. + * Opt out (different product chrome): DocumentViewer and differentials + * presentation workflow. */ export type InformationPageWidth = "default" | "narrow" | "bleed"; diff --git a/src/components/mode-nav/registry-mode-nav.tsx b/src/components/mode-nav/registry-mode-nav.tsx index 3479577dc..5956e9c16 100644 --- a/src/components/mode-nav/registry-mode-nav.tsx +++ b/src/components/mode-nav/registry-mode-nav.tsx @@ -7,7 +7,9 @@ import { ListChecks, Network, Search, + Sparkles, Stethoscope, + Waypoints, type LucideIcon, } from "lucide-react"; import { usePathname } from "next/navigation"; @@ -33,6 +35,7 @@ export const registryModeNavDensityProfiles = { formulation: "compact-four", differentials: "balanced-four", factsheets: "two-item", + "therapy-compass": "balanced-four", } as const satisfies Record; /** @@ -49,6 +52,8 @@ const iconByItemId: Record = { compare: GitCompareArrows, builder: ListChecks, map: Network, + recommend: Sparkles, + pathways: Waypoints, // The Factsheets hero glyph (`factsheets-home-page.tsx`), so the tab wears the // same mark as the surface it points at. Not LayoutGrid: the search page uses // that for its card/list view toggle, and one glyph must not mean two things diff --git a/src/components/page-secondary-navigation.tsx b/src/components/page-secondary-navigation.tsx index 620fe46ee..751759ad4 100644 --- a/src/components/page-secondary-navigation.tsx +++ b/src/components/page-secondary-navigation.tsx @@ -38,10 +38,6 @@ export function PageSecondaryNavigation({ const locallyOwnedInformationNavigation = hasLocalInformationPageNavigation(pathname); const activeId = activeModeSecondaryNavigationId(modeId, pathname); - // Therapy Compass owns both its workflow bindings and its dynamic detail - // sections inside TcProvider; rendering the shell registry as well would - // duplicate the bar and discard its URL/state-aware action bindings. - if (pathname === "/therapy-compass" || pathname.startsWith("/therapy-compass/")) return null; if (locallyOwnedInformationNavigation) return null; // A mode with no registered destinations gets no bar and no landmark. The // seven that used to register a lone `action` entry each rendered one diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index 818707485..f367a5aec 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -1,8 +1,21 @@ "use client"; -import { createContext, useContext, useMemo, useState, useDeferredValue, type ReactNode } from "react"; +import { createContext, useContext, useEffect, useMemo, useState, useDeferredValue, type ReactNode } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { + THERAPY_KNOWN_SCREENS, + THERAPY_MAX_COMPARE, + readTherapyWorkspaceState, + resolveTherapyRoute, + therapyHrefWithSearchParams, + therapyRecordHref, + therapyScreenHref, + therapyWorkspaceSearchParams, + type TherapySheetSection, + type TherapyWorkspaceState, +} from "@/lib/therapy-compass-navigation"; + import { useTherapyData } from "./data/use-therapy-data"; import { THERAPY_CATALOGUE_SUMMARY } from "./data/generated-assets"; import { @@ -16,52 +29,7 @@ import { } from "./data/select"; import type { Pathway, ReferenceData, Therapy } from "./data/types"; -export const KNOWN_SCREENS = [ - "search", - "detail", - "compare", - "recommend", - "pathways", - "brief", - "home", - "sheets", -] as const; -/** - * The screens with a module of their own. Anything else `resolveRoute` produces - * — `review` today — falls through to the generic screen, which is what - * `isOther` below reports. Exported so the mode nav can declare its item ids - * against it: an id that is not a screen name could never be matched by the - * `activeId` the nav passes. - */ -export type TherapyScreen = (typeof KNOWN_SCREENS)[number]; -export const MAX_COMPARE = 4; - -// Therapy Compass now owns a route family under this base. Screen state is derived -// from the pathname (not React state) so every destination is a real URL: Home is the -// base, the fixed workspaces are static children, and a therapy detail / brief / sheet -// is `${BASE}/[/brief|/sheet]`. Reserved segments never collide with therapy -// slugs (verified in scripts) so a first segment that is not reserved is a slug. -const BASE = "/therapy-compass"; -const RESERVED_SEGMENTS = new Set(["search", "recommend", "compare", "pathways", "review"]); - -function screenHref(screen: string): string { - return screen === "home" ? BASE : `${BASE}/${screen}`; -} - -/** Resolve the active screen + therapy slug from the current pathname. */ -function resolveRoute(pathname: string): { screen: string; slug: string | null } { - const rest = pathname.startsWith(BASE) ? pathname.slice(BASE.length).replace(/^\/+/, "") : ""; - const segments = rest ? rest.split("/") : []; - if (segments.length === 0) return { screen: "home", slug: null }; - const [first, second] = segments; - if (RESERVED_SEGMENTS.has(first)) return { screen: first, slug: null }; - // A non-reserved first segment is a therapy slug; the optional second segment - // selects the brief-intervention or patient-sheet sub-view. - const screen = second === "brief" ? "brief" : second === "sheet" ? "sheets" : "detail"; - return { screen, slug: first }; -} - -type SheetSectionKey = "about" | "steps" | "practice" | "coping" | "contacts"; +type SheetSectionKey = TherapySheetSection; export type TcBindings = { // ---- data ----------------------------------------------------------- @@ -83,23 +51,12 @@ export type TcBindings = { goRecommend: () => void; goCompare: () => void; goPathways: () => void; - goBrief: () => void; - goSheets: () => void; goDetail: () => void; goReview: () => void; + workspaceHref: (href: string) => string; isHome: boolean; isOther: boolean; otherLabel: string; - /** - * Real URLs for the two record-scoped destinations, resolved the same way - * `goBrief`/`goSheets` resolve their targets. `ModeNav` takes an href and - * never an onClick, so the resolution has to be a value rather than a - * handler — and one shared helper feeds both, so a link and its imperative - * twin can never disagree about where they go. - */ - briefHref: string; - sheetHref: string; - // ---- active therapy (detail / brief / sheet) ------------------------ selectedSlug: string | null; selectedTherapy: Therapy | null; @@ -151,32 +108,21 @@ export type TcBindings = { // ---- comparison tabs + density ------------------------------------- cmpTab: string; - tabPriorities: string; - tabDifferences: string; - tabAll: string; setTabPriorities: () => void; setTabDifferences: () => void; setTabAll: () => void; density: string; - segComfortable: string; - segDense: string; setComfortable: () => void; setDense: () => void; // ---- brief-intervention tabs --------------------------------------- briefTab: string; - brief5: string; - brief15: string; - briefGround: string; set5: () => void; set15: () => void; setGround: () => void; // ---- patient-sheet tone -------------------------------------------- sheetTone: string; - tonePlain: string; - toneWarm: string; - toneClinical: string; setTonePlain: () => void; setToneWarm: () => void; setToneClinical: () => void; @@ -199,31 +145,10 @@ export type TcBindings = { toggleContacts: () => void; sheetClinician: boolean; toggleClinician: () => void; - clinicianTrack: string; - clinicianKnob: string; - printSheet: () => void; }; const TcContext = createContext(null); -function tabStyle(active: boolean): string { - return [ - "inline-flex min-h-tap items-center justify-center border-0 border-b-2 border-b-transparent bg-transparent px-1 py-2.5 text-sm font-medium text-[color:var(--text-muted)]", - "hover:enabled:text-[color:var(--text)]", - active ? "border-b-[color:var(--clinical-accent)] font-semibold text-[color:var(--clinical-accent-hover)]" : "", - ] - .filter(Boolean) - .join(" "); -} -function segStyle(active: boolean): string { - return [ - "inline-flex min-h-tap items-center justify-center rounded-md border-0 bg-transparent px-4 py-[7px] text-sm-minus font-semibold text-[color:var(--text-muted)]", - "hover:enabled:bg-[color:var(--surface-subtle)] hover:enabled:text-[color:var(--text)]", - active ? "bg-[color:var(--surface)] text-[color:var(--clinical-accent-hover)] shadow-[var(--e1)]" : "", - ] - .filter(Boolean) - .join(" "); -} function chipStyle(active: boolean): string { return [ "inline-flex min-h-tap items-center justify-center rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-3.5 py-2 text-sm-minus font-semibold text-[color:var(--text-muted)]", @@ -240,7 +165,7 @@ export function TcProvider({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const { screen, slug: routeSlug } = resolveRoute(pathname); + const { screen, slug: routeSlug } = resolveTherapyRoute(pathname); const isHome = screen === "home"; // Search needs the complete prose corpus to preserve its existing weighted // matches (#1471). Home paints from generated summary metadata without a @@ -254,8 +179,8 @@ export function TcProvider({ children }: { children: ReactNode }) { }); const therapies = useMemo(() => data?.therapies ?? [], [data]); // Home reads the build-time summary rather than the catalogue so first paint - // never waits on catalogue I/O (`enabled: !isHome` above). The count and the - // default slugs are therefore only as true as the last generator run, which + // never waits on catalogue I/O (`enabled: !isHome` above). The count is + // therefore only as true as the last generator run, which // is why `npm run check:therapy-data-index` (build-therapies-index --check) // is load-bearing in verify:cheap - do not bypass it. If home ever lists real // therapy records rather than a count, it must re-enable `useTherapyData`; @@ -269,14 +194,19 @@ export function TcProvider({ children }: { children: ReactNode }) { // Non-navigational interaction state lives in the provider, which the layout // keeps mounted across the tool's routes so selections persist between screens. const qParam = (searchParams.get("q") ?? "").trim(); - const [selectedSlug, setSelectedSlug] = useState(null); - const [compareSlugs, setCompareSlugs] = useState([]); - const [search, setSearch] = useState(() => - qParam ? { ...EMPTY_SEARCH, query: qParam } : EMPTY_SEARCH, - ); + const workspaceFromUrl = readTherapyWorkspaceState(searchParams); + const [compareSlugs, setCompareSlugs] = useState(workspaceFromUrl.compareSlugs); + const [search, setSearch] = useState(() => ({ + ...EMPTY_SEARCH, + query: qParam, + tags: workspaceFromUrl.topics, + briefOnly: workspaceFromUrl.briefOnly, + sheetOnly: workspaceFromUrl.sheetOnly, + reviewedOnly: workspaceFromUrl.reviewedOnly, + })); const [recQuery, setRecQuery] = useState("What therapy for anxiety in outpatient care?"); - const [recConstraints, setRecConstraints] = useState(["outpatient"]); - const [selectedPathwaySlug, setSelectedPathwaySlug] = useState(null); + const [recConstraints, setRecConstraints] = useState(workspaceFromUrl.constraints); + const [selectedPathwaySlug, setSelectedPathwaySlug] = useState(workspaceFromUrl.pathwaySlug); // Seed the search query from a `?q=` deep link (universal-search "view all" or a // recent-search pick) and re-sync whenever the deep link changes, using the @@ -284,38 +214,67 @@ export function TcProvider({ children }: { children: ReactNode }) { // deep links is preserved without a setState-in-effect cascade. The sync is // unconditional (including an empty `q`) so navigating from `?q=act` back to a // query-less URL clears the stale query and the rendered state matches the URL. - const [seededQuery, setSeededQuery] = useState(qParam); - if (qParam !== seededQuery) { - setSeededQuery(qParam); - setSearch((prev) => ({ ...prev, query: qParam })); - } - - const [cmpTab, setCmpTab] = useState("differences"); - const [density, setDensity] = useState("comfortable"); - const [briefTab, setBriefTab] = useState("5min"); - const [sheetTone, setSheetTone] = useState("plain"); + const [cmpTab, setCmpTab] = useState(workspaceFromUrl.comparison); + const [density, setDensity] = useState(workspaceFromUrl.density); + const [briefTab, setBriefTab] = useState(workspaceFromUrl.duration); + const [sheetTone, setSheetTone] = useState(workspaceFromUrl.tone); const [sheetSections, setSheetSections] = useState>({ - about: true, - steps: true, - practice: true, - coping: true, - contacts: true, + about: workspaceFromUrl.sections.includes("about"), + steps: workspaceFromUrl.sections.includes("steps"), + practice: workspaceFromUrl.sections.includes("practice"), + coping: workspaceFromUrl.sections.includes("coping"), + contacts: workspaceFromUrl.sections.includes("contacts"), }); - const [sheetClinician, setSheetClinician] = useState(true); + const [sheetClinician, setSheetClinician] = useState(workspaceFromUrl.clinician); + + // Back/forward and shared links are authoritative for every non-sensitive + // workspace choice. Recommendation free text is intentionally absent: it may + // contain patient information and remains session-only in `recQuery`. + const urlStateKey = searchParams.toString(); + useEffect(() => { + const canonical = therapyWorkspaceSearchParams(searchParams, readTherapyWorkspaceState(searchParams)); + if (canonical.toString() === urlStateKey) return; + router.replace(therapyHrefWithSearchParams(pathname, canonical), { scroll: false }); + }, [pathname, router, searchParams, urlStateKey]); + const [seededQParam, setSeededQParam] = useState(qParam); + if (qParam !== seededQParam) { + setSeededQParam(qParam); + setSearch((prev) => ({ ...prev, query: qParam })); + } + const [seededUrlStateKey, setSeededUrlStateKey] = useState(urlStateKey); + if (urlStateKey !== seededUrlStateKey) { + setSeededUrlStateKey(urlStateKey); + setCompareSlugs(workspaceFromUrl.compareSlugs); + setSearch((prev) => ({ + ...prev, + tags: workspaceFromUrl.topics, + briefOnly: workspaceFromUrl.briefOnly, + sheetOnly: workspaceFromUrl.sheetOnly, + reviewedOnly: workspaceFromUrl.reviewedOnly, + })); + setRecConstraints(workspaceFromUrl.constraints); + setSelectedPathwaySlug(workspaceFromUrl.pathwaySlug); + setCmpTab(workspaceFromUrl.comparison); + setDensity(workspaceFromUrl.density); + setBriefTab(workspaceFromUrl.duration); + setSheetTone(workspaceFromUrl.tone); + setSheetSections({ + about: workspaceFromUrl.sections.includes("about"), + steps: workspaceFromUrl.sections.includes("steps"), + practice: workspaceFromUrl.sections.includes("practice"), + coping: workspaceFromUrl.sections.includes("coping"), + contacts: workspaceFromUrl.sections.includes("contacts"), + }); + setSheetClinician(workspaceFromUrl.clinician); + } const bySlug = useMemo(() => new Map(therapies.map((t) => [t.slug, t])), [therapies]); const unreviewedTherapies = useMemo(() => therapies.filter((t) => t.reviewStatus !== "reviewed"), [therapies]); - // Default selections once data arrives so detail/brief/sheet/pathways are never empty. - // A slug in the URL always wins; otherwise fall back to any imperatively-set slug, - // then the first therapy so the no-arg brief/sheet nav buttons have a target. - const defaultTherapy = - screen === "brief" - ? therapies.find((therapy) => therapy.briefInterventionAvailable) - : screen === "sheets" - ? therapies.find((therapy) => therapy.patientSheetAvailable) - : therapies[0]; - const effectiveSelectedSlug = routeSlug ?? selectedSlug ?? defaultTherapy?.slug ?? null; + // Record-owned outputs require an explicit slug in the URL. No workspace + // action is allowed to choose an unrelated default therapy on the reader's + // behalf. + const effectiveSelectedSlug = routeSlug; const selectedTherapy = effectiveSelectedSlug ? (bySlug.get(effectiveSelectedSlug) ?? null) : null; const effectivePathwaySlug = selectedPathwaySlug ?? pathways[0]?.slug ?? null; const selectedPathway = effectivePathwaySlug ? (pathways.find((p) => p.slug === effectivePathwaySlug) ?? null) : null; @@ -359,11 +318,42 @@ export function TcProvider({ children }: { children: ReactNode }) { ); const value = useMemo(() => { - const go = (next: string) => router.push(screenHref(next)); - const toggleSection = (key: SheetSectionKey) => setSheetSections((prev) => ({ ...prev, [key]: !prev[key] })); + const enabledSections = (Object.entries(sheetSections) as Array<[SheetSectionKey, boolean]>) + .filter(([, enabled]) => enabled) + .map(([key]) => key); + const workspaceState: TherapyWorkspaceState = { + compareSlugs, + topics: search.tags, + briefOnly: search.briefOnly, + sheetOnly: search.sheetOnly, + reviewedOnly: search.reviewedOnly, + constraints: recConstraints, + pathwaySlug: selectedPathwaySlug, + comparison: cmpTab, + density, + duration: briefTab, + tone: sheetTone, + sections: enabledSections, + clinician: sheetClinician, + }; + const workspaceParams = (patch: Partial = {}) => + therapyWorkspaceSearchParams(searchParams, { ...workspaceState, ...patch }); + const pushWorkspace = (href: string, patch: Partial = {}) => + router.push(therapyHrefWithSearchParams(href, workspaceParams(patch))); + const replaceWorkspace = (patch: Partial) => + router.replace(therapyHrefWithSearchParams(pathname, workspaceParams(patch)), { scroll: false }); + const go = (next: string) => pushWorkspace(therapyScreenHref(next)); + const toggleSection = (key: SheetSectionKey) => { + const nextSections = { ...sheetSections, [key]: !sheetSections[key] }; + setSheetSections(nextSections); + replaceWorkspace({ + sections: (Object.entries(nextSections) as Array<[SheetSectionKey, boolean]>) + .filter(([, enabled]) => enabled) + .map(([section]) => section), + }); + }; const patchSearch = (patch: Partial) => setSearch((prev) => ({ ...prev, ...patch })); - const openSlug = (slug: string, sub?: "brief" | "sheet") => - router.push(sub ? `${BASE}/${slug}/${sub}` : `${BASE}/${slug}`); + const openSlug = (slug: string, sub?: "brief" | "sheet") => pushWorkspace(therapyRecordHref(slug, sub)); // Unsupported artifact actions are a no-op. Call sites expose an honest disabled // state instead of silently sending the user to a different detail destination. const hasBrief = (slug: string | null | undefined) => @@ -375,26 +365,6 @@ export function TcProvider({ children }: { children: ReactNode }) { const openSheetOr = (slug: string) => { if (hasSheet(slug)) openSlug(slug, "sheet"); }; - // One resolver for both the href and the imperative push. The generated - // default is unconditional rather than home-only: a `` needs a real - // URL for SSR, prefetch and middle-click, and `THERAPY_CATALOGUE_SUMMARY` - // is static module data available before the catalogue has loaded. The - // consequence is deliberate — these destinations are always navigable, so - // with nothing selected they open the first record carrying the artifact. - const artifactSlug = (has: (therapy: Therapy) => boolean, fallback: string) => { - const selected = effectiveSelectedSlug ? bySlug.get(effectiveSelectedSlug) : undefined; - if (selected && has(selected)) return selected.slug; - return therapies.find(has)?.slug ?? fallback; - }; - const briefHref = `${BASE}/${artifactSlug( - (therapy) => therapy.briefInterventionAvailable, - THERAPY_CATALOGUE_SUMMARY.defaultBriefSlug, - )}/brief`; - const sheetHref = `${BASE}/${artifactSlug( - (therapy) => therapy.patientSheetAvailable, - THERAPY_CATALOGUE_SUMMARY.defaultSheetSlug, - )}/sheet`; - return { loading, error, @@ -413,17 +383,12 @@ export function TcProvider({ children }: { children: ReactNode }) { goRecommend: () => go("recommend"), goCompare: () => go("compare"), goPathways: () => go("pathways"), - // Same value the nav links to, so the button and the link can never - // disagree about the destination. - goBrief: () => router.push(briefHref), - goSheets: () => router.push(sheetHref), goDetail: () => (effectiveSelectedSlug ? openSlug(effectiveSelectedSlug) : go("home")), goReview: () => go("review"), + workspaceHref: (href) => therapyHrefWithSearchParams(href, workspaceParams()), isHome: screen === "home", - isOther: !KNOWN_SCREENS.includes(screen as TherapyScreen), + isOther: !THERAPY_KNOWN_SCREENS.includes(screen as (typeof THERAPY_KNOWN_SCREENS)[number]), otherLabel: screen.charAt(0).toUpperCase() + screen.slice(1), - briefHref, - sheetHref, selectedSlug: effectiveSelectedSlug, selectedTherapy, @@ -431,18 +396,10 @@ export function TcProvider({ children }: { children: ReactNode }) { open: (slug) => openSlug(slug), openBrief: (slug) => openBriefOr(slug), openSheet: (slug) => openSheetOr(slug), - // On a routed brief/sheet/detail screen the URL slug wins over `selectedSlug`, - // so a picker choice must navigate to the chosen therapy's matching subroute - // instead of only setting state. Artifact pickers expose only supported records; - // the guarded helpers remain no-ops for any stale or programmatic invalid choice. + // Artifact pickers always navigate to an explicit record URL. No local + // selection can become an invisible fallback for a later output action. select: (slug) => - screen === "brief" - ? openBriefOr(slug) - : screen === "sheets" - ? openSheetOr(slug) - : screen === "detail" - ? openSlug(slug) - : setSelectedSlug(slug), + screen === "brief" ? openBriefOr(slug) : screen === "sheets" ? openSheetOr(slug) : openSlug(slug), search, searchResults, @@ -451,77 +408,150 @@ export function TcProvider({ children }: { children: ReactNode }) { submitQuery: (q) => { patchSearch({ query: q }); const trimmed = q.trim(); - // Keep the query in the URL so the search screen is deep-linkable / shareable - // and the run-enabled link keeps rendering the tool (not the dashboard). - router.push(trimmed ? `${BASE}/search?q=${encodeURIComponent(trimmed)}&run=1` : `${BASE}/search`); + const params = workspaceParams(); + if (trimmed) { + params.set("q", trimmed); + params.set("run", "1"); + } else { + params.delete("q"); + params.delete("run"); + } + router.push(therapyHrefWithSearchParams(therapyScreenHref("search"), params)); + }, + toggleTag: (tag) => { + const tags = search.tags.includes(tag) ? search.tags.filter((x) => x !== tag) : [...search.tags, tag]; + patchSearch({ tags }); + replaceWorkspace({ topics: tags }); + }, + toggleBriefOnly: () => { + const briefOnly = !search.briefOnly; + patchSearch({ briefOnly }); + replaceWorkspace({ briefOnly }); + }, + toggleSheetOnly: () => { + const sheetOnly = !search.sheetOnly; + patchSearch({ sheetOnly }); + replaceWorkspace({ sheetOnly }); + }, + toggleReviewedOnly: () => { + const reviewedOnly = !search.reviewedOnly; + patchSearch({ reviewedOnly }); + replaceWorkspace({ reviewedOnly }); + }, + clearSearch: () => { + setSearch(EMPTY_SEARCH); + const params = workspaceParams({ topics: [], briefOnly: false, sheetOnly: false, reviewedOnly: false }); + params.delete("q"); + params.delete("run"); + router.replace(therapyHrefWithSearchParams(pathname, params), { scroll: false }); }, - toggleTag: (tag) => - setSearch((prev) => ({ - ...prev, - tags: prev.tags.includes(tag) ? prev.tags.filter((x) => x !== tag) : [...prev.tags, tag], - })), - toggleBriefOnly: () => setSearch((prev) => ({ ...prev, briefOnly: !prev.briefOnly })), - toggleSheetOnly: () => setSearch((prev) => ({ ...prev, sheetOnly: !prev.sheetOnly })), - toggleReviewedOnly: () => setSearch((prev) => ({ ...prev, reviewedOnly: !prev.reviewedOnly })), - clearSearch: () => setSearch(EMPTY_SEARCH), // Filter-only clear. The results-band shelf lists filters and says so // ("Filtered by"), so its Clear must not delete the search term the user // is reading — that is a control doing more than it advertises. - clearSearchFilters: () => setSearch((prev) => ({ ...EMPTY_SEARCH, query: prev.query })), + clearSearchFilters: () => { + setSearch((prev) => ({ ...EMPTY_SEARCH, query: prev.query })); + replaceWorkspace({ topics: [], briefOnly: false, sheetOnly: false, reviewedOnly: false }); + }, compareSlugs, compareTherapies, toggleCompare: (slug) => { - setCompareSlugs((prev) => - prev.includes(slug) ? prev.filter((x) => x !== slug) : prev.length >= MAX_COMPARE ? prev : [...prev, slug], - ); - go("compare"); + const next = compareSlugs.includes(slug) + ? compareSlugs.filter((value) => value !== slug) + : compareSlugs.length >= THERAPY_MAX_COMPARE + ? compareSlugs + : [...compareSlugs, slug]; + setCompareSlugs(next); + pushWorkspace(therapyScreenHref("compare"), { compareSlugs: next }); + }, + addCompare: (slug) => { + const next = + compareSlugs.includes(slug) || compareSlugs.length >= THERAPY_MAX_COMPARE + ? compareSlugs + : [...compareSlugs, slug]; + setCompareSlugs(next); + replaceWorkspace({ compareSlugs: next }); + }, + removeCompare: (slug) => { + const next = compareSlugs.filter((value) => value !== slug); + setCompareSlugs(next); + replaceWorkspace({ compareSlugs: next }); + }, + clearCompare: () => { + setCompareSlugs([]); + replaceWorkspace({ compareSlugs: [] }); }, - addCompare: (slug) => - setCompareSlugs((prev) => (prev.includes(slug) || prev.length >= MAX_COMPARE ? prev : [...prev, slug])), - removeCompare: (slug) => setCompareSlugs((prev) => prev.filter((x) => x !== slug)), - clearCompare: () => setCompareSlugs([]), isInCompare: (slug) => compareSlugs.includes(slug), recQuery, setRecQuery, recConstraints, - toggleConstraint: (key) => - setRecConstraints((prev) => (prev.includes(key) ? prev.filter((x) => x !== key) : [...prev, key])), + toggleConstraint: (key) => { + const next = recConstraints.includes(key) + ? recConstraints.filter((value) => value !== key) + : [...recConstraints, key]; + setRecConstraints(next); + replaceWorkspace({ constraints: next }); + }, recommendations, selectedPathwaySlug: effectivePathwaySlug, selectedPathway, - selectPathway: (slug) => setSelectedPathwaySlug(slug), + selectPathway: (slug) => { + setSelectedPathwaySlug(slug); + replaceWorkspace({ pathwaySlug: slug }); + }, cmpTab, - tabPriorities: tabStyle(cmpTab === "priorities"), - tabDifferences: tabStyle(cmpTab === "differences"), - tabAll: tabStyle(cmpTab === "all"), - setTabPriorities: () => setCmpTab("priorities"), - setTabDifferences: () => setCmpTab("differences"), - setTabAll: () => setCmpTab("all"), + setTabPriorities: () => { + setCmpTab("priorities"); + replaceWorkspace({ comparison: "priorities" }); + }, + setTabDifferences: () => { + setCmpTab("differences"); + replaceWorkspace({ comparison: "differences" }); + }, + setTabAll: () => { + setCmpTab("all"); + replaceWorkspace({ comparison: "all" }); + }, density, - segComfortable: segStyle(density === "comfortable"), - segDense: segStyle(density === "dense"), - setComfortable: () => setDensity("comfortable"), - setDense: () => setDensity("dense"), + setComfortable: () => { + setDensity("comfortable"); + replaceWorkspace({ density: "comfortable" }); + }, + setDense: () => { + setDensity("dense"); + replaceWorkspace({ density: "dense" }); + }, briefTab, - brief5: tabStyle(briefTab === "5min"), - brief15: tabStyle(briefTab === "15min"), - briefGround: tabStyle(briefTab === "ground"), - set5: () => setBriefTab("5min"), - set15: () => setBriefTab("15min"), - setGround: () => setBriefTab("ground"), + set5: () => { + setBriefTab("5min"); + replaceWorkspace({ duration: "5min" }); + }, + set15: () => { + setBriefTab("15min"); + replaceWorkspace({ duration: "15min" }); + }, + setGround: () => { + setBriefTab("ground"); + replaceWorkspace({ duration: "ground" }); + }, sheetTone, - tonePlain: segStyle(sheetTone === "plain"), - toneWarm: segStyle(sheetTone === "warm"), - toneClinical: segStyle(sheetTone === "clinical"), - setTonePlain: () => setSheetTone("plain"), - setToneWarm: () => setSheetTone("warm"), - setToneClinical: () => setSheetTone("clinical"), + setTonePlain: () => { + setSheetTone("plain"); + replaceWorkspace({ tone: "plain" }); + }, + setToneWarm: () => { + setSheetTone("warm"); + replaceWorkspace({ tone: "warm" }); + }, + setToneClinical: () => { + setSheetTone("clinical"); + replaceWorkspace({ tone: "clinical" }); + }, secAbout: sheetSections.about, secSteps: sheetSections.steps, @@ -539,15 +569,16 @@ export function TcProvider({ children }: { children: ReactNode }) { toggleCoping: () => toggleSection("coping"), toggleContacts: () => toggleSection("contacts"), sheetClinician, - toggleClinician: () => setSheetClinician((prev) => !prev), - clinicianTrack: "", - clinicianKnob: "", - printSheet: () => { - if (typeof window !== "undefined") window.print(); + toggleClinician: () => { + const clinician = !sheetClinician; + setSheetClinician(clinician); + replaceWorkspace({ clinician }); }, }; }, [ router, + pathname, + searchParams, loading, error, retry, @@ -570,6 +601,7 @@ export function TcProvider({ children }: { children: ReactNode }) { recConstraints, recommendations, effectivePathwaySlug, + selectedPathwaySlug, selectedPathway, cmpTab, density, diff --git a/src/components/therapy-compass/data/select.ts b/src/components/therapy-compass/data/select.ts index c0321faaa..771ef2625 100644 --- a/src/components/therapy-compass/data/select.ts +++ b/src/components/therapy-compass/data/select.ts @@ -1,4 +1,5 @@ import type { Therapy } from "./types"; +import { scoreTherapyCandidate } from "@/lib/therapy-ranking"; // ---- text helpers ------------------------------------------------------- @@ -168,35 +169,18 @@ export function matchesAvailability(therapy: Therapy, reviewedOnly: boolean, bri return true; } -function scoreTherapy(t: Therapy, q: string): number { - if (!q) return 1; - const name = lc(t.name); - const tags = t.tags.map(lc); - let score = 0; - if (name === q) score += 100; - if (name.startsWith(q)) score += 40; - if (name.includes(q)) score += 20; - if (t.aliases.some((a) => lc(a).includes(q))) score += 18; - if (tags.some((tag) => tag.includes(q))) score += 14; - if (lc(t.category).includes(q)) score += 8; - if (lc(t.bestUsedFor).includes(q)) score += 6; - if (lc(t.targetSymptoms).includes(q)) score += 5; - if (lc(t.clinicalSummary).includes(q)) score += 3; - if (lc(t.indications).includes(q)) score += 3; - return score; -} - export function searchTherapies(therapies: Therapy[], opts: SearchOptions): Therapy[] { const q = opts.query.trim().toLowerCase(); const topics = new Set(opts.tags); const scored = therapies - .filter((t) => { - if (!matchesAvailability(t, opts.reviewedOnly, opts.briefOnly)) return false; - if (opts.sheetOnly && !t.patientSheetAvailable) return false; - if (!matchesTopics(t, topics)) return false; - return scoreTherapy(t, q) > 0; + .map((t) => { + if (!matchesAvailability(t, opts.reviewedOnly, opts.briefOnly)) return null; + if (opts.sheetOnly && !t.patientSheetAvailable) return null; + if (!matchesTopics(t, topics)) return null; + const score = scoreTherapyCandidate(t, q); + return score > 0 ? { t, s: score } : null; }) - .map((t) => ({ t, s: scoreTherapy(t, q) })); + .filter((candidate): candidate is { t: Therapy; s: number } => candidate !== null); scored.sort((a, b) => b.s - a.s || a.t.name.localeCompare(b.t.name)); return scored.map((x) => x.t); } @@ -279,7 +263,7 @@ export function rankRecommendations( const cons = RECOMMEND_CONSTRAINTS.filter((c) => constraintKeys.includes(c.key)); const scored = therapies.map((t) => { let score = 0; - if (q) score += Math.min(scoreTherapy(t, q), 60); + if (q) score += Math.min(scoreTherapyCandidate(t, q), 60); for (const c of cons) if (c.match(t)) score += 10; if (t.reviewStatus === "reviewed") score += 4; if (typeof t.indexCompleteness === "number") score += t.indexCompleteness / 100; diff --git a/src/components/therapy-compass/nav.tsx b/src/components/therapy-compass/nav.tsx deleted file mode 100644 index efe7f9959..000000000 --- a/src/components/therapy-compass/nav.tsx +++ /dev/null @@ -1,81 +0,0 @@ -"use client"; - -import { Columns3, FileText, House, Search, Sparkles, Timer, Waypoints } from "lucide-react"; - -import { ModeNav, type ModeNavItem } from "@/components/mode-nav/mode-nav"; - -import { MAX_COMPARE, useTcBindings, type TherapyScreen } from "./bindings"; - -const BASE = "/therapy-compass"; - -/** - * Every id is a `resolveRoute` screen name. That is what lets `TherapyModeNav` - * hand `ModeNav` the current screen as its `activeId` with no second pathname - * parser and no lookup table in between, and the type is the guard: rename a - * screen and this stops compiling rather than quietly un-highlighting a tab. - * - * `review` and `detail` have no entry, and their screen names therefore resolve - * to "no active item" — correct for both. Review is a governance queue rather - * than a step in delivering care, reachable from the detail and pathway - * screens; a record is not a destination. - */ -type TherapyNavItem = ModeNavItem & { id: TherapyScreen }; - -/** - * Therapy's pages for the shared `ModeNav`, in declared order. - * - * Order is load-bearing, not editorial: at three slots the survivors are the - * first two, so the library door and the only destination carrying state are - * the ones that stay on the bar at every width. Everything after Pathways is - * reached through More at every band — which is why the two long labels and - * Home sit there rather than competing for a slot they would never win. - * - * Home duplicates the mode pill directly above it and is the one entry the - * app-wide secondary-navigation registry deliberately omits for every mode - * (`src/lib/mode-secondary-navigation.ts`); it is here because Therapy's menu - * is meant to list every Therapy page, and it never occupies a tab. - * - * Brief intervention and Patient sheet act on a selected therapy. Their hrefs - * resolve through `briefHref`/`sheetHref`, which fall back to the first - * catalogue record carrying that artifact when nothing is selected — so with - * no selection they open a therapy the reader was not looking at. That is a - * deliberate product decision, not an oversight; the availability-guarded - * buttons on the record page (`screens/detail-screen.tsx`) remain the safe - * route for a specific therapy. - */ -function useTherapyNavItems(): TherapyNavItem[] { - const b = useTcBindings(); - - return [ - { id: "search", label: "Search", href: `${BASE}/search`, icon: Search }, - { - id: "compare", - label: "Compare", - href: `${BASE}/compare`, - icon: Columns3, - // Fill, not catalogue size: the basket holds four and the strip never - // showed how many were in it. - count: `${b.compareSlugs.length}/${MAX_COMPARE}`, - }, - { id: "recommend", label: "Recommend", href: `${BASE}/recommend`, icon: Sparkles }, - { id: "pathways", label: "Pathways", href: `${BASE}/pathways`, icon: Waypoints }, - { id: "home", label: "Home", href: BASE, icon: House }, - { id: "brief", label: "Brief Intervention", href: b.briefHref, icon: Timer }, - { id: "sheets", label: "Patient Sheets", href: b.sheetHref, icon: FileText }, - ]; -} - -/** - * The shared mode bar, pinned inside the universal header at every width. - * - * `activeId` is passed rather than left to `ModeNav`'s own path matching, which - * treats a prefix as a match: Home's href is the mode base, so every Therapy - * route starts with it and Home would read as the current page everywhere. - * `b.screen` is the single canonical pathname-to-screen mapping and already - * answers the question exactly. - */ -export function TherapyModeNav() { - const b = useTcBindings(); - const items = useTherapyNavItems(); - return ; -} diff --git a/src/components/therapy-compass/screens/brief-screen.tsx b/src/components/therapy-compass/screens/brief-screen.tsx index d59a2d804..ec393a079 100644 --- a/src/components/therapy-compass/screens/brief-screen.tsx +++ b/src/components/therapy-compass/screens/brief-screen.tsx @@ -2,7 +2,10 @@ import { useMemo, useState } from "react"; -import { pageContainer } from "@/components/ui-primitives"; +import { InformationPageFooter, InformationPageShell } from "@/components/information-page-shell"; +import { Tabs } from "@/components/ui/tabs"; +import { BrowserPrintButton, PrintOutput } from "@/components/ui/print-output"; +import { therapyRecordHref } from "@/lib/therapy-compass-navigation"; import { useTcBindings } from "../bindings"; import { commandControl, outlineControl, therapyBtn } from "../controls"; @@ -10,6 +13,7 @@ import { parseSteps, summarise } from "../data/select"; import { AlertIcon, CheckIcon, CopyIcon, ExternalLinkIcon, FileTextIcon, SearchIcon } from "../icons"; import { LoadingState } from "../ui"; import { useClipboard } from "../use-clipboard"; +import { TherapyRecordNavHeader } from "../therapy-record-nav-header"; const CHECKLIST = [ "Confirm the primary problem", @@ -60,258 +64,271 @@ export function BriefScreen() { ].join("\n"); return ( -
-
-
-

- Brief Intervention -

-

- Fast scripts and steps drawn from each record’s delivery fields. -

-
-
- -
-
- -
- - - -
- -
- {/* records list */} -
- -
- {briefTherapies.map((x) => { - const active = x.slug === t.slug; - return ( - - ); - })} -
-
- Showing {briefTherapies.length} records -
-
- - {/* brief detail */} -
-
-
-
-

{t.name}

- - {durationLabel} mode - - - {t.reviewStatus === "reviewed" ? "Reviewed" : "Clinician review required"} - -
+ <> + + +
+
+
+

+ Brief Intervention +

+

+ Fast scripts and steps drawn from each record’s delivery fields. +

+
+
+
-
- - - - -
-
-
-
- {durationLabel} delivery -
- {steps.length ? ( -
- {steps.map((step, i) => ( -
- { + if (value === "15min") b.set15(); + else if (value === "ground") b.setGround(); + else b.set5(); + }} + items={[ + { id: "5min", label: "5 minutes" }, + { id: "15min", label: "15 minutes" }, + { id: "ground", label: "Grounding now" }, + ]} + > +
+ {/* records list */} +
+ +
+ {briefTherapies.map((x) => { + const active = x.slug === t.slug; + return ( + + ); + })} +
+
+ Showing {briefTherapies.length} records +
+
+ + {/* brief detail */} + +
+
+
+

{t.name}

+ + {durationLabel} mode + + + {t.reviewStatus === "reviewed" ? "Reviewed" : "Clinician review required"} -
-
{step}
- -
- ))} + +
+
+ + + + +
- ) : ( -

- No structured {durationLabel.toLowerCase()} steps in this record yet.{" "} - {t.briefVersion - ? "Use the source brief version and the clinician script below." - : "Open the full record for delivery guidance."} -

- )} - {t.clinicianScripts.length ? ( -
-
- CLINICIAN SCRIPT -
- {t.clinicianScripts.slice(0, 2).map((c, i) => ( -
- {c.scriptType ? ( -
- {c.scriptType} +
+
+
+ {durationLabel} delivery +
+ {steps.length ? ( +
+ {steps.map((step, i) => ( +
+ + {i + 1} + +
+
{step}
+ +
+
+ ))} +
+ ) : ( +

+ No structured {durationLabel.toLowerCase()} steps in this record yet.{" "} + {t.briefVersion + ? "Use the source brief version and the clinician script below." + : "Open the full record for delivery guidance."} +

+ )} + + {t.clinicianScripts.length ? ( +
+
+ CLINICIAN SCRIPT
- ) : null} -

{c.body}

+ {t.clinicianScripts.slice(0, 2).map((c, i) => ( +
+ {c.scriptType ? ( +
+ {c.scriptType} +
+ ) : null} +

{c.body}

+
+ ))} +
+ ) : null} +
+ +
+
+ Before use
- ))} +
+ {CHECKLIST.map((item) => ( + + + {item} + + ))} +
+
+ + + Clinical review is required before saving or sharing. + +
+
- ) : null} -
-
-
Before use
-
- {CHECKLIST.map((item) => ( - - - {item} - - ))} -
-
- - - Clinical review is required before saving or sharing. - -
+
+ + +
+
-
- -
- - -
-
-
-
+ + + Clinical review is required before saving, sharing, or using this intervention. + +
+ + ); } diff --git a/src/components/therapy-compass/screens/compare-screen.tsx b/src/components/therapy-compass/screens/compare-screen.tsx index 81d2abf4c..c65c4a1af 100644 --- a/src/components/therapy-compass/screens/compare-screen.tsx +++ b/src/components/therapy-compass/screens/compare-screen.tsx @@ -1,10 +1,14 @@ "use client"; -import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { pageContainer } from "@/components/ui-primitives"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { Tabs } from "@/components/ui/tabs"; -import { MAX_COMPARE, useTcBindings } from "../bindings"; +import { THERAPY_MAX_COMPARE } from "@/lib/therapy-compass-navigation"; + +import { useTcBindings } from "../bindings"; import { commandControl, outlineControl, therapyBtn } from "../controls"; import { needsReviewCount, parseSteps, searchTherapies, shortestDelivery, summarise } from "../data/select"; import type { Therapy } from "../data/types"; @@ -92,9 +96,7 @@ export function CompareScreen() { "set", ); - const cols = `minmax(180px,1.1fr) ${items.map(() => "minmax(160px,1fr)").join(" ")}`; const dense = b.density === "dense"; - const cellPad = dense ? "11px 16px" : "15px 20px"; return (
@@ -113,24 +115,16 @@ export function CompareScreen() {

-
- - -
+ (value === "dense" ? b.setDense() : b.setComfortable())} + options={[ + { value: "comfortable", label: "Comfortable" }, + { value: "dense", label: "Dense" }, + ]} + className="w-auto" + /> - - -
- - {/* table */} -
-
-
Field
- {items.map((t) => ( -
-
- - {t.name} -
-
- {t.reviewStatus === "reviewed" ? "Reviewed" : "Needs review"} -
-
- ))} + + + + + + {items.map((t) => ( + + ))} + + + + {rows.map((r, ri) => { + const warn = r.tone === "warning"; + const stripe = ri % 2 === 1; + const rowTone = warn + ? "bg-[color:var(--warning-bg)] text-[color:var(--warning-text)]" + : stripe + ? "bg-[color:var(--surface-subtle)]" + : "bg-[color:var(--surface)]"; + return ( + + + {items.map((t) => ( + + ))} + + ); + })} + +
Therapy comparison by clinical field
+ Field + +
+ + {t.name} +
+
+ {t.reviewStatus === "reviewed" ? "Reviewed" : "Needs review"} +
+
+ + + {r.label} + + + {r.get(t)} +
- {rows.map((r, ri) => { - const warn = r.tone === "warning"; - const stripe = ri % 2 === 1; - return ( -
-
- - {r.label} -
- {items.map((t) => ( -
- {r.get(t)} -
- ))} -
- ); - })} -
-
- - Comparisons are source-grounded. Review status reflects the latest source checks. -
+
+ + Comparisons are source-grounded. Review status reflects the latest source checks. +
+ )}
@@ -314,7 +320,7 @@ function SummaryCell({ function AddPicker() { const b = useTcBindings(); const [q, setQ] = useState(""); - const atLimit = b.compareSlugs.length >= MAX_COMPARE; + const atLimit = b.compareSlugs.length >= THERAPY_MAX_COMPARE; const matches = useMemo(() => { if (atLimit || !q.trim()) return []; return searchTherapies(b.therapies, { query: q, tags: [], briefOnly: false, sheetOnly: false, reviewedOnly: false }) diff --git a/src/components/therapy-compass/screens/detail-screen.tsx b/src/components/therapy-compass/screens/detail-screen.tsx index d29824ec9..759d0f120 100644 --- a/src/components/therapy-compass/screens/detail-screen.tsx +++ b/src/components/therapy-compass/screens/detail-screen.tsx @@ -2,8 +2,10 @@ import type { ReactNode } from "react"; -import { ContextualBackLink } from "@/components/contextual-back-link"; -import { pageContainer } from "@/components/ui-primitives"; +import { InformationPageFooter, InformationPageShell } from "@/components/information-page-shell"; +import { SourceDesignationBadge, SourceStatusBadge } from "@/components/ui-primitives"; +import { therapyScreenHref } from "@/lib/therapy-compass-navigation"; +import { therapySourceMetadata } from "@/lib/therapy-source-governance"; import { useTcBindings } from "../bindings"; import { card, heroCard, outlineControl, therapyBtn } from "../controls"; @@ -11,7 +13,6 @@ import { complexityLabel, parseSteps, summarise } from "../data/select"; import type { Therapy } from "../data/types"; import { AlertIcon, - ArrowLeftIcon, ChecklistIcon, ChevronRightIcon, ClockIcon, @@ -20,246 +21,288 @@ import { DatabaseIcon, FileTextIcon, InfoIcon, + HeartIcon, PersonIcon, ScaleIcon, ShieldIcon, } from "../icons"; +import { TherapyRecordNavHeader } from "../therapy-record-nav-header"; import { Eyebrow, LoadingState, StatusBadge, TagRow } from "../ui"; +import { useTherapyFavourite } from "../use-therapy-favourite"; export function DetailScreen() { const b = useTcBindings(); const t = b.selectedTherapy; + const favourite = useTherapyFavourite(t?.slug ?? null); if (!t) return ; const steps = parseSteps(t.deliverySteps); + const { notice, saved, toggleFavourite } = favourite; return ( -
- - - Back to results - - -
-
- {/* HERO */} -
-
- - {t.complexity ? ( - - {complexityLabel(t.complexity)} - - ) : null} - {t.modality ? ( - - {t.modality} - - ) : null} -
-

- {t.name} -

- {t.aliases.length ? ( -
- Also known as {t.aliases.join(", ")} + <> + + +
+
+
+ {/* HERO */} +
+
+ + {t.complexity ? ( + + {complexityLabel(t.complexity)} + + ) : null} + {t.modality ? ( + + {t.modality} + + ) : null} + +
+

+ {notice} +

+

+ {t.name} +

+ {t.aliases.length ? ( +
+ Also known as {t.aliases.join(", ")} +
+ ) : ( +
{t.category}
+ )} + {t.clinicalSummary ? ( +

+ {t.clinicalSummary} +

+ ) : null} +
- ) : ( -
{t.category}
- )} - {t.clinicalSummary ? ( -

- {t.clinicalSummary} -

- ) : null} - -
- - {/* QUICK TILES */} -
- - - - -
- - {/* BODY */} -
- {t.mechanism ? : null} - - {steps.length ? ( - - {steps.map((step, i) => ( -
  • - {step} -
  • - ))} - - } - /> - ) : ( - - )} - -
    - {/* ACTIONS */} -
    - {t.patientSheetAvailable ? ( - - ) : null} - - {t.briefInterventionAvailable ? ( - - ) : null} - -
    -
    + {/* QUICK TILES */} +
    + + + + +
    - {/* RIGHT RAIL */} -
    -
    -
    At a glance
    -
    - - - -
    -
    + {/* BODY */} +
    + {t.mechanism ? : null} + + {steps.length ? ( + + {steps.map((step, i) => ( +
  • + {step} +
  • + ))} + + } + /> + ) : ( + + )} + +
    - {b.relatedForSelected.length ? ( -
    -
    Related therapies
    -
    - {b.relatedForSelected.map((r, i, arr) => ( + {/* ACTIONS */} +
    + {t.patientSheetAvailable ? ( + ) : null} + + {t.briefInterventionAvailable ? ( + - ))} + ) : null} +
    - ) : null} -
    -
    - - Source provenance -
    -
    - {t.sources.length ? ( - t.sources.slice(0, 3).map((src, i) => ( -
    - Source:{" "} - - {src.title ?? src.sourceType ?? "Uploaded source"} - + {/* RIGHT RAIL */} +
    +
    +
    At a glance
    +
    + + + +
    +
    + + {b.relatedForSelected.length ? ( +
    +
    Related therapies
    +
    + {b.relatedForSelected.map((r, i, arr) => ( + + ))} +
    +
    + ) : null} + +
    +
    + + Source provenance +
    +
    + {t.sources.length ? ( + t.sources.slice(0, 3).map((src, i) => ( +
    + + {src.title ?? src.reference ?? src.sourceType ?? "Source title not provided"} + + {src.reference && src.reference !== src.title ? ( + {src.reference} + ) : null} + + + + +
    + )) + ) : ( +
    + Source:{" "} + + {t.sourceNotes ? "Referenced record" : "Single therapy record"} + +
    + )} +
    + Review:{" "} + + {t.reviewStatus === "reviewed" ? "Reviewed" : "Not yet provided"} +
    - )) - ) : ( -
    - Source:{" "} - - {t.sourceNotes ? "Referenced record" : "Single therapy record"} -
    - )} -
    - Review:{" "} - - {t.reviewStatus === "reviewed" ? "Reviewed" : "Not yet provided"} -
    -
    -
    -
    + + Decision support — verify the record and linked source before clinical use. + +
    + + ); } diff --git a/src/components/therapy-compass/screens/home-screen.tsx b/src/components/therapy-compass/screens/home-screen.tsx index 00ddb42de..474ccd72a 100644 --- a/src/components/therapy-compass/screens/home-screen.tsx +++ b/src/components/therapy-compass/screens/home-screen.tsx @@ -1,10 +1,11 @@ "use client"; -import { FileText, Network, Search, Sparkles, Waypoints } from "lucide-react"; +import { GitCompareArrows, Network, Search, Sparkles, Waypoints } from "lucide-react"; import { useRouter } from "next/navigation"; import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { therapyHrefWithSearchParams, therapyScreenHref } from "@/lib/therapy-compass-navigation"; import { THERAPY_CATALOGUE_SUMMARY } from "../data/generated-assets"; @@ -39,25 +40,31 @@ export function HomeScreen() { title: "Recommend a therapy", description: "Match a clinical question to indexed options.", icon: Sparkles, - href: "/therapy-compass/recommend", + href: therapyScreenHref("recommend"), }, { title: "Open a pathway", description: "Problem-based, step-by-step workflows.", icon: Waypoints, - href: "/therapy-compass/pathways", + href: therapyScreenHref("pathways"), }, { - title: "Create a patient sheet", - description: "Design and print a plain-language handout.", - icon: FileText, - href: `/therapy-compass/${THERAPY_CATALOGUE_SUMMARY.defaultSheetSlug}/sheet`, + title: "Compare therapies", + description: "Compare clinical fit, cautions and delivery.", + icon: GitCompareArrows, + href: therapyScreenHref("compare"), }, ]} pillsTitle="Common therapy searches" pills={SUGGESTIONS.map((suggestion) => ({ label: suggestion, - onClick: () => router.push(`/therapy-compass/search?q=${encodeURIComponent(suggestion)}&run=1`), + onClick: () => + router.push( + therapyHrefWithSearchParams( + therapyScreenHref("search"), + new URLSearchParams({ q: suggestion, run: "1" }), + ), + ), icon: Network, }))} footer={ diff --git a/src/components/therapy-compass/screens/search-screen.tsx b/src/components/therapy-compass/screens/search-screen.tsx index 894554c24..b81fab981 100644 --- a/src/components/therapy-compass/screens/search-screen.tsx +++ b/src/components/therapy-compass/screens/search-screen.tsx @@ -6,6 +6,7 @@ import { SearchResultsEmptyState, SearchResultsHeaderBand, } from "@/components/clinical-dashboard/search-results-header-band"; +import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { ResultFilterFacetChips, ResultFilterSheet, @@ -220,8 +221,8 @@ export function SearchScreen() { query={q} appliedFilters={appliedFilters} onClearFilters={b.clearSearchFilters} - // Query-only zero results otherwise have no filter chip, example, - // or cross-mode action. Restore a one-tap escape without relabeling + // Query-only zero results otherwise have no filter chip or example. + // Restore a one-tap escape without relabeling // a query reset as a filter operation. onClearSearch={b.clearSearch} /> @@ -234,6 +235,10 @@ export function SearchScreen() { )} )} + + {!b.error && !b.loading ? ( + + ) : null} ); } diff --git a/src/components/therapy-compass/screens/sheets-screen.tsx b/src/components/therapy-compass/screens/sheets-screen.tsx index 079ae7899..5b1e2dc40 100644 --- a/src/components/therapy-compass/screens/sheets-screen.tsx +++ b/src/components/therapy-compass/screens/sheets-screen.tsx @@ -2,13 +2,18 @@ import { useMemo, useState, type ReactNode } from "react"; -import { pageContainer } from "@/components/ui-primitives"; +import { InformationPageFooter, InformationPageShell } from "@/components/information-page-shell"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { BrowserPrintButton, PrintOutput } from "@/components/ui/print-output"; +import { ToggleSwitch } from "@/components/ui-primitives"; +import { therapyRecordHref } from "@/lib/therapy-compass-navigation"; import { useTcBindings } from "../bindings"; import { parseSteps, searchTherapies } from "../data/select"; -import { ChevronDownIcon, PrinterIcon, ScaleIcon, SearchIcon } from "../icons"; +import { ChevronDownIcon, ScaleIcon, SearchIcon } from "../icons"; import { LoadingState } from "../ui"; import { therapyBtn } from "../controls"; +import { TherapyRecordNavHeader } from "../therapy-record-nav-header"; export function SheetsScreen() { const b = useTcBindings(); @@ -27,273 +32,266 @@ export function SheetsScreen() { const sheetTitle = t.name.replace(/\s*\([^)]*\)\s*$/, ""); return ( -
    -
    -
    -

    - Patient Sheet Builder -

    -

    - Design, personalise and print a plain-language handout from a source-grounded record. -

    -
    -
    - -
    -
    - -
    - {/* BUILDER */} -
    -
    -
    Therapy
    - -
    - Reading level & tone + <> + + +
    +
    +
    +

    + Patient Sheet Builder +

    +

    + Design, personalise and print a plain-language handout from a source-grounded record. +

    -
    - - - +
    +
    -
    -
    Sections
    -

    Toggle what appears on the sheet.

    -
    - - - - - -
    -
    +
    + {/* BUILDER */} +
    +
    +
    Therapy
    + +
    + Reading level & tone +
    + { + if (value === "warm") b.setToneWarm(); + else if (value === "clinical") b.setToneClinical(); + else b.setTonePlain(); + }} + options={[ + { value: "plain", label: "Plain" }, + { value: "warm", label: "Warm" }, + { value: "clinical", label: "Clinical" }, + ]} + /> +
    -
    -
    - - - Clinician footer - - - Name, service and review date. - - - -
    -

    - Tip: every heading and paragraph on the sheet is editable — click to rewrite it before printing. Wording - follows the {toneWord} tone. -

    -
    -
    +
    +
    Sections
    +

    + Toggle what appears on the sheet. +

    +
    + + + + + +
    +
    - {/* PAPER */} -
    -
    -
    -
    - - - - - Therapy · Patient information - +
    +
    + + + Clinician footer + + + Name, service and review date. + + + +
    +

    + Tip: every heading and paragraph on the sheet is editable — click to rewrite it before printing. + Wording follows the {toneWord} tone. +

    - Prepared for you
    -

    - {sheetTitle} -

    -

    - {t.bestUsedFor && t.bestUsedFor.length < 70 && !/^(most|the|a |an )/i.test(t.bestUsedFor) - ? `A step-by-step plan to help with ${t.bestUsedFor.toLowerCase()}.` - : `A plain-language plan to help you get the most from ${sheetTitle.toLowerCase()}.`} -

    - - {b.secAbout && about ? {about} : null} - - {b.secSteps && steps.length ? ( -
    -

    - Your plan -

    -
    - {steps.map((step, i) => ( -
    - - {i + 1} - -

    - {step} -

    -
    - ))} + {/* PAPER */} +
    + +
    +
    + + + + + Therapy · Patient information + +
    + Prepared for you
    -
    - ) : null} - {b.secPractice ? ( -
    -

    - Practice at home -

    + {sheetTitle} +

    - {t.homework || - "Try the steps above between sessions. Note what you did and how it felt, and bring this to your next appointment."} + {t.bestUsedFor && t.bestUsedFor.length < 70 && !/^(most|the|a |an )/i.test(t.bestUsedFor) + ? `A step-by-step plan to help with ${t.bestUsedFor.toLowerCase()}.` + : `A plain-language plan to help you get the most from ${sheetTitle.toLowerCase()}.`}

    -
    - ) : null} - {b.secCoping ? ( - - Some days will feel harder than others — that’s normal. Make the step smaller rather than skipping - it. If your distress rises sharply or you have thoughts of harming yourself, use the contacts below - straight away. - - ) : null} + {b.secAbout && about ? {about} : null} - {b.secContacts ? ( -
    -

    - Support contacts -

    -
    - Your clinician: ______________________ · Phone: ______________ -
    - In a crisis, call your local emergency number or a 24/7 crisis line. -
    -
    - ) : null} + {b.secSteps && steps.length ? ( +
    +

    + Your plan +

    +
    + {steps.map((step, i) => ( +
    + + {i + 1} + +

    + {step} +

    +
    + ))} +
    +
    + ) : null} - {b.sheetClinician ? ( -
    - - Clinician: ____________________ - - - Service: ____________________ - - - Reviewed: __ / __ / ____ - -
    - ) : null} + {b.secPractice ? ( +
    +

    + Practice at home +

    +

    + {t.homework || + "Try the steps above between sessions. Note what you did and how it felt, and bring this to your next appointment."} +

    +
    + ) : null} + + {b.secCoping ? ( + + Some days will feel harder than others — that’s normal. Make the step smaller rather than + skipping it. If your distress rises sharply or you have thoughts of harming yourself, use the + contacts below straight away. + + ) : null} + + {b.secContacts ? ( +
    +

    + Support contacts +

    +
    + Your clinician: ______________________ · Phone: ______________ +
    + In a crisis, call your local emergency number or a 24/7 crisis line. +
    +
    + ) : null} + + {b.sheetClinician ? ( +
    + + Clinician: ____________________ + + + Service: ____________________ + + + Reviewed: __ / __ / ____ + +
    + ) : null} + +
    -
    -
    -
    +
    + + Patient information generated from a source-grounded record — review before sharing. + +
    +
    + + ); } diff --git a/src/components/therapy-compass/therapy-card.tsx b/src/components/therapy-compass/therapy-card.tsx index ffc98e191..791be2749 100644 --- a/src/components/therapy-compass/therapy-card.tsx +++ b/src/components/therapy-compass/therapy-card.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from "react"; -import { ignoreUnavailableActivation } from "@/components/ui-primitives"; import { useTcBindings } from "./bindings"; import { cardPreviewText, prioritiseTherapyTags, summarise } from "./data/select"; import type { Therapy } from "./data/types"; @@ -18,10 +17,12 @@ import { ScaleIcon, } from "./icons"; import { Eyebrow, IconTile, TagRow } from "./ui"; +import { useTherapyFavourite } from "./use-therapy-favourite"; /** Large search-result card with why-matched / avoid / best-fit columns. */ export function ResultCard({ therapy }: { therapy: Therapy }) { const b = useTcBindings(); + const { notice, saved, toggleFavourite } = useTherapyFavourite(therapy.slug); const inCompare = b.isInCompare(therapy.slug); const subtitle = cardPreviewText(therapy.clinicalSummary, { exclude: therapy.name }) || @@ -54,12 +55,12 @@ export function ResultCard({ therapy }: { therapy: Therapy }) {
    @@ -85,6 +86,15 @@ export function ResultCard({ therapy }: { therapy: Therapy }) {
    + {notice ? ( +

    + {notice} +

    + ) : null}
    - + { - if (typeof navigator === "undefined" || !navigator.clipboard || !text) return false; + if (typeof navigator === "undefined" || !text) return false; try { - await navigator.clipboard.writeText(text); + await copyTextToClipboard(text); return true; } catch { return false; diff --git a/src/components/therapy-compass/use-therapy-favourite.ts b/src/components/therapy-compass/use-therapy-favourite.ts new file mode 100644 index 000000000..b829dd766 --- /dev/null +++ b/src/components/therapy-compass/use-therapy-favourite.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { useAccountData } from "@/components/account-data-provider"; + +export function useTherapyFavourite(slug: string | null) { + const accountData = useAccountData(); + const [notice, setNotice] = useState(null); + const saved = slug ? accountData.isSaved("therapy", slug) : false; + const desiredSavedRef = useRef(saved); + const currentSlugRef = useRef(slug); + const latestMutationRef = useRef(0); + useEffect(() => { + desiredSavedRef.current = saved; + currentSlugRef.current = slug; + }, [saved, slug]); + + async function toggleFavourite() { + if (!slug) return; + const mutationId = ++latestMutationRef.current; + const nowSaved = !desiredSavedRef.current; + desiredSavedRef.current = nowSaved; + try { + if (!(await accountData.setFavourite("therapy", slug, nowSaved))) { + if (mutationId !== latestMutationRef.current || currentSlugRef.current !== slug) return; + desiredSavedRef.current = saved; + setNotice( + accountData.isAuthenticated ? "Save failed. Try again." : "Sign in or create an account to save therapies.", + ); + return; + } + if (mutationId !== latestMutationRef.current || currentSlugRef.current !== slug) return; + setNotice(nowSaved ? "Therapy saved." : "Therapy removed from saved items."); + } catch { + if (mutationId !== latestMutationRef.current || currentSlugRef.current !== slug) return; + desiredSavedRef.current = saved; + setNotice("Save failed. Try again."); + } + } + + return { notice, saved, toggleFavourite }; +} diff --git a/src/components/therapy-compass/workspace.tsx b/src/components/therapy-compass/workspace.tsx index 71dd81b5c..25edaf91b 100644 --- a/src/components/therapy-compass/workspace.tsx +++ b/src/components/therapy-compass/workspace.tsx @@ -4,11 +4,12 @@ import { usePathname } from "next/navigation"; import type { ReactNode } from "react"; import { ModeHomeVerificationFooter } from "@/components/mode-home-template"; +import { InformationPageShell } from "@/components/information-page-shell"; import { cn, pageContainer } from "@/components/ui-primitives"; +import { isInformationPage } from "@/lib/information-pages"; import { TcProvider, useTcBindings } from "./bindings"; import { accentControl, therapyBtn } from "./controls"; -import { TherapyModeNav } from "./nav"; function TherapyCompassFooter() { return ( @@ -47,6 +48,21 @@ function TherapyCompassDataError() { ); } +function TherapyCompassInformationRoute({ children }: { children: ReactNode }) { + const b = useTcBindings(); + if (b.error) { + return ( + + + + ); + } + if (b.loading && b.therapies.length === 0) { + return {children}; + } + return children; +} + function TherapyCompassMain({ children, showFooter, @@ -79,6 +95,7 @@ function TherapyCompassMain({ export function TherapyCompassWorkspace({ children }: { children: ReactNode }) { const pathname = usePathname(); const isHome = pathname === "/therapy-compass"; + const informationPage = isInformationPage(pathname); return ( @@ -86,15 +103,13 @@ export function TherapyCompassWorkspace({ children }: { children: ReactNode }) { data-therapy-root className="min-h-0 bg-[color:var(--background)] text-[color:var(--text)] sm:min-h-[calc(100dvh-var(--shell-header-h))]" > - {/* Every route but the mode home carries the shared bar, which pins - itself inside the universal header's collapse track and so hides and - reveals with it at every width. Home keeps none: `ModeHomeTemplate` - already surfaces the same destinations as tiles, which is the - convention every mode home follows. */} - {isHome ? null : } - - {children} - + {informationPage ? ( + {children} + ) : ( + + {children} + + )} ); diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 71f0695c9..e90729104 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -393,12 +393,12 @@ export function ToggleSwitch({ disabled={disabled} onClick={onToggle} className={cn( - track, + "inline-grid min-h-tap min-w-tap shrink-0 place-items-center rounded-full", "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", controlDisabled, )} > - {knob} + {knob} ); } diff --git a/src/components/ui/print-output.tsx b/src/components/ui/print-output.tsx new file mode 100644 index 000000000..3abbb54dc --- /dev/null +++ b/src/components/ui/print-output.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Printer } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/components/ui-primitives"; + +export function BrowserPrintButton({ label = "Print" }: { label?: string }) { + return ( + + ); +} + +export function PrintOutput({ + children, + className, + provenance, + paperTone, +}: { + children: ReactNode; + className?: string; + provenance: ReactNode; + paperTone?: "therapy"; +}) { + return ( +
    + {children} +
    {provenance}
    +
    + ); +} diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 1c74249d6..996006fd9 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -32,6 +32,7 @@ export type AppModeSearchKind = | "dsm" | "specifiers" | "formulation" + | "therapies" | "calculators" | "tools"; export type AppModeResultKind = AppModeSearchKind; @@ -366,14 +367,12 @@ export const appModeDefinitions = [ label: "Therapy", description: "Source-grounded therapy decision support", href: "/therapy-compass", - // Cleared for production discovery: the re-curated therapy pathways have - // qualified-clinician sign-off, so Therapy is now a first-class mode in the - // production sidebar and MODE dropdown (no longer devOnly-gated). + // Keep Therapy available for local clinical review while its catalogue is + // awaiting qualified-clinician sign-off. Removing this gate requires the + // catalogue review-status contract to prove production-ready records. + devOnly: true, search: { - // Therapy owns its in-tool search over the imported therapy library (not - // the document corpus), so the shared composer borrows the benign "tools" - // command behavior while routing into Therapy's dedicated search page. - kind: "tools", + kind: "therapies", // The longer phrase became the late portal's LCP element on Therapy Home. // Keep the full search scope in the accessible name below; the concise // visible prompt lets the already-painted hero remain the LCP owner. @@ -385,7 +384,7 @@ export const appModeDefinitions = [ emptyTitle: "Browse the therapy library", readyTitle: "Search source-grounded therapies", progressLabel: "Loading the therapy library.", - resultKind: "tools", + resultKind: "therapies", resultHeading: "Therapies", resultsSurface: "results-band", statusLabel: "Therapy", @@ -474,7 +473,13 @@ export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOpti const suffix = namespacedParams.toString(); const namespacedHref = - query && modeId === "dsm" ? "/dsm/search" : query && modeId === "factsheets" ? "/factsheets/search" : mode.href; + query && modeId === "dsm" + ? "/dsm/search" + : query && modeId === "factsheets" + ? "/factsheets/search" + : query && modeId === "therapy-compass" + ? "/therapy-compass/search" + : mode.href; return suffix ? `${namespacedHref}?${suffix}` : namespacedHref; } @@ -549,6 +554,7 @@ export function isSearchableAppMode(modeId: string): modeId is SearchableAppMode kind === "dsm" || kind === "specifiers" || kind === "formulation" || + kind === "therapies" || kind === "calculators" || kind === "tools" ); diff --git a/src/lib/information-pages.ts b/src/lib/information-pages.ts index 2389db9ec..6efde3416 100644 --- a/src/lib/information-pages.ts +++ b/src/lib/information-pages.ts @@ -4,8 +4,8 @@ * the floating composer on read-focused routes. * * Basic chrome for these pages lives in `src/components/information-page-shell.tsx`. - * Intentional opt-outs from that shell (different product chrome): document viewer, - * therapy-compass CSS workspace, differentials presentation workflow. + * Intentional opt-outs from that shell (different product chrome): document viewer + * and the differentials presentation workflow. */ export type InformationPageMode = @@ -81,5 +81,6 @@ export const informationPageShellModes = [ "specifiers", "formulation", "factsheets", + "therapy-compass", "dsm", ] as const satisfies readonly InformationPageMode[]; diff --git a/src/lib/mode-secondary-navigation.ts b/src/lib/mode-secondary-navigation.ts index ac51ae962..889fec988 100644 --- a/src/lib/mode-secondary-navigation.ts +++ b/src/lib/mode-secondary-navigation.ts @@ -1,18 +1,11 @@ import { appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { therapyWorkspaceNavigationEntries } from "@/lib/therapy-compass-navigation"; export type ModeSecondaryNavigationEntry = { id: string; label: string; shortLabel?: string; href?: string; - action?: - | "search" - | "therapy-search" - | "therapy-recommend" - | "therapy-compare" - | "therapy-pathways" - | "therapy-brief" - | "therapy-sheets"; }; /** @@ -21,7 +14,7 @@ export type ModeSecondaryNavigationEntry = { * secondary bar must never repeat a generic Home destination. */ export const modeSecondaryNavigationRegistry = { - // Empty is a real answer, not a gap. These seven modes each registered one + // Empty is a real answer, not a gap. These eight modes each registered one // `action: "search"` entry, which rendered a lone + + + ); +} + describe("favourites account retry", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -186,4 +202,58 @@ describe("favourites account retry", () => { expect(screen.getByTestId("status")).toHaveTextContent("ready"); expect(screen.getByTestId("count")).toHaveTextContent("0"); }); + + it("serializes opposite writes and leaves the final requested state persisted", async () => { + type PutResponse = { + ok: boolean; + status: number; + json: () => Promise>; + }; + const pendingPuts: Array<{ + body: { contentType: string; contentKey: string; saved: boolean }; + resolve: (response: PutResponse) => void; + }> = []; + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + if (method === "GET") { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ favourites: [] }), + }); + } + if (method === "PUT") { + return new Promise((resolve) => { + pendingPuts.push({ + body: JSON.parse(String(init?.body)) as { contentType: string; contentKey: string; saved: boolean }, + resolve, + }); + }); + } + throw new Error(`Unexpected fetch method: ${method}`); + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId("account-ready")).toHaveTextContent("ready")); + expect(fetchMock).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Save therapy" })); + fireEvent.click(screen.getByRole("button", { name: "Remove therapy" })); + + expect(screen.getByTestId("therapy-saved")).toHaveTextContent("not saved"); + await waitFor(() => expect(pendingPuts).toHaveLength(1)); + expect(pendingPuts[0]?.body.saved).toBe(true); + + await act(async () => pendingPuts[0]?.resolve({ ok: true, status: 200, json: async () => ({}) })); + await waitFor(() => expect(pendingPuts).toHaveLength(2)); + expect(pendingPuts[1]?.body.saved).toBe(false); + + await act(async () => pendingPuts[1]?.resolve({ ok: true, status: 200, json: async () => ({}) })); + await waitFor(() => expect(screen.getByTestId("therapy-saved")).toHaveTextContent("not saved")); + }); }); diff --git a/tests/favourites-clear-order.dom.test.tsx b/tests/favourites-clear-order.dom.test.tsx new file mode 100644 index 000000000..5ab66d861 --- /dev/null +++ b/tests/favourites-clear-order.dom.test.tsx @@ -0,0 +1,165 @@ +/** @vitest-environment jsdom */ + +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AccountDataProvider, useAccountData } from "@/components/account-data-provider"; + +const authSession = vi.hoisted(() => ({ + status: "authenticated" as string, + authEpoch: 1, + authorizationHeader: { Authorization: "Bearer test-token" } as Record, + session: { user: { email: "clinician@clinic.example" } }, + isConfigured: true, + error: null as string | null, + signInWithEmail: vi.fn(), + signOut: vi.fn(), + markSessionExpired: vi.fn(), +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => authSession, +})); + +type MockResponse = { + ok: boolean; + status: number; + json: () => Promise>; +}; + +function deferredResponse() { + let resolve!: (response: MockResponse) => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + return { promise, resolve }; +} + +function Probe() { + const { clearFavourites, isSaved, ready, setFavourite } = useAccountData(); + return ( +
    + {ready ? "ready" : "loading"} + {isSaved("therapy", "cbt") ? "saved" : "not saved"} + + +
    + ); +} + +describe("favourites clear ordering", () => { + beforeEach(() => { + vi.restoreAllMocks(); + authSession.status = "authenticated"; + authSession.authEpoch += 1; + authSession.markSessionExpired.mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("waits for an earlier PUT before issuing clear-all DELETE", async () => { + const put = deferredResponse(); + const methods: string[] = []; + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + methods.push(method); + if (method === "GET") { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ favourites: [] }), + }); + } + if (method === "PUT") return put.promise; + if (method === "DELETE") { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({}), + }); + } + throw new Error(`Unexpected fetch method: ${method}`); + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId("account-ready")).toHaveTextContent("ready")); + fireEvent.click(screen.getByRole("button", { name: "Save therapy" })); + await waitFor(() => expect(methods).toEqual(["GET", "PUT"])); + + fireEvent.click(screen.getByRole("button", { name: "Clear favourites" })); + await act(async () => { + await Promise.resolve(); + }); + expect(methods).toEqual(["GET", "PUT"]); + + await act(async () => { + put.resolve({ ok: true, status: 200, json: async () => ({}) }); + }); + + await waitFor(() => expect(methods).toEqual(["GET", "PUT", "DELETE"])); + await waitFor(() => expect(screen.getByTestId("therapy-saved")).toHaveTextContent("not saved")); + }); + + it("rolls a post-clear failed save back to the empty server state", async () => { + const clear = deferredResponse(); + const put = deferredResponse(); + const methods: string[] = []; + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + methods.push(method); + if (method === "GET") { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ favourites: [{ contentType: "therapy", contentKey: "cbt" }] }), + }); + } + if (method === "DELETE") return clear.promise; + if (method === "PUT") return put.promise; + throw new Error(`Unexpected fetch method: ${method}`); + }); + vi.stubGlobal("fetch", fetchMock); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId("therapy-saved")).toHaveTextContent("saved")); + fireEvent.click(screen.getByRole("button", { name: "Clear favourites" })); + await waitFor(() => expect(methods).toEqual(["GET", "DELETE"])); + + fireEvent.click(screen.getByRole("button", { name: "Save therapy" })); + await act(async () => { + await Promise.resolve(); + }); + expect(methods).toEqual(["GET", "DELETE"]); + + await act(async () => { + clear.resolve({ ok: true, status: 200, json: async () => ({}) }); + }); + await waitFor(() => expect(methods).toEqual(["GET", "DELETE", "PUT"])); + + await act(async () => { + put.resolve({ + ok: false, + status: 503, + json: async () => ({ message: "Saved items could not be updated." }), + }); + }); + await waitFor(() => expect(screen.getByTestId("therapy-saved")).toHaveTextContent("not saved")); + }); +}); diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index 2e17cfa37..4649b1dc2 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -32,7 +32,7 @@ const dashboardResultComposerSlotSource = read( const composerSlotSource = read("src/lib/mode-home-composer.ts"); const phoneHeaderPortalSource = read("src/components/clinical-dashboard/phone-header-collapse-portal.tsx"); const phoneFooterPortalSource = read("src/components/clinical-dashboard/phone-footer-layer-portal.tsx"); -const therapyNavSource = read("src/components/therapy-compass/nav.tsx"); +const registryModeNavSource = read("src/components/mode-nav/registry-mode-nav.tsx"); const modeNavSource = read("src/components/mode-nav/mode-nav.tsx"); const modeNavPortalSource = read("src/components/mode-nav/mode-nav-portal.tsx"); const documentViewerSource = read("src/components/DocumentViewer.tsx"); @@ -349,9 +349,9 @@ describe("shared header hide/reveal wiring", () => { // negatively alone, a `nav.tsx` that had dropped navigation entirely would // still pass. Therapy never names the portal itself — it delegates to // `ModeNav` — so the positive half of the assertion has to follow that hop. - expect(therapyNavSource).toContain("{bar}"); - expect(therapyNavSource).not.toContain("PhoneHeaderCollapsePortal"); + expect(registryModeNavSource).not.toContain("PhoneHeaderCollapsePortal"); expect(modeNavPortalSource).toContain("phoneHeaderCollapseAddonSlotId"); expect(modeNavPortalSource).toContain("createPortal(children, host)"); expect(documentViewerSource).toContain(""); diff --git a/tests/helpers/style-contracts.ts b/tests/helpers/style-contracts.ts index e48e9dae1..cc79c4e5f 100644 --- a/tests/helpers/style-contracts.ts +++ b/tests/helpers/style-contracts.ts @@ -172,6 +172,8 @@ export type StyleEffectContract = { /** Human-readable name used in the test title. */ readonly description: string; readonly route: string; + /** Optional viewport needed to make a responsive rule's target render. */ + readonly viewport?: Readonly<{ width: number; height: number }>; /** Playwright selector for an element carrying `className`. */ readonly selector: string; readonly bootstrap?: StyleContractSessionBootstrap; @@ -250,10 +252,9 @@ export const STYLE_EFFECT_CONTRACTS: readonly StyleEffectContract[] = [ { className: "mode-nav__rule", description: "mode nav overflow rule marks the page held in More", - // Brief Intervention is the seventh of Therapy's seven destinations, so it - // never gets a slot of its own at any band and More carries its rule at - // every width — including the default desktop viewport this spec runs at. - route: "/therapy-compass/acceptance-and-commitment-therapy-act/brief", + // Compare folds into More at Therapy's three-slot phone band. + route: "/therapy-compass/compare", + viewport: { width: 352, height: 844 }, // Scoped through the collapse host: Next streams the server-rendered copy // while the client tree hydrates, so a bare testid can resolve to two navs. selector: '[data-testid="universal-header-collapse"] [data-testid="mode-nav"] .mode-nav__more .mode-nav__rule', @@ -261,8 +262,7 @@ export const STYLE_EFFECT_CONTRACTS: readonly StyleEffectContract[] = [ // with Tailwind's `bg-transparent`, and the unlayered rule here is what // paints it. Move that rule into a layer and it loses to the utility: the // element still has every class, still has its 2px box, and marks nothing — - // so on a phone five of Therapy's seven pages would silently stop saying - // where you are. + // so a folded page would silently stop saying where you are. computed: { height: "2px" }, nonInert: ["backgroundColor"], colorToken: { property: "backgroundColor", token: "--clinical-accent" }, @@ -270,7 +270,8 @@ export const STYLE_EFFECT_CONTRACTS: readonly StyleEffectContract[] = [ { className: "mode-nav__ink", description: "mode nav overflow ink takes heading weight when it holds the page", - route: "/therapy-compass/acceptance-and-commitment-therapy-act/brief", + route: "/therapy-compass/compare", + viewport: { width: 352, height: 844 }, selector: '[data-testid="universal-header-collapse"] [data-testid="mode-nav"] .mode-nav__more .mode-nav__ink', // The other half of the mark, and a genuine cascade fight: SlotInk sets // `text-[color:var(--text-muted)]` for the off state, and this unlayered @@ -410,10 +411,8 @@ export const STYLE_CONTRACT_EXEMPTIONS: Readonly> = { "mode-nav__more": "scoping ancestor only; the rules it scopes are contracted on mode-nav__rule and mode-nav__ink", // Therapy Compass residuals moved out of the deleted parallel stylesheet. - // Phone/print behaviour is pinned by therapy-compass-responsive-contract; no + // Phone behaviour is pinned by therapy-compass-responsive-contract; no // browser computed-effect contract yet (#094 / #183). - "therapy-compare-grid": - "CSS-var comparison columns — column count is set inline via --tc-compare-columns; layout covered by therapy-compass-responsive-contract", "therapy-pathway-list": "phone border swap (right→bottom) under max-width 640px — covered by therapy-compass-responsive-contract", }; diff --git a/tests/mode-home-loading-contract.test.ts b/tests/mode-home-loading-contract.test.ts index 84b1d739b..c63618457 100644 --- a/tests/mode-home-loading-contract.test.ts +++ b/tests/mode-home-loading-contract.test.ts @@ -85,12 +85,12 @@ describe("mode-home loading contract", () => { it("loads the Therapy workspace only for rich child routes, not the lightweight home", () => { const source = readFileSync( - join(process.cwd(), "src/components/clinical-dashboard/shared-search-app-shell.tsx"), + join(process.cwd(), "src/components/therapy-compass/therapy-compass-route-layout.tsx"), "utf8", ); - expect(source).toMatch(/dynamic\([\s\S]*?import\("@\/components\/therapy-compass\/workspace"\)/); + expect(source).toMatch(/dynamic\([\s\S]*?import\("\.\/workspace"\)/); expect(source).not.toMatch(/^import \{ TherapyCompassWorkspace \}/m); - expect(source).toContain('pathname.startsWith("/therapy-compass/")'); + expect(source).toContain("pathname === THERAPY_HOME"); }); it("keeps mode-home route loading top-aligned on phones", () => { diff --git a/tests/mode-nav-addon-slot.dom.test.tsx b/tests/mode-nav-addon-slot.dom.test.tsx index 0af4942f1..ee8ad9ec4 100644 --- a/tests/mode-nav-addon-slot.dom.test.tsx +++ b/tests/mode-nav-addon-slot.dom.test.tsx @@ -246,6 +246,7 @@ describe("header addon slot ownership", () => { "src/components/services/service-detail-page.tsx", "src/components/specifiers/specifier-map-nav-header.tsx", "src/components/specifiers/specifier-nav-header.tsx", + "src/components/therapy-compass/therapy-record-nav-header.tsx", ]); }); diff --git a/tests/mode-nav-contract.test.ts b/tests/mode-nav-contract.test.ts index e9171e0c2..63314e6cb 100644 --- a/tests/mode-nav-contract.test.ts +++ b/tests/mode-nav-contract.test.ts @@ -16,7 +16,6 @@ const modeNavSource = read("src/components/mode-nav/mode-nav.tsx"); const registryModeNavSource = read("src/components/mode-nav/registry-mode-nav.tsx"); const portalSource = read("src/components/mode-nav/mode-nav-portal.tsx"); const globalsSource = read("src/app/globals.css"); -const therapyNavSource = read("src/components/therapy-compass/nav.tsx"); const workspaceSource = read("src/components/therapy-compass/workspace.tsx"); /** The slice of globals.css owned by the bar, so assertions cannot drift into other rules. */ @@ -189,47 +188,21 @@ describe("ModeNav item contract", () => { expect(modeNavSource).not.toMatch(/onClick\?:/); }); - it("gives Therapy its seven destinations in declared order", () => { - const itemIds = [...therapyNavSource.matchAll(/\bid: "([a-z-]+)"/g)].map((match) => match[1]); - // Order is load-bearing: at three slots the survivors are the first two, so - // the library door and the only destination carrying state are the ones - // that stay on the bar. Everything from `home` on is reached through More - // at every band, which is where the two long labels have to live. - expect(itemIds).toEqual(["search", "compare", "recommend", "pathways", "home", "brief", "sheets"]); - - // Compare carries fill, not catalogue size: "3/4" is worth a glance, "205" - // is noise on every screen. - expect(therapyNavSource).toContain("${b.compareSlugs.length}/${MAX_COMPARE}"); + it("gives Therapy the four shared workspace destinations in declared order", () => { + expect(modeSecondaryNavigationEntries("therapy-compass").map((entry) => entry.id)).toEqual([ + "search", + "recommend", + "compare", + "pathways", + ]); }); - it("routes the record-scoped destinations through resolved hrefs, never a handler", () => { - // `ModeNavItem` takes an href so deep links, back and prefetch work. These - // two resolve a slug, so the resolution has to reach the item as a value. - expect(therapyNavSource).toContain("href: b.briefHref"); - expect(therapyNavSource).toContain("href: b.sheetHref"); - expect(therapyNavSource).not.toMatch(/onClick/); - }); - - it("names the active page rather than letting a prefix match claim it", () => { - // Home's href is the mode base, and every Therapy route starts with it, so - // ModeNav's own `startsWith` derivation would light Home up everywhere. - expect(therapyNavSource).toContain("activeId={b.screen}"); - }); - - it("puts every Therapy route on the shared bar", () => { - // The pill strip is gone: no second nav, no sideways scroll, no route-local - // portal competing for the header's addon slot. - expect(therapyNavSource).not.toContain("TherapyCompassNav"); - expect(therapyNavSource).not.toContain("PhoneHeaderCollapsePortal"); - expect(therapyNavSource).not.toContain('data-testid="therapy-compass-section-nav"'); + it("keeps record-owned Brief and Sheet routes off the mode bar", () => { + const itemIds = modeSecondaryNavigationEntries("therapy-compass").map((entry) => entry.id); + expect(itemIds).not.toContain("brief"); + expect(itemIds).not.toContain("sheets"); + expect(workspaceSource).not.toContain("TherapyModeNav"); expect(globalsSource).not.toContain('[data-testid="therapy-compass-section-nav"]'); - expect(workspaceSource).toContain(""); - expect(workspaceSource).not.toContain("TherapyCompassNav"); - }); - - it("keeps the mode home free of the bar, as every mode home is", () => { - // ModeHomeTemplate already surfaces the same destinations as tiles. - expect(workspaceSource).toContain("{isHome ? null : }"); }); }); @@ -311,7 +284,10 @@ describe("ModeNav density coverage", () => { ), ); const assignedProfiles = new Map( - [...registryModeNavSource.matchAll(/^\s{2}([a-z-]+): "([a-z-]+)",$/gm)].map((match) => [match[1], match[2]]), + [...registryModeNavSource.matchAll(/^\s{2}(?:"([a-z-]+)"|([a-z-]+)): "([a-z-]+)",$/gm)].map((match) => [ + match[1] ?? match[2], + match[3], + ]), ); it("drives every adopted mode, not just the first consumer", () => { @@ -323,9 +299,10 @@ describe("ModeNav density coverage", () => { for (const modeId of MODE_NAV_ADOPTED_MODES) { expect(covered.has(modeId), `${modeId} adopted the bar but the density spec never loads it`).toBe(true); } - // Therapy is not registry-driven (`useTherapyNavItems`), so it is not in - // MODE_NAV_ADOPTED_MODES — and it is the mode with the long labels. - expect(covered.get("therapy-compass")).toBe(7); + // Therapy now uses the shared registry and deliberately exposes only the + // four workspace destinations. Record-owned outputs require a selected + // therapy and therefore stay off the global mode bar. + expect(covered.get("therapy-compass")).toBe(4); }); it("keeps each mode's declared destination count in step with the registry", () => { @@ -344,8 +321,8 @@ describe("ModeNav density coverage", () => { expect(MODE_NAV_DENSITY_PROFILES).toContain(profile); expect(coveredProfiles.get(modeId), `${modeId} browser profile`).toBe(profile); } - expect(coveredProfiles.get("therapy-compass")).toBe("extended"); - expect(therapyNavSource).toContain('densityProfile="extended"'); + expect(coveredProfiles.get("therapy-compass")).toBe("balanced-four"); + expect(registryModeNavSource).toContain('"therapy-compass": "balanced-four"'); }); }); diff --git a/tests/mode-secondary-navigation.test.ts b/tests/mode-secondary-navigation.test.ts index bb5af111a..7aa45a9ea 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -25,7 +25,7 @@ const expectedLabels: Record = { prescribing: [], tools: [], calculators: [], - "therapy-compass": ["Search", "Recommend", "Compare", "Pathways", "Brief Intervention", "Patient Sheets"], + "therapy-compass": ["Search", "Recommend", "Compare", "Pathways"], factsheets: ["Topics", "Search"], }; @@ -47,7 +47,7 @@ const cleanLandingPath: Record = { }; /** - * The seven modes that register nothing. Each used to carry one + * The eight modes that register nothing. Each used to carry one * `action: "search"` entry rendering a lone + ); +} + +describe("Therapy comparison URL identity", () => { + it("serializes the exact selected therapy slug while preserving search context", () => { + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Compare exact therapy" })); + + expect(navState.push).toHaveBeenCalledTimes(1); + const destination = new URL(String(navState.push.mock.calls[0]?.[0]), "http://localhost"); + expect(destination.pathname).toBe("/therapy-compass/compare"); + expect(destination.searchParams.get("q")).toBe("CBT"); + expect(destination.searchParams.get("run")).toBe("1"); + expect(destination.searchParams.get("ids")).toBe("cognitive-behavioural-therapy-cbt"); + }); +}); diff --git a/tests/therapy-compass-data-recovery.dom.test.tsx b/tests/therapy-compass-data-recovery.dom.test.tsx index a3c640027..9eeb2abaa 100644 --- a/tests/therapy-compass-data-recovery.dom.test.tsx +++ b/tests/therapy-compass-data-recovery.dom.test.tsx @@ -66,9 +66,9 @@ describe("Therapy Compass required data recovery", () => { expect(screen.getAllByRole("main")).toHaveLength(1); await waitFor(() => expect(fetchMock).not.toHaveBeenCalled()); - expect(screen.getByRole("link", { name: /Create a patient sheet/i })).toHaveAttribute( + expect(screen.getByRole("link", { name: /Compare therapies/i })).toHaveAttribute( "href", - `/therapy-compass/${THERAPY_CATALOGUE_SUMMARY.defaultSheetSlug}/sheet`, + "/therapy-compass/compare", ); }); diff --git a/tests/therapy-compass-mode-wiring.test.ts b/tests/therapy-compass-mode-wiring.test.ts index 4a017d00d..f123d0c0d 100644 --- a/tests/therapy-compass-mode-wiring.test.ts +++ b/tests/therapy-compass-mode-wiring.test.ts @@ -240,7 +240,7 @@ describe("Therapy Compass production-mode wiring", () => { expect(routeSrc).toMatch(/searchParams/); expect(routeSrc).toMatch(/redirect\(`\/therapy-compass\/search/); // ...and the provider derives the active screen from the pathname and seeds the query from ?q. - expect(bindingsSrc).toMatch(/resolveRoute\(pathname\)/); + expect(bindingsSrc).toMatch(/resolveTherapyRoute\(pathname\)/); expect(bindingsSrc).toMatch(/searchParams\.get\("q"\)/); }); @@ -291,8 +291,9 @@ describe("Therapy Compass production-mode wiring", () => { expect(searchScreenSrc).toContain("onClearFilters={b.clearSearchFilters}"); expect(searchScreenSrc).not.toContain("onClearFilters={b.clearSearch}"); // The binding must preserve the query rather than reset the whole shape. + expect(bindingsSrc).toContain("setSearch((prev) => ({ ...EMPTY_SEARCH, query: prev.query }));"); expect(bindingsSrc).toContain( - "clearSearchFilters: () => setSearch((prev) => ({ ...EMPTY_SEARCH, query: prev.query }))", + "replaceWorkspace({ topics: [], briefOnly: false, sheetOnly: false, reviewedOnly: false });", ); // Filter-contract adoption (docs/filter-contract.md section 6): the phone // sheet converged onto the shared `ResultFilterSheet`, whose `onClearAll` diff --git a/tests/therapy-compass-navigation.test.ts b/tests/therapy-compass-navigation.test.ts new file mode 100644 index 000000000..d510bd956 --- /dev/null +++ b/tests/therapy-compass-navigation.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_THERAPY_WORKSPACE_STATE, + readTherapyWorkspaceState, + resolveTherapyRoute, + therapyRecordHref, + therapyScreenHref, + therapyWorkspaceNavigationEntries, + therapyWorkspaceSearchParams, +} from "@/lib/therapy-compass-navigation"; + +describe("Therapy Compass canonical navigation", () => { + it("resolves homes, workflows, records and record-owned outputs", () => { + expect(resolveTherapyRoute("/therapy-compass")).toEqual({ screen: "home", slug: null }); + expect(resolveTherapyRoute("/therapy-compass/compare")).toEqual({ screen: "compare", slug: null }); + expect(resolveTherapyRoute("/therapy-compass/cbt")).toEqual({ screen: "detail", slug: "cbt" }); + expect(resolveTherapyRoute("/therapy-compass/cbt/brief")).toEqual({ screen: "brief", slug: "cbt" }); + expect(resolveTherapyRoute("/therapy-compass/cbt/sheet")).toEqual({ screen: "sheets", slug: "cbt" }); + expect(therapyScreenHref("search")).toBe("/therapy-compass/search"); + expect(therapyRecordHref("cbt", "sheet")).toBe("/therapy-compass/cbt/sheet"); + }); + + it("round-trips encoded record slugs and tolerates malformed path encoding", () => { + const slug = "acceptance/commitment therapy"; + expect(resolveTherapyRoute(therapyRecordHref(slug))).toEqual({ screen: "detail", slug }); + expect(resolveTherapyRoute("/therapy-compass/%E0%A4%A")).toEqual({ + screen: "detail", + slug: "%E0%A4%A", + }); + }); + + it("round-trips every approved shareable workspace choice", () => { + const state = { + ...DEFAULT_THERAPY_WORKSPACE_STATE, + compareSlugs: ["cbt", "dbt"], + topics: ["Mood", "Trauma"], + briefOnly: true, + sheetOnly: true, + reviewedOnly: true, + constraints: [], + pathwaySlug: "acute-anxiety", + comparison: "all" as const, + density: "dense" as const, + duration: "15min" as const, + tone: "warm" as const, + sections: [], + clinician: false, + }; + const params = therapyWorkspaceSearchParams(new URLSearchParams("q=anxiety&run=1"), state); + + expect(readTherapyWorkspaceState(params)).toEqual(state); + expect(params.get("q")).toBe("anxiety"); + expect(params.get("run")).toBe("1"); + }); + + it("never serializes or carries recommendation and patient free text", () => { + const current = new URLSearchParams( + "q=anxiety&run=1&recQuery=patient+name&prompt=private&patient=private¬es=private&ids=cbt,dbt", + ); + const canonical = therapyWorkspaceSearchParams(current, { + ...DEFAULT_THERAPY_WORKSPACE_STATE, + compareSlugs: ["cbt", "dbt"], + }); + + expect(canonical.has("recQuery")).toBe(false); + expect(canonical.has("prompt")).toBe(false); + expect(canonical.has("patient")).toBe(false); + expect(canonical.has("notes")).toBe(false); + expect(therapyWorkspaceNavigationEntries(current)).toEqual([ + ["q", "anxiety"], + ["run", "1"], + ["ids", "cbt,dbt"], + ]); + }); + + it("caps and de-duplicates comparison IDs from untrusted URLs", () => { + const state = readTherapyWorkspaceState(new URLSearchParams("ids=a,a,b,c,d,e,,")); + expect(state.compareSlugs).toEqual(["a", "b", "c", "d"]); + }); + + it("serializes patient-sheet sections in canonical order", () => { + const params = therapyWorkspaceSearchParams(new URLSearchParams(), { + ...DEFAULT_THERAPY_WORKSPACE_STATE, + sections: ["contacts", "about", "practice"], + }); + + expect(params.getAll("section")).toEqual(["about", "practice", "contacts"]); + expect(readTherapyWorkspaceState(params).sections).toEqual(["about", "practice", "contacts"]); + }); +}); diff --git a/tests/therapy-compass-provider-seed.dom.test.tsx b/tests/therapy-compass-provider-seed.dom.test.tsx index 6f8d31da0..421ee0db4 100644 --- a/tests/therapy-compass-provider-seed.dom.test.tsx +++ b/tests/therapy-compass-provider-seed.dom.test.tsx @@ -1,4 +1,4 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TcProvider, useTcBindings } from "@/components/therapy-compass/bindings"; @@ -20,7 +20,14 @@ afterEach(() => vi.unstubAllGlobals()); function Probe() { const b = useTcBindings(); - return
    ; + return ( + <> +
    + + + ); } describe("TcProvider URL-driven seeding", () => { @@ -53,6 +60,32 @@ describe("TcProvider URL-driven seeding", () => { expect(getByTestId("probe").getAttribute("data-query")).toBe("beta"); }); + it("preserves a locally typed query when only workspace filter parameters change", () => { + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); + + navState.pathname = "/therapy-compass/search"; + navState.search = "q=alpha"; + const { getByRole, getByTestId, rerender } = render( + + + , + ); + + fireEvent.click(getByRole("button", { name: "Type draft query" })); + expect(getByTestId("probe").getAttribute("data-query")).toBe("draft"); + + navState.search = "q=alpha&brief=1"; + rerender( + + + , + ); + expect(getByTestId("probe").getAttribute("data-query")).toBe("draft"); + }); + it("resolves a therapy detail screen + slug from the pathname", () => { vi.stubGlobal( "fetch", diff --git a/tests/therapy-compass-responsive-contract.test.ts b/tests/therapy-compass-responsive-contract.test.ts index c1ac7efec..d8b5f1e72 100644 --- a/tests/therapy-compass-responsive-contract.test.ts +++ b/tests/therapy-compass-responsive-contract.test.ts @@ -9,12 +9,15 @@ const therapyPath = "src/components/therapy-compass"; const globalsSource = read("src/app/globals.css"); const controlsSource = read(`${therapyPath}/controls.ts`); -const therapyNavSource = read(`${therapyPath}/nav.tsx`); +const registryModeNavSource = read("src/components/mode-nav/registry-mode-nav.tsx"); const therapyCardSource = read(`${therapyPath}/therapy-card.tsx`); +const therapyFavouriteSource = read(`${therapyPath}/use-therapy-favourite.ts`); const workspaceSource = read(`${therapyPath}/workspace.tsx`); const homeSource = read(`${therapyPath}/screens/home-screen.tsx`); const modeHomeComposerSource = read("src/lib/mode-home-composer.ts"); const modeHomeTemplateSource = read("src/components/mode-home-template.tsx"); +const informationPageShellSource = read("src/components/information-page-shell.tsx"); +const printOutputSource = read("src/components/ui/print-output.tsx"); const detailSource = read(`${therapyPath}/screens/detail-screen.tsx`); const compareSource = read(`${therapyPath}/screens/compare-screen.tsx`); const recommendSource = read(`${therapyPath}/screens/recommend-screen.tsx`); @@ -61,9 +64,10 @@ describe("Therapy Compass responsive contract", () => { // The sideways-scrolling pill strip is retired: Therapy's nav is the shared // bar, which folds its overflow into a sheet rather than off the screen // edge. Its own density and centring contract lives in mode-nav-contract. - expect(therapyNavSource).toContain("ModeNav"); - expect(therapyNavSource).not.toContain("overflow-x-auto"); - expect(therapyNavSource).not.toContain("w-fit"); + expect(registryModeNavSource).toContain("ModeNav"); + expect(registryModeNavSource).toContain('"therapy-compass": "balanced-four"'); + expect(registryModeNavSource).not.toContain("overflow-x-auto"); + expect(registryModeNavSource).not.toContain("w-fit"); expect(globalsSource).toContain("position: relative;"); }); @@ -74,12 +78,29 @@ describe("Therapy Compass responsive contract", () => { // The portal is `ModeNavHeaderPortal`'s job now, not the mode's — it claims // the same slot at every width rather than only below the phone seam. expect(read("src/components/mode-nav/mode-nav-portal.tsx")).toContain("phoneHeaderCollapseAddonSlotId"); - expect(therapyNavSource).not.toContain("PhoneHeaderCollapsePortal"); + expect(registryModeNavSource).not.toContain("PhoneHeaderCollapsePortal"); }); it("puts every therapy screen on the shared content rail", () => { // Three rails used to disagree: header max-w-7xl, bar full-bleed, body on - // bespoke 1240/1180px caps. `pageContainer` is the repo's canonical token. + // bespoke 1240/1180px caps. Catalogue screens use `pageContainer` + // directly; information routes inherit the same rail from the shared shell. + for (const [name, source] of [ + ["compare", compareSource], + ["recommend", recommendSource], + ["pathways", pathwaysSource], + ["other", otherSource], + ] as const) { + expect(source, `${name} screen`).toContain("pageContainer"); + } + expect(informationPageShellSource).toContain("pageContainer"); + for (const [name, source] of [ + ["detail", detailSource], + ["brief", briefSource], + ["sheets", sheetsSource], + ] as const) { + expect(source, `${name} screen`).toContain("InformationPageShell"); + } for (const [name, source] of [ ["detail", detailSource], ["compare", compareSource], @@ -89,7 +110,6 @@ describe("Therapy Compass responsive contract", () => { ["sheets", sheetsSource], ["other", otherSource], ] as const) { - expect(source, `${name} screen`).toContain("pageContainer"); expect(source, `${name} screen`).not.toContain("max-w-[1240px]"); expect(source, `${name} screen`).not.toContain("max-w-[1180px]"); } @@ -99,7 +119,6 @@ describe("Therapy Compass responsive contract", () => { it("keeps phone reflow and comparison scroll residuals in globals.css", () => { expect(globalsSource).toMatch(/@media \(max-width: 640px\)/); - expect(globalsSource).toContain(".therapy-compare-table"); expect(globalsSource).toContain("overflow-x: auto !important;"); expect(globalsSource).toContain("[data-therapy-scroll-sm]"); }); @@ -120,11 +139,9 @@ describe("Therapy Compass responsive contract", () => { expect(homeSource).toContain("ModeHomeVerificationFooter"); expect(responsiveStackCount(detailSource)).toBeGreaterThanOrEqual(1); expect(detailSource).toContain("max-sm:static"); - expect( - responsiveStackCount(compareSource) + (compareSource.includes("therapy-compare-tabs") ? 1 : 0), - ).toBeGreaterThanOrEqual(1); - expect(compareSource).toContain("therapy-compare-tabs"); - expect(compareSource).toContain("therapy-compare-table"); + expect(responsiveStackCount(compareSource)).toBeGreaterThanOrEqual(1); + expect(compareSource).toContain(" { expect(resultCardSource).not.toContain(" { - const favouriteButton = therapyCardSource.match( - //, - )?.[0]; - - expect(favouriteButton).toBeTruthy(); - // `aria-disabled` + the shared inert handler, not the native attribute: the - // reason lives in the title, and `disabled` would take the only route to it - // (the tab stop) away. The handler is what makes the control do nothing — - // its presence is the contract, so this no longer asserts "no onClick". - expect(favouriteButton).toContain('aria-disabled="true"'); - expect(favouriteButton).not.toMatch(/(^|\s)disabled(\s|=|$)/); - expect(favouriteButton).toContain("onClick={ignoreUnavailableActivation}"); - expect(favouriteButton).toContain('aria-label="Favourite saving is not available yet"'); - expect(favouriteButton).toContain("iconControl"); - expect(controlsSource).toContain("aria-disabled:cursor-not-allowed"); + it("keeps the Favourite action wired and surfaces mutation failures", () => { + expect(therapyCardSource).toContain("useTherapyFavourite(therapy.slug)"); + expect(therapyCardSource).toContain("onClick={() => void toggleFavourite()}"); + expect(therapyCardSource).toContain("aria-pressed={saved}"); + expect(therapyCardSource).toContain('role="status"'); + expect(therapyFavouriteSource).toContain('await accountData.setFavourite("therapy", slug, nowSaved)'); + expect(therapyFavouriteSource).toContain("Sign in or create an account to save therapies."); + expect(therapyFavouriteSource).toContain("Save failed. Try again."); }); it("keeps search result cards dense: single-row tags, top favourite, clamped match cells", () => { @@ -208,42 +217,32 @@ describe("Therapy Compass responsive contract", () => { }); it("uses complete toggle semantics and preserves full-size control hit targets", () => { - const briefGroupTag = openingTagWith(briefSource, "div", [ - 'role="group"', - 'aria-label="Brief intervention duration"', - ]); - const compareGroupTag = openingTagWith(compareSource, "div", ['role="group"', 'aria-label="Comparison fields"']); - expect(briefGroupTag).toBeTruthy(); - expect(compareGroupTag).toBeTruthy(); - - for (const state of ['b.briefTab === "5min"', 'b.briefTab === "15min"', 'b.briefTab === "ground"']) { - expect(openingTagWith(briefSource, "button", [`aria-pressed={${state}}`])).toBeTruthy(); - } - for (const state of ['b.cmpTab === "priorities"', 'b.cmpTab === "differences"', 'b.cmpTab === "all"']) { - expect(openingTagWith(compareSource, "button", [`aria-pressed={${state}}`])).toBeTruthy(); - } - expect(briefSource).not.toContain('role="tab"'); - expect(briefSource).not.toContain("aria-selected="); - expect(compareSource).not.toContain('role="tab"'); - expect(compareSource).not.toContain("aria-selected="); + expect(briefSource).toContain(' { - const pageRuleStart = globalsSource.indexOf("@page therapy-compass-sheet"); + it("uses shared print output ownership with route-scoped paper styling", () => { + expect(briefSource).toContain(" ({ }), })); +// Cross-mode results have their own auth/network contracts. These tests own +// only Therapy's facet state and result filtering, so keep that sibling surface +// outside this focused harness. +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + import { SearchScreen } from "@/components/therapy-compass/screens/search-screen"; describe("therapy-compass search filter contract adoption", () => { diff --git a/tests/therapy-favourite.dom.test.tsx b/tests/therapy-favourite.dom.test.tsx new file mode 100644 index 000000000..07ed719a6 --- /dev/null +++ b/tests/therapy-favourite.dom.test.tsx @@ -0,0 +1,109 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Therapy } from "@/components/therapy-compass/data/types"; + +const accountState = vi.hoisted(() => ({ + authenticated: false, + setFavourite: vi.fn<(kind: "therapy", slug: string, saved: boolean) => Promise>(), +})); + +vi.mock("@/components/account-data-provider", () => ({ + useAccountData: () => ({ + isAuthenticated: accountState.authenticated, + isSaved: () => false, + setFavourite: accountState.setFavourite, + }), +})); + +vi.mock("@/components/therapy-compass/bindings", () => ({ + useTcBindings: () => ({ + search: { query: "CBT", tags: [] }, + isInCompare: () => false, + open: vi.fn(), + openSheet: vi.fn(), + toggleCompare: vi.fn(), + }), +})); + +import { ResultCard } from "@/components/therapy-compass/therapy-card"; + +const therapy = { + slug: "cognitive-behavioural-therapy", + name: "Cognitive behavioural therapy", + aliases: ["CBT"], + tags: ["CBT"], + category: "Behavioural", + reviewStatus: "needs_review", + briefInterventionAvailable: true, + patientSheetAvailable: true, +} as Therapy; + +describe("Therapy favourite feedback", () => { + afterEach(() => { + accountState.authenticated = false; + accountState.setFavourite.mockReset(); + }); + + it("announces the sign-in requirement when a signed-out save is rejected", async () => { + accountState.setFavourite.mockResolvedValue(false); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: `Save ${therapy.name} to favourites` })); + + expect(await screen.findByRole("status")).toHaveTextContent("Sign in or create an account to save therapies."); + }); + + it("announces a mutation failure for an authenticated account", async () => { + accountState.authenticated = true; + accountState.setFavourite.mockResolvedValue(false); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: `Save ${therapy.name} to favourites` })); + + expect(await screen.findByRole("status")).toHaveTextContent("Save failed. Try again."); + }); + + it("retries the same save intent after a failed mutation", async () => { + accountState.authenticated = true; + accountState.setFavourite.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const user = userEvent.setup(); + render(); + + const saveButton = screen.getByRole("button", { name: `Save ${therapy.name} to favourites` }); + await user.click(saveButton); + expect(await screen.findByRole("status")).toHaveTextContent("Save failed. Try again."); + + await user.click(saveButton); + + expect(accountState.setFavourite).toHaveBeenNthCalledWith(1, "therapy", therapy.slug, true); + expect(accountState.setFavourite).toHaveBeenNthCalledWith(2, "therapy", therapy.slug, true); + expect(await screen.findByRole("status")).toHaveTextContent("Therapy saved."); + }); + + it("preserves the final intent across rapid opposite favourite toggles", async () => { + accountState.authenticated = true; + const pending: Array<{ saved: boolean; resolve: (value: boolean) => void }> = []; + accountState.setFavourite.mockImplementation( + (_kind, _slug, saved) => + new Promise((resolve) => { + pending.push({ saved, resolve }); + }), + ); + render(); + + const saveButton = screen.getByRole("button", { name: `Save ${therapy.name} to favourites` }); + fireEvent.click(saveButton); + fireEvent.click(saveButton); + + expect(accountState.setFavourite).toHaveBeenCalledTimes(2); + expect(pending.map((mutation) => mutation.saved)).toEqual([true, false]); + + await act(async () => pending[0]?.resolve(true)); + await act(async () => pending[1]?.resolve(true)); + expect(await screen.findByRole("status")).toHaveTextContent("Therapy removed from saved items."); + }); +}); diff --git a/tests/therapy-global-convergence-contract.test.ts b/tests/therapy-global-convergence-contract.test.ts new file mode 100644 index 000000000..23b10bdb3 --- /dev/null +++ b/tests/therapy-global-convergence-contract.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { informationPageShellModes } from "@/lib/information-pages"; + +const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + +describe("Therapy global-site convergence", () => { + it("registers Therapy record routes as shared information pages", () => { + expect(informationPageShellModes).toContain("therapy-compass"); + expect(read("src/lib/information-pages.ts")).not.toContain("therapy-compass CSS workspace"); + }); + + it("keeps submitted Therapy searches connected to shared cross-mode results", () => { + const searchScreen = read("src/components/therapy-compass/screens/search-screen.tsx"); + expect(searchScreen).toContain(" { + expect(read("src/components/account-data-provider.tsx")).toContain( + '"service" | "form" | "differential" | "therapy"', + ); + expect(read("src/app/api/account/favourites/route.ts")).toContain( + 'z.enum(["service", "form", "differential", "therapy"])', + ); + expect(read("supabase/migrations/20260814150000_add_therapy_favourites.sql")).toContain( + "'differential', 'therapy'", + ); + expect(read("src/components/therapy-compass/therapy-card.tsx")).toContain("useTherapyFavourite(therapy.slug)"); + expect(read("src/components/therapy-compass/use-therapy-favourite.ts")).toContain( + 'await accountData.setFavourite("therapy", slug, nowSaved)', + ); + }); + + it("uses one shared browser-print output owner for Brief and Patient Sheet", () => { + const brief = read("src/components/therapy-compass/screens/brief-screen.tsx"); + const sheet = read("src/components/therapy-compass/screens/sheets-screen.tsx"); + const printOutput = read("src/components/ui/print-output.tsx"); + expect(brief).toContain(" readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + +describe("Therapy PR unblocking contracts", () => { + it("keeps the Therapy catalogue out of the shared initial favourites graph", () => { + const source = read("src/components/clinical-dashboard/use-saved-registry-favourites.ts"); + expect(source).not.toContain('from "@/lib/therapies"'); + expect(source).toContain('import("@/lib/therapies")'); + }); + + it("keeps the production content gate while allowing isolated offline UI verification", () => { + const layoutSource = read("src/app/(search-app)/therapy-compass/layout.tsx"); + const therapiesSource = read("src/lib/therapies.ts"); + + expect(layoutSource).toContain('process.env.PLAYWRIGHT_OFFLINE_MODE === "true"'); + expect(layoutSource).toContain("!offlineReviewBuild"); + expect(layoutSource).toContain("notFound()"); + expect(layoutSource).not.toContain("NEXT_PUBLIC_DEMO_MODE"); + expect(therapiesSource).toContain('process.env.PLAYWRIGHT_OFFLINE_MODE === "true" ? "development"'); + expect(therapiesSource).toContain('environment === "production"'); + }); + + it("canonicalises hidden shared-home modes instead of retaining impossible URL state", () => { + const source = read("src/app/(search-app)/page.tsx"); + expect(source).toContain('canonicalParams.set("mode", "answer")'); + expect(source).toContain("redirect(`/?${canonicalParams.toString()}`)"); + }); +}); diff --git a/tests/therapy-ranking.test.ts b/tests/therapy-ranking.test.ts new file mode 100644 index 000000000..5b7d0ff21 --- /dev/null +++ b/tests/therapy-ranking.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { EMPTY_SEARCH, searchTherapies } from "@/components/therapy-compass/data/select"; +import { THERAPY_CATALOGUE_ASSETS } from "@/components/therapy-compass/data/generated-assets"; +import type { Therapy } from "@/components/therapy-compass/data/types"; +import { rankTherapyCandidates, scoreTherapyCandidate } from "@/lib/therapy-ranking"; +import { + findTherapyRecord, + searchTherapyRecords, + therapyRecords, + therapyRecordsForEnvironment, + therapySlugs, +} from "@/lib/therapies"; + +const records = [ + { + name: "Cognitive behavioural therapy", + aliases: ["CBT"], + tags: ["Anxiety", "Depression"], + category: "Behavioural", + bestUsedFor: "Anxiety disorders", + }, + { + name: "Acceptance and commitment therapy", + aliases: ["ACT"], + tags: ["Anxiety"], + category: "Contextual", + bestUsedFor: "Psychological flexibility", + }, +]; + +const fullTherapyRecords = JSON.parse( + readFileSync(new URL(`../public/therapy-compass-data/${THERAPY_CATALOGUE_ASSETS.full}`, import.meta.url), "utf8"), +) as Therapy[]; + +describe("shared Therapy ranker", () => { + it("uses one normalized scoring contract for catalogue and universal callers", () => { + expect(scoreTherapyCandidate(records[0], "cognitive behavioural therapy")).toBeGreaterThan( + scoreTherapyCandidate(records[1], "cognitive behavioural therapy"), + ); + expect(scoreTherapyCandidate(records[0], "CBT")).toBeGreaterThan(scoreTherapyCandidate(records[1], "CBT")); + }); + + it("preserves stable alphabetical browse ordering and query relevance", () => { + expect(rankTherapyCandidates(records, "").map((match) => match.record.name)).toEqual([ + "Acceptance and commitment therapy", + "Cognitive behavioural therapy", + ]); + expect(rankTherapyCandidates(records, "CBT")[0]?.record.name).toBe("Cognitive behavioural therapy"); + }); + + it("excludes unreviewed Therapy content from production discovery and routes", () => { + expect(therapyRecords.length).toBeGreaterThan(0); + expect(therapyRecordsForEnvironment("production")).toEqual([]); + expect(searchTherapyRecords("CBT", "production")).toEqual([]); + expect(therapySlugs("production")).toEqual([]); + expect(findTherapyRecord(therapyRecords[0].slug, "production")).toBeUndefined(); + }); + + it.each([ + "CBT", + "ACT", + "DBT", + "EMDR", + "grounding", + "trauma", + "sleep", + "psychosis", + "self harm", + "adolescent", + "motivational interviewing", + ])("keeps the top five ordered identically across catalogue and universal discovery for %s", (query) => { + const catalogueOrder = searchTherapies(fullTherapyRecords, { ...EMPTY_SEARCH, query }) + .slice(0, 5) + .map((record) => record.slug); + const universalOrder = searchTherapyRecords(query, "development") + .slice(0, 5) + .map(({ record }) => record.slug); + + expect(universalOrder).toEqual(catalogueOrder); + }); + + it.each([ + ["CBT", "cognitive-behavioural-therapy-cbt"], + ["ACT", "acceptance-and-commitment-therapy-act"], + ["DBT", "dialectical-behaviour-therapy-dbt"], + ["EMDR", "eye-movement-desensitisation-and-reprocessing-emdr"], + ])("ranks the exact %s alias first", (query, expectedSlug) => { + expect(searchTherapyRecords(query, "development")[0]?.record.slug).toBe(expectedSlug); + }); +}); diff --git a/tests/therapy-review-regressions.test.ts b/tests/therapy-review-regressions.test.ts new file mode 100644 index 000000000..d0c1fdc1b --- /dev/null +++ b/tests/therapy-review-regressions.test.ts @@ -0,0 +1,95 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +function source(path: string): string { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("Therapy review regression contracts", () => { + it("keeps Therapy controls and overflow content keyboard accessible", () => { + const brief = source("src/components/therapy-compass/screens/brief-screen.tsx"); + const compare = source("src/components/therapy-compass/screens/compare-screen.tsx"); + + expect(brief).toContain('className="w-full min-h-tap'); + expect(compare).toContain('role="region"'); + expect(compare).toContain('aria-label="Therapy comparison table"'); + expect(compare).toContain("tabIndex={0}"); + }); + + it("keeps status and main-landmark semantics present through state changes", () => { + const detail = source("src/components/therapy-compass/screens/detail-screen.tsx"); + const workspace = source("src/components/therapy-compass/workspace.tsx"); + + expect(detail).toContain('role="status"'); + expect(detail).toContain(': "sr-only"'); + expect(workspace).toContain(''); + expect(workspace.indexOf("if (b.error)")).toBeLessThan( + workspace.indexOf("if (b.loading && b.therapies.length === 0)"), + ); + }); + + it("keeps Therapy unavailable in production until clinical review is complete", () => { + const layout = source("src/app/(search-app)/therapy-compass/layout.tsx"); + const modes = source("src/lib/app-modes.ts"); + const therapies = source("src/lib/therapies.ts"); + + expect(layout).toContain('isAppModeVisible("therapy-compass", "production")'); + expect(layout).toContain("notFound()"); + expect(modes).toMatch(/id: "therapy-compass"[\s\S]*?devOnly: true/); + expect(therapies).toContain('environment === "production"'); + expect(therapies).toContain("therapyNeedsReview(record)"); + }); + + it("adds the favourites check without validating existing rows in the same migration", () => { + const addConstraint = source("supabase/migrations/20260814150000_add_therapy_favourites.sql"); + const validateConstraint = source( + "supabase/migrations/20260814151000_validate_therapy_favourites_content_type.sql", + ); + + expect(addConstraint.toLowerCase()).toContain("not valid"); + expect(addConstraint.toLowerCase()).not.toContain("validate constraint"); + expect(validateConstraint.toLowerCase()).toContain("validate constraint user_favourites_content_type_check"); + expect(validateConstraint.toLowerCase()).not.toContain("drop constraint"); + expect(validateConstraint.toLowerCase()).not.toContain("add constraint"); + }); + + it("keeps follow-up Therapy review fixes canonical, token-backed, and single-pass", () => { + const home = source("src/components/therapy-compass/screens/home-screen.tsx"); + const detail = source("src/components/therapy-compass/screens/detail-screen.tsx"); + const select = source("src/components/therapy-compass/data/select.ts"); + const globals = source("src/app/globals.css"); + const universalSearch = source("tests/ui-universal-search.spec.ts"); + + expect(home).toContain('therapyScreenHref("recommend")'); + expect(home).toContain('therapyScreenHref("pathways")'); + expect(home).toContain('therapyScreenHref("compare")'); + expect(home).not.toContain('href: "/therapy-compass/'); + expect(home).not.toContain("`/therapy-compass/search?q="); + + const searchStart = select.indexOf("export function searchTherapies"); + const searchEnd = select.indexOf("// ---- related", searchStart); + expect(searchStart).toBeGreaterThanOrEqual(0); + expect(searchEnd).toBeGreaterThan(searchStart); + const searchImplementation = select.slice(searchStart, searchEnd); + expect(searchImplementation.match(/scoreTherapyCandidate\(/g)).toHaveLength(1); + + expect(detail).toContain("top-[calc(var(--shell-header-h)+1rem)]"); + + const printStart = globals.indexOf(" [data-print-provenance] {", globals.indexOf("@media print")); + const printEnd = globals.indexOf("\n }", printStart); + expect(printStart).toBeGreaterThanOrEqual(0); + expect(printEnd).toBeGreaterThan(printStart); + const printProvenance = globals.slice(printStart, printEnd); + expect(printProvenance).toContain("border-top: 1px solid var(--border);"); + expect(printProvenance).toContain("color: var(--text-muted);"); + expect(printProvenance).not.toContain("#d6dce5"); + expect(printProvenance).not.toContain("#5b6472"); + + const groupedStart = universalSearch.indexOf('test("selecting a grouped result navigates to the record"'); + const groupedEnd = universalSearch.indexOf('test("Enter with nothing highlighted', groupedStart); + expect(groupedStart).toBeGreaterThanOrEqual(0); + expect(groupedEnd).toBeGreaterThan(groupedStart); + expect(universalSearch.slice(groupedStart, groupedEnd)).toContain("await expect(option).toBeInViewport();"); + }); +}); diff --git a/tests/therapy-source-governance.test.ts b/tests/therapy-source-governance.test.ts new file mode 100644 index 000000000..de8afad6f --- /dev/null +++ b/tests/therapy-source-governance.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { therapySourceMetadata } from "@/lib/therapy-source-governance"; + +describe("therapy source governance", () => { + const source = { title: "Example guideline", sourceType: "Guideline", reference: "Ref 12" }; + + it("keeps imported source currency, designation and extraction quality unknown", () => { + expect(therapySourceMetadata(source, "needs_review")).toMatchObject({ + source_title: "Example guideline", + registry_record_subkind: null, + publisher: null, + document_status: "unknown", + clinical_validation_status: "unverified", + extraction_quality: "unknown", + }); + }); + + it("only maps an explicitly reviewed record to local review", () => { + expect(therapySourceMetadata(source, "reviewed").clinical_validation_status).toBe("locally_reviewed"); + }); +}); diff --git a/tests/therapy-tabs.dom.test.tsx b/tests/therapy-tabs.dom.test.tsx new file mode 100644 index 000000000..56ce18681 --- /dev/null +++ b/tests/therapy-tabs.dom.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { TcProvider } from "@/components/therapy-compass/bindings"; +import { BriefScreen } from "@/components/therapy-compass/screens/brief-screen"; +import { CompareScreen } from "@/components/therapy-compass/screens/compare-screen"; + +const navigation = vi.hoisted(() => ({ pathname: "/therapy-compass/compare", search: "ids=alpha,beta" })); + +vi.mock("next/navigation", () => ({ + usePathname: () => navigation.pathname, + useSearchParams: () => new URLSearchParams(navigation.search), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), +})); + +const therapy = vi.hoisted(() => (slug: string, name: string) => ({ + slug, + name, + category: "Skills based", + modality: "Individual", + clinicalSummary: `${name} summary`, + bestUsedFor: "Anxiety", + indications: "Anxiety", + contraindicationsOrCautions: "Review suitability before use.", + deliverySteps: "1. Orient the patient\n2. Practise the skill", + patientExplanation: "A practical therapy skill.", + sourceNotes: null, + targetSymptoms: "Anxiety", + patientPopulation: "Adults", + setting: "Outpatient", + sessionLength: "50 minutes", + timeRequired: "5 minutes", + complexity: "Low", + mechanism: "Skills practice", + briefVersion: "Orient and practise.", + fifteenMinuteVersion: "Orient, practise and review.", + fullSessionVersion: null, + homework: null, + materials: null, + commonPitfalls: null, + alternatives: null, + relatedTherapies: null, + evidenceLevel: "Review source", + evidenceNotes: null, + limitations: null, + references: null, + reviewStatus: "reviewed", + confidenceLevel: null, + contentOrigin: null, + patientSheetAvailable: true, + briefInterventionAvailable: true, + sourceCompleteness: null, + indexCompleteness: null, + reviewCompleteness: null, + tags: ["Anxiety"], + warnings: [], + aliases: [], + sources: [], + patientSheetTemplates: [], + clinicianScripts: [], + reviewChecklist: null, +})); + +vi.mock("@/components/therapy-compass/data/use-therapy-data", () => ({ + useTherapyData: () => ({ + data: { + therapies: [therapy("alpha", "Alpha therapy"), therapy("beta", "Beta therapy")], + pathways: [], + reference: { categories: [], tags: [], measures: [] }, + }, + loading: false, + error: null, + retry: vi.fn(), + }), +})); + +function expectOwnedTabPanel(label: string) { + const tablist = screen.getByRole("tablist", { name: label }); + const selectedTab = within(tablist).getByRole("tab", { selected: true }); + const panel = screen.getByRole("tabpanel"); + + expect(selectedTab).toHaveAttribute("aria-controls", panel.id); + expect(panel).toHaveAttribute("aria-labelledby", selectedTab.id); +} + +afterEach(() => { + navigation.pathname = "/therapy-compass/compare"; + navigation.search = "ids=alpha,beta"; +}); + +describe("Therapy shared Tabs ownership", () => { + it("owns the comparison table through an associated tabpanel", () => { + render( + + + , + ); + + expectOwnedTabPanel("Comparison fields"); + expect(screen.getByRole("table", { name: "Therapy comparison by clinical field" })).toBeInTheDocument(); + }); + + it("owns the brief intervention content through an associated tabpanel", () => { + navigation.pathname = "/therapy-compass/alpha/brief"; + navigation.search = ""; + + render( + + + , + ); + + expectOwnedTabPanel("Brief intervention duration"); + expect(screen.getByRole("heading", { name: "Alpha therapy" })).toBeInTheDocument(); + }); +}); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index bd899723f..6cf8391cc 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -669,19 +669,19 @@ test.describe("Clinical KB accessibility coverage", () => { waitUntil: "domcontentloaded", }); await expect(page.getByRole("heading", { name: "Brief Intervention" })).toBeVisible({ timeout: 60_000 }); - const durationGroup = page.getByRole("group", { name: "Brief intervention duration" }); - const fiveMinuteButton = durationGroup.getByRole("button", { name: "5 minutes", exact: true }); - const fifteenMinuteButton = durationGroup.getByRole("button", { name: "15 minutes", exact: true }); - await expect(fiveMinuteButton).toHaveAttribute("aria-pressed", "true"); - await fifteenMinuteButton.click(); - await expect(fiveMinuteButton).toHaveAttribute("aria-pressed", "false"); - await expect(fifteenMinuteButton).toHaveAttribute("aria-pressed", "true"); + const durationTabs = page.getByRole("tablist", { name: "Brief intervention duration" }); + const fiveMinuteTab = durationTabs.getByRole("tab", { name: "5 minutes", exact: true }); + const fifteenMinuteTab = durationTabs.getByRole("tab", { name: "15 minutes", exact: true }); + await expect(fiveMinuteTab).toHaveAttribute("aria-selected", "true"); + await fifteenMinuteTab.click(); + await expect(fiveMinuteTab).toHaveAttribute("aria-selected", "false"); + await expect(fifteenMinuteTab).toHaveAttribute("aria-selected", "true"); await page.goto("/therapy-compass/cognitive-behavioural-therapy-cbt/sheet", { waitUntil: "domcontentloaded", }); await expect(page.getByRole("heading", { name: "Patient Sheet Builder" })).toBeVisible({ timeout: 60_000 }); - await expect(page.getByRole("button", { name: "Plain", exact: true })).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("radio", { name: "Plain", exact: true })).toBeChecked(); const clinicianSwitch = page.getByRole("switch", { name: "Show clinician footer" }); const clinicianSwitchSize = await clinicianSwitch.evaluate((element) => { diff --git a/tests/ui-mode-nav-density.spec.ts b/tests/ui-mode-nav-density.spec.ts index 4a4d36d90..1bde4d814 100644 --- a/tests/ui-mode-nav-density.spec.ts +++ b/tests/ui-mode-nav-density.spec.ts @@ -80,7 +80,7 @@ const gotoTherapySearch = (page: Page) => gotoTherapy(page); * offline rather than leaving a mode uncovered. */ const MODES = [ - { modeId: "therapy-compass", route: "/therapy-compass/search?q=CBT&run=1", items: 7, profile: "extended" }, + { modeId: "therapy-compass", route: "/therapy-compass/search?q=CBT&run=1", items: 4, profile: "balanced-four" }, { modeId: "dsm", route: "/dsm/compare", items: 2, profile: "two-item" }, { modeId: "specifiers", route: "/specifiers/compare", items: 4, profile: "compact-four" }, { modeId: "formulation", route: "/formulation/compare", items: 4, profile: "compact-four" }, @@ -113,27 +113,6 @@ function densityPoints(profile: DensityProfile) { */ const slotsAt = (items: number, capacity: number) => Math.min(items, capacity); -/** - * A route whose destination is permanently in the overflow. - * - * Therapy ships seven destinations and the bands cap at five slots, so Brief - * Intervention never gets one — which fires `moreHoldsActive`, the branch that - * decides what the More slot renders when the current page has folded. If it - * ever borrows the folded label, this is the route where "Brief Intervention" - * lands beside Search and Compare in a 352px bar and the arithmetic stops - * working (~430px of content, 352px of space). - */ -const FOLDED_ACTIVE_ROUTE = "/therapy-compass/acceptance-and-commitment-therapy-act/brief"; - -/** - * The other permanently-folded destination. Both record-scoped pages reach the - * More slot by the same route through `data-active-from="none"`, but they are - * separate items with separate labels — and "Patient Sheets" is the second - * longest label in the set, so it is the one that would expose a regression - * where the slot sizes to its content after all. - */ -const FOLDED_SHEET_ROUTE = "/therapy-compass/acceptance-and-commitment-therapy-act/sheet"; - type NavState = { state: "bar" | "collapsed" | "none"; labels: { text: string; clipped: boolean; scrollWidth: number; clientWidth: number }[]; @@ -289,38 +268,11 @@ test.describe("ModeNav density", () => { }); } - for (const width of [BAND_3_PX, BAND_4_PX, BAND_5_PX]) { - for (const [route, label] of [ - [FOLDED_ACTIVE_ROUTE, "Brief Intervention"], - [FOLDED_SHEET_ROUTE, "Patient Sheets"], - ] as const) { - test(`keeps the overflow slot at one width on ${label} (${width}px)`, async ({ page }) => { - await page.setViewportSize({ width, height: 900 }); - await gotoTherapy(page, route); - - await expect.poll(async () => (await readNav(page)).state, { timeout: 10_000 }).toBe("bar"); - - const nav = await readNav(page); - expectNoClippedLabels(nav, `${width}px on ${label}`); - // The last slot is the overflow control, and its word does not change - // with the route. Borrowing the folded page's label is what the band - // budget cannot pay for; the rule and the off-screen name carry the - // signal instead, and neither has a width. - expect(nav.labels.at(-1)?.text).toBe("More"); - expect(nav.labels.map((slot) => slot.text)).not.toContain(label); - }); - } - } - /** * Every Therapy destination, at every band, must say where you are. * - * `search`/`compare` always have their own slot, `recommend` appears at 33rem - * and `pathways` at 42rem, and `brief` never does — so the mark moves between - * the page's own tab and the More slot as the container narrows. A component - * that decides this alone can only see "has a band at all", which leaves five - * of the seven pages showing nothing on a phone. Exactly one of the two must - * be marked at any width: never zero, never both. + * The mark moves between the page's own tab and More as the container narrows. + * Exactly one must be marked at any width: never zero, never both. */ for (const width of [BAND_3_PX, BAND_4_PX, BAND_5_PX]) { test(`marks the current page exactly once at ${width}px`, async ({ page }) => { @@ -331,8 +283,6 @@ test.describe("ModeNav density", () => { ["/therapy-compass/compare", "Compare"], ["/therapy-compass/recommend", "Recommend"], ["/therapy-compass/pathways", "Pathways"], - [FOLDED_ACTIVE_ROUTE, "Brief Intervention"], - [FOLDED_SHEET_ROUTE, "Patient Sheets"], ] as const) { await gotoTherapy(page, route); await expect.poll(async () => (await readNav(page)).state, { timeout: 10_000 }).toBe("bar"); diff --git a/tests/ui-primitives.dom.test.tsx b/tests/ui-primitives.dom.test.tsx index 60f0a9972..d1092c04d 100644 --- a/tests/ui-primitives.dom.test.tsx +++ b/tests/ui-primitives.dom.test.tsx @@ -104,7 +104,10 @@ describe("ToggleSwitch", () => { it("requires a name when operable", async () => { const onToggle = vi.fn(); render(); - await userEvent.click(screen.getByRole("switch", { name: "Pregnancy" })); + const toggle = screen.getByRole("switch", { name: "Pregnancy" }); + expect(toggle.className).toMatch(/min-h-tap/); + expect(toggle.className).toMatch(/min-w-tap/); + await userEvent.click(toggle); expect(onToggle).toHaveBeenCalledOnce(); }); diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index 509c2e0ed..002129c01 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -134,6 +134,23 @@ async function installOfflineApiFixtures(page: Page, problems: string[]) { await route.fulfill({ json: { records: [], total: 0, governance: {}, demoMode: true } }); return; } + if (pathname === "/api/search/universal") { + const query = url.searchParams.get("q") ?? ""; + const response = { + query, + tookMs: 0, + demoMode: true, + contextMode: url.searchParams.get("mode") ?? "therapy-compass", + preferredDomains: ["therapies"], + domainOrder: [], + groups: [], + }; + await route.fulfill({ + body: `${JSON.stringify({ type: "complete", response })}\n`, + contentType: "application/x-ndjson; charset=utf-8", + }); + return; + } const differentialMatch = pathname.match(/^\/api\/differentials\/([^/]+)$/); if (differentialMatch) { const record = getDifferentialRecord(decodeURIComponent(differentialMatch[1])); @@ -354,7 +371,10 @@ test.describe("previously uncovered production routes", () => { await compare.focus(); await page.keyboard.press("Space"); - await expect(page).toHaveURL(/\/therapy-compass\/compare$/); + await expect(page).toHaveURL(/\/therapy-compass\/compare(?:\?.*)?$/); + const comparisonUrl = new URL(page.url()); + expect(comparisonUrl.searchParams.get("q")).toBe("CBT"); + expect(comparisonUrl.searchParams.get("ids")).toBeTruthy(); await expect(page.getByRole("heading", { name: "Therapy Comparison", level: 1 })).toBeVisible(); }); diff --git a/tests/ui-style-contract.spec.ts b/tests/ui-style-contract.spec.ts index c1bc0b1f4..31d6a133a 100644 --- a/tests/ui-style-contract.spec.ts +++ b/tests/ui-style-contract.spec.ts @@ -23,6 +23,7 @@ test.describe("unlayered style rules render their effect", () => { for (const contract of STYLE_EFFECT_CONTRACTS) { test(contract.description, async ({ page }) => { + if (contract.viewport) await page.setViewportSize(contract.viewport); if (contract.bootstrap?.sessionStorage?.length) { await page.addInitScript( ({ sessionStorage }) => { diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 6ee42b12b..05da62d9a 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -245,6 +245,17 @@ test.describe("universal search typeahead", () => { const option = page.getByRole("option", { name: /^Acamprosate Alcohol/ }); await expect(option).toBeVisible(); + // Scroll the command list itself before clicking. Playwright's generic + // actionability scroll can move the document when this lower grouped item + // is clipped, which intentionally closes the floating command surface. + // A user reaches the item by scrolling this listbox, not the page. + await option.evaluate((element) => { + const listbox = element.closest('[role="listbox"]'); + if (!listbox) throw new Error("Universal-search option is not owned by a listbox."); + listbox.scrollTop = Math.max(0, (element as HTMLElement).offsetTop - listbox.clientHeight / 2); + }); + await expect(option).toBeVisible(); + await expect(option).toBeInViewport(); await option.click(); await expect(page).toHaveURL(/\/medications\/acamprosate/, { timeout: 30_000 }); }); diff --git a/tests/universal-search-also-matches-state.test.ts b/tests/universal-search-also-matches-state.test.ts new file mode 100644 index 000000000..61395f78e --- /dev/null +++ b/tests/universal-search-also-matches-state.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { shouldRunUniversalAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches-state"; + +describe("universal also-matches submission state", () => { + it("keeps non-Answer mode panels independent of the shared-home URL", () => { + expect(shouldRunUniversalAlsoMatches("services", "?mode=services")).toBe(true); + }); + + it("allows the server render until client URL state is available", () => { + expect(shouldRunUniversalAlsoMatches("answer", null)).toBe(true); + }); + + it("requires both run=1 and a query for Answer mode", () => { + expect(shouldRunUniversalAlsoMatches("answer", "?mode=answer&q=acamprosate&run=1")).toBe(true); + expect(shouldRunUniversalAlsoMatches("answer", "?mode=answer&q=acamprosate")).toBe(false); + expect(shouldRunUniversalAlsoMatches("answer", "?mode=answer&run=1")).toBe(false); + }); + + it("does not revive a persisted Answer panel on an unsubmitted hidden-mode home", () => { + expect(shouldRunUniversalAlsoMatches("answer", "?mode=answer&focus=1")).toBe(false); + }); +});