diff --git a/docs/site-map.md b/docs/site-map.md index fa16c3d02..56e440321 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -691,6 +691,13 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## Mockup/prototype routes - `/mockups/answer-evidence-popups` - Route discovered from app directory Source: `src/app/mockups/answer-evidence-popups/page.tsx`. +- `/mockups/calculators-bedside-sheet` - Route discovered from app directory Source: `src/app/mockups/calculators-bedside-sheet/page.tsx`. +- `/mockups/calculators-clinical-console` - Route discovered from app directory Source: `src/app/mockups/calculators-clinical-console/page.tsx`. +- `/mockups/calculators-directory-grid` - Route discovered from app directory Source: `src/app/mockups/calculators-directory-grid/page.tsx`. +- `/mockups/calculators-guided-flow` - Route discovered from app directory Source: `src/app/mockups/calculators-guided-flow/page.tsx`. +- `/mockups/calculators-popup-sheet` - Route discovered from app directory Source: `src/app/mockups/calculators-popup-sheet/page.tsx`. +- `/mockups/calculators-search` - Route discovered from app directory Source: `src/app/mockups/calculators-search/page.tsx`. +- `/mockups/calculators-search-page` - Route discovered from app directory Source: `src/app/mockups/calculators-search-page/page.tsx`. - `/mockups/document-search` - Route discovered from app directory Source: `src/app/mockups/document-search/page.tsx`. - `/mockups/document-search-evidence-lens` - Route discovered from app directory Source: `src/app/mockups/document-search-evidence-lens/page.tsx`. - `/mockups/document-search-triage-board` - Route discovered from app directory Source: `src/app/mockups/document-search-triage-board/page.tsx`. diff --git a/src/app/mockups/calculators-bedside-sheet/page.tsx b/src/app/mockups/calculators-bedside-sheet/page.tsx new file mode 100644 index 000000000..fdfd598da --- /dev/null +++ b/src/app/mockups/calculators-bedside-sheet/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { CalculatorsBedsideSheetMockup } from "@/components/calculator-mockups"; + +export const metadata: Metadata = { + title: "Calculators Bedside Sheet Mockup - Clinical KB", + description: "Multi-scale bedside assessment sheet with a running session summary for Clinical KB.", +}; + +export default function CalculatorsBedsideSheetMockupRoute() { + return ; +} diff --git a/src/app/mockups/calculators-clinical-console/page.tsx b/src/app/mockups/calculators-clinical-console/page.tsx new file mode 100644 index 000000000..baf66eeb2 --- /dev/null +++ b/src/app/mockups/calculators-clinical-console/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { CalculatorsClinicalConsoleMockup } from "@/components/calculator-mockups"; + +export const metadata: Metadata = { + title: "Calculators Clinical Console Mockup - Clinical KB", + description: "Split-pane psychiatry calculator console with a live score ticker for Clinical KB.", +}; + +export default function CalculatorsClinicalConsoleMockupRoute() { + return ; +} diff --git a/src/app/mockups/calculators-directory-grid/page.tsx b/src/app/mockups/calculators-directory-grid/page.tsx new file mode 100644 index 000000000..811927db5 --- /dev/null +++ b/src/app/mockups/calculators-directory-grid/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { CalculatorsDirectoryGridMockup } from "@/components/calculator-mockups"; + +export const metadata: Metadata = { + title: "Calculators Directory Grid Mockup - Clinical KB", + description: "Searchable psychiatry calculator directory with expand-in-place scoring for Clinical KB.", +}; + +export default function CalculatorsDirectoryGridMockupRoute() { + return ; +} diff --git a/src/app/mockups/calculators-guided-flow/page.tsx b/src/app/mockups/calculators-guided-flow/page.tsx new file mode 100644 index 000000000..eeab3f746 --- /dev/null +++ b/src/app/mockups/calculators-guided-flow/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { CalculatorsGuidedFlowMockup } from "@/components/calculator-mockups"; + +export const metadata: Metadata = { + title: "Calculators Guided Flow Mockup - Clinical KB", + description: "Phone-first one-question-at-a-time psychiatry calculator flow for Clinical KB.", +}; + +export default function CalculatorsGuidedFlowMockupRoute() { + return ; +} diff --git a/src/app/mockups/calculators-popup-sheet/page.tsx b/src/app/mockups/calculators-popup-sheet/page.tsx new file mode 100644 index 000000000..ef91a5d44 --- /dev/null +++ b/src/app/mockups/calculators-popup-sheet/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { CalculatorsPopupSheetMockup } from "@/components/calculator-mockups"; + +export const metadata: Metadata = { + title: "Calculators Popup Sheet Mockup - Clinical KB", + description: + "Search-first psychiatry calculators page where each calculator opens as a desktop dialog or mobile bottom sheet for Clinical KB.", +}; + +export default function CalculatorsPopupSheetMockupRoute() { + return ; +} diff --git a/src/app/mockups/calculators-search-page/page.tsx b/src/app/mockups/calculators-search-page/page.tsx new file mode 100644 index 000000000..2e39887c4 --- /dev/null +++ b/src/app/mockups/calculators-search-page/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { CalculatorsSearchPageMockup } from "@/components/calculator-mockups"; + +export const metadata: Metadata = { + title: "Calculators Search Page Mockup - Clinical KB", + description: + "Search-results-shell psychiatry calculators page listing every calculator as a tile with a domain rail and popup scoring for Clinical KB.", +}; + +export default function CalculatorsSearchPageMockupRoute() { + return ; +} diff --git a/src/app/mockups/calculators-search/page.tsx b/src/app/mockups/calculators-search/page.tsx new file mode 100644 index 000000000..804b0a21d --- /dev/null +++ b/src/app/mockups/calculators-search/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import { CalculatorsSearchDetailMockup } from "@/components/calculator-mockups"; + +export const metadata: Metadata = { + title: "Calculators Search Mockup - Clinical KB", + description: + "Search-first psychiatry calculators page with individual calculator detail view and score-linked next clinical actions for Clinical KB.", +}; + +export default function CalculatorsSearchMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index f498a929e..583d30e68 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -17,6 +17,10 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { // primary search surface, so the shared bottom composer is hidden (as with // the tools/favourites mockups). const isTherapyCompassMockup = pathname.startsWith("/mockups/therapy-compass"); + // The calculators search page owns its own search input (top on desktop, docked + // at the bottom on phones), so the shared universal composer is suppressed here + // to avoid a second, floating search bar. + const isCalculatorsSearchPageMockup = pathname === "/mockups/calculators-search-page"; return ( diff --git a/src/components/calculator-mockups/bedside-sheet-mockup.tsx b/src/components/calculator-mockups/bedside-sheet-mockup.tsx new file mode 100644 index 000000000..9a520f42e --- /dev/null +++ b/src/components/calculator-mockups/bedside-sheet-mockup.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { AlertTriangle, CheckCheck, ChevronDown, ClipboardCopy, ClipboardList, NotebookPen } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { calculators, domainLabels, type CalculatorFixture } from "./calculator-fixtures"; +import { + CalculatorItems, + FlagNotice, + ScoreBandBar, + SeverityPill, + deriveCalculator, + focusRing, + formatResultSummary, + progressLabel, + toneBar, + type AnswerMap, +} from "./calculator-ui"; + +type SessionAnswers = Record; + +function SheetSection({ + calc, + answers, + open, + onToggleOpen, + onAnswersChange, +}: { + calc: CalculatorFixture; + answers: AnswerMap; + open: boolean; + onToggleOpen: () => void; + onAnswersChange: (next: AnswerMap) => void; +}) { + const derived = deriveCalculator(calc, answers); + const Icon = calc.icon; + + return ( +
+ + + {open ? ( +
+ + + + +
+
+ {progressLabel(derived)} + {derived.started ? : null} +
+ + {derived.started && derived.result.guidance ? ( +

{derived.result.guidance}

+ ) : null} + {calc.caution ? ( +

{calc.caution}

+ ) : null} +

+ {calc.scoringNote} · {calc.source} +

+
+
+ ) : null} +
+ ); +} + +export function CalculatorsBedsideSheetMockup() { + const [session, setSession] = useState({}); + const [openSections, setOpenSections] = useState>({ phq9: true }); + const [copied, setCopied] = useState(false); + + const startedCalcs = calculators + .map((calc) => ({ calc, derived: deriveCalculator(calc, session[calc.id] ?? {}) })) + .filter((entry) => entry.derived.started); + + const copySession = async () => { + const lines = startedCalcs.map(({ calc, derived }) => formatResultSummary(calc, derived)); + try { + await navigator.clipboard.writeText(lines.join("\n")); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable in some embeds — mockup-safe no-op */ + } + }; + + return ( +
+
+
+
+ + +
+

+ Assessment sheet +

+

+ Run several scales in one review — a summary builds as you go. +

+
+
+ +
+
+ +
+ {calculators.map((calc) => ( + setOpenSections((prev) => ({ ...prev, [calc.id]: !(prev[calc.id] ?? false) }))} + onAnswersChange={(next) => setSession((prev) => ({ ...prev, [calc.id]: next }))} + /> + ))} +
+ + {/* Session summary dock — floats above the site composer */} +
+
+
+ + + +
+ {startedCalcs.length ? ( + + ) : ( +

+ No scales started yet — completed scores collect here for your note. +

+ )} +
+
+
+ ); +} diff --git a/src/components/calculator-mockups/calculator-fixtures.ts b/src/components/calculator-mockups/calculator-fixtures.ts new file mode 100644 index 000000000..707da5c54 --- /dev/null +++ b/src/components/calculator-mockups/calculator-fixtures.ts @@ -0,0 +1,653 @@ +import { + Activity, + AlertTriangle, + Baby, + Brain, + CloudRain, + GlassWater, + HeartPulse, + Repeat2, + ShieldAlert, + Wine, + Zap, + type LucideIcon, +} from "lucide-react"; + +export type CalculatorTone = "success" | "info" | "warning" | "danger"; + +export type CalculatorOption = { + label: string; + /** Compact label for segmented controls on narrow screens. */ + short: string; + points: number; +}; + +export type CalculatorItem = { + id: string; + text: string; + /** Optional clarifier rendered under the item text. */ + detail?: string; + /** + * checkbox items score `points` when ticked and 0 when clear; + * options items score the selected option. + */ + kind: "checkbox" | "options"; + points?: number; + options?: CalculatorOption[]; + /** Safety flag surfaced whenever this item scores above zero. */ + flag?: string; +}; + +export type ScoreBand = { + min: number; + max: number; + label: string; + tone: CalculatorTone; + guidance: string; +}; + +export type CalculatorDomain = "mood" | "anxiety" | "substance" | "risk" | "distress"; + +export type CalculatorFixture = { + id: string; + abbrev: string; + name: string; + domain: CalculatorDomain; + icon: LucideIcon; + /** When to reach for this tool. */ + indication: string; + /** One-line summary for cards and rails. */ + summary: string; + /** Question stem shown above the items. */ + stem?: string; + timeEstimate: string; + minScore: number; + maxScore: number; + items: CalculatorItem[]; + bands: ScoreBand[]; + /** How to read the number, shown with the result. */ + scoringNote: string; + source: string; + caution?: string; +}; + +export const domainLabels: Record = { + mood: "Mood", + anxiety: "Anxiety & OCD", + substance: "Substance use", + risk: "Suicide risk", + distress: "General distress", +}; + +export const domainIcons: Record = { + mood: CloudRain, + anxiety: Zap, + substance: Wine, + risk: ShieldAlert, + distress: Activity, +}; + +const frequency0to3: CalculatorOption[] = [ + { label: "Not at all", short: "0", points: 0 }, + { label: "Several days", short: "1", points: 1 }, + { label: "More than half the days", short: "2", points: 2 }, + { label: "Nearly every day", short: "3", points: 3 }, +]; + +const kessler1to5: CalculatorOption[] = [ + { label: "None of the time", short: "1", points: 1 }, + { label: "A little of the time", short: "2", points: 2 }, + { label: "Some of the time", short: "3", points: 3 }, + { label: "Most of the time", short: "4", points: 4 }, + { label: "All of the time", short: "5", points: 5 }, +]; + +const ybocsSeverity: CalculatorOption[] = [ + { label: "None", short: "0", points: 0 }, + { label: "Mild", short: "1", points: 1 }, + { label: "Moderate", short: "2", points: 2 }, + { label: "Severe", short: "3", points: 3 }, + { label: "Extreme", short: "4", points: 4 }, +]; + +export const calculators: CalculatorFixture[] = [ + { + id: "phq9", + abbrev: "PHQ-9", + name: "Patient Health Questionnaire-9", + domain: "mood", + icon: CloudRain, + indication: "Screen for depression, grade severity, and track response to treatment over time.", + summary: "9-item depression severity score with treatment-action bands.", + stem: "Over the last 2 weeks, how often have you been bothered by:", + timeEstimate: "2–3 min", + minScore: 0, + maxScore: 27, + scoringNote: "Sum of 9 items (0–3 each). Severity bands map to stepped treatment actions.", + source: "Kroenke, Spitzer & Williams 2001", + caution: "Any endorsement of item 9 requires direct suicide-risk assessment regardless of total score.", + items: [ + { id: "p1", kind: "options", options: frequency0to3, text: "Little interest or pleasure in doing things" }, + { id: "p2", kind: "options", options: frequency0to3, text: "Feeling down, depressed, or hopeless" }, + { + id: "p3", + kind: "options", + options: frequency0to3, + text: "Trouble falling or staying asleep, or sleeping too much", + }, + { id: "p4", kind: "options", options: frequency0to3, text: "Feeling tired or having little energy" }, + { id: "p5", kind: "options", options: frequency0to3, text: "Poor appetite or overeating" }, + { + id: "p6", + kind: "options", + options: frequency0to3, + text: "Feeling bad about yourself — or that you are a failure or have let yourself or your family down", + }, + { + id: "p7", + kind: "options", + options: frequency0to3, + text: "Trouble concentrating on things, such as reading or watching television", + }, + { + id: "p8", + kind: "options", + options: frequency0to3, + text: "Moving or speaking noticeably slowly — or the opposite, being unusually fidgety or restless", + }, + { + id: "p9", + kind: "options", + options: frequency0to3, + text: "Thoughts that you would be better off dead, or of hurting yourself in some way", + flag: "Item 9 endorsed — complete a structured suicide-risk assessment now.", + }, + ], + bands: [ + { min: 0, max: 4, label: "Minimal", tone: "success", guidance: "Monitor; treatment may not be required." }, + { min: 5, max: 9, label: "Mild", tone: "info", guidance: "Watchful waiting; repeat PHQ-9 at follow-up." }, + { + min: 10, + max: 14, + label: "Moderate", + tone: "warning", + guidance: "Treatment plan: psychotherapy, follow-up and/or pharmacotherapy.", + }, + { + min: 15, + max: 19, + label: "Moderately severe", + tone: "warning", + guidance: "Active treatment with pharmacotherapy and/or psychotherapy.", + }, + { + min: 20, + max: 27, + label: "Severe", + tone: "danger", + guidance: "Initiate pharmacotherapy; expedite specialist referral if impairment is severe.", + }, + ], + }, + { + id: "gad7", + abbrev: "GAD-7", + name: "Generalised Anxiety Disorder-7", + domain: "anxiety", + icon: Zap, + indication: "Screen for generalised anxiety and grade severity; scores ≥10 warrant further assessment.", + summary: "7-item anxiety severity score; also performs well for panic and social anxiety.", + stem: "Over the last 2 weeks, how often have you been bothered by:", + timeEstimate: "1–2 min", + minScore: 0, + maxScore: 21, + scoringNote: "Sum of 7 items (0–3 each). ≥10 is the usual cut-point for probable GAD.", + source: "Spitzer et al. 2006", + items: [ + { id: "g1", kind: "options", options: frequency0to3, text: "Feeling nervous, anxious, or on edge" }, + { id: "g2", kind: "options", options: frequency0to3, text: "Not being able to stop or control worrying" }, + { id: "g3", kind: "options", options: frequency0to3, text: "Worrying too much about different things" }, + { id: "g4", kind: "options", options: frequency0to3, text: "Trouble relaxing" }, + { id: "g5", kind: "options", options: frequency0to3, text: "Being so restless that it is hard to sit still" }, + { id: "g6", kind: "options", options: frequency0to3, text: "Becoming easily annoyed or irritable" }, + { id: "g7", kind: "options", options: frequency0to3, text: "Feeling afraid, as if something awful might happen" }, + ], + bands: [ + { min: 0, max: 4, label: "Minimal", tone: "success", guidance: "No action beyond routine care." }, + { min: 5, max: 9, label: "Mild", tone: "info", guidance: "Monitor; repeat GAD-7 at review." }, + { + min: 10, + max: 14, + label: "Moderate", + tone: "warning", + guidance: "Probable anxiety disorder — confirm diagnosis and agree a treatment plan.", + }, + { + min: 15, + max: 21, + label: "Severe", + tone: "danger", + guidance: "Active treatment warranted; assess functional impact and comorbid depression.", + }, + ], + }, + { + id: "k10", + abbrev: "K10", + name: "Kessler Psychological Distress Scale", + domain: "distress", + icon: Activity, + indication: + "Measure non-specific psychological distress over the past 4 weeks; standard for Australian mental-health care plans.", + summary: "10-item distress measure scored 10–50, widely used across Australian primary care.", + stem: "In the past 4 weeks, about how often did you feel:", + timeEstimate: "2–3 min", + minScore: 10, + maxScore: 50, + scoringNote: "Sum of 10 items (1–5 each); range 10–50. Higher scores indicate greater distress.", + source: "Kessler et al. 2002 · ABS scoring", + items: [ + { id: "k1", kind: "options", options: kessler1to5, text: "Tired out for no good reason" }, + { id: "k2", kind: "options", options: kessler1to5, text: "Nervous" }, + { id: "k3", kind: "options", options: kessler1to5, text: "So nervous that nothing could calm you down" }, + { id: "k4", kind: "options", options: kessler1to5, text: "Hopeless" }, + { id: "k5", kind: "options", options: kessler1to5, text: "Restless or fidgety" }, + { id: "k6", kind: "options", options: kessler1to5, text: "So restless you could not sit still" }, + { id: "k7", kind: "options", options: kessler1to5, text: "Depressed" }, + { id: "k8", kind: "options", options: kessler1to5, text: "That everything was an effort" }, + { id: "k9", kind: "options", options: kessler1to5, text: "So sad that nothing could cheer you up" }, + { id: "k10", kind: "options", options: kessler1to5, text: "Worthless" }, + ], + bands: [ + { min: 10, max: 15, label: "Low", tone: "success", guidance: "Likely well; no specific action." }, + { + min: 16, + max: 21, + label: "Moderate", + tone: "info", + guidance: "Consistent with mild distress — brief intervention and review.", + }, + { + min: 22, + max: 29, + label: "High", + tone: "warning", + guidance: "Likely mild-to-moderate mental disorder — structured assessment indicated.", + }, + { + min: 30, + max: 50, + label: "Very high", + tone: "danger", + guidance: "Likely severe disorder — comprehensive assessment and active treatment.", + }, + ], + }, + { + id: "mdq", + abbrev: "MDQ", + name: "Mood Disorder Questionnaire", + domain: "mood", + icon: Repeat2, + indication: "Screen for lifetime bipolar-spectrum disorder before starting or reviewing antidepressant treatment.", + summary: "13 lifetime hypomanic symptoms plus co-occurrence and impairment criteria.", + stem: "Has there ever been a period of time when you were not your usual self and you…", + timeEstimate: "3–4 min", + minScore: 0, + maxScore: 13, + scoringNote: + "Positive screen requires ≥7 symptoms, symptoms clustering in the same period, and moderate-or-serious impairment.", + source: "Hirschfeld et al. 2000", + caution: "A positive screen is not a diagnosis — confirm with a structured bipolar-disorder assessment.", + items: [ + { + id: "m1", + kind: "checkbox", + points: 1, + text: "…felt so good or hyper that other people thought you were not your normal self, or you got into trouble", + }, + { + id: "m2", + kind: "checkbox", + points: 1, + text: "…were so irritable that you shouted at people or started fights", + }, + { id: "m3", kind: "checkbox", points: 1, text: "…felt much more self-confident than usual" }, + { + id: "m4", + kind: "checkbox", + points: 1, + text: "…got much less sleep than usual and found you didn't really miss it", + }, + { id: "m5", kind: "checkbox", points: 1, text: "…were much more talkative or spoke much faster than usual" }, + { + id: "m6", + kind: "checkbox", + points: 1, + text: "…had thoughts racing through your head that you couldn't slow down", + }, + { + id: "m7", + kind: "checkbox", + points: 1, + text: "…were so easily distracted that you had trouble concentrating or staying on track", + }, + { id: "m8", kind: "checkbox", points: 1, text: "…had much more energy than usual" }, + { id: "m9", kind: "checkbox", points: 1, text: "…were much more active or did many more things than usual" }, + { + id: "m10", + kind: "checkbox", + points: 1, + text: "…were much more social or outgoing — for example, telephoning friends in the middle of the night", + }, + { id: "m11", kind: "checkbox", points: 1, text: "…were much more interested in sex than usual" }, + { + id: "m12", + kind: "checkbox", + points: 1, + text: "…did things that were unusual for you or that other people might have thought excessive, foolish, or risky", + }, + { + id: "m13", + kind: "checkbox", + points: 1, + text: "…found that spending money got you or your family into trouble", + }, + { + id: "mco", + kind: "checkbox", + points: 0, + text: "Several of these ever happened during the same period of time", + detail: "Criterion 2 — co-occurrence. Does not add to the symptom count.", + }, + { + id: "mimp", + kind: "options", + text: "How much of a problem did any of these cause you?", + detail: "Criterion 3 — impairment. Moderate or serious is required for a positive screen.", + options: [ + { label: "No problem", short: "None", points: 0 }, + { label: "Minor problem", short: "Minor", points: 0 }, + { label: "Moderate problem", short: "Mod", points: 0 }, + { label: "Serious problem", short: "Serious", points: 0 }, + ], + }, + ], + bands: [ + { + min: 0, + max: 6, + label: "Below symptom threshold", + tone: "success", + guidance: "Screen negative on symptom count alone.", + }, + { + min: 7, + max: 13, + label: "Symptom threshold met", + tone: "warning", + guidance: "Check co-occurrence and impairment criteria to complete the screen.", + }, + ], + }, + { + id: "cage", + abbrev: "CAGE", + name: "CAGE Questionnaire", + domain: "substance", + icon: Wine, + indication: "Rapid 4-question lifetime screen for problem drinking in adults.", + summary: "Four yes/no questions; two or more positives is a clinically significant screen.", + timeEstimate: "under 1 min", + minScore: 0, + maxScore: 4, + scoringNote: "1 point per “yes”. ≥2 is a positive screen; follow with AUDIT and drinking history.", + source: "Ewing 1984", + items: [ + { id: "c1", kind: "checkbox", points: 1, text: "Have you ever felt you should Cut down on your drinking?" }, + { id: "c2", kind: "checkbox", points: 1, text: "Have people Annoyed you by criticising your drinking?" }, + { id: "c3", kind: "checkbox", points: 1, text: "Have you ever felt bad or Guilty about your drinking?" }, + { + id: "c4", + kind: "checkbox", + points: 1, + text: "Have you ever had a drink first thing in the morning (Eye-opener) to steady your nerves or get rid of a hangover?", + }, + ], + bands: [ + { + min: 0, + max: 1, + label: "Screen negative", + tone: "success", + guidance: "Reinforce low-risk drinking guidance; rescreen opportunistically.", + }, + { + min: 2, + max: 4, + label: "Clinically significant", + tone: "danger", + guidance: "Positive screen — take a full drinking history and complete the AUDIT.", + }, + ], + }, + { + id: "auditc", + abbrev: "AUDIT-C", + name: "Alcohol Use Disorders Identification Test — Consumption", + domain: "substance", + icon: GlassWater, + indication: "Brief consumption screen for hazardous drinking — the first three AUDIT items.", + summary: "3 consumption questions scored 0–4 each; sex-specific positive thresholds.", + timeEstimate: "1 min", + minScore: 0, + maxScore: 12, + scoringNote: "Positive screen at ≥3 for women and ≥4 for men. Higher totals track hazard severity.", + source: "Bush et al. 1998", + items: [ + { + id: "a1", + kind: "options", + text: "How often do you have a drink containing alcohol?", + options: [ + { label: "Never", short: "0", points: 0 }, + { label: "Monthly or less", short: "1", points: 1 }, + { label: "2–4 times a month", short: "2", points: 2 }, + { label: "2–3 times a week", short: "3", points: 3 }, + { label: "4+ times a week", short: "4", points: 4 }, + ], + }, + { + id: "a2", + kind: "options", + text: "How many standard drinks do you have on a typical day when you are drinking?", + options: [ + { label: "1–2", short: "0", points: 0 }, + { label: "3–4", short: "1", points: 1 }, + { label: "5–6", short: "2", points: 2 }, + { label: "7–9", short: "3", points: 3 }, + { label: "10 or more", short: "4", points: 4 }, + ], + }, + { + id: "a3", + kind: "options", + text: "How often do you have six or more standard drinks on one occasion?", + options: [ + { label: "Never", short: "0", points: 0 }, + { label: "Less than monthly", short: "1", points: 1 }, + { label: "Monthly", short: "2", points: 2 }, + { label: "Weekly", short: "3", points: 3 }, + { label: "Daily or almost daily", short: "4", points: 4 }, + ], + }, + ], + bands: [ + { min: 0, max: 2, label: "Lower risk", tone: "success", guidance: "Below screening threshold for both sexes." }, + { + min: 3, + max: 4, + label: "At threshold", + tone: "warning", + guidance: "Positive for women at ≥3 and men at ≥4 — brief intervention and full AUDIT.", + }, + { + min: 5, + max: 12, + label: "Higher risk", + tone: "danger", + guidance: "Likely hazardous or harmful drinking — full AUDIT, brief intervention, consider referral.", + }, + ], + }, + { + id: "sadpersons", + abbrev: "SAD PERSONS", + name: "SAD PERSONS Scale", + domain: "risk", + icon: ShieldAlert, + indication: "Structure a rapid inventory of static suicide-risk factors as an adjunct to clinical assessment.", + summary: "10 yes/no risk factors, one point each; supports — never replaces — clinical judgement.", + timeEstimate: "1–2 min", + minScore: 0, + maxScore: 10, + scoringNote: "1 point per factor present. Bands are indicative only; act on clinical concern at any score.", + source: "Patterson et al. 1983", + caution: + "Risk scales have poor predictive value. Use to prompt a structured risk assessment, not to gate disposition decisions.", + items: [ + { id: "s1", kind: "checkbox", points: 1, text: "Sex — male" }, + { id: "s2", kind: "checkbox", points: 1, text: "Age — under 19 or over 45" }, + { id: "s3", kind: "checkbox", points: 1, text: "Depression — current low mood or diagnosed depressive episode" }, + { id: "s4", kind: "checkbox", points: 1, text: "Previous suicide attempt or psychiatric care" }, + { id: "s5", kind: "checkbox", points: 1, text: "Excess alcohol or substance use" }, + { id: "s6", kind: "checkbox", points: 1, text: "Rational thinking loss — psychosis or organic impairment" }, + { id: "s7", kind: "checkbox", points: 1, text: "Separated, widowed, or divorced" }, + { + id: "s8", + kind: "checkbox", + points: 1, + text: "Organised or serious attempt / plan", + flag: "Organised plan endorsed — escalate to a full structured risk assessment now.", + }, + { id: "s9", kind: "checkbox", points: 1, text: "No social supports" }, + { id: "s10", kind: "checkbox", points: 1, text: "Stated future intent to repeat or ambivalence about survival" }, + ], + bands: [ + { + min: 0, + max: 4, + label: "Lower indicative risk", + tone: "info", + guidance: "Consider discharge with follow-up if the clinical assessment agrees.", + }, + { + min: 5, + max: 6, + label: "Intermediate", + tone: "warning", + guidance: "Consider close follow-up or admission; complete a structured risk assessment.", + }, + { + min: 7, + max: 10, + label: "Higher indicative risk", + tone: "danger", + guidance: "Admission usually indicated; ensure immediate safety planning.", + }, + ], + }, + { + id: "ybocs", + abbrev: "Y-BOCS", + name: "Yale-Brown Obsessive Compulsive Scale — Severity", + domain: "anxiety", + icon: Brain, + indication: "Grade the severity of obsessive-compulsive symptoms and track treatment response.", + summary: "10 severity items (0–4) across obsessions and compulsions; total 0–40.", + timeEstimate: "5–8 min", + minScore: 0, + maxScore: 40, + scoringNote: "Sum of 10 items. Items 1–5 grade obsessions, 6–10 compulsions; subscales can be reported separately.", + source: "Goodman et al. 1989", + items: [ + { id: "y1", kind: "options", options: ybocsSeverity, text: "Time occupied by obsessive thoughts" }, + { id: "y2", kind: "options", options: ybocsSeverity, text: "Interference from obsessive thoughts" }, + { id: "y3", kind: "options", options: ybocsSeverity, text: "Distress associated with obsessive thoughts" }, + { + id: "y4", + kind: "options", + options: ybocsSeverity, + text: "Resistance against obsessions", + detail: "0 = always resists", + }, + { id: "y5", kind: "options", options: ybocsSeverity, text: "Degree of control over obsessive thoughts" }, + { id: "y6", kind: "options", options: ybocsSeverity, text: "Time spent performing compulsive behaviours" }, + { id: "y7", kind: "options", options: ybocsSeverity, text: "Interference from compulsive behaviours" }, + { id: "y8", kind: "options", options: ybocsSeverity, text: "Distress if compulsions are prevented" }, + { + id: "y9", + kind: "options", + options: ybocsSeverity, + text: "Resistance against compulsions", + detail: "0 = always resists", + }, + { id: "y10", kind: "options", options: ybocsSeverity, text: "Degree of control over compulsive behaviour" }, + ], + bands: [ + { min: 0, max: 7, label: "Subclinical", tone: "success", guidance: "Symptoms below the clinical range." }, + { min: 8, max: 15, label: "Mild", tone: "info", guidance: "Consider CBT with exposure and response prevention." }, + { + min: 16, + max: 23, + label: "Moderate", + tone: "warning", + guidance: "Active treatment: ERP and/or SSRI at adequate dose.", + }, + { + min: 24, + max: 31, + label: "Severe", + tone: "danger", + guidance: "Intensive treatment; review augmentation options.", + }, + { + min: 32, + max: 40, + label: "Extreme", + tone: "danger", + guidance: "Specialist OCD service involvement recommended.", + }, + ], + }, +]; + +export const calculatorById = (id: string): CalculatorFixture => { + const found = calculators.find((calc) => calc.id === id); + if (!found) throw new Error(`Unknown calculator fixture: ${id}`); + return found; +}; + +export const domainOrder: CalculatorDomain[] = ["mood", "anxiety", "substance", "risk", "distress"]; + +/** Extra directory entries shown as "coming soon" in directory-style mockups. */ +export const plannedCalculators: { abbrev: string; name: string; indication: string; icon: LucideIcon }[] = [ + { + abbrev: "CIWA-Ar", + name: "Clinical Institute Withdrawal Assessment — Alcohol", + indication: "Grade alcohol-withdrawal severity and drive symptom-triggered dosing.", + icon: HeartPulse, + }, + { + abbrev: "EPDS", + name: "Edinburgh Postnatal Depression Scale", + indication: "Screen for depression in the perinatal period.", + icon: Baby, + }, + { + abbrev: "COWS", + name: "Clinical Opiate Withdrawal Scale", + indication: "Grade opioid-withdrawal severity before induction.", + icon: AlertTriangle, + }, +]; diff --git a/src/components/calculator-mockups/calculator-pathways.ts b/src/components/calculator-mockups/calculator-pathways.ts new file mode 100644 index 000000000..eb56ddea4 --- /dev/null +++ b/src/components/calculator-mockups/calculator-pathways.ts @@ -0,0 +1,296 @@ +import type { CalculatorFixture } from "./calculator-fixtures"; +import type { DerivedCalculator } from "./calculator-ui"; + +/** + * Score-driven pathway content for the search/detail mockup: what to do next + * at the current severity band, and which knowledge-base content to surface. + * Mockup fixtures only — production would resolve `related` through the live + * retrieval index instead of hand-authored hrefs. + */ + +export type PathwayAction = { + label: string; + detail?: string; +}; + +export type RelatedKind = "guideline" | "medication" | "differential" | "service" | "form" | "answer" | "calculator"; + +export type RelatedItem = { + title: string; + kind: RelatedKind; + /** Route within the app; ignored for kind "calculator". */ + href?: string; + /** For kind "calculator": switch to this scale in place. */ + calcId?: string; + note?: string; + /** Only show once the current band index reaches this value (0-based). */ + minBandIndex?: number; +}; + +export const relatedKindLabels: Record = { + guideline: "Guideline", + medication: "Medication", + differential: "Differential", + service: "Service", + form: "Form", + answer: "Ask", + calculator: "Calculator", +}; + +type CalculatorPathway = { + /** Parallel to calc.bands; falls back to the band guidance sentence. */ + bandActions?: PathwayAction[][]; + related: RelatedItem[]; +}; + +const pathways: Record = { + phq9: { + bandActions: [ + [ + { label: "Reassure and reinforce sleep, activity, and alcohol basics" }, + { label: "Rescreen if the clinical picture changes" }, + ], + [ + { label: "Watchful waiting with psychoeducation" }, + { label: "Repeat PHQ-9 in 2–4 weeks", detail: "Track the trend, not the single score" }, + { label: "Consider low-intensity psychological therapy" }, + ], + [ + { label: "Confirm DSM-5 criteria for a major depressive episode" }, + { label: "Start psychological therapy; consider an SSRI" }, + { label: "Screen for bipolarity before any antidepressant", detail: "Run the MDQ below" }, + ], + [ + { label: "Active treatment: pharmacotherapy and/or psychotherapy" }, + { label: "Screen for bipolarity before prescribing", detail: "Run the MDQ below" }, + { label: "Safety-net and book review within 1–2 weeks" }, + ], + [ + { label: "Initiate pharmacotherapy; consider psychiatry referral" }, + { label: "Assess psychotic features and ECT indications" }, + { label: "Complete a structured suicide-risk assessment now" }, + ], + ], + related: [ + { + title: "Major depression — stepped treatment pathway", + kind: "guideline", + href: "/documents/search?q=major+depression+treatment+pathway", + }, + { + title: "Sertraline — initiation and monitoring", + kind: "medication", + href: "/medications/sertraline", + minBandIndex: 2, + }, + { title: "Low mood — differential diagnoses", kind: "differential", href: "/differentials" }, + { title: "Mental health treatment plan", kind: "form", href: "/forms", minBandIndex: 1 }, + { + title: "Acute mental health team referral", + kind: "service", + href: "/services", + minBandIndex: 3, + }, + { + title: "ECT consent requirements", + kind: "answer", + href: "/?mode=answer&q=ECT+consent+requirements", + minBandIndex: 4, + }, + { title: "MDQ — bipolar screen before antidepressants", kind: "calculator", calcId: "mdq", minBandIndex: 2 }, + ], + }, + gad7: { + bandActions: [ + [{ label: "No action beyond routine care" }], + [{ label: "Psychoeducation and active monitoring" }, { label: "Repeat GAD-7 at next review" }], + [ + { label: "Confirm the anxiety diagnosis and rule out mimics", detail: "Thyroid, stimulants, withdrawal" }, + { label: "Refer for CBT; consider an SSRI" }, + { label: "Co-screen for depression", detail: "Run the PHQ-9 below" }, + ], + [ + { label: "Active treatment: CBT and/or SSRI at adequate dose" }, + { label: "Assess functional impact and comorbid depression" }, + { label: "Avoid initiating benzodiazepines for chronic anxiety" }, + ], + ], + related: [ + { + title: "Generalised anxiety — management pathway", + kind: "guideline", + href: "/documents/search?q=generalised+anxiety+management", + }, + { + title: "Escitalopram — dosing and cautions", + kind: "medication", + href: "/medications/escitalopram", + minBandIndex: 2, + }, + { title: "Anxiety — differential diagnoses", kind: "differential", href: "/differentials" }, + { + title: "Benzodiazepine deprescribing", + kind: "answer", + href: "/?mode=answer&q=benzodiazepine+deprescribing", + minBandIndex: 3, + }, + { title: "PHQ-9 — depression co-screen", kind: "calculator", calcId: "phq9", minBandIndex: 2 }, + ], + }, + k10: { + bandActions: [ + [{ label: "Likely well — no specific action" }], + [{ label: "Brief intervention and lifestyle advice" }, { label: "Repeat K10 at follow-up" }], + [ + { label: "Structured assessment for anxiety and depression", detail: "PHQ-9 and GAD-7 below" }, + { label: "Consider a mental health treatment plan" }, + ], + [ + { label: "Comprehensive assessment and active treatment" }, + { label: "Prepare a mental health treatment plan and referral" }, + { label: "Assess suicide risk directly" }, + ], + ], + related: [ + { title: "Mental health treatment plan", kind: "form", href: "/forms", minBandIndex: 2 }, + { + title: "Psychological distress — stepped care", + kind: "guideline", + href: "/documents/search?q=stepped+care+psychological+distress", + }, + { title: "PHQ-9 — depression severity", kind: "calculator", calcId: "phq9", minBandIndex: 2 }, + { title: "GAD-7 — anxiety severity", kind: "calculator", calcId: "gad7", minBandIndex: 2 }, + ], + }, + mdq: { + related: [ + { + title: "Bipolar disorder — assessment and referral", + kind: "guideline", + href: "/documents/search?q=bipolar+disorder+assessment", + }, + { + title: "Lithium — initiation and monitoring", + kind: "medication", + href: "/medications/lithium-carbonate-ir-sr", + minBandIndex: 1, + }, + { title: "Elevated mood — differential diagnoses", kind: "differential", href: "/differentials" }, + { title: "PHQ-9 — current depressive severity", kind: "calculator", calcId: "phq9" }, + ], + }, + cage: { + bandActions: [ + [{ label: "Reinforce low-risk drinking guidance" }, { label: "Rescreen opportunistically" }], + [ + { label: "Take a full drinking history" }, + { label: "Complete the full AUDIT", detail: "AUDIT-C below covers consumption only" }, + { label: "Brief intervention; assess dependence and withdrawal risk" }, + { label: "Consider thiamine if dependence is likely" }, + ], + ], + related: [ + { title: "AUDIT-C — consumption screen", kind: "calculator", calcId: "auditc" }, + { + title: "Alcohol withdrawal management", + kind: "guideline", + href: "/documents/search?q=alcohol+withdrawal+management", + minBandIndex: 1, + }, + { title: "Thiamine — Wernicke prophylaxis", kind: "medication", href: "/medications/thiamine", minBandIndex: 1 }, + { title: "Drug and alcohol service referral", kind: "service", href: "/services", minBandIndex: 1 }, + ], + }, + auditc: { + bandActions: [ + [{ label: "Below screening threshold — reinforce low-risk limits" }], + [ + { label: "Positive for women at ≥3, men at ≥4 — brief intervention" }, + { label: "Complete the full 10-item AUDIT" }, + ], + [ + { label: "Likely hazardous or harmful drinking — full AUDIT" }, + { label: "Brief intervention; assess dependence and withdrawal risk" }, + { label: "Consider drug and alcohol service referral" }, + ], + ], + related: [ + { + title: "Alcohol — brief intervention guide", + kind: "guideline", + href: "/documents/search?q=alcohol+brief+intervention", + }, + { title: "CAGE — dependence signal check", kind: "calculator", calcId: "cage" }, + { title: "Drug and alcohol service referral", kind: "service", href: "/services", minBandIndex: 2 }, + { + title: "Safe drinking limits", + kind: "answer", + href: "/?mode=answer&q=safe+drinking+limits+australia", + }, + ], + }, + sadpersons: { + bandActions: [ + [ + { label: "Complete a structured risk assessment regardless of score" }, + { label: "Safety plan and follow-up if discharging" }, + ], + [ + { label: "Structured risk assessment now" }, + { label: "Consider admission or intensive community follow-up" }, + { label: "Involve family or carers where safe to do so" }, + ], + [ + { label: "Admission usually indicated — ensure immediate safety" }, + { label: "Continuous observation while in the department" }, + { label: "Structured risk assessment and psychiatry review" }, + ], + ], + related: [ + { + title: "Suicide risk assessment framework", + kind: "guideline", + href: "/documents/search?q=suicide+risk+assessment+framework", + }, + { title: "Safety planning template", kind: "form", href: "/forms" }, + { title: "13YARN — crisis support referral", kind: "service", href: "/services" }, + { title: "Acute mental health team", kind: "service", href: "/services", minBandIndex: 1 }, + ], + }, + ybocs: { + related: [ + { + title: "OCD — exposure and response prevention", + kind: "guideline", + href: "/documents/search?q=OCD+exposure+response+prevention", + }, + { + title: "Sertraline — higher-dose OCD treatment", + kind: "medication", + href: "/medications/sertraline", + minBandIndex: 2, + }, + { + title: "SSRI augmentation in OCD", + kind: "answer", + href: "/?mode=answer&q=SSRI+augmentation+OCD", + minBandIndex: 3, + }, + ], + }, +}; + +/** Actions for the current band — falls back to the band's guidance sentence. */ +export function actionsForBand(calc: CalculatorFixture, derived: DerivedCalculator): PathwayAction[] { + const bandIndex = derived.band ? calc.bands.indexOf(derived.band) : -1; + if (bandIndex < 0) return []; + const custom = pathways[calc.id]?.bandActions?.[bandIndex]; + if (custom?.length) return custom; + return derived.result.guidance ? [{ label: derived.result.guidance }] : []; +} + +/** Related knowledge-base content visible at the current band. */ +export function relatedForBand(calc: CalculatorFixture, derived: DerivedCalculator): RelatedItem[] { + const bandIndex = derived.band ? calc.bands.indexOf(derived.band) : 0; + return (pathways[calc.id]?.related ?? []).filter((item) => (item.minBandIndex ?? 0) <= bandIndex); +} diff --git a/src/components/calculator-mockups/calculator-ui.tsx b/src/components/calculator-mockups/calculator-ui.tsx new file mode 100644 index 000000000..f27a241d6 --- /dev/null +++ b/src/components/calculator-mockups/calculator-ui.tsx @@ -0,0 +1,663 @@ +"use client"; + +import { AlertTriangle, Check, CheckCheck, ClipboardCopy, RotateCcw, type LucideIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import type { CalculatorFixture, CalculatorItem, CalculatorTone, ScoreBand } from "./calculator-fixtures"; + +export const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +/* + * Answers hold the raw selection, not points: + * checkbox items — 1 ("Yes"), 0 ("No"), or undefined (not yet answered) + * options items — the selected option index, or undefined + * Points are always derived from the fixture so zero-point criterion items + * (e.g. MDQ co-occurrence / impairment) still record their state. + */ +export type AnswerMap = Record; + +export function itemScore(item: CalculatorItem, selection: number | undefined): number { + if (selection === undefined) return 0; + if (item.kind === "checkbox") return selection === 1 ? (item.points ?? 0) : 0; + return item.options?.[selection]?.points ?? 0; +} + +/** True for scales whose every item is a yes/no checkbox (CAGE, SAD PERSONS). */ +export function isCheckboxOnly(calc: CalculatorFixture): boolean { + return calc.items.length > 0 && calc.items.every((item) => item.kind === "checkbox"); +} + +/** + * Seed every unset checkbox item to an explicit 0 ("No"). Invoked by the + * "Mark remaining as No" affordance — never on mount — so an all-negative + * CAGE/SAD PERSONS screen reads as a valid 0 result (started + complete) only + * once the user chooses to record it, not merely by opening the scale. + */ +export function seedCheckboxDefaults(calc: CalculatorFixture, answers: AnswerMap): AnswerMap { + if (!isCheckboxOnly(calc)) return answers; + if (calc.items.every((item) => answers[item.id] !== undefined)) return answers; + const next: AnswerMap = { ...answers }; + for (const item of calc.items) { + if (next[item.id] === undefined) next[item.id] = 0; + } + return next; +} + +export type CalculatorResult = { + label: string; + tone: CalculatorTone; + guidance: string; +}; + +export type CalculatorState = { + answers: AnswerMap; + score: number; + /** Options-style items answered so far. */ + answeredCount: number; + optionItemCount: number; + /** Checkbox-style items currently ticked. */ + checkedCount: number; + checkboxItemCount: number; + complete: boolean; + started: boolean; + band: ScoreBand | undefined; + result: CalculatorResult; + flags: string[]; + toggleCheckbox: (itemId: string) => void; + selectOption: (itemId: string, optionIndex: number) => void; + reset: () => void; +}; + +function bandForScore(calc: CalculatorFixture, score: number): ScoreBand | undefined { + return calc.bands.find((band) => score >= band.min && score <= band.max); +} + +function mdqResult(answers: AnswerMap, symptomScore: number): CalculatorResult { + const symptomsMet = symptomScore >= 7; + const coOccurrence = answers.mco === 1; + const impairIndex = answers.mimp; + const impairmentMet = impairIndex !== undefined && impairIndex >= 2; + + if (symptomsMet && coOccurrence && impairmentMet) { + return { + label: "Positive screen", + tone: "danger", + guidance: + "All three criteria met — proceed to a structured bipolar-disorder assessment before treatment changes.", + }; + } + if (symptomsMet) { + const missing = [!coOccurrence ? "co-occurrence" : null, !impairmentMet ? "moderate-or-serious impairment" : null] + .filter(Boolean) + .join(" and "); + return { + label: "Symptom threshold met", + tone: "warning", + guidance: `≥7 symptoms endorsed but ${missing} not confirmed — complete the remaining criteria.`, + }; + } + return { + label: "Negative screen", + tone: "success", + guidance: "Below the 7-symptom threshold. Rescreen if the history changes.", + }; +} + +export type DerivedCalculator = Omit; + +/** Pure scoring/interpretation over an answer map — shared by every mockup. */ +export function deriveCalculator(calc: CalculatorFixture, answers: AnswerMap): DerivedCalculator { + const optionItems = calc.items.filter((item) => item.kind === "options"); + const checkboxItems = calc.items.filter((item) => item.kind === "checkbox"); + const score = calc.items.reduce((sum, item) => sum + itemScore(item, answers[item.id]), 0); + const answeredCount = optionItems.filter((item) => answers[item.id] !== undefined).length; + const checkedCount = checkboxItems.filter((item) => answers[item.id] === 1).length; + const checkboxAnsweredCount = checkboxItems.filter((item) => answers[item.id] !== undefined).length; + // Checkbox-only scales complete once every yes/no item has an explicit value + // (seeded to 0 on open). Mixed scales (MDQ) complete on answered options; + // an unticked symptom checkbox is a valid "not endorsed", not a gap. + const complete = + answeredCount === optionItems.length && (optionItems.length > 0 || checkboxAnsweredCount === checkboxItems.length); + const started = Object.values(answers).some((value) => value !== undefined); + // Only publish a severity band when the reading is trustworthy. Options scales + // with a zero floor (PHQ-9/GAD-7) may show a provisional band as they fill in, + // but checkbox-only screens (CAGE/SAD PERSONS) must wait for completion — a + // half-ticked screen still has undefined items and must never read "negative" — + // and non-zero-minimum scales (K10: 10–50) must not publish below their floor + // (nine "None of the time" answers sum to 9). + const showBand = isCheckboxOnly(calc) ? complete : calc.minScore === 0 || complete; + const band = showBand ? bandForScore(calc, score) : undefined; + const flags = calc.items + .filter((item) => item.flag && itemScore(item, answers[item.id]) > 0) + .map((item) => item.flag as string); + + const result: CalculatorResult = + calc.id === "mdq" + ? mdqResult(answers, score) + : { + label: band?.label ?? "—", + tone: band?.tone ?? "info", + guidance: band?.guidance ?? "", + }; + + return { + score, + answeredCount, + optionItemCount: optionItems.length, + checkedCount, + checkboxItemCount: checkboxItems.length, + complete, + started, + band, + result, + flags, + }; +} + +export function toggleCheckboxAnswer(answers: AnswerMap, itemId: string): AnswerMap { + // Toggle between explicit 1 ("Yes") and 0 ("No") rather than clearing to + // undefined, so an unticked box stays a recorded negative answer. + return { ...answers, [itemId]: answers[itemId] === 1 ? 0 : 1 }; +} + +export function selectOptionAnswer(answers: AnswerMap, itemId: string, optionIndex: number): AnswerMap { + return { ...answers, [itemId]: answers[itemId] === optionIndex ? undefined : optionIndex }; +} + +export function useCalculatorState(calc: CalculatorFixture): CalculatorState { + const [answers, setAnswers] = useState({}); + + const toggleCheckbox = useCallback((itemId: string) => { + setAnswers((prev) => toggleCheckboxAnswer(prev, itemId)); + }, []); + + const selectOption = useCallback((itemId: string, optionIndex: number) => { + setAnswers((prev) => selectOptionAnswer(prev, itemId, optionIndex)); + }, []); + + const reset = useCallback(() => setAnswers({}), []); + + return useMemo( + () => ({ ...deriveCalculator(calc, answers), answers, toggleCheckbox, selectOption, reset }), + [answers, calc, reset, selectOption, toggleCheckbox], + ); +} + +/* ---------- tone styling ---------- */ + +export const toneChip: Record = { + success: "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]", + info: "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]", + warning: "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]", + danger: "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]", +}; + +export const toneBar: Record = { + success: "bg-[color:var(--success)]", + info: "bg-[color:var(--info)]", + warning: "bg-[color:var(--warning)]", + danger: "bg-[color:var(--danger)]", +}; + +export function SeverityPill({ tone, label, className }: { tone: CalculatorTone; label: string; className?: string }) { + return ( + + {label} + + ); +} + +/** + * Horizontal severity spectrum: one segment per band (width proportional to + * its score range) with a marker at the current score. + */ +export function ScoreBandBar({ + calc, + score, + started, + className, +}: { + calc: CalculatorFixture; + score: number; + started: boolean; + className?: string; +}) { + const span = calc.maxScore - calc.minScore || 1; + const fraction = Math.min(1, Math.max(0, (score - calc.minScore) / span)); + + return ( +
+
+ {calc.bands.map((band) => ( +
+ ))} + {started ? ( + +
+ {calc.minScore} + {calc.maxScore} +
+
+ ); +} + +export function BandLegend({ calc, activeBand }: { calc: CalculatorFixture; activeBand?: ScoreBand }) { + return ( +
    + {calc.bands.map((band) => { + const active = activeBand === band; + return ( +
  • +
  • + ); + })} +
+ ); +} + +/* ---------- interactive item controls ---------- */ + +export function CheckboxRow({ + item, + checked, + onToggle, + index, + dense = false, +}: { + item: CalculatorItem; + checked: boolean; + onToggle: () => void; + index?: number; + dense?: boolean; +}) { + return ( + + ); +} + +export function OptionScale({ + item, + value, + onSelect, + layout = "row", +}: { + item: CalculatorItem; + value: number | undefined; + onSelect: (optionIndex: number) => void; + /** row = compact single line of numbered chips; stack = full-label buttons. */ + layout?: "row" | "stack"; +}) { + const options = item.options ?? []; + const showPoints = options.some((option) => option.points !== 0); + + if (layout === "stack") { + return ( +
+ {options.map((option, optionIndex) => { + const active = value === optionIndex; + return ( + + ); + })} +
+ ); + } + + return ( +
+ {options.map((option, optionIndex) => { + const active = value === optionIndex; + return ( + + ); + })} +
+ ); +} + +export function FlagNotice({ flags }: { flags: string[] }) { + if (!flags.length) return null; + return ( +
+ {flags.map((flag) => ( +

+

+ ))} +
+ ); +} + +export function ResetButton({ onReset, disabled }: { onReset: () => void; disabled?: boolean }) { + return ( + + ); +} + +export function progressLabel(state: DerivedCalculator): string { + if (state.optionItemCount === 0) return `${state.checkedCount} of ${state.checkboxItemCount} endorsed`; + const answered = `${state.answeredCount} of ${state.optionItemCount} answered`; + return state.checkboxItemCount > 0 ? `${answered} · ${state.checkedCount} endorsed` : answered; +} + +/** Compact metadata chip: item count, time estimate, score range. */ +export function MetaPill({ icon: Icon, label }: { icon: LucideIcon; label: string }) { + return ( + + + ); +} + +/** Copy-to-clipboard button with its own "Copied" feedback state. */ +export function CopyResultButton({ + calc, + state, + label = "Copy result", + className, +}: { + calc: CalculatorFixture; + state: DerivedCalculator; + label?: string; + className?: string; +}) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + try { + await navigator.clipboard.writeText(formatResultSummary(calc, state)); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable in some embeds — mockup-safe no-op */ + } + }; + + return ( + + ); +} + +/** One-line result summary used by every copy-to-clipboard affordance. */ +export function formatResultSummary(calc: CalculatorFixture, state: DerivedCalculator): string { + return `${calc.abbrev} ${state.score}/${calc.maxScore} — ${state.result.label}${ + state.complete ? "" : ` (${progressLabel(state)})` + }`; +} + +/** + * The single option set shared by every options item, or null when items + * carry bespoke option sets (then items render stacked full labels instead + * of numbered chips plus one response key). + */ +export function sharedOptionKey(calc: CalculatorFixture) { + const optionItems = calc.items.filter((item) => item.kind === "options"); + if (optionItems.length < 2) return null; + const first = optionItems[0].options; + return optionItems.every((item) => item.options === first) ? (first ?? null) : null; +} + +export function ResponseKey({ calc }: { calc: CalculatorFixture }) { + const key = sharedOptionKey(calc); + if (!key) return null; + return ( +
+ {key.map((option) => ( + + {option.short} + {option.label} + + ))} +
+ ); +} + +/** + * Full interactive item list for a calculator: response key, stem, then a + * CheckboxRow or OptionScale per item. Uniform scales get numbered chips + * with one key; bespoke option sets get stacked full-label buttons. + */ +export function CalculatorItems({ + calc, + answers, + onAnswersChange, + dense = false, + showKey = true, +}: { + calc: CalculatorFixture; + answers: AnswerMap; + onAnswersChange: (next: AnswerMap) => void; + dense?: boolean; + showKey?: boolean; +}) { + const key = sharedOptionKey(calc); + // Offer an explicit "all remaining negative" affordance for checkbox-only + // scales, so an all-negative CAGE/SAD PERSONS can be completed without ticking + // each box — but only on user action, never by merely opening the scale. + const canMarkRemaining = isCheckboxOnly(calc) && calc.items.some((item) => answers[item.id] === undefined); + + return ( +
+ {showKey ? : null} + {calc.stem ? ( +

{calc.stem}

+ ) : null} + {calc.items.map((item, itemIndex) => + item.kind === "checkbox" ? ( + onAnswersChange(toggleCheckboxAnswer(answers, item.id))} + dense={dense} + /> + ) : ( +
+
+

+ + {itemIndex + 1}. + + {item.text} +

+ {item.detail ? ( +

{item.detail}

+ ) : null} +
+ onAnswersChange(selectOptionAnswer(answers, item.id, optionIndex))} + layout={key ? "row" : "stack"} + /> +
+ ), + )} + {canMarkRemaining ? ( + + ) : null} +
+ ); +} diff --git a/src/components/calculator-mockups/clinical-console-mockup.tsx b/src/components/calculator-mockups/clinical-console-mockup.tsx new file mode 100644 index 000000000..8114e153b --- /dev/null +++ b/src/components/calculator-mockups/clinical-console-mockup.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { AlertTriangle, Info, Stethoscope } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { calculators, domainIcons, domainLabels, domainOrder, type CalculatorFixture } from "./calculator-fixtures"; +import { + CalculatorItems, + CopyResultButton, + FlagNotice, + ScoreBandBar, + SeverityPill, + deriveCalculator, + focusRing, + progressLabel, + toneBar, + type AnswerMap, +} from "./calculator-ui"; + +type SessionAnswers = Record; + +function RailEntry({ + calc, + active, + answers, + onSelect, +}: { + calc: CalculatorFixture; + active: boolean; + answers: AnswerMap; + onSelect: () => void; +}) { + const derived = deriveCalculator(calc, answers); + + return ( + + ); +} + +export function CalculatorsClinicalConsoleMockup() { + const [activeId, setActiveId] = useState("phq9"); + const [session, setSession] = useState({}); + + const calc = useMemo(() => calculators.find((entry) => entry.id === activeId) ?? calculators[0], [activeId]); + const answers = session[calc.id] ?? {}; + const derived = deriveCalculator(calc, answers); + + const setAnswers = (next: AnswerMap) => setSession((prev) => ({ ...prev, [calc.id]: next })); + + return ( +
+
+
+
+ + +
+

+ Calculator console +

+

+ Work through scales side by side — progress is kept per calculator. +

+
+
+ + Session only · nothing stored + +
+
+ +
+ {/* Rail — horizontal chips on phones, grouped list on desktop */} + + + {/* Workspace */} +
+
+
+

{calc.abbrev}

+ {calc.name} +
+

+

+ {calc.caution ? ( +

{calc.caution}

+ ) : null} +
+ + {/* Sticky live-score ticker */} +
+
+
+ + {derived.started ? derived.score : "—"} + / {calc.maxScore} + + + {progressLabel(derived)} +
+ +
+ + {derived.started && derived.result.guidance ? ( +

{derived.result.guidance}

+ ) : null} +
+ + + + + +
+ + {calc.scoringNote} {calc.source} + + +
+
+
+
+ ); +} diff --git a/src/components/calculator-mockups/directory-grid-mockup.tsx b/src/components/calculator-mockups/directory-grid-mockup.tsx new file mode 100644 index 000000000..7be202e52 --- /dev/null +++ b/src/components/calculator-mockups/directory-grid-mockup.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { BookOpen, Calculator, ChevronDown, Clock3, Info, ListChecks, Search, ShieldCheck, Sigma } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { + calculators, + domainLabels, + domainOrder, + plannedCalculators, + type CalculatorDomain, + type CalculatorFixture, +} from "./calculator-fixtures"; +import { + BandLegend, + CalculatorItems, + CopyResultButton, + FlagNotice, + MetaPill, + ResetButton, + ScoreBandBar, + SeverityPill, + deriveCalculator, + focusRing, + progressLabel, + type AnswerMap, +} from "./calculator-ui"; + +type DomainFilter = CalculatorDomain | "all"; + +function ExpandedCalculator({ + calc, + answers, + onAnswersChange, +}: { + calc: CalculatorFixture; + answers: AnswerMap; + onAnswersChange: (next: AnswerMap) => void; +}) { + const state = deriveCalculator(calc, answers); + + return ( +
+ + + +
+ ); +} + +function CalculatorCard({ + calc, + open, + onToggle, + answers, + onAnswersChange, +}: { + calc: CalculatorFixture; + open: boolean; + onToggle: () => void; + answers: AnswerMap; + onAnswersChange: (next: AnswerMap) => void; +}) { + const Icon = calc.icon; + + return ( +
+ + +
+ + + +
+ + {open ? : null} +
+ ); +} + +const filterChips: { id: DomainFilter; label: string }[] = [ + { id: "all", label: "All" }, + ...domainOrder.map((domain) => ({ id: domain as DomainFilter, label: domainLabels[domain] })), +]; + +export function CalculatorsDirectoryGridMockup() { + const [query, setQuery] = useState(""); + const [domain, setDomain] = useState("all"); + const [openId, setOpenId] = useState("cage"); + // Answers live here, keyed by calculator id, so they survive a card collapsing + // or another card opening (which unmounts ExpandedCalculator). + const [session, setSession] = useState>({}); + + const visible = useMemo(() => { + const trimmed = query.trim().toLowerCase(); + return calculators.filter((calc) => { + if (domain !== "all" && calc.domain !== domain) return false; + if (!trimmed) return true; + const haystack = [calc.abbrev, calc.name, calc.indication, calc.summary, domainLabels[calc.domain]] + .join(" ") + .toLowerCase(); + return haystack.includes(trimmed) || calc.items.some((item) => item.text.toLowerCase().includes(trimmed)); + }); + }, [domain, query]); + + return ( +
+
+
+
+ + +
+

+ Clinical calculators +

+

+ Validated psychiatry scores with the indication, items, and interpretation in one place. +

+
+
+ +
event.preventDefault()} + className="grid min-h-12 w-full grid-cols-[auto_minmax(0,1fr)] items-center gap-2 rounded-full border border-[color:var(--border-strong)] bg-[color:var(--surface)] px-3 shadow-[var(--shadow-tight)]" + > +
+
+ +
+
+ {visible.map((calc) => ( + setOpenId((prev) => (prev === calc.id ? null : calc.id))} + answers={session[calc.id] ?? {}} + onAnswersChange={(next) => setSession((prev) => ({ ...prev, [calc.id]: next }))} + /> + ))} + {!visible.length ? ( +

+ No calculators match that search. +

+ ) : null} +
+ +
+
+
+
+ {plannedCalculators.map((planned) => { + const Icon = planned.icon; + return ( +
+ + + + + {planned.abbrev} + + Planned + + + + {planned.indication} + + +
+ ); + })} +
+
+ +

+

+
+
+ ); +} diff --git a/src/components/calculator-mockups/guided-flow-mockup.tsx b/src/components/calculator-mockups/guided-flow-mockup.tsx new file mode 100644 index 000000000..9198f8133 --- /dev/null +++ b/src/components/calculator-mockups/guided-flow-mockup.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { ArrowLeft, ArrowRight, Check, ChevronRight, Clock3, ListChecks, RotateCcw, X } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { calculators, domainLabels, type CalculatorFixture, type CalculatorItem } from "./calculator-fixtures"; +import { + BandLegend, + CopyResultButton, + FlagNotice, + ScoreBandBar, + SeverityPill, + deriveCalculator, + focusRing, + itemScore, + type AnswerMap, +} from "./calculator-ui"; + +function PickerScreen({ onPick }: { onPick: (id: string) => void }) { + return ( +
+
+

Guided calculators

+

+ One question at a time, sized for the ward round. Pick a scale to begin. +

+
+
+ {calculators.map((calc) => { + const Icon = calc.icon; + return ( + + ); + })} +
+
+ ); +} + +function QuestionScreen({ + calc, + item, + stepIndex, + answers, + onAnswer, + onBack, + onExit, +}: { + calc: CalculatorFixture; + item: CalculatorItem; + stepIndex: number; + answers: AnswerMap; + onAnswer: (value: number) => void; + onBack: () => void; + onExit: () => void; +}) { + const derived = deriveCalculator(calc, answers); + const total = calc.items.length; + const value = answers[item.id]; + + return ( +
+
+ +
+

{calc.abbrev}

+

+ {stepIndex + 1} / {total} +

+
+ + {derived.started ? derived.score : 0} + +
+ +
+
+
+ +
+ {calc.stem ? ( +

+ {calc.stem} +

+ ) : null} +

{item.text}

+ {item.detail ? ( +

{item.detail}

+ ) : null} + +
+ {item.kind === "checkbox" ? ( + <> + + + + ) : ( + (item.options ?? []).map((option, optionIndex) => { + const active = value === optionIndex; + const showPoints = (item.options ?? []).some((entry) => entry.points !== 0); + return ( + + ); + }) + )} +
+
+ +
+ + Tap an answer to continue +
+
+ ); +} + +function ResultScreen({ + calc, + answers, + onRestart, + onExit, +}: { + calc: CalculatorFixture; + answers: AnswerMap; + onRestart: () => void; + onExit: () => void; +}) { + const derived = deriveCalculator(calc, answers); + + return ( +
+
+

+ {calc.abbrev} result +

+

+ {derived.score} + / {calc.maxScore} +

+
+ +
+ + {derived.result.guidance ? ( +

+ {derived.result.guidance} +

+ ) : null} + +
+ +
+
+

+ Answer review +

+
    + {calc.items.map((item, itemIndex) => { + const value = answers[item.id]; + const answerLabel = + item.kind === "checkbox" ? (value === 1 ? "Yes" : "No") : (item.options?.[value ?? -1]?.label ?? "—"); + const points = itemScore(item, value); + return ( +
  • + + {itemIndex + 1}. + + {item.text} + + {answerLabel} + + +{points} + + +
  • + ); + })} +
+
+
+ +
+ {calc.caution ? ( +

{calc.caution}

+ ) : null} +

{calc.source}

+
+ +
+ + +
+
+ ); +} + +export function CalculatorsGuidedFlowMockup() { + const [activeId, setActiveId] = useState(null); + const [stepIndex, setStepIndex] = useState(0); + const [answers, setAnswers] = useState({}); + + const calc = useMemo(() => calculators.find((entry) => entry.id === activeId) ?? null, [activeId]); + const finished = calc !== null && stepIndex >= calc.items.length; + + const start = (id: string) => { + setActiveId(id); + setStepIndex(0); + setAnswers({}); + }; + + const exit = () => { + setActiveId(null); + setStepIndex(0); + setAnswers({}); + }; + + const answer = (item: CalculatorItem, value: number) => { + setAnswers((prev) => ({ ...prev, [item.id]: value })); + setStepIndex((prev) => prev + 1); + }; + + return ( +
+
+ {!calc ? ( + + ) : finished ? ( + start(calc.id)} onExit={exit} /> + ) : ( + answer(calc.items[stepIndex], value)} + onBack={() => setStepIndex((prev) => Math.max(0, prev - 1))} + onExit={exit} + /> + )} +
+
+ ); +} diff --git a/src/components/calculator-mockups/index.ts b/src/components/calculator-mockups/index.ts new file mode 100644 index 000000000..74d5c5023 --- /dev/null +++ b/src/components/calculator-mockups/index.ts @@ -0,0 +1,7 @@ +export { CalculatorsBedsideSheetMockup } from "./bedside-sheet-mockup"; +export { CalculatorsClinicalConsoleMockup } from "./clinical-console-mockup"; +export { CalculatorsDirectoryGridMockup } from "./directory-grid-mockup"; +export { CalculatorsGuidedFlowMockup } from "./guided-flow-mockup"; +export { CalculatorsPopupSheetMockup } from "./popup-sheet-mockup"; +export { CalculatorsSearchPageMockup } from "./search-page-mockup"; +export { CalculatorsSearchDetailMockup } from "./search-detail-mockup"; diff --git a/src/components/calculator-mockups/popup-sheet-mockup.tsx b/src/components/calculator-mockups/popup-sheet-mockup.tsx new file mode 100644 index 000000000..701b70e30 --- /dev/null +++ b/src/components/calculator-mockups/popup-sheet-mockup.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { Info, X } from "lucide-react"; +import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; + +import { cn } from "@/components/ui-primitives"; + +import { calculators, domainLabels, type CalculatorFixture } from "./calculator-fixtures"; +import { + CalculatorItems, + ScoreBandBar, + SeverityPill, + deriveCalculator, + focusRing, + progressLabel, + type AnswerMap, +} from "./calculator-ui"; +import { + CalculatorSearchHome, + NextActionsPanel, + RelatedContentPanel, + ScorePanel, + type SessionAnswers, +} from "./search-detail-mockup"; + +/** + * Popup variant of the search flow: the individual calculator opens as a + * modal dialog on desktop and a bottom sheet on phones, keeping the search + * page in place underneath. Uses the app's modal layer (z-100) and the + * sheet-up / dialog-rise motion tokens. + */ +export function CalculatorSheet({ + calc, + answers, + onAnswersChange, + onClose, + onOpenCalculator, +}: { + calc: CalculatorFixture; + answers: AnswerMap; + onAnswersChange: (next: AnswerMap) => void; + onClose: () => void; + onOpenCalculator: (calcId: string) => void; +}) { + const derived = deriveCalculator(calc, answers); + const closeRef = useRef(null); + const dialogRef = useRef(null); + const scrollRef = useRef(null); + const previousFocusRef = useRef(null); + const Icon = calc.icon; + + // Save the opener, move focus into the dialog, and restore on close. + useEffect(() => { + previousFocusRef.current = document.activeElement as HTMLElement | null; + closeRef.current?.focus(); + return () => previousFocusRef.current?.focus?.(); + }, [calc.id]); + + // Switching calculators in place keeps this sheet mounted, so reset its scroll + // on calc change — otherwise the next one opens at the prior sheet's offset + // (e.g. partway down the related-content rows) instead of its indication. + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }); + }, [calc.id]); + + // Trap Tab / Shift+Tab within the dialog so focus can't reach the page behind. + const trapTab = (event: ReactKeyboardEvent) => { + if (event.key !== "Tab") return; + const root = dialogRef.current; + if (!root) return; + const focusables = Array.from( + root.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ).filter( + (element) => element.tabIndex !== -1 && (element.offsetParent !== null || element === document.activeElement), + ); + if (!focusables.length) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + + return ( +
+ + + + {/* Live strip pinned under the header while items scroll */} +
+
+ + {derived.started ? derived.score : "—"} + / {calc.maxScore} + + + {progressLabel(derived)} + + +
+ +
+ +
+

+

+ {calc.caution ? ( +

{calc.caution}

+ ) : null} + + + +
+ + +
+ + onAnswersChange({})} /> +
+
+
+ ); +} + +export function CalculatorsPopupSheetMockup() { + const [openId, setOpenId] = useState(null); + const [session, setSession] = useState({}); + + const activeCalc = openId ? calculators.find((calc) => calc.id === openId) : undefined; + + useEffect(() => { + if (!activeCalc) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpenId(null); + }; + window.addEventListener("keydown", onKey); + document.body.style.overflow = "hidden"; + return () => { + window.removeEventListener("keydown", onKey); + document.body.style.overflow = ""; + }; + }, [activeCalc]); + + return ( +
+ + {activeCalc ? ( + setSession((prev) => ({ ...prev, [activeCalc.id]: next }))} + onClose={() => setOpenId(null)} + onOpenCalculator={setOpenId} + /> + ) : null} +
+ ); +} diff --git a/src/components/calculator-mockups/search-detail-mockup.tsx b/src/components/calculator-mockups/search-detail-mockup.tsx new file mode 100644 index 000000000..2e831ad14 --- /dev/null +++ b/src/components/calculator-mockups/search-detail-mockup.tsx @@ -0,0 +1,698 @@ +"use client"; + +import { + AlertTriangle, + ArrowLeft, + ArrowRight, + ArrowUpRight, + Calculator, + Clock3, + History, + Info, + ListChecks, + Search, + Sigma, + Sparkles, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; + +import { ModeHomeHero } from "@/components/mode-home-template"; +import { chatComposerInput, chatComposerShell, chatSendButton, cn, eyebrowText } from "@/components/ui-primitives"; + +import { + calculators, + domainLabels, + domainOrder, + type CalculatorDomain, + type CalculatorFixture, +} from "./calculator-fixtures"; +import { + actionsForBand, + relatedForBand, + relatedKindLabels, + type RelatedItem, + type RelatedKind, +} from "./calculator-pathways"; +import { + BandLegend, + CalculatorItems, + CopyResultButton, + MetaPill, + ResetButton, + ScoreBandBar, + SeverityPill, + deriveCalculator, + focusRing, + progressLabel, + toneBar, + type AnswerMap, + type DerivedCalculator, +} from "./calculator-ui"; + +type DomainFilter = CalculatorDomain | "all"; +export type SessionAnswers = Record; + +const relatedKindChip: Record = { + guideline: + "border-[color:var(--type-document-border)] bg-[color:var(--type-document-soft)] text-[color:var(--type-document)]", + medication: + "border-[color:var(--type-table-border)] bg-[color:var(--type-table-soft)] text-[color:var(--type-table)]", + differential: + "border-[color:var(--type-source-border)] bg-[color:var(--type-source-soft)] text-[color:var(--type-source)]", + service: + "border-[color:var(--type-service-border)] bg-[color:var(--type-service-soft)] text-[color:var(--type-service)]", + form: "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]", + answer: "border-[color:var(--type-search-border)] bg-[color:var(--type-search-soft)] text-[color:var(--type-search)]", + calculator: + "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]", +}; + +/** First item whose text matches the query — shown as match context in results. */ +function matchContext(calc: CalculatorFixture, query: string): string | null { + const matched = calc.items.find((item) => item.text.toLowerCase().includes(query)); + return matched ? matched.text : null; +} + +function StartedChip({ derived }: { derived: DerivedCalculator }) { + if (!derived.started) return null; + return ( + + {derived.score} + {derived.result.label} + + ); +} + +/* ---------- search view ---------- */ + +function CalculatorResultCard({ + calc, + derived, + onOpen, +}: { + calc: CalculatorFixture; + derived: DerivedCalculator; + onOpen: () => void; +}) { + const Icon = calc.icon; + + return ( + + ); +} + +function CalculatorResultRow({ + calc, + derived, + context, + onOpen, +}: { + calc: CalculatorFixture; + derived: DerivedCalculator; + context: string | null; + onOpen: () => void; +}) { + const Icon = calc.icon; + + return ( + + ); +} + +const filterChips: { id: DomainFilter; label: string }[] = [ + { id: "all", label: "All" }, + ...domainOrder.map((domain) => ({ id: domain as DomainFilter, label: domainLabels[domain] })), +]; + +/** + * The calculators search home: mode-home hero, composer-pill search, domain + * filters, continue strip, and the card grid / live results. Reused by both + * the full-page detail flow and the popup-sheet variant. + */ +export function CalculatorSearchHome({ + session, + onOpen, +}: { + session: SessionAnswers; + onOpen: (calcId: string) => void; +}) { + const [query, setQuery] = useState(""); + const [domain, setDomain] = useState("all"); + + const trimmed = query.trim().toLowerCase(); + + const results = useMemo(() => { + return calculators + .filter((calc) => domain === "all" || calc.domain === domain) + .map((calc) => ({ + calc, + context: trimmed ? matchContext(calc, trimmed) : null, + matches: + !trimmed || + [calc.abbrev, calc.name, calc.indication, calc.summary, domainLabels[calc.domain]] + .join(" ") + .toLowerCase() + .includes(trimmed) || + calc.items.some((item) => item.text.toLowerCase().includes(trimmed)), + })) + .filter((entry) => entry.matches); + }, [domain, trimmed]); + + const inProgress = calculators + .map((calc) => ({ calc, derived: deriveCalculator(calc, session[calc.id] ?? {}) })) + .filter((entry) => entry.derived.started); + + return ( +
+ + +
{ + event.preventDefault(); + if (results.length === 1) onOpen(results[0].calc.id); + }} + className={cn(chatComposerShell, "mx-auto w-full max-w-2xl")} + > + + + setQuery(event.target.value)} + placeholder="Search scales, symptoms, or indications" + aria-label="Search calculators" + className={chatComposerInput} + /> + +
+ +
+ {filterChips.map((chip) => { + const active = domain === chip.id; + return ( + + ); + })} +
+ + {!trimmed && inProgress.length ? ( +
+

+

+
+ {inProgress.map(({ calc, derived }) => ( + + ))} +
+
+ ) : null} + + {trimmed ? ( +
+

+ {results.length} {results.length === 1 ? "calculator" : "calculators"} for “{query.trim()}” +

+ {results.map(({ calc, context }) => ( + onOpen(calc.id)} + /> + ))} + {!results.length ? ( +
+

No calculator matches that.

+

+ Try a symptom (“hopeless”, “drinking”) or ask Clinical Guide below. +

+
+ ) : null} +
+ ) : ( +
+

All calculators

+
+ {results.map(({ calc }) => ( + onOpen(calc.id)} + /> + ))} +
+
+ )} + +

+ Scores support clinical judgement — they never replace a full assessment. Nothing entered here is stored. +

+
+ ); +} + +/* ---------- detail view: score-linked panels ---------- */ + +export function NextActionsPanel({ calc, derived }: { calc: CalculatorFixture; derived: DerivedCalculator }) { + const actions = actionsForBand(calc, derived); + + return ( +
+
+

Next clinical actions

+ + +
+ + {!derived.started ? ( +

+ Answer the items and recommendations for the scored severity band appear here. +

+ ) : ( + <> + {derived.flags.map((flag) => ( +

+

+ ))} +
    + {actions.map((action, actionIndex) => ( +
  1. + + {actionIndex + 1} + + + + {action.label} + + {action.detail ? ( + + {action.detail} + + ) : null} + +
  2. + ))} +
+ {derived.band ? ( +

+ For + — updates automatically as the + score changes. +

+ ) : null} + + )} +
+ ); +} + +export function RelatedContentPanel({ + calc, + derived, + onOpenCalculator, +}: { + calc: CalculatorFixture; + derived: DerivedCalculator; + onOpenCalculator: (calcId: string) => void; +}) { + const visible = relatedForBand(calc, derived); + const all = relatedForBand(calc, { ...derived, band: calc.bands[calc.bands.length - 1] }); + const moreAtHigherSeverity = all.length > visible.length; + + if (!all.length) return null; + + const rowClass = cn( + "grid w-full min-h-tap grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-2 text-left transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--surface-subtle)]", + focusRing, + ); + + const rowBody = (item: RelatedItem) => ( + <> + + {relatedKindLabels[item.kind]} + + + + {item.title} + + {item.note ? ( + + {item.note} + + ) : null} + +
+ } + summary={ + + } + sidebar={ + <> + + + + + } + sidebarMobile={ +
+ +
+ } + > + {results.length ? ( +
+ {results.map(({ calc, context }) => ( + setOpenId(calc.id)} + /> + ))} +
+ ) : ( +
+ + +

+ No calculators match “{query.trim()}”. +

+

+ Try a symptom (“hopeless”, “drinking”, “worry”) or clear the filters. +

+ +
+ )} + + + {/* Phones: composer docks at the bottom, matching the site-wide composer + placement, and slides away on scroll-down in lockstep with the header. + Hidden while a calculator sheet is open. */} + {activeCalc ? null : ( +
setDockFocused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDockFocused(false); + }} + className={cn( + "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--border)] bg-[color:var(--surface-glass)] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 backdrop-blur-md transition-transform duration-200 ease-out motion-reduce:transition-none sm:hidden", + dockHidden ? "translate-y-full" : "translate-y-0", + )} + aria-hidden={dockHidden} + inert={dockHidden || undefined} + > + +
+ )} + + {activeCalc ? ( + setSession((prev) => ({ ...prev, [activeCalc.id]: next }))} + onClose={() => setOpenId(null)} + onOpenCalculator={setOpenId} + /> + ) : null} + + ); +}