From 8ab0e2826eab329d0a2c86347c3be4b2836582ff Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:56:17 +0800 Subject: [PATCH 1/5] feat(formulation): add psychiatric formulation workspace --- docs/codebase-index.md | 44 +- docs/site-map.md | 45 +- playwright.config.ts | 4 +- scripts/generate-site-map.ts | 33 +- src/app/formulation/[slug]/page.tsx | 15 + src/app/formulation/builder/page.tsx | 19 + src/app/formulation/compare/page.tsx | 14 + .../{specifiers => formulation}/layout.tsx | 4 +- src/app/formulation/map/page.tsx | 11 + src/app/formulation/page.tsx | 16 + src/app/page.tsx | 15 +- src/app/specifiers/[[...path]]/page.tsx | 19 + src/app/specifiers/[slug]/page.tsx | 16 - src/app/specifiers/builder/page.tsx | 16 - src/app/specifiers/compare/page.tsx | 14 - src/app/specifiers/map/page.tsx | 11 - src/app/specifiers/page.tsx | 17 - .../clinical-dashboard/ClinicalSidebar.tsx | 4 +- .../global-search-shell.tsx | 8 +- .../master-search-header.tsx | 22 +- .../clinical-dashboard/mode-action-popup.tsx | 36 +- .../formulation/formulation-builder-page.tsx | 707 ++++++++++ .../formulation/formulation-compare-page.tsx | 286 ++++ .../formulation-home-page.tsx} | 245 ++-- .../formulation/formulation-map-page.tsx | 188 +++ .../formulation-mechanism-page.tsx | 361 +++++ .../formulation-ui.tsx} | 73 +- .../specifiers/specifier-builder-page.tsx | 264 ---- .../specifiers/specifier-compare-page.tsx | 238 ---- .../specifiers/specifier-map-page.tsx | 169 --- .../specifiers/specifier-record-page.tsx | 251 ---- src/data/formulation-content.json | 1188 +++++++++++++++++ src/lib/app-mode-icons.ts | 4 +- src/lib/app-modes.ts | 40 +- src/lib/formulation.ts | 311 +++++ src/lib/specifiers.ts | 711 ---------- tests/app-modes.test.ts | 32 +- tests/formulation.test.ts | 100 ++ tests/specifiers.test.ts | 91 -- tests/ui-formulation.spec.ts | 154 +++ tests/ui-smoke.spec.ts | 12 +- tests/ui-specifiers.spec.ts | 138 -- 42 files changed, 3707 insertions(+), 2239 deletions(-) create mode 100644 src/app/formulation/[slug]/page.tsx create mode 100644 src/app/formulation/builder/page.tsx create mode 100644 src/app/formulation/compare/page.tsx rename src/app/{specifiers => formulation}/layout.tsx (53%) create mode 100644 src/app/formulation/map/page.tsx create mode 100644 src/app/formulation/page.tsx create mode 100644 src/app/specifiers/[[...path]]/page.tsx delete mode 100644 src/app/specifiers/[slug]/page.tsx delete mode 100644 src/app/specifiers/builder/page.tsx delete mode 100644 src/app/specifiers/compare/page.tsx delete mode 100644 src/app/specifiers/map/page.tsx delete mode 100644 src/app/specifiers/page.tsx create mode 100644 src/components/formulation/formulation-builder-page.tsx create mode 100644 src/components/formulation/formulation-compare-page.tsx rename src/components/{specifiers/specifiers-home-page.tsx => formulation/formulation-home-page.tsx} (56%) create mode 100644 src/components/formulation/formulation-map-page.tsx create mode 100644 src/components/formulation/formulation-mechanism-page.tsx rename src/components/{specifiers/specifier-ui.tsx => formulation/formulation-ui.tsx} (56%) delete mode 100644 src/components/specifiers/specifier-builder-page.tsx delete mode 100644 src/components/specifiers/specifier-compare-page.tsx delete mode 100644 src/components/specifiers/specifier-map-page.tsx delete mode 100644 src/components/specifiers/specifier-record-page.tsx create mode 100644 src/data/formulation-content.json create mode 100644 src/lib/formulation.ts delete mode 100644 src/lib/specifiers.ts create mode 100644 tests/formulation.test.ts delete mode 100644 tests/specifiers.test.ts create mode 100644 tests/ui-formulation.spec.ts delete mode 100644 tests/ui-specifiers.spec.ts diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 3f4a4dcc8..26088386c 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -44,23 +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 (9):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, specifiers, prescribing, tools +- **Modes (9):** `src/lib/app-modes.ts` — answer, documents, services, forms, favourites, differentials, formulation, 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/` | -| `/specifiers`, `/specifiers/[slug]`, `/builder`, `/compare`, `/map` | `src/app/specifiers/` | -| `/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/` | +| `/formulation`, `/formulation/[slug]`, `/builder`, `/compare`, `/map` | `src/app/formulation/` | +| `/mockups/*` | `src/app/mockups/` (404 in production) | +| `/auth/callback` | `src/app/auth/callback/route.ts` | ### API routes (`src/app/api/`) @@ -123,11 +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 | -| `specifiers.ts` | Local psychiatric specifier catalogue | -| `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 | +| `formulation.ts` | Local formulation mechanism library and builder helpers | +| `clinical-safety.ts`, `demo-data.ts`, `ui-copy.ts` | Safety copy and demo mode | ### Infra helpers @@ -252,16 +252,16 @@ Golden retrieval fixture: `scripts/fixtures/rag-retrieval-golden.json` - 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; Specifiers is a local structured decision-support catalogue +- Registry modes: services, forms, medications, differentials; Formulation is a local mechanism and structured-draft workspace - 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`, `/specifiers`, `/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`, `/formulation`, `/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, `/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`. +- **Results routing**: standalone routes own their submitted searches via `?q=…&run=1` (`/services` → `ServicesNavigatorPage`, `/forms` → `FormsSearchResultsPage`, `/differentials` → `DifferentialsHome` results view, `/formulation` → local mechanism results, `/favourites` filters the command library in place). Answer, Documents, and Prescribing submitted searches render inside `ClinicalDashboard` — intentional, since they need retrieval/answer state. `/?mode=favourites` redirects to `/favourites`; `/?mode=differentials` redirects to `/differentials`; `/?mode=formulation` redirects to `/formulation`. - **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 90a90483f..affd3a90e 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -15,14 +15,15 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/documents/source/evidence` - Compatibility redirect sharing the canonical live document viewer handoff. Source: `src/app/documents/source/evidence/page.tsx`. - `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. +- `/formulation` - Clinical formulation home and local mechanism search surface. Source: `src/app/formulation/page.tsx`. +- `/formulation/builder` - Structured clinical formulation builder. Source: `src/app/formulation/builder/page.tsx`. +- `/formulation/compare` - Side-by-side mechanism comparison. Source: `src/app/formulation/compare/page.tsx`. +- `/formulation/map` - Formulation mechanism domain map. Source: `src/app/formulation/map/page.tsx`. - `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. - `/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`. +- `/specifiers/[[...path]]` - Compatibility redirect to the corrected Formulation workspace. Source: `src/app/specifiers/[[...path]]/page.tsx`. - `/tools` - Route discovered from app directory Source: `src/app/tools/page.tsx`. ## Mode/query routes @@ -33,7 +34,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/forms` - Forms mode. Search kind: `forms`. Query example: `/forms?q=transport+forms&focus=1&run=1`. - `/favourites` - Favourites mode. Search kind: `favourites`. Query example: `/favourites?q=clozapine+set&focus=1&run=1`. - `/differentials` - Differentials mode. Search kind: `differentials`. Query example: `/differentials?q=acute+confusion&focus=1&run=1`. -- `/specifiers` - Specifiers mode. Search kind: `specifiers`. Query example: `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1`. +- `/formulation` - Formulation mode. Search kind: `formulation`. Query example: `/formulation?q=I+keep+going+over+it&focus=1&run=1`. - `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. - `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. @@ -47,7 +48,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | | Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | | Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`. | -| Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | +| Formulation | `/formulation` | `/formulation?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | | Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | | Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`). | @@ -505,21 +506,21 @@ 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` +### Formulation mechanism slugs + +- `/formulation/[slug]` - Dynamic route family. +- `attachment-avoidance` +- `avoidance` +- `dissociation` +- `emotional-dysregulation` +- `negative-core-beliefs` +- `perfectionism` +- `projection` +- `reassurance-seeking` +- `rumination` +- `shame` +- `splitting` +- `worry` ### Medication slugs @@ -631,7 +632,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Forms | `src/app/forms, src/lib/forms.ts, src/app/api/registry/records` | | Favourites | `src/app/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx` | | Differentials | `src/app/differentials, src/lib/differentials.ts` | -| Specifiers | `src/app/specifiers, src/components/specifiers, src/lib/specifiers.ts` | +| Formulation | `src/app/formulation, src/components/formulation, src/lib/formulation.ts` | | Medications | `src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx` | | Documents | `src/app/documents, src/lib/document-flow-routes.ts` | | Tools | `src/components/applications-launcher-page.tsx` | diff --git a/playwright.config.ts b/playwright.config.ts index aa0cdd32f..ddcce3876 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|specifiers)\.spec\.ts/; +const productionSpecPattern = /.*ui-(smoke|stress|accessibility|tools|overlap|universal-search|formulation)\.spec\.ts/; const mockupSpecPattern = /.*ui-(tools|tools-collapse|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers)\.spec\.ts/, + /.*ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|formulation)\.spec\.ts/, timeout: 60_000, retries: process.env.CI ? 1 : 0, expect: { diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index ce1d12d7d..cacbd8b2b 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -6,9 +6,9 @@ import { format } from "prettier"; import { appModeDefinitions, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; import { documentsSearchHref, DOCUMENTS_MODE_HOME_ROUTE } from "@/lib/document-flow-routes"; import { differentialRecords } from "@/lib/differentials"; +import { formulationMechanisms } from "@/lib/formulation"; import { formRecords } from "@/lib/forms"; import { serviceRecords } from "@/lib/services"; -import { specifierRecords } from "@/lib/specifiers"; const appDir = path.join(process.cwd(), "src", "app"); const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); @@ -52,11 +52,12 @@ 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.", + "/formulation": "Clinical formulation home and local mechanism search surface.", + "/formulation/[slug]": "Formulation mechanism decision-support guide.", + "/formulation/builder": "Structured clinical formulation builder.", + "/formulation/compare": "Side-by-side mechanism comparison.", + "/formulation/map": "Formulation mechanism domain map.", + "/specifiers/[[...path]]": "Compatibility redirect to the corrected Formulation workspace.", }; const apiDescriptions: Record = { @@ -96,7 +97,7 @@ const routeOwnershipRows = [ ["Forms", "src/app/forms, src/lib/forms.ts, src/app/api/registry/records"], ["Favourites", "src/app/favourites, src/components/clinical-dashboard/favourites-command-library-page.tsx"], ["Differentials", "src/app/differentials, src/lib/differentials.ts"], - ["Specifiers", "src/app/specifiers, src/components/specifiers, src/lib/specifiers.ts"], + ["Formulation", "src/app/formulation, src/components/formulation, src/lib/formulation.ts"], ["Medications", "src/app/medications, src/components/clinical-dashboard/medication-prescribing-workspace.tsx"], ["Documents", "src/app/documents, src/lib/document-flow-routes.ts"], ["Tools", "src/components/applications-launcher-page.tsx"], @@ -205,7 +206,7 @@ function renderModeRoutes() { forms: appModeHomeHref("forms", { query: "transport forms", focus: true, run: true }), favourites: appModeHomeHref("favourites", { query: "clozapine set", focus: true, run: true }), differentials: appModeHomeHref("differentials", { query: "acute confusion", focus: true, run: true }), - specifiers: appModeHomeHref("specifiers", { query: "depressed but racing thoughts", focus: true, run: true }), + formulation: appModeHomeHref("formulation", { query: "I keep going over it", focus: true, run: true }), prescribing: appModeHomeHref("prescribing", { query: "acamprosate renal dose", focus: true, run: true }), tools: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), }; @@ -273,10 +274,10 @@ function renderModePageIndex() { detail: "`/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, and `/differentials/presentations`.", }, { - mode: "Specifiers", - home: appModeHomeHref("specifiers"), - search: appModeHomeHref("specifiers", { query: "depressed but racing thoughts", focus: true, run: true }), - detail: "`/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`.", + mode: "Formulation", + home: appModeHomeHref("formulation"), + search: appModeHomeHref("formulation", { query: "I keep going over it", focus: true, run: true }), + detail: "`/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`.", }, { mode: "Medication", @@ -326,7 +327,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { "/services/[slug]", "/forms/[slug]", "/differentials/diagnoses/[slug]", - "/specifiers/[slug]", + "/formulation/[slug]", "/medications/[slug]", ].includes(route.route), ); @@ -377,9 +378,9 @@ function renderSiteMapRaw(data = collectSiteMapData()) { ), "", ...renderSlugInventory( - "Psychiatric specifier slugs", - "/specifiers/[slug]", - specifierRecords.map((record) => record.slug), + "Formulation mechanism slugs", + "/formulation/[slug]", + formulationMechanisms.map((mechanism) => mechanism.id), ), "", ...renderSlugInventory("Medication slugs", "/medications/[slug]", medicationSlugs), diff --git a/src/app/formulation/[slug]/page.tsx b/src/app/formulation/[slug]/page.tsx new file mode 100644 index 000000000..5752eea9a --- /dev/null +++ b/src/app/formulation/[slug]/page.tsx @@ -0,0 +1,15 @@ +import { notFound } from "next/navigation"; + +import { FormulationMechanismPage } from "@/components/formulation/formulation-mechanism-page"; +import { findFormulationMechanism, formulationMechanisms } from "@/lib/formulation"; + +export function generateStaticParams() { + return formulationMechanisms.map((mechanism) => ({ slug: mechanism.id })); +} + +export default async function FormulationMechanismRoute({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const mechanism = findFormulationMechanism(slug); + if (!mechanism) notFound(); + return ; +} diff --git a/src/app/formulation/builder/page.tsx b/src/app/formulation/builder/page.tsx new file mode 100644 index 000000000..1fc2e66ad --- /dev/null +++ b/src/app/formulation/builder/page.tsx @@ -0,0 +1,19 @@ +import { FormulationBuilderPage } from "@/components/formulation/formulation-builder-page"; + +type BuilderRouteProps = { + searchParams?: Promise<{ + mechanism?: string | string[]; + template?: string | string[]; + }>; +}; + +export default async function FormulationBuilderRoute({ searchParams }: BuilderRouteProps) { + const params = searchParams ? await searchParams : {}; + const initialMechanisms = Array.isArray(params.mechanism) + ? Array.from(new Set(params.mechanism)) + : params.mechanism + ? [params.mechanism] + : []; + const initialTemplate = Array.isArray(params.template) ? params.template[0] : params.template; + return ; +} diff --git a/src/app/formulation/compare/page.tsx b/src/app/formulation/compare/page.tsx new file mode 100644 index 000000000..ac29c74f4 --- /dev/null +++ b/src/app/formulation/compare/page.tsx @@ -0,0 +1,14 @@ +import { FormulationComparePage } from "@/components/formulation/formulation-compare-page"; + +type CompareRouteProps = { + searchParams?: Promise<{ a?: string | string[]; b?: string | string[] }>; +}; + +function firstParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function FormulationCompareRoute({ searchParams }: CompareRouteProps) { + const params = searchParams ? await searchParams : {}; + return ; +} diff --git a/src/app/specifiers/layout.tsx b/src/app/formulation/layout.tsx similarity index 53% rename from src/app/specifiers/layout.tsx rename to src/app/formulation/layout.tsx index 744354acd..3c2c1a89f 100644 --- a/src/app/specifiers/layout.tsx +++ b/src/app/formulation/layout.tsx @@ -2,9 +2,9 @@ import type { ReactNode } from "react"; import { GlobalSearchShell } from "@/components/clinical-dashboard/global-search-shell"; -export default function SpecifiersLayout({ children }: { children: ReactNode }) { +export default function FormulationLayout({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/src/app/formulation/map/page.tsx b/src/app/formulation/map/page.tsx new file mode 100644 index 000000000..46b24f6d7 --- /dev/null +++ b/src/app/formulation/map/page.tsx @@ -0,0 +1,11 @@ +import { FormulationMapPage } from "@/components/formulation/formulation-map-page"; + +type MapRouteProps = { + searchParams?: Promise<{ mechanism?: string | string[] }>; +}; + +export default async function FormulationMapRoute({ searchParams }: MapRouteProps) { + const params = searchParams ? await searchParams : {}; + const initialId = Array.isArray(params.mechanism) ? params.mechanism[0] : params.mechanism; + return ; +} diff --git a/src/app/formulation/page.tsx b/src/app/formulation/page.tsx new file mode 100644 index 000000000..a4a58ecc4 --- /dev/null +++ b/src/app/formulation/page.tsx @@ -0,0 +1,16 @@ +import { FormulationHomePage } from "@/components/formulation/formulation-home-page"; + +type FormulationRouteProps = { + searchParams?: Promise<{ q?: string | string[]; run?: string | string[] }>; +}; + +function firstParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + +export default async function FormulationRoute({ searchParams }: FormulationRouteProps) { + const params = searchParams ? await searchParams : {}; + const query = firstParam(params.q)?.trim() ?? ""; + const hasSubmittedSearch = firstParam(params.run) === "1" && query.length > 0; + return ; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index cf08ba561..b7d72a499 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -19,6 +19,7 @@ function firstSearchParam(value: string | string[] | undefined) { export default async function Home({ searchParams }: HomeProps) { const params = searchParams ? await searchParams : {}; const requestedMode = firstSearchParam(params.mode); + const legacySpecifierMode = requestedMode === "specifiers"; const initialSearchMode: AppModeId = isAppModeId(requestedMode) && isAppModeVisible(requestedMode) ? requestedMode : "answer"; @@ -44,14 +45,14 @@ export default async function Home({ searchParams }: HomeProps) { redirect(suffix ? `/differentials?${suffix}` : "/differentials"); } - if (initialSearchMode === "specifiers") { - const specifierParams = new URLSearchParams(); + if (initialSearchMode === "formulation" || legacySpecifierMode) { + const formulationParams = new URLSearchParams(); const query = firstSearchParam(params.q)?.trim(); - if (query) specifierParams.set("q", query); - if (firstSearchParam(params.focus) === "1") specifierParams.set("focus", "1"); - if (firstSearchParam(params.run) === "1") specifierParams.set("run", "1"); - const suffix = specifierParams.toString(); - redirect(suffix ? `/specifiers?${suffix}` : "/specifiers"); + if (query) formulationParams.set("q", query); + if (firstSearchParam(params.focus) === "1") formulationParams.set("focus", "1"); + if (firstSearchParam(params.run) === "1") formulationParams.set("run", "1"); + const suffix = formulationParams.toString(); + redirect(suffix ? `/formulation?${suffix}` : "/formulation"); } return ; diff --git a/src/app/specifiers/[[...path]]/page.tsx b/src/app/specifiers/[[...path]]/page.tsx new file mode 100644 index 000000000..d5f23fe32 --- /dev/null +++ b/src/app/specifiers/[[...path]]/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; + +type LegacySpecifierRouteProps = { + params: Promise<{ path?: string[] }>; + searchParams?: Promise>; +}; + +export default async function LegacySpecifierRoute({ params, searchParams }: LegacySpecifierRouteProps) { + const { path = [] } = await params; + const values = searchParams ? await searchParams : {}; + const nextParams = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (Array.isArray(value)) value.forEach((item) => nextParams.append(key === "specifier" ? "mechanism" : key, item)); + else if (value) nextParams.set(key === "specifier" ? "mechanism" : key, value); + } + const pathname = ["/formulation", ...path].join("/"); + const suffix = nextParams.toString(); + redirect(suffix ? `${pathname}?${suffix}` : pathname); +} diff --git a/src/app/specifiers/[slug]/page.tsx b/src/app/specifiers/[slug]/page.tsx deleted file mode 100644 index e8bb1a5d5..000000000 --- a/src/app/specifiers/[slug]/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 26e85256f..000000000 --- a/src/app/specifiers/builder/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { SpecifierBuilderPage } from "@/components/specifiers/specifier-builder-page"; - -type BuilderRouteProps = { - searchParams?: Promise<{ specifier?: string | string[] }>; -}; - -export default async function SpecifierBuilderRoute({ searchParams }: BuilderRouteProps) { - const params = searchParams ? await searchParams : {}; - const initialSpecifiers = Array.isArray(params.specifier) - ? Array.from(new Set(params.specifier)) - : params.specifier - ? [params.specifier] - : []; - - return ; -} diff --git a/src/app/specifiers/compare/page.tsx b/src/app/specifiers/compare/page.tsx deleted file mode 100644 index 41fb3905f..000000000 --- a/src/app/specifiers/compare/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -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/map/page.tsx b/src/app/specifiers/map/page.tsx deleted file mode 100644 index 0f77de187..000000000 --- a/src/app/specifiers/map/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index b685ade9f..000000000 --- a/src/app/specifiers/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 2c6597049..492f496a1 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -11,6 +11,7 @@ import { MessageSquarePlus, MessageSquare, Moon, + Network, PanelLeftClose, PanelLeftOpen, Pill, @@ -18,7 +19,6 @@ import { Settings as SettingsIcon, Sparkles, Sun, - Tags, Wrench, } from "lucide-react"; import { appModeIcons } from "@/lib/app-mode-icons"; @@ -76,7 +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: "formulation", label: "Formulation", icon: Network, href: "/formulation" }, { id: "prescribing", label: "Medication", icon: Pill, href: "/?mode=prescribing" }, { id: "tools", label: "Tools", icon: Wrench, href: "/?mode=tools" }, ] as const; diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index ee61e795a..3bc291bfd 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -131,7 +131,7 @@ function GlobalSearchShellClient(props: GlobalSearchShellProps) { resolvedSearchMode !== "forms" && resolvedSearchMode !== "favourites" && resolvedSearchMode !== "differentials" && - resolvedSearchMode !== "specifiers" && + resolvedSearchMode !== "formulation" && !isDocumentSearchMockupRoute; const isMedicationDetailRoute = /^\/medications\/[^/]+$/.test(pathname); const shouldRenderClinicalDashboard = !isMedicationDetailRoute && (isHomeRoute || shouldRenderDashboardSearch); @@ -251,7 +251,7 @@ function GlobalStandaloneSearchShellClient({ resolvedSearchMode !== "forms" && resolvedSearchMode !== "favourites" && resolvedSearchMode !== "differentials" && - resolvedSearchMode !== "specifiers" && + resolvedSearchMode !== "formulation" && !isDocumentSearchMockupRoute; const isStandaloneModeHome = !hasSubmittedModeSearch && @@ -260,7 +260,7 @@ function GlobalStandaloneSearchShellClient({ (searchMode === "forms" && pathname === "/forms") || (searchMode === "favourites" && pathname === "/favourites") || (searchMode === "differentials" && pathname === "/differentials") || - (searchMode === "specifiers" && pathname === "/specifiers") || + (searchMode === "formulation" && pathname === "/formulation") || (searchMode === "tools" && pathname === "/tools")); const isDifferentialPresentationWorkflow = pathname.startsWith("/differentials/presentations"); const shouldShowDesktopSidebar = !hideDesktopSidebar; @@ -339,7 +339,7 @@ function GlobalStandaloneSearchShellClient({ router.prefetch("/?mode=tools"); router.prefetch("/favourites"); router.prefetch("/differentials"); - router.prefetch("/specifiers"); + router.prefetch("/formulation"); } function openGuide() { diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 86e06ee4e..dd9beb7e4 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -276,7 +276,7 @@ export function MasterSearchHeader({ selectedSearch.kind === "services" || selectedSearch.kind === "tools" || selectedSearch.kind === "favourites" || - selectedSearch.kind === "specifiers"; + selectedSearch.kind === "formulation"; const canAsk = trimmedQuery.length >= 1 && !loading && selectedSearchable && (realDataReady || canRunLocalSearch); const indexedDocumentTotal = documentTotal ?? documents.length; const hasUnloadedDocuments = indexedDocumentTotal > documents.length; @@ -443,8 +443,8 @@ export function MasterSearchHeader({ ? "favourites" : searchMode === "differentials" ? "differentials" - : searchMode === "specifiers" - ? "specifiers" + : searchMode === "formulation" + ? "formulation" : searchMode === "tools" ? "tools" : "answer"; @@ -608,20 +608,20 @@ export function MasterSearchHeader({ onOpenEvidence?.(); return; } - if (actionId === "specifiers-search") { - onSearchModeChange("specifiers"); + if (actionId === "formulation-search") { + onSearchModeChange("formulation"); return; } - if (actionId === "specifiers-builder") { - window.location.assign("/specifiers/builder"); + if (actionId === "formulation-builder") { + window.location.assign("/formulation/builder"); return; } - if (actionId === "specifiers-compare") { - window.location.assign("/specifiers/compare"); + if (actionId === "formulation-compare") { + window.location.assign("/formulation/compare"); return; } - if (actionId === "specifiers-map") { - window.location.assign("/specifiers/map"); + if (actionId === "formulation-map") { + window.location.assign("/formulation/map"); return; } } diff --git a/src/components/clinical-dashboard/mode-action-popup.tsx b/src/components/clinical-dashboard/mode-action-popup.tsx index 5323a7959..a95f8daf1 100644 --- a/src/components/clinical-dashboard/mode-action-popup.tsx +++ b/src/components/clinical-dashboard/mode-action-popup.tsx @@ -30,12 +30,12 @@ import { ListChecks, Lock, MessageSquarePlus, + Network, Plus, Search, ShieldCheck, Sparkles, Table2, - Tags, UploadCloud, Waypoints, Wrench, @@ -55,7 +55,7 @@ export type ModeActionSetId = | "favourites" | "tools" | "differentials" - | "specifiers" + | "formulation" | "prescribing"; export type ModeActionPlacement = "up" | "down"; @@ -118,10 +118,10 @@ export type ModeActionId = | "differentials-criteria" | "differentials-documents" | "differentials-evidence" - | "specifiers-search" - | "specifiers-builder" - | "specifiers-compare" - | "specifiers-map"; + | "formulation-search" + | "formulation-builder" + | "formulation-compare" + | "formulation-map"; export type ModeActionItem = { id: ModeActionId; @@ -231,27 +231,27 @@ const modeActionSets = { }, { id: "differentials-evidence", label: "View evidence", description: "Review cited support", icon: ShieldCheck }, ], - specifiers: [ + formulation: [ { - id: "specifiers-search", - label: "Find a specifier", - description: "Match presentation features", - icon: Tags, + id: "formulation-search", + label: "Find a mechanism", + description: "Match clinical clues and patient language", + icon: Network, primary: true, }, { - id: "specifiers-builder", - label: "Build wording", - description: "Assemble diagnostic wording", + id: "formulation-builder", + label: "Build formulation", + description: "Structure and review a working draft", icon: ListChecks, }, { - id: "specifiers-compare", - label: "Compare specifiers", - description: "Clarify close clinical calls", + id: "formulation-compare", + label: "Compare mechanisms", + description: "Test alternative hypotheses", icon: GitCompareArrows, }, - { id: "specifiers-map", label: "Specifier map", description: "Browse by diagnostic role", icon: Waypoints }, + { id: "formulation-map", label: "Mechanism map", description: "Browse by formulation domain", icon: Waypoints }, ], prescribing: [ { diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx new file mode 100644 index 000000000..89c7ff146 --- /dev/null +++ b/src/components/formulation/formulation-builder-page.tsx @@ -0,0 +1,707 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowLeft, + ArrowRight, + Check, + Clipboard, + FileCheck2, + Network, + RotateCcw, + Search, + ShieldCheck, + Sparkles, + Target, + Waypoints, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + FormulationBreadcrumbs, + FormulationPageShell, + FormulationSafetyNote, + FormulationSubnav, + MechanismDomainChips, + SessionPrivacyNote, + formulationCard, +} from "@/components/formulation/formulation-ui"; +import { cn, eyebrowText } from "@/components/ui-primitives"; +import { + findFormulationMechanism, + formulationDomains, + formulationDraftFor, + formulationQualityPrompts, + formulationSectionsForTemplate, + formulationTemplates, + normalizeMechanismSelection, + searchFormulationMechanisms, + suggestionsForFormulationSection, + type FormulationMechanism, +} from "@/lib/formulation"; + +const builderSteps = [ + { id: "select", label: "Select", description: "Mechanisms" }, + { id: "structure", label: "Structure", description: "Framework" }, + { id: "review", label: "Review", description: "Quality" }, + { id: "draft", label: "Draft", description: "Formulation" }, +] as const; + +type BuilderStepId = (typeof builderSteps)[number]["id"]; + +function StepProgress({ active, onChange }: { active: BuilderStepId; onChange: (step: BuilderStepId) => void }) { + const activeIndex = builderSteps.findIndex((step) => step.id === active); + return ( +
    + {builderSteps.map((step, index) => { + const isActive = step.id === active; + const isComplete = index < activeIndex; + return ( +
  1. + +
  2. + ); + })} +
+ ); +} + +function BuilderThread({ + mechanisms, + templateId, + completedQuality, +}: { + mechanisms: FormulationMechanism[]; + templateId: string; + completedQuality: number; +}) { + return ( + + ); +} + +function StepHeading({ eyebrow, title, body }: { eyebrow: string; title: string; body: string }) { + return ( +
+

{eyebrow}

+

{title}

+

{body}

+
+ ); +} + +export function FormulationBuilderPage({ + initialMechanisms = [], + initialTemplate, +}: { + initialMechanisms?: string[]; + initialTemplate?: string; +}) { + const validInitialTemplate = formulationTemplates.some((template) => template.id === initialTemplate) + ? initialTemplate! + : formulationTemplates[0].id; + const [activeStep, setActiveStep] = useState("select"); + const [selectedIds, setSelectedIds] = useState(() => normalizeMechanismSelection(initialMechanisms)); + const [query, setQuery] = useState(""); + const [domain, setDomain] = useState("all"); + const [templateId, setTemplateId] = useState(validInitialTemplate); + const [sectionNotes, setSectionNotes] = useState>({}); + const [qualityNotes, setQualityNotes] = useState>({}); + const [editedDraft, setEditedDraft] = useState(null); + const [copied, setCopied] = useState(false); + + const selectedMechanisms = useMemo( + () => + selectedIds + .map((id) => findFormulationMechanism(id)) + .filter((mechanism): mechanism is FormulationMechanism => Boolean(mechanism)), + [selectedIds], + ); + const visibleMechanisms = useMemo( + () => searchFormulationMechanisms(query, { domain }).map((result) => result.mechanism), + [domain, query], + ); + const activeSections = formulationSectionsForTemplate(templateId); + const generatedDraft = formulationDraftFor({ + mechanisms: selectedMechanisms, + templateId, + notes: sectionNotes, + qualityNotes, + }); + const draft = editedDraft ?? generatedDraft; + const completedQuality = formulationQualityPrompts.filter((prompt) => qualityNotes[prompt.id]?.trim()).length; + const activeIndex = builderSteps.findIndex((step) => step.id === activeStep); + + function toggleMechanism(id: string) { + setSelectedIds((current) => + current.includes(id) ? current.filter((item) => item !== id) : normalizeMechanismSelection([...current, id]), + ); + setEditedDraft(null); + } + + function updateSection(id: string, value: string) { + setSectionNotes((current) => ({ ...current, [id]: value })); + setEditedDraft(null); + } + + function applySuggestions(id: string) { + const suggestions = suggestionsForFormulationSection(selectedMechanisms, id); + if (!suggestions.length) return; + updateSection(id, suggestions.map((item) => `- ${item}`).join("\n")); + } + + function move(direction: -1 | 1) { + const next = builderSteps[activeIndex + direction]; + if (!next) return; + if (next.id === "draft") setEditedDraft(generatedDraft); + setActiveStep(next.id); + } + + async function copyDraft() { + await navigator.clipboard.writeText(draft); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } + + return ( + +
+ + +
+ +
+

Formulation builder

+

+ Build a formulation that can be tested +

+

+ Select candidate mechanisms, structure them with a clinical framework, challenge the hypothesis, then edit a + de-identified draft. +

+
+ + + +
+
+ {activeStep === "select" ? ( +
+ + +
+ {selectedMechanisms.length ? ( +
+
+
+

Selected hypotheses

+

+ {selectedMechanisms.length} {selectedMechanisms.length === 1 ? "mechanism" : "mechanisms"} in + the thread +

+
+ +
+
+ {selectedMechanisms.map((mechanism) => ( + + ))} +
+
+ ) : ( +
+
+ +

+ No mechanisms selected +

+

+ Search by mechanism, clinical clue, patient phrase, symptom, or domain. +

+
+
+ )} + +
+ + +
+ +
+ {visibleMechanisms.map((mechanism) => { + const checked = selectedIds.includes(mechanism.id); + return ( + + ); + })} +
+ +
+

Formulation language

+

+ {selectedMechanisms.length + ? selectedMechanisms.map((mechanism) => mechanism.exampleSentence).join(" ") + : "Select mechanisms to develop formulation language and treatment targets."} +

+
+
+
+ ) : null} + + {activeStep === "structure" ? ( +
+ +
+
+ {formulationTemplates.map((template) => { + const active = template.id === templateId; + return ( + + ); + })} +
+ +
+ {activeSections.map((section) => { + const suggestions = suggestionsForFormulationSection(selectedMechanisms, section.id); + return ( +
+
+
+

{section.label}

+

+ {section.prompt} +

+
+ {suggestions.length ? ( + + ) : null} +
+