refactor(charts): split HealthChart into its read, view, layers and header

HealthChart was a 1,929-line component. Its parts move to
components/charts/health-chart/:

- series-math: series names, day keys, moving average, personal
  baseline, annotation and marker placement (pure)
- use-health-chart-series: the fetch window, the batched-slice decision,
  the daily read and the data-ready latch
- use-health-chart-view: the visible slice, bucketing, the average and
  trend lines, the prior period, the visible-range stats and the y domain
- use-health-chart-overlays: value bands, target zones, baselines, the
  trend read-out, the spoken summary and the table columns
- chart-header, band-overlay, chart-tooltip-content
- chart-layers: the reference layers and series as plain functions that
  return the Recharts elements, so the plot's child list is unchanged

health-chart.tsx keeps the component and its path (chart-runtime still
imports it from there) and shrinks to 488 lines. Output is unchanged.
The guards pinned to the file follow the code into the folder, and the
baseline-gating check now calls the layer instead of matching source.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 11:25:16 +02:00
1 parent a812a902dd
commit 5075536f7f
24 files changed
+2365 -2224

No files matched your search

+3
View File
@@ -214,6 +214,9 @@ const eslintConfig = defineConfig([
"src/components/charts/chart-runtime.ts",
"src/components/admin/host-metrics-chart.tsx",
"src/components/charts/health-chart.tsx",
// HealthChart's plot layers, imported by health-chart.tsx alone, so
// they sit inside the same chart-runtime graph.
"src/components/charts/health-chart/chart-layers.tsx",
"src/components/charts/medication-compliance-chart.tsx",
"src/components/charts/mood-chart.tsx",
"src/components/charts/nutrient-daily-bar-chart.tsx",
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 121,
"total": 142301,
"count": 120,
"total": 139784,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
@@ -13,7 +13,6 @@
"app/api/workouts/batch/route.ts": 958,
"app/medications/page-client.tsx": 839,
"components/admin/backups-section.tsx": 1462,
"components/charts/health-chart.tsx": 2517,
"components/charts/mood-chart.tsx": 1166,
"components/cycle/log-day-sheet.tsx": 1639,
"components/documents/document-detail-sheet.tsx": 1304,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 408,
"total": 144450,
"total": 143009,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -97,7 +97,7 @@
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
"components/charts/health-chart.tsx :: HealthChart": 1929,
"components/charts/health-chart.tsx :: HealthChart": 488,
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
"components/charts/mood-chart.tsx :: MoodChart": 896,
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2953,
"total": 10418,
"count": 2952,
"total": 10336,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -767,7 +767,6 @@
"components/charts/chart-tooltip.tsx": 1,
"components/charts/compliance-heatmap.tsx": 6,
"components/charts/health-chart-dynamic.tsx": 3,
"components/charts/health-chart.tsx": 82,
"components/charts/medication-compliance-chart.tsx": 21,
"components/charts/mini-window.ts": 1,
"components/charts/mood-chart.tsx": 33,
@@ -256,7 +256,7 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
why: "The same key shape for `today`, compared against those buckets. A comparison, not a label.",
},
{
file: "src/components/charts/health-chart.tsx",
file: "src/components/charts/health-chart/series-math.ts",
why: "`makeDayKeyFormatter` — the per-row bucketing key. Its fields are read back through `formatToParts` and reassembled as `YYYY-MM-DD`.",
},
{
@@ -139,7 +139,7 @@ const TS_READERS: Record<string, Entry> = {
exempt:
"means several readings of a type the day route sends one by one (weight, blood pressure, glucose); pulse arrives as one server-side day value (`dayValueShape` in lib/day/values.ts)",
},
"components/charts/health-chart.tsx": {
"components/charts/health-chart/series-math.ts": {
exempt:
"a rolling average over the points the chart draws: display smoothing, not a stated figure",
},
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { resolveAnnotationPositions } from "../health-chart";
import { resolveAnnotationPositions } from "../health-chart/series-math";
/**
* v1.4.20 — storyboard-annotation pinning.
@@ -1,3 +1,4 @@
import { baselineLayers } from "../health-chart/chart-layers";
import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -54,28 +55,22 @@ vi.mock("@/hooks/use-auth", () => ({
}));
describe("HealthChart default overlay state", () => {
it("source: personal baseline is gated behind a toggle (no unconditional ReferenceLine)", () => {
// The B1a wrapper rendered the baseline `<ReferenceLine>` for every
// type unconditionally. Make sure the source no longer paints it
// outside an opt-in branch — we look for the tell-tale pattern of
// `types.map((type, i) => { ... <ReferenceLine ... y={baseline}`
// appearing without a `showTrend` / `showBaseline` guard above it.
const moduleUrl = new URL("../health-chart.tsx", import.meta.url);
const src = readFileSync(fileURLToPath(moduleUrl), "utf8");
// Personal-baseline rendering must be guarded — the JSX block that
// emits the dashed baseline must be wrapped in a conditional
// expression starting with a state flag.
const baselineSection = src.match(
/\{types\.map\(\(type, i\) => \{[\s\S]*?const baseline = personalBaselines\.get\(type\);[\s\S]*?\}\)\}/,
it("paints the personal baseline only behind the trend toggle", () => {
// An always-on dashed mean line was rejected: the baseline layer paints
// nothing with the trend overlay off, and one line per type with it on.
const baselines = new Map([["WEIGHT", 80]]);
const t = (key: string) => key;
expect(
baselineLayers(false, ["WEIGHT"], ["var(--chart-1)"], baselines, t),
).toBe(false);
const on = baselineLayers(
true,
["WEIGHT"],
["var(--chart-1)"],
baselines,
t,
);
if (baselineSection) {
// If the section still exists, it's not gated. Allow a guard
// wrapper test instead — match `{showSomething &&`.
throw new Error(
"Personal baseline ReferenceLine is rendered unconditionally — must be gated behind an opt-in toggle.",
);
}
expect(Array.isArray(on) && on.filter(Boolean)).toHaveLength(1);
});
it("does NOT paint the personal-baseline label by default in MoodChart source", () => {
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { computePersonalBaseline } from "../health-chart";
import { computePersonalBaseline } from "../health-chart/series-math";
/**
* v1.4.16 — HealthChart Apple-Health-style polish contract.
@@ -11,7 +11,8 @@ import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const HEALTH_CHART_PATH = resolve(__dirname, "..", "health-chart.tsx");
import { readSource, readSourceFolder } from "@/__tests__/helpers/source-files";
const WEIGHT_PAGE_PATH = resolve(
__dirname,
"../../../app/insights/weight/page.tsx",
@@ -24,7 +25,13 @@ const EN_BUNDLE_PATH = resolve(__dirname, "../../../../messages/en.json");
describe("v1.16.8 — chart-card target-adjust link removed", () => {
it("ships no targetSettingsHref prop or header link in the chart", () => {
const source = readFileSync(HEALTH_CHART_PATH, "utf8");
// The component and every part it is built from (header included).
const source = [
readSource("src/components/charts/health-chart.tsx", "HealthChart"),
...readSourceFolder("src/components/charts/health-chart", {
floor: 9,
}).map(([, text]) => text),
].join("\n");
expect(source).not.toContain("targetSettingsHref");
expect(source).not.toContain("chart-target-settings-link");
expect(source).not.toContain("charts.adjustTargets");
@@ -113,7 +113,7 @@ describe("measurementLabelKey", () => {
it("matches the canonical label map for every type outside the chart's own labels", async () => {
const { MEASUREMENT_TYPE_LABEL_KEYS } =
await import("@/lib/measurements/type-label-keys");
const { measurementLabelKey } = await import("../health-chart");
const { measurementLabelKey } = await import("../health-chart/series-math");
// The four types whose key breaks the convention carry a chart label.
const chartLabelled = new Set([
"WEIGHT",
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { resolveVerticalMarkerPositions } from "../health-chart";
import { resolveVerticalMarkerPositions } from "../health-chart/series-math";
/**
* v1.4.25 — GLP-1 injection-day vertical-marker pinning.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,43 @@
import type { VisibleValueBand } from "./types";
/**
* The value bands, painted as HTML behind the plot. The overlay is pinned to
* the plot's own edges (the chart margin of 8 px plus the y axis on the left,
* the 8 px margin on the right, the plot's top and bottom insets), so a band
* tracks the line exactly.
*/
export function ChartBandOverlay({
bands,
yAxisWidth,
}: {
bands: VisibleValueBand[];
yAxisWidth: number;
}) {
return (
<div
className="pointer-events-none absolute"
style={{
left: `${8 + yAxisWidth}px`,
right: "8px",
top: "10px",
bottom: "32px",
zIndex: 0,
}}
>
{bands.map((band) => (
<div
key={band.key}
className="absolute right-0 left-0"
style={{
top: `${band.topPct}%`,
height: `${band.heightPct}%`,
backgroundColor: band.color,
opacity: band.opacity,
borderTop: `1px solid ${band.color}`,
borderBottom: `1px solid ${band.color}`,
}}
/>
))}
</div>
);
}
@@ -0,0 +1,114 @@
"use client";
import type { ComponentType } from "react";
import { TileHeader } from "@/components/insights/tile-header";
import type { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import type {
ChartRangePoints,
ComparisonBaseline,
} from "@/lib/dashboard-layout";
import { useTranslations } from "@/lib/i18n/context";
import { ChartOverlayControls } from "../chart-overlay-controls";
import { ChartRangeTabs } from "../chart-range-tabs";
/**
* The chart card's header. Mobile first: the title and its chips on one row,
* the range tabs and the overlay cog right-aligned on a second; the bucket
* and comparison chips hide below `sm` (the range tabs already say which
* window is shown, and the cog still reaches the overlays). From `sm` up
* everything sits on one row.
*/
export function HealthChartHeader({
title,
titleIcon,
bucketCaption,
compareBaseline,
hasComparisonData,
rangePoints,
onRangeChange,
overlayPrefs,
showOverlayControls,
}: {
title: string;
titleIcon?: ComponentType<{ className?: string }>;
bucketCaption: string | null;
compareBaseline: ComparisonBaseline;
hasComparisonData: boolean;
rangePoints: number;
onRangeChange: (days: ChartRangePoints) => void;
overlayPrefs: ReturnType<typeof useChartOverlayPrefs>;
showOverlayControls: boolean;
}) {
const { t } = useTranslations();
return (
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
{/* With a `titleIcon` (every metric page) the card leads with the
canonical `<TileHeader>`; the dashboard and ad-hoc mounts keep the
compact heading. That heading is an `<h2>`: the card is a sibling
of the other cards in its grid, which name themselves at the
second level, and one level lower skipped a level under the page
`<h1>`. */}
{titleIcon ? (
<TileHeader icon={titleIcon} title={title} />
) : (
<h2 className="text-sm font-semibold">{title}</h2>
)}
{bucketCaption && (
<span
data-slot="chart-bucket-caption"
className="bg-muted/40 text-muted-foreground text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex"
>
{t(bucketCaption)}
</span>
)}
{/* The comparison caption sits beside the bucket chip: "which window"
and "which overlay" in one glance. The unavailable fallback is
muted so it does not read as an error. */}
{compareBaseline !== "none" && hasComparisonData && (
<span
className="text-dose-accent bg-dose-accent/10 text-2xs hidden rounded-md border border-current/30 px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)}
</span>
)}
{compareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
compareBaseline === "lastMonth"
? "comparison.unavailable.lastMonth"
: "comparison.unavailable.lastYear",
)}
</span>
)}
{/* The page-level target control owns "adjust targets"; the chart
card stays a read surface. */}
</div>
<div
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
data-slot="chart-header-controls"
>
<ChartRangeTabs value={rangePoints} onChange={onRangeChange} />
{/* Only a chart bound to a persistent key offers the overlay
controls; ad-hoc charts keep the clean line. */}
{showOverlayControls ? (
<ChartOverlayControls
prefs={overlayPrefs.prefs}
onChange={overlayPrefs.setPrefs}
hasComparisonData={hasComparisonData}
/>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,352 @@
"use client";
/**
* The reference layers and series inside `HealthChart`'s plot.
*
* Each is a plain function that returns the Recharts elements (never a
* component wrapping them), so the plot's child list is exactly the list of
* elements it always was: Recharts lays its layers out in source order, and a
* wrapper component would change what it sees.
*/
import {
Area,
Line,
ReferenceArea,
ReferenceDot,
ReferenceLine,
} from "recharts";
import { OPEN_DAY_LINE } from "@/components/day/chart-day-links";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import type { ResolvedAnnotation, ResolvedVerticalMarker } from "./series-math";
import type { ChartDataPoint, VisibleTargetZone } from "./types";
type Translate = (key: string) => string;
/** Target zones: the shaded area, then the dashed low and high edges. */
export function targetZoneLayers(
zones: VisibleTargetZone[],
maxPointIndex: number,
) {
return [
zones.map((zone) => (
<ReferenceArea
key={`${zone.key}-fill`}
x1={0}
x2={maxPointIndex}
y1={zone.low}
y2={zone.high}
fill={zone.color}
fillOpacity={zone.opacity}
strokeOpacity={0}
ifOverflow="discard"
/>
)),
zones.map((zone) => (
<ReferenceLine
key={`${zone.key}-low`}
y={zone.low}
stroke={zone.color}
strokeDasharray="6 4"
strokeWidth={1.75}
strokeOpacity={zone.lineOpacity}
/>
)),
zones.map((zone) => (
<ReferenceLine
key={`${zone.key}-high`}
y={zone.high}
stroke={zone.color}
strokeDasharray="6 4"
strokeWidth={1.75}
strokeOpacity={zone.lineOpacity}
label={
zone.label
? {
value: zone.label,
position: "right",
fill: zone.textColor ?? zone.color,
fontSize: 10,
}
: undefined
}
/>
)),
];
}
/**
* Storyboard annotations: a vertical line on the visible point with its label
* above, truncated to 24 characters below 640 px.
*/
export function annotationLayers(
annotations: ResolvedAnnotation[],
viewportWidth: number,
) {
return annotations.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,
}}
/>
));
}
/**
* Event markers: a thin dashed line and a dot where it meets the x axis, so a
* row of them reads as a cadence. A label only when the caller gave one.
*/
export function verticalMarkerLayers(
markers: ResolvedVerticalMarker[],
yDomain: [number, number] | undefined,
) {
return [
markers.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 &&
markers.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"
/>
)),
];
}
/**
* The open day: a dashed line through it and a ring on each of its values,
* so the point the day panel describes stays in sight beside it.
*/
export function openDayLayers(
openDayIndex: number | undefined,
openDayPoint: ChartDataPoint | undefined,
types: string[],
colors: string[],
) {
return [
openDayIndex !== undefined ? (
<ReferenceLine key="open-day" x={openDayIndex} {...OPEN_DAY_LINE} />
) : null,
openDayIndex !== undefined && openDayPoint
? types.map((type, i) => {
const v = openDayPoint[type];
if (typeof v !== "number") return null;
return (
<ReferenceDot
key={`open-day-${type}`}
x={openDayIndex}
y={v}
r={6.5}
fill="var(--card)"
stroke={colors[i % colors.length]}
strokeWidth={2.5}
ifOverflow="discard"
/>
);
})
: null,
];
}
/**
* The personal baseline per type, only while the trend overlay is shown (an
* always-on mean line was rejected). Only the first type carries the label,
* so a multi-type chart does not paint it twice.
*/
export function baselineLayers(
showTrend: boolean,
types: string[],
colors: string[],
personalBaselines: Map<string, number>,
t: Translate,
) {
return (
showTrend &&
types.map((type, i) => {
const baseline = personalBaselines.get(type);
if (baseline == null) return null;
return (
<ReferenceLine
key={`baseline-${type}`}
y={baseline}
stroke={colors[i % colors.length]}
strokeDasharray="2 4"
strokeOpacity={0.4}
strokeWidth={1}
ifOverflow="discard"
label={
i === 0
? {
value: t("charts.personalBaseline"),
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
opacity: 0.7,
}
: undefined
}
/>
);
})
);
}
/**
* The series, in paint order: the min–max range band behind the mean line
* (not in mini mode, and only with the target-range toggle, which controls
* both bands), the lines, the dashed 7-day average, the trend line, and the
* dimmed prior period (thinner and at 45 % so the current line reads first).
*/
export function seriesLayers({
types,
colors,
label,
t,
mini,
showBands,
showMA,
showTrend,
compareBaseline,
hasComparisonData,
animationsEnabled,
}: {
types: string[];
colors: string[];
label: (type: string) => string;
t: Translate;
mini: boolean;
showBands: boolean;
showMA: boolean;
showTrend: boolean;
compareBaseline: ComparisonBaseline;
hasComparisonData: boolean;
animationsEnabled: boolean;
}) {
return [
!mini &&
showBands &&
types.map((type, i) => (
<Area
key={`${type}__range`}
type="monotone"
dataKey={`${type}__range`}
name={`${label(type)} (${t("charts.rangeBand")})`}
stroke="none"
fill={colors[i % colors.length]}
fillOpacity={0.12}
connectNulls
isAnimationActive={animationsEnabled}
legendType="none"
tooltipType="none"
/>
)),
types.map((type, i) => (
<Line
key={type}
type="monotone"
dataKey={type}
name={label(type)}
stroke={colors[i % colors.length]}
strokeWidth={2}
dot={{ r: 3, fill: colors[i % colors.length] }}
activeDot={{ r: 5 }}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
/>
)),
showMA &&
types.map((type, i) => (
<Line
key={`${type}_ma`}
type="monotone"
dataKey={`${type}_ma`}
name={`${label(type)} (${t("charts.movingAverage7d")})`}
stroke={colors[i % colors.length]}
strokeWidth={1.5}
strokeDasharray="5 5"
strokeOpacity={0.7}
dot={false}
connectNulls
/>
)),
showTrend &&
types.map((type) => (
<Line
key={`${type}_trend`}
type="linear"
dataKey={`${type}_trend`}
name={`${label(type)} (${t("charts.trend")})`}
stroke="var(--muted-foreground)"
strokeWidth={1}
strokeDasharray="8 4"
dot={false}
connectNulls
/>
)),
compareBaseline !== "none" &&
hasComparisonData &&
types.map((type, i) => (
<Line
key={`${type}_compare`}
type="monotone"
dataKey={`${type}_compare`}
name={`${label(type)} (${t(
compareBaseline === "lastMonth"
? "comparison.captionLastMonth"
: "comparison.captionLastYear",
)})`}
stroke={colors[i % colors.length]}
strokeWidth={1.25}
strokeDasharray="4 3"
strokeOpacity={0.45}
dot={false}
connectNulls
isAnimationActive={animationsEnabled}
animationDuration={animationsEnabled ? 600 : 0}
animationEasing="ease-out"
legendType="none"
data-slot={`chart-compare-line-${type}`}
/>
)),
];
}
@@ -0,0 +1,158 @@
"use client";
import type { ReactNode } from "react";
import type { useFormatters } from "@/lib/i18n/context";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { RichChartTooltip, type RichTooltipRow } from "../chart-tooltip";
import type { ChartDataPoint } from "./types";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
interface TooltipPayloadItem {
name?: string;
value?: number;
color?: string;
dataKey?: string;
payload?: ChartDataPoint;
}
/**
* The tooltip's `content` renderer. One row per series under the cursor,
* with a delta: against the prior period when the comparison is on and the
* day has a prior value (and the prior value as its own row), otherwise
* against the personal baseline. The auxiliary lines (`*_ma`, `*_trend`,
* `*_compare`) are skipped: the comparison shows up as the delta, and the
* average and trend are already drawn as dashed overlays.
*/
export function makeTooltipContent({
chartData,
dayLinkSource,
personalBaselines,
compareBaseline,
bucketCaption,
unit,
t,
fmt,
dateLabelFor,
formatValue,
tooltipAction,
}: {
chartData: ChartDataPoint[] | undefined;
dayLinkSource: ChartDataPoint[] | undefined;
personalBaselines: Map<string, number>;
compareBaseline: ComparisonBaseline;
bucketCaption: string | null;
unit?: string;
t: Translate;
fmt: ReturnType<typeof useFormatters>;
dateLabelFor: (date: Date) => string;
formatValue: (value: number) => string;
tooltipAction: (index: number | undefined) => ReactNode | undefined;
}) {
const unitSuffix = unit ? ` ${unit}` : "";
const signedDelta = (diff: number) => {
const sign = diff > 0 ? "+" : "−";
return `${sign}${fmt.number(Math.abs(diff), 1)}${unitSuffix}`;
};
return function TooltipContent(props: unknown) {
const {
active,
payload,
label: rechartsLabel,
} = props as {
active?: boolean;
payload?: TooltipPayloadItem[];
label?: number;
};
if (!active || !payload?.length) return null;
const ts =
payload[0]?.payload?.timestamp ??
(typeof rechartsLabel === "number"
? chartData?.[Math.round(rechartsLabel)]?.timestamp
: undefined);
const dateLabel = ts ? dateLabelFor(new Date(ts)) : "";
const rows: RichTooltipRow[] = [];
// The current and prior values of the hovered day come from the same
// payload object.
const hoverPoint = payload[0]?.payload as ChartDataPoint | undefined;
for (const item of payload) {
if (typeof item.value !== "number") continue;
const dataKey = String(item.dataKey ?? "");
if (
dataKey.endsWith("_ma") ||
dataKey.endsWith("_trend") ||
dataKey.endsWith("_compare")
)
continue;
const baseline = personalBaselines.get(dataKey);
let delta: string | undefined;
const compareValue =
compareBaseline !== "none" && hoverPoint
? (hoverPoint[`${dataKey}_compare`] as number | undefined)
: undefined;
if (
compareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
const diff = item.value - compareValue;
delta =
Math.abs(diff) < 0.05
? t("charts.deltaUnchanged")
: t(
compareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
: "comparison.deltaVs.lastYear",
).replace("{delta}", signedDelta(diff));
} else if (baseline != null) {
const diff = item.value - baseline;
delta =
Math.abs(diff) < 0.05
? t("charts.deltaUnchanged")
: t("charts.deltaVsBaseline").replace("{delta}", signedDelta(diff));
}
rows.push({
// Name the statistic where it is not the plain mean of the readings:
// a day of pulse is the mean of its hours' means.
name: usesHourlyMeanDay(dataKey)
? t("charts.tooltipHourlyMean", {
metric: item.name ?? dataKey,
})
: (item.name ?? dataKey),
value: `${formatValue(item.value)}${unitSuffix}`,
color: item.color ?? "var(--dracula-purple)",
delta,
});
if (
compareBaseline !== "none" &&
typeof compareValue === "number" &&
Number.isFinite(compareValue)
) {
rows.push({
name: `${item.name ?? dataKey} · ${t("comparison.tooltipPrior")}`,
value: `${formatValue(compareValue)}${unitSuffix}`,
color: item.color ?? "var(--dracula-purple)",
});
}
}
if (rows.length === 0) return null;
return (
<RichChartTooltip
active
label={bucketCaption ? `${dateLabel} · ${t(bucketCaption)}` : dateLabel}
rows={rows}
action={tooltipAction(
hoverPoint ? dayLinkSource?.indexOf(hoverPoint) : undefined,
)}
/>
);
};
}
@@ -0,0 +1,221 @@
/**
* The pure arithmetic behind `HealthChart`: series names, day keys, the
* moving average, the personal baseline and where annotations and markers
* land. No React and no Recharts, so each piece has direct unit coverage.
*/
import { chartPointDayKey } from "@/components/day/chart-day";
import { translateOrNull } from "@/lib/i18n/translate-code";
const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "charts.weight",
BLOOD_PRESSURE_SYS: "charts.systolic",
BLOOD_PRESSURE_DIA: "charts.diastolic",
PULSE: "charts.pulse",
BODY_FAT: "charts.bodyFat",
SLEEP_DURATION: "charts.sleep",
ACTIVITY_STEPS: "charts.steps",
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
TOTAL_BODY_WATER: "charts.bodyWater",
BONE_MASS: "charts.boneMass",
OXYGEN_SATURATION: "charts.spo2",
};
/**
* The measurement label key for a type outside the chart's short labels:
* `RESTING_HEART_RATE` → `measurements.typeRestingHeartRate`. Derived rather
* than imported, because the full label map would ride into every route that
* draws a chart; `health-chart-type-label.test.tsx` pins that the derivation
* matches `MEASUREMENT_TYPE_LABEL_KEYS` for every type it is used for.
*/
export function measurementLabelKey(type: string): string {
return `measurements.type${type
.toLowerCase()
.split("_")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("")}`;
}
/**
* The name a series carries in the trend line, the legend, the tooltip and
* the spoken summary. The chart's own short labels come first; every other
* type takes its measurement label, so no overlay ever shows the raw enum.
*/
export function getTypeLabel(
type: string,
valueMode: "raw" | "bmi",
t: (key: string) => string,
): string {
if (type === "WEIGHT" && valueMode === "bmi") {
return "BMI";
}
const key = BASE_TYPE_LABEL_KEYS[type];
if (key) return t(key);
const measurementKey = measurementLabelKey(type);
return translateOrNull(t, measurementKey) ?? type;
}
/**
* A `YYYY-MM-DD` day-key formatter pinned to the time zone. Memoised per zone
* by the caller so the per-row loop reuses one `Intl.DateTimeFormat`.
*/
export function makeDayKeyFormatter(tz: string): Intl.DateTimeFormat {
return new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}
export function toDayKey(
value: string,
formatter: Intl.DateTimeFormat,
): string {
const parts = formatter.formatToParts(new Date(value));
const year = parts.find((part) => part.type === "year")?.value;
const month = parts.find((part) => part.type === "month")?.value;
const day = parts.find((part) => part.type === "day")?.value;
if (!year || !month || !day) {
throw new Error("Could not derive day key for chart");
}
return `${year}-${month}-${day}`;
}
export function dayKeyToTimestamp(dayKey: string): number {
const [year, month, day] = dayKey.split("-").map(Number);
return Date.UTC(year, month - 1, day, 12, 0, 0);
}
export function movingAverageByPoints(
data: Array<{ date: Date; value: number }>,
windowSize: number,
): Array<{ date: Date; value: number }> {
if (data.length === 0) return [];
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
return sorted.map((point, index) => {
const start = Math.max(0, index - windowSize + 1);
const window = sorted.slice(start, index + 1);
const avg =
window.reduce((sum, item) => sum + item.value, 0) / window.length;
return {
date: point.date,
value: Math.round(avg * 100) / 100,
};
});
}
/**
* The personal baseline: the median of a daily series, over at most its most
* recent 90 points (the rolling window "your normal" is framed on). Null
* under 5 points, since a baseline drawn from one or two readings misleads.
*/
export function computePersonalBaseline(
data: Array<{ value: number }>,
windowPoints = 90,
): number | null {
if (data.length < 5) return null;
const recent = data.slice(-windowPoints);
const sorted = recent.map((d) => d.value).sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[mid - 1] + sorted[mid]) / 2;
}
return sorted[mid];
}
/**
* An annotation placed on a visible point, with a 24-character truncation
* precomputed for narrow screens.
*/
export interface ResolvedAnnotation {
pointIndex: number;
label: string;
color: string;
truncatedLabel: string;
}
/** A vertical marker placed on a visible point. */
export interface ResolvedVerticalMarker {
pointIndex: number;
color: string;
label: string | undefined;
}
/**
* Markers land only on a day the chart has a point for (matched by exact day
* key): a line through a gap in the axis would hang with no data context and
* read as a rendering bug. Off-window markers drop.
*/
export function resolveVerticalMarkerPositions(
markers: Array<{ date: string; label?: string; color?: string }> | undefined,
chartData: Array<{ date: string; timestamp?: number }> | 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. A drawn point's `date` is its display label
// ("12.03."), so the day key comes from its timestamp, which a daily
// point holds at noon UTC of its day; a bare `{ date }` (the pure-helper
// tests) is the key.
indexByDate.set(
typeof point.timestamp === "number"
? chartPointDayKey(point.timestamp)
: 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 ?? "var(--success)",
label: marker.label,
});
}
return out;
}
/**
* Annotations snap to the nearest visible point within 7 days; further off
* they drop, or long buckets (monthly aggregation) would pull events from
* months away onto one point and stack their labels.
*/
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;
}
}
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;
}
+186
View File
@@ -0,0 +1,186 @@
import type { ComponentType } from "react";
import type {
ChartOverlayKey,
ComparisonBaseline,
} from "@/lib/dashboard-layout";
import type { MetricWindowStats } from "@/lib/charts/window-stats";
import type { DataWindow } from "../mini-window";
/** A shaded value band behind the series (an HTML overlay under the plot). */
export interface ChartValueBand {
min: number;
max: number;
color: string;
opacity?: number;
strokeOpacity?: number;
}
/** A target zone drawn inside the plot, with dashed edges and a label. */
export interface ChartTargetZone {
min: number;
max: number;
color: string;
opacity?: number;
label?: string;
textColor?: string;
lineOpacity?: number;
}
export interface HealthChartProps {
types: string[];
title: string;
colors?: string[];
unit?: string;
yAxisUnit?: string;
valueMode?: "raw" | "bmi";
yAxisWidth?: number;
showYAxisUnit?: boolean;
valueBands?: ChartValueBand[];
targetZones?: ChartTargetZone[];
/**
* Compact chart for the rationale card: no range tabs, no overlay toggles,
* tighter padding. Tooltip, gradient and personal baseline stay, so the
* small chart tells the same story.
*/
mini?: boolean;
/**
* Pin the chart to a rationale data window regardless of any parent UI
* state (the recommendation card's mini chart).
*/
windowOverride?: DataWindow;
/**
* "lastMonth" / "lastYear" paints a dimmed prior-period overlay beneath the
* current series, shifted so the prior day lands under its current-period
* sibling. "none" or undefined draws the chart without it.
*/
compareBaseline?: ComparisonBaseline;
/**
* Per-chart overlay-prefs key. With it the chart offers the overlay
* controls and reads its toggles and range from the persisted prefs;
* without it (mini mode, ad-hoc use) the toggles default to off.
*/
chartKey?: ChartOverlayKey;
/**
* Whether a chart bound to a `chartKey` also shows the overlay dropdown.
* Off for a chart that persists only its range tab (the score histories).
* Defaults to on.
*/
overlayControls?: boolean;
/**
* Storyboard annotations: a vertical reference line and label on the point
* whose day key matches `date` (`YYYY-MM-DD`). The series and tooltip are
* untouched; an annotation outside the visible window drops.
*/
annotations?: Array<{ date: string; label: string; color: string }>;
/**
* Injection-day markers: a thin dashed line and a dot on the x axis at the
* point whose day key matches `date`, with no label unless one is given.
* Default colour `var(--success)`. Off-window markers drop.
*/
verticalMarkers?: Array<{ date: string; label?: string; color?: string }>;
/**
* The display time zone for tick labels and day buckets. Without it the
* chart uses the signed-in account's zone, and before that has loaded the
* server's default.
*/
userTimezone?: string;
/**
* Display-time scale applied to every raw value at the one read boundary
* (bucketing, y domain, baseline, trend and tooltip all see the scaled
* series), e.g. m/s → km/h. Defaults to 1.
*/
valueScale?: number;
/**
* Display-time offset folded in beside `valueScale`
* (`display = raw * valueScale + valueOffset`), for °C → °F. Defaults to 0.
* Every delta and slope is a difference, so the offset cancels there.
*/
valueOffset?: number;
/**
* Reports the per-type min / max / median / mean of the data visible under
* the active range tab, on mount and on every change, for the stat strip a
* sub-page lifts it into. Mini mode never reports.
*/
onVisibleStats?: (stats: Record<string, MetricWindowStats> | null) => void;
/**
* Leading glyph for the card's `<TileHeader>`. Without it the card keeps
* the compact heading.
*/
titleIcon?: ComponentType<{ className?: string }>;
/**
* Fires once the data query has settled (success or error), for the
* dashboard's shared reveal. Repeat-safe.
*/
onDataReady?: () => void;
/**
* Pre-fetched daily series keyed by `MeasurementType`, from the dashboard's
* one batched read. Read for the covered types instead of a per-type fetch
* while the active window fits `preloadedCoverageDays`; SLEEP_DURATION is
* never pre-fetched.
*/
preloadedSeries?: Record<string, MeasurementApiRow[]>;
/**
* The day span the `preloadedSeries` slice covers. A wider range tab falls
* through to a real fetch. Required with `preloadedSeries`.
*/
preloadedCoverageDays?: number;
/**
* The drawn points as a table under the chart, behind a collapsed
* disclosure. Opt-in: the metric pages want the numbers, the dashboard
* tiles do not.
*/
showDataTable?: boolean;
/**
* The chart as a door to its days: a click on a point opens that day, the
* open day is marked, and dots under the axis mark the days that hold
* anything. Only while drawn in days, never in `mini` mode.
*/
dayLinks?: boolean;
}
export interface ChartDataPoint {
date: string;
timestamp: number;
pointIndex?: number;
// `${type}__range` keys carry a `[min, max]` tuple for the range band;
// every other key is a scalar.
[key: string]: string | number | undefined | [number, number];
}
export interface MeasurementApiRow {
measuredAt: string;
value: number;
// The day's min / max from the rollup daily aggregate (non-cumulative
// metrics only), for the range band around the mean line. Never drawn on
// the 7-day range. `null` is accepted beside `undefined` because the
// batched series carries `number | null`.
minValue?: number | null;
maxValue?: number | null;
// The raw-row count per bucket, for the "more days needed" copy. Absent on
// the per-night sleep series.
count?: number;
}
export interface VisibleValueBand {
key: string;
topPct: number;
heightPct: number;
color: string;
opacity: number;
strokeOpacity: number;
}
export interface VisibleTargetZone {
key: string;
low: number;
high: number;
topPct: number;
heightPct: number;
color: string;
opacity: number;
label: string | undefined;
textColor: string | undefined;
lineOpacity: number;
}
@@ -0,0 +1,309 @@
"use client";
import { useMemo } from "react";
import {
computeWindowTrend,
SPLIT_HALF_THRESHOLD_DAYS,
} from "@/lib/analytics/window-trend";
import type { MetricWindowStats } from "@/lib/charts/window-stats";
import type { useFormatters } from "@/lib/i18n/context";
import type { ChartDataTableColumn } from "../chart-data-table";
import { computePersonalBaseline, getTypeLabel } from "./series-math";
import type {
ChartDataPoint,
ChartTargetZone,
ChartValueBand,
VisibleTargetZone,
VisibleValueBand,
} from "./types";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
type Formatters = ReturnType<typeof useFormatters>;
/** A band clipped to the y domain, as top / height percentages of the plot. */
function clipToDomain(
min: number,
max: number,
[domainMin, domainMax]: [number, number],
) {
const span = domainMax - domainMin;
const low = Math.max(Math.min(min, max), domainMin);
const high = Math.min(Math.max(min, max), domainMax);
if (high <= low) return null;
return {
low,
high,
topPct: ((domainMax - high) / span) * 100,
heightPct: ((high - low) / span) * 100,
};
}
/**
* The layers drawn over the series: the value bands and target zones
* (clipped to the y domain, only with the target-range toggle on), the
* personal baseline per type, and the trend read-out above the plot.
*/
export function useHealthChartOverlays({
showBands,
showTrend,
valueBands,
targetZones,
yDomain,
chartData,
types,
unit,
valueMode,
t,
fmt,
}: {
showBands: boolean;
showTrend: boolean;
valueBands?: ChartValueBand[];
targetZones?: ChartTargetZone[];
yDomain: [number, number] | undefined;
chartData: ChartDataPoint[] | undefined;
types: string[];
unit?: string;
valueMode: "raw" | "bmi";
t: Translate;
fmt: Formatters;
}) {
const visibleBands = useMemo<VisibleValueBand[]>(() => {
if (!showBands || !valueBands?.length || !yDomain) return [];
const span = yDomain[1] - yDomain[0];
if (!Number.isFinite(span) || span <= 0) return [];
return valueBands
.map((band, index) => {
const clipped = clipToDomain(band.min, band.max, yDomain);
if (!clipped) return null;
return {
key: `${band.min}-${band.max}-${index}`,
topPct: clipped.topPct,
heightPct: clipped.heightPct,
color: band.color,
opacity: band.opacity ?? 0.2,
strokeOpacity: band.strokeOpacity ?? 0.35,
};
})
.filter(
(entry): entry is VisibleValueBand =>
entry !== null && entry.heightPct > 0,
);
}, [showBands, valueBands, yDomain]);
const visibleTargetZones = useMemo<VisibleTargetZone[]>(() => {
if (!showBands || !targetZones?.length || !yDomain) return [];
const span = yDomain[1] - yDomain[0];
if (!Number.isFinite(span) || span <= 0) return [];
const zones: VisibleTargetZone[] = [];
for (const [index, zone] of targetZones.entries()) {
const clipped = clipToDomain(zone.min, zone.max, yDomain);
if (!clipped) continue;
zones.push({
key: `${zone.min}-${zone.max}-${index}`,
...clipped,
color: zone.color,
opacity: zone.opacity ?? 0.3,
label: zone.label ?? undefined,
textColor: zone.textColor ?? undefined,
lineOpacity: zone.lineOpacity ?? 0.42,
});
}
return zones;
}, [showBands, targetZones, yDomain]);
const trendInfo = useMemo(() => {
if (!showTrend || !chartData?.length) return [];
const formatSigned = (value: number) => {
const formatted = fmt.number(Math.abs(value), 1);
return `${value > 0 ? "+" : value < 0 ? "-" : ""}${formatted}`;
};
return types
.map((type) => {
const series = chartData.filter(
(point) =>
typeof point[type] === "number" &&
typeof point[`${type}_trend`] === "number",
);
if (series.length < 2) return null;
const first = series[0];
const last = series[series.length - 1];
const days = (last.timestamp - first.timestamp) / 86400000;
if (!Number.isFinite(days) || days <= 0) return null;
const rawValues = series.map((point) => point[type] as number);
const trendValues = series.map(
(point) => point[`${type}_trend`] as number,
);
const computed = computeWindowTrend({
rawValues,
trendValues,
windowDays: days,
});
if (!computed) return null;
const { weeklyDelta, splitHalfDelta } = computed;
const base = rawValues[0];
const weeklyPct =
Math.abs(base) > Number.EPSILON
? (weeklyDelta / Math.abs(base)) * 100
: null;
const unitSuffix = unit ? ` ${unit}` : "";
// On a long window the per-week slope rounds to ±0.0 and reads as "no
// change", so from SPLIT_HALF_THRESHOLD_DAYS the first-half versus
// second-half mean delta is added: one number that cannot round to
// zero unless the metric truly did not move.
let totalDeltaSegment = "";
if (days >= SPLIT_HALF_THRESHOLD_DAYS && splitHalfDelta !== null) {
const meanFirst =
rawValues
.slice(0, Math.floor(rawValues.length / 2))
.reduce((sum, value) => sum + value, 0) /
Math.max(1, Math.floor(rawValues.length / 2));
const totalDeltaPct =
Math.abs(meanFirst) > Number.EPSILON
? (splitHalfDelta / Math.abs(meanFirst)) * 100
: null;
totalDeltaSegment = ` · ${t(
"charts.totalDelta",
)} ${formatSigned(splitHalfDelta)}${unitSuffix}${
totalDeltaPct != null ? ` (${formatSigned(totalDeltaPct)} %)` : ""
}`;
}
return `${getTypeLabel(type, valueMode, t)}: ${formatSigned(
weeklyDelta,
)}${unitSuffix}${t("charts.perWeek")}${
weeklyPct != null
? ` (${formatSigned(weeklyPct)} %${t("charts.perWeek")})`
: ""
}${totalDeltaSegment}`;
})
.filter((entry): entry is string => entry !== null);
}, [chartData, showTrend, types, unit, valueMode, t, fmt]);
// The personal baseline (rolling 90-point median) per type, a faint dashed
// "your normal" line, so each point reads against the person's own level.
const personalBaselines = useMemo(() => {
if (!chartData?.length) return new Map<string, number>();
const map = new Map<string, number>();
for (const type of types) {
const series = chartData
.filter((d) => typeof d[type] === "number")
.map((d) => ({ value: d[type] as number }));
const baseline = computePersonalBaseline(series, 90);
if (baseline !== null) map.set(type, baseline);
}
return map;
}, [chartData, types]);
return { visibleBands, visibleTargetZones, trendInfo, personalBaselines };
}
/**
* The words around the plot: a spoken summary of the primary series (range,
* latest value, direction) so a screen reader does not meet an unlabelled
* graphic, and the data table's columns, one per series that has a value in
* the window, named by the same helper as the legend.
*/
export function useHealthChartLabels({
types,
valueMode,
t,
fmt,
chartData,
chartDataWithCompare,
visibleStatsByType,
showDataTable,
mini,
}: {
types: string[];
valueMode: "raw" | "bmi";
t: Translate;
fmt: Formatters;
chartData: ChartDataPoint[] | undefined;
chartDataWithCompare: ChartDataPoint[] | undefined;
visibleStatsByType: Record<string, MetricWindowStats> | null;
showDataTable: boolean;
mini: boolean;
}) {
const primaryType = types[0];
const chartAriaLabel = useMemo(() => {
const metric = getTypeLabel(primaryType, valueMode, t);
const source = chartDataWithCompare ?? chartData;
const values = (source ?? [])
.map((point) => point[primaryType])
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (values.length === 0) {
return t("charts.a11y.noData", { metric });
}
const stats = visibleStatsByType?.[primaryType];
const minVal = stats?.min ?? Math.min(...values);
const maxVal = stats?.max ?? Math.max(...values);
const latestVal = values[values.length - 1];
const firstVal = values[0];
const epsilon = Math.max(Math.abs(maxVal - minVal) * 0.02, 0.01);
const delta = latestVal - firstVal;
const trend =
delta > epsilon
? t("charts.a11y.trendUp")
: delta < -epsilon
? t("charts.a11y.trendDown")
: t("charts.a11y.trendFlat");
return t("charts.a11y.summary", {
metric,
min: fmt.number(minVal, 1),
max: fmt.number(maxVal, 1),
latest: fmt.number(latestVal, 1),
trend,
});
}, [
primaryType,
valueMode,
t,
chartDataWithCompare,
chartData,
visibleStatsByType,
fmt,
]);
// A type with no value anywhere in the window gets no column: a column of
// dashes states nothing the chart's own empty line does not.
const dataTableColumns = useMemo<ChartDataTableColumn[]>(() => {
if (!showDataTable || mini) return [];
const source = chartDataWithCompare ?? chartData;
if (!source?.length) return [];
return types
.filter((type) =>
source.some(
(point) =>
typeof point[type] === "number" &&
Number.isFinite(point[type] as number),
),
)
.map((type) => ({ key: type, label: getTypeLabel(type, valueMode, t) }));
}, [
showDataTable,
mini,
chartDataWithCompare,
chartData,
types,
valueMode,
t,
]);
return { primaryType, chartAriaLabel, dataTableColumns };
}
@@ -0,0 +1,360 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { chartSeriesParams } from "@/components/charts/chart-series-request";
import type { MeasurementType } from "@/generated/prisma/client";
import { apiGet } from "@/lib/api/api-fetch";
import type { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
import {
localDayKeyFor,
openEndedFetchWindow,
} from "@/lib/charts/fetch-window";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { queryKeys } from "@/lib/query-keys";
import { rangeWindowDays } from "../chart-range-tabs";
import { dayKeyToTimestamp, toDayKey } from "./series-math";
import type { ChartDataPoint, MeasurementApiRow } from "./types";
type BucketValues = Record<
string,
{
sum: number;
count: number;
// The bucket's spread for the range band, from the rollup's min / max
// columns; null when the row carries none (raw rows, cumulative metric).
min: number | null;
max: number | null;
}
>;
/**
* One read of the chart's series, folded to one point per profile-zone day.
* Every value passes the display scale once, here; cumulative types add up
* within a day and the rest average; the rollup's min / max become the range
* band's tuple.
*/
async function readDailySeries({
types,
fetchWindow,
usePreloaded,
preloadedSeries,
valueScale,
valueOffset,
dayKeyFormatter,
tzFmt,
}: {
types: string[];
fetchWindow: ReturnType<typeof openEndedFetchWindow>;
usePreloaded: boolean;
preloadedSeries?: Record<string, MeasurementApiRow[]>;
valueScale: number;
valueOffset: number;
dayKeyFormatter: Intl.DateTimeFormat;
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
}): Promise<ChartDataPoint[]> {
const dailyAggregates = new Map<
string,
{ timestamp: number; values: BucketValues }
>();
// SLEEP_DURATION is stored one row per stage per night. The series
// adapter reconstructs ONE point per night carrying the night's time
// asleep in hours (core + deep + REM, bare asleep only without a
// granular stage, in bed and awake excluded, dual-source nights
// collapsed), so every sleep surface shares one reconstruction.
async function fetchSleepNights() {
const sleepParams = new URLSearchParams();
sleepParams.set("kind", "sleep");
// The series route caps sleep at 365 days; clamp the request so a
// longer comparison window still resolves.
sleepParams.set(
"days",
String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
);
// A failed read rejects the query rather than caching an empty
// series as success (which painted "no data" with no retry).
const data = await apiGet<{
points?: Array<{ at: string; value: number }>;
}>(`/api/measurements/series?${sleepParams}`);
const points = data?.points ?? [];
for (const point of points) {
const value = point.value * valueScale + valueOffset;
if (value == null || !Number.isFinite(value)) continue;
const dayKey = toDayKey(point.at, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values["SLEEP_DURATION"] ?? {
sum: 0,
count: 0,
min: null,
max: null,
};
current.sum += value;
current.count += 1;
bucket.values["SLEEP_DURATION"] = current;
dailyAggregates.set(dayKey, bucket);
}
}
async function fetchMeasurementsByType(type: string) {
if (type === "SLEEP_DURATION") {
await fetchSleepNights();
return;
}
const typeParams = chartSeriesParams(type, fetchWindow);
// The batched slice reads through the same rollup path as the
// per-type read, so the bucketed result is identical. A covered type
// missing from it paints the no-data card rather than fetching.
const page =
usePreloaded && preloadedSeries
? (preloadedSeries[type] ?? [])
: await (async () => {
// A failed read rejects the whole query (see above).
const data = await apiGet<{
measurements?: MeasurementApiRow[];
}>(`/api/measurements?${typeParams}`);
return data?.measurements ?? [];
})();
for (const measurement of page) {
// The display scale folds into the value at this one boundary, so
// everything downstream works on the scaled series. The BMI
// division happens in `select`, so the cache stays raw.
const value = measurement.value * valueScale + valueOffset;
if (!Number.isFinite(value)) {
continue;
}
const dayKey = toDayKey(measurement.measuredAt, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
timestamp: dayKeyToTimestamp(dayKey),
values: {},
};
const current = bucket.values[type] ?? {
sum: 0,
count: 0,
min: null,
max: null,
};
current.sum += value;
current.count += 1;
// The rollup bucket's spread, scaled like the value so the band
// tracks the line. The 7-day range never draws the band.
if (
fetchWindow.windowDays > 7 &&
typeof measurement.minValue === "number" &&
typeof measurement.maxValue === "number"
) {
const scaledMin = measurement.minValue * valueScale + valueOffset;
const scaledMax = measurement.maxValue * valueScale + valueOffset;
current.min =
current.min === null ? scaledMin : Math.min(current.min, scaledMin);
current.max =
current.max === null ? scaledMax : Math.max(current.max, scaledMax);
}
bucket.values[type] = current;
dailyAggregates.set(dayKey, bucket);
}
}
await Promise.all(types.map(fetchMeasurementsByType));
const allData: ChartDataPoint[] = Array.from(dailyAggregates.values())
.map((bucket) => {
const point: ChartDataPoint = {
date: tzFmt.dateShortSmart(new Date(bucket.timestamp)),
timestamp: bucket.timestamp,
};
for (const [type, stats] of Object.entries(bucket.values)) {
// Cumulative types (steps, active energy, distance, flights,
// daylight) add up within a day; everything else averages.
const isCumulative = CUMULATIVE_HK_TYPES.has(type as MeasurementType);
point[type] = isCumulative ? stats.sum : stats.sum / stats.count;
// The day's spread as a `[min, max]` tuple for the range band,
// when the rollup supplied both bounds and the metric is not a
// sum (a total has no meaningful intra-day spread).
if (!isCumulative && stats.min !== null && stats.max !== null) {
point[`${type}__range`] = [stats.min, stats.max] as never;
}
}
return point;
})
.sort((a, b) => a.timestamp - b.timestamp);
return allData;
}
/**
* The chart's daily series: one point per day in the profile time zone,
* read from the window the active range tab needs.
*
* The window is bounded by the range tab (the "All" tab asks for about ten
* years; the server steps up to week and month buckets past its day cap) and
* stretched backwards by the comparison shift so the prior period rides the
* same read. It ends at the end of TODAY in the profile zone, not at the mount
* instant, so a reading saved after mount falls inside the window the
* invalidated query refetches, and the cache key stays put all day.
*
* On the dashboard the batched slice stands in for the per-type reads, but
* only for windows over 7 days that fit the batch's coverage and only when
* every non-sleep type is in it; sleep always reads its own per-night series.
*/
export function useHealthChartSeries({
types,
valueMode,
bmiDivisor,
valueScale,
valueOffset,
userTimezone,
dayKeyFormatter,
tzFmt,
rangePoints,
effectiveCompareBaseline,
preloadedSeries,
preloadedCoverageDays,
enabled,
onDataReady,
}: {
types: string[];
valueMode: "raw" | "bmi";
bmiDivisor: number | null;
valueScale: number;
valueOffset: number;
userTimezone: string;
dayKeyFormatter: Intl.DateTimeFormat;
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
rangePoints: number;
effectiveCompareBaseline: ComparisonBaseline;
preloadedSeries?: Record<string, MeasurementApiRow[]>;
preloadedCoverageDays?: number;
enabled: boolean;
onDataReady?: () => void;
}) {
const todayKey = localDayKeyFor(new Date(), userTimezone);
const fetchWindow = useMemo(() => {
const windowDays = rangeWindowDays(rangePoints);
const compareShift =
effectiveCompareBaseline === "lastMonth"
? 30
: effectiveCompareBaseline === "lastYear"
? 365
: 0;
return openEndedFetchWindow(
windowDays + compareShift,
todayKey,
userTimezone,
);
}, [rangePoints, effectiveCompareBaseline, userTimezone, todayKey]);
// Without an explicit coverage (a legacy mount) the slice is bypassed
// rather than trusted blindly.
const usePreloaded = useMemo(() => {
if (!preloadedSeries) return false;
if (fetchWindow.windowDays <= 7) return false;
const coverage = preloadedCoverageDays ?? 0;
if (fetchWindow.windowDays > coverage) return false;
return types.every(
(type) => type === "SLEEP_DURATION" || preloadedSeries[type] != null,
);
}, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]);
const chartQuery = useQuery({
// A range-tab change re-keys the query; without placeholder data every
// switch dropped the painted chart to a skeleton for the round trip. It
// is only honest because the chart dims while `isPlaceholderData` is
// true: the old range must never masquerade as the new one.
placeholderData: keepPreviousData,
// The `["chart-data"]` prefix lands in `measurementDependentKeys`, so a
// new measurement evicts every chart's cache in one pass. The BMI view
// shares the raw WEIGHT entry (the division happens in `select`), the
// time zone and the window re-key it, and the discriminator separates a
// batched render from a self-fetch only on mounts that pass a batch (an
// empty string keeps every other caller's key as it was).
queryKey: queryKeys.chartData(
types.join(","),
"raw",
"no-bmi",
userTimezone,
fetchWindow.from,
fetchWindow.to,
valueScale,
preloadedSeries ? (usePreloaded ? "preloaded" : "fetch") : "",
),
// A minute of freshness so moving between metric pages does not refire
// every chart; five minutes of cache for a quick back-and-forth.
staleTime: 60_000,
gcTime: 5 * 60_000,
queryFn: () =>
readDailySeries({
types,
fetchWindow,
usePreloaded,
preloadedSeries,
valueScale,
valueOffset,
dayKeyFormatter,
tzFmt,
}),
// The BMI view: the cached raw WEIGHT series divided at read time, so the
// entry is shared with the weight chart. No height → no series. The range
// tuples are dropped; the BMI view has never drawn the band.
select: useCallback(
(points: ChartDataPoint[]): ChartDataPoint[] => {
if (valueMode !== "bmi") return points;
if (!bmiDivisor) return [];
return points.map((p) => {
const out: ChartDataPoint = {
date: p.date,
timestamp: p.timestamp,
};
for (const type of types) {
const v = p[type];
if (typeof v === "number") {
out[type] = v / bmiDivisor;
}
}
return out;
});
},
[valueMode, bmiDivisor, types],
),
enabled,
});
// Report the settled first read to the dashboard's shared reveal, exactly
// once. Latched behind a ref and keyed on `isLoading` alone: the dashboard
// passes an inline closure, and re-running the notify on every commit kept
// the chart row's popper anchors re-committing until React's update-depth
// guard tripped (#185). A late-arriving handler still fires.
const { isLoading } = chartQuery;
const onDataReadyRef = useRef(onDataReady);
useEffect(() => {
onDataReadyRef.current = onDataReady;
}, [onDataReady]);
const dataReadyFiredRef = useRef(false);
useEffect(() => {
if (
!shouldFireDataReady({
isLoading,
alreadyFired: dataReadyFiredRef.current,
})
)
return;
dataReadyFiredRef.current = true;
onDataReadyRef.current?.();
}, [isLoading]);
return chartQuery;
}
@@ -0,0 +1,347 @@
"use client";
import { useEffect, useMemo } from "react";
import { bucketCaptionKey } from "@/lib/charts/bucket-caption";
import type { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import {
bucketTimeSeries,
pickBucket,
type ChartBucketType,
} from "@/lib/charts/bucket-time-series";
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
import {
computeWindowStats,
type MetricWindowStats,
} from "@/lib/charts/window-stats";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
import { trendLineByTime } from "@/lib/stats/linear-fit";
import { movingAverageByPoints } from "./series-math";
import type { ChartDataPoint } from "./types";
/**
* The visible slice at its grain, indexed for the x axis, with the 7-day
* moving average and the trend line stamped on as `${type}_ma` and
* `${type}_trend` when those overlays are on.
*/
function bucketVisibleSeries(
{
sliced,
bucketType,
}: { sliced: ChartDataPoint[]; bucketType: ChartBucketType },
{
types,
userTimezone,
tzFmt,
showMA,
showTrend,
}: {
types: string[];
userTimezone: string;
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
showMA: boolean;
showTrend: boolean;
},
): ChartDataPoint[] {
const bucketed =
bucketType === "day"
? sliced
: bucketTimeSeries(
sliced.map((p) => ({
timestamp: p.timestamp,
values: Object.fromEntries(
types.map((type) => [type, p[type] as number | undefined]),
),
})),
{ bucket: bucketType, timeZone: userTimezone },
).points.map<ChartDataPoint>((point) => {
const date = new Date(point.timestamp);
const out: ChartDataPoint = {
date: tzFmt.dateShortSmart(date),
timestamp: point.timestamp,
};
for (const [type, value] of Object.entries(point.values)) {
out[type] = value;
}
return out;
});
const enriched: ChartDataPoint[] = bucketed.map((d, index) => ({
...d,
pointIndex: index,
}));
if (showMA) {
for (const type of types) {
const typeData = enriched
.filter((d) => d[type] !== undefined)
.map((d) => ({
date: new Date(d.timestamp),
value: d[type] as number,
}));
if (typeData.length >= 2) {
const ma = movingAverageByPoints(typeData, 7);
for (const point of ma) {
const pointTimestamp = point.date.getTime();
const existing = enriched.find((d) => d.timestamp === pointTimestamp);
if (existing) {
existing[`${type}_ma`] = point.value;
}
}
}
}
}
if (showTrend) {
for (const type of types) {
const typeData = enriched
.filter((d) => d[type] !== undefined)
.map((d) => ({
date: new Date(d.timestamp),
value: d[type] as number,
}));
if (typeData.length >= 2) {
const trend = trendLineByTime(typeData);
if (trend) {
for (const point of trend.points) {
const pointTimestamp = point.date.getTime();
const existing = enriched.find(
(d) => d.timestamp === pointTimestamp,
);
if (existing) {
existing[`${type}_trend`] = point.value;
}
}
}
}
}
}
return enriched;
}
/**
* The prior period stamped onto the visible points as `${type}_compare`: the
* fetched daily series shifted forward by 30 / 365 days, matched on the same
* day key the chart uses. A prior point paints only where its shifted day is
* a visible day.
*/
function mergePriorPeriod(
chartData: ChartDataPoint[],
data: ChartDataPoint[],
{
types,
tzFmt,
baseline,
}: {
types: string[];
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
baseline: Exclude<ComparisonBaseline, "none">;
},
): ChartDataPoint[] {
const shifted = shiftDailySeriesForward(
data.map((row) => ({
timestamp: row.timestamp,
values: Object.fromEntries(
types.map((type) => [type, row[type] as number | undefined]),
),
})),
baseline,
);
// Indexed by the same day key the chart already uses.
const shiftedByDay = new Map<string, Record<string, number>>();
for (const row of shifted) {
const dayKey = tzFmt.dateShortSmart(new Date(row.timestamp));
const slot = shiftedByDay.get(dayKey) ?? {};
for (const [type, value] of Object.entries(row.values)) {
if (typeof value === "number" && Number.isFinite(value)) {
slot[type] = value;
}
}
shiftedByDay.set(dayKey, slot);
}
return chartData.map((point) => {
const compareValues = shiftedByDay.get(point.date);
if (!compareValues) return point;
const merged: ChartDataPoint = { ...point };
for (const type of types) {
const v = compareValues[type];
if (typeof v === "number" && Number.isFinite(v)) {
merged[`${type}_compare`] = v;
}
}
return merged;
});
}
/**
* What the chart draws from its daily series: the visible window, bucketed
* to day, week or month, with the moving average, the trend line and the
* prior-period overlay merged in, and the y domain that fits all of it.
*/
export function useHealthChartView({
data,
rangePoints,
types,
userTimezone,
tzFmt,
showMA,
showTrend,
effectiveCompareBaseline,
mini,
isLoading,
isError,
onVisibleStats,
}: {
data: ChartDataPoint[] | undefined;
rangePoints: number;
types: string[];
userTimezone: string;
tzFmt: ReturnType<typeof makeBucketLabelFormatters>;
showMA: boolean;
showTrend: boolean;
effectiveCompareBaseline: ComparisonBaseline;
mini: boolean;
isLoading: boolean;
isError: boolean;
onVisibleStats?: (stats: Record<string, MetricWindowStats> | null) => void;
}) {
// The grain (day / week / month) is picked from the VISIBLE range, so a
// 30-day tab stays daily on a five-year-old account, and it has ONE source:
// the points, the caption chip and the data table all read it. The slice
// is a calendar-day window, as the range tabs promise and the fetch
// requests, on the self-fetched and the batched path alike.
const visibleSlice = useMemo(() => {
if (!data?.length) return null;
const cutoff = new Date().getTime() - rangePoints * 86_400_000;
const sliced =
rangePoints > 0
? data.filter((point) => point.timestamp >= cutoff)
: [...data];
// An empty window stays an empty slice ("no data in this range");
// returning null would quietly widen the window to the whole series.
const rangeDays =
sliced.length < 2
? 0
: Math.round(
(sliced[sliced.length - 1].timestamp - sliced[0].timestamp) /
(24 * 60 * 60 * 1000),
);
return { sliced, bucketType: pickBucket(rangeDays) };
}, [data, rangePoints]);
const chartData = useMemo(() => {
if (!data?.length || !visibleSlice) return data;
return bucketVisibleSeries(visibleSlice, {
types,
userTimezone,
tzFmt,
showMA,
showTrend,
});
}, [data, visibleSlice, showMA, showTrend, types, tzFmt, userTimezone]);
// Min / max / median / mean of what is visible under the active range tab,
// for the stat strip a sub-page lifts the callback into. Mini mode has no
// stat strip.
const visibleStatsByType = useMemo<Record<
string,
MetricWindowStats
> | null>(() => {
if (mini || !chartData?.length) return null;
const out: Record<string, MetricWindowStats> = {};
for (const type of types) {
const stats = computeWindowStats(
chartData.map((point) => point[type] as number | undefined),
);
if (stats.count > 0) out[type] = stats;
}
return Object.keys(out).length > 0 ? out : null;
}, [mini, chartData, types]);
// An effect, so the parent's state update never runs during this render,
// and only once the read settled: a report during the first fetch would
// tell the strip "no window" and it would paint the full-range summary
// first. A failed read settles too, so the strip never waits forever.
useEffect(() => {
if (mini || isLoading) return;
onVisibleStats?.(isError ? null : visibleStatsByType);
}, [mini, isLoading, isError, visibleStatsByType, onVisibleStats]);
// The prior period, from the already-fetched daily series shifted forward
// by 30 / 365 days, stamped onto the visible points under `${type}_compare`.
// A prior point paints only where its shifted day is a visible day.
const chartDataWithCompare = useMemo(() => {
if (!chartData || effectiveCompareBaseline === "none") return chartData;
if (!data?.length) return chartData;
return mergePriorPeriod(chartData, data, {
types,
tzFmt,
baseline: effectiveCompareBaseline,
});
}, [chartData, effectiveCompareBaseline, data, types, tzFmt]);
// True when at least one visible day has a prior value. A partial overlay
// counts as available, so the "comparison unavailable" caption never sits
// beside dimmed history the reader can see.
const hasComparisonData = useMemo(() => {
if (effectiveCompareBaseline === "none" || !chartDataWithCompare)
return false;
return chartDataWithCompare.some((point) =>
types.some(
(type) =>
typeof point[`${type}_compare`] === "number" &&
Number.isFinite(point[`${type}_compare`] as number),
),
);
}, [chartDataWithCompare, effectiveCompareBaseline, types]);
const activeBucket: ChartBucketType = visibleSlice?.bucketType ?? "day";
// What a folded point is: the average day of its week or month (a daily
// total's average for steps and the like), named on the caption chip and
// in the tooltip.
const bucketCaption = bucketCaptionKey(activeBucket, types);
const yDomain = useMemo<[number, number] | undefined>(() => {
if (!chartDataWithCompare?.length) return undefined;
const keys = [...types];
if (showMA) keys.push(...types.map((type) => `${type}_ma`));
if (showTrend) keys.push(...types.map((type) => `${type}_trend`));
// The prior period may sit on a different scale; without it the dimmed
// line clips outside the plot.
if (effectiveCompareBaseline !== "none") {
keys.push(...types.map((type) => `${type}_compare`));
}
const values = chartDataWithCompare
.flatMap((point) => keys.map((key) => point[key]))
.filter((value): value is number => typeof value === "number")
.filter((value) => Number.isFinite(value));
return computePaddedYDomain(values, types);
}, [
chartDataWithCompare,
effectiveCompareBaseline,
showMA,
showTrend,
types,
]);
return {
visibleSlice,
chartData,
visibleStatsByType,
chartDataWithCompare,
hasComparisonData,
activeBucket,
bucketCaption,
yDomain,
};
}
@@ -66,7 +66,7 @@ describe("shouldFireDataReady", () => {
describe("chart notify wiring", () => {
const charts = [
"src/components/charts/health-chart.tsx",
"src/components/charts/health-chart/use-health-chart-series.ts",
"src/components/charts/mood-chart.tsx",
"src/components/charts/medication-compliance-chart.tsx",
];
@@ -65,7 +65,10 @@ describe.each([
describe("health chart wiring", () => {
it("builds its fetch window from today's key, not the mount instant", () => {
const chart = readFileSync(
join(process.cwd(), "src/components/charts/health-chart.tsx"),
join(
process.cwd(),
"src/components/charts/health-chart/use-health-chart-series.ts",
),
"utf8",
);
const memo = chart.slice(chart.indexOf("const fetchWindow = useMemo("));