From 888d4a93a9a4533539fd4a2e482c6a018e2f02e1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:11:28 +0800 Subject: [PATCH 1/5] fix: separate specifiers from formulation --- docs/branch-review-ledger.md | 1 + docs/site-map.md | 24 +- playwright.config.ts | 5 +- scripts/generate-site-map.ts | 22 +- src/app/page.tsx | 13 +- src/app/specifiers/[[...path]]/page.tsx | 68 -- src/app/specifiers/[slug]/page.tsx | 16 + src/app/specifiers/builder/page.tsx | 16 + src/app/specifiers/compare/page.tsx | 14 + src/app/specifiers/layout.tsx | 11 + src/app/specifiers/map/page.tsx | 11 + src/app/specifiers/page.tsx | 17 + .../clinical-dashboard/ClinicalSidebar.tsx | 2 + .../global-search-shell.tsx | 6 +- .../master-search-header.tsx | 29 +- .../clinical-dashboard/mode-action-popup.tsx | 28 + .../universal-search-command-surface.tsx | 6 +- .../specifiers/specifier-builder-page.tsx | 264 +++++++ .../specifiers/specifier-compare-page.tsx | 238 ++++++ .../specifiers/specifier-map-page.tsx | 169 +++++ .../specifiers/specifier-record-page.tsx | 251 +++++++ src/components/specifiers/specifier-ui.tsx | 130 ++++ .../specifiers/specifiers-home-page.tsx | 331 ++++++++ src/lib/app-mode-icons.ts | 2 + src/lib/app-modes.ts | 53 +- src/lib/specifiers.ts | 711 ++++++++++++++++++ src/lib/universal-search-domains.ts | 11 +- src/lib/universal-search-mode-context.ts | 6 +- src/lib/universal-search.ts | 23 +- tests/app-modes.test.ts | 27 +- tests/specifiers.test.ts | 91 +++ tests/ui-formulation.spec.ts | 35 +- tests/ui-specifiers.spec.ts | 138 ++++ tests/universal-search.test.ts | 35 +- 34 files changed, 2684 insertions(+), 120 deletions(-) delete mode 100644 src/app/specifiers/[[...path]]/page.tsx create mode 100644 src/app/specifiers/[slug]/page.tsx create mode 100644 src/app/specifiers/builder/page.tsx create mode 100644 src/app/specifiers/compare/page.tsx create mode 100644 src/app/specifiers/layout.tsx create mode 100644 src/app/specifiers/map/page.tsx create mode 100644 src/app/specifiers/page.tsx create mode 100644 src/components/specifiers/specifier-builder-page.tsx create mode 100644 src/components/specifiers/specifier-compare-page.tsx create mode 100644 src/components/specifiers/specifier-map-page.tsx create mode 100644 src/components/specifiers/specifier-record-page.tsx create mode 100644 src/components/specifiers/specifier-ui.tsx create mode 100644 src/components/specifiers/specifiers-home-page.tsx create mode 100644 src/lib/specifiers.ts create mode 100644 tests/specifiers.test.ts create mode 100644 tests/ui-specifiers.spec.ts diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 059c75989..62c96abd5 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -489,3 +489,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-14 | PR #655 / codex/release-blocker-remediation | 3ed3a7a2df37d7d15143ab7606e5748ac7ecca09 + reviewed follow-up diff | offline dose-route latency follow-up | The next isolated timeout was a short IM/PO agitation question receiving the same blanket ten-term AND expansion. Agitation dose/route retrieval now keeps only the dose and route signals present in the question; the exact case retrieves its expected source and five citations in 1.54 seconds without a model. All remaining RAG cases 23–44 passed individually, so no further deadline crash remains. | Focused clinical-search/retrieval Vitest 111/111; scoped ESLint; Prettier; `git diff --check`; live provider-free case 22 passed; live provider-free cases 23–44 passed individually. | | 2026-07-14 | PR #655 / codex/release-blocker-remediation | a3f3a89676015cd5f018c07e8c3ad9483f91cef6 + reviewed follow-up diff | offline adversarial-latency follow-up | The final blocking offline-quality failure was an adversarial secret-exfiltration query that correctly refused but first spent about 25 seconds in lexical retrieval. Adversarial manipulation now short-circuits at the search boundary before provider-client creation, cache access, classification, aliases, or Supabase work, and is never cached. | Focused Vitest 2/2; scoped ESLint; Prettier; full TypeScript; `git diff --check`; live provider-free adversarial case completed in 100 ms with 0 ms RPC time; `eval:quality:release:offline` passed with zero blocking failures and zero model, request-ID, token, cost, or generation-latency evidence. Flaky local browser/composite suites intentionally not repeated; hosted CI remains authoritative. | | 2026-07-14 | PR #666 / codex/release-blocker-remediation | 9b56eebe4b23ab783207445fb827c317c8d59be8 + reviewed follow-up diff | review-followup | One late P2 retrieval-contract gap was confirmed: the optimized agitation query retained IM/PO but could drop other already-supported amount, route, and frequency aliases. Medication evidence intent is now shared with retrieval selection, and focused agitation queries preserve requested numeric units, SC, SL, PRN, and frequency signals without restoring the broad ten-term expansion. | GitHub review-thread inspection; focused clinical-search/retrieval Vitest 112/112; scoped ESLint; Prettier; `git diff --check`. Hosted final-head TypeScript/build/CI and exact-head staging evidence remain required after push. | +| 2026-07-14 | codex/formulation-specifier-separation-current | 25d7f733f + uncommitted fix | Specifiers/Formulation boundary bug hunt and remediation | Confirmed two P1 clinical-navigation/content boundary defects: the completed Formulation change deleted the distinct psychiatric Specifiers workspace and redirected every `/specifiers/*` route into Formulation; a later universal-search contract also labelled Formulation mechanism results as `specifiers`. Restored the independent Specifiers catalogue, detail, builder, compare, and map surfaces; registered both app modes and action sets; kept submitted searches in their owning standalone workspace; and split universal search into real `specifiers` and `formulation` domains with correct records, headings, mode ownership, and destinations. | Offline only: full Vitest 2,404 passed/1 skipped, TypeScript, full ESLint, focused Vitest 56/56, sitemap check, `git diff --check`, production-readiness READY with expected missing-env warnings, identity-checked server at `http://localhost:3384`, and repaired Chromium journeys for Specifiers desktop/mobile, Formulation mobile, and explicit route-family separation all passed. No provider-backed or live API checks run. | diff --git a/docs/site-map.md b/docs/site-map.md index 0254e1a1b..ef4c771ac 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -23,7 +23,10 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/privacy` - Privacy and data-processing governance draft. Source: `src/app/privacy/page.tsx`. - `/reference/colour-coding` - Route discovered from app directory Source: `src/app/reference/colour-coding/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. -- `/specifiers/[[...path]]` - Compatibility redirect to the corrected Formulation workspace. Source: `src/app/specifiers/[[...path]]/page.tsx`. +- `/specifiers` - Psychiatric specifier home and local search surface. Source: `src/app/specifiers/page.tsx`. +- `/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`. - `/tools` - Route discovered from app directory Source: `src/app/tools/page.tsx`. ## Mode/query routes @@ -34,6 +37,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/forms` - Forms mode. Search kind: `forms`. Query example: `/forms?q=transport+forms&focus=1&run=1`. - `/favourites` - Favourites mode. Search kind: `favourites`. Query example: `/favourites?q=clozapine+set&focus=1&run=1`. - `/differentials` - Differentials mode. Search kind: `differentials`. Query example: `/differentials?q=acute+confusion&focus=1&run=1`. +- `/specifiers` - Specifiers mode. Search kind: `specifiers`. Query example: `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1`. - `/formulation` - Formulation mode. Search kind: `formulation`. Query example: `/formulation?q=I+keep+going+over+it&focus=1&run=1`. - `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. @@ -48,6 +52,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | | Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | | Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | +| Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | | Formulation | `/formulation` | `/formulation?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | | Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | | Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`). | @@ -293,6 +298,22 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `youthreach-south` - `youthreach-south-ycatt` +### Psychiatric specifier slugs + +- `/specifiers/[slug]` - Dynamic route family. +- `in-full-remission` +- `in-partial-remission` +- `mild-severity` +- `with-anxious-distress` +- `with-atypical-features` +- `with-catatonia` +- `with-melancholic-features` +- `with-mixed-features` +- `with-peripartum-onset` +- `with-psychotic-features` +- `with-rapid-cycling` +- `with-seasonal-pattern` + ### Seeded form slugs - `/forms/[slug]` - Dynamic route family. @@ -633,6 +654,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Forms | `src/app/forms, src/lib/forms.ts, src/app/api/registry/records` | | Favourites | `src/app/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx` | | Differentials | `src/app/differentials, src/lib/differentials.ts` | +| Specifiers | `src/app/specifiers, src/components/specifiers, src/lib/specifiers.ts` | | Formulation | `src/app/formulation, src/components/formulation, src/lib/formulation.ts` | | Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | | Documents | `src/app/documents, src/lib/document-flow-routes.ts` | diff --git a/playwright.config.ts b/playwright.config.ts index ddcce3876..22feb6ba7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,14 +11,15 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // mockup can never mask a production-journey regression (PT-05). The two // Tag-level filters keep production and prototype journeys disjoint even when // they share a spec file; firefox/webkit retain the legacy full testMatch. -const productionSpecPattern = /.*ui-(smoke|stress|accessibility|tools|overlap|universal-search|formulation)\.spec\.ts/; +const productionSpecPattern = + /.*ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation)\.spec\.ts/; const mockupSpecPattern = /.*ui-(tools|tools-collapse|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|formulation)\.spec\.ts/, + /.*ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation)\.spec\.ts/, timeout: 60_000, retries: process.env.CI ? 1 : 0, expect: { diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index cacbd8b2b..1c5b5aaf9 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -9,6 +9,7 @@ import { differentialRecords } from "@/lib/differentials"; import { formulationMechanisms } from "@/lib/formulation"; import { formRecords } from "@/lib/forms"; import { serviceRecords } from "@/lib/services"; +import { specifierRecords } from "@/lib/specifiers"; const appDir = path.join(process.cwd(), "src", "app"); const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); @@ -57,7 +58,11 @@ const routeDescriptions: Record = { "/formulation/builder": "Structured clinical formulation builder.", "/formulation/compare": "Side-by-side mechanism comparison.", "/formulation/map": "Formulation mechanism domain map.", - "/specifiers/[[...path]]": "Compatibility redirect to the corrected Formulation workspace.", + "/specifiers": "Psychiatric specifier home and local search surface.", + "/specifiers/[slug]": "Psychiatric specifier decision-support guide.", + "/specifiers/builder": "Structured diagnostic wording builder.", + "/specifiers/compare": "Side-by-side psychiatric specifier comparison.", + "/specifiers/map": "Psychiatric specifier family map.", }; const apiDescriptions: Record = { @@ -97,6 +102,7 @@ const routeOwnershipRows = [ ["Forms", "src/app/forms, src/lib/forms.ts, src/app/api/registry/records"], ["Favourites", "src/app/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx"], ["Differentials", "src/app/differentials, src/lib/differentials.ts"], + ["Specifiers", "src/app/specifiers, src/components/specifiers, src/lib/specifiers.ts"], ["Formulation", "src/app/formulation, src/components/formulation, src/lib/formulation.ts"], ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], ["Documents", "src/app/documents, src/lib/document-flow-routes.ts"], @@ -206,6 +212,7 @@ function renderModeRoutes() { forms: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), favourites: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), differentials: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), + specifiers: appModeHomeHref("specifiers", { query: "depressed but racing thoughts", focus: true, run: true }), formulation: appModeHomeHref("formulation", { query: "I keep going over it", focus: true, run: true }), prescribing: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), tools: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), @@ -273,6 +280,12 @@ function renderModePageIndex() { search: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), detail: "`/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`.", }, + { + mode: "Specifiers", + home: appModeHomeHref("specifiers"), + search: appModeHomeHref("specifiers", { query: "depressed but racing thoughts", focus: true, run: true }), + detail: "`/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`.", + }, { mode: "Formulation", home: appModeHomeHref("formulation"), @@ -327,6 +340,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { "/services/[slug]", "/forms/[slug]", "/differentials/diagnoses/[slug]", + "/specifiers/[slug]", "/formulation/[slug]", "/medications/[slug]", ].includes(route.route), @@ -365,6 +379,12 @@ function renderSiteMapRaw(data = collectSiteMapData()) { serviceRecords.map((record) => record.slug), ), "", + ...renderSlugInventory( + "Psychiatric specifier slugs", + "/specifiers/[slug]", + specifierRecords.map((record) => record.slug), + ), + "", ...renderSlugInventory( "Seeded form slugs", "/forms/[slug]", diff --git a/src/app/page.tsx b/src/app/page.tsx index bbefb8817..f96e14461 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -25,7 +25,6 @@ export default async function Home({ searchParams }: HomeProps) { await connection(); const params = searchParams ? await searchParams : {}; const requestedMode = firstSearchParam(params.mode); - const legacySpecifierMode = requestedMode === "specifiers"; const initialSearchMode: AppModeId = isAppModeId(requestedMode) && isAppModeVisible(requestedMode) ? requestedMode : "answer"; @@ -51,7 +50,17 @@ export default async function Home({ searchParams }: HomeProps) { redirect(suffix ? `/differentials?${suffix}` : "/differentials"); } - if (initialSearchMode === "formulation" || legacySpecifierMode) { + if (initialSearchMode === "specifiers") { + const specifierParams = new URLSearchParams(); + const query = firstSearchParam(params.q)?.trim(); + if (query) specifierParams.set("q", query); + if (firstSearchParam(params.focus) === "1") specifierParams.set("focus", "1"); + if (firstSearchParam(params.run) === "1") specifierParams.set("run", "1"); + const suffix = specifierParams.toString(); + redirect(suffix ? `/specifiers?${suffix}` : "/specifiers"); + } + + if (initialSearchMode === "formulation") { const formulationParams = new URLSearchParams(); const query = firstSearchParam(params.q)?.trim(); if (query) formulationParams.set("q", query); diff --git a/src/app/specifiers/[[...path]]/page.tsx b/src/app/specifiers/[[...path]]/page.tsx deleted file mode 100644 index ef39e3729..000000000 --- a/src/app/specifiers/[[...path]]/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { redirect } from "next/navigation"; - -import { findFormulationMechanism } from "@/lib/formulation"; - -type LegacySpecifierRouteProps = { - params: Promise<{ path?: string[] }>; - searchParams?: Promise>; -}; - -function humanizeLegacyMechanism(value: string) { - return value.replaceAll("-", " ").trim(); -} - -export default async function LegacySpecifierRoute({ params, searchParams }: LegacySpecifierRouteProps) { - const { path = [] } = await params; - const values = searchParams ? await searchParams : {}; - const [legacyDestination] = path; - const isLegacyCompare = path.length === 1 && legacyDestination === "compare"; - const nextParams = new URLSearchParams(); - for (const [key, value] of Object.entries(values)) { - const items = Array.isArray(value) ? value : value ? [value] : []; - if (key === "specifier" && isLegacyCompare) { - items.slice(0, 2).forEach((item, index) => nextParams.append(index === 0 ? "a" : "b", item)); - } else { - const destinationKey = key === "specifier" ? "mechanism" : key === "query" ? "q" : key; - items.forEach((item) => nextParams.append(destinationKey, item)); - } - } - - let canPreserveDestination = - path.length === 1 && - (["builder", "compare", "map"].includes(legacyDestination) || Boolean(findFormulationMechanism(legacyDestination))); - - const mechanismParamKeys = - legacyDestination === "compare" - ? ["a", "b"] - : legacyDestination === "builder" || legacyDestination === "map" - ? ["mechanism"] - : []; - const requestedMechanisms = mechanismParamKeys.flatMap((key) => nextParams.getAll(key)); - const hasUnsupportedMechanism = requestedMechanisms.some((id) => !findFormulationMechanism(id)); - - if (hasUnsupportedMechanism) { - canPreserveDestination = false; - mechanismParamKeys.forEach((key) => nextParams.delete(key)); - if (!nextParams.has("q")) { - nextParams.set("q", requestedMechanisms.map(humanizeLegacyMechanism).join(" versus ")); - } - nextParams.set("run", "1"); - } - - if (!path.length && nextParams.has("mechanism")) { - const requested = nextParams.getAll("mechanism"); - nextParams.delete("mechanism"); - if (!nextParams.has("q")) nextParams.set("q", requested.map(humanizeLegacyMechanism).join(" ")); - nextParams.set("run", "1"); - } - - const pathname = canPreserveDestination ? `/formulation/${legacyDestination}` : "/formulation"; - - if (path.length && !canPreserveDestination && !nextParams.has("q")) { - nextParams.set("q", humanizeLegacyMechanism(path.join(" "))); - nextParams.set("run", "1"); - } - - const suffix = nextParams.toString(); - redirect(suffix ? `${pathname}?${suffix}` : pathname); -} diff --git a/src/app/specifiers/[slug]/page.tsx b/src/app/specifiers/[slug]/page.tsx new file mode 100644 index 000000000..e8bb1a5d5 --- /dev/null +++ b/src/app/specifiers/[slug]/page.tsx @@ -0,0 +1,16 @@ +import { notFound } from "next/navigation"; + +import { SpecifierRecordPage } from "@/components/specifiers/specifier-record-page"; +import { findSpecifier, specifierRecords } from "@/lib/specifiers"; + +export function generateStaticParams() { + return specifierRecords.map((record) => ({ slug: record.slug })); +} + +export default async function SpecifierDetailRoute({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const record = findSpecifier(slug); + if (!record) notFound(); + + return ; +} diff --git a/src/app/specifiers/builder/page.tsx b/src/app/specifiers/builder/page.tsx new file mode 100644 index 000000000..26e85256f --- /dev/null +++ b/src/app/specifiers/builder/page.tsx @@ -0,0 +1,16 @@ +import { SpecifierBuilderPage } from "@/components/specifiers/specifier-builder-page"; + +type BuilderRouteProps = { + searchParams?: Promise<{ specifier?: string | string[] }>; +}; + +export default async function SpecifierBuilderRoute({ searchParams }: BuilderRouteProps) { + const params = searchParams ? await searchParams : {}; + const initialSpecifiers = Array.isArray(params.specifier) + ? Array.from(new Set(params.specifier)) + : params.specifier + ? [params.specifier] + : []; + + return ; +} diff --git a/src/app/specifiers/compare/page.tsx b/src/app/specifiers/compare/page.tsx new file mode 100644 index 000000000..41fb3905f --- /dev/null +++ b/src/app/specifiers/compare/page.tsx @@ -0,0 +1,14 @@ +import { SpecifierComparePage } from "@/components/specifiers/specifier-compare-page"; + +type CompareRouteProps = { + searchParams?: Promise<{ a?: string | string[]; b?: string | string[] }>; +}; + +function first(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function SpecifierCompareRoute({ searchParams }: CompareRouteProps) { + const params = searchParams ? await searchParams : {}; + return ; +} diff --git a/src/app/specifiers/layout.tsx b/src/app/specifiers/layout.tsx new file mode 100644 index 000000000..744354acd --- /dev/null +++ b/src/app/specifiers/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; + +export default function SpecifiersLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/specifiers/map/page.tsx b/src/app/specifiers/map/page.tsx new file mode 100644 index 000000000..0f77de187 --- /dev/null +++ b/src/app/specifiers/map/page.tsx @@ -0,0 +1,11 @@ +import { SpecifierMapPage } from "@/components/specifiers/specifier-map-page"; + +type MapRouteProps = { + searchParams?: Promise<{ selected?: string | string[] }>; +}; + +export default async function SpecifierMapRoute({ searchParams }: MapRouteProps) { + const params = searchParams ? await searchParams : {}; + const selected = Array.isArray(params.selected) ? params.selected[0] : params.selected; + return ; +} diff --git a/src/app/specifiers/page.tsx b/src/app/specifiers/page.tsx new file mode 100644 index 000000000..b685ade9f --- /dev/null +++ b/src/app/specifiers/page.tsx @@ -0,0 +1,17 @@ +import { SpecifiersHomePage } from "@/components/specifiers/specifiers-home-page"; + +type SpecifiersRouteProps = { + searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; run?: string | string[] }>; +}; + +function firstSearchParam(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function SpecifiersRoute({ searchParams }: SpecifiersRouteProps) { + const params = searchParams ? await searchParams : {}; + const query = (firstSearchParam(params.q) ?? firstSearchParam(params.query) ?? "").trim(); + const hasSubmittedSearch = firstSearchParam(params.run) === "1" && query.length > 0; + + return ; +} diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 492f496a1..3452c78ff 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -19,6 +19,7 @@ import { Settings as SettingsIcon, Sparkles, Sun, + Tags, Wrench, } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; @@ -76,6 +77,7 @@ const sidebarToolItems = [ { id: "forms", label: "Forms", icon: ClipboardPen, href: "/forms", badge: "Early access" }, { id: "favourites", label: "Favourites", icon: Heart, href: "/favourites" }, { id: "differentials", label: "Differentials", icon: BrainCircuit, href: "/differentials" }, + { id: "specifiers", label: "Specifiers", icon: Tags, href: "/specifiers" }, { id: "formulation", label: "Formulation", icon: Network, href: "/formulation" }, { id: "prescribing", label: "Medication", icon: Pill, href: "/?mode=prescribing" }, { id: "tools", label: "Tools", icon: Wrench, href: "/?mode=tools" }, diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index e2ee2c2fe..67adfc38f 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -126,6 +126,7 @@ function GlobalSearchShellClient(props: GlobalSearchShellProps) { resolvedSearchMode !== "forms" && resolvedSearchMode !== "favourites" && resolvedSearchMode !== "differentials" && + resolvedSearchMode !== "specifiers" && resolvedSearchMode !== "formulation" && !isDocumentSearchMockupRoute; const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); @@ -236,7 +237,7 @@ function GlobalStandaloneSearchShellClient({ const isDocumentSearchMockupRoute = pathname.startsWith("/mockups/document-search"); const isDocumentCommandSearchView = pathname === "/documents/search" && requestedQuery.length > 0; const useCompactBottomSearch = hasSubmittedModeSearch || isDocumentCommandSearchView; - // Services, forms, and favourites own their submitted-search views on their + // Registry and local decision-support modes own their submitted-search views on their // standalone routes; the shell must not swap them to the dashboard. On the // home route the dashboard always renders, so these exclusions only apply // to the standalone pages. @@ -246,6 +247,7 @@ function GlobalStandaloneSearchShellClient({ resolvedSearchMode !== "forms" && resolvedSearchMode !== "favourites" && resolvedSearchMode !== "differentials" && + resolvedSearchMode !== "specifiers" && resolvedSearchMode !== "formulation" && !isDocumentSearchMockupRoute; const isStandaloneModeHome = @@ -255,6 +257,7 @@ function GlobalStandaloneSearchShellClient({ (searchMode === "forms" && pathname === "/forms") || (searchMode === "favourites" && pathname === "/favourites") || (searchMode === "differentials" && pathname === "/differentials") || + (searchMode === "specifiers" && pathname === "/specifiers") || (searchMode === "formulation" && pathname === "/formulation") || (searchMode === "tools" && pathname === "/tools")); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); @@ -334,6 +337,7 @@ function GlobalStandaloneSearchShellClient({ router.prefetch("/?mode=tools"); router.prefetch("/favourites"); router.prefetch("/differentials"); + router.prefetch("/specifiers"); router.prefetch("/formulation"); } diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index dd9beb7e4..c2d7cc34d 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -276,6 +276,7 @@ export function MasterSearchHeader({ selectedSearch.kind === "services" || selectedSearch.kind === "tools" || selectedSearch.kind === "favourites" || + selectedSearch.kind === "specifiers" || selectedSearch.kind === "formulation"; const canAsk = trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch); const indexedDocumentTotal = documentTotal ?? documents.length; @@ -443,11 +444,13 @@ export function MasterSearchHeader({ ? "favourites" : searchMode === "differentials" ? "differentials" - : searchMode === "formulation" - ? "formulation" - : searchMode === "tools" - ? "tools" - : "answer"; + : searchMode === "specifiers" + ? "specifiers" + : searchMode === "formulation" + ? "formulation" + : searchMode === "tools" + ? "tools" + : "answer"; const actionMenuItems = modeActionItemsFor(actionMenuSetId); const actionMenuTitle = selectedAppMode.label; const actionMenuSubtitle = searchMode === "answer" ? "Source-backed mode" : selectedAppMode.description; @@ -608,6 +611,22 @@ export function MasterSearchHeader({ onOpenEvidence?.(); return; } + if (actionId === "specifiers-search") { + onSearchModeChange("specifiers"); + return; + } + if (actionId === "specifiers-builder") { + window.location.assign("/specifiers/builder"); + return; + } + if (actionId === "specifiers-compare") { + window.location.assign("/specifiers/compare"); + return; + } + if (actionId === "specifiers-map") { + window.location.assign("/specifiers/map"); + return; + } if (actionId === "formulation-search") { onSearchModeChange("formulation"); return; diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 7134df343..d81e3c52d 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -35,6 +35,7 @@ import { Search, ShieldCheck, Sparkles, + Tags, Table2, UploadCloud, Waypoints, @@ -55,6 +56,7 @@ export type ModeActionSetId = | "favourites" | "tools" | "differentials" + | "specifiers" | "formulation" | "prescribing"; export type ModeActionPlacement = "up" | "down"; @@ -118,6 +120,10 @@ export type ModeActionId = | "differentials-criteria" | "differentials-documents" | "differentials-evidence" + | "specifiers-search" + | "specifiers-builder" + | "specifiers-compare" + | "specifiers-map" | "formulation-search" | "formulation-builder" | "formulation-compare" @@ -231,6 +237,28 @@ const modeActionSets = { }, { id: "differentials-evidence", label: "View evidence", description: "Review cited support", icon: ShieldCheck }, ], + specifiers: [ + { + id: "specifiers-search", + label: "Find a specifier", + description: "Match presentation features", + icon: Tags, + primary: true, + }, + { + id: "specifiers-builder", + label: "Build wording", + description: "Assemble diagnostic wording", + icon: ListChecks, + }, + { + id: "specifiers-compare", + label: "Compare specifiers", + description: "Clarify close clinical calls", + icon: GitCompareArrows, + }, + { id: "specifiers-map", label: "Specifier map", description: "Browse by diagnostic role", icon: Waypoints }, + ], formulation: [ { id: "formulation-search", diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index ffda3c92f..f6a36ae79 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -42,7 +42,8 @@ const domainsByTargetMode: Partial> = services: ["services"], forms: ["forms"], differentials: ["differentials", "presentations"], - formulation: ["specifiers"], + specifiers: ["specifiers"], + formulation: ["formulation"], tools: ["tools"], }; @@ -53,7 +54,8 @@ const domainHeadings: Record = { forms: "Forms", differentials: "Differentials", presentations: "Presentations", - specifiers: "Formulation", + specifiers: "Specifiers", + formulation: "Formulation", tools: "Tools", }; diff --git a/src/components/specifiers/specifier-builder-page.tsx b/src/components/specifiers/specifier-builder-page.tsx new file mode 100644 index 000000000..d22d0a091 --- /dev/null +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -0,0 +1,264 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, Check, FileCheck2, ListChecks, RotateCcw, Tags } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + SpecifierBreadcrumbs, + SpecifierPageShell, + SpecifierSafetyNote, + SpecifierSubnav, + specifierCard, +} from "@/components/specifiers/specifier-ui"; +import { cn, eyebrowText } from "@/components/ui-primitives"; +import { + normalizeSpecifierSelection, + specifierAppliesToBuilderDiagnosis, + specifierFamilies, + specifierRecords, + type SpecifierBuilderDiagnosis, + type SpecifierFamily, + type SpecifierRecord, +} from "@/lib/specifiers"; + +const diagnosisPresets: Array<{ id: SpecifierBuilderDiagnosis; label: string }> = [ + { id: "mdd-recurrent", label: "Major depressive disorder, recurrent" }, + { id: "mdd-single", label: "Major depressive disorder, single episode" }, + { id: "bipolar-i-depressed", label: "Bipolar I disorder, current episode depressed" }, + { id: "bipolar-i-manic", label: "Bipolar I disorder, current episode manic" }, + { id: "bipolar-ii-depressed", label: "Bipolar II disorder, current episode depressed" }, +]; + +const specifierFamilyOrder: Record = { + "episode-features": 0, + "course-onset": 1, + "severity-remission": 2, +}; + +function wordingSegment(record: SpecifierRecord) { + if (record.slug === "mild-severity") return "mild"; + if (record.slug === "with-psychotic-features") return "severe with psychotic features"; + return record.name.charAt(0).toLowerCase() + record.name.slice(1); +} + +export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecifiers?: string[] }) { + const initialDiagnosis = diagnosisPresets[0]; + const validInitial = normalizeSpecifierSelection(initialSpecifiers).filter((slug) => { + const record = specifierRecords.find((candidate) => candidate.slug === slug); + return record ? specifierAppliesToBuilderDiagnosis(record, initialDiagnosis.id) : false; + }); + const [diagnosisId, setDiagnosisId] = useState(initialDiagnosis.id); + const [selected, setSelected] = useState(validInitial); + const diagnosis = diagnosisPresets.find((preset) => preset.id === diagnosisId) ?? initialDiagnosis; + const selectedRecords = useMemo( + () => + selected + .map((slug) => specifierRecords.find((record) => record.slug === slug)) + .filter((record): record is SpecifierRecord => Boolean(record)) + .sort((left, right) => specifierFamilyOrder[left.family] - specifierFamilyOrder[right.family]), + [selected], + ); + const wording = [diagnosis.label, ...selectedRecords.map((record) => wordingSegment(record))].join(", "); + + function changeDiagnosis(nextDiagnosis: SpecifierBuilderDiagnosis) { + setDiagnosisId(nextDiagnosis); + setSelected((current) => + current.filter((slug) => { + const record = specifierRecords.find((candidate) => candidate.slug === slug); + return record ? specifierAppliesToBuilderDiagnosis(record, nextDiagnosis) : false; + }), + ); + } + + function toggle(slug: string) { + setSelected((current) => { + if (current.includes(slug)) return current.filter((item) => item !== slug); + return normalizeSpecifierSelection([...current, slug]); + }); + } + + return ( + +
+ + +
+ +
+

Structured diagnostic language

+

+ Build the diagnosis in the right order +

+

+ Start with the base diagnosis, then add episode features, course or onset, and one current severity or + remission descriptor. +

+
+ +
+
+
+
+ + 1 + +
+

Base diagnosis

+

+ Name the disorder and episode +

+
+
+ +
+ + {specifierFamilies + .filter((family) => family.id !== "all") + .map((family, familyIndex) => { + const records = specifierRecords.filter((record) => record.family === family.id); + const singleChoice = family.id === "severity-remission"; + return ( +
+
+ + {familyIndex + 2} + +
+

{singleChoice ? "Choose up to one" : "Choose when supported"}

+

{family.label}

+
+
+
+ {records.map((record, index) => { + const checked = selected.includes(record.slug); + const compatible = specifierAppliesToBuilderDiagnosis(record, diagnosisId); + return ( + + ); + })} +
+
+ ); + })} +
+ + +
+ + +
+ ); +} diff --git a/src/components/specifiers/specifier-compare-page.tsx b/src/components/specifiers/specifier-compare-page.tsx new file mode 100644 index 000000000..c3a5b81ae --- /dev/null +++ b/src/components/specifiers/specifier-compare-page.tsx @@ -0,0 +1,238 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, GitCompareArrows, HelpCircle, Repeat2, Tags } from "lucide-react"; +import { useState } from "react"; + +import { + SpecifierBreadcrumbs, + SpecifierFamilyBadge, + SpecifierPageShell, + SpecifierSafetyNote, + SpecifierSubnav, + specifierCard, +} from "@/components/specifiers/specifier-ui"; +import { cn, eyebrowText } from "@/components/ui-primitives"; +import { findSpecifier, specifierRecords } from "@/lib/specifiers"; + +const comparisonRows = [ + { label: "Clinical focus", key: "focus" as const }, + { label: "Time course", key: "timeCourse" as const }, + { label: "Look for", key: "lookFor" as const }, + { label: "Do not overcall from", key: "caution" as const }, +]; + +function fallbackPair(leftSlug?: string, rightSlug?: string) { + const left = findSpecifier(leftSlug ?? "") ?? specifierRecords[0]; + const requestedRight = findSpecifier(rightSlug ?? ""); + const right = + requestedRight && requestedRight.slug !== left.slug + ? requestedRight + : specifierRecords.find((item) => item.slug !== left.slug)!; + return { left, right }; +} + +function Selector({ + label, + value, + otherValue, + onChange, +}: { + label: string; + value: string; + otherValue: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +export function SpecifierComparePage({ initialLeft, initialRight }: { initialLeft?: string; initialRight?: string }) { + const initial = fallbackPair(initialLeft, initialRight); + const [leftSlug, setLeftSlug] = useState(initial.left.slug); + const [rightSlug, setRightSlug] = useState(initial.right.slug); + const left = findSpecifier(leftSlug) ?? initial.left; + const right = findSpecifier(rightSlug) ?? initial.right; + + function chooseLeft(nextSlug: string) { + setLeftSlug(nextSlug); + if (nextSlug === rightSlug) { + setRightSlug(specifierRecords.find((item) => item.slug !== nextSlug)?.slug ?? rightSlug); + } + } + + function chooseRight(nextSlug: string) { + setRightSlug(nextSlug); + if (nextSlug === leftSlug) { + setLeftSlug(specifierRecords.find((item) => item.slug !== nextSlug)?.slug ?? leftSlug); + } + } + + function swap() { + setLeftSlug(right.slug); + setRightSlug(left.slug); + } + + return ( + +
+ + +
+ +
+

Close-call reasoning

+

+ Compare specifiers +

+

+ Clarify the deciding signal, chronology, and common overcalls. This comparison changes as you choose either + specifier. +

+
+ +
+ + + +
+ +
+
+ +
+

Ask this

+

+ Is the central pattern “{left.comparison.focus.toLowerCase()}” or “{right.comparison.focus.toLowerCase()} + ”? +

+
+
+
+ +
+
+ {[left, right].map((record, index) => ( +
+
+ + {index === 0 ? "A" : "B"} + +

{record.shortName}

+
+

{record.summary}

+ +
+ ))} +
+ +
+ {comparisonRows.map((row) => ( +
+
+ {row.label} +
+
+ + A + + {left.comparison[row.key]} +
+
+ + B + + {right.comparison[row.key]} +
+
+ ))} +
+ +
+ {[left, right].map((record, index) => ( +
+

Example wording

+

{record.wording}

+ + Open full guide + + +
+ ))} +
+
+ +
+ + + Browse the map + + + Build diagnostic wording + + +
+ + +
+ ); +} diff --git a/src/components/specifiers/specifier-map-page.tsx b/src/components/specifiers/specifier-map-page.tsx new file mode 100644 index 000000000..b302d12ad --- /dev/null +++ b/src/components/specifiers/specifier-map-page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, CheckCircle2, GitCompareArrows, Tags, Waypoints } from "lucide-react"; +import { useState } from "react"; + +import { + DiagnosisChips, + SpecifierBreadcrumbs, + SpecifierFamilyBadge, + SpecifierPageShell, + SpecifierSafetyNote, + SpecifierSubnav, + specifierCard, +} from "@/components/specifiers/specifier-ui"; +import { cn, eyebrowText } from "@/components/ui-primitives"; +import { findSpecifier, specifierFamilies, specifierRecords } from "@/lib/specifiers"; + +export function SpecifierMapPage({ initialSlug }: { initialSlug?: string }) { + const [selectedSlug, setSelectedSlug] = useState(findSpecifier(initialSlug ?? "")?.slug ?? specifierRecords[0].slug); + const selected = findSpecifier(selectedSlug) ?? specifierRecords[0]; + + return ( + +
+ + +
+ +
+

Diagnostic architecture

+

+ Specifier map +

+

+ Browse by the job each specifier performs. The sequence keeps diagnostic wording clear without implying that + every diagnosis uses every category. +

+
+ +
+ {["Base diagnosis", "Episode features", "Course and onset", "Severity or remission"].map((label, index) => ( +
+
+ + {index + 1} + + {label} +
+ {index < 3 ? ( + + ) : null} +
+ ))} +
+ +
+
+ {specifierFamilies + .filter((family) => family.id !== "all") + .map((family) => { + const records = specifierRecords.filter((record) => record.family === family.id); + return ( +
+
+ +
+

{family.label}

+

+ {records.length} options +

+
+
+
+ {records.map((record) => { + const active = selected.slug === record.slug; + return ( + + ); + })} +
+
+ ); + })} +
+ + +
+ + +
+ ); +} diff --git a/src/components/specifiers/specifier-record-page.tsx b/src/components/specifiers/specifier-record-page.tsx new file mode 100644 index 000000000..f8f25309e --- /dev/null +++ b/src/components/specifiers/specifier-record-page.tsx @@ -0,0 +1,251 @@ +import Link from "next/link"; +import { + ArrowRight, + Check, + ChevronDown, + CircleHelp, + FileCheck2, + GitCompareArrows, + ListChecks, + MessageSquareQuote, + Route, + ShieldAlert, + Tags, + Target, +} from "lucide-react"; + +import { + DiagnosisChips, + SectionHeading, + SpecifierBreadcrumbs, + SpecifierFamilyBadge, + SpecifierPageShell, + SpecifierSafetyNote, + SpecifierSubnav, + specifierCard, +} from "@/components/specifiers/specifier-ui"; +import { cn, eyebrowText } from "@/components/ui-primitives"; +import { relatedSpecifiers, type SpecifierRecord } from "@/lib/specifiers"; + +function comparisonHref(left: string, right?: string) { + const params = new URLSearchParams({ a: left }); + if (right) params.set("b", right); + return `/specifiers/compare?${params.toString()}`; +} + +function RecordFact({ icon: Icon, label, body }: { icon: typeof Tags; label: string; body: string }) { + return ( +
+ + + + + {label} + {body} + +
+ ); +} + +function GuidanceSection({ + icon: Icon, + title, + items, + tone = "default", + open = false, +}: { + icon: typeof Tags; + title: string; + items: string[]; + tone?: "default" | "success" | "warning"; + open?: boolean; +}) { + const toneClass = + tone === "success" + ? "text-[color:var(--success)] bg-[color:var(--success-soft)]" + : tone === "warning" + ? "text-[color:var(--warning)] bg-[color:var(--warning-soft)]" + : "text-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)]"; + + return ( +
+ + + + + {title} + + +
+
    + {items.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+ ); +} + +export function SpecifierRecordPage({ record }: { record: SpecifierRecord }) { + const related = relatedSpecifiers(record); + const primaryRelated = related[0]; + + return ( + +
+ + +
+ +
+
+ + + +
+
+

Psychiatric specifier

+

+ {record.name} +

+
+

{record.summary}

+
+ + +
+
+
+ +
+ + + Compare + + + + Build wording + +
+
+ +
+
+

+ What matters now +

+
+
+ + + + +
+
+ +
+
+ + +
+ + + + `“${item}”`)} + /> +
+ +
+
+ + + +
+

Clinical implication

+

+ How this changes the plan +

+
+
+

{record.treatmentLens}

+
+
+ + +
+ + +
+ ); +} diff --git a/src/components/specifiers/specifier-ui.tsx b/src/components/specifiers/specifier-ui.tsx new file mode 100644 index 000000000..d77bf526a --- /dev/null +++ b/src/components/specifiers/specifier-ui.tsx @@ -0,0 +1,130 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import { ArrowLeft, ChevronRight, Info, Tags } from "lucide-react"; + +import { cn, eyebrowText, pageContainer } from "@/components/ui-primitives"; +import type { SpecifierRecord } from "@/lib/specifiers"; + +export const specifierCard = + "rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-inset)]"; + +export function SpecifierPageShell({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+
{children}
+
+ ); +} + +export function SpecifierBreadcrumbs({ current }: { current?: string }) { + return ( + + ); +} + +export function SpecifierSubnav({ active }: { active: "search" | "builder" | "compare" | "map" }) { + const items = [ + { id: "search" as const, label: "Find", href: "/specifiers" }, + { id: "builder" as const, label: "Build wording", href: "/specifiers/builder" }, + { id: "compare" as const, label: "Compare", href: "/specifiers/compare" }, + { id: "map" as const, label: "Map", href: "/specifiers/map" }, + ]; + + return ( + + ); +} + +export function SpecifierFamilyBadge({ record }: { record: SpecifierRecord }) { + return ( + + + {record.familyLabel} + + ); +} + +export function DiagnosisChips({ values }: { values: string[] }) { + return ( +
+ {values.map((value) => ( + + {value} + + ))} +
+ ); +} + +export function SpecifierSafetyNote({ compact = false }: { compact?: boolean }) { + return ( + + ); +} + +export function SectionHeading({ eyebrow, title, body }: { eyebrow?: string; title: string; body?: string }) { + return ( +
+ {eyebrow ?

{eyebrow}

: null} +

{title}

+ {body ?

{body}

: null} +
+ ); +} diff --git a/src/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx new file mode 100644 index 000000000..b8d9e202e --- /dev/null +++ b/src/components/specifiers/specifiers-home-page.tsx @@ -0,0 +1,331 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { + ArrowRight, + CheckCircle2, + ChevronRight, + GitCompareArrows, + ListChecks, + Search, + ShieldCheck, + Tags, + Waypoints, +} from "lucide-react"; + +import { ModeHomeMain, ModeHomeTemplate, ModeHomeVerificationFooter } from "@/components/mode-home-template"; +import { + DiagnosisChips, + SpecifierBreadcrumbs, + SpecifierFamilyBadge, + SpecifierPageShell, + SpecifierSafetyNote, + SpecifierSubnav, + specifierCard, +} from "@/components/specifiers/specifier-ui"; +import { cn, eyebrowText } from "@/components/ui-primitives"; +import { appModeHomeHref } from "@/lib/app-modes"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { searchSpecifiers, specifierFamilies, specifierSearchPresets, type SpecifierFamily } from "@/lib/specifiers"; + +const diagnosisOptions = [ + { value: "", label: "All diagnoses" }, + { value: "depressive", label: "Depressive" }, + { value: "bipolar", label: "Bipolar" }, + { value: "psychotic", label: "Psychotic" }, + { value: "mood", label: "Mood episodes" }, +]; + +function presetHref(query: string) { + return appModeHomeHref("specifiers", { query, run: true, focus: true }); +} + +function SpecifierPathwayStrip() { + const steps = [ + { label: "Diagnosis", body: "Name the disorder" }, + { label: "Episode features", body: "Describe what is present now" }, + { label: "Course and onset", body: "Place the episode in time" }, + { label: "Severity or remission", body: "State current burden and recovery" }, + ]; + + return ( +
+
+
+

Specifier pathway

+

+ Build diagnostic wording in clinical order +

+
+ +
+
    + {steps.map((step, index) => ( +
  1. 0 && "border-t border-[color:var(--border)] sm:border-t-0", + index % 2 === 1 && "sm:border-l sm:border-[color:var(--border)]", + index === 2 && "lg:border-l lg:border-[color:var(--border)]", + )} + > + + {index + 1} + + + {step.label} + + {step.body} + + +
  2. + ))} +
+
+ ); +} + +function SpecifiersHome() { + return ( + + ({ + label: preset.label, + href: presetHref(preset.query), + icon: Tags, + }))} + pillsAction={ + + Browse map + + + } + footer={ +
+ + +
+ } + /> +
+ ); +} + +function EmptySearchResults({ query }: { query: string }) { + return ( +
+ + + +
+

No strong match for “{query}”

+

+ Try the episode pattern, timing, patient language, or the base diagnosis. For example: “depressed but racing + thoughts” or “returns every winter”. +

+
+ + Clear search + +
+ ); +} + +function SpecifierResults({ query }: { query: string }) { + const [family, setFamily] = useState<"all" | SpecifierFamily>("all"); + const [diagnosis, setDiagnosis] = useState(""); + const results = useMemo(() => searchSpecifiers(query, { family, diagnosis }), [diagnosis, family, query]); + + return ( + +
+ + +
+ +
+
+

Specifier search

+

+ Matches for “{query}” +

+

+ Results prioritise clinical fit, episode timing, and patient language. Open a result to check exclusions and + wording. +

+
+

+ {results.length} {results.length === 1 ? "match" : "matches"} +

+
+ +
+
+ {specifierFamilies.map((option) => { + const active = family === option.id; + return ( + + ); + })} +
+ +
+ + {results.length === 0 ? ( + + ) : ( +
+ {results.map(({ record }, index) => ( +
+
+
+
+ + {record.name} + + {index === 0 ? ( + + + Best fit + + ) : null} +
+

+ {record.summary} +

+
+ + +
+
+ +
+

Deciding signal

+

+ {record.clinicalSignal} +

+
+ + + Open + + +
+
+
+

Ask this

+

+ {record.decisionQuestion} +

+
+
+

Typical language

+

+ “{record.patientLanguage[0]}” +

+
+
+
+ ))} +
+ )} + + +
+ ); +} + +export function SpecifiersHomePage({ query = "", autoRunSearch = false }: { query?: string; autoRunSearch?: boolean }) { + const trimmedQuery = query.trim(); + if (!autoRunSearch || !trimmedQuery) return ; + return ; +} diff --git a/src/lib/app-mode-icons.ts b/src/lib/app-mode-icons.ts index 30a4dd84c..d09db9c1d 100644 --- a/src/lib/app-mode-icons.ts +++ b/src/lib/app-mode-icons.ts @@ -6,6 +6,7 @@ import { Network, Pill, Route, + Tags, Sparkles, Wrench, type LucideIcon, @@ -21,6 +22,7 @@ export const appModeIcons: Record = { forms: FileSignature, favourites: Heart, differentials: BrainCircuit, + specifiers: Tags, formulation: Network, prescribing: Pill, tools: Wrench, diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 3898cf4ca..ae0a551e1 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -9,6 +9,7 @@ export const appModeIds = [ "forms", "favourites", "differentials", + "specifiers", "formulation", "prescribing", "tools", @@ -18,9 +19,25 @@ export type AppModeId = (typeof appModeIds)[number]; export type SearchableAppModeId = AppModeId; export type AppModeSearchKind = - "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "formulation" | "tools"; + | "answer" + | "documents" + | "services" + | "forms" + | "favourites" + | "differentials" + | "specifiers" + | "formulation" + | "tools"; export type AppModeResultKind = - "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "formulation" | "tools"; + | "answer" + | "documents" + | "services" + | "forms" + | "favourites" + | "differentials" + | "specifiers" + | "formulation" + | "tools"; export type AppModeSearchConfig = { kind: AppModeSearchKind; @@ -183,6 +200,28 @@ export const appModeDefinitions = [ defaultQueryMode: "compare_guidance", }, }, + { + id: "specifiers", + label: "Specifiers", + description: "Refine diagnostic wording and episode patterns", + href: "/specifiers", + search: { + kind: "specifiers", + placeholder: "Describe the presentation or search a specifier...", + inputAriaLabel: "Search psychiatric specifiers by presentation or diagnosis", + submitIdleLabel: "Find", + submitBusyLabel: "Find", + submitAriaLabel: "Find matching psychiatric specifiers", + emptyTitle: "Describe the presentation", + readyTitle: "Find the most relevant specifier", + progressLabel: "Matching presentation features to specifiers.", + resultKind: "specifiers", + resultHeading: "Specifier matches", + statusLabel: "Specifiers", + nextStep: "Check fit and refine the diagnostic wording", + badgeLabel: null, + }, + }, { id: "formulation", label: "Formulation", @@ -277,7 +316,14 @@ export function appModeSearchConfig(modeId: AppModeId) { return appModeDefinition(modeId).search; } -const namespaceIsolatedModes = new Set(["services", "forms", "favourites", "differentials", "formulation"]); +const namespaceIsolatedModes = new Set([ + "services", + "forms", + "favourites", + "differentials", + "specifiers", + "formulation", +]); export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOptions = {}) { const mode = appModeDefinition(modeId); @@ -347,6 +393,7 @@ export function isSearchableAppMode(modeId: string): modeId is SearchableAppMode kind === "forms" || kind === "favourites" || kind === "differentials" || + kind === "specifiers" || kind === "formulation" || kind === "tools" ); diff --git a/src/lib/specifiers.ts b/src/lib/specifiers.ts new file mode 100644 index 000000000..bd8d304a0 --- /dev/null +++ b/src/lib/specifiers.ts @@ -0,0 +1,711 @@ +export type SpecifierFamily = "episode-features" | "course-onset" | "severity-remission"; + +export type SpecifierBuilderDiagnosis = + "mdd-recurrent" | "mdd-single" | "bipolar-i-depressed" | "bipolar-i-manic" | "bipolar-ii-depressed"; + +export type SpecifierRecord = { + slug: string; + name: string; + shortName: string; + family: SpecifierFamily; + familyLabel: string; + appliesTo: string[]; + summary: string; + clinicalSignal: string; + decisionQuestion: string; + patientLanguage: string[]; + fit: string[]; + notFit: string[]; + checks: string[]; + treatmentLens: string; + wording: string; + comparison: { + focus: string; + timeCourse: string; + lookFor: string; + caution: string; + }; + keywords: string[]; + relatedSlugs: string[]; +}; + +export const specifierFamilies: Array<{ id: "all" | SpecifierFamily; label: string; shortLabel: string }> = [ + { id: "all", label: "All specifiers", shortLabel: "All" }, + { id: "episode-features", label: "Episode features", shortLabel: "Features" }, + { id: "course-onset", label: "Course and onset", shortLabel: "Course" }, + { id: "severity-remission", label: "Severity and remission", shortLabel: "Severity" }, +]; + +export const specifierRecords: SpecifierRecord[] = [ + { + slug: "with-anxious-distress", + name: "With anxious distress", + shortName: "Anxious distress", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Depressive disorders", "Bipolar disorders"], + summary: "Prominent tension, restlessness, worry, or fear accompanies the current mood episode.", + clinicalSignal: "Anxiety is woven into the mood episode and changes its risk and management profile.", + decisionQuestion: + "Is anxious arousal a meaningful part of this mood episode rather than a separate background condition?", + patientLanguage: ["I feel keyed up all day", "Something awful is about to happen", "I cannot settle inside"], + fit: [ + "Anxious tension or restlessness rises and falls with the mood episode.", + "Worry, loss-of-control fears, or dread meaningfully increase distress or impairment.", + "The pattern is present often enough to influence formulation and care planning.", + ], + notFit: [ + "Anxiety is better explained by a separate anxiety disorder with an independent course.", + "Restlessness is more consistent with akathisia, intoxication, withdrawal, delirium, or another medical cause.", + ], + checks: [ + "Clarify timing against the mood episode", + "Separate worry from psychotic threat beliefs", + "Review agitation and suicide risk", + ], + treatmentLens: + "Name the anxiety burden explicitly; it may affect monitoring, tolerability, risk, and pacing of treatment.", + wording: "Major depressive disorder, current episode, with anxious distress", + comparison: { + focus: "Anxious arousal occurring within a mood episode", + timeCourse: "Tracks the current depressive or manic-spectrum episode", + lookFor: "Tension, restlessness, worry, dread, fear of losing control", + caution: "Do not infer it from insomnia or agitation alone", + }, + keywords: ["anxiety", "worry", "tense", "restless", "dread", "agitated depression", "fear", "keyed up"], + relatedSlugs: ["with-mixed-features", "with-melancholic-features"], + }, + { + slug: "with-mixed-features", + name: "With mixed features", + shortName: "Mixed features", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Depressive disorders", "Bipolar disorders"], + summary: "Clinically meaningful symptoms of the opposite mood pole occur during the predominant mood episode.", + clinicalSignal: + "The presentation crosses mood poles rather than being explained by anxiety, irritability, or agitation alone.", + decisionQuestion: + "Are there distinct opposite-pole symptoms during this episode that change diagnostic and treatment reasoning?", + patientLanguage: [ + "I am depressed but my thoughts are racing", + "I barely sleep and still feel driven", + "I feel sped up and hopeless", + ], + fit: [ + "Opposite-pole symptoms are observable, recurrent, and tied to the current episode.", + "Activation includes features such as elevated drive, reduced need for sleep, or accelerated thought and speech.", + "The pattern changes the assessment of bipolarity and antidepressant-related risk.", + ], + notFit: [ + "The only cross-over features are irritability, distractibility, insomnia, or nonspecific agitation.", + "Activation is better explained by substances, medication, trauma arousal, ADHD, or another condition.", + ], + checks: [ + "Map symptoms to the predominant episode", + "Ask about reduced need for sleep, not insomnia alone", + "Review medication and substance timing", + ], + treatmentLens: + "Treat as a cross-polar signal: reassess medication strategy, activation risk, and longitudinal bipolar history.", + wording: "Major depressive disorder, current episode, with mixed features", + comparison: { + focus: "Opposite-pole mood symptoms", + timeCourse: "Co-occur during the predominant mood episode", + lookFor: "Distinct activation or depressive features from the opposite pole", + caution: "Irritability and agitation are not sufficient by themselves", + }, + keywords: ["mixed", "racing thoughts", "sped up", "less sleep", "activated", "agitated", "depressed and energised"], + relatedSlugs: ["with-anxious-distress", "with-psychotic-features"], + }, + { + slug: "with-melancholic-features", + name: "With melancholic features", + shortName: "Melancholic features", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Major depressive episodes"], + summary: + "Profound loss of pleasure or mood reactivity occurs with a characteristic biological and psychomotor pattern.", + clinicalSignal: "Pleasure and emotional reactivity are markedly reduced, not merely overshadowed by stress.", + decisionQuestion: + "Is there a pervasive non-reactive depressive pattern with characteristic biological or psychomotor change?", + patientLanguage: ["Nothing reaches me", "Even good news changes nothing", "Mornings are unbearable"], + fit: [ + "There is marked anhedonia or very limited mood reactivity.", + "The episode includes a coherent pattern such as early waking, diurnal change, psychomotor change, or excessive guilt.", + "The presentation is qualitatively different from ordinary sadness or understandable distress.", + ], + notFit: [ + "Mood reliably brightens with positive events or interpersonal connection.", + "Reduced enjoyment is better explained by sedation, negative symptoms, cognitive disorder, or environmental deprivation.", + ], + checks: [ + "Test mood reactivity with concrete examples", + "Observe psychomotor change", + "Clarify diurnal and sleep pattern", + ], + treatmentLens: + "Document the biological and psychomotor pattern because it may influence treatment intensity and monitoring.", + wording: "Major depressive disorder, current episode, with melancholic features", + comparison: { + focus: "Non-reactive, biologically patterned depression", + timeCourse: "Persists across the depressive episode", + lookFor: "Anhedonia, non-reactivity, diurnal change, psychomotor change, guilt", + caution: "Severity alone does not establish melancholic features", + }, + keywords: ["melancholic", "anhedonia", "non reactive", "early waking", "morning worse", "psychomotor", "guilt"], + relatedSlugs: ["with-atypical-features", "with-psychotic-features"], + }, + { + slug: "with-atypical-features", + name: "With atypical features", + shortName: "Atypical features", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Depressive disorders", "Bipolar depressive episodes"], + summary: "Mood reactivity occurs with a characteristic reversed-vegetative or interpersonal sensitivity pattern.", + clinicalSignal: + "Mood can brighten in response to positive events despite a clinically significant depressive episode.", + decisionQuestion: + "Does mood reactivity sit alongside a coherent atypical depressive pattern rather than an isolated symptom?", + patientLanguage: ["Good news can lift me for a while", "I sleep and eat much more", "Rejection knocks me flat"], + fit: [ + "Mood shows genuine reactivity to positive events.", + "The episode includes a compatible pattern such as increased sleep, increased appetite, leaden heaviness, or rejection sensitivity.", + "The pattern is clinically meaningful and not simply a preference or coping style.", + ], + notFit: [ + "There is no meaningful mood reactivity.", + "Sleep or appetite change is better explained by medication, shift work, endocrine illness, or another condition.", + ], + checks: [ + "Ask for a recent positive-event example", + "Map sleep and appetite change from baseline", + "Clarify longstanding rejection sensitivity", + ], + treatmentLens: + "Capture the reactive and reversed-vegetative pattern without using “atypical” to mean unusual or mild.", + wording: "Major depressive disorder, current episode, with atypical features", + comparison: { + focus: "Reactive mood with a characteristic symptom pattern", + timeCourse: "Present through much of the depressive episode", + lookFor: "Mood reactivity, hypersomnia, increased appetite, leaden feeling, rejection sensitivity", + caution: "The label does not simply mean an unusual presentation", + }, + keywords: ["atypical", "mood reactive", "hypersomnia", "sleeping more", "eating more", "leaden", "rejection"], + relatedSlugs: ["with-melancholic-features", "with-anxious-distress"], + }, + { + slug: "with-psychotic-features", + name: "With psychotic features", + shortName: "Psychotic features", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Depressive disorders", "Bipolar disorders"], + summary: + "Delusions or hallucinations occur during a severe mood episode and require explicit risk and diagnostic review.", + clinicalSignal: "Psychosis is present, and its relationship to the mood episode determines the diagnostic wording.", + decisionQuestion: + "Do psychotic symptoms occur within the mood episode, and are they mood-congruent, mood-incongruent, or independent?", + patientLanguage: ["I know I have ruined everyone", "A voice says I deserve punishment", "I have a special mission"], + fit: [ + "Delusions or hallucinations are clearly present during the mood episode.", + "The content, conviction, and behavioural impact have been assessed rather than inferred from severe worry or guilt.", + "The longitudinal relationship between mood and psychosis is documented.", + ], + notFit: [ + "Unusual thoughts retain insight and do not reach delusional intensity.", + "Psychosis persists outside mood episodes in a way that suggests a different primary diagnosis.", + ], + checks: [ + "Assess command content and immediate risk", + "Map psychosis against mood chronology", + "Describe congruence without forcing it", + ], + treatmentLens: + "Escalate assessment and treatment intensity; document safety, capacity, and the mood–psychosis timeline.", + wording: "Major depressive disorder, current episode, severe with psychotic features", + comparison: { + focus: "Delusions or hallucinations within a mood episode", + timeCourse: "Mapped directly against mood-episode onset and remission", + lookFor: "Conviction, perceptual disturbance, behavioural impact, congruence", + caution: "Severe guilt, rumination, or intrusive thoughts are not automatically psychotic", + }, + keywords: ["psychotic", "delusion", "hallucination", "voices", "paranoia", "mood congruent", "mood incongruent"], + relatedSlugs: ["with-mixed-features", "with-catatonia"], + }, + { + slug: "with-catatonia", + name: "With catatonia", + shortName: "Catatonia", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Mood disorders", "Psychotic disorders", "Medical conditions"], + summary: "A marked psychomotor syndrome is present and needs structured, time-sensitive assessment.", + clinicalSignal: "A cluster of psychomotor signs is more informative than immobility or reduced speech alone.", + decisionQuestion: + "Is there a coherent catatonic syndrome after urgent medical, neurological, toxic, and medication causes are considered?", + patientLanguage: [ + "They have stopped moving and speaking", + "They hold the same posture", + "Their movements copy mine", + ], + fit: [ + "Multiple characteristic psychomotor signs are directly observed or reliably described.", + "The change is substantial from baseline and not explained by ordinary withdrawal or fatigue.", + "A structured examination and urgent physical assessment are underway.", + ], + notFit: [ + "Reduced movement is better explained by sedation, delirium, neurological disease, severe parkinsonism, or negative symptoms.", + "Only one nonspecific sign is present without a broader syndrome.", + ], + checks: [ + "Record observed psychomotor signs", + "Check hydration, intake, observations, and complications", + "Review medicines and medical causes urgently", + ], + treatmentLens: + "Treat catatonia as time-sensitive; coordinate urgent medical assessment and syndrome-specific management.", + wording: "Bipolar I disorder, current episode depressed, with catatonia", + comparison: { + focus: "A clustered psychomotor syndrome", + timeCourse: "Acute or subacute change requiring close observation", + lookFor: "Immobility, posturing, negativism, echophenomena, stereotypy, agitation without purpose", + caution: "Do not equate quietness, mutism, or immobility alone with catatonia", + }, + keywords: [ + "catatonia", + "mute", + "mutism", + "stupor", + "posturing", + "negativism", + "echolalia", + "echopraxia", + "immobile", + ], + relatedSlugs: ["with-psychotic-features", "with-melancholic-features"], + }, + { + slug: "with-seasonal-pattern", + name: "With seasonal pattern", + shortName: "Seasonal pattern", + family: "course-onset", + familyLabel: "Course and onset", + appliesTo: ["Recurrent depressive disorder", "Bipolar disorders"], + summary: + "Episodes show a repeated temporal relationship with a particular season and a contrasting pattern at another time of year.", + clinicalSignal: "The recurring calendar-linked course matters more than the season in which one episode happens.", + decisionQuestion: + "Across years, is there a consistent seasonal onset-and-remission pattern that is not explained by seasonal stressors?", + patientLanguage: ["Every winter I shut down", "It lifts each spring", "The same pattern returns each year"], + fit: [ + "Episode onset repeatedly aligns with a particular season.", + "Remission or a switch in mood polarity also follows a recurring seasonal relationship.", + "Nonseasonal episodes do not dominate the longitudinal history.", + ], + notFit: [ + "Only the current episode occurred in winter or another season.", + "The pattern is better explained by recurring work, anniversary, social, or environmental stressors.", + ], + checks: [ + "Build a multi-year month-by-month timeline", + "Mark remissions as well as onsets", + "Separate latitude and lifestyle effects from episode recurrence", + ], + treatmentLens: + "Use the longitudinal pattern to support anticipatory monitoring and relapse planning before the usual onset period.", + wording: "Major depressive disorder, recurrent, with seasonal pattern", + comparison: { + focus: "Recurring season-linked episode timing", + timeCourse: "Demonstrated over repeated annual cycles", + lookFor: "Consistent onset, remission, or polarity change in relation to season", + caution: "One winter episode is not a seasonal pattern", + }, + keywords: ["seasonal", "winter depression", "summer", "spring", "every year", "annual", "season"], + relatedSlugs: ["with-peripartum-onset", "with-rapid-cycling"], + }, + { + slug: "with-peripartum-onset", + name: "With peripartum onset", + shortName: "Peripartum onset", + family: "course-onset", + familyLabel: "Course and onset", + appliesTo: ["Depressive disorders", "Bipolar disorders"], + summary: "A mood episode begins during pregnancy or in the period soon after birth.", + clinicalSignal: + "Timing around pregnancy and birth changes safety assessment, treatment context, and family support needs.", + decisionQuestion: + "Did the mood episode begin in the peripartum window, and are bipolarity, psychosis, and immediate safety actively assessed?", + patientLanguage: [ + "This began during the pregnancy", + "I have not felt like myself since the birth", + "I am frightened by what my mind is doing", + ], + fit: [ + "Episode onset is clearly linked to pregnancy or the early post-birth period.", + "The assessment distinguishes depressive, manic-spectrum, psychotic, anxiety, trauma, and adjustment presentations.", + "Parent, infant, sleep, feeding, support, and safeguarding needs are considered together.", + ], + notFit: [ + "The episode clearly pre-dated pregnancy without a meaningful peripartum onset or recurrence.", + "Symptoms are better explained by transient adjustment alone, while still recognising that distress may require care.", + ], + checks: [ + "Screen urgently for psychosis and mania", + "Assess parent and infant safety", + "Review sleep loss, supports, medicines, and feeding context", + ], + treatmentLens: + "Coordinate timely perinatal mental health care; new psychosis, mania, or severe deterioration requires urgent escalation.", + wording: "Major depressive disorder, single episode, with peripartum onset", + comparison: { + focus: "Mood-episode onset around pregnancy or birth", + timeCourse: "Defined by episode onset, not simply current parenting status", + lookFor: "Onset timing, bipolar indicators, psychosis, sleep loss, safety and support", + caution: "Do not treat all post-birth distress as unipolar depression", + }, + keywords: ["peripartum", "postpartum", "postnatal", "pregnancy", "after birth", "new mother", "new parent"], + relatedSlugs: ["with-psychotic-features", "with-mixed-features"], + }, + { + slug: "with-rapid-cycling", + name: "With rapid cycling", + shortName: "Rapid cycling", + family: "course-onset", + familyLabel: "Course and onset", + appliesTo: ["Bipolar I disorder", "Bipolar II disorder"], + summary: "Multiple distinct mood episodes occur within a year, separated by remission or a switch in polarity.", + clinicalSignal: "Count syndromal episodes across time; do not substitute moment-to-moment emotional lability.", + decisionQuestion: + "Does the longitudinal timeline show repeated distinct episodes rather than rapid shifts within one episode?", + patientLanguage: [ + "I have had several separate episodes this year", + "I recover and then switch again", + "My mood episodes keep returning", + ], + fit: [ + "Distinct depressive, manic, or hypomanic episodes can be identified on a timeline.", + "Episodes are separated by remission or a clear switch in mood polarity.", + "Medication, thyroid, substance, sleep, and reproductive factors have been reviewed.", + ], + notFit: [ + "Mood shifts occur within hours in response to events without distinct syndromal episodes.", + "A single prolonged mixed, unstable, or substance-related episode is being counted as several episodes.", + ], + checks: [ + "Build a 12-month episode timeline", + "Mark recovery and polarity switches", + "Review treatment and biological contributors", + ], + treatmentLens: "Use the episode timeline to guide maintenance strategy and reduce destabilising contributors.", + wording: "Bipolar I disorder, current episode depressed, with rapid cycling", + comparison: { + focus: "Frequency of distinct bipolar mood episodes", + timeCourse: "Counted across a defined 12-month interval", + lookFor: "Syndromal episodes, remission intervals, polarity switches", + caution: "Emotional lability is not the same as rapid cycling", + }, + keywords: [ + "rapid cycling", + "several episodes", + "many episodes", + "four episodes", + "mood switches", + "bipolar course", + "lability", + ], + relatedSlugs: ["with-mixed-features", "with-seasonal-pattern"], + }, + { + slug: "mild-severity", + name: "Mild severity", + shortName: "Mild", + family: "severity-remission", + familyLabel: "Severity and remission", + appliesTo: ["Mood disorders", "Other specified diagnoses"], + summary: "Symptoms meet the disorder threshold with limited excess symptoms, distress, and functional impairment.", + clinicalSignal: "Severity describes the whole episode: symptom burden, intensity, risk, and functional impact.", + decisionQuestion: + "Is the current episode genuinely lower in burden and impairment while still meeting the diagnostic threshold?", + patientLanguage: ["I can still function, but it takes much more effort", "Symptoms are present but contained"], + fit: [ + "The diagnostic threshold is met.", + "Symptoms beyond the minimum are limited and their intensity is manageable.", + "Functional impairment is present but relatively contained.", + ], + notFit: [ + "Risk, psychosis, catatonia, marked impairment, or severe biological disturbance indicates a higher severity level.", + "The person is below diagnostic threshold, in which case remission or another formulation may be more accurate.", + ], + checks: [ + "Separate symptom count from intensity", + "Document functional impact", + "Check risk and protective factors", + ], + treatmentLens: "Match treatment intensity to need while avoiding minimisation of meaningful distress or risk.", + wording: "Major depressive disorder, single episode, mild", + comparison: { + focus: "Current episode burden and impairment", + timeCourse: "Rated for the present episode", + lookFor: "Symptom load, intensity, risk, and function", + caution: "Functioning at work does not automatically mean mild illness", + }, + keywords: ["mild", "severity", "still functioning", "limited impairment", "low severity"], + relatedSlugs: ["in-partial-remission", "in-full-remission"], + }, + { + slug: "in-partial-remission", + name: "In partial remission", + shortName: "Partial remission", + family: "severity-remission", + familyLabel: "Severity and remission", + appliesTo: ["Mood disorders", "Psychotic disorders"], + summary: + "The full syndrome has improved, but meaningful symptoms remain or a sustained symptom-free interval has not yet been established.", + clinicalSignal: "The person is better but not symptom-free, or recovery is too recent to call full remission.", + decisionQuestion: + "Has the episode clearly receded while residual symptoms or insufficient duration prevent full-remission wording?", + patientLanguage: [ + "I am much better, but not back to myself", + "Some symptoms are still there", + "The worst has passed", + ], + fit: [ + "Full criteria are no longer met or there has been substantial improvement.", + "Residual symptoms remain clinically meaningful, or the improved interval is not yet sustained.", + "Function and risk have improved but still require active follow-up.", + ], + notFit: [ + "The person still meets full current-episode criteria.", + "No significant symptoms remain and the recovery interval supports full-remission wording.", + ], + checks: [ + "List residual symptoms explicitly", + "Document change in function and risk", + "Confirm the recovery timeline", + ], + treatmentLens: + "Target residual symptoms and relapse prevention; partial recovery can still carry substantial recurrence risk.", + wording: "Major depressive disorder, recurrent, in partial remission", + comparison: { + focus: "Incomplete recovery from the most recent episode", + timeCourse: "After improvement, before sustained full remission", + lookFor: "Residual symptoms, recent recovery, persistent functional impact", + caution: "Improvement alone does not mean the current episode has ended", + }, + keywords: [ + "partial remission", + "improving", + "better but", + "residual symptoms", + "not fully recovered", + "recovering", + ], + relatedSlugs: ["in-full-remission", "mild-severity"], + }, + { + slug: "in-full-remission", + name: "In full remission", + shortName: "Full remission", + family: "severity-remission", + familyLabel: "Severity and remission", + appliesTo: ["Mood disorders", "Psychotic disorders"], + summary: "No significant signs or symptoms of the recent episode remain across a sustained interval.", + clinicalSignal: "Full remission is a longitudinal conclusion, not a single good day or improvement from baseline.", + decisionQuestion: + "Are clinically significant episode symptoms absent, with recovery sustained long enough to support full-remission wording?", + patientLanguage: ["I feel back to my usual self", "The episode symptoms have gone", "I have been well and stable"], + fit: [ + "Significant signs and symptoms of the recent episode are no longer present.", + "Recovery is sustained rather than a brief fluctuation.", + "Current functioning and risk are consistent with remission, while relapse prevention continues.", + ], + notFit: [ + "Residual symptoms remain clinically meaningful.", + "The person has only recently improved or still meets current-episode criteria.", + ], + checks: [ + "Confirm symptom absence across settings", + "Review duration and function", + "Maintain relapse and early-warning planning", + ], + treatmentLens: + "Continue maintenance and relapse-prevention planning; remission does not imply treatment or monitoring is unnecessary.", + wording: "Major depressive disorder, recurrent, in full remission", + comparison: { + focus: "Sustained absence of significant episode symptoms", + timeCourse: "Established after a durable recovery interval", + lookFor: "Symptom resolution, stable function, sustained recovery", + caution: "Do not infer full remission from partial improvement", + }, + keywords: ["full remission", "recovered", "well now", "symptom free", "stable", "back to normal"], + relatedSlugs: ["in-partial-remission", "mild-severity"], + }, +]; + +export const specifierSearchPresets = [ + { label: "Agitated depression", query: "depressed, racing thoughts and barely sleeping" }, + { label: "Post-birth onset", query: "mood episode began after birth" }, + { label: "Winter recurrence", query: "depression returns every winter and lifts in spring" }, + { label: "Residual symptoms", query: "much better but not fully recovered" }, + { label: "Psychomotor change", query: "stopped speaking and holds the same posture" }, +]; + +export function findSpecifier(slug: string) { + return specifierRecords.find((record) => record.slug === slug); +} + +export function normalizeSpecifierSelection(slugs: string[]) { + const selected: string[] = []; + const conflictsBySlug: Record = { + "mild-severity": ["with-psychotic-features"], + "with-psychotic-features": ["mild-severity"], + }; + + for (const slug of slugs) { + const record = findSpecifier(slug); + if (!record || selected.includes(slug)) continue; + + if (record.family === "severity-remission") { + const retained = selected.filter((selectedSlug) => findSpecifier(selectedSlug)?.family !== "severity-remission"); + selected.splice(0, selected.length, ...retained); + } + + const conflicts = new Set(conflictsBySlug[slug] ?? []); + if (conflicts.size) { + const retained = selected.filter((selectedSlug) => !conflicts.has(selectedSlug)); + selected.splice(0, selected.length, ...retained); + } + + selected.push(slug); + } + + return selected; +} + +function normalizeSearchText(value: string) { + return value + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +const ignoredSearchWords = new Set([ + "a", + "an", + "and", + "are", + "for", + "i", + "in", + "is", + "it", + "of", + "or", + "the", + "to", + "with", +]); + +function searchTokens(query: string) { + return normalizeSearchText(query) + .split(" ") + .filter((token) => token.length > 1 && !ignoredSearchWords.has(token)); +} + +function recordSearchText(record: SpecifierRecord) { + return normalizeSearchText( + [ + record.name, + record.shortName, + record.familyLabel, + ...record.appliesTo, + record.summary, + record.clinicalSignal, + record.decisionQuestion, + ...record.patientLanguage, + ...record.keywords, + ].join(" "), + ); +} + +const diagnosisFiltersByApplicability: Record = { + "Depressive disorders": ["depressive", "mood"], + "Bipolar disorders": ["bipolar", "mood"], + "Major depressive episodes": ["depressive", "bipolar", "mood"], + "Bipolar depressive episodes": ["bipolar", "mood"], + "Mood disorders": ["depressive", "bipolar", "mood"], + "Psychotic disorders": ["psychotic"], + "Medical conditions": [], + "Recurrent depressive disorder": ["depressive", "mood"], + "Bipolar I disorder": ["bipolar", "mood"], + "Bipolar II disorder": ["bipolar", "mood"], + "Other specified diagnoses": [], +}; + +const builderDiagnosesByApplicability: Record = { + "Depressive disorders": ["mdd-recurrent", "mdd-single"], + "Bipolar disorders": ["bipolar-i-depressed", "bipolar-i-manic", "bipolar-ii-depressed"], + "Major depressive episodes": ["mdd-recurrent", "mdd-single", "bipolar-i-depressed", "bipolar-ii-depressed"], + "Bipolar depressive episodes": ["bipolar-i-depressed", "bipolar-ii-depressed"], + "Mood disorders": ["mdd-recurrent", "mdd-single", "bipolar-i-depressed", "bipolar-i-manic", "bipolar-ii-depressed"], + "Psychotic disorders": [], + "Medical conditions": [], + "Recurrent depressive disorder": ["mdd-recurrent"], + "Bipolar I disorder": ["bipolar-i-depressed", "bipolar-i-manic"], + "Bipolar II disorder": ["bipolar-ii-depressed"], + "Other specified diagnoses": [], +}; + +export function specifierAppliesToBuilderDiagnosis(record: SpecifierRecord, diagnosis: SpecifierBuilderDiagnosis) { + return record.appliesTo.some((applicability) => builderDiagnosesByApplicability[applicability]?.includes(diagnosis)); +} + +const knownDiagnosisFilters = new Set(["depressive", "bipolar", "psychotic", "mood"]); + +function matchesDiagnosisFilter(record: SpecifierRecord, diagnosis: string) { + if (!diagnosis) return true; + if (!knownDiagnosisFilters.has(diagnosis)) { + return normalizeSearchText(record.appliesTo.join(" ")).includes(diagnosis); + } + + return record.appliesTo.some((applicability) => diagnosisFiltersByApplicability[applicability]?.includes(diagnosis)); +} + +export function searchSpecifiers( + query: string, + options: { family?: "all" | SpecifierFamily; diagnosis?: string } = {}, +) { + const normalizedQuery = normalizeSearchText(query); + const tokens = searchTokens(query); + const diagnosis = normalizeSearchText(options.diagnosis ?? ""); + + return specifierRecords + .filter((record) => !options.family || options.family === "all" || record.family === options.family) + .filter((record) => matchesDiagnosisFilter(record, diagnosis)) + .map((record, index) => { + const title = normalizeSearchText(`${record.name} ${record.shortName}`); + const keywords = normalizeSearchText(record.keywords.join(" ")); + const haystack = recordSearchText(record); + let score = normalizedQuery ? 0 : specifierRecords.length - index; + + if (normalizedQuery && title.includes(normalizedQuery)) score += 80; + if (normalizedQuery && keywords.includes(normalizedQuery)) score += 55; + for (const token of tokens) { + if (title.includes(token)) score += 18; + if (keywords.includes(token)) score += 10; + if (haystack.includes(token)) score += 3; + } + + return { record, score }; + }) + .filter((result) => !normalizedQuery || result.score > 0) + .sort((left, right) => right.score - left.score || left.record.name.localeCompare(right.record.name)); +} + +export function relatedSpecifiers(record: SpecifierRecord) { + return record.relatedSlugs.map(findSpecifier).filter((item): item is SpecifierRecord => Boolean(item)); +} diff --git a/src/lib/universal-search-domains.ts b/src/lib/universal-search-domains.ts index de154b9ea..4ca4b8089 100644 --- a/src/lib/universal-search-domains.ts +++ b/src/lib/universal-search-domains.ts @@ -4,7 +4,15 @@ // browser bundle. universal-search.ts re-exports both names for server consumers. export type UniversalSearchDomain = - "documents" | "medications" | "services" | "forms" | "differentials" | "presentations" | "specifiers" | "tools"; + | "documents" + | "medications" + | "services" + | "forms" + | "differentials" + | "presentations" + | "specifiers" + | "formulation" + | "tools"; // Canonical order: the default group order in responses AND the topHit tiebreak when // several domains hold a confident (whole-phrase title) match. "presentations" sits @@ -20,5 +28,6 @@ export const universalSearchDomains: UniversalSearchDomain[] = [ "differentials", "presentations", "specifiers", + "formulation", "tools", ]; diff --git a/src/lib/universal-search-mode-context.ts b/src/lib/universal-search-mode-context.ts index 4254e3beb..3a91673e3 100644 --- a/src/lib/universal-search-mode-context.ts +++ b/src/lib/universal-search-mode-context.ts @@ -8,7 +8,8 @@ const preferredDomainsByMode: Record = { forms: "forms", differentials: "differentials", presentations: "differentials", - specifiers: "formulation", + specifiers: "specifiers", + formulation: "formulation", tools: "tools", }; diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts index fe7337579..3eb4ff86c 100644 --- a/src/lib/universal-search.ts +++ b/src/lib/universal-search.ts @@ -19,6 +19,7 @@ import { rowToServiceRecord } from "@/lib/registry-records"; import { fetchOwnerRegistryRowsWithSeed } from "@/lib/registry-seed"; import { rankServiceRecords, serviceRecords, type ServiceRecord } from "@/lib/services"; import { searchFormulationMechanisms } from "@/lib/formulation"; +import { searchSpecifiers as searchPsychiatricSpecifiers } from "@/lib/specifiers"; import { rankToolRecords } from "@/lib/tools-catalog"; import type { ClinicalQueryAnalysis, SearchResult } from "@/lib/types"; import { universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search-domains"; @@ -256,12 +257,12 @@ async function searchToolsDomain(args: ResolvedSearchArgs): Promise { +async function searchFormulationDomain(args: ResolvedSearchArgs): Promise { return searchFormulationMechanisms(args.baseQuery) .slice(0, args.limitPerDomain) .map(({ mechanism, score }) => ({ id: mechanism.id, - kind: "specifiers" as const, + kind: "formulation" as const, title: mechanism.name, subtitle: mechanism.summary, href: `/formulation/${mechanism.id}`, @@ -271,6 +272,21 @@ async function searchSpecifiersDomain(args: ResolvedSearchArgs): Promise { + return searchPsychiatricSpecifiers(args.baseQuery) + .slice(0, args.limitPerDomain) + .map(({ record, score }) => ({ + id: record.slug, + kind: "specifiers" as const, + title: record.name, + subtitle: record.summary, + href: `/specifiers/${record.slug}`, + score, + badge: record.familyLabel, + meta: record.appliesTo.slice(0, 2).join(" · ") || undefined, + })); +} + function searchResultDocumentHref(result: SearchResult) { const metadata = result.source_metadata && typeof result.source_metadata === "object" @@ -371,6 +387,7 @@ const domainAdapters: Record< differentials: { run: searchDifferentialsDomain, timeoutMs: registryDomainTimeoutMs }, presentations: { run: searchPresentationsDomain, timeoutMs: registryDomainTimeoutMs }, specifiers: { run: searchSpecifiersDomain, timeoutMs: registryDomainTimeoutMs }, + formulation: { run: searchFormulationDomain, timeoutMs: registryDomainTimeoutMs }, tools: { run: searchToolsDomain, timeoutMs: registryDomainTimeoutMs }, }; @@ -561,6 +578,8 @@ export function universalSearchViewAllHref(domain: UniversalSearchDomain, query: case "presentations": return `/differentials?q=${encodeURIComponent(query)}&run=1`; case "specifiers": + return `/specifiers?q=${encodeURIComponent(query)}&run=1`; + case "formulation": return `/formulation?q=${encodeURIComponent(query)}&run=1`; case "tools": return `/?mode=tools&q=${encodeURIComponent(query)}&run=1`; diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts index d3fc27bcf..51b5c0002 100644 --- a/tests/app-modes.test.ts +++ b/tests/app-modes.test.ts @@ -12,14 +12,17 @@ import { isSearchableAppMode, visibleAppModeDefinitions, } from "@/lib/app-modes"; -import { universalSearchPreferredDomains } from "@/lib/universal-search-mode-context"; +import { universalSearchModeForDomain, universalSearchPreferredDomains } from "@/lib/universal-search-mode-context"; describe("app mode search contract", () => { it("maps every mode to its preferred universal-search domains", () => { expect(universalSearchPreferredDomains("answer")).toEqual(["documents"]); expect(universalSearchPreferredDomains("prescribing")).toEqual(["medications", "documents"]); expect(universalSearchPreferredDomains("differentials")).toEqual(["differentials", "presentations"]); - expect(universalSearchPreferredDomains("formulation")).toEqual(["specifiers"]); + expect(universalSearchPreferredDomains("specifiers")).toEqual(["specifiers"]); + expect(universalSearchPreferredDomains("formulation")).toEqual(["formulation"]); + expect(universalSearchModeForDomain("specifiers")).toBe("specifiers"); + expect(universalSearchModeForDomain("formulation")).toBe("formulation"); expect(universalSearchPreferredDomains("favourites")).toEqual([]); }); @@ -124,6 +127,18 @@ describe("app mode search contract", () => { expect(config.placeholder.toLowerCase()).toContain("pattern"); }); + it("keeps specifiers separate from formulation as diagnostic wording support", () => { + const config = appModeSearchConfig("specifiers"); + const mode = appModeDefinitions.find((definition) => definition.id === "specifiers"); + + expect(isSearchableAppMode("specifiers")).toBe(true); + expect(mode?.href).toBe("/specifiers"); + expect(config.kind).toBe("specifiers"); + expect(config.resultKind).toBe("specifiers"); + expect(config.placeholder.toLowerCase()).toContain("specifier"); + expect(appModeHomeHref("specifiers")).not.toBe(appModeHomeHref("formulation")); + }); + it("keeps source-library shortcut searches in their active mode family", () => { expect(appModeCanUseSourceLibraryShortcut("answer")).toBe(false); expect(appModeCanUseSourceLibraryShortcut("tools")).toBe(false); @@ -135,6 +150,7 @@ describe("app mode search contract", () => { expect(appModeCanUseSourceLibraryShortcut("favourites")).toBe(false); expect(appModeCanUseSourceLibraryShortcut("prescribing")).toBe(true); expect(appModeCanUseSourceLibraryShortcut("differentials")).toBe(true); + expect(appModeCanUseSourceLibraryShortcut("specifiers")).toBe(false); expect(appModeCanUseSourceLibraryShortcut("formulation")).toBe(false); expect(appModeSourceLibrarySearchMode("documents")).toBe("documents"); @@ -166,6 +182,9 @@ describe("app mode search contract", () => { expect(appModeHomeHref("differentials", { query: " acute confusion ", focus: true })).toBe( "/differentials?q=acute+confusion&focus=1", ); + expect(appModeHomeHref("specifiers", { query: " depressed but racing thoughts ", run: true, focus: true })).toBe( + "/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1", + ); expect(appModeHomeHref("formulation", { query: " I keep going over it ", run: true, focus: true })).toBe( "/formulation?q=I+keep+going+over+it&focus=1&run=1", ); @@ -204,6 +223,7 @@ describe("app mode search contract", () => { "forms", "favourites", "differentials", + "specifiers", "formulation", "prescribing", "tools", @@ -221,6 +241,7 @@ describe("app mode search contract", () => { expect(isAppModeVisible("forms", "production")).toBe(true); expect(isAppModeVisible("favourites", "production")).toBe(true); expect(isAppModeVisible("differentials", "production")).toBe(true); + expect(isAppModeVisible("specifiers", "production")).toBe(true); expect(isAppModeVisible("formulation", "production")).toBe(true); expect(isAppModeVisible("prescribing", "production")).toBe(true); expect(isAppModeVisible("tools", "production")).toBe(true); @@ -229,6 +250,7 @@ describe("app mode search contract", () => { expect(productionModes).toContain("forms"); expect(productionModes).toContain("favourites"); expect(productionModes).toContain("differentials"); + expect(productionModes).toContain("specifiers"); expect(productionModes).toContain("formulation"); expect(productionModes).toContain("prescribing"); expect(productionModes).toContain("tools"); @@ -240,6 +262,7 @@ describe("app mode search contract", () => { "forms", "favourites", "differentials", + "specifiers", "formulation", "prescribing", "tools", diff --git a/tests/specifiers.test.ts b/tests/specifiers.test.ts new file mode 100644 index 000000000..eaee2fac7 --- /dev/null +++ b/tests/specifiers.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + findSpecifier, + normalizeSpecifierSelection, + searchSpecifiers, + specifierAppliesToBuilderDiagnosis, + specifierRecords, +} from "@/lib/specifiers"; + +describe("psychiatric specifier catalogue", () => { + it("keeps slugs unique and related links valid", () => { + const slugs = specifierRecords.map((record) => record.slug); + expect(new Set(slugs).size).toBe(slugs.length); + + for (const record of specifierRecords) { + expect(record.name).toBeTruthy(); + expect(record.summary).toBeTruthy(); + expect(record.fit.length).toBeGreaterThanOrEqual(2); + expect(record.notFit.length).toBeGreaterThanOrEqual(2); + expect(record.wording).toBeTruthy(); + for (const relatedSlug of record.relatedSlugs) { + expect(findSpecifier(relatedSlug)).toBeTruthy(); + } + } + }); + + it("matches natural clinical language to the deciding specifier", () => { + expect(searchSpecifiers("depressed but racing thoughts and barely sleeping")[0]?.record.slug).toBe( + "with-mixed-features", + ); + expect(searchSpecifiers("depression returns every winter and lifts in spring")[0]?.record.slug).toBe( + "with-seasonal-pattern", + ); + expect(searchSpecifiers("stopped speaking and holds the same posture")[0]?.record.slug).toBe("with-catatonia"); + expect(searchSpecifiers("much better but not fully recovered")[0]?.record.slug).toBe("in-partial-remission"); + }); + + it("filters by diagnostic role and diagnosis context", () => { + const courseResults = searchSpecifiers("", { family: "course-onset" }); + expect(courseResults.length).toBeGreaterThan(0); + expect(courseResults.every(({ record }) => record.family === "course-onset")).toBe(true); + + const psychoticResults = searchSpecifiers("", { diagnosis: "psychotic" }); + expect(psychoticResults.map(({ record }) => record.slug)).toContain("with-catatonia"); + + const depressiveResults = searchSpecifiers("mild", { diagnosis: "depressive" }); + expect(depressiveResults.map(({ record }) => record.slug)).toContain("mild-severity"); + + const moodEpisodeResults = searchSpecifiers("", { diagnosis: "mood" }); + expect(moodEpisodeResults.map(({ record }) => record.slug)).toEqual( + expect.arrayContaining(["with-anxious-distress", "with-rapid-cycling", "mild-severity"]), + ); + }); + + it("normalizes seeded builder selections to one severity or remission descriptor", () => { + expect( + normalizeSpecifierSelection([ + "with-anxious-distress", + "in-partial-remission", + "missing-specifier", + "in-full-remission", + "with-anxious-distress", + ]), + ).toEqual(["with-anxious-distress", "in-full-remission"]); + + expect(normalizeSpecifierSelection(["mild-severity", "with-psychotic-features"])).toEqual([ + "with-psychotic-features", + ]); + expect(normalizeSpecifierSelection(["with-psychotic-features", "mild-severity"])).toEqual(["mild-severity"]); + }); + + it("matches builder specifiers to the selected diagnosis and episode", () => { + const rapidCycling = findSpecifier("with-rapid-cycling"); + const melancholic = findSpecifier("with-melancholic-features"); + expect(rapidCycling).toBeDefined(); + expect(melancholic).toBeDefined(); + + expect(specifierAppliesToBuilderDiagnosis(rapidCycling!, "mdd-recurrent")).toBe(false); + expect(specifierAppliesToBuilderDiagnosis(rapidCycling!, "bipolar-i-manic")).toBe(true); + expect(specifierAppliesToBuilderDiagnosis(melancholic!, "bipolar-i-manic")).toBe(false); + expect(specifierAppliesToBuilderDiagnosis(melancholic!, "bipolar-i-depressed")).toBe(true); + }); + + it("does not expose provenance columns in the specifier search records", () => { + for (const record of specifierRecords) { + expect(Object.hasOwn(record, "source")).toBe(false); + expect(Object.hasOwn(record, "sourceStatus")).toBe(false); + } + }); +}); diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts index ca14a5205..03a1fb8e0 100644 --- a/tests/ui-formulation.spec.ts +++ b/tests/ui-formulation.spec.ts @@ -101,7 +101,8 @@ test("keeps mobile search, domain filtering, record actions, and universal chrom await expectNoHorizontalOverflow(page); await page.getByRole("link", { name: "Open Worry" }).click(); - await expect(page.getByRole("heading", { name: "Worry", exact: true })).toBeVisible(); + await expect(page).toHaveURL(/\/formulation\/worry$/, { timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Worry", exact: true })).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("link", { name: "Compare", exact: true }).last()).toBeVisible(); await expect(page.getByRole("link", { name: "Use in formulation", exact: true }).first()).toBeVisible(); await expectNoHorizontalOverflow(page); @@ -146,32 +147,20 @@ test("moves a selected mechanism through framework, quality review, and an edita await expectNoBlockingAxeViolations(page, testInfo); }); -test("keeps legacy specifier detail links on a valid formulation route", async ({ page }) => { +test("keeps specifier and formulation route families clinically separate", async ({ page }) => { await gotoApp(page, "/specifiers/with-anxious-distress"); - await expect(page).toHaveURL(/\/formulation\?q=with\+anxious\+distress&run=1$/); - await expect(page.getByRole("heading", { name: /Mechanisms matching “with anxious distress”/ })).toBeVisible(); + await expect(page).toHaveURL(/\/specifiers\/with-anxious-distress$/); + await expect(page.getByRole("heading", { name: "With anxious distress", exact: true })).toBeVisible(); + await expect(page.getByText("Psychiatric specifier", { exact: true })).toBeVisible(); + await expect(page.getByText(/Mechanisms matching/)).toHaveCount(0); await expect(page.getByText("Page not found", { exact: true })).toHaveCount(0); - await gotoApp(page, "/specifiers/compare?specifier=rumination&specifier=worry"); - await expect(page).toHaveURL(/\/formulation\/compare\?a=rumination&b=worry$/); - await expect(page.getByRole("combobox", { name: "Mechanism A" })).toHaveValue("rumination"); - await expect(page.getByRole("combobox", { name: "Mechanism B" })).toHaveValue("worry"); - - await gotoApp(page, "/specifiers?query=I+keep+going+over+it&run=1"); - await expect(page).toHaveURL(/\/formulation\?q=I\+keep\+going\+over\+it&run=1$/); - await expect(page.getByRole("heading", { name: /Mechanisms matching “I keep going over it”/ })).toBeVisible(); - await expect(page.getByRole("link", { name: "Rumination", exact: true })).toBeVisible(); - - await gotoApp(page, "/specifiers/builder?specifier=with-psychotic-features"); - await expect(page).toHaveURL(/\/formulation\?q=with\+psychotic\+features&run=1$/); - await expect(page.getByRole("heading", { name: /Mechanisms matching “with psychotic features”/ })).toBeVisible(); - - await gotoApp(page, "/specifiers/compare?a=with-anxious-distress&b=with-mixed-features"); - await expect(page).toHaveURL(/\/formulation\?q=with\+anxious\+distress\+versus\+with\+mixed\+features&run=1$/); - await expect( - page.getByRole("heading", { name: /Mechanisms matching “with anxious distress versus with mixed features”/ }), - ).toBeVisible(); + await gotoApp(page, "/formulation/rumination"); + await expect(page).toHaveURL(/\/formulation\/rumination$/); + await expect(page.getByRole("heading", { name: "Rumination", exact: true })).toBeVisible(); + await expect(page.getByText("Formulation mechanism", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("With anxious distress", { exact: true })).toHaveCount(0); }); test("compares supported alternatives and groups mechanisms without implying causation", async ({ page }) => { diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts new file mode 100644 index 000000000..98b0820c5 --- /dev/null +++ b/tests/ui-specifiers.spec.ts @@ -0,0 +1,138 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page, type TestInfo } from "playwright/test"; + +const axeWcagTags = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]; +const axeBlockingImpacts = new Set(["critical", "serious"]); + +async function blockExternalRequests(page: Page) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + !["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname) + ) { + await route.abort("blockedbyclient"); + return; + } + await route.fallback(); + }); +} + +async function gotoApp(page: Page, path: string) { + await page.goto(path, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); +} + +async function expectNoHorizontalOverflow(page: Page) { + const overflow = await page.evaluate( + () => Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(2); +} + +async function expectNoBlockingAxeViolations(page: Page, testInfo: TestInfo) { + const results = await new AxeBuilder({ page }).withTags(axeWcagTags).analyze(); + await testInfo.attach("axe-violations", { + body: JSON.stringify(results.violations, null, 2), + contentType: "application/json", + }); + + const blocking = results.violations + .filter((violation) => axeBlockingImpacts.has(violation.impact ?? "")) + .map( + (violation) => + `${violation.id} (${violation.impact}): ${violation.help} — ${violation.nodes + .map((node) => `${node.target.join(" ")}: ${node.failureSummary ?? "no failure summary"}`) + .join(" | ")}; see ${violation.helpUrl}`, + ); + expect(blocking, "axe found critical/serious WCAG A/AA violations").toEqual([]); +} + +test.beforeEach(async ({ page }) => { + await blockExternalRequests(page); +}); + +test("searches clinical language without provenance fields and carries a result into wording", async ({ + page, +}, testInfo) => { + await gotoApp(page, "/specifiers"); + + await expect(page.getByRole("heading", { name: "Refine the diagnosis with the right specifier" })).toBeVisible(); + await expect(page.getByTestId("specifiers-home")).toBeVisible(); + + const search = page.getByTestId("global-search-input").filter({ visible: true }).first(); + await expect(search).toHaveAccessibleName( + /Search indexed guidelines by question or keyword - Search psychiatric specifiers by presentation or diagnosis/, + ); + await search.fill("depressed but racing thoughts"); + await page.getByRole("button", { name: "Find matching psychiatric specifiers" }).click(); + + await expect(page).toHaveURL(/\/specifiers\?.*q=depressed(?:\+|%20)but(?:\+|%20)racing(?:\+|%20)thoughts.*run=1/); + await expect(page.getByRole("heading", { name: /Matches for “depressed but racing thoughts”/ })).toBeVisible(); + await expect(page.getByRole("link", { name: "With mixed features", exact: true })).toBeVisible(); + await expect(page.getByText("Source status", { exact: true })).toHaveCount(0); + await expect(page.getByText("Source", { exact: true })).toHaveCount(0); + + await page.getByRole("link", { name: "Open With mixed features" }).click(); + await expect(page).toHaveURL(/\/specifiers\/with-mixed-features$/); + await expect(page.getByRole("heading", { name: "With mixed features", exact: true })).toBeVisible(); + await expect(page.getByText("What matters now", { exact: true })).toBeVisible(); + + await page.getByRole("link", { name: "Use in builder", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Build the diagnosis in the right order" })).toBeVisible(); + await expect(page.getByRole("checkbox", { name: /Mixed features/ })).toBeChecked(); + await expect(page.getByText(/with mixed features/i).first()).toBeVisible(); + await expectNoHorizontalOverflow(page); + await expectNoBlockingAxeViolations(page, testInfo); +}); + +test("keeps mobile search, filters, results, and the fixed composer usable", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await gotoApp(page, "/specifiers?q=returns+every+winter&run=1"); + + await expect(page.getByRole("heading", { name: /Matches for “returns every winter”/ })).toBeVisible(); + await expect(page.getByRole("link", { name: "With seasonal pattern", exact: true })).toBeVisible(); + await expect(page.getByRole("combobox", { name: "Filter by diagnosis" })).toBeVisible(); + await expect(page.getByTestId("global-search-input").filter({ visible: true }).first()).toBeVisible(); + await expect(page.getByText("Source status", { exact: true })).toHaveCount(0); + await expect(page.getByText("Source", { exact: true })).toHaveCount(0); + await expectNoHorizontalOverflow(page); + + await page.getByRole("link", { name: "Open With seasonal pattern" }).click(); + await expect(page.getByRole("heading", { name: "With seasonal pattern", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Compare", exact: true }).last()).toBeVisible(); + await expect(page.getByRole("link", { name: "Build wording", exact: true }).last()).toBeVisible(); + await expectNoHorizontalOverflow(page); + await expectNoBlockingAxeViolations(page, testInfo); +}); + +test("keeps the base diagnosis severity-neutral when applying a severity descriptor", async ({ page }) => { + await gotoApp(page, "/specifiers/builder?specifier=mild-severity"); + + await expect(page.getByRole("combobox", { name: "Diagnostic phrase" })).toHaveValue("mdd-recurrent"); + await expect(page.getByText("Major depressive disorder, recurrent, mild", { exact: true })).toBeVisible(); + await expect(page.getByText(/severe, mild|moderate, mild/i)).toHaveCount(0); + + const anxiousDistress = page.getByRole("checkbox", { name: /Anxious distress/ }); + await page.getByText("Anxious distress", { exact: true }).click(); + await expect(anxiousDistress).toBeChecked(); + await expect( + page.getByText("Major depressive disorder, recurrent, with anxious distress, mild", { exact: true }), + ).toBeVisible(); +}); + +test("blocks incompatible specifiers and preserves severe psychotic-features wording", async ({ page }) => { + await gotoApp(page, "/specifiers/builder?specifier=with-rapid-cycling&specifier=with-psychotic-features"); + + const rapidCycling = page.getByRole("checkbox", { name: /Rapid cycling/ }); + await expect(rapidCycling).toBeDisabled(); + await expect(rapidCycling).not.toBeChecked(); + await expect( + page.getByText("Major depressive disorder, recurrent, severe with psychotic features", { exact: true }), + ).toBeVisible(); + + await page.getByRole("combobox", { name: "Diagnostic phrase" }).selectOption("bipolar-i-manic"); + await expect(rapidCycling).toBeEnabled(); + await page.getByText("Rapid cycling", { exact: true }).click(); + await expect(rapidCycling).toBeChecked(); +}); diff --git a/tests/universal-search.test.ts b/tests/universal-search.test.ts index 3e713c271..6e0bb5496 100644 --- a/tests/universal-search.test.ts +++ b/tests/universal-search.test.ts @@ -47,9 +47,25 @@ describe("runUniversalSearch (demo/fixtures path)", () => { limitPerDomain: 5, demo: true, }); - const formulation = formulationResponse.groups.find((group) => group.kind === "specifiers"); + const formulation = formulationResponse.groups.find((group) => group.kind === "formulation"); expect(formulation?.items[0]?.title).toBe("Avoidance"); expect(formulation?.items[0]?.href).toBe("/formulation/avoidance"); + + const specifierResponse = await runUniversalSearch({ + query: "depressed but racing thoughts", + limitPerDomain: 5, + demo: true, + }); + const specifiers = specifierResponse.groups.find((group) => group.kind === "specifiers"); + expect(specifiers?.items[0]?.title).toBe("With mixed features"); + expect(specifiers?.items[0]?.href).toBe("/specifiers/with-mixed-features"); + }); + + it("keeps view-all destinations separate for specifiers and formulation", async () => { + const { universalSearchViewAllHref } = await loadUniversalSearch(); + + expect(universalSearchViewAllHref("specifiers", "mixed features")).toBe("/specifiers?q=mixed%20features&run=1"); + expect(universalSearchViewAllHref("formulation", "rumination")).toBe("/formulation?q=rumination&run=1"); }); it("filters to requested domains only", async () => { @@ -61,9 +77,17 @@ describe("runUniversalSearch (demo/fixtures path)", () => { demo: true, }); expect(response.groups.map((group) => group.kind)).toEqual( - ["documents", "medications", "services", "forms", "differentials", "presentations", "specifiers", "tools"].filter( - (domain) => ["tools", "differentials"].includes(domain), - ), + [ + "documents", + "medications", + "services", + "forms", + "differentials", + "presentations", + "specifiers", + "formulation", + "tools", + ].filter((domain) => ["tools", "differentials"].includes(domain)), ); }); @@ -267,7 +291,7 @@ describe("runUniversalSearch (query intelligence & ranking)", () => { expect(response.contextMode).toBe("documents"); expect(response.preferredDomains).toEqual(["documents"]); expect(response.domainOrder?.[0]).toBe("documents"); - expect(response.topHit?.kind).toBe("specifiers"); + expect(response.topHit?.kind).toBe("formulation"); expect(response.topHit?.title).toBe("Avoidance"); }); @@ -461,6 +485,7 @@ describe("GET /api/search/universal (live public/owner path)", () => { "differentials", "presentations", "specifiers", + "formulation", "tools", ], })); From daf0f7e9da55b09e0babe9e662ab3676843b14ad Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:32:32 +0800 Subject: [PATCH 2/5] test: include specifiers in mode menu navigation --- tests/ui-smoke.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 20b50dabd..8eb330ce4 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2500,6 +2500,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.keyboard.press("ArrowDown"); await expect(appModeMenu.getByRole("menuitemradio", { name: /^Differentials\b/ })).toBeFocused(); await page.keyboard.press("ArrowDown"); + await expect(appModeMenu.getByRole("menuitemradio", { name: /^Specifiers\b/ })).toBeFocused(); + await page.keyboard.press("ArrowDown"); await expect(appModeMenu.getByRole("menuitemradio", { name: /^Formulation\b/ })).toBeFocused(); await page.keyboard.press("ArrowDown"); await expect(appModeMenu.getByRole("menuitemradio", { name: /^Medication\b/ })).toBeFocused(); From 7a77dde96c03fd6e5e18c8391c2f2e00b318d5d2 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:19:37 +0000 Subject: [PATCH 3/5] fix: apply CodeRabbit auto-fixes Fixed 8 file(s) based on 3 unresolved review comments. Co-authored-by: CodeRabbit --- src/app/specifiers/[slug]/page.tsx | 12 ++++++++++ src/app/specifiers/compare/page.tsx | 21 ++++++++++++++++++ src/app/specifiers/map/page.tsx | 8 +++++++ .../specifiers/specifier-builder-page.tsx | 21 +++++++++++++----- .../specifiers/specifier-map-page.tsx | 22 ++++++++++++++++++- .../specifiers/specifiers-home-page.tsx | 14 ++++++------ src/lib/specifiers.ts | 19 +++++++++++++--- tests/ui-specifiers.spec.ts | 20 +++++++++++++++++ 8 files changed, 121 insertions(+), 16 deletions(-) diff --git a/src/app/specifiers/[slug]/page.tsx b/src/app/specifiers/[slug]/page.tsx index e8bb1a5d5..646a7314a 100644 --- a/src/app/specifiers/[slug]/page.tsx +++ b/src/app/specifiers/[slug]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation"; +import type { Metadata } from "next"; import { SpecifierRecordPage } from "@/components/specifiers/specifier-record-page"; import { findSpecifier, specifierRecords } from "@/lib/specifiers"; @@ -7,6 +8,17 @@ export function generateStaticParams() { return specifierRecords.map((record) => ({ slug: record.slug })); } +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params; + const record = findSpecifier(slug); + if (!record) return {}; + + return { + title: record.name, + description: record.summary, + }; +} + export default async function SpecifierDetailRoute({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const record = findSpecifier(slug); diff --git a/src/app/specifiers/compare/page.tsx b/src/app/specifiers/compare/page.tsx index 41fb3905f..9fcf70616 100644 --- a/src/app/specifiers/compare/page.tsx +++ b/src/app/specifiers/compare/page.tsx @@ -1,4 +1,7 @@ +import type { Metadata } from "next"; + import { SpecifierComparePage } from "@/components/specifiers/specifier-compare-page"; +import { findSpecifier } from "@/lib/specifiers"; type CompareRouteProps = { searchParams?: Promise<{ a?: string | string[]; b?: string | string[] }>; @@ -8,6 +11,24 @@ function first(value?: string | string[]) { return Array.isArray(value) ? value[0] : value; } +export async function generateMetadata({ searchParams }: CompareRouteProps): Promise { + const params = searchParams ? await searchParams : {}; + const left = findSpecifier(first(params.a) ?? ""); + const right = findSpecifier(first(params.b) ?? ""); + + if (left && right) { + return { + title: `Compare ${left.shortName} and ${right.shortName}`, + description: `Side-by-side comparison of ${left.name} and ${right.name} specifiers.`, + }; + } + + return { + title: "Compare specifiers", + description: "Compare psychiatric specifiers side by side to understand the deciding features.", + }; +} + export default async function SpecifierCompareRoute({ searchParams }: CompareRouteProps) { const params = searchParams ? await searchParams : {}; return ; diff --git a/src/app/specifiers/map/page.tsx b/src/app/specifiers/map/page.tsx index 0f77de187..50dcf3057 100644 --- a/src/app/specifiers/map/page.tsx +++ b/src/app/specifiers/map/page.tsx @@ -1,9 +1,17 @@ +import type { Metadata } from "next"; + import { SpecifierMapPage } from "@/components/specifiers/specifier-map-page"; type MapRouteProps = { searchParams?: Promise<{ selected?: string | string[] }>; }; +export const metadata: Metadata = { + title: "Specifier map", + description: + "Browse psychiatric specifiers by diagnostic architecture: episode features, course and onset, severity and remission.", +}; + export default async function SpecifierMapRoute({ searchParams }: MapRouteProps) { const params = searchParams ? await searchParams : {}; const selected = Array.isArray(params.selected) ? params.selected[0] : params.selected; diff --git a/src/components/specifiers/specifier-builder-page.tsx b/src/components/specifiers/specifier-builder-page.tsx index d22d0a091..c9af53409 100644 --- a/src/components/specifiers/specifier-builder-page.tsx +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -43,14 +43,25 @@ function wordingSegment(record: SpecifierRecord) { } export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecifiers?: string[] }) { - const initialDiagnosis = diagnosisPresets[0]; - const validInitial = normalizeSpecifierSelection(initialSpecifiers).filter((slug) => { + const normalizedInitial = normalizeSpecifierSelection(initialSpecifiers); + + // Determine the best initial diagnosis from the deep-linked specifiers + const inferredDiagnosis = normalizedInitial.length > 0 + ? diagnosisPresets.find((preset) => + normalizedInitial.every((slug) => { + const record = specifierRecords.find((candidate) => candidate.slug === slug); + return record ? specifierAppliesToBuilderDiagnosis(record, preset.id) : false; + }) + ) ?? diagnosisPresets[0] + : diagnosisPresets[0]; + + const validInitial = normalizedInitial.filter((slug) => { const record = specifierRecords.find((candidate) => candidate.slug === slug); - return record ? specifierAppliesToBuilderDiagnosis(record, initialDiagnosis.id) : false; + return record ? specifierAppliesToBuilderDiagnosis(record, inferredDiagnosis.id) : false; }); - const [diagnosisId, setDiagnosisId] = useState(initialDiagnosis.id); + const [diagnosisId, setDiagnosisId] = useState(inferredDiagnosis.id); const [selected, setSelected] = useState(validInitial); - const diagnosis = diagnosisPresets.find((preset) => preset.id === diagnosisId) ?? initialDiagnosis; + const diagnosis = diagnosisPresets.find((preset) => preset.id === diagnosisId) ?? inferredDiagnosis; const selectedRecords = useMemo( () => selected diff --git a/src/components/specifiers/specifier-map-page.tsx b/src/components/specifiers/specifier-map-page.tsx index b302d12ad..ff28db1f8 100644 --- a/src/components/specifiers/specifier-map-page.tsx +++ b/src/components/specifiers/specifier-map-page.tsx @@ -17,9 +17,29 @@ import { cn, eyebrowText } from "@/components/ui-primitives"; import { findSpecifier, specifierFamilies, specifierRecords } from "@/lib/specifiers"; export function SpecifierMapPage({ initialSlug }: { initialSlug?: string }) { - const [selectedSlug, setSelectedSlug] = useState(findSpecifier(initialSlug ?? "")?.slug ?? specifierRecords[0].slug); + // Derive valid initial slug from props + const validInitialSlug = findSpecifier(initialSlug ?? "")?.slug ?? specifierRecords[0].slug; + + // Track selected slug with derived initial state pattern (useState with function) + const [state, setState] = useState({ selectedSlug: validInitialSlug, lastInitialSlug: initialSlug }); + + // Derive state: if initialSlug prop changed, reset to new initial; otherwise keep current selection + const selectedSlug = + state.lastInitialSlug !== initialSlug + ? validInitialSlug + : state.selectedSlug; + + // Update state if derived slug differs from stored state + if (selectedSlug !== state.selectedSlug || state.lastInitialSlug !== initialSlug) { + setState({ selectedSlug, lastInitialSlug: initialSlug }); + } + const selected = findSpecifier(selectedSlug) ?? specifierRecords[0]; + const setSelectedSlug = (slug: string) => { + setState({ selectedSlug: slug, lastInitialSlug: initialSlug }); + }; + return (
diff --git a/src/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx index b8d9e202e..b358e2ee0 100644 --- a/src/components/specifiers/specifiers-home-page.tsx +++ b/src/components/specifiers/specifiers-home-page.tsx @@ -157,10 +157,10 @@ function EmptySearchResults({ query }: { query: string }) {
-

No strong match for “{query}”

+

No strong match for “{query}”

- Try the episode pattern, timing, patient language, or the base diagnosis. For example: “depressed but racing - thoughts” or “returns every winter”. + Try the episode pattern, timing, patient language, or the base diagnosis. For example: “depressed but racing + thoughts” or “returns every winter”.

Specifier search

- Matches for “{query}” + Matches for “{query}”

- Results prioritise clinical fit, episode timing, and patient language. Open a result to check exclusions and + Results ranked by text relevance: title, keywords, episode timing, and patient language. Open a result to check exclusions and wording.

@@ -268,7 +268,7 @@ function SpecifierResults({ query }: { query: string }) { {index === 0 ? ( - Best fit + Top match ) : null} @@ -310,7 +310,7 @@ function SpecifierResults({ query }: { query: string }) {

Typical language

- “{record.patientLanguage[0]}” + “{record.patientLanguage[0]}”

diff --git a/src/lib/specifiers.ts b/src/lib/specifiers.ts index bd8d304a0..a9bfc0c7e 100644 --- a/src/lib/specifiers.ts +++ b/src/lib/specifiers.ts @@ -3,13 +3,26 @@ export type SpecifierFamily = "episode-features" | "course-onset" | "severity-re export type SpecifierBuilderDiagnosis = "mdd-recurrent" | "mdd-single" | "bipolar-i-depressed" | "bipolar-i-manic" | "bipolar-ii-depressed"; +export type SpecifierApplicability = + | "Depressive disorders" + | "Bipolar disorders" + | "Major depressive episodes" + | "Bipolar depressive episodes" + | "Mood disorders" + | "Psychotic disorders" + | "Medical conditions" + | "Recurrent depressive disorder" + | "Bipolar I disorder" + | "Bipolar II disorder" + | "Other specified diagnoses"; + export type SpecifierRecord = { slug: string; name: string; shortName: string; family: SpecifierFamily; familyLabel: string; - appliesTo: string[]; + appliesTo: SpecifierApplicability[]; summary: string; clinicalSignal: string; decisionQuestion: string; @@ -632,7 +645,7 @@ function recordSearchText(record: SpecifierRecord) { ); } -const diagnosisFiltersByApplicability: Record = { +const diagnosisFiltersByApplicability: Record = { "Depressive disorders": ["depressive", "mood"], "Bipolar disorders": ["bipolar", "mood"], "Major depressive episodes": ["depressive", "bipolar", "mood"], @@ -646,7 +659,7 @@ const diagnosisFiltersByApplicability: Record = { "Other specified diagnoses": [], }; -const builderDiagnosesByApplicability: Record = { +const builderDiagnosesByApplicability: Record = { "Depressive disorders": ["mdd-recurrent", "mdd-single"], "Bipolar disorders": ["bipolar-i-depressed", "bipolar-i-manic", "bipolar-ii-depressed"], "Major depressive episodes": ["mdd-recurrent", "mdd-single", "bipolar-i-depressed", "bipolar-ii-depressed"], diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index 98b0820c5..544daa526 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -136,3 +136,23 @@ test("blocks incompatible specifiers and preserves severe psychotic-features wor await page.getByText("Rapid cycling", { exact: true }).click(); await expect(rapidCycling).toBeChecked(); }); + +test("infers the correct diagnosis from a non-MDD deep link", async ({ page }) => { + await gotoApp(page, "/specifiers/builder?specifier=with-rapid-cycling"); + + const rapidCycling = page.getByRole("checkbox", { name: /Rapid cycling/ }); + await expect(rapidCycling).toBeChecked(); + await expect(page.getByRole("combobox", { name: "Diagnostic phrase" })).toHaveValue("bipolar-i-depressed"); + await expect( + page.getByText("Bipolar I disorder, current episode depressed, with rapid cycling", { exact: true }), + ).toBeVisible(); +}); + +test("labels search results using text-relevance language instead of clinical-fit language", async ({ page }) => { + await gotoApp(page, "/specifiers?q=racing+thoughts&run=1"); + + await expect(page.getByRole("heading", { name: /Matches for "racing thoughts"/ })).toBeVisible(); + await expect(page.getByText("Top match", { exact: true })).toBeVisible(); + await expect(page.getByText(/Results ranked by text relevance/i)).toBeVisible(); + await expect(page.getByText(/clinical fit/i)).toHaveCount(0); +}); From 3ed63d4747696f8047a4c0b1e2605422c5996588 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 17:35:40 +0000 Subject: [PATCH 4/5] fix(specifiers): repair formatting and wire the Specifiers command surface - format specifier builder/map/home components so `format:check` (Static PR checks) passes again - add a `specifiers` search-command-surface config so the Specifiers mode composer renders smart examples, typeahead, and cross-mode chips instead of falling back to raw children - wire the existing Specifiers and Formulation mode action sets into the universal command surface so their quick actions render in each mode's composer - cover the Specifiers command-surface config with a targeted unit test Keeps Specifiers and Formulation as two fully separate modes, each connected to its own routes, search domain, and quick actions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C2VkQ6g1tYpMXCfpzKKkav --- .../universal-search-command-surface.tsx | 6 +++++- .../specifiers/specifier-builder-page.tsx | 17 +++++++++-------- .../specifiers/specifier-map-page.tsx | 5 +---- .../specifiers/specifiers-home-page.tsx | 12 +++++++----- src/lib/search-command-surface.ts | 10 ++++++++++ tests/search-command-surface.test.ts | 5 +++++ 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 85b07b993..34a5a1517 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -732,7 +732,11 @@ export function UniversalSearchCommandSurface({ ? "answer" : modeId === "tools" ? "tools" - : null; + : modeId === "specifiers" + ? "specifiers" + : modeId === "formulation" + ? "formulation" + : null; if (actionSetId) { const actions = modeActionItemsFor(actionSetId).slice(0, 3); diff --git a/src/components/specifiers/specifier-builder-page.tsx b/src/components/specifiers/specifier-builder-page.tsx index c9af53409..d73b5675e 100644 --- a/src/components/specifiers/specifier-builder-page.tsx +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -46,14 +46,15 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif const normalizedInitial = normalizeSpecifierSelection(initialSpecifiers); // Determine the best initial diagnosis from the deep-linked specifiers - const inferredDiagnosis = normalizedInitial.length > 0 - ? diagnosisPresets.find((preset) => - normalizedInitial.every((slug) => { - const record = specifierRecords.find((candidate) => candidate.slug === slug); - return record ? specifierAppliesToBuilderDiagnosis(record, preset.id) : false; - }) - ) ?? diagnosisPresets[0] - : diagnosisPresets[0]; + const inferredDiagnosis = + normalizedInitial.length > 0 + ? (diagnosisPresets.find((preset) => + normalizedInitial.every((slug) => { + const record = specifierRecords.find((candidate) => candidate.slug === slug); + return record ? specifierAppliesToBuilderDiagnosis(record, preset.id) : false; + }), + ) ?? diagnosisPresets[0]) + : diagnosisPresets[0]; const validInitial = normalizedInitial.filter((slug) => { const record = specifierRecords.find((candidate) => candidate.slug === slug); diff --git a/src/components/specifiers/specifier-map-page.tsx b/src/components/specifiers/specifier-map-page.tsx index ff28db1f8..b01f95440 100644 --- a/src/components/specifiers/specifier-map-page.tsx +++ b/src/components/specifiers/specifier-map-page.tsx @@ -24,10 +24,7 @@ export function SpecifierMapPage({ initialSlug }: { initialSlug?: string }) { const [state, setState] = useState({ selectedSlug: validInitialSlug, lastInitialSlug: initialSlug }); // Derive state: if initialSlug prop changed, reset to new initial; otherwise keep current selection - const selectedSlug = - state.lastInitialSlug !== initialSlug - ? validInitialSlug - : state.selectedSlug; + const selectedSlug = state.lastInitialSlug !== initialSlug ? validInitialSlug : state.selectedSlug; // Update state if derived slug differs from stored state if (selectedSlug !== state.selectedSlug || state.lastInitialSlug !== initialSlug) { diff --git a/src/components/specifiers/specifiers-home-page.tsx b/src/components/specifiers/specifiers-home-page.tsx index b358e2ee0..5423d5863 100644 --- a/src/components/specifiers/specifiers-home-page.tsx +++ b/src/components/specifiers/specifiers-home-page.tsx @@ -157,10 +157,12 @@ function EmptySearchResults({ query }: { query: string }) {
-

No strong match for “{query}”

+

+ No strong match for “{query}” +

- Try the episode pattern, timing, patient language, or the base diagnosis. For example: “depressed but racing - thoughts” or “returns every winter”. + Try the episode pattern, timing, patient language, or the base diagnosis. For example: “depressed but + racing thoughts” or “returns every winter”.

- Results ranked by text relevance: title, keywords, episode timing, and patient language. Open a result to check exclusions and - wording. + Results ranked by text relevance: title, keywords, episode timing, and patient language. Open a result to + check exclusions and wording.

diff --git a/src/lib/search-command-surface.ts b/src/lib/search-command-surface.ts index 5b4bd5bb7..0b4b81792 100644 --- a/src/lib/search-command-surface.ts +++ b/src/lib/search-command-surface.ts @@ -129,6 +129,16 @@ const searchCommandSurfaceByMode: Partial { expect(searchCommandSurfaceConfig("tools")?.examples.length).toBeGreaterThan(0); expect(searchCommandSurfaceConfig("formulation")?.crossModes).toContain("differentials"); + + const specifiers = searchCommandSurfaceConfig("specifiers"); + expect(specifiers?.examples.length).toBeGreaterThan(0); + expect(specifiers?.suggestions.length).toBeGreaterThan(0); + expect(specifiers?.crossModes).toContain("formulation"); }); it("detects form code queries", () => { From 059398c962711f57e5f9ee7c6b4783066cd6718f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 17:54:03 +0000 Subject: [PATCH 5/5] test(specifiers): align UI specs with the separated Specifiers mode The UI regression job was red on three stale/incorrect specifier assertions: - ui-smoke: the legacy test asserted `?mode=specifiers` redirects to `/formulation`. Specifiers is now its own mode, so it redirects to `/specifiers`; update the expectation to the standalone route and heading. - ui-specifiers "text-relevance" test used straight quotes in the results heading, but the page renders curly quotes (“/”); match them. - ui-specifiers builder test expected the pre-fix behaviour where a mixed deep link fell back to hard-coded MDD and disabled rapid cycling. The merged diagnosis-inference fix now infers a compatible bipolar base instead, so the deep link is scoped to the psychotic-features specifier that legitimately keeps the recurrent-MDD base and blocks the bipolar-only specifier. Verified locally against the running app: all 6 ui-specifiers tests and both mode-param redirect smoke tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C2VkQ6g1tYpMXCfpzKKkav --- tests/ui-smoke.spec.ts | 8 ++++---- tests/ui-specifiers.spec.ts | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 923ce8ed0..c7f158ab6 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2365,14 +2365,14 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); - test("dashboard specifiers mode param redirects through legacy route to formulation", async ({ page }) => { + test("dashboard specifiers mode param redirects to the standalone specifiers route", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); await gotoApp(page, "/?mode=specifiers&q=anxious+distress&focus=1&run=1"); - // /?mode=specifiers → /specifiers → compatibility redirect to /formulation - await expect(page).toHaveURL(/\/formulation\?q=anxious\+distress&focus=1&run=1$/); - await expect(page.getByRole("heading", { level: 1, name: "Mechanisms matching “anxious distress”" })).toBeVisible(); + // /?mode=specifiers → /specifiers (Specifiers is its own mode, distinct from Formulation) + await expect(page).toHaveURL(/\/specifiers\?q=anxious\+distress&focus=1&run=1$/); + await expect(page.getByRole("heading", { level: 1, name: "Matches for “anxious distress”" })).toBeVisible(); }); test("dashboard formulation mode param redirects to the standalone formulation route", async ({ page }) => { diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index 544daa526..b9308d936 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -122,7 +122,9 @@ test("keeps the base diagnosis severity-neutral when applying a severity descrip }); test("blocks incompatible specifiers and preserves severe psychotic-features wording", async ({ page }) => { - await gotoApp(page, "/specifiers/builder?specifier=with-rapid-cycling&specifier=with-psychotic-features"); + // Psychotic features applies to MDD, so the deep link stays on the recurrent-MDD base. + // Rapid cycling is bipolar-only, so it must remain blocked until a bipolar base is chosen. + await gotoApp(page, "/specifiers/builder?specifier=with-psychotic-features"); const rapidCycling = page.getByRole("checkbox", { name: /Rapid cycling/ }); await expect(rapidCycling).toBeDisabled(); @@ -151,7 +153,7 @@ test("infers the correct diagnosis from a non-MDD deep link", async ({ page }) = test("labels search results using text-relevance language instead of clinical-fit language", async ({ page }) => { await gotoApp(page, "/specifiers?q=racing+thoughts&run=1"); - await expect(page.getByRole("heading", { name: /Matches for "racing thoughts"/ })).toBeVisible(); + await expect(page.getByRole("heading", { name: /Matches for “racing thoughts”/ })).toBeVisible(); await expect(page.getByText("Top match", { exact: true })).toBeVisible(); await expect(page.getByText(/Results ranked by text relevance/i)).toBeVisible(); await expect(page.getByText(/clinical fit/i)).toHaveCount(0);