From 15b0ae32795ec56664becb9d786a02c9f8a52e83 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:49:49 +0800 Subject: [PATCH 01/25] feat(therapy): converge mode with shared site infrastructure --- ...a808d0e509b1e14d099c299ec5e1e334.record.md | 1 + ...dbb1c7065b10703f617accee66359995.record.md | 1 + ...041ad3983f040dbc0dc0f0cf4df50c84.record.md | 1 + docs/codebase-index.md | 2 +- docs/design-system/COMPONENTS.md | 14 +- docs/design-system/adoption-manifest.json | 60 ++- docs/site-map.md | 2 +- .../(search-app)/therapy-compass/layout.tsx | 15 +- src/app/api/account/favourites/route.ts | 2 +- src/app/globals.css | 90 +--- src/components/account-data-provider.tsx | 14 +- .../clinical-dashboard/dashboard-nav.tsx | 6 +- .../favourites-command-library-page.tsx | 5 +- .../favourites-prototype-data.ts | 4 +- .../master-search-header.tsx | 1 + .../shared-search-app-shell.tsx | 22 +- .../use-saved-registry-favourites.ts | 27 +- src/components/information-page-shell.tsx | 4 +- src/components/mode-nav/registry-mode-nav.tsx | 5 + src/components/page-secondary-navigation.tsx | 4 - src/components/therapy-compass/bindings.tsx | 503 ++++++++++-------- src/components/therapy-compass/data/select.ts | 25 +- src/components/therapy-compass/nav.tsx | 81 --- .../therapy-compass/screens/brief-screen.tsx | 489 +++++++++-------- .../screens/compare-screen.tsx | 207 +++---- .../therapy-compass/screens/detail-screen.tsx | 458 ++++++++-------- .../therapy-compass/screens/home-screen.tsx | 10 +- .../therapy-compass/screens/search-screen.tsx | 9 +- .../therapy-compass/screens/sheets-screen.tsx | 490 +++++++++-------- .../therapy-compass/therapy-card.tsx | 14 +- .../therapy-compass-route-layout.tsx | 22 + .../therapy-record-nav-header.tsx | 24 + src/components/therapy-compass/ui.tsx | 9 +- .../therapy-compass/use-clipboard.ts | 5 +- src/components/therapy-compass/workspace.tsx | 30 +- src/components/ui-primitives.tsx | 4 +- src/components/ui/print-output.tsx | 34 ++ src/lib/app-modes.ts | 17 +- src/lib/information-pages.ts | 5 +- src/lib/mode-secondary-navigation.ts | 45 +- src/lib/saved-registry-storage.ts | 2 + src/lib/therapies.ts | 68 +-- src/lib/therapy-compass-navigation.ts | 194 +++++++ src/lib/therapy-ranking.ts | 103 ++++ src/lib/therapy-source-governance.ts | 31 ++ .../20260814150000_add_therapy_favourites.sql | 6 + supabase/schema.sql | 2 +- tests/app-modes.test.ts | 11 +- tests/design-token-contract.test.ts | 9 +- tests/header-scroll-hide-contract.test.ts | 6 +- tests/helpers/style-contracts.ts | 19 +- tests/mode-home-loading-contract.test.ts | 6 +- tests/mode-nav-addon-slot.dom.test.tsx | 1 + tests/mode-nav-contract.test.ts | 67 +-- tests/mode-secondary-navigation.test.ts | 20 +- tests/page-secondary-navigation.dom.test.tsx | 8 +- tests/route-reachability.test.ts | 12 +- ...ompass-artifact-href-fallback.dom.test.tsx | 64 --- ...y-compass-artifact-navigation.dom.test.tsx | 57 -- ...therapy-compass-data-recovery.dom.test.tsx | 4 +- tests/therapy-compass-mode-wiring.test.ts | 5 +- tests/therapy-compass-navigation.test.ts | 72 +++ ...herapy-compass-search-filters.dom.test.tsx | 7 + ...herapy-global-convergence-contract.test.ts | 44 ++ tests/therapy-ranking.test.ts | 34 ++ tests/therapy-source-governance.test.ts | 20 + tests/therapy-tabs.dom.test.tsx | 116 ++++ tests/ui-accessibility.spec.ts | 16 +- tests/ui-mode-nav-density.spec.ts | 56 +- tests/ui-primitives.dom.test.tsx | 5 +- tests/ui-route-coverage.spec.ts | 22 +- tests/ui-style-contract.spec.ts | 1 + tests/ui-universal-search.spec.ts | 10 + 73 files changed, 2220 insertions(+), 1639 deletions(-) create mode 100644 docs/branch-review-records/15df886bae0a49c72881baed6a778a36a808d0e509b1e14d099c299ec5e1e334.record.md create mode 100644 docs/branch-review-records/2d205ab081f71c1df3d01466069b2308dbb1c7065b10703f617accee66359995.record.md create mode 100644 docs/branch-review-records/fc9303a43ee12deed2f6e7bc96f9b216041ad3983f040dbc0dc0f0cf4df50c84.record.md delete mode 100644 src/components/therapy-compass/nav.tsx create mode 100644 src/components/therapy-compass/therapy-compass-route-layout.tsx create mode 100644 src/components/therapy-compass/therapy-record-nav-header.tsx create mode 100644 src/components/ui/print-output.tsx create mode 100644 src/lib/therapy-compass-navigation.ts create mode 100644 src/lib/therapy-ranking.ts create mode 100644 src/lib/therapy-source-governance.ts create mode 100644 supabase/migrations/20260814150000_add_therapy_favourites.sql delete mode 100644 tests/therapy-compass-artifact-href-fallback.dom.test.tsx create mode 100644 tests/therapy-compass-navigation.test.ts create mode 100644 tests/therapy-global-convergence-contract.test.ts create mode 100644 tests/therapy-ranking.test.ts create mode 100644 tests/therapy-source-governance.test.ts create mode 100644 tests/therapy-tabs.dom.test.tsx diff --git a/docs/branch-review-records/15df886bae0a49c72881baed6a778a36a808d0e509b1e14d099c299ec5e1e334.record.md b/docs/branch-review-records/15df886bae0a49c72881baed6a778a36a808d0e509b1e14d099c299ec5e1e334.record.md new file mode 100644 index 0000000000..425302e48f --- /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/2d205ab081f71c1df3d01466069b2308dbb1c7065b10703f617accee66359995.record.md b/docs/branch-review-records/2d205ab081f71c1df3d01466069b2308dbb1c7065b10703f617accee66359995.record.md new file mode 100644 index 0000000000..c14d70f9f9 --- /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/fc9303a43ee12deed2f6e7bc96f9b216041ad3983f040dbc0dc0f0cf4df50c84.record.md b/docs/branch-review-records/fc9303a43ee12deed2f6e7bc96f9b216041ad3983f040dbc0dc0f0cf4df50c84.record.md new file mode 100644 index 0000000000..4d52bdc3ad --- /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 3f3bc2b291..0f6aae5f17 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 e2eea76857..389fab312d 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 | 26 | | `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 0f706d3d38..95db36828e 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -279,11 +279,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, @@ -1404,6 +1406,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": [ @@ -1416,6 +1420,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": { @@ -1562,12 +1568,14 @@ "tests/header-scroll-hide-contract.test.ts", "tests/image-lightbox-geometry.test.ts", "tests/in-page-nav-route-sections.dom.test.tsx", + "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 +1643,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 +1705,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 +1799,25 @@ "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-tabs.dom.test.tsx", + "tests/ui-v2-components.dom.test.tsx" + ], "baseline": { "targetLayer": "v2", "liveLayer": "v2", @@ -1796,8 +1826,8 @@ }, "built": true, "locallyRegistered": true, - "v2ShellMounted": false, - "v2MountMode": "none", + "v2ShellMounted": true, + "v2MountMode": "inherited-global-root", "proofDeclared": true, "baselineCommitted": false }, @@ -1903,11 +1933,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, @@ -3400,7 +3432,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 55ea7e389e..dd99e3d017 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -59,7 +59,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/formulation` - Formulation mode. Search kind: `formulation`. Query example: `/formulation?q=I+keep+going+over+it&focus=1&run=1`. - `/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`. -- `/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)/therapy-compass/layout.tsx b/src/app/(search-app)/therapy-compass/layout.tsx index e44b5c3be9..c568a84b91 100644 --- a/src/app/(search-app)/therapy-compass/layout.tsx +++ b/src/app/(search-app)/therapy-compass/layout.tsx @@ -1,6 +1,17 @@ +import { Suspense } from "react"; import type { ReactNode } from "react"; -// 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"; + +// 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; + return ( + }> + {children} + + ); } diff --git a/src/app/api/account/favourites/route.ts b/src/app/api/account/favourites/route.ts index d59fa52389..36f61f40cf 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 23dfd37aae..6454c34075 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4115,69 +4115,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; @@ -4234,25 +4175,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; @@ -4261,10 +4203,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 #d6dce5; + padding-top: var(--spacing-icon-xs); + color: #5b6472; + 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 df34725688..597f41e649 100644 --- a/src/components/account-data-provider.tsx +++ b/src/components/account-data-provider.tsx @@ -8,19 +8,21 @@ 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; -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 +31,7 @@ function readDemoFavourites(): FavouritesByType { service: readSavedRegistrySlugs(savedServicesStorageKey), form: readSavedRegistrySlugs(savedFormsStorageKey), differential: readSavedRegistrySlugs(savedDifferentialsStorageKey), + therapy: readSavedRegistrySlugs(savedTherapiesStorageKey), }; } @@ -53,13 +56,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() ) { diff --git a/src/components/clinical-dashboard/dashboard-nav.tsx b/src/components/clinical-dashboard/dashboard-nav.tsx index 74016390c4..fc12a3d4f5 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 517d1f57d2..cf9beadf46 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -52,7 +52,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"; type ViewMode = FavouritesViewMode; type SortMode = "last-used" | "title" | "type"; @@ -90,6 +90,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 = { @@ -109,6 +110,7 @@ const typeByPrototypeType: Record services: "Service", forms: "Form", differentials: "Differential", + therapies: "Therapy", }; const fallbackIconByType: Record = { @@ -118,6 +120,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 a2f0a8f24d..fef6ef4b5c 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 471929f013..987404a02f 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -373,6 +373,7 @@ export function MasterSearchHeader({ selectedSearch.kind === "documents" || selectedSearch.kind === "forms" || selectedSearch.kind === "services" || + selectedSearch.kind === "therapies" || selectedSearch.kind === "tools" || selectedSearch.kind === "favourites" || selectedSearch.kind === "specifiers" || diff --git a/src/components/clinical-dashboard/shared-search-app-shell.tsx b/src/components/clinical-dashboard/shared-search-app-shell.tsx index 286a4fde26..6712523b7c 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/use-saved-registry-favourites.ts b/src/components/clinical-dashboard/use-saved-registry-favourites.ts index d241532a8a..7006d5c0e2 100644 --- a/src/components/clinical-dashboard/use-saved-registry-favourites.ts +++ b/src/components/clinical-dashboard/use-saved-registry-favourites.ts @@ -11,6 +11,7 @@ import { type SavedFavouritesBandStatus, } from "@/components/clinical-dashboard/saved-registry-favourites-status"; import type { ServiceRecord } from "@/lib/services"; +import { findTherapyRecord } from "@/lib/therapies"; import { useRegistryRecords } from "@/lib/use-registry-records"; function recordToFavourite(record: ServiceRecord, type: "services" | "forms"): FavouriteItem { @@ -51,6 +52,7 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { const savedServices = favourites.service; const savedForms = favourites.form; const savedDifferentials = favourites.differential; + const savedTherapies = favourites.therapy; const services = useRegistryRecords("service", { enabled: savedServices.length > 0, view: "search" }); const forms = useRegistryRecords("form", { enabled: savedForms.length > 0, view: "search" }); @@ -80,8 +82,29 @@ export function useSavedRegistryFavourites(): SavedRegistryFavouritesResult { icon: BrainCircuit, keywords: slug.replaceAll("-", " "), })); - return [...serviceItems, ...formItems, ...differentialItems]; - }, [services.records, forms.records, savedServices, savedForms, savedDifferentials]); + const therapyItems: FavouriteItem[] = 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, + ]; + }); + return [...serviceItems, ...formItems, ...differentialItems, ...therapyItems]; + }, [services.records, forms.records, savedServices, savedForms, savedDifferentials, savedTherapies]); // 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. diff --git a/src/components/information-page-shell.tsx b/src/components/information-page-shell.tsx index c728d32438..b41b85613e 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 3479577dc4..5956e9c161 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 620fe46eea..751759ad44 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 8187074858..734ae191a0 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,63 @@ 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 [seededUrlStateKey, setSeededUrlStateKey] = useState(urlStateKey); + if (urlStateKey !== seededUrlStateKey) { + setSeededUrlStateKey(urlStateKey); + setCompareSlugs(workspaceFromUrl.compareSlugs); + setSearch((prev) => ({ + ...prev, + query: qParam, + 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 +314,34 @@ export function TcProvider({ children }: { children: ReactNode }) { ); const value = useMemo(() => { - const go = (next: string) => router.push(screenHref(next)); + 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) => setSheetSections((prev) => ({ ...prev, [key]: !prev[key] })); 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 +353,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 +371,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 +384,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 +396,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, @@ -533,21 +551,57 @@ export function TcProvider({ children }: { children: ReactNode }) { chipPractice: chipStyle(sheetSections.practice), chipCoping: chipStyle(sheetSections.coping), chipContacts: chipStyle(sheetSections.contacts), - toggleAbout: () => toggleSection("about"), - toggleSteps: () => toggleSection("steps"), - togglePractice: () => toggleSection("practice"), - toggleCoping: () => toggleSection("coping"), - toggleContacts: () => toggleSection("contacts"), + toggleAbout: () => { + toggleSection("about"); + replaceWorkspace({ + sections: sheetSections.about + ? enabledSections.filter((key) => key !== "about") + : [...enabledSections, "about"], + }); + }, + toggleSteps: () => { + toggleSection("steps"); + replaceWorkspace({ + sections: sheetSections.steps + ? enabledSections.filter((key) => key !== "steps") + : [...enabledSections, "steps"], + }); + }, + togglePractice: () => { + toggleSection("practice"); + replaceWorkspace({ + sections: sheetSections.practice + ? enabledSections.filter((key) => key !== "practice") + : [...enabledSections, "practice"], + }); + }, + toggleCoping: () => { + toggleSection("coping"); + replaceWorkspace({ + sections: sheetSections.coping + ? enabledSections.filter((key) => key !== "coping") + : [...enabledSections, "coping"], + }); + }, + toggleContacts: () => { + toggleSection("contacts"); + replaceWorkspace({ + sections: sheetSections.contacts + ? enabledSections.filter((key) => key !== "contacts") + : [...enabledSections, "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 +624,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 c0321faaa6..9fe9b86321 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,24 +169,6 @@ 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); @@ -194,9 +177,9 @@ export function searchTherapies(therapies: Therapy[], opts: SearchOptions): Ther 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; + return scoreTherapyCandidate(t, q, "catalogue") > 0; }) - .map((t) => ({ t, s: scoreTherapy(t, q) })); + .map((t) => ({ t, s: scoreTherapyCandidate(t, q, "catalogue") })); scored.sort((a, b) => b.s - a.s || a.t.name.localeCompare(b.t.name)); return scored.map((x) => x.t); } @@ -279,7 +262,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, "catalogue"), 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 efe7f99593..0000000000 --- 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 d59a2d8043..af6d95a14d 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 81d2abf4ca..7c79cdb4ae 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 +317,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 d29824ec93..24dbd28e0c 100644 --- a/src/components/therapy-compass/screens/detail-screen.tsx +++ b/src/components/therapy-compass/screens/detail-screen.tsx @@ -2,8 +2,11 @@ import type { ReactNode } from "react"; -import { ContextualBackLink } from "@/components/contextual-back-link"; -import { pageContainer } from "@/components/ui-primitives"; +import { useAccountData } from "@/components/account-data-provider"; +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 +14,6 @@ import { complexityLabel, parseSteps, summarise } from "../data/select"; import type { Therapy } from "../data/types"; import { AlertIcon, - ArrowLeftIcon, ChecklistIcon, ChevronRightIcon, ClockIcon, @@ -20,246 +22,276 @@ import { DatabaseIcon, FileTextIcon, InfoIcon, + HeartIcon, PersonIcon, ScaleIcon, ShieldIcon, } from "../icons"; +import { TherapyRecordNavHeader } from "../therapy-record-nav-header"; import { Eyebrow, LoadingState, StatusBadge, TagRow } from "../ui"; export function DetailScreen() { const b = useTcBindings(); + const accountData = useAccountData(); const t = b.selectedTherapy; if (!t) return ; const steps = parseSteps(t.deliverySteps); + const saved = accountData.isSaved("therapy", t.slug); 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} + +
+

+ {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 00ddb42dec..81901bef2c 100644 --- a/src/components/therapy-compass/screens/home-screen.tsx +++ b/src/components/therapy-compass/screens/home-screen.tsx @@ -1,6 +1,6 @@ "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"; @@ -48,10 +48,10 @@ export function HomeScreen() { href: "/therapy-compass/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: "/therapy-compass/compare", }, ]} pillsTitle="Common therapy searches" diff --git a/src/components/therapy-compass/screens/search-screen.tsx b/src/components/therapy-compass/screens/search-screen.tsx index 894554c24f..b81fab9812 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 079ae78997..5b1e2dc40d 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 ffc98e1914..80c46a2260 100644 --- a/src/components/therapy-compass/therapy-card.tsx +++ b/src/components/therapy-compass/therapy-card.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; -import { ignoreUnavailableActivation } from "@/components/ui-primitives"; +import { useAccountData } from "@/components/account-data-provider"; import { useTcBindings } from "./bindings"; import { cardPreviewText, prioritiseTherapyTags, summarise } from "./data/select"; import type { Therapy } from "./data/types"; @@ -22,6 +22,8 @@ import { Eyebrow, IconTile, TagRow } from "./ui"; /** Large search-result card with why-matched / avoid / best-fit columns. */ export function ResultCard({ therapy }: { therapy: Therapy }) { const b = useTcBindings(); + const accountData = useAccountData(); + const saved = accountData.isSaved("therapy", therapy.slug); const inCompare = b.isInCompare(therapy.slug); const subtitle = cardPreviewText(therapy.clinicalSummary, { exclude: therapy.name }) || @@ -54,12 +56,12 @@ export function ResultCard({ therapy }: { therapy: Therapy }) {
    diff --git a/src/components/therapy-compass/therapy-compass-route-layout.tsx b/src/components/therapy-compass/therapy-compass-route-layout.tsx new file mode 100644 index 0000000000..6b60d11b4e --- /dev/null +++ b/src/components/therapy-compass/therapy-compass-route-layout.tsx @@ -0,0 +1,22 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +const THERAPY_HOME = "/therapy-compass"; +const TherapyCompassWorkspace = dynamic(() => import("./workspace").then((module) => module.TherapyCompassWorkspace)); + +/** + * Route-local client boundary for Therapy's catalogue workspace. + * + * The home remains the lightweight shared ModeHomeTemplate surface and must not + * mount the full catalogue provider. Every richer child route keeps the provider + * mounted across client navigation without teaching the global shell about one + * mode's private state. + */ +export function TherapyCompassRouteLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + if (pathname === THERAPY_HOME) return children; + return {children}; +} diff --git a/src/components/therapy-compass/therapy-record-nav-header.tsx b/src/components/therapy-compass/therapy-record-nav-header.tsx new file mode 100644 index 0000000000..e0f77bdcb4 --- /dev/null +++ b/src/components/therapy-compass/therapy-record-nav-header.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { InPageNavHeader } from "@/components/in-page-nav/in-page-nav-header"; + +export function TherapyRecordNavHeader({ + title, + backHref, + backLabel, + testIdPrefix, +}: { + title: string; + backHref: string; + backLabel: string; + testIdPrefix: string; +}) { + return ( + + ); +} diff --git a/src/components/therapy-compass/ui.tsx b/src/components/therapy-compass/ui.tsx index c0546dcb4d..b5cd59299b 100644 --- a/src/components/therapy-compass/ui.tsx +++ b/src/components/therapy-compass/ui.tsx @@ -201,7 +201,14 @@ export function Meter({ value, label }: { value: number | null; label: string }) {label} {value == null ? "—" : `${v}%`}
    - + { - 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/workspace.tsx b/src/components/therapy-compass/workspace.tsx index 71dd81b5c1..98b7ed40e1 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,16 @@ function TherapyCompassDataError() { ); } +function TherapyCompassInformationRoute({ children }: { children: ReactNode }) { + const b = useTcBindings(); + if (!b.error) return children; + return ( + + + + ); +} + function TherapyCompassMain({ children, showFooter, @@ -79,6 +90,7 @@ function TherapyCompassMain({ export function TherapyCompassWorkspace({ children }: { children: ReactNode }) { const pathname = usePathname(); const isHome = pathname === "/therapy-compass"; + const informationPage = isInformationPage(pathname); return ( @@ -86,15 +98,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 71f0695c9e..e907291049 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 0000000000..3abbb54dce --- /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 704ad37b77..394f806235 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -31,6 +31,7 @@ export type AppModeSearchKind = | "dsm" | "specifiers" | "formulation" + | "therapies" | "tools"; export type AppModeResultKind = AppModeSearchKind; @@ -345,10 +346,7 @@ export const appModeDefinitions = [ // qualified-clinician sign-off, so Therapy is now a first-class mode in the // production sidebar and MODE dropdown (no longer devOnly-gated). 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. @@ -360,7 +358,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", @@ -448,7 +446,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; } @@ -523,6 +527,7 @@ export function isSearchableAppMode(modeId: string): modeId is SearchableAppMode kind === "dsm" || kind === "specifiers" || kind === "formulation" || + kind === "therapies" || kind === "tools" ); } diff --git a/src/lib/information-pages.ts b/src/lib/information-pages.ts index 2389db9ec5..6efde34162 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 48b959ab4d..8878e96241 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"; }; /** @@ -57,18 +50,15 @@ export const modeSecondaryNavigationRegistry = { ], prescribing: [], tools: [], - // Inert: `PageSecondaryNavigation` early-returns on `/therapy-compass*`, and - // the mode's live destination list is `useTherapyNavItems` in - // `src/components/therapy-compass/nav.tsx`, which feeds the shared `ModeNav`. - // Kept because this registry is a 13-mode contract, not because it renders. - // Editing these entries changes nothing a user sees. + // Record-owned outputs (briefs and patient sheets) deliberately stay off the + // global mode bar: they require an explicitly selected therapy. This prevents + // a generic navigation action from silently opening an unrelated default + // record. The global/sidebar mode switch already owns Home. "therapy-compass": [ - { id: "search", label: "Search", action: "therapy-search" }, - { id: "recommend", label: "Recommend", action: "therapy-recommend" }, - { id: "compare", label: "Compare", action: "therapy-compare" }, - { id: "pathways", label: "Pathways", action: "therapy-pathways" }, - { id: "brief", label: "Brief Intervention", action: "therapy-brief" }, - { id: "sheets", label: "Patient Sheets", action: "therapy-sheets" }, + { id: "search", label: "Search", href: "/therapy-compass/search" }, + { id: "recommend", label: "Recommend", href: "/therapy-compass/recommend" }, + { id: "compare", label: "Compare", href: "/therapy-compass/compare" }, + { id: "pathways", label: "Pathways", href: "/therapy-compass/pathways" }, ], // Two genuinely distinct surfaces: `/factsheets` is the browse home (category // chips + a featured grid) and `/factsheets/search` is a separate component @@ -106,9 +96,8 @@ export type RoutedModeSecondaryNavigationId = Extract = query ? [["q", query]] : []; // Returning to Search with a carried query must reopen the results view diff --git a/src/lib/saved-registry-storage.ts b/src/lib/saved-registry-storage.ts index 98e748f3f8..a8f518f860 100644 --- a/src/lib/saved-registry-storage.ts +++ b/src/lib/saved-registry-storage.ts @@ -1,6 +1,7 @@ export const savedServicesStorageKey = "clinical-kb-saved-services"; export const savedFormsStorageKey = "clinical-kb-saved-forms"; export const savedDifferentialsStorageKey = "clinical-kb-saved-differentials"; +export const savedTherapiesStorageKey = "clinical-kb-saved-therapies"; export const savedFactsheetsStorageKey = "clinical-kb-saved-factsheets"; export const savedRegistryStorageChangedEvent = "clinical-kb-saved-registry-changed"; @@ -36,6 +37,7 @@ export function subscribeSavedRegistrySlugs(onChange: () => void) { event.key === savedServicesStorageKey || event.key === savedFormsStorageKey || event.key === savedDifferentialsStorageKey || + event.key === savedTherapiesStorageKey || event.key === savedFactsheetsStorageKey ) { onChange(); diff --git a/src/lib/therapies.ts b/src/lib/therapies.ts index 61d42e4472..0b84430800 100644 --- a/src/lib/therapies.ts +++ b/src/lib/therapies.ts @@ -1,4 +1,5 @@ import therapiesIndexJson from "@/data/therapies-index.json"; +import { rankTherapyCandidates } from "@/lib/therapy-ranking"; // Server-side therapy catalogue. Backed by src/data/therapies-index.json — a trimmed, // rankable projection of the ~2.5 MB public Therapy Compass dataset (regenerated by @@ -55,74 +56,15 @@ export function therapyNeedsReview(record: TherapyIndexRecord): boolean { return record.reviewStatus !== "reviewed"; } -const normalize = (value: string | null | undefined) => - (value ?? "") - .toLowerCase() - .replace(/[^a-z0-9\s]/g, " ") - .replace(/\s+/g, " ") - .trim(); - -function haystackFor(record: TherapyIndexRecord): string { - return normalize( - [ - record.name, - record.category, - record.modality, - record.bestUsedFor, - record.targetSymptoms, - record.clinicalSummary, - record.indications, - record.tags.join(" "), - record.aliases.join(" "), - ] - .filter(Boolean) - .join(" "), - ); -} - export type TherapySearchMatch = { record: TherapyIndexRecord; score: number }; /** - * Rank the therapy library for a query. Mirrors the client scorer in - * data/select.ts but runs over the trimmed server index. An empty query returns + * Rank the therapy library for a query through the shared universal profile. + * The full catalogue uses the same ranking owner with its established profile. + * An empty query returns * the alphabetical library (stable order) so the universal-search domain can * still surface a browse list. */ export function searchTherapyRecords(query: string): TherapySearchMatch[] { - const normalizedQuery = normalize(query); - const queryTokens = normalizedQuery.split(" ").filter(Boolean); - - return therapyRecords - .map((record, index) => { - const name = normalize(record.name); - const aliases = record.aliases.map(normalize); - const tags = normalize(record.tags.join(" ")); - const bestUsedFor = normalize(record.bestUsedFor); - const targetSymptoms = normalize(record.targetSymptoms); - const haystack = haystackFor(record); - - // No query → preserve alphabetical order as a descending browse score. - let score = normalizedQuery ? 0 : therapyRecords.length - index; - - if (normalizedQuery) { - if (name === normalizedQuery) score += 100; - else if (name.startsWith(normalizedQuery)) score += 55; - else if (name.includes(normalizedQuery)) score += 30; - if (aliases.some((alias) => alias === normalizedQuery)) score += 60; - else if (aliases.some((alias) => alias.includes(normalizedQuery))) score += 22; - if (tags.includes(normalizedQuery)) score += 18; - if (bestUsedFor.includes(normalizedQuery)) score += 10; - if (targetSymptoms.includes(normalizedQuery)) score += 8; - for (const token of queryTokens) { - if (name.includes(token)) score += 12; - if (aliases.some((alias) => alias.includes(token))) score += 8; - if (tags.includes(token)) score += 6; - if (haystack.includes(token)) score += 3; - } - } - - return score > 0 ? { record, score } : null; - }) - .filter((match): match is TherapySearchMatch => Boolean(match)) - .sort((left, right) => right.score - left.score || left.record.name.localeCompare(right.record.name)); + return rankTherapyCandidates(therapyRecords, query, "universal"); } diff --git a/src/lib/therapy-compass-navigation.ts b/src/lib/therapy-compass-navigation.ts new file mode 100644 index 0000000000..53f67428ae --- /dev/null +++ b/src/lib/therapy-compass-navigation.ts @@ -0,0 +1,194 @@ +export const THERAPY_COMPASS_BASE = "/therapy-compass"; +export const THERAPY_MAX_COMPARE = 4; + +export const THERAPY_KNOWN_SCREENS = [ + "search", + "detail", + "compare", + "recommend", + "pathways", + "brief", + "home", + "sheets", +] as const; + +export type TherapyScreen = (typeof THERAPY_KNOWN_SCREENS)[number]; +export type TherapyCompareTab = "priorities" | "differences" | "all"; +export type TherapyDensity = "comfortable" | "dense"; +export type TherapyBriefDuration = "5min" | "15min" | "ground"; +export type TherapySheetTone = "plain" | "warm" | "clinical"; +export type TherapySheetSection = "about" | "steps" | "practice" | "coping" | "contacts"; + +export const THERAPY_RESERVED_ROUTE_SEGMENTS = ["search", "recommend", "compare", "pathways", "review"] as const; +const RESERVED_SEGMENTS = new Set(THERAPY_RESERVED_ROUTE_SEGMENTS); +const ALL_SHEET_SECTIONS: TherapySheetSection[] = ["about", "steps", "practice", "coping", "contacts"]; + +export const THERAPY_WORKSPACE_PARAM_KEYS = [ + "ids", + "topic", + "brief", + "sheet", + "reviewed", + "constraint", + "pathway", + "comparison", + "density", + "duration", + "tone", + "section", + "clinician", +] as const; + +const THERAPY_SENSITIVE_PARAM_KEYS = ["prompt", "recQuery", "recommendation", "patient", "notes"] as const; + +export type TherapyWorkspaceState = { + compareSlugs: string[]; + topics: string[]; + briefOnly: boolean; + sheetOnly: boolean; + reviewedOnly: boolean; + constraints: string[]; + pathwaySlug: string | null; + comparison: TherapyCompareTab; + density: TherapyDensity; + duration: TherapyBriefDuration; + tone: TherapySheetTone; + sections: TherapySheetSection[]; + clinician: boolean; +}; + +export const DEFAULT_THERAPY_WORKSPACE_STATE: TherapyWorkspaceState = { + compareSlugs: [], + topics: [], + briefOnly: false, + sheetOnly: false, + reviewedOnly: false, + constraints: ["outpatient"], + pathwaySlug: null, + comparison: "differences", + density: "comfortable", + duration: "5min", + tone: "plain", + sections: ALL_SHEET_SECTIONS, + clinician: true, +}; + +function uniqueNonEmpty(values: Iterable, limit = Number.POSITIVE_INFINITY): string[] { + const unique: string[] = []; + for (const value of values) { + const trimmed = value.trim(); + if (!trimmed || unique.includes(trimmed)) continue; + unique.push(trimmed); + if (unique.length >= limit) break; + } + return unique; +} + +function oneOf(value: string | null, options: readonly T[], fallback: T): T { + return value && options.includes(value as T) ? (value as T) : fallback; +} + +/** Resolve the active Therapy screen and record slug from a canonical pathname. */ +export function resolveTherapyRoute(pathname: string): { screen: string; slug: string | null } { + const rest = pathname.startsWith(THERAPY_COMPASS_BASE) + ? pathname.slice(THERAPY_COMPASS_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 }; + const screen = second === "brief" ? "brief" : second === "sheet" ? "sheets" : "detail"; + return { screen, slug: first }; +} + +export function therapyScreenHref(screen: string): string { + return screen === "home" ? THERAPY_COMPASS_BASE : `${THERAPY_COMPASS_BASE}/${screen}`; +} + +export function therapyRecordHref(slug: string, artifact?: "brief" | "sheet"): string { + const encodedSlug = encodeURIComponent(slug); + return artifact ? `${THERAPY_COMPASS_BASE}/${encodedSlug}/${artifact}` : `${THERAPY_COMPASS_BASE}/${encodedSlug}`; +} + +/** Parse only non-sensitive, explicitly shareable workspace state. */ +export function readTherapyWorkspaceState(params: Pick): TherapyWorkspaceState { + const compareSlugs = uniqueNonEmpty((params.get("ids") ?? "").split(","), THERAPY_MAX_COMPARE); + const topics = uniqueNonEmpty(params.getAll("topic")); + const rawConstraints = uniqueNonEmpty(params.getAll("constraint")); + const rawSections = uniqueNonEmpty(params.getAll("section")); + const sections = rawSections.filter((value): value is TherapySheetSection => + ALL_SHEET_SECTIONS.includes(value as TherapySheetSection), + ); + + return { + compareSlugs, + topics, + briefOnly: params.get("brief") === "1", + sheetOnly: params.get("sheet") === "1", + reviewedOnly: params.get("reviewed") === "1", + constraints: rawConstraints.includes("none") + ? [] + : rawConstraints.length > 0 + ? rawConstraints + : DEFAULT_THERAPY_WORKSPACE_STATE.constraints, + pathwaySlug: params.get("pathway")?.trim() || null, + comparison: oneOf(params.get("comparison"), ["priorities", "differences", "all"], "differences"), + density: oneOf(params.get("density"), ["comfortable", "dense"], "comfortable"), + duration: oneOf(params.get("duration"), ["5min", "15min", "ground"], "5min"), + tone: oneOf(params.get("tone"), ["plain", "warm", "clinical"], "plain"), + sections: rawSections.includes("none") ? [] : sections.length > 0 ? sections : ALL_SHEET_SECTIONS, + clinician: params.get("clinician") !== "0", + }; +} + +function setRepeated(params: URLSearchParams, key: string, values: readonly string[]) { + params.delete(key); + for (const value of uniqueNonEmpty(values)) params.append(key, value); +} + +/** + * Canonicalise shareable state while preserving unrelated search/navigation + * context such as `q`, `run`, and `from`. Free-text recommendation prompts are + * intentionally unrepresentable here so they cannot leak into URLs or logs. + */ +export function therapyWorkspaceSearchParams( + current: Pick, + state: TherapyWorkspaceState, +): URLSearchParams { + const params = new URLSearchParams(current.toString()); + for (const key of THERAPY_WORKSPACE_PARAM_KEYS) params.delete(key); + for (const key of THERAPY_SENSITIVE_PARAM_KEYS) params.delete(key); + + if (state.compareSlugs.length > 0) + params.set("ids", uniqueNonEmpty(state.compareSlugs, THERAPY_MAX_COMPARE).join(",")); + setRepeated(params, "topic", state.topics); + if (state.briefOnly) params.set("brief", "1"); + if (state.sheetOnly) params.set("sheet", "1"); + if (state.reviewedOnly) params.set("reviewed", "1"); + if (state.constraints.join("|") !== DEFAULT_THERAPY_WORKSPACE_STATE.constraints.join("|")) { + if (state.constraints.length === 0) params.set("constraint", "none"); + else setRepeated(params, "constraint", state.constraints); + } + if (state.pathwaySlug) params.set("pathway", state.pathwaySlug); + if (state.comparison !== DEFAULT_THERAPY_WORKSPACE_STATE.comparison) params.set("comparison", state.comparison); + if (state.density !== DEFAULT_THERAPY_WORKSPACE_STATE.density) params.set("density", state.density); + if (state.duration !== DEFAULT_THERAPY_WORKSPACE_STATE.duration) params.set("duration", state.duration); + if (state.tone !== DEFAULT_THERAPY_WORKSPACE_STATE.tone) params.set("tone", state.tone); + if (state.sections.join("|") !== ALL_SHEET_SECTIONS.join("|")) { + if (state.sections.length === 0) params.set("section", "none"); + else setRepeated(params, "section", state.sections); + } + if (!state.clinician) params.set("clinician", "0"); + return params; +} + +export function therapyHrefWithSearchParams(pathname: string, params: Pick): string { + const query = params.toString(); + return query ? `${pathname}?${query}` : pathname; +} + +/** Entries safe to carry between Therapy workflows through the shared mode bar. */ +export function therapyWorkspaceNavigationEntries(params: URLSearchParams): Array { + const allowed = new Set(["q", "run", ...THERAPY_WORKSPACE_PARAM_KEYS]); + return Array.from(params.entries()).filter(([key, value]) => allowed.has(key) && Boolean(value)); +} diff --git a/src/lib/therapy-ranking.ts b/src/lib/therapy-ranking.ts new file mode 100644 index 0000000000..16a3eac315 --- /dev/null +++ b/src/lib/therapy-ranking.ts @@ -0,0 +1,103 @@ +export type TherapyRankable = { + name: string; + aliases: string[]; + tags: string[]; + category?: string | null; + modality?: string | null; + bestUsedFor?: string | null; + targetSymptoms?: string | null; + clinicalSummary?: string | null; + indications?: string | null; +}; + +export type TherapyRankingProfile = "catalogue" | "universal"; + +const lowercase = (value: string | null | undefined) => (value ?? "").toLowerCase(); +const normalize = (value: string | null | undefined) => + lowercase(value) + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); + +/** + * The single Therapy ranking owner. Profiles preserve the two established + * product contracts while they are evaluated for a future quality-tuning pass: + * the full catalogue scorer and the universal-search projection scorer. + */ +export function scoreTherapyCandidate(record: TherapyRankable, query: string, profile: TherapyRankingProfile): number { + if (profile === "catalogue") { + const q = query.trim().toLowerCase(); + if (!q) return 1; + const name = lowercase(record.name); + const tags = record.tags.map(lowercase); + let score = 0; + if (name === q) score += 100; + if (name.startsWith(q)) score += 40; + if (name.includes(q)) score += 20; + if (record.aliases.some((alias) => lowercase(alias).includes(q))) score += 18; + if (tags.some((tag) => tag.includes(q))) score += 14; + if (lowercase(record.category).includes(q)) score += 8; + if (lowercase(record.bestUsedFor).includes(q)) score += 6; + if (lowercase(record.targetSymptoms).includes(q)) score += 5; + if (lowercase(record.clinicalSummary).includes(q)) score += 3; + if (lowercase(record.indications).includes(q)) score += 3; + return score; + } + + const q = normalize(query); + if (!q) return 1; + const tokens = q.split(" ").filter(Boolean); + const name = normalize(record.name); + const aliases = record.aliases.map(normalize); + const tags = normalize(record.tags.join(" ")); + const bestUsedFor = normalize(record.bestUsedFor); + const targetSymptoms = normalize(record.targetSymptoms); + const haystack = normalize( + [ + record.name, + record.category, + record.modality, + record.bestUsedFor, + record.targetSymptoms, + record.clinicalSummary, + record.indications, + record.tags.join(" "), + record.aliases.join(" "), + ] + .filter(Boolean) + .join(" "), + ); + + let score = 0; + if (name === q) score += 100; + else if (name.startsWith(q)) score += 55; + else if (name.includes(q)) score += 30; + if (aliases.some((alias) => alias === q)) score += 60; + else if (aliases.some((alias) => alias.includes(q))) score += 22; + if (tags.includes(q)) score += 18; + if (bestUsedFor.includes(q)) score += 10; + if (targetSymptoms.includes(q)) score += 8; + for (const token of tokens) { + if (name.includes(token)) score += 12; + if (aliases.some((alias) => alias.includes(token))) score += 8; + if (tags.includes(token)) score += 6; + if (haystack.includes(token)) score += 3; + } + return score; +} + +export function rankTherapyCandidates( + records: readonly T[], + query: string, + profile: TherapyRankingProfile, +): Array<{ record: T; score: number }> { + const emptyQuery = !query.trim(); + return records + .map((record, index) => ({ + record, + score: + emptyQuery && profile === "universal" ? records.length - index : scoreTherapyCandidate(record, query, profile), + })) + .filter((match) => match.score > 0) + .sort((left, right) => right.score - left.score || left.record.name.localeCompare(right.record.name)); +} diff --git a/src/lib/therapy-source-governance.ts b/src/lib/therapy-source-governance.ts new file mode 100644 index 0000000000..49876c00e0 --- /dev/null +++ b/src/lib/therapy-source-governance.ts @@ -0,0 +1,31 @@ +import type { ClinicalSourceMetadata } from "@/lib/types"; + +type TherapySourceInput = { + title: string | null; + sourceType: string | null; + reference: string | null; +}; + +/** Adapt imported Therapy provenance without inventing currency, quality or approval. */ +export function therapySourceMetadata(source: TherapySourceInput, recordReviewStatus: string): ClinicalSourceMetadata { + return { + source_kind: "document", + registry_record_kind: null, + registry_record_subkind: source.sourceType, + registry_record_id: null, + registry_record_slug: null, + source_title: source.title ?? source.reference ?? source.sourceType, + publisher: source.sourceType, + publisher_code: null, + jurisdiction: null, + version: null, + publication_date: null, + review_date: null, + uploaded_at: null, + indexed_at: null, + uploaded_by: null, + document_status: "unknown", + clinical_validation_status: recordReviewStatus === "reviewed" ? "locally_reviewed" : "unverified", + extraction_quality: "unknown", + }; +} diff --git a/supabase/migrations/20260814150000_add_therapy_favourites.sql b/supabase/migrations/20260814150000_add_therapy_favourites.sql new file mode 100644 index 0000000000..8fc0a4abd5 --- /dev/null +++ b/supabase/migrations/20260814150000_add_therapy_favourites.sql @@ -0,0 +1,6 @@ +alter table public.user_favourites + drop constraint if exists user_favourites_content_type_check; + +alter table public.user_favourites + add constraint user_favourites_content_type_check + check (content_type in ('service', 'form', 'differential', 'therapy')); diff --git a/supabase/schema.sql b/supabase/schema.sql index 0b6a2844f4..656c2f98da 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -8887,7 +8887,7 @@ create table if not exists public.user_favourites ( created_at timestamptz not null default now(), primary key (user_id, content_type, content_key), constraint user_favourites_content_type_check - check (content_type in ('service', 'form', 'differential')), + check (content_type in ('service', 'form', 'differential', 'therapy')), constraint user_favourites_content_key_check check (content_key = btrim(content_key) and char_length(content_key) between 1 and 180) ); diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts index e5aff4e24f..69fcf2d1ae 100644 --- a/tests/app-modes.test.ts +++ b/tests/app-modes.test.ts @@ -175,6 +175,14 @@ describe("app mode search contract", () => { ); }); + it("routes Therapy searches as a first-class local catalogue", () => { + const config = appModeSearchConfig("therapy-compass"); + expect(isSearchableAppMode("therapy-compass")).toBe(true); + expect(config.kind).toBe("therapies"); + expect(config.resultKind).toBe("therapies"); + expect(appModeHomeHref("therapy-compass", { query: "CBT", run: true })).toBe("/therapy-compass/search?q=CBT&run=1"); + }); + it("keeps source-library shortcut searches in their active mode family", () => { expect(appModeCanUseSourceLibraryShortcut("answer")).toBe(false); expect(appModeCanUseSourceLibraryShortcut("tools")).toBe(false); @@ -389,8 +397,7 @@ describe("app mode search contract", () => { differentials: "/differentials?q=clozapine&run=1", specifiers: "/specifiers?q=clozapine&run=1", formulation: "/formulation?q=clozapine&run=1", - // Therapy resolves to its home, which server-redirects to /therapy-compass/search. - "therapy-compass": "/therapy-compass?q=clozapine&run=1", + "therapy-compass": "/therapy-compass/search?q=clozapine&run=1", // Tools has no search route by design: it filters its launcher in place. tools: "/tools?q=clozapine&run=1", }); diff --git a/tests/design-token-contract.test.ts b/tests/design-token-contract.test.ts index 1f6e710868..635db13c4a 100644 --- a/tests/design-token-contract.test.ts +++ b/tests/design-token-contract.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; @@ -173,7 +173,8 @@ describe("elevation ladder", () => { it("keeps the retired --shadow-tight token deleted across the tracked tree", () => { const tracked = execFileSync("git", ["ls-files", "src"], { encoding: "utf8" }) .split("\n") - .filter((file) => /\.(tsx?|css)$/.test(file)); + .filter((file) => /\.(tsx?|css)$/.test(file)) + .filter((file) => existsSync(new URL(`../${file}`, import.meta.url))); const survivors = tracked.flatMap((file) => { const source = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); @@ -381,7 +382,8 @@ describe("type scale floor", () => { // arrival. Mockups are NOT exempt here — a dead class breaks them too. const tracked = execFileSync("git", ["ls-files", "src"], { encoding: "utf8" }) .split("\n") - .filter((file) => /\.(tsx?|css)$/.test(file)); + .filter((file) => /\.(tsx?|css)$/.test(file)) + .filter((file) => existsSync(new URL(`../${file}`, import.meta.url))); const orphans = tracked.flatMap((file) => { const source = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); @@ -420,6 +422,7 @@ describe("leading vocabulary", () => { const production = execFileSync("git", ["ls-files", "src"], { encoding: "utf8" }) .split("\n") .filter((file) => /\.(tsx?|css)$/.test(file)) + .filter((file) => existsSync(new URL(`../${file}`, import.meta.url))) .filter((file) => !/mockup/i.test(file)); const offenders = production.flatMap((file) => { diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index 3ce7fc070a..07b46d2206 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 343a3feb5b..f08d14bd32 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 @@ -381,10 +382,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 84b1d739b0..c636184579 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 0af4942f1a..ee8ad9ec40 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 e9171e0c27..63314e6cb8 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 597617e00a..613cf70f4c 100644 --- a/tests/mode-secondary-navigation.test.ts +++ b/tests/mode-secondary-navigation.test.ts @@ -24,7 +24,7 @@ const expectedLabels: Record = { formulation: ["Find", "Build", "Compare", "Map"], prescribing: [], tools: [], - "therapy-compass": ["Search", "Recommend", "Compare", "Pathways", "Brief Intervention", "Patient Sheets"], + "therapy-compass": ["Search", "Recommend", "Compare", "Pathways"], factsheets: ["Topics", "Search"], }; @@ -224,6 +224,17 @@ describe("mode secondary navigation registry", () => { currentSearchParams: new URLSearchParams("q=sertraline&category=Medicines&run=1"), }), ).toBe("/factsheets"); + + expect( + modeSecondaryNavigationHref({ + modeId: "therapy-compass", + itemId: "compare", + href: "/therapy-compass/compare", + currentSearchParams: new URLSearchParams( + "q=trauma&run=1&ids=cbt%2Cact&topic=Anxiety&density=dense&prompt=patient+name", + ), + }), + ).toBe("/therapy-compass/compare?q=trauma&run=1&ids=cbt%2Cact&topic=Anxiety&density=dense"); }); it("adopts only modes with two or more routed destinations (explicit list, not silent derivation)", () => { @@ -238,6 +249,7 @@ describe("mode secondary navigation registry", () => { "factsheets", "formulation", "specifiers", + "therapy-compass", ]); for (const modeId of MODE_NAV_ADOPTED_MODES) { @@ -248,7 +260,6 @@ describe("mode secondary navigation registry", () => { } for (const modeId of appModeIds) { - if (modeId === "therapy-compass") continue; // owns ModeNav via useTherapyNavItems if (routedModeSecondaryNavigationCount(modeId) < 2) { expect(MODE_NAV_ADOPTED_MODES).not.toContain(modeId); } @@ -269,6 +280,11 @@ describe("mode secondary navigation registry", () => { expect(activeModeSecondaryNavigationId("factsheets", "/factsheets/sertraline")).toBeNull(); expect(activeModeSecondaryNavigationId("factsheets", "/factsheets")).toBe("topics"); expect(activeModeSecondaryNavigationId("factsheets", "/factsheets/search")).toBe("search"); + expect(activeModeSecondaryNavigationId("therapy-compass", "/therapy-compass/search")).toBe("search"); + expect(activeModeSecondaryNavigationId("therapy-compass", "/therapy-compass/recommend")).toBe("recommend"); + expect(activeModeSecondaryNavigationId("therapy-compass", "/therapy-compass/compare")).toBe("compare"); + expect(activeModeSecondaryNavigationId("therapy-compass", "/therapy-compass/pathways")).toBe("pathways"); + expect(activeModeSecondaryNavigationId("therapy-compass", "/therapy-compass/cbt")).toBeNull(); // The `registry[modeId][0]?.id` fallback is gone. A mode with no branch and // no entries has no current destination, rather than silently lighting its diff --git a/tests/page-secondary-navigation.dom.test.tsx b/tests/page-secondary-navigation.dom.test.tsx index dae913965b..3431cc78df 100644 --- a/tests/page-secondary-navigation.dom.test.tsx +++ b/tests/page-secondary-navigation.dom.test.tsx @@ -155,7 +155,7 @@ describe("PageSecondaryNavigation", () => { expect(screen.queryByTestId("secondary-navigation")).toBeNull(); }); - it("leaves locally controlled information and Therapy workflow navigation to their page owners", async () => { + it("leaves information navigation local and renders Therapy workflow navigation from the shared registry", async () => { const { rerender } = render( , ); @@ -168,7 +168,11 @@ describe("PageSecondaryNavigation", () => { hasSubmittedSearch={false} />, ); - expect(screen.queryByTestId("secondary-navigation")).toBeNull(); + expect(screen.getByTestId("mode-nav")).toBeVisible(); + expect(screen.getByRole("link", { name: "Search" })).toHaveAttribute("href", "/therapy-compass/search"); + expect(screen.getByRole("link", { name: "Recommend" })).toHaveAttribute("href", "/therapy-compass/recommend"); + expect(screen.getByRole("link", { name: "Compare" })).toHaveAttribute("href", "/therapy-compass/compare"); + expect(screen.getByRole("link", { name: "Pathways" })).toHaveAttribute("href", "/therapy-compass/pathways"); rerender( `s, so Brief Intervention and - * Patient Sheets need a real URL before any catalogue has loaded — on the - * server pass, on the mode home (where `TcProvider` deliberately fetches - * nothing), and through every render while the fetch is in flight. An href of - * `/therapy-compass/undefined/brief` would prefetch a 404 and break - * middle-click; that is what the unconditional generated-asset fallback exists - * to prevent, and why it is not gated on the home route the way the old - * imperative handler's was. - */ - -const nav = vi.hoisted(() => ({ pathname: "/therapy-compass", search: "" })); - -vi.mock("next/navigation", () => ({ - usePathname: () => nav.pathname, - useSearchParams: () => new URLSearchParams(nav.search), - useRouter: () => ({ push: () => {}, replace: () => {}, prefetch: () => {} }), -})); - -vi.mock("@/components/therapy-compass/data/use-therapy-data", () => ({ - useTherapyData: () => ({ data: null, loading: true, error: null, retry: () => {} }), -})); - -function Probe() { - const b = useTcBindings(); - return ( - - ); -} - -describe("Therapy Compass artifact hrefs with no catalogue", () => { - for (const pathname of ["/therapy-compass", "/therapy-compass/search", "/therapy-compass/pathways"]) { - it(`resolves to the generated default slug on ${pathname}`, () => { - nav.pathname = pathname; - const { getByTestId } = render( - - - , - ); - - const brief = getByTestId("brief-href").getAttribute("href"); - const sheet = getByTestId("sheet-href").getAttribute("href"); - - expect(brief).toBe(`/therapy-compass/${THERAPY_CATALOGUE_SUMMARY.defaultBriefSlug}/brief`); - expect(sheet).toBe(`/therapy-compass/${THERAPY_CATALOGUE_SUMMARY.defaultSheetSlug}/sheet`); - for (const href of [brief, sheet]) { - expect(href).not.toContain("undefined"); - expect(href).not.toContain("//"); - } - }); - } -}); diff --git a/tests/therapy-compass-artifact-navigation.dom.test.tsx b/tests/therapy-compass-artifact-navigation.dom.test.tsx index ea9ec4a1c4..8ffabcb205 100644 --- a/tests/therapy-compass-artifact-navigation.dom.test.tsx +++ b/tests/therapy-compass-artifact-navigation.dom.test.tsx @@ -76,24 +76,10 @@ function Probe() { + {notice ? ( +

    + {notice} +

    + ) : null}

    {t.name}

    diff --git a/src/components/therapy-compass/therapy-card.tsx b/src/components/therapy-compass/therapy-card.tsx index 80c46a2260..791be27493 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 { useAccountData } from "@/components/account-data-provider"; import { useTcBindings } from "./bindings"; import { cardPreviewText, prioritiseTherapyTags, summarise } from "./data/select"; import type { Therapy } from "./data/types"; @@ -18,12 +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 accountData = useAccountData(); - const saved = accountData.isSaved("therapy", therapy.slug); + const { notice, saved, toggleFavourite } = useTherapyFavourite(therapy.slug); const inCompare = b.isInCompare(therapy.slug); const subtitle = cardPreviewText(therapy.clinicalSummary, { exclude: therapy.name }) || @@ -57,7 +56,7 @@ export function ResultCard({ therapy }: { therapy: Therapy }) { type="button" className={`${iconControl} absolute top-3 right-3 z-[10] sm:top-3.5 sm:right-4`} aria-pressed={saved} - onClick={() => void accountData.setFavourite("therapy", therapy.slug, !saved)} + onClick={() => void toggleFavourite()} title={saved ? "Remove from favourites" : "Save therapy to favourites"} aria-label={saved ? `Remove ${therapy.name} from favourites` : `Save ${therapy.name} to favourites`} > @@ -87,6 +86,15 @@ export function ResultCard({ therapy }: { therapy: Therapy }) { + {notice ? ( +

    + {notice} +

    + ) : null}