diff --git a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts new file mode 100644 index 000000000..2d4605610 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts @@ -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 + * `` 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); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 9b901d7b7..afe78c52b 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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 `` 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(); + 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 + // 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) => ( + + ))} + {yDomain && + verticalMarkerPositions.map((marker, i) => ( + + ))} {/* 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