From ff1e335c19c095d5d7e24f98efd32e9c083c0494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 17:17:45 +0200 Subject: [PATCH] feat(charts): storyboard annotations on HealthChart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an additive annotations[] prop to that paints a vertical Recharts per AI-narrated event on the underlying timeline. The line + tooltip + comparison overlay remain untouched — annotations sit between the cartesian grid and the data lines so they read as orientation, never as data. The /insights page wires advisor.payload.insights.storyboardAnnotations into the 90-day BP chart, mapping the four canonical categories to Dracula colours (medication=pink, event=cyan, milestone=green, warning=orange). Pure helper resolveAnnotationPositions() handles the date->pointIndex mapping with a 7-day snap window (so annotations don't stack on monthly buckets) and a 24-char truncation for the small-viewport label. The unit suite pins exact-day pinning, near-day snapping, out-of-window dropping, and label truncation. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/insights/page.tsx | 28 ++++ .../health-chart-annotations.test.ts | 149 ++++++++++++++++++ src/components/charts/health-chart.tsx | 102 ++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 src/components/charts/__tests__/health-chart-annotations.test.ts diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index b701a3992..ccdbd2739 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -890,6 +890,33 @@ export default function InsightsPage() { } : undefined; + // v1.4.20 phase B4 — storyboard annotations for the 90-day BP chart. + // The advisor payload may carry up to 20 entries; we transform them + // into the {date, label, color} shape consumes. The + // colour map mirrors the four canonical categories. Cached payloads + // from before PROMPT_VERSION 4.20.2 simply produce an empty array. + const STORYBOARD_COLOR_BY_CATEGORY: Record = { + medication: "var(--dracula-pink)", + event: "var(--dracula-cyan)", + milestone: "var(--dracula-green)", + warning: "var(--dracula-orange)", + }; + const rawStoryboard = ( + advisor.payload?.insights as + | { storyboardAnnotations?: Array<{ + date: string; + label: string; + category: string; + }> } + | undefined + )?.storyboardAnnotations; + const bpStoryboardAnnotations = (rawStoryboard ?? []).map((entry) => ({ + date: entry.date, + label: entry.label, + color: + STORYBOARD_COLOR_BY_CATEGORY[entry.category] ?? "var(--dracula-purple)", + })); + return (
diff --git a/src/components/charts/__tests__/health-chart-annotations.test.ts b/src/components/charts/__tests__/health-chart-annotations.test.ts new file mode 100644 index 000000000..e2db9a07c --- /dev/null +++ b/src/components/charts/__tests__/health-chart-annotations.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { resolveAnnotationPositions } from "../health-chart"; + +/** + * v1.4.20 phase B4 — storyboard-annotation pinning. + * + * The chart maps incoming `{date, label, color}[]` annotations onto + * the bucketed series so each annotation paints a vertical reference + * line at the closest visible point. Annotations that fall outside the + * visible window (or > 7 days from any bucket) silently drop. + * + * Helper is pure; the suite pins: + * - exact-match dates land on their bucket + * - close-by dates snap to the nearest bucket within ±7 days + * - far-off dates drop entirely + * - labels longer than 24 chars truncate on the small-viewport line + * - empty / malformed input never throws + */ + +const DAY_MS = 24 * 60 * 60 * 1000; + +function makeSeries(start: number, count: number): Array<{ timestamp: number }> { + return Array.from({ length: count }, (_, i) => ({ + timestamp: start + i * DAY_MS, + })); +} + +describe("resolveAnnotationPositions", () => { + // 30 daily points starting 2026-04-15 (UTC noon). + const startMs = Date.UTC(2026, 3, 15, 12, 0, 0); + const dailySeries = makeSeries(startMs, 30); + + it("returns empty when annotations is undefined", () => { + expect(resolveAnnotationPositions(undefined, dailySeries)).toEqual([]); + }); + + it("returns empty when chartData is empty", () => { + expect( + resolveAnnotationPositions( + [{ date: "2026-04-15", label: "x", color: "#fff" }], + [], + ), + ).toEqual([]); + }); + + it("returns empty when chartData is undefined", () => { + expect( + resolveAnnotationPositions( + [{ date: "2026-04-15", label: "x", color: "#fff" }], + undefined, + ), + ).toEqual([]); + }); + + it("pins an exact-day annotation to its visible bucket", () => { + const positions = resolveAnnotationPositions( + [ + { date: "2026-04-15", label: "Started Ramipril", color: "#ff79c6" }, + ], + dailySeries, + ); + expect(positions).toHaveLength(1); + expect(positions[0].pointIndex).toBe(0); + expect(positions[0].label).toBe("Started Ramipril"); + expect(positions[0].color).toBe("#ff79c6"); + }); + + it("snaps to the nearest visible bucket within 7 days", () => { + // 2026-04-19 sits between visible day 4 (2026-04-19) and 5; exact match. + const positions = resolveAnnotationPositions( + [{ date: "2026-04-19", label: "Sustained dip", color: "#8be9fd" }], + dailySeries, + ); + expect(positions).toHaveLength(1); + expect(positions[0].pointIndex).toBe(4); + }); + + it("drops annotations more than 7 days off any visible bucket", () => { + // 2026-06-15 is 31 days past the visible window's end (2026-05-14). + const positions = resolveAnnotationPositions( + [{ date: "2026-06-15", label: "Out of range", color: "#fff" }], + dailySeries, + ); + expect(positions).toHaveLength(0); + }); + + it("drops malformed dates silently", () => { + const positions = resolveAnnotationPositions( + [ + { date: "not-a-date", label: "x", color: "#fff" }, + { date: "2026-04-15", label: "ok", color: "#0f0" }, + ], + dailySeries, + ); + expect(positions).toHaveLength(1); + expect(positions[0].label).toBe("ok"); + }); + + it("truncates labels > 24 chars on the small-viewport line", () => { + const longLabel = "Started new medication regimen with extra notes"; + expect(longLabel.length).toBeGreaterThan(24); + const positions = resolveAnnotationPositions( + [{ date: "2026-04-15", label: longLabel, color: "#fff" }], + dailySeries, + ); + expect(positions).toHaveLength(1); + expect(positions[0].truncatedLabel).toHaveLength(24); + expect(positions[0].truncatedLabel.endsWith("…")).toBe(true); + // Full label preserved on the wide-viewport line. + expect(positions[0].label).toBe(longLabel); + }); + + it("does not truncate labels at exactly 24 chars", () => { + const exact24 = "x".repeat(24); + const positions = resolveAnnotationPositions( + [{ date: "2026-04-15", label: exact24, color: "#fff" }], + dailySeries, + ); + expect(positions[0].truncatedLabel).toBe(exact24); + expect(positions[0].truncatedLabel).not.toContain("…"); + }); + + it("preserves multiple in-window annotations in input order", () => { + const positions = resolveAnnotationPositions( + [ + { date: "2026-04-15", label: "A", color: "#fff" }, + { date: "2026-04-22", label: "B", color: "#fff" }, + { date: "2026-05-01", label: "C", color: "#fff" }, + ], + dailySeries, + ); + expect(positions.map((p) => p.label)).toEqual(["A", "B", "C"]); + expect(positions.map((p) => p.pointIndex)).toEqual([0, 7, 16]); + }); + + it("handles a sparse weekly-bucket series", () => { + // 12 weekly buckets spanning ~84 days from 2026-04-15. + const weeklySeries = makeSeries(startMs, 12).map((p, i) => ({ + timestamp: startMs + i * 7 * DAY_MS, + })); + // 2026-04-22 is 7 days from start → bucket 1. + const positions = resolveAnnotationPositions( + [{ date: "2026-04-22", label: "Hit", color: "#fff" }], + weeklySeries, + ); + 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 757c96df8..13038f4c9 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -124,6 +124,20 @@ interface HealthChartProps { * its toggles default to OFF (clean line). */ chartKey?: ChartOverlayKey; + /** + * v1.4.20 phase B4 — additive storyboard annotations. + * + * Each entry pins a vertical reference line + label to the data point + * whose Berlin-day key matches `date` (`YYYY-MM-DD`). The chart line + * + tooltip + tooltip-deltas remain untouched — Recharts' source-order + * layering paints `` between the cartesian grid and + * the data lines so the annotation reads as orientation, not data. + * + * Annotations whose date falls outside the visible window silently + * drop (we already use `ifOverflow="discard"` on every reference line + * that needs the same behaviour). + */ + annotations?: Array<{ date: string; label: string; color: string }>; } interface ChartDataPoint { @@ -247,6 +261,56 @@ export function computePersonalBaseline( return sorted[mid]; } +/** + * v1.4.20 phase B4 — pure helper that maps storyboard annotations to + * visible chart pointIndex positions. Exported so the unit suite can + * pin behaviour without rendering Recharts. Returns the subset of + * annotations that fall on (or within 7 days of) a visible bucketed + * point, with a 24-char truncation precomputed for ` | undefined, + chartData: Array<{ timestamp: number }> | undefined, +): ResolvedAnnotation[] { + if (!annotations || !chartData || chartData.length === 0) return []; + const out: ResolvedAnnotation[] = []; + for (const annotation of annotations) { + const targetMs = Date.parse(`${annotation.date}T12:00:00Z`); + if (!Number.isFinite(targetMs)) continue; + let bestIdx = -1; + let bestDelta = Number.POSITIVE_INFINITY; + for (const [i, point] of chartData.entries()) { + const delta = Math.abs(point.timestamp - targetMs); + if (delta < bestDelta) { + bestDelta = delta; + bestIdx = i; + } + } + // Drop annotations more than 7 days off any visible point — long + // bucket windows (monthly aggregation) would otherwise snap multi- + // month events to the closest visible bucket and stack labels. + if (bestIdx < 0 || bestDelta > 7 * 24 * 60 * 60 * 1000) continue; + const truncatedLabel = + annotation.label.length > 24 + ? `${annotation.label.slice(0, 23)}…` + : annotation.label; + out.push({ + pointIndex: bestIdx, + label: annotation.label, + color: annotation.color, + truncatedLabel, + }); + } + return out; +} + function buildTrendSeriesByTime(data: Array<{ date: Date; value: number }>): { points: Array<{ date: Date; value: number }>; slopePerDay: number; @@ -300,6 +364,7 @@ export function HealthChart({ windowOverride, compareBaseline = "none", chartKey, + annotations, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); @@ -832,6 +897,16 @@ export function HealthChart({ if (!isLoading && !data?.length) return null; const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1); + + // v1.4.20 phase B4 — derive visible annotations via the pure helper + // (`resolveAnnotationPositions`). Drops anything outside the + // visible window so the chart line + tooltip stay untouched. + const annotationPositions = resolveAnnotationPositions( + annotations, + chartData, + ); + + const showContextDetails = showMA || showTrend || showBands; const animationsEnabled = !prefersReducedMotion(); @@ -1090,6 +1165,33 @@ export function HealthChart({ } /> ))} + {/* v1.4.20 phase B4 — storyboard annotations. + Vertical reference lines pinned to a specific + visible pointIndex; the label sits above with a + short truncation on `=sm. Annotations off-window silently + drop via `ifOverflow="discard"`. */} + {annotationPositions.map((annotation, i) => ( + + ))} {/* v1.4.18 — personal-baseline reference line is now opt-in via the Trend toggle. Marc rejected the always-on dashed mean line; it now only paints when