mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user