mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(mental-health): drop the trend chart from the check-in page
A rising score curve on the check-in page itself reads as a verdict at the moment someone is taking stock of how they feel. The page keeps the calm dated list of past check-ins (severity badge, score, the discreet crisis-resource re-surface); the score trend stays available through the PHQ9_SCORE / GAD7_SCORE measurement rows on the Insights / Measurements surfaces. The per-card trend sheet and its chart component are removed, along with the now-unused chart i18n keys in all six locales.
This commit is contained in:
1 parent
53f84abfbb
commit
44bc91c3ea
11 files changed
+125
-474
No files matched your search
+2
-6
@@ -7680,14 +7680,11 @@
|
||||
"history": {
|
||||
"title": "Dein Verlauf",
|
||||
"empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast.",
|
||||
"chartTitle": "Punkteverlauf",
|
||||
"flaggedBadge": "Hilfe angezeigt",
|
||||
"totalLabel": "Punkte",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Einstufung"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Du musst das nicht allein durchstehen",
|
||||
@@ -7755,8 +7752,7 @@
|
||||
"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
-6
@@ -7680,14 +7680,11 @@
|
||||
"history": {
|
||||
"title": "Your history",
|
||||
"empty": "No check-ins yet. Your trend will appear here once you complete one.",
|
||||
"chartTitle": "Score trend",
|
||||
"flaggedBadge": "Support shown",
|
||||
"totalLabel": "Score",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Category"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "You don't have to face this alone",
|
||||
@@ -7755,8 +7752,7 @@
|
||||
"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
-6
@@ -7680,14 +7680,11 @@
|
||||
"history": {
|
||||
"title": "Tu historial",
|
||||
"empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una.",
|
||||
"chartTitle": "Evolución de la puntuación",
|
||||
"flaggedBadge": "Apoyo mostrado",
|
||||
"totalLabel": "Puntuación",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Categoría"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "No tienes que afrontar esto a solas",
|
||||
@@ -7755,8 +7752,7 @@
|
||||
"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
-6
@@ -7680,14 +7680,11 @@
|
||||
"history": {
|
||||
"title": "Ton historique",
|
||||
"empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un.",
|
||||
"chartTitle": "Évolution du score",
|
||||
"flaggedBadge": "Aide affichée",
|
||||
"totalLabel": "Score",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Catégorie"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Tu n'as pas à affronter cela seul(e)",
|
||||
@@ -7755,8 +7752,7 @@
|
||||
"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
-6
@@ -7680,14 +7680,11 @@
|
||||
"history": {
|
||||
"title": "La tua cronologia",
|
||||
"empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno.",
|
||||
"chartTitle": "Andamento del punteggio",
|
||||
"flaggedBadge": "Supporto mostrato",
|
||||
"totalLabel": "Punteggio",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Categoria"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Non devi affrontare tutto questo da solo/a",
|
||||
@@ -7755,8 +7752,7 @@
|
||||
"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
-6
@@ -7680,14 +7680,11 @@
|
||||
"history": {
|
||||
"title": "Twoja historia",
|
||||
"empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego.",
|
||||
"chartTitle": "Przebieg wyniku",
|
||||
"flaggedBadge": "Pokazano wsparcie",
|
||||
"totalLabel": "Wynik",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Kategoria"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Nie musisz mierzyć się z tym sam(a)",
|
||||
@@ -7755,8 +7752,7 @@
|
||||
"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.",
|
||||
|
||||
@@ -11,8 +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 chart
|
||||
* shell + dated list with severity + "support shown" badges.
|
||||
* 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).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
@@ -174,9 +175,12 @@ describe("history rendering", () => {
|
||||
|
||||
const html = withProviders(<AssessmentHistory rows={rows} />);
|
||||
|
||||
it("paints the lazy chart shell + the dated list", () => {
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
it("paints the dated list WITHOUT a trend chart shell", () => {
|
||||
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", () => {
|
||||
@@ -196,74 +200,14 @@ describe("history rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("instrument card → per-instrument trend (v1.25.12)", () => {
|
||||
it("exposes the card body as a detail target separate from Start", () => {
|
||||
describe("instrument card (v1.27.6 — no trend-detail target)", () => {
|
||||
it("renders the card body as plain content with Start as the single action", () => {
|
||||
const html = withProviders(
|
||||
<InstrumentCard
|
||||
instrument="PHQ9"
|
||||
last={undefined}
|
||||
onStart={() => {}}
|
||||
onOpenDetail={() => {}}
|
||||
/>,
|
||||
<InstrumentCard instrument="PHQ9" last={undefined} onStart={() => {}} />,
|
||||
);
|
||||
// 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.
|
||||
// The former trend-detail button is gone…
|
||||
expect(html).not.toContain('data-slot="instrument-card-open"');
|
||||
// …and the Start action remains.
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
"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.
|
||||
*
|
||||
* One instrument at a time: PHQ-9 (0–27) and GAD-7 (0–21) have different ranges
|
||||
* and bands, so the parent toggles 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,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.3 — the history card: a per-instrument trend chart over a band-shaded
|
||||
* plot, then the dated list with severity badges. Mirrors the labs detail spine
|
||||
* (chart → list) and lazy-loads Recharts through `next/dynamic` so it stays off
|
||||
* the surface's first-load JS, with a `ChartSkeleton` shell.
|
||||
* v1.25.3 — the history card: a dated list with severity badges.
|
||||
*
|
||||
* PHQ-9 / GAD-7 toggle: the two instruments have different score ranges + bands,
|
||||
* so the chart paints one at a time.
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
@@ -16,16 +20,11 @@
|
||||
* 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";
|
||||
@@ -33,25 +32,6 @@ import { Activity } from "lucide-react";
|
||||
import { CrisisCard } from "./crisis-card";
|
||||
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
function lower(id: InstrumentId): "phq9" | "gad7" {
|
||||
return id === "PHQ9" ? "phq9" : "gad7";
|
||||
}
|
||||
@@ -68,73 +48,57 @@ function crisisFromRow(row: AssessmentRow): CrisisSet {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
const [tab, setTab] = useState<InstrumentId>(instrument ?? "PHQ9");
|
||||
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 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 === activeTab)
|
||||
.filter((r) => r.instrument === tab)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
|
||||
),
|
||||
[rows, activeTab],
|
||||
[rows, tab],
|
||||
);
|
||||
|
||||
// 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;
|
||||
const isEmpty = rows.length === 0;
|
||||
|
||||
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 && (
|
||||
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.chartTitle")}
|
||||
aria-label={t("mentalHealth.history.title")}
|
||||
>
|
||||
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === id}
|
||||
aria-selected={tab === id}
|
||||
size="sm"
|
||||
variant={activeTab === id ? "secondary" : "ghost"}
|
||||
variant={tab === id ? "secondary" : "ghost"}
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
@@ -145,88 +109,61 @@ export function AssessmentHistory({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
<Badge variant="secondary">
|
||||
{t(
|
||||
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.totalScore}
|
||||
{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>
|
||||
</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>
|
||||
{flagged && open && (
|
||||
<CrisisCard crisis={crisisFromRow(row)} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -24,18 +24,11 @@ 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();
|
||||
@@ -45,16 +38,10 @@ export function InstrumentCard({
|
||||
return (
|
||||
<Card className="h-full gap-3" data-slot="instrument-card">
|
||||
<CardContent className="flex h-full flex-col space-y-3.5 p-4">
|
||||
{/* 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"
|
||||
>
|
||||
{/* v1.27.6 — the card body is no longer a trend-detail target (the
|
||||
trend chart left this surface); the card informs, the Start button
|
||||
below is the single action. */}
|
||||
<div className="flex flex-1 flex-col gap-3.5">
|
||||
<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">
|
||||
@@ -63,9 +50,7 @@ export function InstrumentCard({
|
||||
</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. */}
|
||||
relative day (today / yesterday / date) right-aligned. */}
|
||||
<div className="text-muted-foreground flex min-h-5 items-center justify-between gap-2 text-xs">
|
||||
{last ? (
|
||||
<>
|
||||
@@ -78,7 +63,7 @@ export function InstrumentCard({
|
||||
<span>{t("mentalHealth.noResultYet")}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
*
|
||||
* v1.25.3 — this is now the ORCHESTRATOR + landing only; the 387-line monolith
|
||||
* split into focused components (`instrument-card`, `check-in-wizard`,
|
||||
* `assessment-result`, `assessment-history` + its lazy chart, `crisis-card`).
|
||||
* `assessment-result`, `assessment-history`, `crisis-card`).
|
||||
* The landing follows the card grammar of Vorsorge / Medications: instrument
|
||||
* 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.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -27,7 +31,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
|
||||
import { AssessmentHistory } from "./assessment-history";
|
||||
import { AssessmentResult } from "./assessment-result";
|
||||
@@ -47,11 +50,6 @@ 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(),
|
||||
@@ -124,7 +122,6 @@ export function MentalWellbeing() {
|
||||
instrument={id}
|
||||
last={lastByInstrument.get(id)}
|
||||
onStart={() => begin(id)}
|
||||
onOpenDetail={() => setDetailInstrument(id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -132,33 +129,6 @@ 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