Merge branch 'feat/w-g-screenings' into release/v1.27.10

This commit is contained in:
Marc-André Bombeck committed 2026-07-05 20:36:50 +02:00
commit 6e59fe5ce0
40 files changed
+2180 -352

No files matched your search

@@ -155,6 +155,39 @@ const LEGIT_IDENTICAL = new Set<string>([
"mentalHealth.items.phq9.7",
"mentalHealth.items.phq9.8",
"mentalHealth.items.phq9.9",
// v1.27.9 — the Sleep Condition Indicator is validated in English only
// (Espie 2014, CC BY-NC). Item text, section stems and response anchors
// ship VERBATIM in the source language in every locale — a self-made
// translation would invalidate the instrument — and the UI shows a
// localized "validated in English" note instead. Identical-to-EN is the
// intended state here, not a leak.
"mentalHealth.items.sci.1",
"mentalHealth.items.sci.2",
"mentalHealth.items.sci.3",
"mentalHealth.items.sci.4",
"mentalHealth.items.sci.5",
"mentalHealth.items.sci.6",
"mentalHealth.items.sci.7",
"mentalHealth.items.sci.8",
"mentalHealth.stems.sci.night",
"mentalHealth.stems.sci.impact",
"mentalHealth.stems.sci.finally",
"mentalHealth.sciOptions.duration.0",
"mentalHealth.sciOptions.quality.4",
"mentalHealth.sciOptions.quality.3",
"mentalHealth.sciOptions.quality.2",
"mentalHealth.sciOptions.quality.1",
"mentalHealth.sciOptions.quality.0",
"mentalHealth.sciOptions.impact.4",
"mentalHealth.sciOptions.impact.3",
"mentalHealth.sciOptions.impact.2",
"mentalHealth.sciOptions.impact.1",
"mentalHealth.sciOptions.impact.0",
"mentalHealth.sciOptions.problemDuration.4",
"mentalHealth.sciOptions.problemDuration.3",
"mentalHealth.sciOptions.problemDuration.2",
"mentalHealth.sciOptions.problemDuration.1",
"mentalHealth.sciOptions.problemDuration.0",
]);
describe("i18n English-leak guard", () => {
@@ -284,6 +284,98 @@ describe("POST /api/mental-health/assessments", () => {
expect(createArg.data.externalId).toBe("outbox-aa01");
});
it("scores a WHO-5 check-in as raw-sum × 4 and projects a WHO5_SCORE measurement", async () => {
// Raw 3+3+2+2+2 = 12 → reported 48 (below the WHO "below 50" cut → low).
vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
id: "mha_who5",
instrument: "WHO5",
locale: "en",
version: "standard",
totalScore: 48,
severityBand: "low",
item9Flagged: false,
crisisShownAt: null,
takenAt: new Date("2026-06-28T00:00:00.000Z"),
createdAt: new Date("2026-06-28T00:00:00.000Z"),
} as never);
const res = await callPost(
makeReq({ instrument: "WHO5", items: [3, 3, 2, 2, 2] }),
);
expect(res.status).toBe(201);
const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock
.calls[0][0] as {
data: { totalScore: number; severityBand: string; item9Flagged: boolean };
};
expect(createArg.data.totalScore).toBe(48);
expect(createArg.data.severityBand).toBe("low");
// Positively-worded instrument, no safety item — never flagged.
expect(createArg.data.item9Flagged).toBe(false);
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { type: string; value: number; source: string };
};
expect(measArg.data.type).toBe("WHO5_SCORE");
expect(measArg.data.value).toBe(48);
expect(measArg.data.source).toBe("COMPUTED");
const body = (await res.json()) as {
data: { crisis: unknown; actionThreshold: number };
};
// No crisis routing on the WHO-5 — the ≤50 pointer is client-side copy.
expect(body.data.crisis).toBeNull();
expect(body.data.actionThreshold).toBe(50);
});
it("accepts an SCI check-in (0–4 anchors) and projects an SCI_SCORE measurement", async () => {
vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({
id: "mha_sci",
instrument: "SCI",
locale: "en",
version: "standard",
totalScore: 16,
severityBand: "belowThreshold",
item9Flagged: false,
crisisShownAt: null,
takenAt: new Date("2026-06-28T00:00:00.000Z"),
createdAt: new Date("2026-06-28T00:00:00.000Z"),
} as never);
// Total 4+3+2+1+0+4+1+1 = 16 → the paper's ≤16 probable-insomnia range.
const res = await callPost(
makeReq({ instrument: "SCI", items: [4, 3, 2, 1, 0, 4, 1, 1] }),
);
expect(res.status).toBe(201);
const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock
.calls[0][0] as { data: { totalScore: number; severityBand: string } };
expect(createArg.data.totalScore).toBe(16);
expect(createArg.data.severityBand).toBe("belowThreshold");
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
data: { type: string; value: number; source: string };
};
expect(measArg.data.type).toBe("SCI_SCORE");
expect(measArg.data.value).toBe(16);
expect(measArg.data.source).toBe("COMPUTED");
});
it("rejects an out-of-scale answer for the instrument with 422 (SCI max 4, PHQ-9 max 3)", async () => {
// 5 is a legal WHO-5 answer but out of scale for the SCI…
const sciRes = await callPost(
makeReq({ instrument: "SCI", items: [5, 3, 2, 1, 0, 4, 1, 1] }),
);
expect(sciRes.status).toBe(422);
// …and 4 is out of scale for the PHQ-9.
const phqRes = await callPost(
makeReq({ instrument: "PHQ9", items: [4, 0, 0, 0, 0, 0, 0, 0, 0] }),
);
expect(phqRes.status).toBe(422);
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
expect(prisma.measurement.create).not.toHaveBeenCalled();
});
it("rejects a wrong item count with 422 and never writes", async () => {
const res = await callPost(
makeReq({ instrument: "PHQ9", items: [1, 2, 3] }), // 3 ≠ 9
@@ -229,8 +229,9 @@ async function postAssessment(request: NextRequest): Promise<Response> {
if (existing) return respondExisting(existing);
}
// Server-authoritative scoring (the client never computes these).
const total = scoreTotal(items);
// Server-authoritative scoring (the client never computes these). The
// total is the REPORTED score — the WHO-5 projects raw-sum × 4 (0–100).
const total = scoreTotal(id, items);
const band = severityBand(id, total);
const flagged = isSafetyFlagged(id, items);
const presentedLocale = locale ?? user.locale ?? "en";
@@ -116,11 +116,11 @@ const TYPE_GROUPS = [
],
},
// v1.27.6 — the mental-wellbeing screenings become plannable like any
// other Vorsorge item. Auto-resolve rides the server-written PHQ9_SCORE /
// GAD7_SCORE row a completed check-in produces.
// other Vorsorge item. Auto-resolve rides the server-written *_SCORE row a
// completed check-in produces (v1.27.9 adds WHO-5 + SCI).
{
group: "mentalWellbeing",
types: ["PHQ9_SCORE", "GAD7_SCORE"],
types: ["PHQ9_SCORE", "GAD7_SCORE", "WHO5_SCORE", "SCI_SCORE"],
},
] as const;
@@ -125,6 +125,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "measurements.typePhq9Score",
GAD7_SCORE: "measurements.typeGad7Score",
// ── v1.27.9 — screening scores ──
WHO5_SCORE: "measurements.typeWho5Score",
SCI_SCORE: "measurements.typeSciScore",
GRIP_STRENGTH: "measurements.typeGripStrength",
PAIN_NRS: "measurements.typePainNrs",
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
@@ -257,6 +260,9 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
// Activity the pain trace; Scale the anthropometric waist pair.
PHQ9_SCORE: Gauge,
GAD7_SCORE: Gauge,
// ── v1.27.9 — screening scores ride the same composite-dial icon ──
WHO5_SCORE: Gauge,
SCI_SCORE: Gauge,
GRIP_STRENGTH: Dumbbell,
PAIN_NRS: Activity,
WAIST_CIRCUMFERENCE: Scale,
@@ -380,6 +386,9 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
// chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist.
PHQ9_SCORE: "bg-chart-1/20 text-chart-1",
GAD7_SCORE: "bg-chart-1/20 text-chart-1",
// ── v1.27.9 — screening scores join the composite/score colour family ──
WHO5_SCORE: "bg-chart-1/20 text-chart-1",
SCI_SCORE: "bg-chart-1/20 text-chart-1",
GRIP_STRENGTH: "bg-chart-1/20 text-chart-1",
PAIN_NRS: "bg-chart-3/20 text-chart-3",
WAIST_CIRCUMFERENCE: "bg-chart-4/20 text-chart-4",
@@ -11,9 +11,9 @@ import en from "../../../../messages/en.json";
* means clicks can't be driven here — the wizard's advance/back grammar is
* pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable
* contracts: the disclaimer renders ONLY on the landing (never while testing),
* a positive item-9 surfaces the crisis card, and the history paints its
* dated list with severity + "support shown" badges — and NO trend chart
* (v1.27.6: the score curve lives in Insights / Measurements, not here).
* a positive item-9 surfaces the crisis card, and (v1.27.9) the landing is
* intro + instrument cards only — the dated history + trend chart live in the
* per-instrument detail a card click opens, pinned to one instrument.
*/
vi.mock("@/lib/api/api-fetch", () => ({
@@ -26,6 +26,7 @@ import { CheckInWizard } from "../check-in-wizard";
import { AssessmentResult } from "../assessment-result";
import { AssessmentHistory } from "../assessment-history";
import { InstrumentCard } from "../instrument-card";
import { InstrumentDetail } from "../instrument-detail";
import type { AssessmentRow, CreateResponse } from "../types";
const mh = en.mentalHealth;
@@ -178,18 +179,17 @@ describe("history rendering", () => {
},
];
const html = withProviders(<AssessmentHistory rows={rows} />);
const html = withProviders(
<AssessmentHistory rows={rows} instrument="PHQ9" />,
);
it("paints the dated list WITHOUT a trend chart shell", () => {
it("paints the pinned dated list with the trend-chart shell, no instrument toggle", () => {
// v1.27.9 — the history is pinned to one instrument inside the detail
// surface: no PHQ-9/GAD-7 tablist, and the lazy chart's skeleton shell
// holds the layout in SSR (recharts itself stays off first-load JS).
expect(html).toContain('data-slot="history-list"');
// v1.27.6 — no chart on this surface: no lazy-chart skeleton, no
// recharts container.
expect(html).not.toContain('data-slot="skeleton"');
expect(html).not.toContain("recharts");
});
it("shows the PHQ-9 / GAD-7 toggle and a severity badge", () => {
expect(html).toContain('role="tablist"');
expect(html).toContain('data-pinned="PHQ9"');
expect(html).not.toContain('role="tablist"');
expect(html).toContain(mh.band.PHQ9.modSevere);
});
@@ -198,14 +198,175 @@ describe("history rendering", () => {
expect(html).toContain(mh.history.flaggedBadge);
});
it("renders the empty state with no rows", () => {
const empty = withProviders(<AssessmentHistory rows={[]} />);
it("renders the empty state with no rows for the pinned instrument", () => {
const empty = withProviders(
<AssessmentHistory rows={rows} instrument="SCI" />,
);
expect(empty).toContain(mh.history.empty);
expect(empty).not.toContain('data-slot="history-list"');
});
it("stays OFF the landing entirely (intro + cards only)", () => {
const landing = withProviders(<MentalWellbeing />);
expect(landing).not.toContain('data-slot="mental-health-history"');
expect(landing).not.toContain('data-slot="history-list"');
});
});
describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => {
it("renders all four instrument cards on the landing", () => {
const html = withProviders(<MentalWellbeing />);
expect(html).toContain(mh.instrument.phq9);
expect(html).toContain(mh.instrument.gad7);
expect(html).toContain(mh.instrument.who5);
expect(html).toContain(mh.instrument.sci);
// The rhythm hint states both recall windows honestly.
expect(html).toContain(mh.pageRhythmHint);
});
it("WHO-5 wizard opens on item 1 with the six-point scale in source order and the recall stem", () => {
const html = withProviders(
<CheckInWizard
instrument="WHO5"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).toContain(mh.items.who5["1"]);
expect(html).toContain('data-slot="check-in-stem"');
expect(html).toContain(mh.stems.who5.period);
// Six anchors, 5 → 0 (the WHO form leads with "All of the time").
for (const v of ["5", "4", "3", "2", "1", "0"] as const) {
expect(html).toContain(mh.who5Options[v]);
}
// English items are the validated wording for en — no translation note.
expect(html).not.toContain('data-slot="check-in-validated-note"');
// No functional follow-up outside the PHQ-9: 5 steps total.
expect(html).toContain("1 / 5");
});
it("SCI wizard opens on item 1 with the item-specific duration anchors and section stem", () => {
const html = withProviders(
<CheckInWizard
instrument="SCI"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).toContain(mh.items.sci["1"]);
expect(html).toContain(mh.stems.sci.night);
// Item 1 carries the sleep-latency time ranges, 4 → 0.
for (const v of ["4", "3", "2", "1", "0"] as const) {
expect(html).toContain(mh.sciOptions.duration[v]);
}
expect(html).toContain("1 / 8");
});
it("shows the gentle PHQ-9 pointer on a WHO-5 total of 50 or below", () => {
const result: CreateResponse = {
assessment: {
id: "w1",
instrument: "WHO5",
locale: "en",
totalScore: 48,
severityBand: "low",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
actionThreshold: 50,
crisis: null,
};
const html = withProviders(
<AssessmentResult
result={result}
onTakeAnother={() => {}}
onBack={() => {}}
/>,
);
expect(html).toContain('data-slot="result-follow-up-hint"');
expect(html).toContain(mh.followUpHint.who5);
// Required WHO attribution rides the result view.
expect(html).toContain("CC BY-NC-SA 3.0 IGO");
expect(html).not.toContain('data-slot="mental-health-crisis-card"');
});
it("suppresses the hint on a good WHO-5 total (direction is inverted vs PHQ-9)", () => {
const result: CreateResponse = {
assessment: {
id: "w2",
instrument: "WHO5",
locale: "en",
totalScore: 80,
severityBand: "good",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
actionThreshold: 50,
crisis: null,
};
const html = withProviders(
<AssessmentResult
result={result}
onTakeAnother={() => {}}
onBack={() => {}}
/>,
);
expect(html).not.toContain('data-slot="result-follow-up-hint"');
});
it("shows the neutral SCI band wording at 16 or below, with the Sleepio/BMJ attribution", () => {
const result: CreateResponse = {
assessment: {
id: "s1",
instrument: "SCI",
locale: "en",
totalScore: 14,
severityBand: "belowThreshold",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
actionThreshold: 16,
crisis: null,
};
const html = withProviders(
<AssessmentResult
result={result}
onTakeAnother={() => {}}
onBack={() => {}}
/>,
);
expect(html).toContain(mh.followUpHint.sci);
expect(html).toContain(mh.band.SCI.belowThreshold);
expect(html).toContain("BMJ Open");
});
it("pinned history renders the WHO-5 / SCI series through the same component", () => {
const row: AssessmentRow = {
id: "t1",
instrument: "WHO5",
locale: "en",
totalScore: 72,
severityBand: "good",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
};
const html = withProviders(
<AssessmentHistory rows={[row]} instrument="WHO5" />,
);
expect(html).toContain('data-pinned="WHO5"');
expect(html).toContain(mh.band.WHO5.good);
});
});
describe("instrument card (med-/Vorsorge card anatomy + detail opener)", () => {
const lastRow: AssessmentRow = {
id: "l1",
instrument: "PHQ9",
@@ -217,25 +378,43 @@ describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
takenAt: "2026-06-20T00:00:00.000Z",
};
it("renders the shared med-card header (title + category badge), no trend target", () => {
const html = withProviders(
<InstrumentCard instrument="PHQ9" last={undefined} onStart={() => {}} />,
function card(
instrument: AssessmentRow["instrument"],
last?: AssessmentRow,
): string {
return withProviders(
<InstrumentCard
instrument={instrument}
last={last}
onStart={() => {}}
onOpenDetail={() => {}}
/>,
);
}
it("renders the shared med-card header and the clickable detail body", () => {
const html = card("PHQ9");
// The med-card header primitive paints the bold name + category badge.
expect(html).toContain(mh.instrument.phq9);
expect(html).toContain(mh.instrumentSub.phq9);
// The former trend-detail button is gone…
expect(html).not.toContain('data-slot="instrument-card-open"');
// v1.27.9 — the card body opens the per-instrument detail…
expect(html).toContain('data-slot="instrument-card-open"');
expect(html).toContain(mh.openDetail);
// …and the Start action remains the single bottom-pinned action.
expect(html).toContain(mh.start);
// No history yet → the calm no-check-in line, no fabricated dashes.
expect(html).toContain(mh.noResultYet);
// v1.27.9 — the required attribution footer rides every card.
expect(html).toContain('data-slot="instrument-card-attribution"');
});
it("carries the instrument's licence line on the WHO-5 / SCI cards", () => {
expect(card("WHO5")).toContain("CC BY-NC-SA 3.0 IGO");
expect(card("SCI")).toContain("Sleepio Limited");
});
it("shows last test (relative) and last result (score + band word)", () => {
const html = withProviders(
<InstrumentCard instrument="PHQ9" last={lastRow} onStart={() => {}} />,
);
const html = card("PHQ9", lastRow);
expect(html).toContain(mh.lastResult);
expect(html).toContain(mh.lastScore);
// Score + band word ride one value slot ("8 · Mild").
@@ -244,3 +423,46 @@ describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
expect(html).not.toContain(mh.noResultYet);
});
});
describe("instrument detail (v1.27.9 — opened from a card)", () => {
const rows: AssessmentRow[] = [
{
id: "d1",
instrument: "SCI",
locale: "en",
totalScore: 22,
severityBand: "aboveThreshold",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
{
id: "d2",
instrument: "PHQ9",
locale: "en",
totalScore: 4,
severityBand: "minimal",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-01T00:00:00.000Z",
},
];
it("shows last score + band, the Start action, the pinned history and the attribution", () => {
const html = withProviders(
<InstrumentDetail instrument="SCI" rows={rows} onStart={() => {}} />,
);
expect(html).toContain('data-slot="instrument-detail"');
expect(html).toContain('data-slot="instrument-detail-last-score"');
expect(html).toContain(mh.band.SCI.aboveThreshold);
expect(html).toContain('data-slot="instrument-detail-start"');
expect(html).toContain(mh.start);
// The pinned history (chart shell + dated list) rides inside the detail.
expect(html).toContain('data-pinned="SCI"');
expect(html).toContain('data-slot="history-list"');
// …filtered to the pinned instrument: the PHQ-9 row stays out.
expect(html).not.toContain(mh.band.PHQ9.minimal);
// Required attribution line.
expect(html).toContain("Sleepio Limited");
});
});
@@ -0,0 +1,167 @@
"use client";
/**
* v1.25.3 — per-instrument screener trend chart.
*
* v1.25.5 — aligned to the dashboard's chart language exactly: a single
* `type="monotone"` line over the primary colour with a soft gradient area
* fill beneath (same `fillOpacity` + margins the dashboard line charts use),
* the shared grid / axis tokens, and `RichChartTooltip`. The earlier
* severity-band `ReferenceArea`s — unique to this surface — are gone so it
* reads like every other trend; the band still surfaces in the tooltip.
*
* v1.27.9 — back on the surface, but only inside the per-instrument detail
* (opened from a card): the landing stays free of any score curve, so the
* trend is opt-in behind a deliberate click. The Y domain and band wording
* come from the instrument registry, so the WHO-5 (0–100, higher = better)
* and the SCI (0–32, higher = better) render through the same component.
*
* One instrument at a time: the ranges and band sets differ, so the parent
* pins which series this paints.
*/
import { useMemo } from "react";
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { formatDateShort } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
import type { AssessmentRow, InstrumentId } from "./types";
interface ChartPoint {
timestamp: number;
value: number;
band: string;
}
export function AssessmentHistoryChart({
instrument,
rows,
}: {
instrument: InstrumentId;
/** Rows for THIS instrument, any order (sorted internally, ascending time). */
rows: AssessmentRow[];
}) {
const { t } = useTranslations();
const def = INSTRUMENTS[instrument];
const points = useMemo<ChartPoint[]>(() => {
return [...rows]
.sort(
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
)
.map((r) => ({
timestamp: new Date(r.takenAt).getTime(),
value: r.totalScore,
band: r.severityBand,
}));
}, [rows]);
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
const gradientId = `mh-trend-${instrument}`;
if (points.length === 0) {
return (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("mentalHealth.history.empty")}
</p>
);
}
return (
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
<ComposedChart
data={points}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={primary} stopOpacity={0.18} />
<stop offset="95%" stopColor={primary} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) => formatDateShort(new Date(ts))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={[0, def.maxScore]}
allowDecimals={false}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={32}
/>
<Tooltip
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rowsOut: RichTooltipRow[] = [
{
name: t("mentalHealth.history.totalLabel"),
value: String(point.value),
color: primary,
},
{
name: t("mentalHealth.history.bandLabel"),
value: t(`mentalHealth.band.${instrument}.${point.band}`),
color: "var(--muted-foreground)",
},
];
return (
<RichChartTooltip
active
label={formatDateShort(new Date(point.timestamp))}
rows={rowsOut}
/>
);
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="none"
fill={`url(#${gradientId})`}
isAnimationActive={animate}
/>
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
);
}
@@ -1,30 +1,31 @@
"use client";
/**
* v1.25.3 — the history card: a dated list with severity badges.
* v1.25.3 — the dated result history with severity badges.
*
* v1.27.6 — the trend CHART is gone from this surface. A rising score curve
* on the check-in page itself reads as a verdict at the exact moment someone
* is taking stock of how they feel; the page stays a calm list of past
* check-ins. The score trend still exists — the totals ride PHQ9_SCORE /
* GAD7_SCORE measurement rows, so Insights / Measurements carry the curve
* for whoever seeks it out.
* v1.27.9 — pinned-only: the combined landing card (with the instrument
* toggle) is gone; the landing shows intro + instrument cards and nothing
* else. This component now renders ONE instrument's detail body — trend
* chart (lazy Recharts, the labs-detail precedent) over the dated list —
* inside the detail surface a card click opens. The history is deliberately
* opt-in behind that click, never pushed onto the main page.
*
* PHQ-9 / GAD-7 toggle: the two instruments have different score ranges +
* bands, so the list shows one at a time.
*
* CRISIS-SAFETY: a flagged PHQ-9 row carries a discreet "support shown" marker;
* tapping it re-surfaces the STATIC crisis-resource card derived from the row's
* stored locale (`crisisResourcesForLocale`) — it NEVER decrypts or reveals the
* item-9 answer, and surfacing it triggers no third-party alert. No new data
* leaves the safety boundary.
* CRISIS-SAFETY: a flagged PHQ-9 row carries a discreet "support shown"
* marker; tapping it re-surfaces the STATIC crisis-resource card derived from
* the row's stored locale (`crisisResourcesForLocale`) — it NEVER decrypts or
* reveals the item-9 answer, and surfacing it triggers no third-party alert.
* No new data leaves the safety boundary.
*/
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
import type { ComponentProps } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources";
import { Activity } from "lucide-react";
@@ -32,8 +33,24 @@ import { Activity } from "lucide-react";
import { CrisisCard } from "./crisis-card";
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
function lower(id: InstrumentId): "phq9" | "gad7" {
return id === "PHQ9" ? "phq9" : "gad7";
// Defer the recharts trend chart so recharts stays off the surface's
// first-load JS (the labs detail precedent). The `<ChartSkeleton>` shell
// holds the layout.
const AssessmentHistoryChartLazy = dynamic(
() =>
importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({
default: mod.AssessmentHistoryChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
function AssessmentHistoryChart(
props: ComponentProps<typeof AssessmentHistoryChartLazy>,
) {
return (
<ChartErrorBoundary>
<AssessmentHistoryChartLazy {...props} />
</ChartErrorBoundary>
);
}
/** Re-derive the STATIC crisis set from a row's locale — no decryption. */
@@ -48,122 +65,92 @@ function crisisFromRow(row: AssessmentRow): CrisisSet {
};
}
export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
export function AssessmentHistory({
rows,
instrument,
}: {
/** ALL assessments (any instrument); filtered internally. */
rows: AssessmentRow[];
/** The instrument this detail surface is pinned to. */
instrument: InstrumentId;
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
const [tab, setTab] = useState<InstrumentId>("PHQ9");
// Which flagged row's crisis card is currently expanded (re-surfaced).
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(null);
const forTab = useMemo(
const forInstrument = useMemo(
() =>
rows
.filter((r) => r.instrument === tab)
.filter((r) => r.instrument === instrument)
.sort(
(a, b) =>
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
),
[rows, tab],
[rows, instrument],
);
const isEmpty = rows.length === 0;
if (forInstrument.length === 0) {
return (
<div data-slot="mental-health-history" data-pinned={instrument}>
<EmptyState
icon={<Activity className="size-6" />}
title={t("mentalHealth.history.title")}
description={t("mentalHealth.history.empty")}
/>
</div>
);
}
return (
<Card data-slot="mental-health-history">
<CardHeader>
<CardTitle className="text-base">
{t("mentalHealth.history.title")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isEmpty ? (
<EmptyState
icon={<Activity className="size-6" />}
title={t("mentalHealth.history.title")}
description={t("mentalHealth.history.empty")}
/>
) : (
<>
{/* PHQ-9 / GAD-7 toggle — the two ranges + band sets differ, so
the list shows one instrument at a time. */}
<div
className="flex gap-1"
role="tablist"
aria-label={t("mentalHealth.history.title")}
>
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
<Button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
size="sm"
variant={tab === id ? "secondary" : "ghost"}
className="h-8 px-3 text-xs"
onClick={() => {
setTab(id);
setOpenCrisisRowId(null);
}}
>
{t(`mentalHealth.history.tab.${lower(id)}`)}
</Button>
))}
</div>
<div
data-slot="mental-health-history"
data-pinned={instrument}
className="flex flex-col gap-4"
>
<AssessmentHistoryChart instrument={instrument} rows={forInstrument} />
{forTab.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("mentalHealth.history.empty")}
</p>
) : (
<ul className="flex flex-col gap-1.5" data-slot="history-list">
{forTab.map((row) => {
const flagged = row.item9Flagged;
const open = openCrisisRowId === row.id;
return (
<li key={row.id} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">
{formatDate(row.takenAt)}
</span>
<div className="flex items-center gap-2">
{flagged && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0"
aria-expanded={open}
onClick={() =>
setOpenCrisisRowId(open ? null : row.id)
}
data-slot="history-flagged-marker"
>
<Badge variant="outline">
{t("mentalHealth.history.flaggedBadge")}
</Badge>
</Button>
)}
<Badge variant="secondary">
{t(
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
)}
</Badge>
<span className="font-medium tabular-nums">
{row.totalScore}
</span>
</div>
</div>
{flagged && open && (
<CrisisCard crisis={crisisFromRow(row)} />
)}
</li>
);
})}
</ul>
)}
</>
)}
</CardContent>
</Card>
<ul className="flex flex-col gap-1.5" data-slot="history-list">
{forInstrument.map((row) => {
const flagged = row.item9Flagged;
const open = openCrisisRowId === row.id;
return (
<li key={row.id} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">
{formatDate(row.takenAt)}
</span>
<div className="flex items-center gap-2">
{flagged && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0"
aria-expanded={open}
onClick={() => setOpenCrisisRowId(open ? null : row.id)}
data-slot="history-flagged-marker"
>
<Badge variant="outline">
{t("mentalHealth.history.flaggedBadge")}
</Badge>
</Button>
)}
<Badge variant="secondary">
{t(
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
)}
</Badge>
<span className="font-medium tabular-nums">
{row.totalScore}
</span>
</div>
</div>
{flagged && open && <CrisisCard crisis={crisisFromRow(row)} />}
</li>
);
})}
</ul>
</div>
);
}
@@ -6,6 +6,12 @@
* another". Content is unchanged from the v1.25 monolith; it is re-housed as
* one stack inside the page spine instead of loose top-level sections.
*
* v1.27.9 — direction-aware follow-up hints from the registry: PHQ-9/GAD-7
* keep the ≥10 professional-help suggestion, a WHO-5 total of 50 or below
* gets a gentle pointer to the PHQ-9 check-in (per the WHO scoring comment),
* and an SCI total of 16 or below gets the paper's neutral band wording.
* The attribution footer now comes from the instrument definition.
*
* SAFETY: the crisis set rides the POST response (`crisis`) on any non-zero
* item 9 and renders immediately; this surface never invites an AI Coach
* conversation about item content.
@@ -16,14 +22,10 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { PHQ_GAD_ATTRIBUTION } from "@/lib/mental-health/instruments";
import { INSTRUMENTS, needsFollowUp } from "@/lib/mental-health/instruments";
import { CrisisCard } from "./crisis-card";
import type { CreateResponse, InstrumentId } from "./types";
function lower(id: InstrumentId): "phq9" | "gad7" {
return id === "PHQ9" ? "phq9" : "gad7";
}
import type { CreateResponse } from "./types";
export function AssessmentResult({
result,
@@ -36,7 +38,8 @@ export function AssessmentResult({
}) {
const { t } = useTranslations();
const instrument = result.assessment.instrument;
const key = lower(instrument);
const def = INSTRUMENTS[instrument];
const key = def.i18nKey;
// a11y: on submit the wizard unmounts and this view mounts; move focus to the
// result heading so keyboard / screen-reader users land on the new content
@@ -104,9 +107,16 @@ export function AssessmentResult({
)}
</span>
</div>
{result.assessment.totalScore >= result.actionThreshold && (
<p className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs">
{t("mentalHealth.considerProfessional")}
{/* Direction-aware follow-up hint: PHQ-9/GAD-7 point up (≥ 10),
WHO-5 (≤ 50 → gentle PHQ-9 pointer) and SCI (≤ 16 → the paper's
neutral band wording) point down. Soft suggestions, never a
diagnosis. */}
{needsFollowUp(instrument, result.assessment.totalScore) && (
<p
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
data-slot="result-follow-up-hint"
>
{t(`mentalHealth.followUpHint.${key}`)}
</p>
)}
</CardContent>
@@ -114,11 +124,11 @@ export function AssessmentResult({
{result.crisis && <CrisisCard crisis={result.crisis} />}
<p className="text-muted-foreground text-[11px] leading-snug">
<p className="text-muted-foreground text-xs leading-snug">
<span className="font-medium">
{t("mentalHealth.attributionLabel")}:
</span>{" "}
{PHQ_GAD_ATTRIBUTION}
{def.attribution}
</p>
<Button variant="outline" onClick={onTakeAnother}>
@@ -12,6 +12,14 @@
* Answering the last question arms the submit directly — the next thing
* the user sees is the dignified result view.
*
* v1.27.9 — the wizard drives from the instrument registry instead of a
* hard-wired 0–3 scale: option values/order, option-label scheme (shared
* anchors vs the SCI's per-item anchor groups), and the recall-stem caption
* (the WHO-5 / SCI items are fragments under a shared stem on the source
* forms) all come from `INSTRUMENTS`. Locales without a validated item
* translation (SCI outside English) get an honest chrome note; the items
* themselves stay in the validated source language.
*
* Header: instrument title + ONE standardized explanation line, NO
* disclaimer (§2 — the "voluntary self-test, not a diagnosis" disclaimer
* renders only on the landing, never while taking the test).
@@ -22,7 +30,12 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
import {
INSTRUMENTS,
hasValidatedItems,
optionLabelKey,
stemKey,
} from "@/lib/mental-health/instruments";
import {
isComplete,
@@ -33,11 +46,7 @@ import {
} from "./check-in-nav";
import type { InstrumentId } from "./types";
const SCALE = [0, 1, 2, 3] as const;
function lower(id: InstrumentId): "phq9" | "gad7" {
return id === "PHQ9" ? "phq9" : "gad7";
}
const FUNCTIONAL_SCALE = [0, 1, 2, 3] as const;
export function CheckInWizard({
instrument,
@@ -54,15 +63,16 @@ export function CheckInWizard({
isPending: boolean;
isError: boolean;
}) {
const { t } = useTranslations();
const key = lower(instrument);
const itemCount = INSTRUMENTS[instrument].itemCount;
const { t, locale } = useTranslations();
const def = INSTRUMENTS[instrument];
const key = def.i18nKey;
const itemCount = def.itemCount;
// v1.27.8 — the PHQ-9 asks its validated functional-impairment
// follow-up as a regular LAST question (10 of 10) instead of a
// separate review-step widget. It is OPTIONAL per the instrument
// (answering is not required to submit) and never scores into the
// total — the value rides the existing `functionalDifficulty` API
// field. GAD-7 has no such item and keeps its 7 steps.
// field. The other instruments have no such item.
const hasFunctionalStep = instrument === "PHQ9";
const totalSteps = itemCount + (hasFunctionalStep ? 1 : 0);
@@ -79,6 +89,9 @@ export function CheckInWizard({
const questionLabel = onFunctionalStep
? t("mentalHealth.functionalTitle")
: t(`mentalHealth.items.${key}.${step}`);
// Recall-stem caption above the question (WHO-5 / SCI fragments).
const stem = onFunctionalStep ? null : stemKey(instrument, questionIndex);
const scale = onFunctionalStep ? FUNCTIONAL_SCALE : def.optionOrder;
// a11y: announce each step by moving focus to its heading (the question
// text). Mirrors the medication dialog's per-step title focus so a
@@ -135,6 +148,16 @@ export function CheckInWizard({
<p className="text-muted-foreground text-sm">
{t(`mentalHealth.instrumentDescription.${key}`)}
</p>
{/* Honest translation note: the instrument is validated in its source
language; the surrounding chrome stays localized. */}
{!hasValidatedItems(instrument, locale) && (
<p
className="text-muted-foreground text-xs"
data-slot="check-in-validated-note"
>
{t("mentalHealth.validatedInEnglishNote")}
</p>
)}
</header>
<Card className="gap-0 p-0">
@@ -146,6 +169,11 @@ export function CheckInWizard({
total: totalSteps,
})}
</p>
{stem && (
<p className="text-sm" data-slot="check-in-stem">
{t(`mentalHealth.stems.${stem}`)}
</p>
)}
<h2
ref={headingRef}
tabIndex={-1}
@@ -163,7 +191,7 @@ export function CheckInWizard({
role="group"
aria-label={questionLabel}
>
{SCALE.map((v) => {
{scale.map((v) => {
const selected = onFunctionalStep
? functional === v
: items[questionIndex] === v;
@@ -180,7 +208,9 @@ export function CheckInWizard({
>
{onFunctionalStep
? t(`mentalHealth.functional.${v}`)
: t(`mentalHealth.options.${v}`)}
: t(
`mentalHealth.${optionLabelKey(instrument, questionIndex, v)}`,
)}
</Button>
);
})}
@@ -3,54 +3,81 @@
/**
* v1.25.3 — one neutral card per instrument on the landing.
*
* v1.27.6 — rebuilt onto the shared med-/Vorsorge card anatomy so the two
* test cards read exactly like their peers: `MedicationCardHeader` (bold
* v1.27.6 — rebuilt onto the shared med-/Vorsorge card anatomy so the test
* cards read exactly like their peers: `MedicationCardHeader` (bold
* name + outline category badge), a label/value block in the next-last
* grammar ("last test" → relative day, "last result" → score + band word),
* and a single bottom-pinned Start action. The surface stays NEUTRAL
* regardless of the last band — no severity tint (house rule: status reads
* through discreet text only, never a card wash).
*
* v1.27.9 — the card generalises over the four-instrument registry and
* carries the instrument's required attribution/licence line as a small
* factual footer (WHO CC BY-NC-SA citation for the WHO-5, the Sleepio
* CC BY-NC citation for the SCI, the Pfizer grant for PHQ/GAD). A locale
* without validated item wording additionally gets the honest
* "validated in English" note. The card BODY (header + last-result block)
* opens the per-instrument detail — the Vorsorge-/med-card interaction —
* while the bottom-pinned Start button stays a separate, non-overlapping
* action.
*/
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import {
INSTRUMENTS,
hasValidatedItems,
} from "@/lib/mental-health/instruments";
import type { AssessmentRow, InstrumentId } from "./types";
function lower(id: InstrumentId): "phq9" | "gad7" {
return id === "PHQ9" ? "phq9" : "gad7";
}
export function InstrumentCard({
instrument,
last,
onStart,
onOpenDetail,
}: {
instrument: InstrumentId;
/** Most-recent assessment for this instrument, or undefined when none yet. */
last: AssessmentRow | undefined;
onStart: () => void;
/**
* Open this instrument's detail (last score + band, trend chart, dated
* history). The card body is the target; Start stays separate.
*/
onOpenDetail: () => void;
}) {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const { date: formatDate } = useFormatters();
const key = lower(instrument);
const def = INSTRUMENTS[instrument];
const key = def.i18nKey;
const title = t(`mentalHealth.instrument.${key}`);
return (
<Card className="h-full gap-3 md:gap-3" data-slot="instrument-card">
<MedicationCardHeader
name={title}
dose=""
categoryLabel={t(`mentalHealth.instrumentSub.${key}`)}
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{/* Body is the detail target: clicking the header / last-result block
opens this instrument's detail (chart + dated history), like a
medication card. The Start button below stays a separate action. */}
<button
type="button"
onClick={onOpenDetail}
data-slot="instrument-card-open"
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
className="focus-visible:ring-ring/60 hover:bg-accent/40 flex cursor-pointer flex-col gap-3 rounded-t-xl text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<MedicationCardHeader
name={title}
dose=""
categoryLabel={t(`mentalHealth.instrumentSub.${key}`)}
/>
{/* Label/value block in the Vorsorge card's next-last grammar. With
no history yet, one calm line replaces the pair — no dashes
pretending a value exists. */}
{last ? (
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
<div className="min-h-[2.75rem] w-full space-y-1.5 px-4 text-sm md:px-6">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastResult")}
@@ -74,16 +101,29 @@ export function InstrumentCard({
</div>
</div>
) : (
<div className="text-muted-foreground flex min-h-[2.75rem] items-center text-sm">
<div className="text-muted-foreground flex min-h-[2.75rem] items-center px-4 text-sm md:px-6">
{t("mentalHealth.noResultYet")}
</div>
)}
</button>
<CardContent className="flex h-full flex-col">
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot. */}
<div className="mt-auto pt-0">
<div className="mt-auto space-y-2 pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
{/* Required attribution — factual and unobtrusive; identical slot on
every card so the grid stays symmetric. */}
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="instrument-card-attribution"
>
{!hasValidatedItems(instrument, locale) && (
<>{t("mentalHealth.validatedInEnglishNote")} </>
)}
{def.attribution}
</p>
</div>
</CardContent>
</Card>
@@ -0,0 +1,86 @@
"use client";
/**
* v1.27.9 — per-instrument detail surface, opened by clicking an instrument
* card (the Vorsorge-/med-card detail interaction). Hosted in a
* `ResponsiveSheet` by the page orchestrator; this component owns the calm
* detail spine: last score + band → Start action → trend chart + dated
* history (via the pinned `AssessmentHistory`) → the instrument's required
* attribution line. The history and the score curve live ONLY here — the
* landing stays a quiet card grid, so the trend is opt-in behind a
* deliberate click, never pushed at the moment someone arrives to check in.
*/
import { Button } from "@/components/ui/button";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
import { AssessmentHistory } from "./assessment-history";
import type { AssessmentRow, InstrumentId } from "./types";
export function InstrumentDetail({
instrument,
rows,
onStart,
}: {
instrument: InstrumentId;
/** ALL assessments; the pinned history filters to this instrument. */
rows: AssessmentRow[];
/** Start a check-in for this instrument (closes the host sheet). */
onStart: () => void;
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
const def = INSTRUMENTS[instrument];
const last = rows.find((r) => r.instrument === instrument);
return (
<div className="flex flex-col gap-4" data-slot="instrument-detail">
{/* Last result, in the card's label/value grammar — score + band word
on one slot, the relative day on the other. Neutral text, no tint. */}
{last && (
<div className="space-y-1.5 text-sm">
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastResult")}
</span>
<span className="text-foreground text-right">
{relativeCalendarDate(last.takenAt, t, formatDate)}
</span>
</div>
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-shrink truncate font-medium">
{t("mentalHealth.lastScore")}
</span>
<span
className="text-foreground text-right"
data-slot="instrument-detail-last-score"
>
{last.totalScore}
{" · "}
{t(`mentalHealth.band.${last.instrument}.${last.severityBand}`)}
</span>
</div>
</div>
)}
{/* The Start action stays reachable here too — the detail is a calm
reading surface, not a dead end. */}
<Button
type="button"
className="min-h-11 w-full"
onClick={onStart}
data-slot="instrument-detail-start"
>
{t("mentalHealth.start")}
</Button>
<AssessmentHistory rows={rows} instrument={instrument} />
{/* Required attribution — same factual line as the card + result view. */}
<p className="text-muted-foreground text-xs leading-snug">
{def.attribution}
</p>
</div>
);
}
@@ -20,9 +20,12 @@
* cards (last-result line + Start) + a history card. The disclaimer renders
* ONLY here (a muted caption) and behind the InfoHint — never while testing.
*
* v1.27.6 — the trend chart (and the per-card trend sheet) left this surface;
* the score curve lives in Insights / Measurements via the PHQ9_SCORE /
* GAD7_SCORE measurement rows. The landing keeps the calm dated list only.
* v1.27.9 — the landing is intro + instrument cards, nothing else: the
* combined history card left the main page entirely. Each card opens a
* per-instrument detail sheet (the Vorsorge-/med-card detail interaction)
* carrying last score + band, the trend chart, the dated history, and the
* Start action — the Verlauf is opt-in behind that click, never pushed onto
* the page someone arrives at to take stock.
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -30,12 +33,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
import { PageHeader } from "@/components/ui/page-header";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { AssessmentHistory } from "./assessment-history";
import { AssessmentResult } from "./assessment-result";
import { CheckInWizard } from "./check-in-wizard";
import { InstrumentCard } from "./instrument-card";
import { InstrumentDetail } from "./instrument-detail";
import type {
AssessmentRow,
CreateResponse,
@@ -50,6 +55,11 @@ export function MentalWellbeing() {
const [phase, setPhase] = useState<Phase>("choose");
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
const [result, setResult] = useState<CreateResponse | null>(null);
// Which instrument's detail sheet is open (null = closed). Clicking a card
// body opens THAT instrument's detail; Start stays a separate action.
const [detailInstrument, setDetailInstrument] = useState<InstrumentId | null>(
null,
);
const { data: history } = useQuery({
queryKey: queryKeys.mentalHealthAssessments(),
@@ -82,6 +92,7 @@ export function MentalWellbeing() {
}, [history]);
function begin(id: InstrumentId) {
setDetailInstrument(null);
setInstrument(id);
setResult(null);
mutation.reset();
@@ -116,9 +127,10 @@ export function MentalWellbeing() {
description={t("mentalHealth.pageDescription")}
/>
{/* v1.27.6 — one gentle cadence hint: both instruments ask about
the last two weeks, so every 2–4 weeks is a sensible rhythm.
Body prose (foreground), not another muted description line. */}
{/* v1.27.6 — one gentle cadence hint; v1.27.9 states each recall
window honestly (PHQ-9 / GAD-7 / WHO-5 look back two weeks, the
SCI asks about the past month). Body prose (foreground), not
another muted description line. */}
<p className="max-w-prose text-sm">
{t("mentalHealth.pageRhythmHint")}
</p>
@@ -126,19 +138,45 @@ export function MentalWellbeing() {
<section aria-label={t("mentalHealth.choosePrompt")}>
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
{INSTRUMENT_ORDER.map((id) => (
<li key={id} className="contents">
<InstrumentCard
instrument={id}
last={lastByInstrument.get(id)}
onStart={() => begin(id)}
onOpenDetail={() => setDetailInstrument(id)}
/>
</li>
))}
</ul>
</section>
<AssessmentHistory rows={history?.assessments ?? []} />
{/* Per-instrument detail, opened from a card body: last result,
trend chart, dated history, Start — the Verlauf lives here,
not on the landing. */}
<ResponsiveSheet
open={detailInstrument !== null}
onOpenChange={(open) => {
if (!open) setDetailInstrument(null);
}}
contentWidth="lg"
title={
detailInstrument
? t(
`mentalHealth.instrument.${INSTRUMENTS[detailInstrument].i18nKey}`,
)
: t("mentalHealth.history.title")
}
description={t("mentalHealth.history.chartTitle")}
>
{detailInstrument && (
<InstrumentDetail
instrument={detailInstrument}
rows={history?.assessments ?? []}
onStart={() => begin(detailInstrument)}
/>
)}
</ResponsiveSheet>
</>
)}
@@ -105,10 +105,14 @@ const EXPECTED_TYPES = [
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
// ── v1.27.9 — screening scores (WHO-5 percentage 0–100, SCI total 0–32;
// both server-derived from a completed assessment, higher = better) ──
"WHO5_SCORE",
"SCI_SCORE",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 73 canonical measurement types", () => {
it("exposes the 75 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -264,6 +268,12 @@ describe("measurementTypeEnum coverage", () => {
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
// v1.27.9 — WHO-5 / SCI screening totals follow the PHQ-9 / GAD-7
// precedent: opt-in questionnaire derivations, excluded from the vitals
// table; they ride the module-gated mental-health export section
// instead. See doctor-report-pdf-core.ts for the matching rationale.
"WHO5_SCORE",
"SCI_SCORE",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+3
View File
@@ -1620,6 +1620,9 @@ const MEASUREMENT_TYPE_MODULE: Record<string, ModuleKey> = {
// opted into.
PHQ9_SCORE: "mentalHealth",
GAD7_SCORE: "mentalHealth",
// v1.27.9 — the WHO-5 / SCI totals ride the same module gate.
WHO5_SCORE: "mentalHealth",
SCI_SCORE: "mentalHealth",
};
function filterMeasurementKeys<T>(
+5
View File
@@ -201,6 +201,11 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
* v1.19.0 — Oura resilience. `RESILIENCE` is an ordinal-encoded categorical
* band (limited=1 … exceptional=5), a derived recovery composite — descriptive,
* not a measured clinical vital — so it is excluded too.
*
* v1.27.9 — WHO-5 / SCI screening totals. `WHO5_SCORE` + `SCI_SCORE` follow
* the PHQ-9 / GAD-7 precedent: opt-in, derived from an in-app questionnaire,
* excluded from the vitals table (they ride the module-gated mental-health
* export section instead, see `doctor-report-data.ts`).
*/
export const DOCTOR_REPORT_VITAL_TYPES = [
"WEIGHT",
+4
View File
@@ -133,6 +133,10 @@ export const ALLOWED_CHART_TOKENS = [
// generic chart.
"metric:PHQ9_SCORE",
"metric:GAD7_SCORE",
// v1.27.9 — WHO-5 (0–100 percentage) + SCI (0–32) screening totals; same
// continuous-series contract, higher = better.
"metric:WHO5_SCORE",
"metric:SCI_SCORE",
"metric:GRIP_STRENGTH",
"metric:PAIN_NRS",
"metric:WAIST_CIRCUMFERENCE",
@@ -184,6 +184,28 @@ describe("runReminderSatisfyForUser", () => {
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
});
it("satisfies the WHO-5 / SCI screening reminders on the identical contract (v1.27.9)", async () => {
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
const measuredAt = new Date("2026-07-01T10:00:00Z");
const { prisma, updates } = makePrisma({
reminders: [reminderRow({ measurementType, intervalDays: 28 })],
measurement: { measuredAt },
});
const isModuleEnabled = vi.fn(async () => true);
const summary = await runReminderSatisfyForUser(
prisma as never,
"u1",
new Date("2026-07-01T12:00:00Z"),
{ isModuleEnabled },
);
expect(summary.satisfied, `${measurementType} should satisfy`).toBe(1);
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
}
});
it("produces no engine activity for a screening reminder when the mental-health module is off", async () => {
const { prisma, updates } = makePrisma({
reminders: [
+2
View File
@@ -67,6 +67,8 @@ function moduleForMeasurementType(
return "sleep";
case "PHQ9_SCORE":
case "GAD7_SCORE":
case "WHO5_SCORE":
case "SCI_SCORE":
return "mentalHealth";
default:
return null;
@@ -89,6 +89,10 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
"GRIP_STRENGTH",
"PAIN_NRS",
"WAIST_TO_HEIGHT",
// v1.27.9 — WHO-5 / SCI screening totals: same in-app questionnaire
// derivation as PHQ-9 / GAD-7. No HK mapping by design.
"WHO5_SCORE",
"SCI_SCORE",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
+3
View File
@@ -209,6 +209,9 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
// PHQ-9 / GAD-7 totals are composite screener indices → scores cluster.
["PHQ9_SCORE", "scores"],
["GAD7_SCORE", "scores"],
// ── v1.27.9 — WHO-5 / SCI totals join the same screener-index cluster.
["WHO5_SCORE", "scores"],
["SCI_SCORE", "scores"],
// Grip strength is a strength/fitness reading → activity cluster.
["GRIP_STRENGTH", "activity"],
// Pain NRS is a patient-reported clinical point-in-time signal → vitals.
@@ -2,9 +2,14 @@ import { describe, it, expect } from "vitest";
import {
INSTRUMENTS,
INSTRUMENT_MEASUREMENT_TYPE,
hasValidatedItems,
isSafetyFlagged,
needsFollowUp,
optionLabelKey,
scoreTotal,
severityBand,
isSafetyFlagged,
stemKey,
} from "../instruments";
/**
@@ -12,19 +17,40 @@ import {
* GAD-7 totals, the validated severity-band edges (5 / 10 / 15 / 20 and
* 5 / 10 / 15), the ≥10 action threshold, and the item-9 ANY-non-zero safety
* flag. A future off-by-one on any band edge or the safety predicate must fail
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7), mirrored in
* `.planning/research/v125/clinical-instruments.md`.
* here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7).
*
* v1.27.9 — the WHO-5 / SCI pins join. Ground truth: the official WHO
* publication WHO/UCN/MSD/MHE/2024.1 ("raw score … zero to 25 … multiplied by
* four" → 0–100; "a percentage score below 50 … an indication for further
* assessment") and Espie et al., BMJ Open 2014;4:e004183 ("total score ranges
* from 0 to 32, with higher values indicative of better sleep"; "an SCI
* cut-off ≤16 … 'probable insomnia disorder'").
*/
describe("mental-health instruments", () => {
describe("scoreTotal", () => {
it("sums the item answers", () => {
expect(scoreTotal([0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
expect(scoreTotal([0, 0, 0, 0, 0, 0, 0])).toBe(0);
it("sums the item answers for PHQ-9 / GAD-7 (multiplier 1)", () => {
expect(scoreTotal("PHQ9", [0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13);
expect(scoreTotal("GAD7", [0, 0, 0, 0, 0, 0, 0])).toBe(0);
});
it("returns the max when every item is 3", () => {
expect(scoreTotal(Array(9).fill(3))).toBe(27); // PHQ-9 max
expect(scoreTotal(Array(7).fill(3))).toBe(21); // GAD-7 max
expect(scoreTotal("PHQ9", Array(9).fill(3))).toBe(27); // PHQ-9 max
expect(scoreTotal("GAD7", Array(7).fill(3))).toBe(21); // GAD-7 max
});
it("WHO-5 reports raw-sum × 4 (the official 0–100 percentage)", () => {
// WHO scoring page: raw 0–25, "multiplied by four" → 0–100.
expect(scoreTotal("WHO5", [0, 0, 0, 0, 0])).toBe(0);
expect(scoreTotal("WHO5", [5, 5, 5, 5, 5])).toBe(100);
expect(scoreTotal("WHO5", [3, 3, 3, 3, 3])).toBe(60);
// Raw 12 (below the raw-13 cut) → 48 on the percentage scale.
expect(scoreTotal("WHO5", [3, 3, 2, 2, 2])).toBe(48);
});
it("SCI sums to 0–32 (higher = better sleep)", () => {
expect(scoreTotal("SCI", Array(8).fill(0))).toBe(0);
expect(scoreTotal("SCI", Array(8).fill(4))).toBe(32);
expect(scoreTotal("SCI", [4, 3, 2, 1, 0, 4, 1, 1])).toBe(16);
});
});
@@ -62,12 +88,47 @@ describe("mental-health instruments", () => {
});
});
describe("action threshold is ≥10 for both instruments", () => {
it("PHQ-9", () => {
expect(INSTRUMENTS.PHQ9.actionThreshold).toBe(10);
describe("severityBand — WHO-5 (below 50 = low; achievable totals are ×4)", () => {
const cases: Array<[number, string]> = [
[0, "low"],
[48, "low"], // raw 12 — below the WHO raw-13 cut
[52, "good"], // raw 13 — at/above the cut
[100, "good"],
];
it.each(cases)("WHO5 total %i → %s", (total, band) => {
expect(severityBand("WHO5", total)).toBe(band);
});
it("GAD-7", () => {
expect(INSTRUMENTS.GAD7.actionThreshold).toBe(10);
});
describe("severityBand — SCI (≤16 probable-insomnia range per Espie 2014)", () => {
const cases: Array<[number, string]> = [
[0, "belowThreshold"],
[16, "belowThreshold"],
[17, "aboveThreshold"],
[32, "aboveThreshold"],
];
it.each(cases)("SCI total %i → %s", (total, band) => {
expect(severityBand("SCI", total)).toBe(band);
});
});
describe("needsFollowUp — direction-aware thresholds", () => {
it("PHQ-9 / GAD-7 point up (≥ 10)", () => {
expect(needsFollowUp("PHQ9", 9)).toBe(false);
expect(needsFollowUp("PHQ9", 10)).toBe(true);
expect(needsFollowUp("GAD7", 9)).toBe(false);
expect(needsFollowUp("GAD7", 10)).toBe(true);
});
it("WHO-5 points down (≤ 50 → gentle PHQ-9 pointer)", () => {
expect(needsFollowUp("WHO5", 48)).toBe(true);
expect(needsFollowUp("WHO5", 50)).toBe(true);
expect(needsFollowUp("WHO5", 52)).toBe(false);
});
it("SCI points down (≤ 16)", () => {
expect(needsFollowUp("SCI", 16)).toBe(true);
expect(needsFollowUp("SCI", 17)).toBe(false);
});
});
@@ -82,13 +143,70 @@ describe("mental-health instruments", () => {
it("does not flag when item 9 = 0 even with a high total", () => {
// Every other item maxed (24), item 9 = 0 → severe band, no flag.
const items = [3, 3, 3, 3, 3, 3, 3, 3, 0];
expect(severityBand("PHQ9", scoreTotal(items))).toBe("severe");
expect(severityBand("PHQ9", scoreTotal("PHQ9", items))).toBe("severe");
expect(isSafetyFlagged("PHQ9", items)).toBe(false);
});
it("never flags GAD-7 (no safety item)", () => {
it("never flags the instruments without a safety item", () => {
expect(isSafetyFlagged("GAD7", [3, 3, 3, 3, 3, 3, 3])).toBe(false);
expect(isSafetyFlagged("WHO5", [0, 0, 0, 0, 0])).toBe(false);
expect(isSafetyFlagged("SCI", [0, 0, 0, 0, 0, 0, 0, 0])).toBe(false);
expect(INSTRUMENTS.GAD7.safetyItemIndex).toBeNull();
expect(INSTRUMENTS.WHO5.safetyItemIndex).toBeNull();
expect(INSTRUMENTS.SCI.safetyItemIndex).toBeNull();
});
});
describe("registry shape — the config the surfaces drive from", () => {
it("presents options in the source order (WHO-5 leads with 5, SCI with 4)", () => {
expect(INSTRUMENTS.WHO5.optionOrder).toEqual([5, 4, 3, 2, 1, 0]);
expect(INSTRUMENTS.SCI.optionOrder).toEqual([4, 3, 2, 1, 0]);
expect(INSTRUMENTS.PHQ9.optionOrder).toEqual([0, 1, 2, 3]);
});
it("resolves option-label keys per scheme (shared vs SCI per-item groups)", () => {
expect(optionLabelKey("PHQ9", 0, 2)).toBe("options.2");
expect(optionLabelKey("WHO5", 4, 5)).toBe("who5Options.5");
// SCI: items 1–2 duration, 3 nights, 4 quality, 5–7 impact, 8 problem duration.
expect(optionLabelKey("SCI", 0, 4)).toBe("sciOptions.duration.4");
expect(optionLabelKey("SCI", 2, 0)).toBe("sciOptions.nights.0");
expect(optionLabelKey("SCI", 3, 2)).toBe("sciOptions.quality.2");
expect(optionLabelKey("SCI", 6, 1)).toBe("sciOptions.impact.1");
expect(optionLabelKey("SCI", 7, 3)).toBe("sciOptions.problemDuration.3");
});
it("maps recall stems (WHO-5 one stem; SCI three sections; PHQ/GAD none)", () => {
expect(stemKey("WHO5", 0)).toBe("who5.period");
expect(stemKey("SCI", 0)).toBe("sci.night");
expect(stemKey("SCI", 3)).toBe("sci.night");
expect(stemKey("SCI", 4)).toBe("sci.impact");
expect(stemKey("SCI", 7)).toBe("sci.finally");
expect(stemKey("PHQ9", 0)).toBeNull();
expect(stemKey("GAD7", 0)).toBeNull();
});
it("declares validated item locales honestly (SCI = English only)", () => {
for (const locale of ["de", "en", "es", "fr", "it", "pl"]) {
expect(hasValidatedItems("WHO5", locale)).toBe(true);
expect(hasValidatedItems("PHQ9", locale)).toBe(true);
}
expect(hasValidatedItems("SCI", "en")).toBe(true);
expect(hasValidatedItems("SCI", "en-GB")).toBe(true);
expect(hasValidatedItems("SCI", "de")).toBe(false);
expect(hasValidatedItems("SCI", "pl")).toBe(false);
});
it("projects each instrument onto its measurement type", () => {
expect(INSTRUMENT_MEASUREMENT_TYPE.WHO5).toBe("WHO5_SCORE");
expect(INSTRUMENT_MEASUREMENT_TYPE.SCI).toBe("SCI_SCORE");
});
it("every instrument carries a non-empty attribution line", () => {
for (const def of Object.values(INSTRUMENTS)) {
expect(def.attribution.length).toBeGreaterThan(20);
}
expect(INSTRUMENTS.WHO5.attribution).toContain("CC BY-NC-SA 3.0 IGO");
expect(INSTRUMENTS.SCI.attribution).toContain("BMJ Open");
});
});
});
+268 -39
View File
@@ -1,58 +1,123 @@
/**
* v1.25 — validated mental-health screener definitions (PHQ-9 + GAD-7).
* v1.27.9 — the registry generalises to carry the WHO-5 well-being index and
* the Sleep Condition Indicator (SCI) on the same infrastructure: per-item
* answer scales, response-option schemes, score direction, and the reported
* total (WHO-5 reports raw-sum × 4 as a 0–100 percentage) are now instrument
* properties instead of PHQ/GAD assumptions.
*
* Pure, server-authoritative scoring. The instrument item wording, 0–3 scale,
* totals, and severity bands are taken from the primary sources (Kroenke 2001
* for PHQ-9; Spitzer 2006 for GAD-7). This module owns the canonical NUMERIC
* contract; the human wording lives in `messages/*.json` under
* `mentalHealth.items.*` (officially-validated translations per locale, English
* fallback otherwise — re-wording invalidates the score). The de/es/fr/it/pl
* item wordings are the public-domain Pfizer/PRIME-MD validated localized
* versions distributed via the MAPI Research Trust (de: Löwe/Gräfe et al.); they
* are NOT machine-translated. See `.planning/research/v125/clinical-instruments.md`.
* Pure, server-authoritative scoring. Item wording, response anchors, scoring
* and band thresholds are taken from the primary sources only:
* - PHQ-9: Kroenke 2001; GAD-7: Spitzer 2006 (public-domain Pfizer grant).
* - WHO-5: WHO/UCN/MSD/MHE/2024.1 (© WHO 2024, CC BY-NC-SA 3.0 IGO) — raw
* 0–25, × 4 → 0–100; "a percentage score below 50 … an indication for
* further assessment". WHO publishes official translations for all six
* app locales; they ship verbatim.
* - SCI: Espie et al., BMJ Open 2014;4:e004183 (© Sleepio Ltd, licensed
* free for non-commercial use, CC BY-NC) — 8 items, 0–4 each, total 0–32,
* HIGHER = better sleep; "a score of ≤16 … seems reasonable to detect
* possible insomnia disorder". Item text is validated in English; app
* locales without a validated translation present the English items.
* This module owns the canonical NUMERIC contract; the human wording lives in
* `messages/*.json` under `mentalHealth.items.*` (validated translations per
* locale where they exist — re-wording invalidates the score). See
* `.planning/research/screening-instruments-2026-07.md`.
*
* PHQ-9 item 9 (index 8) is the passive self-harm ideation screen and is the
* safety-critical field (see `crisis-resources.ts` + the API handler): any
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost. The
* raw item answers are NEVER exposed to the Coach / Insights / export surfaces —
* only the derived total score rides a `*_SCORE` Measurement row.
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost.
* Neither the WHO-5 (all items positively worded) nor the SCI (sleep-specific
* items only) carries a crisis item. The raw item answers are NEVER exposed
* to the Coach / Insights / export surfaces — only the derived total score
* rides a `*_SCORE` Measurement row.
*/
export type InstrumentId = "PHQ9" | "GAD7";
export type InstrumentId = "PHQ9" | "GAD7" | "WHO5" | "SCI";
export interface InstrumentDefinition {
id: InstrumentId;
/** Lowercase i18n slug — the key segment under `mentalHealth.*`. */
i18nKey: "phq9" | "gad7" | "who5" | "sci";
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7, WHO-5 = 5, SCI = 8). */
itemCount: number;
/** Highest per-item answer value (PHQ/GAD 3, SCI 4, WHO-5 5). */
itemMax: number;
/**
* Answer values in the order the source instrument presents them. The
* PHQ/GAD forms run 0→3; the WHO-5 form leads with 5 ("All of the time");
* the SCI form leads with 4.
*/
optionOrder: readonly number[];
/**
* i18n namespace for the response-option labels under `mentalHealth.`:
* without `optionGroups` one shared anchor set (`<ns>.<value>`); with
* `optionGroups` (SCI) per-item anchor groups (`<ns>.<group>.<value>`,
* group = `optionGroups[itemIndex]`).
*/
optionNamespace: string;
optionGroups?: readonly string[];
/**
* Recall-stem caption key per item under `mentalHealth.stems.` — the WHO-5
* and SCI items are fragments under a shared stem on the source forms, so
* the wizard paints the applicable stem above the question. PHQ/GAD keep
* their frame in the instrument description line (shipped contract).
*/
stemKeys?: readonly string[];
/** Multiplier from item-sum to the reported total (WHO-5: × 4). */
scoreMultiplier: number;
/** Inclusive REPORTED total range (WHO-5: 0–100 percentage). */
minScore: number;
maxScore: number;
/** Whether a HIGHER total is the good direction (WHO-5 / SCI). */
higherIsBetter: boolean;
/** Total-score LOINC for the FHIR/export contract; null when none exists. */
totalLoinc: string | null;
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
/**
* Follow-up threshold on the reported scale. The concerning side follows
* the direction: PHQ/GAD ≥ threshold, WHO-5/SCI ≤ threshold (WHO-5 "below
* 50" — 50 itself is unreachable on the ×4 scale; SCI ≤ 16 per Espie 2014).
*/
actionThreshold: number;
/** Item index (0-based) of the safety-critical self-harm item, or null. */
safetyItemIndex: number | null;
/**
* Locales whose bundled item wording is a validated translation. A locale
* outside this list renders the validated source-language items plus an
* honest chrome note — NEVER a self-made translation.
*/
validatedItemLocales: readonly string[];
/**
* Required attribution — render VERBATIM on the instrument's start card and
* the result view (and in exports). Stays in English by convention (the
* developers' names and licence identifiers are not translated).
*/
attribution: string;
}
const ALL_APP_LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
/**
* Required attribution — render VERBATIM on every screen displaying the
* instrument and in the PDF/FHIR export. The PHQ family is public-domain (Pfizer
* 2010); this is the canonical footer text. By convention it stays in English
* (the developers' names are not translated).
* The PHQ family is public-domain (Pfizer 2010); this is the canonical footer
* text per the instrument's own permission grant.
*/
export const PHQ_GAD_ATTRIBUTION =
const PHQ_GAD_ATTRIBUTION =
"Developed by Drs. Robert L. Spitzer, Janet B.W. Williams, Kurt Kroenke and " +
"colleagues, with an educational grant from Pfizer Inc. No permission " +
"required to reproduce, translate, display or distribute.";
export interface InstrumentDefinition {
id: InstrumentId;
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7). */
itemCount: number;
/** Inclusive total range. */
minScore: number;
maxScore: number;
/** Total-score LOINC for the FHIR/export contract. */
totalLoinc: string;
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
/** Optimal screening cut-off (≥ this total = consider professional follow-up). */
actionThreshold: number;
/** Item index (0-based) of the safety-critical self-harm item, or null. */
safetyItemIndex: number | null;
}
const PHQ9: InstrumentDefinition = {
id: "PHQ9",
i18nKey: "phq9",
itemCount: 9,
itemMax: 3,
optionOrder: [0, 1, 2, 3],
optionNamespace: "options",
scoreMultiplier: 1,
minScore: 0,
maxScore: 27,
higherIsBetter: false,
totalLoinc: "44261-6",
bands: [
{ key: "minimal", min: 0, max: 4 },
@@ -64,13 +129,21 @@ const PHQ9: InstrumentDefinition = {
actionThreshold: 10,
// Item 9 ("Thoughts that you would be better off dead…") — the safety field.
safetyItemIndex: 8,
validatedItemLocales: ALL_APP_LOCALES,
attribution: PHQ_GAD_ATTRIBUTION,
};
const GAD7: InstrumentDefinition = {
id: "GAD7",
i18nKey: "gad7",
itemCount: 7,
itemMax: 3,
optionOrder: [0, 1, 2, 3],
optionNamespace: "options",
scoreMultiplier: 1,
minScore: 0,
maxScore: 21,
higherIsBetter: false,
totalLoinc: "70274-6",
bands: [
{ key: "minimal", min: 0, max: 4 },
@@ -80,25 +153,137 @@ const GAD7: InstrumentDefinition = {
],
actionThreshold: 10,
safetyItemIndex: null,
validatedItemLocales: ALL_APP_LOCALES,
attribution: PHQ_GAD_ATTRIBUTION,
};
/**
* WHO-5 well-being index — five positively-worded items over the last two
* weeks, 6-point 5→0 scale. Reported score = raw sum × 4 (0–100, higher =
* better well-being) per the official WHO scoring. "A percentage score below
* 50 … has been suggested as a cut-off for poor mental well-being and as an
* indication for further assessment" (WHO/UCN/MSD/MHE/2024.1) — the result
* view answers a ≤ 50 total with a gentle pointer to the PHQ-9 check-in.
* Achievable totals are multiples of 4, so the 0–50 band equals "below 50".
*/
const WHO5: InstrumentDefinition = {
id: "WHO5",
i18nKey: "who5",
itemCount: 5,
itemMax: 5,
optionOrder: [5, 4, 3, 2, 1, 0],
optionNamespace: "who5Options",
stemKeys: Array(5).fill("who5.period"),
scoreMultiplier: 4,
minScore: 0,
maxScore: 100,
higherIsBetter: true,
totalLoinc: null,
bands: [
{ key: "low", min: 0, max: 50 },
{ key: "good", min: 51, max: 100 },
],
actionThreshold: 50,
safetyItemIndex: null,
// WHO publishes official de/en/es/fr/it/pl versions — all bundled verbatim.
validatedItemLocales: ALL_APP_LOCALES,
attribution:
"World Health Organization. The World Health Organization-Five Well-Being " +
"Index (WHO-5). Geneva: World Health Organization; 2024. Licence: " +
"CC BY-NC-SA 3.0 IGO. WHO does not endorse this application.",
};
/**
* Sleep Condition Indicator — eight DSM-5-aligned items, 0–4 each with
* item-specific anchors, total 0–32, HIGHER = better sleep. "A score of ≤16
* on the SCI seems reasonable to detect possible insomnia disorder" (Espie
* et al. 2014) — the band wording stays neutral per the paper, no diagnosis
* language. Items 1–4 ask about a typical night in the last month, items 5–7
* about the past month's daytime impact, item 8 about problem duration.
*/
const SCI: InstrumentDefinition = {
id: "SCI",
i18nKey: "sci",
itemCount: 8,
itemMax: 4,
optionOrder: [4, 3, 2, 1, 0],
optionNamespace: "sciOptions",
optionGroups: [
"duration",
"duration",
"nights",
"quality",
"impact",
"impact",
"impact",
"problemDuration",
],
stemKeys: [
"sci.night",
"sci.night",
"sci.night",
"sci.night",
"sci.impact",
"sci.impact",
"sci.impact",
"sci.finally",
],
scoreMultiplier: 1,
minScore: 0,
maxScore: 32,
higherIsBetter: true,
totalLoinc: null,
bands: [
{ key: "belowThreshold", min: 0, max: 16 },
{ key: "aboveThreshold", min: 17, max: 32 },
],
actionThreshold: 16,
safetyItemIndex: null,
// Validated in English (the CC BY-NC BMJ Open publication). French/Italian
// validations exist in the literature but their item text is not openly
// redistributable, so every non-EN locale presents the English items with
// an honest note — never a self-made translation.
validatedItemLocales: ["en"],
attribution:
"Espie CA, Kyle SD, Hames P, et al. The Sleep Condition Indicator: a " +
"clinical screening tool to evaluate insomnia disorder. BMJ Open " +
"2014;4:e004183. © Sleepio Limited; free for non-commercial use (CC BY-NC).",
};
export const INSTRUMENTS: Record<InstrumentId, InstrumentDefinition> = {
PHQ9,
GAD7,
WHO5,
SCI,
};
/** Landing / history ordering — the shipped pair first, then the new pair. */
export const INSTRUMENT_ORDER: readonly InstrumentId[] = [
"PHQ9",
"GAD7",
"WHO5",
"SCI",
];
/** The DB MeasurementType the instrument's total score projects to. */
export const INSTRUMENT_MEASUREMENT_TYPE: Record<InstrumentId, string> = {
PHQ9: "PHQ9_SCORE",
GAD7: "GAD7_SCORE",
WHO5: "WHO5_SCORE",
SCI: "SCI_SCORE",
};
/** Sum the item answers into a total (server-authoritative). */
export function scoreTotal(items: readonly number[]): number {
return items.reduce((sum, v) => sum + v, 0);
/**
* The REPORTED total for a completed administration (server-authoritative):
* item-sum × the instrument's multiplier (WHO-5 reports the 0–100 percentage,
* everything else the raw sum).
*/
export function scoreTotal(id: InstrumentId, items: readonly number[]): number {
const sum = items.reduce((acc, v) => acc + v, 0);
return sum * INSTRUMENTS[id].scoreMultiplier;
}
/** Resolve the severity-band key for a total score. */
/** Resolve the severity-band key for a reported total score. */
export function severityBand(id: InstrumentId, total: number): string {
const def = INSTRUMENTS[id];
const band = def.bands.find((b) => total >= b.min && total <= b.max);
@@ -106,6 +291,50 @@ export function severityBand(id: InstrumentId, total: number): string {
return band?.key ?? def.bands[def.bands.length - 1].key;
}
/**
* Whether the gentle follow-up hint applies for a reported total. Direction-
* aware: PHQ-9/GAD-7 point up (≥ 10), WHO-5 and the SCI point down (≤ 50 /
* ≤ 16 — lower totals mean lower well-being / worse sleep).
*/
export function needsFollowUp(id: InstrumentId, total: number): boolean {
const def = INSTRUMENTS[id];
return def.higherIsBetter
? total <= def.actionThreshold
: total >= def.actionThreshold;
}
/**
* i18n key (under `mentalHealth.`) for one response option of one item —
* shared anchors (`options.2`), instrument-specific shared anchors
* (`who5Options.5`), or the SCI's per-item anchor groups
* (`sciOptions.duration.4`).
*/
export function optionLabelKey(
id: InstrumentId,
itemIndex: number,
value: number,
): string {
const def = INSTRUMENTS[id];
return def.optionGroups
? `${def.optionNamespace}.${def.optionGroups[itemIndex]}.${value}`
: `${def.optionNamespace}.${value}`;
}
/** Stem caption key (under `mentalHealth.stems.`) for an item, or null. */
export function stemKey(id: InstrumentId, itemIndex: number): string | null {
return INSTRUMENTS[id].stemKeys?.[itemIndex] ?? null;
}
/**
* Whether the bundled item wording for `locale` is a validated translation.
* When false the surface shows the validated source-language items plus a
* localized "validated in English" chrome note.
*/
export function hasValidatedItems(id: InstrumentId, locale: string): boolean {
const base = locale.toLowerCase().split("-")[0];
return INSTRUMENTS[id].validatedItemLocales.includes(base);
}
/**
* Whether the safety signpost must be shown for these answers. True iff the
* instrument has a safety item and that item was answered with any non-zero
+6 -6
View File
@@ -1,6 +1,6 @@
/**
* OpenAPI route table for the opt-in mental-health screeners
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7.
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7 / WHO-5 / SCI.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request body
* reuses the runtime Zod schema from `@/lib/validations/mental-health` so the
@@ -23,7 +23,7 @@ import { dataEnvelope, stdResponses } from "./shared";
createAssessmentSchema.meta({
id: "CreateMentalHealthAssessmentRequest",
description:
"Record one completed PHQ-9 (9 items) / GAD-7 (7 items) administration. Each item answer is 0–3; the array length must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
"Record one completed screener administration: PHQ-9 (9 items, 0–3), GAD-7 (7 items, 0–3), WHO-5 (5 items, 0–5) or SCI (8 items, 0–4). The array length and per-item ceiling must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total (WHO-5 reports raw-sum × 4 as 0–100) + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
});
listAssessmentsSchema.meta({
@@ -35,7 +35,7 @@ listAssessmentsSchema.meta({
const assessmentRow = z
.object({
id: z.string(),
instrument: z.enum(["PHQ9", "GAD7"]),
instrument: z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]),
locale: z.string(),
version: z.string(),
totalScore: z.number(),
@@ -87,7 +87,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Mental health"],
summary: "List the caller's screener history",
description:
"Returns PHQ-9 / GAD-7 administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
"Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
requestParams: { query: listAssessmentsSchema },
responses: {
"200": {
@@ -106,9 +106,9 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
post: {
tags: ["Mental health"],
summary: "Record a completed PHQ-9 / GAD-7 screener",
summary: "Record a completed screener",
description:
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
requestBody: {
content: {
"application/json": { schema: createAssessmentSchema },
+4
View File
@@ -205,6 +205,10 @@ export function getPRDirection(
// all). All defer to a null PR direction.
case "PHQ9_SCORE":
case "GAD7_SCORE":
// v1.27.9 — WHO-5 / SCI screening totals follow the same no-gamification
// safety rule (no streaks / "best score" framing around a screener).
case "WHO5_SCORE":
case "SCI_SCORE":
case "PAIN_NRS":
case "WAIST_CIRCUMFERENCE":
case "WAIST_TO_HEIGHT":
+41
View File
@@ -1272,6 +1272,47 @@ export const SIGNALS: Record<string, SignalDefinition> = {
category: "survey",
},
},
// ── v1.27.9 — WHO-5 well-being + Sleep Condition Indicator totals ───────
// Same privacy posture as PHQ-9 / GAD-7 (off Coach snapshot + MCP; item
// content never rides a Measurement row). Direction inverts: HIGHER is
// better for both. No verified total-score LOINC exists for either, so no
// FHIR coding is attached (the export skips uncoded signals rather than
// shipping an invented code).
WHO5_SCORE: {
key: "WHO5_SCORE",
kind: "score",
source: { measurementType: "WHO5_SCORE" },
displayName: "WHO-5 well-being index (0–100)",
unit: "score",
direction: "higher-better",
archetype: "physiological-vital",
// "Below 50" is the WHO-suggested cut-off; achievable totals are
// multiples of 4, so ≥ 52 is the good side.
normalRange: { low: 52, high: 100 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
SCI_SCORE: {
key: "SCI_SCORE",
kind: "score",
source: { measurementType: "SCI_SCORE" },
displayName: "Sleep Condition Indicator (total)",
unit: "score",
direction: "higher-better",
archetype: "physiological-vital",
// > 16 identified "no insomnia disorder" in Espie et al. 2014.
normalRange: { low: 17, high: 32 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
// ── v1.25 clinical-signals wave — physical / clinical measurements ──────
GRIP_STRENGTH: {
key: "GRIP_STRENGTH",
@@ -37,10 +37,26 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => {
// v1.27.6 — plannable mental-wellbeing screenings
"PHQ9_SCORE",
"GAD7_SCORE",
// v1.27.9 — WHO-5 + SCI join on the identical contract
"WHO5_SCORE",
"SCI_SCORE",
]),
);
});
it("accepts the WHO-5 / SCI screenings with the 4-week default cadence", () => {
for (const measurementType of ["WHO5_SCORE", "SCI_SCORE"]) {
const parsed = createMeasurementReminderSchema.safeParse({
label: "Screening check-in",
measurementType,
intervalDays: 28,
});
expect(parsed.success, `expected ${measurementType} to be allowed`).toBe(
true,
);
}
});
it("accepts a plannable screening reminder with the 4-week default cadence", () => {
const parsed = createMeasurementReminderSchema.safeParse({
label: "PHQ-9 Check-in",
@@ -473,9 +473,15 @@ describe("measurement validation", () => {
it("rejects a COMPUTED-attributed screener score row on a client write", () => {
// v1.27.6 — the mental-wellbeing screener projects each completed
// administration onto a COMPUTED-source PHQ9_SCORE / GAD7_SCORE row
// (the RECOVERY_SCORE precedent). COMPUTED is not client-attributable,
// so a client can never mint a server-owned score trend point.
for (const type of ["PHQ9_SCORE", "GAD7_SCORE"]) {
// (the RECOVERY_SCORE precedent; v1.27.9 adds WHO5_SCORE / SCI_SCORE).
// COMPUTED is not client-attributable, so a client can never mint a
// server-owned score trend point.
for (const type of [
"PHQ9_SCORE",
"GAD7_SCORE",
"WHO5_SCORE",
"SCI_SCORE",
]) {
const parsed = createMeasurementSchema.safeParse({
type,
value: 5,
+8 -1
View File
@@ -64,11 +64,16 @@ export const measurementReminderTypeEnum = z
// the dispatcher (see `moduleForMeasurementType`).
"PHQ9_SCORE",
"GAD7_SCORE",
// v1.27.9 — WHO-5 + SCI join the plannable screenings on the identical
// contract (auto-resolve on the server-owned COMPUTED score row, module-
// gated in the dispatcher).
"WHO5_SCORE",
"SCI_SCORE",
])
.meta({
id: "MeasurementReminderType",
description:
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 / WHO-5 / SCI screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written *_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
});
export type MeasurementReminderType = z.infer<
@@ -85,6 +90,8 @@ export type MeasurementReminderType = z.infer<
export const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
"PHQ9_SCORE",
"GAD7_SCORE",
"WHO5_SCORE",
"SCI_SCORE",
]);
/** Whether a reminder's `measurementType` targets a screening check-in. */
+12
View File
@@ -135,6 +135,11 @@ export const measurementTypeEnum = z.enum([
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
// ── v1.27.9 — screening scores (same assessment contract as PHQ-9/GAD-7,
// inverted direction: HIGHER is better). WHO-5 stores the reported 0–100
// percentage (raw sum × 4); SCI stores the 0–32 total.
"WHO5_SCORE",
"SCI_SCORE",
]);
/**
@@ -379,6 +384,9 @@ const unitMap: Record<string, string> = {
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "score",
GAD7_SCORE: "score",
// ── v1.27.9 — screening scores ──
WHO5_SCORE: "score",
SCI_SCORE: "score",
GRIP_STRENGTH: "kg",
PAIN_NRS: "score",
WAIST_CIRCUMFERENCE: "cm",
@@ -581,6 +589,10 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener.
PHQ9_SCORE: { min: 0, max: 27 },
GAD7_SCORE: { min: 0, max: 21 },
// ── v1.27.9 — screening scores. WHO-5 reported percentage 0–100 (raw × 4),
// SCI total 0–32 — both server-derived, higher = better.
WHO5_SCORE: { min: 0, max: 100 },
SCI_SCORE: { min: 0, max: 32 },
// Grip strength kg — a child can read in the single digits, an elite athlete
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
GRIP_STRENGTH: { min: 0, max: 120 },
+20 -6
View File
@@ -1,8 +1,10 @@
/**
* v1.25 — request/response validation for the opt-in mental-health screeners
* (PHQ-9 / GAD-7). The item answers are 0–3 integers; the array length must
* match the instrument (PHQ-9 = 9, GAD-7 = 7). `userId` is NEVER a body field —
* it is narrowed from the session in the handler (CLAUDE.md convention).
* (PHQ-9 / GAD-7; v1.27.9 adds WHO-5 + SCI). The item answers are small
* integers whose ceiling is instrument-specific (PHQ/GAD 0–3, SCI 0–4,
* WHO-5 0–5); the array length must match the instrument. `userId` is NEVER
* a body field — it is narrowed from the session in the handler (CLAUDE.md
* convention).
*/
import { z } from "zod/v4";
import {
@@ -10,7 +12,7 @@ import {
type InstrumentId,
} from "@/lib/mental-health/instruments";
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
/**
* Client provenance for an administration. Closed enum so the column can never
@@ -21,12 +23,15 @@ export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
*/
export const assessmentSourceEnum = z.enum(["WEB", "IOS"]);
const itemAnswer = z.number().int().min(0).max(3);
// Structural ceiling — the WHO-5's 0–5 scale is the widest. The exact
// per-instrument ceiling (PHQ/GAD 3, SCI 4) is enforced in the superRefine
// below, where the instrument is known.
const itemAnswer = z.number().int().min(0).max(5);
export const createAssessmentSchema = z
.object({
instrument: assessmentInstrumentEnum,
/** Per-item answers, each 0–3, ordered as the instrument presents them. */
/** Per-item answers, ordered as the instrument presents them. */
items: z.array(itemAnswer).min(1).max(12),
/** Optional functional-impairment follow-up (not scored into the total). */
functionalDifficulty: z.number().int().min(0).max(3).optional(),
@@ -55,6 +60,15 @@ export const createAssessmentSchema = z
message: `${val.instrument} expects exactly ${def.itemCount} item answers`,
});
}
val.items.forEach((answer, index) => {
if (answer > def.itemMax) {
ctx.addIssue({
code: "custom",
path: ["items", index],
message: `${val.instrument} answers range 0–${def.itemMax}`,
});
}
});
});
export const listAssessmentsSchema = z.object({