From c3e7024e15d2157bbc0b989276324cc6ad3eea5c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:16:00 +0800 Subject: [PATCH 1/7] feat(specifiers): add diagnostic decision-support workspace Integrate Specifiers with the shared Clinical Guide shell, add local natural-language search and structured comparison/builder/map workflows, and cover desktop/mobile accessibility without source provenance fields. --- docs/codebase-index.md | 44 +- docs/site-map.md | 23 + playwright.config.ts | 4 +- scripts/generate-site-map.ts | 21 + src/app/specifiers/[slug]/page.tsx | 16 + src/app/specifiers/builder/page.tsx | 16 + src/app/specifiers/compare/page.tsx | 14 + src/app/specifiers/layout.tsx | 11 + src/app/specifiers/map/page.tsx | 11 + src/app/specifiers/page.tsx | 17 + .../clinical-dashboard/ClinicalSidebar.tsx | 2 + .../global-search-shell.tsx | 4 + .../master-search-header.tsx | 27 +- .../clinical-dashboard/mode-action-popup.tsx | 41 +- .../specifiers/specifier-builder-page.tsx | 233 +++++++ .../specifiers/specifier-compare-page.tsx | 238 +++++++ .../specifiers/specifier-map-page.tsx | 169 +++++ .../specifiers/specifier-record-page.tsx | 251 +++++++ src/components/specifiers/specifier-ui.tsx | 130 ++++ .../specifiers/specifiers-home-page.tsx | 331 +++++++++ src/lib/app-mode-icons.ts | 2 + src/lib/app-modes.ts | 39 +- src/lib/specifiers.ts | 637 ++++++++++++++++++ tests/app-modes.test.ts | 19 + tests/specifiers.test.ts | 48 ++ tests/ui-specifiers.spec.ts | 107 +++ 26 files changed, 2422 insertions(+), 33 deletions(-) create mode 100644 src/app/specifiers/[slug]/page.tsx create mode 100644 src/app/specifiers/builder/page.tsx create mode 100644 src/app/specifiers/compare/page.tsx create mode 100644 src/app/specifiers/layout.tsx create mode 100644 src/app/specifiers/map/page.tsx create mode 100644 src/app/specifiers/page.tsx create mode 100644 src/components/specifiers/specifier-builder-page.tsx create mode 100644 src/components/specifiers/specifier-compare-page.tsx create mode 100644 src/components/specifiers/specifier-map-page.tsx create mode 100644 src/components/specifiers/specifier-record-page.tsx create mode 100644 src/components/specifiers/specifier-ui.tsx create mode 100644 src/components/specifiers/specifiers-home-page.tsx create mode 100644 src/lib/specifiers.ts create mode 100644 tests/specifiers.test.ts create mode 100644 tests/ui-specifiers.spec.ts diff --git a/docs/codebase-index.md b/docs/codebase-index.md index a1c1ffe3c..3f4a4dcc8 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -44,22 +44,23 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map - **App shell:** `src/components/clinical-dashboard/global-search-shell.tsx` — canonical route-aware shell and lazy dashboard dispatch. The mockup-named module is a compatibility re-export used only below `/mockups`. - **Home:** `src/app/page.tsx` — dashboard rendered by shell - **Dashboard:** `src/components/ClinicalDashboard.tsx` + `src/components/clinical-dashboard/` -- **Modes (8):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, prescribing, tools +- **Modes (9):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, specifiers, prescribing, tools ### Product pages (`src/app/`) -| Route | File | -| ---------------------------------------------------- | -------------------------------------- | -| `/` | `src/app/page.tsx` | -| `/applications` | `src/app/applications/page.tsx` | -| `/differentials`, `/diagnoses`, `/presentations` | `src/app/differentials/` | -| `/documents/search`, `/source`, `/evidence`, `/[id]` | `src/app/documents/` | -| `/favourites` | `src/app/favourites/page.tsx` | -| `/forms`, `/forms/[slug]` | `src/app/forms/` | -| `/medications`, `/medications/[slug]` | `src/app/medications/` | -| `/services`, `/services/[slug]` | `src/app/services/` | -| `/mockups/*` | `src/app/mockups/` (404 in production) | -| `/auth/callback` | `src/app/auth/callback/route.ts` | +| Route | File | +| ------------------------------------------------------------------- | -------------------------------------- | +| `/` | `src/app/page.tsx` | +| `/applications` | `src/app/applications/page.tsx` | +| `/differentials`, `/diagnoses`, `/presentations` | `src/app/differentials/` | +| `/documents/search`, `/source`, `/evidence`, `/[id]` | `src/app/documents/` | +| `/favourites` | `src/app/favourites/page.tsx` | +| `/forms`, `/forms/[slug]` | `src/app/forms/` | +| `/medications`, `/medications/[slug]` | `src/app/medications/` | +| `/services`, `/services/[slug]` | `src/app/services/` | +| `/specifiers`, `/specifiers/[slug]`, `/builder`, `/compare`, `/map` | `src/app/specifiers/` | +| `/mockups/*` | `src/app/mockups/` (404 in production) | +| `/auth/callback` | `src/app/auth/callback/route.ts` | ### API routes (`src/app/api/`) @@ -122,10 +123,11 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### Clinical product data -| Module | Role | -| -------------------------------------------------------------------- | ------------------------- | -| `differentials.ts`, `forms.ts`, `services.ts`, `registry-records.ts` | Registry-backed content | -| `clinical-safety.ts`, `demo-data.ts`, `ui-copy.ts` | Safety copy and demo mode | +| Module | Role | +| -------------------------------------------------------------------- | ------------------------------------- | +| `differentials.ts`, `forms.ts`, `services.ts`, `registry-records.ts` | Registry-backed content | +| `specifiers.ts` | Local psychiatric specifier catalogue | +| `clinical-safety.ts`, `demo-data.ts`, `ui-copy.ts` | Safety copy and demo mode | ### Infra helpers @@ -247,19 +249,19 @@ Golden retrieval fixture: `scripts/fixtures/rag-retrieval-golden.json` ### Clinical KB surface -- 8 app modes with unified search shell +- 9 app modes with unified search shell - Documents mode: upload/manage private guidelines, search, cited answers - Answer mode: grounded Q&A with PDF-linked citations -- Registry modes: services, forms, medications, differentials +- Registry modes: services, forms, medications, differentials; Specifiers is a local structured decision-support catalogue - Demo mode: synthetic data when Supabase unavailable (`demo-data.ts`, `isDemoMode()` in `env.ts`) ### Global search composer placement rules One shared composer (`master-search-header.tsx`) serves every mode. Placement: -- **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/applications`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. +- **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/specifiers`, `/applications`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. - **Result and detail views**: fixed bottom dock on phone (compact variant on submitted searches), sticky top from `sm` up. -- **Results routing**: standalone routes own their submitted searches via `?q=…&run=1` (`/services` → `ServicesNavigatorPage`, `/forms` → `FormsSearchResultsPage`, `/differentials` → `DifferentialsHome` results view, `/favourites` filters the command library in place). Answer, Documents, and Prescribing submitted searches render inside `ClinicalDashboard` — intentional, since they need retrieval/answer state. `/?mode=favourites` redirects to `/favourites`; `/?mode=differentials` redirects to `/differentials`. +- **Results routing**: standalone routes own their submitted searches via `?q=…&run=1` (`/services` → `ServicesNavigatorPage`, `/forms` → `FormsSearchResultsPage`, `/differentials` → `DifferentialsHome` results view, `/specifiers` → local structured results, `/favourites` filters the command library in place). Answer, Documents, and Prescribing submitted searches render inside `ClinicalDashboard` — intentional, since they need retrieval/answer state. `/?mode=favourites` redirects to `/favourites`; `/?mode=differentials` redirects to `/differentials`. - **Intentionally composer-free routes**: `/differentials/presentations/*` (comparison workflow owns its chrome), `/documents/[id]` viewer (has its own in-document ask composer), `/documents/source/*` (document flow owns mobile chrome). Do not re-flag these in search-consistency audits. - **Local filter fields** (sidebar "Search chats", document drawer "Find a document"/"Find a source PDF") are scoped filters, not global search; they share the `fieldControlWithIcon`/`fieldIcon` primitives. diff --git a/docs/site-map.md b/docs/site-map.md index 2dfa3b243..90a90483f 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -19,6 +19,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` - 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 @@ -29,6 +33,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/forms` - Forms mode. Search kind: `forms`. Query example: `/forms?q=transport+forms&focus=1&run=1`. - `/favourites` - Favourites mode. Search kind: `favourites`. Query example: `/favourites?q=clozapine+set&focus=1&run=1`. - `/differentials` - Differentials mode. Search kind: `differentials`. Query example: `/differentials?q=acute+confusion&focus=1&run=1`. +- `/specifiers` - Specifiers mode. Search kind: `specifiers`. Query example: `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1`. - `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. @@ -42,6 +47,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | | Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | | Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | +| Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | | 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`). | @@ -499,6 +505,22 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `very-late-onset-schizophrenia-like-psychosis-delusional-disorder` - `wernicke-encephalopathy` +### 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` + ### Medication slugs - `/medications/[slug]` - Dynamic route family. @@ -609,6 +631,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Forms | `src/app/forms, src/lib/forms.ts, src/app/api/registry/records` | | Favourites | `src/app/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx` | | Differentials | `src/app/differentials, src/lib/differentials.ts` | +| Specifiers | `src/app/specifiers, src/components/specifiers, src/lib/specifiers.ts` | | Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | | Documents | `src/app/documents, src/lib/document-flow-routes.ts` | | Tools | `src/components/applications-launcher-page.tsx` | diff --git a/playwright.config.ts b/playwright.config.ts index 94b7f97c5..aa0cdd32f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,14 +11,14 @@ 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)\.spec\.ts/; +const productionSpecPattern = /.*ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers)\.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)\.spec\.ts/, + /.*ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers)\.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 f26d779ed..ce1d12d7d 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -8,6 +8,7 @@ import { documentsSearchHref, DOCUMENTS_MODE_HOME_ROUTE } from "@/lib/document-f import { differentialRecords } from "@/lib/differentials"; 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"); @@ -51,6 +52,11 @@ const routeDescriptions: Record = { "/privacy": "Privacy and data-processing governance draft.", "/services": "Services home and search surface.", "/services/[slug]": "Registry-backed service detail.", + "/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 = { @@ -90,6 +96,7 @@ const routeOwnershipRows = [ ["Forms", "src/app/forms, src/lib/forms.ts, src/app/api/registry/records"], ["Favourites", "src/app/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx"], ["Differentials", "src/app/differentials, src/lib/differentials.ts"], + ["Specifiers", "src/app/specifiers, src/components/specifiers, src/lib/specifiers.ts"], ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], ["Documents", "src/app/documents, src/lib/document-flow-routes.ts"], ["Tools", "src/components/applications-launcher-page.tsx"], @@ -198,6 +205,7 @@ function renderModeRoutes() { forms: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), favourites: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), differentials: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), + specifiers: appModeHomeHref("specifiers", { query: "depressed but racing thoughts", focus: true, run: true }), prescribing: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), tools: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), }; @@ -264,6 +272,12 @@ function renderModePageIndex() { search: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), detail: "`/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`.", }, + { + mode: "Specifiers", + home: appModeHomeHref("specifiers"), + search: appModeHomeHref("specifiers", { query: "depressed but racing thoughts", focus: true, run: true }), + detail: "`/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`.", + }, { mode: "Medication", home: appModeHomeHref("prescribing"), @@ -312,6 +326,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { "/services/[slug]", "/forms/[slug]", "/differentials/diagnoses/[slug]", + "/specifiers/[slug]", "/medications/[slug]", ].includes(route.route), ); @@ -361,6 +376,12 @@ function renderSiteMapRaw(data = collectSiteMapData()) { differentialRecords.map((record) => record.slug), ), "", + ...renderSlugInventory( + "Psychiatric specifier slugs", + "/specifiers/[slug]", + specifierRecords.map((record) => record.slug), + ), + "", ...renderSlugInventory("Medication slugs", "/medications/[slug]", medicationSlugs), ]), ...section("Document viewer route", [ diff --git a/src/app/specifiers/[slug]/page.tsx b/src/app/specifiers/[slug]/page.tsx new file mode 100644 index 000000000..e8bb1a5d5 --- /dev/null +++ b/src/app/specifiers/[slug]/page.tsx @@ -0,0 +1,16 @@ +import { notFound } from "next/navigation"; + +import { SpecifierRecordPage } from "@/components/specifiers/specifier-record-page"; +import { findSpecifier, specifierRecords } from "@/lib/specifiers"; + +export function generateStaticParams() { + return specifierRecords.map((record) => ({ slug: record.slug })); +} + +export default async function SpecifierDetailRoute({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const record = findSpecifier(slug); + if (!record) notFound(); + + return ; +} diff --git a/src/app/specifiers/builder/page.tsx b/src/app/specifiers/builder/page.tsx new file mode 100644 index 000000000..8dce4c993 --- /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) + ? params.specifier + : params.specifier + ? [params.specifier] + : []; + + return ; +} diff --git a/src/app/specifiers/compare/page.tsx b/src/app/specifiers/compare/page.tsx new file mode 100644 index 000000000..41fb3905f --- /dev/null +++ b/src/app/specifiers/compare/page.tsx @@ -0,0 +1,14 @@ +import { SpecifierComparePage } from "@/components/specifiers/specifier-compare-page"; + +type CompareRouteProps = { + searchParams?: Promise<{ a?: string | string[]; b?: string | string[] }>; +}; + +function first(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function SpecifierCompareRoute({ searchParams }: CompareRouteProps) { + const params = searchParams ? await searchParams : {}; + return ; +} diff --git a/src/app/specifiers/layout.tsx b/src/app/specifiers/layout.tsx new file mode 100644 index 000000000..744354acd --- /dev/null +++ b/src/app/specifiers/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; + +export default function SpecifiersLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/app/specifiers/map/page.tsx b/src/app/specifiers/map/page.tsx new file mode 100644 index 000000000..0f77de187 --- /dev/null +++ b/src/app/specifiers/map/page.tsx @@ -0,0 +1,11 @@ +import { SpecifierMapPage } from "@/components/specifiers/specifier-map-page"; + +type MapRouteProps = { + searchParams?: Promise<{ selected?: string | string[] }>; +}; + +export default async function SpecifierMapRoute({ searchParams }: MapRouteProps) { + const params = searchParams ? await searchParams : {}; + const selected = Array.isArray(params.selected) ? params.selected[0] : params.selected; + return ; +} diff --git a/src/app/specifiers/page.tsx b/src/app/specifiers/page.tsx new file mode 100644 index 000000000..b685ade9f --- /dev/null +++ b/src/app/specifiers/page.tsx @@ -0,0 +1,17 @@ +import { SpecifiersHomePage } from "@/components/specifiers/specifiers-home-page"; + +type SpecifiersRouteProps = { + searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; run?: string | string[] }>; +}; + +function firstSearchParam(value?: string | string[]) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function SpecifiersRoute({ searchParams }: SpecifiersRouteProps) { + const params = searchParams ? await searchParams : {}; + const query = (firstSearchParam(params.q) ?? firstSearchParam(params.query) ?? "").trim(); + const hasSubmittedSearch = firstSearchParam(params.run) === "1" && query.length > 0; + + return ; +} diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index e92eb1060..2c6597049 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -18,6 +18,7 @@ import { Settings as SettingsIcon, Sparkles, Sun, + Tags, Wrench, } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; @@ -75,6 +76,7 @@ const sidebarToolItems = [ { id: "forms", label: "Forms", icon: ClipboardPen, href: "/forms", badge: "Early access" }, { id: "favourites", label: "Favourites", icon: Heart, href: "/favourites" }, { id: "differentials", label: "Differentials", icon: BrainCircuit, href: "/differentials" }, + { id: "specifiers", label: "Specifiers", icon: Tags, href: "/specifiers" }, { id: "prescribing", label: "Medication", icon: Pill, href: "/?mode=prescribing" }, { id: "tools", label: "Tools", icon: Wrench, href: "/?mode=tools" }, ] as const; diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 1536b69fe..08c7c043e 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -130,6 +130,7 @@ function GlobalSearchShellClient(props: GlobalSearchShellProps) { resolvedSearchMode !== "forms" && resolvedSearchMode !== "favourites" && resolvedSearchMode !== "differentials" && + resolvedSearchMode !== "specifiers" && !isDocumentSearchMockupRoute; const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); @@ -244,6 +245,7 @@ function GlobalStandaloneSearchShellClient({ resolvedSearchMode !== "forms" && resolvedSearchMode !== "favourites" && resolvedSearchMode !== "differentials" && + resolvedSearchMode !== "specifiers" && !isDocumentSearchMockupRoute; const isStandaloneModeHome = !hasSubmittedModeSearch && @@ -252,6 +254,7 @@ function GlobalStandaloneSearchShellClient({ (searchMode === "forms" && pathname === "/forms") || (searchMode === "favourites" && pathname === "/favourites") || (searchMode === "differentials" && pathname === "/differentials") || + (searchMode === "specifiers" && pathname === "/specifiers") || (searchMode === "tools" && pathname === "/tools")); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); const shouldShowDesktopSidebar = !hideDesktopSidebar; @@ -330,6 +333,7 @@ function GlobalStandaloneSearchShellClient({ router.prefetch("/?mode=tools"); router.prefetch("/favourites"); router.prefetch("/differentials"); + router.prefetch("/specifiers"); } function openGuide() { diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 8e2931d2c..86e06ee4e 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -275,7 +275,8 @@ export function MasterSearchHeader({ selectedSearch.kind === "forms" || selectedSearch.kind === "services" || selectedSearch.kind === "tools" || - selectedSearch.kind === "favourites"; + selectedSearch.kind === "favourites" || + selectedSearch.kind === "specifiers"; const canAsk = trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch); const indexedDocumentTotal = documentTotal ?? documents.length; const hasUnloadedDocuments = indexedDocumentTotal > documents.length; @@ -442,9 +443,11 @@ export function MasterSearchHeader({ ? "favourites" : searchMode === "differentials" ? "differentials" - : searchMode === "tools" - ? "tools" - : "answer"; + : searchMode === "specifiers" + ? "specifiers" + : searchMode === "tools" + ? "tools" + : "answer"; const actionMenuItems = modeActionItemsFor(actionMenuSetId); const actionMenuTitle = selectedAppMode.label; const actionMenuSubtitle = searchMode === "answer" ? "Source-backed mode" : selectedAppMode.description; @@ -605,6 +608,22 @@ export function MasterSearchHeader({ onOpenEvidence?.(); return; } + if (actionId === "specifiers-search") { + onSearchModeChange("specifiers"); + return; + } + if (actionId === "specifiers-builder") { + window.location.assign("/specifiers/builder"); + return; + } + if (actionId === "specifiers-compare") { + window.location.assign("/specifiers/compare"); + return; + } + if (actionId === "specifiers-map") { + window.location.assign("/specifiers/map"); + return; + } } function selectAppMode(mode: (typeof appModeDefinitions)[number]) { diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 727bad734..489194bec 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -25,6 +25,7 @@ import { Filter, FolderOpen, GitBranch, + GitCompareArrows, Heart, ListChecks, Lock, @@ -34,7 +35,9 @@ import { ShieldCheck, Sparkles, Table2, + Tags, UploadCloud, + Waypoints, Wrench, X, type LucideIcon, @@ -45,7 +48,15 @@ import { Sheet } from "@/components/ui/sheet"; import { cn, chatComposerIconButton, toolbarButton } from "@/components/ui-primitives"; export type ModeActionSetId = - "answer" | "documents" | "services" | "forms" | "favourites" | "tools" | "differentials" | "prescribing"; + | "answer" + | "documents" + | "services" + | "forms" + | "favourites" + | "tools" + | "differentials" + | "specifiers" + | "prescribing"; export type ModeActionPlacement = "up" | "down"; type IntegratedSurfaceLayout = { @@ -106,7 +117,11 @@ export type ModeActionId = | "differentials-build" | "differentials-criteria" | "differentials-documents" - | "differentials-evidence"; + | "differentials-evidence" + | "specifiers-search" + | "specifiers-builder" + | "specifiers-compare" + | "specifiers-map"; export type ModeActionItem = { id: ModeActionId; @@ -216,6 +231,28 @@ const modeActionSets = { }, { id: "differentials-evidence", label: "View evidence", description: "Review cited support", icon: ShieldCheck }, ], + specifiers: [ + { + id: "specifiers-search", + label: "Find a specifier", + description: "Match presentation features", + icon: Tags, + primary: true, + }, + { + id: "specifiers-builder", + label: "Build wording", + description: "Assemble diagnostic wording", + icon: ListChecks, + }, + { + id: "specifiers-compare", + label: "Compare specifiers", + description: "Clarify close clinical calls", + icon: GitCompareArrows, + }, + { id: "specifiers-map", label: "Specifier map", description: "Browse by diagnostic role", icon: Waypoints }, + ], prescribing: [ { id: "medication-dose", diff --git a/src/components/specifiers/specifier-builder-page.tsx b/src/components/specifiers/specifier-builder-page.tsx new file mode 100644 index 000000000..981c97b35 --- /dev/null +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -0,0 +1,233 @@ +"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 { specifierFamilies, specifierRecords, type SpecifierFamily } from "@/lib/specifiers"; + +const diagnosisPresets = [ + "Major depressive disorder, recurrent, current episode severe", + "Major depressive disorder, single episode, moderate", + "Bipolar I disorder, current episode depressed", + "Bipolar I disorder, current episode manic", + "Bipolar II disorder, current episode depressed", +]; + +function wordingSegment(name: string) { + if (name === "Mild severity") return "mild"; + return name.charAt(0).toLowerCase() + name.slice(1); +} + +export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecifiers?: string[] }) { + const validInitial = initialSpecifiers.filter((slug) => specifierRecords.some((record) => record.slug === slug)); + const [diagnosis, setDiagnosis] = useState(diagnosisPresets[0]); + const [selected, setSelected] = useState(validInitial); + const selectedRecords = useMemo( + () => selected.map((slug) => specifierRecords.find((record) => record.slug === slug)).filter(Boolean), + [selected], + ); + const wording = [diagnosis, ...selectedRecords.map((record) => wordingSegment(record!.name))].join(", "); + + function toggle(slug: string, family: SpecifierFamily) { + setSelected((current) => { + if (current.includes(slug)) return current.filter((item) => item !== slug); + if (family === "severity-remission") { + const severitySlugs = new Set( + specifierRecords.filter((record) => record.family === "severity-remission").map((record) => record.slug), + ); + return [...current.filter((item) => !severitySlugs.has(item)), slug]; + } + return [...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); + return ( + + ); + })} +
+
+ ); + })} +
+ + +
+ + +
+ ); +} diff --git a/src/components/specifiers/specifier-compare-page.tsx b/src/components/specifiers/specifier-compare-page.tsx new file mode 100644 index 000000000..c3a5b81ae --- /dev/null +++ b/src/components/specifiers/specifier-compare-page.tsx @@ -0,0 +1,238 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, GitCompareArrows, HelpCircle, Repeat2, Tags } from "lucide-react"; +import { useState } from "react"; + +import { + SpecifierBreadcrumbs, + SpecifierFamilyBadge, + SpecifierPageShell, + SpecifierSafetyNote, + SpecifierSubnav, + specifierCard, +} from "@/components/specifiers/specifier-ui"; +import { cn, eyebrowText } from "@/components/ui-primitives"; +import { findSpecifier, specifierRecords } from "@/lib/specifiers"; + +const comparisonRows = [ + { label: "Clinical focus", key: "focus" as const }, + { label: "Time course", key: "timeCourse" as const }, + { label: "Look for", key: "lookFor" as const }, + { label: "Do not overcall from", key: "caution" as const }, +]; + +function fallbackPair(leftSlug?: string, rightSlug?: string) { + const left = findSpecifier(leftSlug ?? "") ?? specifierRecords[0]; + const requestedRight = findSpecifier(rightSlug ?? ""); + const right = + requestedRight && requestedRight.slug !== left.slug + ? requestedRight + : specifierRecords.find((item) => item.slug !== left.slug)!; + return { left, right }; +} + +function Selector({ + label, + value, + otherValue, + onChange, +}: { + label: string; + value: string; + otherValue: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +export function SpecifierComparePage({ initialLeft, initialRight }: { initialLeft?: string; initialRight?: string }) { + const initial = fallbackPair(initialLeft, initialRight); + const [leftSlug, setLeftSlug] = useState(initial.left.slug); + const [rightSlug, setRightSlug] = useState(initial.right.slug); + const left = findSpecifier(leftSlug) ?? initial.left; + const right = findSpecifier(rightSlug) ?? initial.right; + + function chooseLeft(nextSlug: string) { + setLeftSlug(nextSlug); + if (nextSlug === rightSlug) { + setRightSlug(specifierRecords.find((item) => item.slug !== nextSlug)?.slug ?? rightSlug); + } + } + + function chooseRight(nextSlug: string) { + setRightSlug(nextSlug); + if (nextSlug === leftSlug) { + setLeftSlug(specifierRecords.find((item) => item.slug !== nextSlug)?.slug ?? leftSlug); + } + } + + function swap() { + setLeftSlug(right.slug); + setRightSlug(left.slug); + } + + return ( + +
+ + +
+ +
+

Close-call reasoning

+

+ Compare specifiers +

+

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

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

Ask this

+

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

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

{record.shortName}

+
+

{record.summary}

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

Example wording

+

{record.wording}

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

Diagnostic architecture

+

+ Specifier map +

+

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

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

{family.label}

+

+ {records.length} options +

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

Psychiatric specifier

+

+ {record.name} +

+
+

{record.summary}

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

+ What matters now +

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

Clinical implication

+

+ How this changes the plan +

+
+
+

{record.treatmentLens}

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

{eyebrow}

: null} +

{title}

+ {body ?

{body}

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

Specifier pathway

+

+ Build diagnostic wording in clinical order +

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

No strong match for “{query}”

+

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

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

Specifier search

+

+ Matches for “{query}” +

+

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

+
+

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

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

+ {record.summary} +

+
+ + +
+
+ +
+

Deciding signal

+

+ {record.clinicalSignal} +

+
+ + + Open + + +
+
+
+

Ask this

+

+ {record.decisionQuestion} +

+
+
+

Typical language

+

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

+
+
+
+ ))} +
+ )} + + +
+ ); +} + +export function SpecifiersHomePage({ query = "", autoRunSearch = false }: { query?: string; autoRunSearch?: boolean }) { + const trimmedQuery = query.trim(); + if (!autoRunSearch || !trimmedQuery) return ; + return ; +} diff --git a/src/lib/app-mode-icons.ts b/src/lib/app-mode-icons.ts index 374ef54f5..7e27728eb 100644 --- a/src/lib/app-mode-icons.ts +++ b/src/lib/app-mode-icons.ts @@ -5,6 +5,7 @@ import { Heart, Pill, Route, + Tags, Sparkles, Wrench, type LucideIcon, @@ -20,6 +21,7 @@ export const appModeIcons: Record = { forms: FileSignature, favourites: Heart, differentials: BrainCircuit, + specifiers: Tags, prescribing: Pill, tools: Wrench, }; diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index b7c2f9b3c..99d2869b8 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -3,13 +3,21 @@ import { documentsSearchHref } from "@/lib/document-flow-routes"; import { appendSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; export type AppModeId = - "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "prescribing" | "tools"; + | "answer" + | "documents" + | "services" + | "forms" + | "favourites" + | "differentials" + | "specifiers" + | "prescribing" + | "tools"; export type SearchableAppModeId = AppModeId; export type AppModeSearchKind = - "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "tools"; + "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "specifiers" | "tools"; export type AppModeResultKind = - "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "tools"; + "answer" | "documents" | "services" | "forms" | "favourites" | "differentials" | "specifiers" | "tools"; export type AppModeSearchConfig = { kind: AppModeSearchKind; @@ -172,6 +180,28 @@ export const appModeDefinitions = [ defaultQueryMode: "compare_guidance", }, }, + { + id: "specifiers", + label: "Specifiers", + description: "Refine diagnostic wording and episode patterns", + href: "/specifiers", + search: { + kind: "specifiers", + placeholder: "Describe the presentation or search a specifier...", + inputAriaLabel: "Search psychiatric specifiers by presentation or diagnosis", + submitIdleLabel: "Find", + submitBusyLabel: "Find", + submitAriaLabel: "Find matching psychiatric specifiers", + emptyTitle: "Describe the presentation", + readyTitle: "Find the most relevant specifier", + progressLabel: "Matching presentation features to specifiers.", + resultKind: "specifiers", + resultHeading: "Specifier matches", + statusLabel: "Specifiers", + nextStep: "Check fit and refine the diagnostic wording", + badgeLabel: null, + }, + }, { id: "prescribing", label: "Medication", @@ -244,7 +274,7 @@ export function appModeSearchConfig(modeId: AppModeId) { return appModeDefinition(modeId).search; } -const namespaceIsolatedModes = new Set(["services", "forms", "favourites", "differentials"]); +const namespaceIsolatedModes = new Set(["services", "forms", "favourites", "differentials", "specifiers"]); export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOptions = {}) { const mode = appModeDefinition(modeId); @@ -314,6 +344,7 @@ export function isSearchableAppMode(modeId: string): modeId is SearchableAppMode kind === "forms" || kind === "favourites" || kind === "differentials" || + kind === "specifiers" || kind === "tools" ); } diff --git a/src/lib/specifiers.ts b/src/lib/specifiers.ts new file mode 100644 index 000000000..7add8d9ca --- /dev/null +++ b/src/lib/specifiers.ts @@ -0,0 +1,637 @@ +export type SpecifierFamily = "episode-features" | "course-onset" | "severity-remission"; + +export type SpecifierRecord = { + slug: string; + name: string; + shortName: string; + family: SpecifierFamily; + familyLabel: string; + appliesTo: string[]; + summary: string; + clinicalSignal: string; + decisionQuestion: string; + patientLanguage: string[]; + fit: string[]; + notFit: string[]; + checks: string[]; + treatmentLens: string; + wording: string; + comparison: { + focus: string; + timeCourse: string; + lookFor: string; + caution: string; + }; + keywords: string[]; + relatedSlugs: string[]; +}; + +export const specifierFamilies: Array<{ id: "all" | SpecifierFamily; label: string; shortLabel: string }> = [ + { id: "all", label: "All specifiers", shortLabel: "All" }, + { id: "episode-features", label: "Episode features", shortLabel: "Features" }, + { id: "course-onset", label: "Course and onset", shortLabel: "Course" }, + { id: "severity-remission", label: "Severity and remission", shortLabel: "Severity" }, +]; + +export const specifierRecords: SpecifierRecord[] = [ + { + slug: "with-anxious-distress", + name: "With anxious distress", + shortName: "Anxious distress", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Depressive disorders", "Bipolar disorders"], + summary: "Prominent tension, restlessness, worry, or fear accompanies the current mood episode.", + clinicalSignal: "Anxiety is woven into the mood episode and changes its risk and management profile.", + decisionQuestion: + "Is anxious arousal a meaningful part of this mood episode rather than a separate background condition?", + patientLanguage: ["I feel keyed up all day", "Something awful is about to happen", "I cannot settle inside"], + fit: [ + "Anxious tension or restlessness rises and falls with the mood episode.", + "Worry, loss-of-control fears, or dread meaningfully increase distress or impairment.", + "The pattern is present often enough to influence formulation and care planning.", + ], + notFit: [ + "Anxiety is better explained by a separate anxiety disorder with an independent course.", + "Restlessness is more consistent with akathisia, intoxication, withdrawal, delirium, or another medical cause.", + ], + checks: [ + "Clarify timing against the mood episode", + "Separate worry from psychotic threat beliefs", + "Review agitation and suicide risk", + ], + treatmentLens: + "Name the anxiety burden explicitly; it may affect monitoring, tolerability, risk, and pacing of treatment.", + wording: "Major depressive disorder, current episode, with anxious distress", + comparison: { + focus: "Anxious arousal occurring within a mood episode", + timeCourse: "Tracks the current depressive or manic-spectrum episode", + lookFor: "Tension, restlessness, worry, dread, fear of losing control", + caution: "Do not infer it from insomnia or agitation alone", + }, + keywords: ["anxiety", "worry", "tense", "restless", "dread", "agitated depression", "fear", "keyed up"], + relatedSlugs: ["with-mixed-features", "with-melancholic-features"], + }, + { + slug: "with-mixed-features", + name: "With mixed features", + shortName: "Mixed features", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Depressive disorders", "Bipolar disorders"], + summary: "Clinically meaningful symptoms of the opposite mood pole occur during the predominant mood episode.", + clinicalSignal: + "The presentation crosses mood poles rather than being explained by anxiety, irritability, or agitation alone.", + decisionQuestion: + "Are there distinct opposite-pole symptoms during this episode that change diagnostic and treatment reasoning?", + patientLanguage: [ + "I am depressed but my thoughts are racing", + "I barely sleep and still feel driven", + "I feel sped up and hopeless", + ], + fit: [ + "Opposite-pole symptoms are observable, recurrent, and tied to the current episode.", + "Activation includes features such as elevated drive, reduced need for sleep, or accelerated thought and speech.", + "The pattern changes the assessment of bipolarity and antidepressant-related risk.", + ], + notFit: [ + "The only cross-over features are irritability, distractibility, insomnia, or nonspecific agitation.", + "Activation is better explained by substances, medication, trauma arousal, ADHD, or another condition.", + ], + checks: [ + "Map symptoms to the predominant episode", + "Ask about reduced need for sleep, not insomnia alone", + "Review medication and substance timing", + ], + treatmentLens: + "Treat as a cross-polar signal: reassess medication strategy, activation risk, and longitudinal bipolar history.", + wording: "Major depressive disorder, current episode, with mixed features", + comparison: { + focus: "Opposite-pole mood symptoms", + timeCourse: "Co-occur during the predominant mood episode", + lookFor: "Distinct activation or depressive features from the opposite pole", + caution: "Irritability and agitation are not sufficient by themselves", + }, + keywords: ["mixed", "racing thoughts", "sped up", "less sleep", "activated", "agitated", "depressed and energised"], + relatedSlugs: ["with-anxious-distress", "with-psychotic-features"], + }, + { + slug: "with-melancholic-features", + name: "With melancholic features", + shortName: "Melancholic features", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Major depressive episodes"], + summary: + "Profound loss of pleasure or mood reactivity occurs with a characteristic biological and psychomotor pattern.", + clinicalSignal: "Pleasure and emotional reactivity are markedly reduced, not merely overshadowed by stress.", + decisionQuestion: + "Is there a pervasive non-reactive depressive pattern with characteristic biological or psychomotor change?", + patientLanguage: ["Nothing reaches me", "Even good news changes nothing", "Mornings are unbearable"], + fit: [ + "There is marked anhedonia or very limited mood reactivity.", + "The episode includes a coherent pattern such as early waking, diurnal change, psychomotor change, or excessive guilt.", + "The presentation is qualitatively different from ordinary sadness or understandable distress.", + ], + notFit: [ + "Mood reliably brightens with positive events or interpersonal connection.", + "Reduced enjoyment is better explained by sedation, negative symptoms, cognitive disorder, or environmental deprivation.", + ], + checks: [ + "Test mood reactivity with concrete examples", + "Observe psychomotor change", + "Clarify diurnal and sleep pattern", + ], + treatmentLens: + "Document the biological and psychomotor pattern because it may influence treatment intensity and monitoring.", + wording: "Major depressive disorder, current episode, with melancholic features", + comparison: { + focus: "Non-reactive, biologically patterned depression", + timeCourse: "Persists across the depressive episode", + lookFor: "Anhedonia, non-reactivity, diurnal change, psychomotor change, guilt", + caution: "Severity alone does not establish melancholic features", + }, + keywords: ["melancholic", "anhedonia", "non reactive", "early waking", "morning worse", "psychomotor", "guilt"], + relatedSlugs: ["with-atypical-features", "with-psychotic-features"], + }, + { + slug: "with-atypical-features", + name: "With atypical features", + shortName: "Atypical features", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Depressive disorders", "Bipolar depressive episodes"], + summary: "Mood reactivity occurs with a characteristic reversed-vegetative or interpersonal sensitivity pattern.", + clinicalSignal: + "Mood can brighten in response to positive events despite a clinically significant depressive episode.", + decisionQuestion: + "Does mood reactivity sit alongside a coherent atypical depressive pattern rather than an isolated symptom?", + patientLanguage: ["Good news can lift me for a while", "I sleep and eat much more", "Rejection knocks me flat"], + fit: [ + "Mood shows genuine reactivity to positive events.", + "The episode includes a compatible pattern such as increased sleep, increased appetite, leaden heaviness, or rejection sensitivity.", + "The pattern is clinically meaningful and not simply a preference or coping style.", + ], + notFit: [ + "There is no meaningful mood reactivity.", + "Sleep or appetite change is better explained by medication, shift work, endocrine illness, or another condition.", + ], + checks: [ + "Ask for a recent positive-event example", + "Map sleep and appetite change from baseline", + "Clarify longstanding rejection sensitivity", + ], + treatmentLens: + "Capture the reactive and reversed-vegetative pattern without using “atypical” to mean unusual or mild.", + wording: "Major depressive disorder, current episode, with atypical features", + comparison: { + focus: "Reactive mood with a characteristic symptom pattern", + timeCourse: "Present through much of the depressive episode", + lookFor: "Mood reactivity, hypersomnia, increased appetite, leaden feeling, rejection sensitivity", + caution: "The label does not simply mean an unusual presentation", + }, + keywords: ["atypical", "mood reactive", "hypersomnia", "sleeping more", "eating more", "leaden", "rejection"], + relatedSlugs: ["with-melancholic-features", "with-anxious-distress"], + }, + { + slug: "with-psychotic-features", + name: "With psychotic features", + shortName: "Psychotic features", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Depressive disorders", "Bipolar disorders"], + summary: + "Delusions or hallucinations occur during a severe mood episode and require explicit risk and diagnostic review.", + clinicalSignal: "Psychosis is present, and its relationship to the mood episode determines the diagnostic wording.", + decisionQuestion: + "Do psychotic symptoms occur within the mood episode, and are they mood-congruent, mood-incongruent, or independent?", + patientLanguage: ["I know I have ruined everyone", "A voice says I deserve punishment", "I have a special mission"], + fit: [ + "Delusions or hallucinations are clearly present during the mood episode.", + "The content, conviction, and behavioural impact have been assessed rather than inferred from severe worry or guilt.", + "The longitudinal relationship between mood and psychosis is documented.", + ], + notFit: [ + "Unusual thoughts retain insight and do not reach delusional intensity.", + "Psychosis persists outside mood episodes in a way that suggests a different primary diagnosis.", + ], + checks: [ + "Assess command content and immediate risk", + "Map psychosis against mood chronology", + "Describe congruence without forcing it", + ], + treatmentLens: + "Escalate assessment and treatment intensity; document safety, capacity, and the mood–psychosis timeline.", + wording: "Major depressive disorder, current episode, severe with psychotic features", + comparison: { + focus: "Delusions or hallucinations within a mood episode", + timeCourse: "Mapped directly against mood-episode onset and remission", + lookFor: "Conviction, perceptual disturbance, behavioural impact, congruence", + caution: "Severe guilt, rumination, or intrusive thoughts are not automatically psychotic", + }, + keywords: ["psychotic", "delusion", "hallucination", "voices", "paranoia", "mood congruent", "mood incongruent"], + relatedSlugs: ["with-mixed-features", "with-catatonia"], + }, + { + slug: "with-catatonia", + name: "With catatonia", + shortName: "Catatonia", + family: "episode-features", + familyLabel: "Episode features", + appliesTo: ["Mood disorders", "Psychotic disorders", "Medical conditions"], + summary: "A marked psychomotor syndrome is present and needs structured, time-sensitive assessment.", + clinicalSignal: "A cluster of psychomotor signs is more informative than immobility or reduced speech alone.", + decisionQuestion: + "Is there a coherent catatonic syndrome after urgent medical, neurological, toxic, and medication causes are considered?", + patientLanguage: [ + "They have stopped moving and speaking", + "They hold the same posture", + "Their movements copy mine", + ], + fit: [ + "Multiple characteristic psychomotor signs are directly observed or reliably described.", + "The change is substantial from baseline and not explained by ordinary withdrawal or fatigue.", + "A structured examination and urgent physical assessment are underway.", + ], + notFit: [ + "Reduced movement is better explained by sedation, delirium, neurological disease, severe parkinsonism, or negative symptoms.", + "Only one nonspecific sign is present without a broader syndrome.", + ], + checks: [ + "Record observed psychomotor signs", + "Check hydration, intake, observations, and complications", + "Review medicines and medical causes urgently", + ], + treatmentLens: + "Treat catatonia as time-sensitive; coordinate urgent medical assessment and syndrome-specific management.", + wording: "Bipolar I disorder, current episode depressed, with catatonia", + comparison: { + focus: "A clustered psychomotor syndrome", + timeCourse: "Acute or subacute change requiring close observation", + lookFor: "Immobility, posturing, negativism, echophenomena, stereotypy, agitation without purpose", + caution: "Do not equate quietness, mutism, or immobility alone with catatonia", + }, + keywords: [ + "catatonia", + "mute", + "mutism", + "stupor", + "posturing", + "negativism", + "echolalia", + "echopraxia", + "immobile", + ], + relatedSlugs: ["with-psychotic-features", "with-melancholic-features"], + }, + { + slug: "with-seasonal-pattern", + name: "With seasonal pattern", + shortName: "Seasonal pattern", + family: "course-onset", + familyLabel: "Course and onset", + appliesTo: ["Recurrent depressive disorder", "Bipolar disorders"], + summary: + "Episodes show a repeated temporal relationship with a particular season and a contrasting pattern at another time of year.", + clinicalSignal: "The recurring calendar-linked course matters more than the season in which one episode happens.", + decisionQuestion: + "Across years, is there a consistent seasonal onset-and-remission pattern that is not explained by seasonal stressors?", + patientLanguage: ["Every winter I shut down", "It lifts each spring", "The same pattern returns each year"], + fit: [ + "Episode onset repeatedly aligns with a particular season.", + "Remission or a switch in mood polarity also follows a recurring seasonal relationship.", + "Nonseasonal episodes do not dominate the longitudinal history.", + ], + notFit: [ + "Only the current episode occurred in winter or another season.", + "The pattern is better explained by recurring work, anniversary, social, or environmental stressors.", + ], + checks: [ + "Build a multi-year month-by-month timeline", + "Mark remissions as well as onsets", + "Separate latitude and lifestyle effects from episode recurrence", + ], + treatmentLens: + "Use the longitudinal pattern to support anticipatory monitoring and relapse planning before the usual onset period.", + wording: "Major depressive disorder, recurrent, with seasonal pattern", + comparison: { + focus: "Recurring season-linked episode timing", + timeCourse: "Demonstrated over repeated annual cycles", + lookFor: "Consistent onset, remission, or polarity change in relation to season", + caution: "One winter episode is not a seasonal pattern", + }, + keywords: ["seasonal", "winter depression", "summer", "spring", "every year", "annual", "season"], + relatedSlugs: ["with-peripartum-onset", "with-rapid-cycling"], + }, + { + slug: "with-peripartum-onset", + name: "With peripartum onset", + shortName: "Peripartum onset", + family: "course-onset", + familyLabel: "Course and onset", + appliesTo: ["Depressive disorders", "Bipolar disorders"], + summary: "A mood episode begins during pregnancy or in the period soon after birth.", + clinicalSignal: + "Timing around pregnancy and birth changes safety assessment, treatment context, and family support needs.", + decisionQuestion: + "Did the mood episode begin in the peripartum window, and are bipolarity, psychosis, and immediate safety actively assessed?", + patientLanguage: [ + "This began during the pregnancy", + "I have not felt like myself since the birth", + "I am frightened by what my mind is doing", + ], + fit: [ + "Episode onset is clearly linked to pregnancy or the early post-birth period.", + "The assessment distinguishes depressive, manic-spectrum, psychotic, anxiety, trauma, and adjustment presentations.", + "Parent, infant, sleep, feeding, support, and safeguarding needs are considered together.", + ], + notFit: [ + "The episode clearly pre-dated pregnancy without a meaningful peripartum onset or recurrence.", + "Symptoms are better explained by transient adjustment alone, while still recognising that distress may require care.", + ], + checks: [ + "Screen urgently for psychosis and mania", + "Assess parent and infant safety", + "Review sleep loss, supports, medicines, and feeding context", + ], + treatmentLens: + "Coordinate timely perinatal mental health care; new psychosis, mania, or severe deterioration requires urgent escalation.", + wording: "Major depressive disorder, single episode, with peripartum onset", + comparison: { + focus: "Mood-episode onset around pregnancy or birth", + timeCourse: "Defined by episode onset, not simply current parenting status", + lookFor: "Onset timing, bipolar indicators, psychosis, sleep loss, safety and support", + caution: "Do not treat all post-birth distress as unipolar depression", + }, + keywords: ["peripartum", "postpartum", "postnatal", "pregnancy", "after birth", "new mother", "new parent"], + relatedSlugs: ["with-psychotic-features", "with-mixed-features"], + }, + { + slug: "with-rapid-cycling", + name: "With rapid cycling", + shortName: "Rapid cycling", + family: "course-onset", + familyLabel: "Course and onset", + appliesTo: ["Bipolar I disorder", "Bipolar II disorder"], + summary: "Multiple distinct mood episodes occur within a year, separated by remission or a switch in polarity.", + clinicalSignal: "Count syndromal episodes across time; do not substitute moment-to-moment emotional lability.", + decisionQuestion: + "Does the longitudinal timeline show repeated distinct episodes rather than rapid shifts within one episode?", + patientLanguage: [ + "I have had several separate episodes this year", + "I recover and then switch again", + "My mood episodes keep returning", + ], + fit: [ + "Distinct depressive, manic, or hypomanic episodes can be identified on a timeline.", + "Episodes are separated by remission or a clear switch in mood polarity.", + "Medication, thyroid, substance, sleep, and reproductive factors have been reviewed.", + ], + notFit: [ + "Mood shifts occur within hours in response to events without distinct syndromal episodes.", + "A single prolonged mixed, unstable, or substance-related episode is being counted as several episodes.", + ], + checks: [ + "Build a 12-month episode timeline", + "Mark recovery and polarity switches", + "Review treatment and biological contributors", + ], + treatmentLens: "Use the episode timeline to guide maintenance strategy and reduce destabilising contributors.", + wording: "Bipolar I disorder, current episode depressed, with rapid cycling", + comparison: { + focus: "Frequency of distinct bipolar mood episodes", + timeCourse: "Counted across a defined 12-month interval", + lookFor: "Syndromal episodes, remission intervals, polarity switches", + caution: "Emotional lability is not the same as rapid cycling", + }, + keywords: [ + "rapid cycling", + "several episodes", + "many episodes", + "four episodes", + "mood switches", + "bipolar course", + "lability", + ], + relatedSlugs: ["with-mixed-features", "with-seasonal-pattern"], + }, + { + slug: "mild-severity", + name: "Mild severity", + shortName: "Mild", + family: "severity-remission", + familyLabel: "Severity and remission", + appliesTo: ["Mood disorders", "Other specified diagnoses"], + summary: "Symptoms meet the disorder threshold with limited excess symptoms, distress, and functional impairment.", + clinicalSignal: "Severity describes the whole episode: symptom burden, intensity, risk, and functional impact.", + decisionQuestion: + "Is the current episode genuinely lower in burden and impairment while still meeting the diagnostic threshold?", + patientLanguage: ["I can still function, but it takes much more effort", "Symptoms are present but contained"], + fit: [ + "The diagnostic threshold is met.", + "Symptoms beyond the minimum are limited and their intensity is manageable.", + "Functional impairment is present but relatively contained.", + ], + notFit: [ + "Risk, psychosis, catatonia, marked impairment, or severe biological disturbance indicates a higher severity level.", + "The person is below diagnostic threshold, in which case remission or another formulation may be more accurate.", + ], + checks: [ + "Separate symptom count from intensity", + "Document functional impact", + "Check risk and protective factors", + ], + treatmentLens: "Match treatment intensity to need while avoiding minimisation of meaningful distress or risk.", + wording: "Major depressive disorder, single episode, mild", + comparison: { + focus: "Current episode burden and impairment", + timeCourse: "Rated for the present episode", + lookFor: "Symptom load, intensity, risk, and function", + caution: "Functioning at work does not automatically mean mild illness", + }, + keywords: ["mild", "severity", "still functioning", "limited impairment", "low severity"], + relatedSlugs: ["in-partial-remission", "in-full-remission"], + }, + { + slug: "in-partial-remission", + name: "In partial remission", + shortName: "Partial remission", + family: "severity-remission", + familyLabel: "Severity and remission", + appliesTo: ["Mood disorders", "Psychotic disorders"], + summary: + "The full syndrome has improved, but meaningful symptoms remain or a sustained symptom-free interval has not yet been established.", + clinicalSignal: "The person is better but not symptom-free, or recovery is too recent to call full remission.", + decisionQuestion: + "Has the episode clearly receded while residual symptoms or insufficient duration prevent full-remission wording?", + patientLanguage: [ + "I am much better, but not back to myself", + "Some symptoms are still there", + "The worst has passed", + ], + fit: [ + "Full criteria are no longer met or there has been substantial improvement.", + "Residual symptoms remain clinically meaningful, or the improved interval is not yet sustained.", + "Function and risk have improved but still require active follow-up.", + ], + notFit: [ + "The person still meets full current-episode criteria.", + "No significant symptoms remain and the recovery interval supports full-remission wording.", + ], + checks: [ + "List residual symptoms explicitly", + "Document change in function and risk", + "Confirm the recovery timeline", + ], + treatmentLens: + "Target residual symptoms and relapse prevention; partial recovery can still carry substantial recurrence risk.", + wording: "Major depressive disorder, recurrent, in partial remission", + comparison: { + focus: "Incomplete recovery from the most recent episode", + timeCourse: "After improvement, before sustained full remission", + lookFor: "Residual symptoms, recent recovery, persistent functional impact", + caution: "Improvement alone does not mean the current episode has ended", + }, + keywords: [ + "partial remission", + "improving", + "better but", + "residual symptoms", + "not fully recovered", + "recovering", + ], + relatedSlugs: ["in-full-remission", "mild-severity"], + }, + { + slug: "in-full-remission", + name: "In full remission", + shortName: "Full remission", + family: "severity-remission", + familyLabel: "Severity and remission", + appliesTo: ["Mood disorders", "Psychotic disorders"], + summary: "No significant signs or symptoms of the recent episode remain across a sustained interval.", + clinicalSignal: "Full remission is a longitudinal conclusion, not a single good day or improvement from baseline.", + decisionQuestion: + "Are clinically significant episode symptoms absent, with recovery sustained long enough to support full-remission wording?", + patientLanguage: ["I feel back to my usual self", "The episode symptoms have gone", "I have been well and stable"], + fit: [ + "Significant signs and symptoms of the recent episode are no longer present.", + "Recovery is sustained rather than a brief fluctuation.", + "Current functioning and risk are consistent with remission, while relapse prevention continues.", + ], + notFit: [ + "Residual symptoms remain clinically meaningful.", + "The person has only recently improved or still meets current-episode criteria.", + ], + checks: [ + "Confirm symptom absence across settings", + "Review duration and function", + "Maintain relapse and early-warning planning", + ], + treatmentLens: + "Continue maintenance and relapse-prevention planning; remission does not imply treatment or monitoring is unnecessary.", + wording: "Major depressive disorder, recurrent, in full remission", + comparison: { + focus: "Sustained absence of significant episode symptoms", + timeCourse: "Established after a durable recovery interval", + lookFor: "Symptom resolution, stable function, sustained recovery", + caution: "Do not infer full remission from partial improvement", + }, + keywords: ["full remission", "recovered", "well now", "symptom free", "stable", "back to normal"], + relatedSlugs: ["in-partial-remission", "mild-severity"], + }, +]; + +export const specifierSearchPresets = [ + { label: "Agitated depression", query: "depressed, racing thoughts and barely sleeping" }, + { label: "Post-birth onset", query: "mood episode began after birth" }, + { label: "Winter recurrence", query: "depression returns every winter and lifts in spring" }, + { label: "Residual symptoms", query: "much better but not fully recovered" }, + { label: "Psychomotor change", query: "stopped speaking and holds the same posture" }, +]; + +export function findSpecifier(slug: string) { + return specifierRecords.find((record) => record.slug === slug); +} + +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(" "), + ); +} + +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) => !diagnosis || normalizeSearchText(record.appliesTo.join(" ")).includes(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/tests/app-modes.test.ts b/tests/app-modes.test.ts index a96feea3a..50fd415d2 100644 --- a/tests/app-modes.test.ts +++ b/tests/app-modes.test.ts @@ -104,6 +104,17 @@ describe("app mode search contract", () => { expect(appModeQueryMode("differentials", "auto")).toBe("compare_guidance"); }); + it("keeps specifiers searchable as a standalone local decision-support mode", () => { + const config = appModeSearchConfig("specifiers"); + const mode = appModeDefinitions.find((definition) => definition.id === "specifiers"); + + expect(isSearchableAppMode("specifiers")).toBe(true); + expect(mode?.href).toBe("/specifiers"); + expect(config.kind).toBe("specifiers"); + expect(config.resultKind).toBe("specifiers"); + expect(config.placeholder.toLowerCase()).toContain("specifier"); + }); + it("keeps source-library shortcut searches in their active mode family", () => { expect(appModeCanUseSourceLibraryShortcut("answer")).toBe(false); expect(appModeCanUseSourceLibraryShortcut("tools")).toBe(false); @@ -115,6 +126,7 @@ describe("app mode search contract", () => { expect(appModeCanUseSourceLibraryShortcut("favourites")).toBe(false); expect(appModeCanUseSourceLibraryShortcut("prescribing")).toBe(true); expect(appModeCanUseSourceLibraryShortcut("differentials")).toBe(true); + expect(appModeCanUseSourceLibraryShortcut("specifiers")).toBe(false); expect(appModeSourceLibrarySearchMode("documents")).toBe("documents"); expect(appModeSourceLibrarySearchMode("services")).toBe("documents"); @@ -145,6 +157,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: " racing thoughts ", run: true, focus: true })).toBe( + "/specifiers?q=racing+thoughts&focus=1&run=1", + ); expect(appModeHomeHref("prescribing", { query: " acamprosate renal dose " })).toBe( "/?mode=prescribing&q=acamprosate+renal+dose", ); @@ -180,6 +195,7 @@ describe("app mode search contract", () => { "forms", "favourites", "differentials", + "specifiers", "prescribing", "tools", ]), @@ -196,6 +212,7 @@ describe("app mode search contract", () => { expect(isAppModeVisible("forms", "production")).toBe(true); expect(isAppModeVisible("favourites", "production")).toBe(true); expect(isAppModeVisible("differentials", "production")).toBe(true); + expect(isAppModeVisible("specifiers", "production")).toBe(true); expect(isAppModeVisible("prescribing", "production")).toBe(true); expect(isAppModeVisible("tools", "production")).toBe(true); expect(productionModes).not.toContain("evidence"); @@ -203,6 +220,7 @@ describe("app mode search contract", () => { expect(productionModes).toContain("forms"); expect(productionModes).toContain("favourites"); expect(productionModes).toContain("differentials"); + expect(productionModes).toContain("specifiers"); expect(productionModes).toContain("prescribing"); expect(productionModes).toContain("tools"); expect(developmentModes).toEqual( @@ -213,6 +231,7 @@ describe("app mode search contract", () => { "forms", "favourites", "differentials", + "specifiers", "prescribing", "tools", ]), diff --git a/tests/specifiers.test.ts b/tests/specifiers.test.ts new file mode 100644 index 000000000..317b111a1 --- /dev/null +++ b/tests/specifiers.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { findSpecifier, searchSpecifiers, 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"); + }); + + 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-specifiers.spec.ts b/tests/ui-specifiers.spec.ts new file mode 100644 index 000000000..2ea25d9a8 --- /dev/null +++ b/tests/ui-specifiers.spec.ts @@ -0,0 +1,107 @@ +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); +}); From c9d8edf85f808c4c83aa86e043f7dce4a8496696 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:16:48 +0800 Subject: [PATCH 2/7] docs: record specifiers branch review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 60b76fd65..a54989858 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -392,3 +392,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-13 | origin/codex/rag-canary-completion | d1b7647bde73e7d0558472ad6aa19c3b49d94639 | branch-cleanup | Retained for open PR #612 and active worktree activity. | Fresh GitHub open-PR query and moving-head observation; deletion prohibited. | | 2026-07-13 | origin/main | 528a1752f41cd29a518ca9341c93c724030173ae | branch-cleanup | Protected base branch retained. | Final refreshed origin/main snapshot before the ledger PR. | | 2026-07-14 | PR #629 / codex/eval-canary-quota-handling | 8fe1be6d0c58c60afeeb82f720b03c90ce57c2cf | review-followup | One P2 structured-error retry defect confirmed and fixed on `codex/eval-canary-structured-errors`. | GitHub connector thread inspection; `tests/eval-utils.test.ts` 14/14; focused ESLint and Prettier. | +| 2026-07-14 | codex/specifiers-design | c3e7024e15d2157bbc0b989276324cc6ad3eea5c | Specifiers UI, clinical decision-support, accessibility, and release-readiness review | One P2 WCAG contrast defect was reproduced and fixed across the builder, comparison, and map accent eyebrows. No remaining high-confidence defect was found in the changed scope; residual risk is clinical/manual governance of the original specifier summaries. | Focused Vitest 18/18; focused Chromium desktop/mobile 2/2 with serious/critical axe WCAG A/AA scanning and overflow checks; `npm run check:production-readiness:ci` READY; `npm run verify:cheap` 2,210 passed/1 skipped; `npm run verify:pr-local` 2,213 passed/1 skipped plus production build and client-bundle secret scan; `git diff --check`. Full advisory `verify:ui` exceeded the local 10-minute execution window; provider-backed checks were not run. | From ede7a2d5b59fd621dc2fbf15ff2ea3179e7315ea Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:48:23 +0800 Subject: [PATCH 3/7] fix(specifiers): normalize routed selection state --- src/app/page.tsx | 10 +++++ src/app/specifiers/builder/page.tsx | 2 +- .../specifiers/specifier-builder-page.tsx | 16 +++---- src/lib/specifiers.ts | 45 ++++++++++++++++++- tests/specifiers.test.ts | 22 ++++++++- tests/ui-smoke.spec.ts | 11 +++++ 6 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index c083f5ee9..cf08ba561 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -44,5 +44,15 @@ export default async function Home({ searchParams }: HomeProps) { redirect(suffix ? `/differentials?${suffix}` : "/differentials"); } + if (initialSearchMode === "specifiers") { + const specifierParams = new URLSearchParams(); + const query = firstSearchParam(params.q)?.trim(); + if (query) specifierParams.set("q", query); + if (firstSearchParam(params.focus) === "1") specifierParams.set("focus", "1"); + if (firstSearchParam(params.run) === "1") specifierParams.set("run", "1"); + const suffix = specifierParams.toString(); + redirect(suffix ? `/specifiers?${suffix}` : "/specifiers"); + } + return ; } diff --git a/src/app/specifiers/builder/page.tsx b/src/app/specifiers/builder/page.tsx index 8dce4c993..26e85256f 100644 --- a/src/app/specifiers/builder/page.tsx +++ b/src/app/specifiers/builder/page.tsx @@ -7,7 +7,7 @@ type BuilderRouteProps = { export default async function SpecifierBuilderRoute({ searchParams }: BuilderRouteProps) { const params = searchParams ? await searchParams : {}; const initialSpecifiers = Array.isArray(params.specifier) - ? params.specifier + ? Array.from(new Set(params.specifier)) : params.specifier ? [params.specifier] : []; diff --git a/src/components/specifiers/specifier-builder-page.tsx b/src/components/specifiers/specifier-builder-page.tsx index 981c97b35..45ac35500 100644 --- a/src/components/specifiers/specifier-builder-page.tsx +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -12,7 +12,7 @@ import { specifierCard, } from "@/components/specifiers/specifier-ui"; import { cn, eyebrowText } from "@/components/ui-primitives"; -import { specifierFamilies, specifierRecords, type SpecifierFamily } from "@/lib/specifiers"; +import { normalizeSpecifierSelection, specifierFamilies, specifierRecords } from "@/lib/specifiers"; const diagnosisPresets = [ "Major depressive disorder, recurrent, current episode severe", @@ -28,7 +28,7 @@ function wordingSegment(name: string) { } export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecifiers?: string[] }) { - const validInitial = initialSpecifiers.filter((slug) => specifierRecords.some((record) => record.slug === slug)); + const validInitial = normalizeSpecifierSelection(initialSpecifiers); const [diagnosis, setDiagnosis] = useState(diagnosisPresets[0]); const [selected, setSelected] = useState(validInitial); const selectedRecords = useMemo( @@ -37,16 +37,10 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif ); const wording = [diagnosis, ...selectedRecords.map((record) => wordingSegment(record!.name))].join(", "); - function toggle(slug: string, family: SpecifierFamily) { + function toggle(slug: string) { setSelected((current) => { if (current.includes(slug)) return current.filter((item) => item !== slug); - if (family === "severity-remission") { - const severitySlugs = new Set( - specifierRecords.filter((record) => record.family === "severity-remission").map((record) => record.slug), - ); - return [...current.filter((item) => !severitySlugs.has(item)), slug]; - } - return [...current, slug]; + return normalizeSpecifierSelection([...current, slug]); }); } @@ -129,7 +123,7 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif toggle(record.slug, record.family)} + onChange={() => toggle(record.slug)} className="peer sr-only" /> record.slug === slug); } +export function normalizeSpecifierSelection(slugs: string[]) { + const selected: string[] = []; + + 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); + } + + selected.push(slug); + } + + return selected; +} + function normalizeSearchText(value: string) { return value .toLowerCase() @@ -601,6 +619,31 @@ function recordSearchText(record: SpecifierRecord) { ); } +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 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 } = {}, @@ -611,7 +654,7 @@ export function searchSpecifiers( return specifierRecords .filter((record) => !options.family || options.family === "all" || record.family === options.family) - .filter((record) => !diagnosis || normalizeSearchText(record.appliesTo.join(" ")).includes(diagnosis)) + .filter((record) => matchesDiagnosisFilter(record, diagnosis)) .map((record, index) => { const title = normalizeSearchText(`${record.name} ${record.shortName}`); const keywords = normalizeSearchText(record.keywords.join(" ")); diff --git a/tests/specifiers.test.ts b/tests/specifiers.test.ts index 317b111a1..63d315e85 100644 --- a/tests/specifiers.test.ts +++ b/tests/specifiers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { findSpecifier, searchSpecifiers, specifierRecords } from "@/lib/specifiers"; +import { findSpecifier, normalizeSpecifierSelection, searchSpecifiers, specifierRecords } from "@/lib/specifiers"; describe("psychiatric specifier catalogue", () => { it("keeps slugs unique and related links valid", () => { @@ -37,6 +37,26 @@ describe("psychiatric specifier catalogue", () => { 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"]); }); it("does not expose provenance columns in the specifier search records", () => { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 3939b3538..a0196925b 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2242,6 +2242,15 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("heading", { level: 1, name: "Differentials" })).toBeVisible(); }); + 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"); + + 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("submitted differentials searches stay on the standalone differentials route", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); @@ -2389,6 +2398,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await page.keyboard.press("ArrowDown"); await expect(appModeMenu.getByRole("menuitemradio", { name: /^Differentials\b/ })).toBeFocused(); await page.keyboard.press("ArrowDown"); + await expect(appModeMenu.getByRole("menuitemradio", { name: /^Specifiers\b/ })).toBeFocused(); + await page.keyboard.press("ArrowDown"); await expect(appModeMenu.getByRole("menuitemradio", { name: /^Medication\b/ })).toBeFocused(); await page.keyboard.press("ArrowDown"); await expect(appModeMenu.getByRole("menuitemradio", { name: /^Tools\b/ })).toBeFocused(); From bb660bd5b46a562616c52fda34a719c6bc7c294a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:49:07 +0800 Subject: [PATCH 4/7] docs: record specifiers review follow-up --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index a54989858..db78612da 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -393,3 +393,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-13 | origin/main | 528a1752f41cd29a518ca9341c93c724030173ae | branch-cleanup | Protected base branch retained. | Final refreshed origin/main snapshot before the ledger PR. | | 2026-07-14 | PR #629 / codex/eval-canary-quota-handling | 8fe1be6d0c58c60afeeb82f720b03c90ce57c2cf | review-followup | One P2 structured-error retry defect confirmed and fixed on `codex/eval-canary-structured-errors`. | GitHub connector thread inspection; `tests/eval-utils.test.ts` 14/14; focused ESLint and Prettier. | | 2026-07-14 | codex/specifiers-design | c3e7024e15d2157bbc0b989276324cc6ad3eea5c | Specifiers UI, clinical decision-support, accessibility, and release-readiness review | One P2 WCAG contrast defect was reproduced and fixed across the builder, comparison, and map accent eyebrows. No remaining high-confidence defect was found in the changed scope; residual risk is clinical/manual governance of the original specifier summaries. | Focused Vitest 18/18; focused Chromium desktop/mobile 2/2 with serious/critical axe WCAG A/AA scanning and overflow checks; `npm run check:production-readiness:ci` READY; `npm run verify:cheap` 2,210 passed/1 skipped; `npm run verify:pr-local` 2,213 passed/1 skipped plus production build and client-bundle secret scan; `git diff --check`. Full advisory `verify:ui` exceeded the local 10-minute execution window; provider-backed checks were not run. | +| 2026-07-14 | PR #633 / codex/specifiers-design | ede7a2d5b59fd621dc2fbf15ff2ea3179e7315ea | Review follow-up and failed UI regression | Four distinct P2 behaviors across five review threads were confirmed and fixed: initial selection normalization, duplicate query removal, clinical applicability mapping, and canonical Specifiers routing. The failed app-menu keyboard test was a stale order assertion and now covers the inserted Specifiers item. | Focused Vitest 5/5; focused Chromium 2/2; `npm run verify:cheap` 2,214 passed/1 skipped; targeted Prettier and `git diff --check`. Static production-readiness remained blocked only by absent local Supabase/OpenAI secrets; no provider-backed checks were run. | From b9491a4420c190dd85979c0e78a2efe0b3a78693 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:58:21 +0800 Subject: [PATCH 5/7] fix(specifiers): avoid contradictory severity wording --- src/components/specifiers/specifier-builder-page.tsx | 4 ++-- tests/ui-specifiers.spec.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/specifiers/specifier-builder-page.tsx b/src/components/specifiers/specifier-builder-page.tsx index 45ac35500..81b8e9ebc 100644 --- a/src/components/specifiers/specifier-builder-page.tsx +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -15,8 +15,8 @@ import { cn, eyebrowText } from "@/components/ui-primitives"; import { normalizeSpecifierSelection, specifierFamilies, specifierRecords } from "@/lib/specifiers"; const diagnosisPresets = [ - "Major depressive disorder, recurrent, current episode severe", - "Major depressive disorder, single episode, moderate", + "Major depressive disorder, recurrent", + "Major depressive disorder, single episode", "Bipolar I disorder, current episode depressed", "Bipolar I disorder, current episode manic", "Bipolar II disorder, current episode depressed", diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index 2ea25d9a8..cd0d75f29 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -105,3 +105,13 @@ test("keeps mobile search, filters, results, and the fixed composer usable", asy 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( + "Major depressive disorder, recurrent", + ); + await expect(page.getByText("Major depressive disorder, recurrent, mild", { exact: true })).toBeVisible(); + await expect(page.getByText(/severe, mild|moderate, mild/i)).toHaveCount(0); +}); From 7f1d211f92bd99f471d3c5f6465b1330ce43dd78 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:17:14 +0800 Subject: [PATCH 6/7] fix(specifiers): preserve diagnostic wording order --- docs/branch-review-ledger.md | 2 +- .../specifiers/specifier-builder-page.tsx | 30 ++++++++++++++----- tests/ui-specifiers.spec.ts | 7 +++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index db78612da..29dde4505 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -393,4 +393,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-13 | origin/main | 528a1752f41cd29a518ca9341c93c724030173ae | branch-cleanup | Protected base branch retained. | Final refreshed origin/main snapshot before the ledger PR. | | 2026-07-14 | PR #629 / codex/eval-canary-quota-handling | 8fe1be6d0c58c60afeeb82f720b03c90ce57c2cf | review-followup | One P2 structured-error retry defect confirmed and fixed on `codex/eval-canary-structured-errors`. | GitHub connector thread inspection; `tests/eval-utils.test.ts` 14/14; focused ESLint and Prettier. | | 2026-07-14 | codex/specifiers-design | c3e7024e15d2157bbc0b989276324cc6ad3eea5c | Specifiers UI, clinical decision-support, accessibility, and release-readiness review | One P2 WCAG contrast defect was reproduced and fixed across the builder, comparison, and map accent eyebrows. No remaining high-confidence defect was found in the changed scope; residual risk is clinical/manual governance of the original specifier summaries. | Focused Vitest 18/18; focused Chromium desktop/mobile 2/2 with serious/critical axe WCAG A/AA scanning and overflow checks; `npm run check:production-readiness:ci` READY; `npm run verify:cheap` 2,210 passed/1 skipped; `npm run verify:pr-local` 2,213 passed/1 skipped plus production build and client-bundle secret scan; `git diff --check`. Full advisory `verify:ui` exceeded the local 10-minute execution window; provider-backed checks were not run. | -| 2026-07-14 | PR #633 / codex/specifiers-design | ede7a2d5b59fd621dc2fbf15ff2ea3179e7315ea | Review follow-up and failed UI regression | Four distinct P2 behaviors across five review threads were confirmed and fixed: initial selection normalization, duplicate query removal, clinical applicability mapping, and canonical Specifiers routing. The failed app-menu keyboard test was a stale order assertion and now covers the inserted Specifiers item. | Focused Vitest 5/5; focused Chromium 2/2; `npm run verify:cheap` 2,214 passed/1 skipped; targeted Prettier and `git diff --check`. Static production-readiness remained blocked only by absent local Supabase/OpenAI secrets; no provider-backed checks were run. | +| 2026-07-14 | PR #633 / codex/specifiers-design | b9491a4420c190dd85979c0e78a2efe0b3a78693 | Review follow-up and failed UI regression | Six distinct P2 behaviors across seven review threads were confirmed and fixed: initial selection normalization, duplicate query removal, clinical applicability mapping, canonical Specifiers routing, severity-neutral base wording, and diagnostic-section ordering. The failed app-menu keyboard test was a stale order assertion and now covers the inserted Specifiers item. | Focused Vitest 5/5; focused Chromium 3/3 plus the diagnostic-order regression; scoped ESLint and TypeScript; CI-mode production readiness READY; production build and client-bundle secret scan passed. Hosted required checks passed before the final ordering fix; provider-backed Supabase/OpenAI checks were not run. | diff --git a/src/components/specifiers/specifier-builder-page.tsx b/src/components/specifiers/specifier-builder-page.tsx index 81b8e9ebc..b56a8d015 100644 --- a/src/components/specifiers/specifier-builder-page.tsx +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -12,7 +12,13 @@ import { specifierCard, } from "@/components/specifiers/specifier-ui"; import { cn, eyebrowText } from "@/components/ui-primitives"; -import { normalizeSpecifierSelection, specifierFamilies, specifierRecords } from "@/lib/specifiers"; +import { + normalizeSpecifierSelection, + specifierFamilies, + specifierRecords, + type SpecifierFamily, + type SpecifierRecord, +} from "@/lib/specifiers"; const diagnosisPresets = [ "Major depressive disorder, recurrent", @@ -22,6 +28,12 @@ const diagnosisPresets = [ "Bipolar II disorder, current episode depressed", ]; +const specifierFamilyOrder: Record = { + "episode-features": 0, + "course-onset": 1, + "severity-remission": 2, +}; + function wordingSegment(name: string) { if (name === "Mild severity") return "mild"; return name.charAt(0).toLowerCase() + name.slice(1); @@ -32,10 +44,14 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif const [diagnosis, setDiagnosis] = useState(diagnosisPresets[0]); const [selected, setSelected] = useState(validInitial); const selectedRecords = useMemo( - () => selected.map((slug) => specifierRecords.find((record) => record.slug === slug)).filter(Boolean), + () => + 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, ...selectedRecords.map((record) => wordingSegment(record!.name))].join(", "); + const wording = [diagnosis, ...selectedRecords.map((record) => wordingSegment(record.name))].join(", "); function toggle(slug: string) { setSelected((current) => { @@ -172,16 +188,16 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif
    {selectedRecords.map((record) => (
  • - {record!.shortName} + {record.shortName} diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index cd0d75f29..d731ec314 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -114,4 +114,11 @@ test("keeps the base diagnosis severity-neutral when applying a severity descrip ); 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(); }); From 65d8f533f23ca59190b1f7ed0ad86fd050381805 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:37:29 +0800 Subject: [PATCH 7/7] fix(specifiers): enforce diagnosis-compatible wording --- docs/branch-review-ledger.md | 2 +- .../specifiers/specifier-builder-page.tsx | 55 +++++++++++++------ src/lib/specifiers.ts | 31 +++++++++++ tests/specifiers.test.ts | 25 ++++++++- tests/ui-specifiers.spec.ts | 20 ++++++- 5 files changed, 111 insertions(+), 22 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index ab4d223a2..83f0e40eb 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -399,4 +399,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-14 | PR #632 / codex/rag-performance-followups | 0c792b8c10516c85d18832fc96562dd70a671b5f | review-followup | One P1 privacy-disclosure defect was confirmed: OpenAI retrieval embedding can receive query text even when answer synthesis remains source-only. Fixed the PIA and public `/privacy` copy to distinguish retrieval-embedding egress from model-backed synthesis egress, with focused copy coverage. | GitHub connector review-thread inspection; focused privacy UI test, ESLint, TypeScript, Prettier, production-readiness, and `git diff --check`. | | 2026-07-14 | PR #632 / codex/rag-performance-followups | a2eb6db0efbef983e1b3242261d5cc6b2b9d839d | review-followup | One late P2 rollout-compatibility defect was confirmed: the provider-fallback SLO would omit recent rows written before `provider_generation_degraded` existed. Fixed the count predicate to include the new flag or legacy `generation_fallback:` reasons while continuing to exclude intentional extractive routes. | GitHub connector review-thread inspection; focused answer-SLO test, ESLint, TypeScript, Prettier, and `git diff --check`. | | 2026-07-14 | codex/specifiers-design | c3e7024e15d2157bbc0b989276324cc6ad3eea5c | Specifiers UI, clinical decision-support, accessibility, and release-readiness review | One P2 WCAG contrast defect was reproduced and fixed across the builder, comparison, and map accent eyebrows. No remaining high-confidence defect was found in the changed scope; residual risk is clinical/manual governance of the original specifier summaries. | Focused Vitest 18/18; focused Chromium desktop/mobile 2/2 with serious/critical axe WCAG A/AA scanning and overflow checks; `npm run check:production-readiness:ci` READY; `npm run verify:cheap` 2,210 passed/1 skipped; `npm run verify:pr-local` 2,213 passed/1 skipped plus production build and client-bundle secret scan; `git diff --check`. Full advisory `verify:ui` exceeded the local 10-minute execution window; provider-backed checks were not run. | -| 2026-07-14 | PR #633 / codex/specifiers-design | b9491a4420c190dd85979c0e78a2efe0b3a78693 | Review follow-up and failed UI regression | Six distinct P2 behaviors across seven review threads were confirmed and fixed: initial selection normalization, duplicate query removal, clinical applicability mapping, canonical Specifiers routing, severity-neutral base wording, and diagnostic-section ordering. The failed app-menu keyboard test was a stale order assertion and now covers the inserted Specifiers item. | Focused Vitest 5/5; focused Chromium 3/3 plus the diagnostic-order regression; scoped ESLint and TypeScript; CI-mode production readiness READY; production build and client-bundle secret scan passed. Hosted required checks passed before the final ordering fix; provider-backed Supabase/OpenAI checks were not run. | +| 2026-07-14 | PR #633 / codex/specifiers-design | 03daca3bfdf86517b9956f3c7d91be27d9f1a751 | Review follow-up and failed UI regression | Eight distinct P2 behaviors across nine review threads were confirmed and fixed: initial selection normalization, duplicate query removal, clinical applicability mapping, canonical Specifiers routing, severity-neutral base wording, diagnostic-section ordering, base/specifier compatibility, and severe psychotic-features wording. The failed app-menu keyboard test was a stale order assertion and now covers the inserted Specifiers item. | Focused Vitest 6/6; focused Chromium 4/4; scoped ESLint and TypeScript; CI-mode production readiness READY; production build and client-bundle secret scan passed. Hosted required checks passed on the refreshed `main` merge before the final compatibility fixes; provider-backed Supabase/OpenAI checks were not run. | diff --git a/src/components/specifiers/specifier-builder-page.tsx b/src/components/specifiers/specifier-builder-page.tsx index b56a8d015..d22d0a091 100644 --- a/src/components/specifiers/specifier-builder-page.tsx +++ b/src/components/specifiers/specifier-builder-page.tsx @@ -14,18 +14,20 @@ import { import { cn, eyebrowText } from "@/components/ui-primitives"; import { normalizeSpecifierSelection, + specifierAppliesToBuilderDiagnosis, specifierFamilies, specifierRecords, + type SpecifierBuilderDiagnosis, type SpecifierFamily, type SpecifierRecord, } from "@/lib/specifiers"; -const diagnosisPresets = [ - "Major depressive disorder, recurrent", - "Major depressive disorder, single episode", - "Bipolar I disorder, current episode depressed", - "Bipolar I disorder, current episode manic", - "Bipolar II disorder, current episode depressed", +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 = { @@ -34,15 +36,21 @@ const specifierFamilyOrder: Record = { "severity-remission": 2, }; -function wordingSegment(name: string) { - if (name === "Mild severity") return "mild"; - return name.charAt(0).toLowerCase() + name.slice(1); +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 validInitial = normalizeSpecifierSelection(initialSpecifiers); - const [diagnosis, setDiagnosis] = useState(diagnosisPresets[0]); + const initialDiagnosis = diagnosisPresets[0]; + const validInitial = normalizeSpecifierSelection(initialSpecifiers).filter((slug) => { + const record = specifierRecords.find((candidate) => candidate.slug === slug); + return record ? specifierAppliesToBuilderDiagnosis(record, initialDiagnosis.id) : false; + }); + const [diagnosisId, setDiagnosisId] = useState(initialDiagnosis.id); const [selected, setSelected] = useState(validInitial); + const diagnosis = diagnosisPresets.find((preset) => preset.id === diagnosisId) ?? initialDiagnosis; const selectedRecords = useMemo( () => selected @@ -51,7 +59,17 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif .sort((left, right) => specifierFamilyOrder[left.family] - specifierFamilyOrder[right.family]), [selected], ); - const wording = [diagnosis, ...selectedRecords.map((record) => wordingSegment(record.name))].join(", "); + 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) => { @@ -95,13 +113,13 @@ export function SpecifierBuilderPage({ initialSpecifiers = [] }: { initialSpecif