From 74c2eb8bffbe56cc589c5775981a49cb1eb902a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 00:26:02 +0200 Subject: [PATCH] feat(charts): BP/weight/pulse polish (gradient, baseline, rich tooltip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HealthChart wrapper is the single component the dashboard uses for every numeric vital — BP systolic/diastolic, weight, pulse, body fat, sleep, steps. This commit upgrades it once with the v1.4.16 visual leap so all those metrics jump together. What changed: - ComposedChart replaces LineChart so a gradient-filled Area can paint under the line. Each metric gets its own chart-gradient-inline-{type} definition; default 35 percent top alpha → 0 percent bottom matches the Apple Health Trends look. A sibling 0×0 SVG mirrors the gradient so SSR markup carries the slot for visual-regression and e2e probes. - Personal baseline reference line: 90-day rolling median per type, painted as a faint dashed line labelled "Your normal" / "Dein Mittel". The new computePersonalBaseline() helper returns null when fewer than 5 points exist so a baseline never renders off noise. - Rich tooltip via the new RichChartTooltip primitive: rounded card with drop shadow, per-row coloured dot, tabular-nums value cell, and a delta-vs-baseline sub-line ("+3 mmHg vs. your normal"). Auxiliary rows from the moving-average / trend overlays are filtered out so the card stays focused on the primary metric. - 600ms ease-out animation on first render via Recharts' isAnimationActive prop, suppressed when prefers-reduced-motion is set at the OS level (new src/lib/charts/reduced-motion.ts helper with 4 unit tests). - Sparse-data placeholder via ChartEmptyState when fewer than 3 daily points exist — a near-empty chart looks broken, the placeholder reads as intentional. In-target zone shading was already wired via the existing valueBands prop and stays untouched. The dashed peer's labelling is unchanged. 7 new tests pin the contract (4 baseline-helper, 2 SSR markup, 1 gradient-id namespacing). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/health-chart-polish.test.tsx | 155 ++++++++++ src/components/charts/health-chart.tsx | 276 ++++++++++++++++-- .../charts/__tests__/reduced-motion.test.ts | 55 ++++ src/lib/charts/reduced-motion.ts | 25 ++ 4 files changed, 489 insertions(+), 22 deletions(-) create mode 100644 src/components/charts/__tests__/health-chart-polish.test.tsx create mode 100644 src/lib/charts/__tests__/reduced-motion.test.ts create mode 100644 src/lib/charts/reduced-motion.ts diff --git a/src/components/charts/__tests__/health-chart-polish.test.tsx b/src/components/charts/__tests__/health-chart-polish.test.tsx new file mode 100644 index 000000000..e9b63be1b --- /dev/null +++ b/src/components/charts/__tests__/health-chart-polish.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { computePersonalBaseline } from "../health-chart"; + +/** + * v1.4.16 B1a — HealthChart Apple-Health-style polish contract. + * + * The HealthChart wrapper is the single component that the dashboard + * uses for BP, weight, pulse, body-fat, sleep, and steps. We upgrade it + * once with the v1.4.16 visual leap and the unit test pins the + * SSR-renderable surface markers: + * + * - the linear gradient primitive is wired (data-slot present), + * - the optional 90-day personal-baseline ReferenceLine is painted + * when the wrapper is given a baseline computation hook, + * - the rich tooltip primitive replaces the default Recharts tooltip + * so tooltip rendering happens through `RichChartTooltip`. + * + * Recharts itself renders client-side so we can't assert pixel layout + * in node-based vitest. We assert that the wrapper's SSR output ships + * the gradient `` block (Recharts emits primitives during SSR + * even though hover/animations only kick in client-side) and that + * the gradient id includes the metric type prefix. + */ + +const sampleSeries = vi.hoisted(() => { + // 30 days of synthetic systolic readings — ~120 baseline with mild + // upward drift + small noise. The chart's 90-day median feature only + // paints a baseline when sufficient data exists, so we hand it a + // dataset comfortably above the 3-point threshold. + const out: Array<{ measuredAt: string; value: number }> = []; + for (let i = 0; i < 30; i++) { + const dt = new Date(Date.UTC(2026, 4, 1 + i, 12, 0, 0)); + out.push({ + measuredAt: dt.toISOString(), + value: 118 + (i % 5) - 2 + i * 0.05, + }); + } + return out; +}); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: sampleSeries.map((row) => { + const ts = Date.parse(row.measuredAt); + return { + date: new Date(ts).toDateString(), + timestamp: ts, + BLOOD_PRESSURE_SYS: row.value, + }; + }), + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +describe("computePersonalBaseline()", () => { + it("returns null with fewer than 5 data points", () => { + expect(computePersonalBaseline([])).toBeNull(); + expect( + computePersonalBaseline([ + { value: 120 }, + { value: 122 }, + { value: 118 }, + { value: 125 }, + ]), + ).toBeNull(); + }); + + it("returns the median for an odd-sized series", () => { + // Sorted: [110, 115, 120, 125, 130]; median = 120. + expect( + computePersonalBaseline([ + { value: 130 }, + { value: 110 }, + { value: 120 }, + { value: 125 }, + { value: 115 }, + ]), + ).toBe(120); + }); + + it("averages the two middle values for an even-sized series", () => { + // Sorted: [110, 115, 120, 125, 130, 135]; middle two = 120,125 → 122.5. + expect( + computePersonalBaseline([ + { value: 110 }, + { value: 115 }, + { value: 120 }, + { value: 125 }, + { value: 130 }, + { value: 135 }, + ]), + ).toBe(122.5); + }); + + it("caps the window at the most recent N points (default 90)", () => { + // 100 points: first 5 are 200, remaining 95 are 100. With a 90-point + // cap we slice off the trailing 90 → all 100s → median 100. + const points: Array<{ value: number }> = []; + for (let i = 0; i < 5; i++) points.push({ value: 200 }); + for (let i = 0; i < 95; i++) points.push({ value: 100 }); + expect(computePersonalBaseline(points, 90)).toBe(100); + }); +}); + +describe(" v1.4.16 B1a polish", () => { + it("emits a sibling-SVG gradient block addressable by Recharts via url(#id)", async () => { + // ResponsiveContainer renders empty during SSR (its dimensions + // depend on the live DOM), so Recharts' own SSR output ships no + // . To keep the gradient SSR-discoverable (and so visual + // regressions can target it without a browser), the wrapper emits + // an external `` next to ResponsiveContainer + // containing the gradient. Recharts picks it up via `url(#id)` + // because SVG IDs are document-scoped. + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-slot="chart-linear-gradient"'); + }); + + it("namespaces the gradient id by metric type so multi-metric charts don't collide", async () => { + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + expect(html).toMatch(/id="chart-gradient-BLOOD_PRESSURE_SYS"/); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index c849cb82d..6bfc5b128 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -4,7 +4,8 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { ResponsiveContainer, - LineChart, + ComposedChart, + Area, Line, XAxis, YAxis, @@ -16,6 +17,13 @@ import { } from "recharts"; import { Loader2 } from "lucide-react"; import { useState, useMemo, useId } from "react"; +import { + ChartLinearGradient, + chartGradientFill, +} from "./chart-gradient"; +import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; +import { ChartEmptyState } from "./chart-empty-state"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; @@ -177,6 +185,31 @@ function movingAverageByPoints( }); } +/** + * v1.4.16 B1a — personal-baseline helper. + * + * Computes the median (50th-percentile) value of a daily-aggregated + * series, capped at the most-recent 90 daily points (Apple Health uses + * the rolling-90-day window for "your normal" framing). Returns `null` + * when fewer than 5 points exist — a baseline drawn off 1-2 readings is + * misleading; surface nothing instead. + * + * Pure & deterministic so the unit test pins exact medians. + */ +export function computePersonalBaseline( + data: Array<{ value: number }>, + windowPoints = 90, +): number | null { + if (data.length < 5) return null; + const recent = data.slice(-windowPoints); + const sorted = recent.map((d) => d.value).sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2; + } + return sorted[mid]; +} + function buildTrendSeriesByTime(data: Array<{ date: Date; value: number }>): { points: Array<{ date: Date; value: number }>; slopePerDay: number; @@ -641,10 +674,28 @@ export function HealthChart({ .filter((entry): entry is string => entry !== null); }, [chartData, showTrend, types, unit, valueMode, t, fmt]); + // v1.4.16 B1a — personal baseline (90-day rolling median) per type. + // Painted as a faint dashed reference line labelled "Your normal" so + // the user can read each point against their own baseline, not an + // absolute value the chart axis happens to render. + const personalBaselines = useMemo(() => { + if (!chartData?.length) return new Map(); + const map = new Map(); + for (const type of types) { + const series = chartData + .filter((d) => typeof d[type] === "number") + .map((d) => ({ value: d[type] as number })); + const baseline = computePersonalBaseline(series, 90); + if (baseline !== null) map.set(type, baseline); + } + return map; + }, [chartData, types]); + if (!isLoading && !data?.length) return null; const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1); const showContextDetails = showMA || showTrend || showBands; + const animationsEnabled = !prefersReducedMotion(); return (
@@ -724,7 +775,15 @@ export function HealthChart({
- ) : !chartData?.length ? null : ( + ) : !chartData?.length ? null : (chartData?.length ?? 0) < 3 ? ( + // v1.4.16 B1a — sparse-data placeholder. <3 daily points is too + // few to render a meaningful trend; paint a friendly hint + // instead so the dashboard doesn't look broken. + + ) : (
{visibleBands.length > 0 ? (
) : null} + {/* v1.4.16 B1a — sibling SVG block. + ResponsiveContainer is empty during SSR so a gradient + defined inside its child isn't discoverable in + static-rendered markup. Emitting the gradient in a + standalone hidden next to the chart keeps it + available document-wide; Recharts' + looks the gradient up by id at paint time and finds it + regardless of which SVG it lives in. */} + +
- + {/* Inline gradient defs as Recharts children — same id + space as the sibling SVG above, so even browsers + that scope SVG-id resolution to the local document + fragment (Safari edge cases) still find a match. */} + + {types.map((type, i) => ( + + + + + ))} + ))} + {/* v1.4.16 B1a — personal-baseline reference line. + 90-day rolling median per type, painted as a faint + dashed line labelled "Your normal" / "Dein Mittel". + Only the *first* baseline gets an inline label so + multi-type charts (e.g. systolic + diastolic) don't + paint two overlapping labels. */} + {types.map((type, i) => { + const baseline = personalBaselines.get(type); + if (baseline == null) return null; + return ( + + ); + })} { + const { + active, + payload, + label: rechartsLabel, + } = props as unknown as { + active?: boolean; + payload?: Array<{ + name?: string; + value?: number; + color?: string; + dataKey?: string; + payload?: ChartDataPoint; + }>; + label?: number; + }; + if (!active || !payload?.length) return null; + const ts = + payload[0]?.payload?.timestamp ?? + (typeof rechartsLabel === "number" + ? chartData?.[Math.round(rechartsLabel)]?.timestamp + : undefined); + const dateLabel = ts + ? formatDateShort(new Date(ts), true) + : ""; + const rows: RichTooltipRow[] = []; + for (const item of payload) { + if (typeof item.value !== "number") continue; + const dataKey = String(item.dataKey ?? ""); + // Skip auxiliary lines (`*_ma`, `*_trend`) so the + // tooltip stays focused on the primary metric + // rows; the user already sees these as dashed + // overlays in the chart. + if (dataKey.endsWith("_ma") || dataKey.endsWith("_trend")) + continue; + const baseline = personalBaselines.get(dataKey); + let delta: string | undefined; + if (baseline != null) { + const diff = item.value - baseline; + if (Math.abs(diff) < 0.05) { + delta = t("charts.deltaUnchanged"); + } else { + const sign = diff > 0 ? "+" : "−"; + const formatted = `${sign}${fmt.number( + Math.abs(diff), + 1, + )}${unit ? ` ${unit}` : ""}`; + delta = t("charts.deltaVsBaseline").replace( + "{delta}", + formatted, + ); + } + } + rows.push({ + name: item.name ?? dataKey, + value: `${formatTooltipValue(item.value)}${ + unit ? ` ${unit}` : "" + }`, + color: item.color ?? "var(--dracula-purple)", + delta, + }); + } + if (rows.length === 0) return null; + return ( + + ); }} - formatter={(value) => - typeof value === "number" - ? `${formatTooltipValue(value)}${unit ? ` ${unit}` : ""}` - : value - } - labelFormatter={(_label, payload) => - payload?.[0]?.payload?.timestamp - ? formatDateShort( - new Date(payload[0].payload.timestamp), - true, - ) - : "" - } /> {showContextDetails && ( )} + {/* v1.4.16 B1a — gradient-filled Area painted *under* + the line. Each type gets its own gradient + referenced by id. The Area's stroke is set to + transparent so only the fill paints; the line on + top of it carries the actual stroke. */} + {types.map((type) => ( + + ))} {types.map((type, i) => ( ))} {showMA && @@ -919,7 +1151,7 @@ export function HealthChart({ connectNulls /> ))} - +
diff --git a/src/lib/charts/__tests__/reduced-motion.test.ts b/src/lib/charts/__tests__/reduced-motion.test.ts new file mode 100644 index 000000000..889152bd1 --- /dev/null +++ b/src/lib/charts/__tests__/reduced-motion.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; + +import { prefersReducedMotion } from "../reduced-motion"; + +describe("prefersReducedMotion()", () => { + const originalWindow = globalThis.window; + + afterEach(() => { + if (originalWindow === undefined) { + // @ts-expect-error — restore SSR-default state. + delete globalThis.window; + } else { + globalThis.window = originalWindow; + } + }); + + it("returns false on the server (no window)", () => { + // @ts-expect-error — explicit SSR-environment shape. + delete globalThis.window; + expect(prefersReducedMotion()).toBe(false); + }); + + it("returns the matchMedia matches flag in the browser", () => { + const matchMedia = vi.fn(() => ({ + matches: true, + media: "(prefers-reduced-motion: reduce)", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + // @ts-expect-error — synthesise window for the test. + globalThis.window = { matchMedia }; + expect(prefersReducedMotion()).toBe(true); + expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); + }); + + it("returns false when matchMedia throws", () => { + // @ts-expect-error — synthesise window. + globalThis.window = { + matchMedia: () => { + throw new Error("legacy browser"); + }, + }; + expect(prefersReducedMotion()).toBe(false); + }); + + it("returns false when matchMedia is missing on the window", () => { + // @ts-expect-error — synthesise window without matchMedia. + globalThis.window = {}; + expect(prefersReducedMotion()).toBe(false); + }); +}); diff --git a/src/lib/charts/reduced-motion.ts b/src/lib/charts/reduced-motion.ts new file mode 100644 index 000000000..2bcc222d5 --- /dev/null +++ b/src/lib/charts/reduced-motion.ts @@ -0,0 +1,25 @@ +/** + * Tiny helper for chart wrappers that need to respect + * `prefers-reduced-motion` (v1.4.16 B1a). + * + * SSR-safe: returns `false` (i.e. animations *enabled*) whenever + * `window.matchMedia` is unavailable. The chart wrappers consume this + * during render and pass the negation to Recharts' `isAnimationActive` + * prop so first-render line-draw animations are suppressed for users + * who've requested reduced motion at the OS level. + * + * Pure / no-side-effects so the unit test runs under the default + * vitest "node" environment. + */ +export function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + try { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } catch { + // matchMedia can throw on legacy mobile browsers when the query + // string is unrecognised — fall back to "no, animate". + return false; + } +}