diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 99a081e71..03f8ed843 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -490,3 +490,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 fa16c3d02..8f445db79 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -26,7 +26,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 @@ -54,7 +57,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | 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`. | | DSM-5 Diagnosis | `/dsm` | `/dsm/search?q=major+depressive+disorder&focus=1&run=1` | `/dsm/diagnoses/[slug]`, `/dsm/compare`, and `/dsm/diagnoses/[slug]/differentials`. | -| Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | Compatibility entry that redirects through `/specifiers` into the Formulation workspace. | +| 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`). | @@ -300,6 +303,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. @@ -791,7 +810,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Favourites | `src/app/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx` | | Differentials | `src/app/differentials, src/lib/differentials.ts` | | DSM-5 Diagnosis | `src/app/dsm, src/components/dsm, src/lib/dsm.ts` | -| Specifiers (legacy redirect) | `src/app/specifiers/[[...path]]/page.tsx` | +| 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 07a7e8dda..510ff32fc 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -10,6 +10,7 @@ import { dsmDiagnoses } from "@/lib/dsm"; 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"); @@ -63,7 +64,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 = { @@ -104,7 +109,7 @@ const routeOwnershipRows = [ ["Favourites", "src/app/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx"], ["Differentials", "src/app/differentials, src/lib/differentials.ts"], ["DSM-5 Diagnosis", "src/app/dsm, src/components/dsm, src/lib/dsm.ts"], - ["Specifiers (legacy redirect)", "src/app/specifiers/[[...path]]/page.tsx"], + ["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"], @@ -293,7 +298,7 @@ function renderModePageIndex() { mode: "Specifiers", home: appModeHomeHref("specifiers"), search: appModeHomeHref("specifiers", { query: "depressed but racing thoughts", focus: true, run: true }), - detail: "Compatibility entry that redirects through `/specifiers` into the Formulation workspace.", + detail: "`/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`.", }, { mode: "Formulation", @@ -349,6 +354,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { "/services/[slug]", "/forms/[slug]", "/differentials/diagnoses/[slug]", + "/specifiers/[slug]", "/dsm/diagnoses/[slug]", "/dsm/diagnoses/[slug]/differentials", "/formulation/[slug]", @@ -389,6 +395,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/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..646a7314a --- /dev/null +++ b/src/app/specifiers/[slug]/page.tsx @@ -0,0 +1,28 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; + +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 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); + 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..9fcf70616 --- /dev/null +++ b/src/app/specifiers/compare/page.tsx @@ -0,0 +1,35 @@ +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[] }>; +}; + +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/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..50dcf3057 --- /dev/null +++ b/src/app/specifiers/map/page.tsx @@ -0,0 +1,19 @@ +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; + 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/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 144b416f5..9c695da86 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -238,7 +238,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. diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 287b2abcc..8f9537929 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" || selectedSearch.kind === "dsm"; const canAsk = trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch); @@ -631,15 +632,15 @@ export function MasterSearchHeader({ return; } if (actionId === "specifiers-builder") { - window.location.assign("/formulation/builder"); + window.location.assign("/specifiers/builder"); return; } if (actionId === "specifiers-compare") { - window.location.assign("/formulation/compare"); + window.location.assign("/specifiers/compare"); return; } if (actionId === "specifiers-map") { - window.location.assign("/formulation/map"); + window.location.assign("/specifiers/map"); return; } if (actionId === "formulation-search") { diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 864338d2d..62faf1be9 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -35,8 +35,8 @@ import { Search, ShieldCheck, Sparkles, - Table2, Tags, + Table2, UploadCloud, Waypoints, Wrench, diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 0f015e723..34a5a1517 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -43,7 +43,8 @@ const domainsByTargetMode: Partial> = forms: ["forms"], differentials: ["differentials", "presentations"], dsm: ["dsm"], - formulation: ["specifiers"], + specifiers: ["specifiers"], + formulation: ["formulation"], tools: ["tools"], }; @@ -55,7 +56,8 @@ const domainHeadings: Record = { differentials: "Differentials", presentations: "Presentations", dsm: "DSM-5 Diagnoses", - specifiers: "Formulation", + specifiers: "Specifiers", + formulation: "Formulation", tools: "Tools", }; @@ -730,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 new file mode 100644 index 000000000..d73b5675e --- /dev/null +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -0,0 +1,276 @@ +"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 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, inferredDiagnosis.id) : false; + }); + const [diagnosisId, setDiagnosisId] = useState(inferredDiagnosis.id); + const [selected, setSelected] = useState(validInitial); + const diagnosis = diagnosisPresets.find((preset) => preset.id === diagnosisId) ?? inferredDiagnosis; + 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..b01f95440 --- /dev/null +++ b/src/components/specifiers/specifier-map-page.tsx @@ -0,0 +1,186 @@ +"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 }) { + // 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 ( + +
+ + +
+ +
+

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..5423d5863 --- /dev/null +++ b/src/components/specifiers/specifiers-home-page.tsx @@ -0,0 +1,333 @@ +"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 ranked by text relevance: title, keywords, 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 ? ( + + + Top match + + ) : 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 c9b47877f..5cfe91739 100644 --- a/src/lib/app-mode-icons.ts +++ b/src/lib/app-mode-icons.ts @@ -7,8 +7,8 @@ import { Network, Pill, Route, - Sparkles, Tags, + Sparkles, Wrench, type LucideIcon, } from "lucide-react"; 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 = [ + { 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 4c901983d..e47f53e45 100644 --- a/src/lib/universal-search-domains.ts +++ b/src/lib/universal-search-domains.ts @@ -12,6 +12,7 @@ export type UniversalSearchDomain = | "presentations" | "dsm" | "specifiers" + | "formulation" | "tools"; // Canonical order: the default group order in responses AND the topHit tiebreak when @@ -32,5 +33,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 6c47bff26..21666e873 100644 --- a/src/lib/universal-search-mode-context.ts +++ b/src/lib/universal-search-mode-context.ts @@ -9,10 +9,8 @@ const preferredDomainsByMode: Record = { forms: "forms", differentials: "differentials", presentations: "differentials", + specifiers: "specifiers", + formulation: "formulation", dsm: "dsm", - // Specifier-domain hits land in Formulation (canonical workspace). - specifiers: "formulation", tools: "tools", }; diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts index a7c78d813..a87960a8b 100644 --- a/src/lib/universal-search.ts +++ b/src/lib/universal-search.ts @@ -20,6 +20,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"; @@ -273,12 +274,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}`, @@ -288,6 +289,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" @@ -389,6 +405,7 @@ const domainAdapters: Record< presentations: { run: searchPresentationsDomain, timeoutMs: registryDomainTimeoutMs }, dsm: { run: searchDsmDomain, timeoutMs: registryDomainTimeoutMs }, specifiers: { run: searchSpecifiersDomain, timeoutMs: registryDomainTimeoutMs }, + formulation: { run: searchFormulationDomain, timeoutMs: registryDomainTimeoutMs }, tools: { run: searchToolsDomain, timeoutMs: registryDomainTimeoutMs }, }; @@ -581,6 +598,8 @@ export function universalSearchViewAllHref(domain: UniversalSearchDomain, query: case "dsm": return `/dsm/search?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 fad1046fa..327cf3f5e 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,7 +127,7 @@ describe("app mode search contract", () => { expect(config.placeholder.toLowerCase()).toContain("pattern"); }); - it("keeps specifiers searchable as a standalone local decision-support mode", () => { + it("keeps specifiers separate from formulation as diagnostic wording support", () => { const config = appModeSearchConfig("specifiers"); const mode = appModeDefinitions.find((definition) => definition.id === "specifiers"); @@ -133,6 +136,7 @@ describe("app mode search contract", () => { expect(config.kind).toBe("specifiers"); expect(config.resultKind).toBe("specifiers"); expect(config.placeholder.toLowerCase()).toContain("specifier"); + expect(appModeHomeHref("specifiers")).not.toBe(appModeHomeHref("formulation")); }); it("routes DSM searches to the dedicated local catalogue", () => { @@ -158,8 +162,8 @@ describe("app mode search contract", () => { expect(appModeCanUseSourceLibraryShortcut("favourites")).toBe(false); expect(appModeCanUseSourceLibraryShortcut("prescribing")).toBe(true); expect(appModeCanUseSourceLibraryShortcut("differentials")).toBe(true); - expect(appModeCanUseSourceLibraryShortcut("formulation")).toBe(false); expect(appModeCanUseSourceLibraryShortcut("specifiers")).toBe(false); + expect(appModeCanUseSourceLibraryShortcut("formulation")).toBe(false); expect(appModeSourceLibrarySearchMode("documents")).toBe("documents"); expect(appModeSourceLibrarySearchMode("services")).toBe("documents"); @@ -190,6 +194,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", ); diff --git a/tests/search-command-surface.test.ts b/tests/search-command-surface.test.ts index 71cc15dba..9615d0589 100644 --- a/tests/search-command-surface.test.ts +++ b/tests/search-command-surface.test.ts @@ -35,6 +35,11 @@ describe("search command surface", () => { 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", () => { 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-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 new file mode 100644 index 000000000..b9308d936 --- /dev/null +++ b/tests/ui-specifiers.spec.ts @@ -0,0 +1,160 @@ +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 }) => { + // 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(); + 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(); +}); + +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); +}); diff --git a/tests/universal-search.test.ts b/tests/universal-search.test.ts index 22c02caac..897f49994 100644 --- a/tests/universal-search.test.ts +++ b/tests/universal-search.test.ts @@ -52,9 +52,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 () => { @@ -75,6 +91,7 @@ describe("runUniversalSearch (demo/fixtures path)", () => { "presentations", "dsm", "specifiers", + "formulation", "tools", ].filter((domain) => ["tools", "differentials"].includes(domain)), ); @@ -280,7 +297,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"); }); @@ -475,6 +492,7 @@ describe("GET /api/search/universal (live public/owner path)", () => { "presentations", "dsm", "specifiers", + "formulation", "tools", ], }));