feat(charts): weight chart accepts vertical injection-day markers

Adds an optional `verticalMarkers` prop to `<HealthChart>` so the
v1.4.25 GLP-1 dashboard tile + the Insights /medikamente sub-page can
paint dashed vertical reference lines at each recorded injection day.
Each marker = `{ date: 'YYYY-MM-DD', label?, color? }`. Defaults to
the strip-tile green (`#50fa7b`) so a row of markers reads as
"injection cadence" at a glance; callers can override per marker.

Rendering:
  - Recharts `<ReferenceLine>` with `strokeDasharray='3 3'`,
    `strokeOpacity=0.55`, no label by default (inline labels would
    crowd a once-weekly cadence over a 90-day window).
  - A small filled `<ReferenceDot>` (r=3) at the y-axis minimum so
    the markers also read along the x-axis baseline.
  - Off-window markers silently drop via `ifOverflow='discard'`,
    matching the existing storyboard-annotation contract.

The marker math is factored into a pure helper
`resolveVerticalMarkerPositions` so the unit suite can pin behaviour
without mounting Recharts. Day-keys are matched by EXACT string
comparison against `chartData[i].date` (no ±7-day snap like the
storyboard annotations) — drawing a marker on a day the chart has no
data point reads as a rendering bug rather than as orientation.

Tests pin: empty input, undefined input, single in-window match,
multi-marker order, caller-supplied color/label overrides, off-window
drop, sparse weekly-bucket exact-day matching.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:31:41 +02:00
1 parent 70fe5a55c2
commit 1332342c59
2 files changed
+236

No files matched your search

@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { resolveVerticalMarkerPositions } from "../health-chart";
/**
* v1.4.25 W6 — GLP-1 injection-day vertical-marker pinning.
*
* The helper maps incoming `{date, label?, color?}[]` markers onto the
* bucketed chart series, returning the pointIndex of every match plus
* the marker's color/label so the chart paints exactly one
* `<ReferenceLine>` per matched bucket. Differs from the storyboard
* `resolveAnnotationPositions` helper in two ways: it does an EXACT
* day-key match (no ±7-day snap, no truncation), and it defaults the
* color to the strip-tile green when omitted.
*
* Pure helper — the suite pins behaviour without mounting Recharts.
*/
describe("resolveVerticalMarkerPositions", () => {
const dailyChartData = Array.from({ length: 10 }, (_, i) => ({
date: `2026-04-${String(15 + i).padStart(2, "0")}`,
}));
it("returns empty when markers is undefined", () => {
expect(resolveVerticalMarkerPositions(undefined, dailyChartData)).toEqual(
[],
);
});
it("returns empty when markers is an empty array", () => {
expect(resolveVerticalMarkerPositions([], dailyChartData)).toEqual([]);
});
it("returns empty when chartData is undefined or empty", () => {
expect(
resolveVerticalMarkerPositions(
[{ date: "2026-04-15" }],
undefined,
),
).toEqual([]);
expect(resolveVerticalMarkerPositions([{ date: "2026-04-15" }], [])).toEqual(
[],
);
});
it("pins a single in-window marker to its exact day-key", () => {
const positions = resolveVerticalMarkerPositions(
[{ date: "2026-04-15" }],
dailyChartData,
);
expect(positions).toHaveLength(1);
expect(positions[0].pointIndex).toBe(0);
expect(positions[0].color).toBe("#50fa7b");
expect(positions[0].label).toBeUndefined();
});
it("pins multiple markers in input order with the correct pointIndex", () => {
const positions = resolveVerticalMarkerPositions(
[
{ date: "2026-04-15" },
{ date: "2026-04-22" },
{ date: "2026-04-19" },
],
dailyChartData,
);
expect(positions.map((p) => p.pointIndex)).toEqual([0, 7, 4]);
});
it("respects caller-supplied color + label overrides", () => {
const positions = resolveVerticalMarkerPositions(
[
{ date: "2026-04-17", label: "Mounjaro 7.5", color: "#ff79c6" },
{ date: "2026-04-19" },
],
dailyChartData,
);
expect(positions[0].color).toBe("#ff79c6");
expect(positions[0].label).toBe("Mounjaro 7.5");
// Default color preserved for the second marker.
expect(positions[1].color).toBe("#50fa7b");
expect(positions[1].label).toBeUndefined();
});
it("drops markers whose date does not match any visible day-key", () => {
const positions = resolveVerticalMarkerPositions(
[
{ date: "2026-04-15" }, // exact match — index 0
{ date: "2026-03-01" }, // off-window
{ date: "2026-05-15" }, // off-window
{ date: "2026-04-24" }, // off-window (visible ends at 2026-04-24? series is 15..24)
],
dailyChartData,
);
// Series is 2026-04-15 .. 2026-04-24. 2026-04-24 is the last entry.
expect(positions.map((p) => p.pointIndex)).toEqual([0, 9]);
});
it("handles a sparse weekly-bucket series with exact-day matching only", () => {
// Weekly buckets — only the date keys that fall on a bucket day land.
const weeklyChartData = ["2026-04-15", "2026-04-22", "2026-04-29"].map(
(date) => ({ date }),
);
const positions = resolveVerticalMarkerPositions(
[
{ date: "2026-04-22" }, // exact bucket day → matches
{ date: "2026-04-23" }, // between buckets → drops (no ±7 snap)
],
weeklyChartData,
);
expect(positions).toHaveLength(1);
expect(positions[0].pointIndex).toBe(1);
});
});
+124
View File
@@ -13,6 +13,7 @@ import {
Legend,
ReferenceLine,
ReferenceArea,
ReferenceDot,
} from "recharts";
import { Loader2 } from "lucide-react";
import { useState, useMemo } from "react";
@@ -138,6 +139,28 @@ interface HealthChartProps {
* that needs the same behaviour).
*/
annotations?: Array<{ date: string; label: string; color: string }>;
/**
* v1.4.25 W6 — vertical injection-day markers.
*
* Each entry pins a thin dashed vertical reference line on the chart
* at the data point whose Berlin/user-tz day-key matches `date`
* (`YYYY-MM-DD`). Designed for the GLP-1 dashboard tile + the
* Insights /medikamente sub-page so users can see "I injected on
* these days; here's how my weight responded". Differs from
* `annotations` in three ways:
* 1. No text label by default — these markers represent events
* the user already saw the chart-line shape for; an inline
* label per marker would crowd the canvas.
* 2. A small filled dot sits at the x-axis intersection (mood-
* chart style emoji-on-axis pattern repurposed) so a row of
* green dots reads as "injection cadence" at a glance.
* 3. Default green (`#50fa7b`) to match the strip-tile palette
* for active medications; callers can override per marker.
*
* Off-window markers silently drop via `ifOverflow="discard"`,
* matching the existing annotations contract.
*/
verticalMarkers?: Array<{ date: string; label?: string; color?: string }>;
/**
* v1.4.25 W7b — per-user display timezone. When passed (mount sites
* thread `useAuth().user?.timezone`), x-axis tick labels and the
@@ -291,6 +314,55 @@ export interface ResolvedAnnotation {
truncatedLabel: string;
}
/**
* v1.4.25 W6 — pure helper for the GLP-1 injection-day vertical
* markers. Exported so the unit suite can pin behaviour without
* spinning Recharts. The shape mirrors `resolveAnnotationPositions`
* minus the truncated-label slot.
*
* `chartData[i].date` is the `YYYY-MM-DD` day-key the chart already
* computed when bucketing measurements; we match by exact key so a
* marker only lands on a day the chart actually has a data point for.
* This is intentional — drawing a vertical line through a gap in the
* x-axis paints a hanging line with no data context, which reads as a
* rendering bug not as orientation.
*
* Off-window markers silently drop (the chart's `<ReferenceLine>` also
* uses `ifOverflow="discard"` as a belt-and-braces guard).
*/
export interface ResolvedVerticalMarker {
pointIndex: number;
color: string;
label: string | undefined;
}
export function resolveVerticalMarkerPositions(
markers:
| Array<{ date: string; label?: string; color?: string }>
| undefined,
chartData: Array<{ date: string }> | undefined,
): ResolvedVerticalMarker[] {
if (!markers || !chartData || chartData.length === 0) return [];
const indexByDate = new Map<string, number>();
for (const [i, point] of chartData.entries()) {
// Last-write-wins — multiple bucket-aggregated points should never
// share the same day key, but defensively keep the latest if they
// do.
indexByDate.set(point.date, i);
}
const out: ResolvedVerticalMarker[] = [];
for (const marker of markers) {
const idx = indexByDate.get(marker.date);
if (idx === undefined) continue;
out.push({
pointIndex: idx,
color: marker.color ?? "#50fa7b",
label: marker.label,
});
}
return out;
}
export function resolveAnnotationPositions(
annotations:
| Array<{ date: string; label: string; color: string }>
@@ -383,6 +455,7 @@ export function HealthChart({
compareBaseline = "none",
chartKey,
annotations,
verticalMarkers,
userTimezone = "Europe/Berlin",
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
@@ -954,6 +1027,16 @@ export function HealthChart({
chartData,
);
// v1.4.25 W6 — GLP-1 injection-day vertical markers. Pure-helper
// pattern lets the chart-tests pin the marker resolution without
// mounting Recharts. Off-window markers silently drop here so the
// <ReferenceLine> below never paints a line for an out-of-range
// day.
const verticalMarkerPositions = resolveVerticalMarkerPositions(
verticalMarkers,
chartData,
);
const showContextDetails = showMA || showTrend || showBands;
const animationsEnabled = !prefersReducedMotion();
@@ -1238,6 +1321,47 @@ export function HealthChart({
}}
/>
))}
{/* v1.4.25 W6 — GLP-1 injection-day vertical markers.
Thin dashed line + a small filled dot at the x-axis
intersection so a row of markers reads as "injection
cadence" without crowding the canvas. Optional label
rendered only when the caller passed one; the
dashboard tile leaves it undefined (the date is
redundant — the chart's x-axis already shows it). */}
{verticalMarkerPositions.map((marker, i) => (
<ReferenceLine
key={`vmarker-${i}-${marker.pointIndex}`}
x={marker.pointIndex}
stroke={marker.color}
strokeDasharray="3 3"
strokeWidth={1.1}
strokeOpacity={0.55}
ifOverflow="discard"
label={
marker.label
? {
value: marker.label,
position: "insideTopRight",
fill: marker.color,
fontSize: 10,
fontWeight: 500,
}
: undefined
}
/>
))}
{yDomain &&
verticalMarkerPositions.map((marker, i) => (
<ReferenceDot
key={`vmarker-dot-${i}-${marker.pointIndex}`}
x={marker.pointIndex}
y={yDomain[0]}
r={3}
fill={marker.color}
stroke="none"
ifOverflow="discard"
/>
))}
{/* v1.4.18 — personal-baseline reference line is now
opt-in via the Trend toggle. the maintainer rejected the
always-on dashed mean line; it now only paints when