mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
b5eb5274b3
commit
5bfba0ba60
10 files changed
+280
-108
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user