feat(targets): 30-day sparkline + Δ-vs-last-month caption per card

The Zielwerte page read as a static reference cheat-sheet — every card
surfaced a current value + range bar but no recent trajectory. Each
target now grows a dependency-free 30-day sparkline beneath the range
bar plus a localised "Δ −2.3 kg vs. last month" caption so the page
reads as a living health log rather than a guideline lookup.

The API computes both fields from a single 60-day query (one round-trip,
not two): `points30d` is the daily-mean series for the last 30 days,
`deltaVsLastMonth` is the signed difference between the current and
prior 30-day averages. Both are null when either window has fewer than
3 readings so cold-start accounts don't see a misleading flat trace
or a "± 0" comparison that's really "no comparison".

BMI piggybacks on the weight series (divided by height²) so the BMI
card's sparkline matches its range bar's y-axis. Derived metrics
(BP-in-target rate, mood stability, medication compliance,
blood-glucose-by-context) keep their previous chrome — the rich
status surface they already render is the right answer there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 21:21:48 +02:00
1 parent 7c46cc9439
commit a22f77881e
5 files changed
+308 -3

No files matched your search

+3
View File
@@ -2558,6 +2558,9 @@
"moodStability": "Stimmungsstabilität",
"steps": "Schritte",
"average30d": "30-Tage-Schnitt:",
"deltaVsLastMonth": "{delta} {unit} vs. Vormonat",
"deltaVsLastMonthFlat": "± 0 {unit} vs. Vormonat",
"deltaUnavailable": "Zu wenige Werte für einen Vormonats-Vergleich",
"currentValue": "Aktuell: {value} {unit}",
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
"sourceLabel": "Quelle: {source}",
+3
View File
@@ -2558,6 +2558,9 @@
"moodStability": "Mood stability",
"steps": "steps",
"average30d": "30-day average:",
"deltaVsLastMonth": "{delta} {unit} vs. last month",
"deltaVsLastMonthFlat": "± 0 {unit} vs. last month",
"deltaUnavailable": "Not enough data for a vs.-last-month comparison",
"currentValue": "Current: {value} {unit}",
"targetRangeValue": "Target: {min}–{max} {unit}",
"sourceLabel": "Source: {source}",
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.22 C1 — `/targets` (Zielwerte) page upgrade.
*
* Each target card grew a 30-day sparkline + a "Δ vs. last month"
* caption so the page reads as a living health log instead of a
* static reference card. The API ships `points30d: number[]` and
* `deltaVsLastMonth: number` per target; the page renders a tiny
* dependency-free SVG path plus the localised delta caption.
*
* Cards without enough data (fewer than 3 points in either window)
* keep their previous v1.4.19 layout — sparkline and delta caption
* stay absent so cold-start accounts don't see a misleading flat
* trace or a "± 0" comparison that's really "no comparison".
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
const sampleData = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 88.2,
average30: 88.6,
trend: "stable",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
points30d: [89.0, 88.8, 88.6, 88.4, 88.2],
deltaVsLastMonth: -2.3,
},
{
type: "PULSE",
label: "Resting pulse",
current: 72,
average30: 72,
trend: "stable",
unit: "bpm",
range: { min: 60, max: 100 },
classification: { category: "On target", color: "#50fa7b" },
source: "AHA",
// No sparkline yet — cold-start account with <3 readings.
points30d: null,
deltaVsLastMonth: null,
},
],
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "tester",
email: "tester@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — sparkline + delta", () => {
it("renders a sparkline SVG for cards with points30d", () => {
const html = render();
// The sparkline carries a stable data-slot the visual test pins.
expect(html).toContain('data-slot="target-sparkline"');
// Path with the dracula-purple token, not a hardcoded hex.
expect(html).toContain("var(--dracula-purple)");
});
it("renders the Δ-vs-last-month caption with the signed delta", () => {
const html = render();
expect(html).toContain('data-slot="target-delta"');
// Weight: −2.3 kg vs. last month
expect(html).toMatch(/−2\.3\s*kg/);
expect(html).toContain("vs. last month");
});
it("uses German vs.-Vormonat caption when the locale is `de`", () => {
const html = render("de");
expect(html).toContain("vs. Vormonat");
});
it("skips both sparkline and delta when the target has no points", () => {
const html = render();
// PULSE card has no points30d / no deltaVsLastMonth — only ONE
// `target-sparkline` slot should be present (from the WEIGHT card).
const sparklineMatches = html.match(/data-slot="target-sparkline"/g) ?? [];
expect(sparklineMatches.length).toBe(1);
const deltaMatches = html.match(/data-slot="target-delta"/g) ?? [];
expect(deltaMatches.length).toBe(1);
});
});
+93 -3
View File
@@ -56,6 +56,19 @@ interface TargetItem {
range: { min: number; max: number } | null;
classification: { category: string; color: string } | null;
source: string;
/**
* v1.4.22 C1 — sparkline support on the Zielwerte page. `points30d`
* is the last 30 days of values (chronological, oldest → newest) for
* the target's primary metric, rounded to one decimal. Derived metrics
* (BMI, BP-in-target, mood stability, glucose-by-context) skip the
* field when the page already renders a richer status surface.
* `deltaVsLastMonth` is the signed difference between the current
* 30-day average and the prior 30-day average (current − prior),
* null when either window has fewer than 3 points to keep the
* comparison honest.
*/
points30d?: number[] | null;
deltaVsLastMonth?: number | null;
details?: {
medications?: Array<{
name: string;
@@ -98,17 +111,28 @@ export const GET = apiHandler(async () => {
];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// v1.4.22 C1 — second 30-day window (days 31–60) so each target card
// can render a "Δ vs. last month" caption alongside its sparkline.
// We fetch 60 days in one query to keep the route round-trip count
// identical to the previous version.
const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
// Fetch all measurements in the last 30 days + the latest for each type
const recentMeasurements = await prisma.measurement.findMany({
// Fetch all measurements in the last 60 days + the latest for each type
const windowedMeasurements = await prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: thirtyDaysAgo },
measuredAt: { gte: sixtyDaysAgo },
},
orderBy: { measuredAt: "desc" },
select: { type: true, value: true, measuredAt: true },
});
const recentMeasurements = windowedMeasurements.filter(
(m) => m.measuredAt >= thirtyDaysAgo,
);
const priorMeasurements = windowedMeasurements.filter(
(m) => m.measuredAt < thirtyDaysAgo,
);
// Also get the absolute latest measurement per type (even if older than 30 days).
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
@@ -163,6 +187,45 @@ export const GET = apiHandler(async () => {
return "stable";
}
/**
* v1.4.22 C1 — sparkline + delta helpers. The sparkline collapses
* the last 30 days of values for a measurement type into a daily
* mean series (chronological, oldest → newest). The delta is the
* signed difference between the current 30-day window's average
* and the prior 30-day window's average. Both helpers skip the
* computation when there are fewer than 3 points in a window so
* the comparison stays honest on cold-start accounts.
*/
function sparklinePoints(type: MeasurementType): number[] | null {
const data = recentMeasurements
.filter((m) => m.type === type)
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
if (data.length < 3) return null;
const byDay = new Map<string, { sum: number; count: number }>();
for (const m of data) {
const day = m.measuredAt.toISOString().slice(0, 10);
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
bucket.sum += m.value;
bucket.count += 1;
byDay.set(day, bucket);
}
return Array.from(byDay.values()).map(
(b) => Math.round((b.sum / b.count) * 10) / 10,
);
}
function deltaVsLastMonth(type: MeasurementType): number | null {
const recentOfType = recentMeasurements.filter((m) => m.type === type);
const priorOfType = priorMeasurements.filter((m) => m.type === type);
if (recentOfType.length < 3 || priorOfType.length < 3) return null;
const recentAvg =
recentOfType.reduce((s, m) => s + m.value, 0) / recentOfType.length;
const priorAvg =
priorOfType.reduce((s, m) => s + m.value, 0) / priorOfType.length;
return Math.round((recentAvg - priorAvg) * 10) / 10;
}
// Build target items
const targets: TargetItem[] = [];
@@ -185,6 +248,8 @@ export const GET = apiHandler(async () => {
range: weightRange,
classification: weightClassification,
source: "WHO BMI",
points30d: sparklinePoints("WEIGHT"),
deltaVsLastMonth: deltaVsLastMonth("WEIGHT"),
});
// 2. Blood Pressure (sys/dia combined)
@@ -210,6 +275,8 @@ export const GET = apiHandler(async () => {
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
classification: bpClassification,
source: "ESH 2023",
points30d: sparklinePoints("BLOOD_PRESSURE_SYS"),
deltaVsLastMonth: deltaVsLastMonth("BLOOD_PRESSURE_SYS"),
// Extra fields for diastolic
} as TargetItem);
@@ -276,6 +343,8 @@ export const GET = apiHandler(async () => {
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
classification: pulseClassification,
source: pulseTarget.source,
points30d: sparklinePoints("PULSE"),
deltaVsLastMonth: deltaVsLastMonth("PULSE"),
});
// 4. Sleep Duration
@@ -295,6 +364,8 @@ export const GET = apiHandler(async () => {
range: sleepRange,
classification: sleepClassification,
source: "AASM/SRS",
points30d: sparklinePoints("SLEEP_DURATION"),
deltaVsLastMonth: deltaVsLastMonth("SLEEP_DURATION"),
});
// 5. BMI (derived from weight + height)
@@ -318,6 +389,19 @@ export const GET = apiHandler(async () => {
bmiClassification = { category: cls.category, color: cls.color };
}
// BMI sparkline / delta derive from the weight series \u2014 divide each
// point by height\u00B2 so the y-axis matches the BMI range bar above.
const weightPoints = sparklinePoints("WEIGHT");
const bmiPoints =
weightPoints != null
? weightPoints.map((v) => Math.round((v / heightSq) * 10) / 10)
: null;
const weightDelta = deltaVsLastMonth("WEIGHT");
const bmiDelta =
weightDelta != null
? Math.round((weightDelta / heightSq) * 10) / 10
: null;
targets.push({
type: "BMI",
label: "BMI",
@@ -328,6 +412,8 @@ export const GET = apiHandler(async () => {
range: { min: 18.5, max: 24.9 },
classification: bmiClassification,
source: "WHO",
points30d: bmiPoints,
deltaVsLastMonth: bmiDelta,
});
}
@@ -353,6 +439,8 @@ export const GET = apiHandler(async () => {
range: bodyFatRange,
classification: bodyFatClassification,
source: "ACE",
points30d: sparklinePoints("BODY_FAT"),
deltaVsLastMonth: deltaVsLastMonth("BODY_FAT"),
});
// 7. Activity Steps
@@ -371,6 +459,8 @@ export const GET = apiHandler(async () => {
unit: "steps",
range: stepsRange,
classification: stepsClassification,
points30d: sparklinePoints("ACTIVITY_STEPS"),
deltaVsLastMonth: deltaVsLastMonth("ACTIVITY_STEPS"),
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
+86
View File
@@ -38,6 +38,13 @@ interface TargetData {
range: { min: number; max: number } | null;
classification: { category: string; color: string } | null;
source: string;
/**
* v1.4.22 C1 — sparkline + vs.-last-month delta. Optional; the API
* skips both when a window has fewer than 3 readings so the comparison
* stays honest on a fresh account.
*/
points30d?: number[] | null;
deltaVsLastMonth?: number | null;
details?: {
medications?: Array<{
name: string;
@@ -241,6 +248,60 @@ function getTargetSourceLink(target: TargetData): string | null {
return null;
}
/**
* v1.4.22 C1 — tiny inline sparkline. Each target card paints the last
* 30 days of values beneath the range bar so the page reads as a
* "current state + recent journey" surface rather than a static
* reference card. The SVG is dependency-free and renders in <1ms; we
* deliberately avoid pulling Recharts in here because every card on
* the page would mount its own ResponsiveContainer.
*
* The path stays in the dracula-purple token so the colour reads as a
* neutral trace, not a status signal — the range bar above already
* carries the green/yellow/red semantics and a second status colour
* on the sparkline would compete for the user's eye.
*/
function Sparkline({
points,
className,
}: {
points: number[];
className?: string;
}) {
if (points.length < 2) return null;
const min = Math.min(...points);
const max = Math.max(...points);
const span = Math.max(max - min, 0.0001);
const w = 100;
const h = 24;
const stepX = w / (points.length - 1);
const path = points
.map((value, i) => {
const x = i * stepX;
const y = h - ((value - min) / span) * h;
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
return (
<svg
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
className={`h-6 w-full ${className ?? ""}`}
aria-hidden="true"
data-slot="target-sparkline"
>
<path
d={path}
fill="none"
stroke="var(--dracula-purple)"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) {
if (trend === "up") {
return <TrendingUp className="h-4 w-4 text-orange-400" />;
@@ -507,6 +568,31 @@ function TargetCard({
</div>
)}
{/* v1.4.22 C1 — sparkline + Δ-vs-last-month caption. The
sparkline collapses to a thinner trace on mobile via the
shared 24px height, and the delta caption sits on its own
line so it stays scannable next to the range bar above. */}
{target.points30d && target.points30d.length >= 2 && (
<div className="space-y-1" data-slot="target-trend">
<Sparkline points={target.points30d} />
{target.deltaVsLastMonth != null && (
<p
className="text-muted-foreground text-xs"
data-slot="target-delta"
>
{Math.abs(target.deltaVsLastMonth) < 0.05
? t("targets.deltaVsLastMonthFlat", { unit: target.unit })
: t("targets.deltaVsLastMonth", {
delta:
(target.deltaVsLastMonth > 0 ? "+" : "−") +
Math.abs(target.deltaVsLastMonth).toFixed(1),
unit: target.unit,
})}
</p>
)}
</div>
)}
{isMedicationCompliance && medicationBreakdown.length > 0 && (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">