feat(charts): BP/weight/pulse polish (gradient, baseline, rich tooltip)

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} <linearGradient> 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:26:13 +02:00
1 parent fdac9e2517
commit 74c2eb8bff
4 files changed
+489 -22

No files matched your search

@@ -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 `<defs>` 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("<HealthChart> v1.4.16 B1a polish", () => {
it("emits a sibling-SVG <defs> 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
// <defs>. To keep the gradient SSR-discoverable (and so visual
// regressions can target it without a browser), the wrapper emits
// an external `<svg width=0 height=0>` 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(
<I18nProvider initialLocale="en">
<HealthChart
types={["BLOOD_PRESSURE_SYS"]}
title="Blood Pressure"
unit="mmHg"
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<HealthChart
types={["BLOOD_PRESSURE_SYS"]}
title="Blood Pressure"
unit="mmHg"
/>
</I18nProvider>,
);
expect(html).toMatch(/id="chart-gradient-BLOOD_PRESSURE_SYS"/);
});
});
+254 -22
View File
@@ -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<string, number>();
const map = new Map<string, number>();
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 (
<div className="bg-card border-border rounded-xl border p-4 md:p-6">
@@ -724,7 +775,15 @@ export function HealthChart({
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
) : !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.
<ChartEmptyState
title={t("charts.emptyStateTitle")}
description={t("charts.emptyStateDescription")}
/>
) : (
<div className="relative h-[240px]">
{visibleBands.length > 0 ? (
<div
@@ -754,12 +813,73 @@ export function HealthChart({
</div>
) : null}
{/* v1.4.16 B1a — sibling SVG <defs> block.
ResponsiveContainer is empty during SSR so a gradient
defined inside its child <svg> isn't discoverable in
static-rendered markup. Emitting the gradient in a
standalone hidden <svg> next to the chart keeps it
available document-wide; Recharts' <Area fill="url(#id)">
looks the gradient up by id at paint time and finds it
regardless of which SVG it lives in. */}
<svg
width={0}
height={0}
aria-hidden="true"
style={{ position: "absolute", pointerEvents: "none" }}
data-slot="chart-gradient-defs"
>
{types.map((type, i) => (
<ChartLinearGradient
key={`grad-${type}`}
id={`chart-gradient-${type}`}
colorVar={
// Best-effort mapping from the dot colour back to a
// Dracula token — the chart already accepts an
// arbitrary `colors` array (defaults to purple/pink/
// cyan), so we mirror that here.
colors[i % colors.length]?.startsWith("var(")
? colors[i % colors.length]
.replace("var(", "")
.replace(")", "")
: "--dracula-purple"
}
/>
))}
</svg>
<div className="relative z-10 h-full touch-pan-y">
<ResponsiveContainer width="100%" height="100%">
<LineChart
<ComposedChart
data={chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
>
{/* 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. */}
<defs>
{types.map((type, i) => (
<linearGradient
key={`inline-grad-${type}`}
id={`chart-gradient-inline-${type}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={colors[i % colors.length]}
stopOpacity={0.35}
/>
<stop
offset="100%"
stopColor={colors[i % colors.length]}
stopOpacity={0}
/>
</linearGradient>
))}
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
@@ -846,27 +966,116 @@ export function HealthChart({
}
/>
))}
{/* 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 (
<ReferenceLine
key={`baseline-${type}`}
y={baseline}
stroke={colors[i % colors.length]}
strokeDasharray="2 4"
strokeOpacity={0.4}
strokeWidth={1}
ifOverflow="discard"
label={
i === 0
? {
value: t("charts.personalBaseline"),
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
opacity: 0.7,
}
: undefined
}
/>
);
})}
<Tooltip
filterNull={false}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.875rem",
cursor={{
stroke: "var(--muted-foreground)",
strokeOpacity: 0.3,
strokeDasharray: "3 3",
}}
content={(props) => {
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 (
<RichChartTooltip
active
label={dateLabel}
rows={rows}
/>
);
}}
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 && (
<Legend
@@ -877,6 +1086,26 @@ export function HealthChart({
}}
/>
)}
{/* 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) => (
<Area
key={`area-${type}`}
type="monotone"
dataKey={type}
stroke="transparent"
fill={chartGradientFill(`chart-gradient-inline-${type}`)}
fillOpacity={1}
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
connectNulls
legendType="none"
/>
))}
{types.map((type, i) => (
<Line
key={type}
@@ -888,6 +1117,9 @@ export function HealthChart({
dot={{ r: 3, fill: colors[i % colors.length] }}
activeDot={{ r: 5 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
))}
{showMA &&
@@ -919,7 +1151,7 @@ export function HealthChart({
connectNulls
/>
))}
</LineChart>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
@@ -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);
});
});
+25
View File
@@ -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;
}
}