feat(mental-health): open an instrument's trend from its card

Clicking a PHQ-9 / GAD-7 card body now opens that instrument's history
in a detail sheet, mirroring how a medication card opens its detail. The
card body is the navigation target; the bottom-pinned Start button stays
a separate action and keeps its canonical typography and spacing.

The detail reuses the existing history chart and dated list, pinned to
the chosen instrument: the PHQ-9 / GAD-7 toggle and the outer card chrome
drop away since the sheet owns the title. The combined history card on the
landing is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 23:42:49 +02:00
1 parent b5eb5274b3
commit 5bfba0ba60
10 files changed
+280 -108

No files matched your search

+2 -1
View File
@@ -7716,7 +7716,8 @@
"review": {
"title": "Überblick",
"recap": "Du hast {answered} von {total} Fragen beantwortet."
}
},
"openDetail": "Verlauf anzeigen"
},
"records": {
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
+2 -1
View File
@@ -7716,7 +7716,8 @@
"review": {
"title": "Review",
"recap": "You answered {answered} of {total} questions."
}
},
"openDetail": "Show trend"
},
"records": {
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
+2 -1
View File
@@ -7716,7 +7716,8 @@
"review": {
"title": "Resumen",
"recap": "Has respondido {answered} de {total} preguntas."
}
},
"openDetail": "Ver evolución"
},
"records": {
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
+2 -1
View File
@@ -7716,7 +7716,8 @@
"review": {
"title": "Récapitulatif",
"recap": "Vous avez répondu à {answered} questions sur {total}."
}
},
"openDetail": "Voir l’évolution"
},
"records": {
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
+2 -1
View File
@@ -7716,7 +7716,8 @@
"review": {
"title": "Riepilogo",
"recap": "Hai risposto a {answered} domande su {total}."
}
},
"openDetail": "Mostra andamento"
},
"records": {
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
+2 -1
View File
@@ -7716,7 +7716,8 @@
"review": {
"title": "Podsumowanie",
"recap": "Odpowiedziano na {answered} z {total} pytań."
}
},
"openDetail": "Pokaż przebieg"
},
"records": {
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
@@ -24,6 +24,7 @@ import { MentalWellbeing } from "../mental-wellbeing";
import { CheckInWizard } from "../check-in-wizard";
import { AssessmentResult } from "../assessment-result";
import { AssessmentHistory } from "../assessment-history";
import { InstrumentCard } from "../instrument-card";
import type { AssessmentRow, CreateResponse } from "../types";
const mh = en.mentalHealth;
@@ -194,3 +195,75 @@ describe("history rendering", () => {
expect(empty).not.toContain('data-slot="history-list"');
});
});
describe("instrument card → per-instrument trend (v1.25.12)", () => {
it("exposes the card body as a detail target separate from Start", () => {
const html = withProviders(
<InstrumentCard
instrument="PHQ9"
last={undefined}
onStart={() => {}}
onOpenDetail={() => {}}
/>,
);
// The title + last-result block is a button that opens the Verlauf…
expect(html).toContain('data-slot="instrument-card-open"');
expect(html).toContain(mh.openDetail);
// …and the Start action is still its own button.
expect(html).toContain(mh.start);
});
it("pins the history to one instrument: no toggle, bare (no titled card)", () => {
const rows: AssessmentRow[] = [
{
id: "p1",
instrument: "PHQ9",
locale: "en",
totalScore: 8,
severityBand: "mild",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
{
id: "g1",
instrument: "GAD7",
locale: "en",
totalScore: 12,
severityBand: "moderate",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
];
const html = withProviders(
<AssessmentHistory rows={rows} instrument="PHQ9" />,
);
// Pinned: the PHQ-9 / GAD-7 toggle + the titled landing card are gone…
expect(html).toContain('data-pinned="PHQ9"');
expect(html).not.toContain('role="tablist"');
// …only the chosen instrument's row paints (its band), not the other's.
expect(html).toContain(mh.band.PHQ9.mild);
expect(html).not.toContain(mh.band.GAD7.moderate);
});
it("shows the per-instrument empty state when that instrument has no rows", () => {
const onlyGad: AssessmentRow[] = [
{
id: "g2",
instrument: "GAD7",
locale: "en",
totalScore: 5,
severityBand: "mild",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-06-20T00:00:00.000Z",
},
];
const html = withProviders(
<AssessmentHistory rows={onlyGad} instrument="PHQ9" />,
);
expect(html).toContain(mh.history.empty);
expect(html).not.toContain('data-slot="history-list"');
});
});
@@ -68,44 +68,60 @@ function crisisFromRow(row: AssessmentRow): CrisisSet {
};
}
export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
export function AssessmentHistory({
rows,
instrument,
}: {
rows: AssessmentRow[];
/**
* Pin the history to a single instrument. When set, the PHQ-9 / GAD-7
* toggle + the outer Card chrome are dropped — the surface is a per-
* instrument detail (opened from an instrument card; the chrome + title
* come from the host sheet). Unset, it renders the combined landing card
* with the toggle, exactly as before.
*/
instrument?: InstrumentId;
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
const [tab, setTab] = useState<InstrumentId>("PHQ9");
const [tab, setTab] = useState<InstrumentId>(instrument ?? "PHQ9");
// Which flagged row's crisis card is currently expanded (re-surfaced).
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(null);
const pinned = instrument != null;
// When pinned, the tab is forced to the host instrument so re-opening the
// detail for the other instrument never paints a stale series.
const activeTab = pinned ? instrument : tab;
const forTab = useMemo(
() =>
rows
.filter((r) => r.instrument === tab)
.filter((r) => r.instrument === activeTab)
.sort(
(a, b) =>
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
),
[rows, tab],
[rows, activeTab],
);
const isEmpty = rows.length === 0;
// Pinned: emptiness is per-instrument (the host opened THIS instrument's
// detail). Combined: emptiness spans both instruments, as before.
const isEmpty = pinned ? forTab.length === 0 : rows.length === 0;
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 can't share an
axis, so the chart + list paint one instrument at a time. */}
const body = (
<>
{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 can't share an
axis, so the chart + list paint one instrument at a time. Hidden
when pinned: the host already chose the instrument. */}
{!pinned && (
<div
className="flex gap-1"
role="tablist"
@@ -116,9 +132,9 @@ export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
key={id}
type="button"
role="tab"
aria-selected={tab === id}
aria-selected={activeTab === id}
size="sm"
variant={tab === id ? "secondary" : "ghost"}
variant={activeTab === id ? "secondary" : "ghost"}
className="h-8 px-3 text-xs"
onClick={() => {
setTab(id);
@@ -129,63 +145,88 @@ export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
</Button>
))}
</div>
)}
<AssessmentHistoryChart instrument={tab} rows={forTab} />
<AssessmentHistoryChart instrument={activeTab} rows={forTab} />
{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>
{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 variant="secondary">
{t(
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
)}
</Badge>
<span className="font-medium tabular-nums">
{row.totalScore}
</span>
</div>
</Badge>
<span className="font-medium tabular-nums">
{row.totalScore}
</span>
</div>
{flagged && open && (
<CrisisCard crisis={crisisFromRow(row)} />
)}
</li>
);
})}
</ul>
)}
</>
)}
</CardContent>
</div>
{flagged && open && (
<CrisisCard crisis={crisisFromRow(row)} />
)}
</li>
);
})}
</ul>
)}
</>
)}
</>
);
// Pinned: render bare (the host sheet owns the chrome + title). Combined:
// the landing's titled history card, unchanged.
if (pinned) {
return (
<div
data-slot="mental-health-history"
data-pinned={instrument}
className="flex flex-col gap-4"
>
{body}
</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">{body}</CardContent>
</Card>
);
}
@@ -24,45 +24,63 @@ 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 trend detail. The card BODY (title + last-result
* line) is the navigation target — mirroring how a medication card opens its
* detail — while the bottom-pinned Start button stays a separate action.
*/
onOpenDetail: () => void;
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
const key = lower(instrument);
const title = t(`mentalHealth.instrument.${key}`);
return (
<Card className="h-full gap-3" data-slot="instrument-card">
<CardContent className="flex h-full flex-col space-y-3.5 p-4">
<div className="flex flex-col gap-0.5">
<span className="text-lg leading-none font-semibold">
{t(`mentalHealth.instrument.${key}`)}
</span>
<span className="text-muted-foreground text-xs">
{t(`mentalHealth.instrumentSub.${key}`)}
</span>
</div>
{/* Body is the detail target: clicking the title / last-result block
opens this instrument's Verlauf, like a medication card. The Start
button below is a separate, deliberately non-overlapping 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 -m-1 flex flex-1 cursor-pointer flex-col gap-3.5 rounded-md p-1 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<div className="flex flex-col gap-0.5">
<span className="text-lg leading-none font-semibold">{title}</span>
<span className="text-muted-foreground text-xs">
{t(`mentalHealth.instrumentSub.${key}`)}
</span>
</div>
{/* Last-test line in the medication-card grammar: label left, the
relative day (today / yesterday / date) right-aligned. The severity
band is intentionally NOT shown here — the trend below carries it. */}
<div className="text-muted-foreground flex min-h-5 items-center justify-between gap-2 text-xs">
{last ? (
<>
<span>{t("mentalHealth.lastResult")}</span>
<span className="shrink-0">
{relativeCalendarDate(last.takenAt, t, formatDate)}
</span>
</>
) : (
<span>{t("mentalHealth.noResultYet")}</span>
)}
</div>
{/* Last-test line in the medication-card grammar: label left, the
relative day (today / yesterday / date) right-aligned. The severity
band is intentionally NOT shown here — the trend behind the card
carries it. */}
<div className="text-muted-foreground flex min-h-5 items-center justify-between gap-2 text-xs">
{last ? (
<>
<span>{t("mentalHealth.lastResult")}</span>
<span className="shrink-0">
{relativeCalendarDate(last.takenAt, t, formatDate)}
</span>
</>
) : (
<span>{t("mentalHealth.noResultYet")}</span>
)}
</div>
</button>
<div className="mt-auto pt-0">
<div className="pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
@@ -26,6 +26,7 @@ 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 { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { AssessmentHistory } from "./assessment-history";
import { AssessmentResult } from "./assessment-result";
@@ -45,6 +46,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 trend detail is open (null = closed). Clicking a card
// body surfaces THAT instrument's Verlauf in a sheet; Start stays separate.
const [detailInstrument, setDetailInstrument] = useState<InstrumentId | null>(
null,
);
const { data: history } = useQuery({
queryKey: queryKeys.mentalHealthAssessments(),
@@ -119,6 +125,7 @@ export function MentalWellbeing() {
instrument={id}
last={lastByInstrument.get(id)}
onStart={() => begin(id)}
onOpenDetail={() => setDetailInstrument(id)}
/>
</li>
))}
@@ -126,6 +133,33 @@ export function MentalWellbeing() {
</section>
<AssessmentHistory rows={history?.assessments ?? []} />
{/* Per-instrument trend detail, opened from a card body. Reuses the
history's chart + dated list, pinned to the chosen instrument. */}
<ResponsiveSheet
open={detailInstrument !== null}
onOpenChange={(open) => {
if (!open) setDetailInstrument(null);
}}
contentWidth="lg"
title={
detailInstrument
? t(
`mentalHealth.instrument.${
detailInstrument === "PHQ9" ? "phq9" : "gad7"
}`,
)
: t("mentalHealth.history.title")
}
description={t("mentalHealth.history.chartTitle")}
>
{detailInstrument && (
<AssessmentHistory
rows={history?.assessments ?? []}
instrument={detailInstrument}
/>
)}
</ResponsiveSheet>
</>
)}