diff --git a/docs/site-map.md b/docs/site-map.md index 5a2a5649e..d6d22b812 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -30,7 +30,14 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/specifiers/builder` - Structured diagnostic wording builder. Source: `src/app/specifiers/builder/page.tsx`. - `/specifiers/compare` - Side-by-side psychiatric specifier comparison. Source: `src/app/specifiers/compare/page.tsx`. - `/specifiers/map` - Psychiatric specifier family map. Source: `src/app/specifiers/map/page.tsx`. -- `/therapy-compass` - Route discovered from app directory Source: `src/app/therapy-compass/page.tsx`. +- `/therapy-compass` - Therapy Compass home (source-grounded therapy decision support). Source: `src/app/therapy-compass/page.tsx`. +- `/therapy-compass/[slug]/brief` - Therapy brief-intervention view. Source: `src/app/therapy-compass/[slug]/brief/page.tsx`. +- `/therapy-compass/[slug]/sheet` - Therapy patient-sheet builder. Source: `src/app/therapy-compass/[slug]/sheet/page.tsx`. +- `/therapy-compass/compare` - Side-by-side therapy comparison. Source: `src/app/therapy-compass/compare/page.tsx`. +- `/therapy-compass/pathways` - Problem-based clinical therapy pathways. Source: `src/app/therapy-compass/pathways/page.tsx`. +- `/therapy-compass/recommend` - Recommend a therapy from a clinical question and constraints. Source: `src/app/therapy-compass/recommend/page.tsx`. +- `/therapy-compass/review` - Therapy records awaiting qualified-clinician source review. Source: `src/app/therapy-compass/review/page.tsx`. +- `/therapy-compass/search` - Therapy library search surface. Source: `src/app/therapy-compass/search/page.tsx`. - `/tools` - Route discovered from app directory Source: `src/app/tools/page.tsx`. ## Mode/query routes @@ -750,6 +757,221 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `splitting` - `worry` +### Therapy slugs + +- `/therapy-compass/[slug]` - Dynamic route family. +- `acceptance-and-commitment-therapy-act` +- `adherence-focused-brief-work-adherence-therapy` +- `adolescent-cbt` +- `adolescent-focused-psychotherapy-for-anorexia-nervosa` +- `advocacy-informed-therapeutic-work` +- `app-supported-therapy` +- `applied-relaxation-relaxation-based-therapy` +- `arts-therapies-for-psychosis` +- `behaviour-therapy` +- `behavioural-activation` +- `behavioural-activation-ba` +- `behavioural-couples-therapy-bct` +- `behavioural-couples-therapy-for-substance-use-disorders-bct-sud` +- `behavioural-parent-training` +- `brief-low-intensity-cbt` +- `brief-supportive-psychotherapy` +- `bright-light-therapy` +- `carer-focused-education-and-support-carer-psychoeducation-in-psychosis` +- `carer-groups` +- `carer-interventions` +- `carer-supported-meal-based-interventions` +- `cbt-informed-psychological-intervention-for-self-harm` +- `child-cbt` +- `circadian-rhythm-based-interventions` +- `client-directed-feedback-informed-brief-work` +- `cognitive-analytic-therapy-cat` +- `cognitive-behavioural-therapy-cbt` +- `cognitive-behavioural-therapy-for-bipolar-depression-bipolar-relapse-prevention` +- `cognitive-behavioural-therapy-for-insomnia` +- `cognitive-behavioural-therapy-for-insomnia-cbt-i` +- `cognitive-behavioural-therapy-for-psychosis-cbtp` +- `cognitive-processing-therapy-cpt` +- `cognitive-remediation-therapy-crt` +- `cognitive-stimulation-therapy` +- `cognitive-therapy` +- `cognitive-therapy-for-ptsd-ct-ptsd` +- `community-based-psychosocial-intervention` +- `community-reinforcement-and-family-training-craft` +- `community-reinforcement-approach` +- `compassion-focused-therapy` +- `constructivist-therapies` +- `contingency-management-cm` +- `coping-skills-interventions` +- `couples-therapy` +- `craving-management-interventions` +- `crisis-intervention-crisis-oriented-brief-therapy` +- `cue-exposure-therapy-cet-for-substance-use-disorders` +- `dbt-informed-adjunctive-emotion-regulation-work-for-eating-disorders` +- `dbt-skills-groups` +- `developmental-social-skills-interventions` +- `developmentally-adapted-dbt` +- `developmentally-adapted-erp` +- `dialectical-behaviour-therapy-dbt` +- `dignity-therapy` +- `dyadic-therapy` +- `dynamic-interpersonal-therapy` +- `eating-disorder-focused-cognitive-behavioural-therapy-cbt-ed-cbt-e` +- `eating-disorder-focused-focal-psychodynamic-therapy-fpt-for-adult-anorexia-nervosa` +- `ect` +- `emdr` +- `emotion-focused-therapy` +- `empowerment-focused-practice` +- `existential-psychotherapy` +- `experiential-therapies` +- `exposure-and-response-prevention-erp` +- `exposure-and-response-prevention-for-tics-erp-for-tics` +- `exposure-based-cbt-exposure-therapy` +- `exposure-therapy` +- `eye-movement-desensitisation-and-reprocessing-emdr` +- `family-based-treatment-for-adolescent-anorexia-nervosa-ft-an` +- `family-based-treatment-for-bulimia-nervosa-ft-bn` +- `family-focused-psychoeducation-for-bipolar-disorder` +- `family-focused-therapy-for-bipolar-disorder-fft-bd` +- `family-intervention-for-psychosis` +- `family-problem-solving-interventions` +- `family-psychoeducation-for-psychosis` +- `family-therapy` +- `functional-family-therapy-fft` +- `gestalt-therapy` +- `good-psychiatric-management` +- `graded-exposure` +- `group-exposure-programmes` +- `group-psychoeducation` +- `group-psychoeducation-for-bipolar-disorder` +- `group-relapse-prevention-programmes` +- `group-social-skills-training` +- `guided-self-help` +- `guided-self-help-for-binge-eating-disorder` +- `guided-self-help-for-bulimia-nervosa` +- `habit-reversal-training-cbit` +- `habit-reversal-training-for-trichotillomania-and-excoriation-disorder` +- `harm-reduction-counselling` +- `health-anxiety-focused-cbt` +- `humanistic-psychotherapy` +- `illness-management-and-recovery-style-interventions-imr-style-interventions` +- `imagery-rehearsal-therapy-irt-for-nightmare-disorder` +- `imagery-rescripting` +- `inference-based-cbt-for-ocd` +- `integrated-cbt-for-ptsd-and-substance-use-disorders` +- `integrated-dual-diagnosis-psychotherapy` +- `intensive-psychodynamic-psychotherapy` +- `internet-delivered-cbt` +- `interoceptive-exposure` +- `interpersonal-and-social-rhythm-therapy-ipsrt` +- `interpersonal-psychotherapy-ipt` +- `interpersonal-therapy` +- `life-review-therapy-reminiscence-therapy` +- `low-intensity-relapse-prevention-programmes` +- `matrix-model` +- `maudsley-anorexia-nervosa-treatment-for-adults-mantra` +- `meaning-centred-psychotherapy` +- `mentalisation-based-therapy-mbt` +- `metacognitive-therapy-mct` +- `mindfulness-based-cognitive-therapy` +- `mindfulness-based-cognitive-therapy-mbct` +- `mindfulness-based-relapse-prevention-mbrp` +- `mindfulness-based-stress-reduction` +- `mindfulness-based-therapies-not-primarily-cbt-anchored` +- `mindfulness-based-therapy-for-insomnia-mbti` +- `mother-infant-therapy-mother-infant-psychotherapy` +- `motivational-enhancement-therapy-met` +- `motivational-interviewing` +- `motivational-interviewing-mi-for-substance-use-disorders` +- `multi-family-interventions` +- `multidimensional-family-therapy` +- `multisystemic-therapy-mst` +- `narrative-exposure-therapy-net` +- `narrative-informed-community-work` +- `narrative-therapy` +- `neurodevelopmentally-adapted-psychosocial-interventions` +- `panic-focused-cbt` +- `parent-and-child-training` +- `parent-child-interaction-therapy-pcit` +- `parent-child-relational-therapy` +- `parent-management-training-pmt` +- `parent-training` +- `peer-led-groups` +- `peer-recovery-interventions` +- `peer-support` +- `person-centred-rogerian-therapy` +- `phase-oriented-trauma-therapy` +- `play-therapy-play-based-therapies` +- `problem-management-plus-pm` +- `problem-solving-therapy` +- `problem-solving-therapy-pst` +- `process-groups` +- `prolonged-exposure-pe` +- `prolonged-exposure-therapy` +- `psychoanalysis` +- `psychodynamic-psychotherapy` +- `psychoeducation` +- `psychoeducation-for-psychosis` +- `psychoeducational-recovery-groups` +- `psychosis-specific-self-management-programmes` +- `psychosocial-case-planning` +- `recovery-groups` +- `recovery-oriented-case-management` +- `recovery-oriented-psychosocial-interventions` +- `recovery-planning-for-addiction` +- `relapse-prevention-psychotherapy` +- `relapse-prevention-therapy-for-substance-use-disorders` +- `relational-cultural-therapy` +- `relational-psychodynamic-therapies` +- `relationship-focused-therapy` +- `resource-linkage-coordination-as-therapeutic-intervention` +- `rtms` +- `schema-therapy` +- `school-refusal-cbt` +- `seeking-safety` +- `self-management-interventions` +- `short-term-psychodynamic-psychotherapy-for-depression-stpp` +- `short-term-psychodynamic-psychotherapy-stpp` +- `skills-training-groups` +- `sleep-compression-therapy-for-insomnia` +- `social-anxiety-focused-cbt` +- `social-cognition-training` +- `social-communication-parent-mediated-autism-interventions` +- `social-role-restoration-work` +- `social-skills-training-sst` +- `solution-focused-brief-therapy` +- `specialist-supportive-clinical-management-sscm` +- `stabilisation-focused-trauma-preparation` +- `stair-skills-training-in-affective-and-interpersonal-regulation` +- `strengths-based-case-management` +- `strengths-based-practice` +- `structured-clinical-management` +- `support-groups` +- `supported-digital-cbt` +- `supported-digital-trauma-focused-cbt` +- `supported-digital-trauma-focused-cbt-self-help-and-digital-therapies` +- `supported-education` +- `supported-employment-individual-placement-and-support-ips` +- `supportive-expressive-psychodynamic-counselling-approaches-for-depression` +- `supportive-psychotherapy` +- `systemic-family-therapy` +- `task-centred-practice` +- `telehealth-delivered-psychotherapy` +- `telephone-delivered-cbt` +- `transference-focused-psychotherapy-tfp` +- `trauma-focused-cognitive-behavioural-therapy-tf-cbt` +- `trauma-informed-skills-work` +- `twelve-step-facilitation-tsf` +- `values-based-therapies` +- `vocational-rehabilitation` +- `waitlist-bridging-brief-interventions` +- `wake-therapy-sleep-deprivation-chronotherapy` +- `workbook-based-therapy` +- `worry-focused-cbt` +- `written-exposure-therapy` +- `youth-ipt-ipt-a` +- `youth-trauma-focused-cbt` + ### Medication slugs - `/medications/[slug]` - Dynamic route family. @@ -789,7 +1011,6 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/settings-search-clinical` - Route discovered from app directory Source: `src/app/mockups/settings-search-clinical/page.tsx`. - `/mockups/settings-search-general` - Route discovered from app directory Source: `src/app/mockups/settings-search-general/page.tsx`. - `/mockups/settings-search-privacy` - Route discovered from app directory Source: `src/app/mockups/settings-search-privacy/page.tsx`. -- `/mockups/therapy-compass` - Route discovered from app directory Source: `src/app/mockups/therapy-compass/page.tsx`. - `/mockups/tools-action-workbench` - Route discovered from app directory Source: `src/app/mockups/tools-action-workbench/page.tsx`. - `/mockups/tools-clinical-lanes` - Route discovered from app directory Source: `src/app/mockups/tools-clinical-lanes/page.tsx`. - `/mockups/tools-command-center` - Route discovered from app directory Source: `src/app/mockups/tools-command-center/page.tsx`. diff --git a/scripts/build-therapies-index.mjs b/scripts/build-therapies-index.mjs new file mode 100644 index 000000000..acfd9913e --- /dev/null +++ b/scripts/build-therapies-index.mjs @@ -0,0 +1,39 @@ +// Generates src/data/therapies-index.json — a trimmed, server-importable projection +// of public/therapy-compass-data/therapies.json (~2.5 MB). The full dataset is fetched +// client-side by the interactive Therapy Compass screens; the server only needs a small +// rankable/metadata projection for routing (generateStaticParams / notFound / metadata) +// and the universal-search "therapies" domain. Re-run after editing the source dataset, +// then run Prettier (which owns the committed file's exact formatting): +// +// node scripts/build-therapies-index.mjs && npm run format +// +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const source = join(root, "public", "therapy-compass-data", "therapies.json"); +const target = join(root, "src", "data", "therapies-index.json"); + +const therapies = JSON.parse(readFileSync(source, "utf8")); + +const projected = therapies + .map((t) => ({ + slug: t.slug, + name: t.name, + category: t.category ?? null, + modality: t.modality ?? null, + clinicalSummary: t.clinicalSummary ?? null, + bestUsedFor: t.bestUsedFor ?? null, + targetSymptoms: t.targetSymptoms ?? null, + indications: t.indications ?? null, + reviewStatus: t.reviewStatus ?? "needs_review", + patientSheetAvailable: Boolean(t.patientSheetAvailable), + briefInterventionAvailable: Boolean(t.briefInterventionAvailable), + tags: Array.isArray(t.tags) ? t.tags : [], + aliases: Array.isArray(t.aliases) ? t.aliases : [], + })) + .sort((a, b) => a.name.localeCompare(b.name)); + +writeFileSync(target, `${JSON.stringify(projected, null, 2)}\n`); +console.log(`Wrote ${projected.length} therapy records to ${target}`); diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 87ddeb945..0ee3e07ac 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -11,6 +11,7 @@ import { formulationMechanisms } from "@/lib/formulation"; import { formRecords } from "@/lib/forms"; import { serviceRecords } from "@/lib/services"; import { specifierRecords } from "@/lib/specifiers"; +import { therapySlugs } from "@/lib/therapies"; const appDir = path.join(process.cwd(), "src", "app"); const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); @@ -79,6 +80,15 @@ const routeDescriptions: Record = { "/specifiers/builder": "Structured diagnostic wording builder.", "/specifiers/compare": "Side-by-side psychiatric specifier comparison.", "/specifiers/map": "Psychiatric specifier family map.", + "/therapy-compass": "Therapy Compass home (source-grounded therapy decision support).", + "/therapy-compass/search": "Therapy library search surface.", + "/therapy-compass/recommend": "Recommend a therapy from a clinical question and constraints.", + "/therapy-compass/compare": "Side-by-side therapy comparison.", + "/therapy-compass/pathways": "Problem-based clinical therapy pathways.", + "/therapy-compass/review": "Therapy records awaiting qualified-clinician source review.", + "/therapy-compass/[slug]": "Therapy record detail.", + "/therapy-compass/[slug]/brief": "Therapy brief-intervention view.", + "/therapy-compass/[slug]/sheet": "Therapy patient-sheet builder.", }; const publicRouteHandlerDescriptions: Record = { @@ -387,6 +397,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { "/dsm/diagnoses/[slug]", "/dsm/diagnoses/[slug]/differentials", "/formulation/[slug]", + "/therapy-compass/[slug]", "/medications/[slug]", ].includes(route.route), ); @@ -457,6 +468,8 @@ function renderSiteMapRaw(data = collectSiteMapData()) { formulationMechanisms.map((mechanism) => mechanism.id), ), "", + ...renderSlugInventory("Therapy slugs", "/therapy-compass/[slug]", therapySlugs()), + "", ...renderSlugInventory("Medication slugs", "/medications/[slug]", medicationSlugs), ]), ...section("Document viewer route", [ diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 583d30e68..ed508daad 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -13,10 +13,6 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const isSourceOverlayRedesignMockup = pathname === "/mockups/document-search/source-overlays"; const isStandaloneDocumentFlow = pathname === "/mockups/document-search"; const isUniversalSearchRedesignMockup = pathname === "/mockups/universal-search-redesign"; - // Therapy Compass keeps the universal header + rail but provides its own - // primary search surface, so the shared bottom composer is hidden (as with - // the tools/favourites mockups). - const isTherapyCompassMockup = pathname.startsWith("/mockups/therapy-compass"); // The calculators search page owns its own search input (top on desktop, docked // at the bottom on phones), so the shared universal composer is suppressed here // to avoid a second, floating search bar. @@ -38,7 +34,6 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isFavouritesPageMockup && !isStandaloneDocumentFlow && !isUniversalSearchRedesignMockup && - !isTherapyCompassMockup && !isCalculatorsSearchPageMockup } chromeVisible={!isSourceOverlayRedesignMockup} diff --git a/src/app/mockups/therapy-compass/page.tsx b/src/app/mockups/therapy-compass/page.tsx deleted file mode 100644 index 95bf7d331..000000000 --- a/src/app/mockups/therapy-compass/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { Metadata } from "next"; - -import { TherapyCompassPage } from "@/components/therapy-compass"; - -export const metadata: Metadata = { - title: "Therapy Compass Mockup - Clinical KB", - description: - "Source-grounded therapy decision-support mockup: search, compare, recommend, pathways, brief interventions and patient sheets.", -}; - -export default function TherapyCompassMockupRoute() { - return ; -} diff --git a/src/app/therapy-compass/[slug]/brief/page.tsx b/src/app/therapy-compass/[slug]/brief/page.tsx new file mode 100644 index 000000000..94e3ec6aa --- /dev/null +++ b/src/app/therapy-compass/[slug]/brief/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { BriefScreen } from "@/components/therapy-compass/screens/brief-screen"; +import { findTherapyRecord, therapyBriefSlugs } from "@/lib/therapies"; + +export function generateStaticParams() { + // Only records that actually ship a brief-intervention version get a route. + return therapyBriefSlugs().map((slug) => ({ slug })); +} + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params; + const record = findTherapyRecord(slug); + return { + title: + record && record.briefInterventionAvailable + ? `${record.name} · Brief intervention - Therapy Compass` + : "Therapy not found - Therapy Compass", + }; +} + +export default async function TherapyCompassBriefRoute({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const record = findTherapyRecord(slug); + // 404 unknown records and records without a brief-intervention version, rather + // than rendering an unsupported workflow. + if (!record || !record.briefInterventionAvailable) notFound(); + return ; +} diff --git a/src/app/therapy-compass/[slug]/page.tsx b/src/app/therapy-compass/[slug]/page.tsx new file mode 100644 index 000000000..65a8e12e6 --- /dev/null +++ b/src/app/therapy-compass/[slug]/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { DetailScreen } from "@/components/therapy-compass/screens/detail-screen"; +import { findTherapyRecord, therapyNeedsReview, therapySlugs } from "@/lib/therapies"; + +export function generateStaticParams() { + return therapySlugs().map((slug) => ({ slug })); +} + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params; + const record = findTherapyRecord(slug); + if (!record) return { title: "Therapy not found - Therapy Compass" }; + const summary = record.clinicalSummary ?? record.bestUsedFor ?? "Source-grounded therapy record."; + return { + title: `${record.name} - Therapy Compass`, + description: therapyNeedsReview(record) ? `${summary} (Awaiting source review.)` : summary, + }; +} + +export default async function TherapyCompassDetailRoute({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + if (!findTherapyRecord(slug)) notFound(); + return ; +} diff --git a/src/app/therapy-compass/[slug]/sheet/page.tsx b/src/app/therapy-compass/[slug]/sheet/page.tsx new file mode 100644 index 000000000..842028372 --- /dev/null +++ b/src/app/therapy-compass/[slug]/sheet/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { SheetsScreen } from "@/components/therapy-compass/screens/sheets-screen"; +import { findTherapyRecord, therapySheetSlugs } from "@/lib/therapies"; + +export function generateStaticParams() { + // Only records that actually ship a patient sheet get a route. + return therapySheetSlugs().map((slug) => ({ slug })); +} + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params; + const record = findTherapyRecord(slug); + return { + title: + record && record.patientSheetAvailable + ? `${record.name} · Patient sheet - Therapy Compass` + : "Therapy not found - Therapy Compass", + }; +} + +export default async function TherapyCompassSheetRoute({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const record = findTherapyRecord(slug); + // 404 unknown records and records without a patient sheet, rather than + // rendering an unsupported workflow. + if (!record || !record.patientSheetAvailable) notFound(); + return ; +} diff --git a/src/app/therapy-compass/compare/page.tsx b/src/app/therapy-compass/compare/page.tsx new file mode 100644 index 000000000..b92c9debf --- /dev/null +++ b/src/app/therapy-compass/compare/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { CompareScreen } from "@/components/therapy-compass/screens/compare-screen"; + +export const metadata: Metadata = { + title: "Compare therapies - Therapy Compass", + description: "Compare therapies side by side on indications, delivery, evidence, and review status.", +}; + +export default function TherapyCompassCompareRoute() { + return ; +} diff --git a/src/app/therapy-compass/layout.tsx b/src/app/therapy-compass/layout.tsx index 8e0c5ca66..b479dacc3 100644 --- a/src/app/therapy-compass/layout.tsx +++ b/src/app/therapy-compass/layout.tsx @@ -1,14 +1,21 @@ -import type { ReactNode } from "react"; +import { Suspense, type ReactNode } from "react"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; +import { TherapyCompassWorkspace } from "@/components/therapy-compass"; // Therapy Compass keeps the universal header + rail but provides its own primary // search surface (the in-tool therapy search), so the shared search composer is -// suppressed here — mirroring how the mockup mounted it. +// suppressed here. The workspace is mounted at the layout level so the therapy +// dataset and interaction state are shared across every /therapy-compass/* route, +// while each route renders its own screen into the workspace's main content. export default function TherapyCompassLayout({ children }: { children: ReactNode }) { return ( - {children} + {/* The workspace provider reads useSearchParams; an explicit boundary lets the + route family prerender on its own, independent of the shell's Suspense. */} + + {children} + ); } diff --git a/src/app/therapy-compass/page.tsx b/src/app/therapy-compass/page.tsx index 360c4e753..7eae10099 100644 --- a/src/app/therapy-compass/page.tsx +++ b/src/app/therapy-compass/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; +import { redirect } from "next/navigation"; -import { TherapyCompassPage } from "@/components/therapy-compass"; +import { HomeScreen } from "@/components/therapy-compass/screens/home-screen"; export const metadata: Metadata = { title: "Therapy - Clinical KB", @@ -8,7 +9,7 @@ export const metadata: Metadata = { "Source-grounded therapy decision support: search, compare, recommend, pathways, brief interventions and patient sheets.", }; -type TherapyCompassRouteProps = { +type TherapyCompassHomeProps = { searchParams?: Promise<{ q?: string | string[]; run?: string | string[] }>; }; @@ -16,9 +17,12 @@ function firstParam(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value; } -export default async function TherapyCompassRoute({ searchParams }: TherapyCompassRouteProps) { +export default async function TherapyCompassHome({ searchParams }: TherapyCompassHomeProps) { const params = searchParams ? await searchParams : {}; const query = firstParam(params.q)?.trim() ?? ""; - const autoRunSearch = firstParam(params.run) === "1" && query.length > 0; - return ; + const autoRun = firstParam(params.run) === "1" && query.length > 0; + // A run-enabled mode search resolves to the home href (appModeHomeHref); send it + // on to the dedicated, deep-linkable search route so the workspace opens on results. + if (autoRun) redirect(`/therapy-compass/search?q=${encodeURIComponent(query)}&run=1`); + return ; } diff --git a/src/app/therapy-compass/pathways/page.tsx b/src/app/therapy-compass/pathways/page.tsx new file mode 100644 index 000000000..099e39409 --- /dev/null +++ b/src/app/therapy-compass/pathways/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { PathwaysScreen } from "@/components/therapy-compass/screens/pathways-screen"; + +export const metadata: Metadata = { + title: "Clinical pathways - Therapy Compass", + description: "Problem-based, step-by-step therapy pathways linking source-grounded therapy records.", +}; + +export default function TherapyCompassPathwaysRoute() { + return ; +} diff --git a/src/app/therapy-compass/recommend/page.tsx b/src/app/therapy-compass/recommend/page.tsx new file mode 100644 index 000000000..3c09b0860 --- /dev/null +++ b/src/app/therapy-compass/recommend/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { RecommendScreen } from "@/components/therapy-compass/screens/recommend-screen"; + +export const metadata: Metadata = { + title: "Recommend a therapy - Therapy Compass", + description: "Match a clinical question and constraints to ranked, source-grounded therapy options.", +}; + +export default function TherapyCompassRecommendRoute() { + return ; +} diff --git a/src/app/therapy-compass/review/page.tsx b/src/app/therapy-compass/review/page.tsx new file mode 100644 index 000000000..ce768b6b4 --- /dev/null +++ b/src/app/therapy-compass/review/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { OtherScreen } from "@/components/therapy-compass/screens/other-screen"; + +export const metadata: Metadata = { + title: "Review queue - Therapy Compass", + description: "Therapy records still awaiting qualified-clinician source review.", +}; + +export default function TherapyCompassReviewRoute() { + return ; +} diff --git a/src/app/therapy-compass/search/page.tsx b/src/app/therapy-compass/search/page.tsx new file mode 100644 index 000000000..b92b6efea --- /dev/null +++ b/src/app/therapy-compass/search/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { SearchScreen } from "@/components/therapy-compass/screens/search-screen"; + +export const metadata: Metadata = { + title: "Search therapies - Therapy Compass", + description: "Search the source-grounded therapy library by problem, symptom, skill, or population.", +}; + +export default function TherapyCompassSearchRoute() { + return ; +} diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 3389983d1..19b48042c 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -49,6 +49,7 @@ const domainsByTargetMode: Partial> = dsm: ["dsm"], specifiers: ["specifiers"], formulation: ["formulation"], + "therapy-compass": ["therapies"], tools: ["tools"], }; @@ -62,6 +63,7 @@ const domainHeadings: Record = { dsm: "DSM-5 Diagnoses", specifiers: "Specifiers", formulation: "Formulation", + therapies: "Therapies", tools: "Tools", }; diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index 3d4e59e65..8b9491e17 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -1,6 +1,7 @@ "use client"; import { createContext, useContext, useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { s } from "./style-utils"; import { useTherapyData } from "./data/use-therapy-data"; @@ -18,6 +19,31 @@ import type { Pathway, ReferenceData, Therapy } from "./data/types"; const KNOWN_SCREENS = ["search", "detail", "compare", "recommend", "pathways", "brief", "home", "sheets"] as const; 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"; export type TcBindings = { @@ -199,35 +225,44 @@ function chipStyle(on: boolean): CSSProperties { ); } -export function TcProvider({ - children, - initialQuery = "", - autoRunSearch = false, -}: { - children: ReactNode; - initialQuery?: string; - autoRunSearch?: boolean; -}) { +export function TcProvider({ children }: { children: ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { data, loading, error, retry } = useTherapyData(); const therapies = useMemo(() => data?.therapies ?? [], [data]); const pathways = useMemo(() => data?.pathways ?? [], [data]); - // Honor a run-enabled deep link (/therapy-compass?q=…&run=1): open on Search - // with the query seeded, so a query submitted from the universal composer or a - // recent-search pick runs in-tool instead of landing on Home. A fresh deep link - // while already mounted re-seeds via the provider key in TherapyCompassPage, - // which remounts this provider when the run-query changes. - const seededQuery = autoRunSearch ? initialQuery.trim() : ""; - const [screen, setScreen] = useState(seededQuery ? "search" : "home"); + // The active screen and therapy are derived from the URL: each workspace route + // renders the matching screen, and this keeps nav highlighting + the selected + // therapy in sync with the address bar (back/forward, deep links, new tabs). + const { screen, slug: routeSlug } = resolveRoute(pathname); + + // 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( - seededQuery ? { ...EMPTY_SEARCH, query: seededQuery } : EMPTY_SEARCH, + const [search, setSearch] = useState(() => + qParam ? { ...EMPTY_SEARCH, query: qParam } : EMPTY_SEARCH, ); const [recQuery, setRecQuery] = useState("What therapy for anxiety in outpatient care?"); const [recConstraints, setRecConstraints] = useState(["outpatient"]); const [selectedPathwaySlug, setSelectedPathwaySlug] = useState(null); + // 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 + // render-phase "adjust state when a value changes" pattern so live typing between + // 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"); @@ -245,7 +280,9 @@ export function TcProvider({ const unreviewedTherapies = useMemo(() => therapies.filter((t) => t.reviewStatus !== "reviewed"), [therapies]); // Default selections once data arrives so detail/brief/sheet/pathways are never empty. - const effectiveSelectedSlug = selectedSlug ?? therapies[0]?.slug ?? null; + // 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 effectiveSelectedSlug = routeSlug ?? selectedSlug ?? therapies[0]?.slug ?? null; 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; @@ -265,9 +302,20 @@ export function TcProvider({ ); const value = useMemo(() => { - const go = (next: string) => setScreen(next); + const go = (next: string) => router.push(screenHref(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}`); + // Some records ship no brief-intervention / patient-sheet version, and those + // subroutes 404. Route to the artifact only when the record actually has it; + // otherwise fall back to the therapy's (always-valid) detail page so an entry + // point never dead-ends on a 404. + const hasBrief = (slug: string | null | undefined) => + !!slug && (bySlug.get(slug)?.briefInterventionAvailable ?? false); + const hasSheet = (slug: string | null | undefined) => !!slug && (bySlug.get(slug)?.patientSheetAvailable ?? false); + const openBriefOr = (slug: string) => openSlug(slug, hasBrief(slug) ? "brief" : undefined); + const openSheetOr = (slug: string) => openSlug(slug, hasSheet(slug) ? "sheet" : undefined); return { loading, @@ -286,9 +334,11 @@ export function TcProvider({ goRecommend: () => go("recommend"), goCompare: () => go("compare"), goPathways: () => go("pathways"), - goBrief: () => go("brief"), - goSheets: () => go("sheets"), - goDetail: () => go("detail"), + // Brief / sheet / detail are therapy sub-routes, so the no-arg nav buttons + // open them for the currently-selected therapy (defaulting to the first). + goBrief: () => (effectiveSelectedSlug ? openBriefOr(effectiveSelectedSlug) : go("home")), + goSheets: () => (effectiveSelectedSlug ? openSheetOr(effectiveSelectedSlug) : go("home")), + goDetail: () => (effectiveSelectedSlug ? openSlug(effectiveSelectedSlug) : go("home")), goReview: () => go("review"), isSearch: screen === "search", isDetail: screen === "detail", @@ -312,26 +362,31 @@ export function TcProvider({ selectedSlug: effectiveSelectedSlug, selectedTherapy, relatedForSelected, - open: (slug) => { - setSelectedSlug(slug); - go("detail"); - }, - openBrief: (slug) => { - setSelectedSlug(slug); - go("brief"); - }, - openSheet: (slug) => { - setSelectedSlug(slug); - go("sheets"); - }, - select: (slug) => setSelectedSlug(slug), + 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 + // (falling back to detail when it lacks that artifact) instead of only setting + // state — otherwise the visible selection would stay pinned to the URL. + select: (slug) => + screen === "brief" + ? openBriefOr(slug) + : screen === "sheets" + ? openSheetOr(slug) + : screen === "detail" + ? openSlug(slug) + : setSelectedSlug(slug), search, searchResults, setQuery: (q) => patchSearch({ query: q }), submitQuery: (q) => { patchSearch({ query: q }); - go("search"); + 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`); }, toggleTag: (tag) => setSearch((prev) => ({ @@ -429,11 +484,13 @@ export function TcProvider({ }, }; }, [ + router, loading, error, retry, data, therapies, + bySlug, unreviewedTherapies, pathways, screen, diff --git a/src/components/therapy-compass/index.ts b/src/components/therapy-compass/index.ts index 8159da27c..cf4099061 100644 --- a/src/components/therapy-compass/index.ts +++ b/src/components/therapy-compass/index.ts @@ -1 +1 @@ -export { TherapyCompassPage } from "./therapy-compass-page"; +export { TherapyCompassWorkspace } from "./workspace"; diff --git a/src/components/therapy-compass/screens/detail-screen.tsx b/src/components/therapy-compass/screens/detail-screen.tsx index c78401c9a..04a479bfa 100644 --- a/src/components/therapy-compass/screens/detail-screen.tsx +++ b/src/components/therapy-compass/screens/detail-screen.tsx @@ -155,17 +155,19 @@ export function DetailScreen() { {/* ACTIONS */}
- + {t.patientSheetAvailable ? ( + + ) : null}