feat(charts): storyboard annotations on HealthChart

Add an additive annotations[] prop to <HealthChart> that paints a
vertical Recharts <ReferenceLine> 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 17:17:45 +02:00
1 parent f1868453af
commit ff1e335c19
3 files changed
+279

No files matched your search

+28
View File
@@ -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 <HealthChart> 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<string, string> = {
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 (
<div className="space-y-8">
<HeroStrip
@@ -997,6 +1024,7 @@ export default function InsightsPage() {
yAxisUnit="Hg"
targetZones={bpTargetZones}
compareBaseline={compareBaseline}
annotations={bpStoryboardAnnotations}
/>
<div className="grid gap-4 xl:grid-cols-2">
@@ -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);
});
});
+102
View File
@@ -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 `<ReferenceLine>` 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 `<sm` rendering.
*/
export interface ResolvedAnnotation {
pointIndex: number;
label: string;
color: string;
truncatedLabel: string;
}
export function resolveAnnotationPositions(
annotations: Array<{ date: string; label: string; color: string }> | 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` (24 chars) and the full
label on >=sm. Annotations off-window silently
drop via `ifOverflow="discard"`. */}
{annotationPositions.map((annotation, i) => (
<ReferenceLine
key={`storyboard-${i}-${annotation.pointIndex}`}
x={annotation.pointIndex}
stroke={annotation.color}
strokeDasharray="4 4"
strokeWidth={1.25}
strokeOpacity={0.7}
ifOverflow="discard"
label={{
value:
viewportWidth < 640
? annotation.truncatedLabel
: annotation.label,
position: "insideTopRight",
fill: annotation.color,
fontSize: 10,
fontWeight: 500,
}}
/>
))}
{/* 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