mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
f1868453af
commit
ff1e335c19
3 files changed
+279
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user