From 86a52dac3376a1146645556358e544fce55fb82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 12:15:17 +0200 Subject: [PATCH] refactor(targets): remove v1.4.22 per-card sparkline + delta caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.4.22 C1 inline sparkline + "Δ vs. last month" caption never earned their place on /targets. The W5 reconcile filed four findings against them (Design M2 — sparkline ignores min-points threshold so 3 points read as "lots of activity"; M7 — SVG aria-hidden hides the only motion cue from screen readers; L3 — preserveAspectRatio="none" distorts the y axis; Senior-Dev M2 — 7 sequential Array.filter passes on the route). The replacement primitive (a 7-day consistency strip) lands in the next phase; this commit just removes the dead weight. Drops: - Sparkline component, TargetItem.points30d, TargetItem.deltaVsLastMonth - The 60-day fetch and the per-type sparklinePoints / deltaVsLastMonth helpers in the route handler - targets.deltaVsLastMonth, targets.deltaVsLastMonthFlat, targets.deltaUnavailable i18n keys (EN + DE) - targets-sparkline.test.tsx (the consistency-strip suite replaces it in phase 3) --- messages/de.json | 3 - messages/en.json | 3 - src/app/__tests__/targets-sparkline.test.tsx | 123 ------------------- src/app/api/insights/targets/route.ts | 102 +-------------- src/app/targets/page.tsx | 86 ------------- 5 files changed, 3 insertions(+), 314 deletions(-) delete mode 100644 src/app/__tests__/targets-sparkline.test.tsx diff --git a/messages/de.json b/messages/de.json index 13c06e9c1..27eedcaca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2627,9 +2627,6 @@ "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 383e3300f..97991c1e7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2627,9 +2627,6 @@ "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 deleted file mode 100644 index ce74403ed..000000000 --- a/src/app/__tests__/targets-sparkline.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -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 7c77c54aa..3f516337c 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -14,7 +14,6 @@ import { import { calculateCompliance } from "@/lib/analytics/compliance"; import { pairByTimestamp } from "@/lib/analytics/correlations"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; -import { berlinDayKey } from "@/lib/analytics/berlin-day"; import { classifyPulseByTarget, getPersonalizedPulseTarget, @@ -57,19 +56,6 @@ 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; @@ -112,28 +98,17 @@ 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 60 days + the latest for each type - const windowedMeasurements = await prisma.measurement.findMany({ + // Fetch all measurements in the last 30 days + the latest for each type + const recentMeasurements = await prisma.measurement.findMany({ where: { userId, type: { in: types }, - measuredAt: { gte: sixtyDaysAgo }, + measuredAt: { gte: thirtyDaysAgo }, }, 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 @@ -188,50 +163,6 @@ 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; - - // v1.4.22 W5 reconcile (Code-MED-3) — bucket by Berlin calendar - // day, not UTC. A 23:30-Berlin reading on Tuesday otherwise - // lands in Wednesday's UTC bucket and the sparkline drifts a - // day on cross-DST boundaries. Aligns with `berlinDayKey()` - // already used by the dashboard analytics route. - const byDay = new Map(); - for (const m of data) { - const day = berlinDayKey(m.measuredAt); - 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[] = []; @@ -254,8 +185,6 @@ export const GET = apiHandler(async () => { range: weightRange, classification: weightClassification, source: "WHO BMI", - points30d: sparklinePoints("WEIGHT"), - deltaVsLastMonth: deltaVsLastMonth("WEIGHT"), }); // 2. Blood Pressure (sys/dia combined) @@ -281,8 +210,6 @@ 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); @@ -349,8 +276,6 @@ 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 @@ -370,8 +295,6 @@ 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) @@ -395,19 +318,6 @@ 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", @@ -418,8 +328,6 @@ export const GET = apiHandler(async () => { range: { min: 18.5, max: 24.9 }, classification: bmiClassification, source: "WHO", - points30d: bmiPoints, - deltaVsLastMonth: bmiDelta, }); } @@ -445,8 +353,6 @@ export const GET = apiHandler(async () => { range: bodyFatRange, classification: bodyFatClassification, source: "ACE", - points30d: sparklinePoints("BODY_FAT"), - deltaVsLastMonth: deltaVsLastMonth("BODY_FAT"), }); // 7. Activity Steps @@ -465,8 +371,6 @@ 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 d2f817eb5..c982c71a5 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -38,13 +38,6 @@ 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; @@ -248,60 +241,6 @@ 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 ; @@ -568,31 +507,6 @@ 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 && (