From b6498bd05534f339e1c8a0dbad9084606f981491 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:46:08 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(mockups):=20psychiatry=20clinical=20?= =?UTF-8?q?calculators=20=E2=80=94=20search=20page=20+=20variants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a psychiatry calculators feature as design mockups: an 8-scale scoring engine (PHQ-9, GAD-7, K10, MDQ, CAGE, AUDIT-C, SAD PERSONS, Y-BOCS) with score-linked next-clinical-actions and knowledge-base links, rendered through seven layout directions under /mockups/calculators-*. The primary direction is /mockups/calculators-search-page: the app's canonical SearchResultsLayout shell (count header band, tile grid, domain rail) with the search placed at the top on desktop and docked at the bottom on phones, matching site-wide search behaviour. The shared universal composer is suppressed on that route so there is a single, correctly-placed search input. Co-Authored-By: Claude Fable 5 --- docs/site-map.md | 7 + .../calculators-bedside-sheet/page.tsx | 12 + .../calculators-clinical-console/page.tsx | 12 + .../calculators-directory-grid/page.tsx | 12 + .../mockups/calculators-guided-flow/page.tsx | 12 + .../mockups/calculators-popup-sheet/page.tsx | 13 + .../mockups/calculators-search-page/page.tsx | 13 + src/app/mockups/calculators-search/page.tsx | 13 + src/app/mockups/mockups-layout-client.tsx | 7 +- .../bedside-sheet-mockup.tsx | 257 +++++++ .../calculator-mockups/calculator-fixtures.ts | 653 +++++++++++++++++ .../calculator-mockups/calculator-pathways.ts | 296 ++++++++ .../calculator-mockups/calculator-ui.tsx | 610 ++++++++++++++++ .../clinical-console-mockup.tsx | 236 ++++++ .../directory-grid-mockup.tsx | 288 ++++++++ .../calculator-mockups/guided-flow-mockup.tsx | 387 ++++++++++ src/components/calculator-mockups/index.ts | 7 + .../calculator-mockups/popup-sheet-mockup.tsx | 177 +++++ .../search-detail-mockup.tsx | 687 ++++++++++++++++++ .../calculator-mockups/search-page-mockup.tsx | 578 +++++++++++++++ 20 files changed, 4276 insertions(+), 1 deletion(-) create mode 100644 src/app/mockups/calculators-bedside-sheet/page.tsx create mode 100644 src/app/mockups/calculators-clinical-console/page.tsx create mode 100644 src/app/mockups/calculators-directory-grid/page.tsx create mode 100644 src/app/mockups/calculators-guided-flow/page.tsx create mode 100644 src/app/mockups/calculators-popup-sheet/page.tsx create mode 100644 src/app/mockups/calculators-search-page/page.tsx create mode 100644 src/app/mockups/calculators-search/page.tsx create mode 100644 src/components/calculator-mockups/bedside-sheet-mockup.tsx create mode 100644 src/components/calculator-mockups/calculator-fixtures.ts create mode 100644 src/components/calculator-mockups/calculator-pathways.ts create mode 100644 src/components/calculator-mockups/calculator-ui.tsx create mode 100644 src/components/calculator-mockups/clinical-console-mockup.tsx create mode 100644 src/components/calculator-mockups/directory-grid-mockup.tsx create mode 100644 src/components/calculator-mockups/guided-flow-mockup.tsx create mode 100644 src/components/calculator-mockups/index.ts create mode 100644 src/components/calculator-mockups/popup-sheet-mockup.tsx create mode 100644 src/components/calculator-mockups/search-detail-mockup.tsx create mode 100644 src/components/calculator-mockups/search-page-mockup.tsx diff --git a/docs/site-map.md b/docs/site-map.md index 0254e1a1b..ec5d291f1 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -534,6 +534,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..6c27f42eb --- /dev/null +++ b/src/components/calculator-mockups/bedside-sheet-mockup.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { 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..3621be7bd --- /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", + 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..3c4133615 --- /dev/null +++ b/src/components/calculator-mockups/calculator-ui.tsx @@ -0,0 +1,610 @@ +"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 when ticked, undefined when clear + * options items — the selected option index + * 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 ? (item.points ?? 0) : 0; + return item.options?.[selection]?.points ?? 0; +} + +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 complete = answeredCount === optionItems.length; + const started = Object.values(answers).some((value) => value !== undefined); + const band = bandForScore(calc, score); + 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 { + return { ...answers, [itemId]: answers[itemId] === 1 ? undefined : 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`; + return `${state.answeredCount} of ${state.optionItemCount} 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); + + 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"} + /> +
+ ), + )} +
+ ); +} 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..dee742fd0 --- /dev/null +++ b/src/components/calculator-mockups/clinical-console-mockup.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { 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..ee983bc10 --- /dev/null +++ b/src/components/calculator-mockups/directory-grid-mockup.tsx @@ -0,0 +1,288 @@ +"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 }: { calc: CalculatorFixture }) { + const [answers, setAnswers] = useState({}); + const state = deriveCalculator(calc, answers); + + return ( +
+ + + +
+ ); +} + +function CalculatorCard({ calc, open, onToggle }: { calc: CalculatorFixture; open: boolean; onToggle: () => 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"); + + const visible = useMemo(() => { + const trimmed = query.trim().toLowerCase(); + return calculators.filter((calc) => { + if (domain !== "all" && calc.domain !== domain) return false; + if (!trimmed) return true; + return [calc.abbrev, calc.name, calc.indication, domainLabels[calc.domain]] + .join(" ") + .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))} + /> + ))} + {!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..012394de9 --- /dev/null +++ b/src/components/calculator-mockups/popup-sheet-mockup.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Info, X } from "lucide-react"; +import { useEffect, useRef, useState } 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 Icon = calc.icon; + + useEffect(() => { + closeRef.current?.focus(); + }, [calc.id]); + + 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..722c40aaf --- /dev/null +++ b/src/components/calculator-mockups/search-detail-mockup.tsx @@ -0,0 +1,687 @@ +"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: search docks at the bottom, matching the site-wide composer + placement. Hidden while a calculator sheet is open. */} + {activeCalc ? null : ( +
+ +
+ )} + + {activeCalc ? ( + setSession((prev) => ({ ...prev, [activeCalc.id]: next }))} + onClose={() => setOpenId(null)} + onOpenCalculator={setOpenId} + /> + ) : null} + + ); +} From 63d5a45585a2af17e6b0f54f387f42ce89415ae5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:26:38 +0800 Subject: [PATCH 02/10] feat(mockups): match the universal composer on the calculators search page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the plain search field with the app's universal-composer treatment: the leading "+" (new search), the teal send button, the "Smart search" hint, the "Prompts" example chips, and the single privacy notice — reusing the answer-footer-search-* classes, AnswerSuggestionChips, and PrivacyInputNotice. Full variant renders in the desktop header; a compact variant (pill + privacy) docks at the bottom on phones. Co-Authored-By: Claude Fable 5 --- .../calculator-mockups/search-page-mockup.tsx | 149 +++++++++++++----- 1 file changed, 113 insertions(+), 36 deletions(-) diff --git a/src/components/calculator-mockups/search-page-mockup.tsx b/src/components/calculator-mockups/search-page-mockup.tsx index c74ad42ef..2c45eb2ec 100644 --- a/src/components/calculator-mockups/search-page-mockup.tsx +++ b/src/components/calculator-mockups/search-page-mockup.tsx @@ -8,16 +8,20 @@ import { Info, LayoutGrid, ListChecks, + Plus, Rows3, Search, + Send, Sigma, SlidersHorizontal, X, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; -import { chatComposerInput, chatComposerShell, chatSendButton, cn, eyebrowText } from "@/components/ui-primitives"; +import { PrivacyInputNotice } from "@/components/privacy-input-notice"; +import { chatComposerInput, chatComposerShellBase, chatSendButton, cn, eyebrowText } from "@/components/ui-primitives"; import { calculators, @@ -59,54 +63,110 @@ function matches(calc: CalculatorFixture, query: string): boolean { return haystack.includes(query) || calc.items.some((item) => item.text.toLowerCase().includes(query)); } -/* ---------- search input (top on desktop, docked at bottom on phones) ---------- */ +/* ---------- universal-style search composer (top on desktop, docked bottom on phones) ---------- */ -function CalculatorSearchForm({ +// Example searches shown in the composer prompt row; each filters the list. +const promptExamples = ["depression", "anxiety", "drinking", "bipolar", "suicide"]; + +/** + * The calculators search composer, matching the app's universal composer: a + * leading "+" (new search), the query input with an inline clear, and the teal + * send button. `variant="full"` adds the Smart-search hint, prompt chips, and + * privacy notice (desktop header); `variant="compact"` shows the pill plus the + * privacy line only (phone bottom dock). + */ +function CalculatorComposer({ query, onQuery, + onReset, onSubmit, + variant, }: { query: string; onQuery: (value: string) => void; + onReset: () => void; onSubmit: () => void; + variant: "full" | "compact"; }) { return ( -
{ - event.preventDefault(); - onSubmit(); - }} - className={cn(chatComposerShell, "w-full")} - > - - - onQuery(event.target.value)} - placeholder="Search calculators by scale, symptom, or indication" - aria-label="Search calculators" - className={chatComposerInput} - /> - {query ? ( +
+ {variant === "full" ? ( +
+ Smart search + + + Try “depression severity” in Calculators. + +
+ ) : null} + + { + event.preventDefault(); + onSubmit(); + }} + className={cn(chatComposerShellBase, "answer-footer-search-pill relative z-10 w-full")} + > + + + +
); } @@ -447,6 +507,11 @@ export function CalculatorsSearchPageMockup() { if (results.length === 1) setOpenId(results[0].calc.id); }; + const resetSearch = () => { + setQuery(""); + setDomain("all"); + }; + return ( <> - {/* Desktop: search sits at the top, matching the site-wide search pages. - Phones get the docked bottom search rendered outside the layout. */} + {/* Desktop: universal-style composer at the top, matching the site-wide + search header. Phones get the docked bottom composer below. */}
- +
@@ -556,11 +627,17 @@ export function CalculatorsSearchPageMockup() { )} - {/* Phones: search docks at the bottom, matching the site-wide composer + {/* Phones: composer docks at the bottom, matching the site-wide composer placement. Hidden while a calculator sheet is open. */} {activeCalc ? null : (
- +
)} From be2f5fa67d69da51f58105535190a4bf5fc7f99c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:17:02 +0800 Subject: [PATCH 03/10] feat(mockups): hide the calculators phone dock on scroll, in sync with header Drive the bottom composer's visibility with the shell's own useHideOnScroll hook so it slides away on scroll-down and returns on scroll-up in lockstep with the top header (identical thresholds, phone-only, inert on desktop). The page's own SearchResultsLayout
is the phone scroller, so the hook is pointed at it by testid; #main-content stays at 0 and only catches descendant scroll. Co-Authored-By: Claude Fable 5 --- .../calculator-mockups/search-page-mockup.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/components/calculator-mockups/search-page-mockup.tsx b/src/components/calculator-mockups/search-page-mockup.tsx index 2c45eb2ec..f60a8868a 100644 --- a/src/components/calculator-mockups/search-page-mockup.tsx +++ b/src/components/calculator-mockups/search-page-mockup.tsx @@ -16,10 +16,11 @@ import { SlidersHorizontal, X, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; +import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; import { chatComposerInput, chatComposerShellBase, chatSendButton, cn, eyebrowText } from "@/components/ui-primitives"; @@ -501,6 +502,17 @@ export function CalculatorsSearchPageMockup() { }; }, [activeCalc]); + // Hide the bottom composer dock on scroll-down in lockstep with the shell's + // top header, using the same hook (identical thresholds, phone-only, inert on + // desktop). On phones this page's own
(searchPageShell) is the scroller + // — #main-content stays at 0 and only catches the descendant scroll — so point + // the hook at that
by its testid. The hook polls the ref until resolved. + const scrollContainerRef = useRef(null); + useEffect(() => { + scrollContainerRef.current = document.querySelector('[data-testid="calculators-search-page"]'); + }, []); + const footerHidden = useHideOnScroll({ containerRef: scrollContainerRef }); + const compact = density === "compact"; const submitSearch = () => { @@ -628,9 +640,17 @@ export function CalculatorsSearchPageMockup() { {/* Phones: composer docks at the bottom, matching the site-wide composer - placement. Hidden while a calculator sheet is open. */} + placement, and slides away on scroll-down in lockstep with the header. + Hidden while a calculator sheet is open. */} {activeCalc ? null : ( -
+
Date: Tue, 14 Jul 2026 16:37:10 +0000 Subject: [PATCH 04/10] fix: apply CodeRabbit auto-fixes Fixed 5 file(s) based on 4 unresolved review comments. Co-authored-by: CodeRabbit --- .../bedside-sheet-mockup.tsx | 23 +++++++-- .../calculator-mockups/calculator-ui.tsx | 7 ++- .../clinical-console-mockup.tsx | 15 +++++- .../calculator-mockups/popup-sheet-mockup.tsx | 47 +++++++++++++++++++ .../calculator-mockups/search-page-mockup.tsx | 1 + 5 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/components/calculator-mockups/bedside-sheet-mockup.tsx b/src/components/calculator-mockups/bedside-sheet-mockup.tsx index 6c27f42eb..9a520f42e 100644 --- a/src/components/calculator-mockups/bedside-sheet-mockup.tsx +++ b/src/components/calculator-mockups/bedside-sheet-mockup.tsx @@ -1,6 +1,6 @@ "use client"; -import { CheckCheck, ChevronDown, ClipboardCopy, ClipboardList, NotebookPen } from "lucide-react"; +import { AlertTriangle, CheckCheck, ChevronDown, ClipboardCopy, ClipboardList, NotebookPen } from "lucide-react"; import { useState } from "react"; import { cn } from "@/components/ui-primitives"; @@ -47,7 +47,10 @@ function SheetSection({ aria-expanded={open} onClick={onToggleOpen} className={cn( - "grid w-full grid-cols-[auto_minmax(0,1fr)_auto_auto] items-center gap-3 rounded-lg p-3 text-left sm:p-4", + "grid w-full items-center gap-3 rounded-lg p-3 text-left sm:p-4", + derived.flags.length > 0 + ? "grid-cols-[auto_minmax(0,1fr)_auto_auto_auto]" + : "grid-cols-[auto_minmax(0,1fr)_auto_auto]", focusRing, )} > @@ -82,6 +85,14 @@ function SheetSection({ ) : ( {calc.items.length} items )} + {derived.flags.length > 0 ? ( + + + ) : null} 0 + ? "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)]" + : "border-[color:var(--border)] bg-[color:var(--surface)]", focusRing, )} > @@ -242,6 +256,9 @@ export function CalculatorsBedsideSheetMockup() { {derived.score}/{calc.maxScore} {derived.result.label} + {derived.flags.length > 0 ? ( +
diff --git a/src/components/calculator-mockups/calculator-ui.tsx b/src/components/calculator-mockups/calculator-ui.tsx index 3c4133615..0ad937206 100644 --- a/src/components/calculator-mockups/calculator-ui.tsx +++ b/src/components/calculator-mockups/calculator-ui.tsx @@ -94,7 +94,8 @@ export function deriveCalculator(calc: CalculatorFixture, answers: AnswerMap): D 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 complete = answeredCount === optionItems.length; + const checkboxAnsweredCount = checkboxItems.filter((item) => answers[item.id] !== undefined).length; + const complete = answeredCount === optionItems.length && checkboxAnsweredCount === checkboxItems.length; const started = Object.values(answers).some((value) => value !== undefined); const band = bandForScore(calc, score); const flags = calc.items @@ -440,7 +441,9 @@ export function ResetButton({ onReset, disabled }: { onReset: () => void; disabl } export function progressLabel(state: DerivedCalculator): string { - if (state.optionItemCount === 0) return `${state.checkedCount} of ${state.checkboxItemCount} endorsed`; + if (state.optionItemCount === 0 && state.checkboxItemCount > 0) { + return `${state.checkedCount} of ${state.checkboxItemCount} endorsed`; + } return `${state.answeredCount} of ${state.optionItemCount} answered`; } diff --git a/src/components/calculator-mockups/clinical-console-mockup.tsx b/src/components/calculator-mockups/clinical-console-mockup.tsx index dee742fd0..61410efc1 100644 --- a/src/components/calculator-mockups/clinical-console-mockup.tsx +++ b/src/components/calculator-mockups/clinical-console-mockup.tsx @@ -1,6 +1,6 @@ "use client"; -import { Info, Stethoscope } from "lucide-react"; +import { AlertTriangle, Info, Stethoscope } from "lucide-react"; import { useMemo, useState } from "react"; import { cn } from "@/components/ui-primitives"; @@ -40,7 +40,10 @@ function RailEntry({ aria-current={active ? "true" : undefined} onClick={onSelect} className={cn( - "grid w-full min-h-tap grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-lg border px-3 py-2 text-left transition", + "grid w-full min-h-tap items-center gap-2 rounded-lg border px-3 py-2 text-left transition", + derived.flags.length > 0 + ? "grid-cols-[minmax(0,1fr)_auto_auto]" + : "grid-cols-[minmax(0,1fr)_auto]", active ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)]" : "border-transparent hover:bg-[color:var(--surface-subtle)]", @@ -72,6 +75,11 @@ function RailEntry({ {calc.items.length} items )} + {derived.flags.length > 0 ? ( + + + ) : null} ); } @@ -137,6 +145,9 @@ export function CalculatorsClinicalConsoleMockup() { className={cn("inline-block h-2 w-2 rounded-full", toneBar[entryDerived.result.tone])} /> ) : null} + {entryDerived.flags.length > 0 ? ( +