mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'feat/w-g-screenings' into release/v1.27.10
This commit is contained in:
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user