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

@@ -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>
</>
)}