diff --git a/messages/de.json b/messages/de.json index a0e5d4797..402eb5abf 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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}", diff --git a/messages/en.json b/messages/en.json index aefd41155..95e328a92 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}", diff --git a/src/app/__tests__/targets-sparkline.test.tsx b/src/app/__tests__/targets-sparkline.test.tsx new file mode 100644 index 000000000..ce74403ed --- /dev/null +++ b/src/app/__tests__/targets-sparkline.test.tsx @@ -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( + + + , + ); +} + +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); + }); +}); diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 3f516337c..68f7696b6 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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(); + 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 diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index 5a9228d2c..721a7381d 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -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 ( + + ); +} + function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) { if (trend === "up") { return ; @@ -507,6 +568,31 @@ function TargetCard({ )} + {/* 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 && ( +
+ + {target.deltaVsLastMonth != null && ( +

+ {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, + })} +

+ )} +
+ )} + {isMedicationCompliance && medicationBreakdown.length > 0 && (