From 5075536f7f17738e46dccfa1cf008e0a99dcd249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:25:16 +0200 Subject: [PATCH] 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. --- eslint.config.mjs | 3 + .../baselines/source-file-lines.json | 5 +- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 5 +- ...ate-order-explicit-formatter-guard.test.ts | 2 +- .../pulse-day-mean-reader-guard.test.ts | 2 +- .../health-chart-annotations.test.ts | 2 +- .../health-chart-overlay-defaults.test.tsx | 37 +- .../__tests__/health-chart-polish.test.tsx | 2 +- .../health-chart-target-link-removed.test.ts | 11 +- .../health-chart-type-label.test.tsx | 2 +- .../health-chart-vertical-markers.test.ts | 2 +- src/components/charts/health-chart.tsx | 2415 ++--------------- .../charts/health-chart/band-overlay.tsx | 43 + .../charts/health-chart/chart-header.tsx | 114 + .../charts/health-chart/chart-layers.tsx | 352 +++ .../health-chart/chart-tooltip-content.tsx | 158 ++ .../charts/health-chart/series-math.ts | 221 ++ src/components/charts/health-chart/types.ts | 186 ++ .../health-chart/use-health-chart-overlays.ts | 309 +++ .../health-chart/use-health-chart-series.ts | 360 +++ .../health-chart/use-health-chart-view.ts | 347 +++ .../charts/__tests__/data-ready-latch.test.ts | 2 +- src/lib/charts/__tests__/fetch-window.test.ts | 5 +- 24 files changed, 2365 insertions(+), 2224 deletions(-) create mode 100644 src/components/charts/health-chart/band-overlay.tsx create mode 100644 src/components/charts/health-chart/chart-header.tsx create mode 100644 src/components/charts/health-chart/chart-layers.tsx create mode 100644 src/components/charts/health-chart/chart-tooltip-content.tsx create mode 100644 src/components/charts/health-chart/series-math.ts create mode 100644 src/components/charts/health-chart/types.ts create mode 100644 src/components/charts/health-chart/use-health-chart-overlays.ts create mode 100644 src/components/charts/health-chart/use-health-chart-series.ts create mode 100644 src/components/charts/health-chart/use-health-chart-view.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index ce53edc99..50a13361d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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", diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 1e37872a7..3b163dee8 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -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, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index ff79075df..53d14f450 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 6ec71c7df..76742a6bb 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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, diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts index a31307fcb..8d479de9c 100644 --- a/src/__tests__/date-order-explicit-formatter-guard.test.ts +++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts @@ -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`.", }, { diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index 0918eda6a..bf8c215dd 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -139,7 +139,7 @@ const TS_READERS: Record = { 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", }, diff --git a/src/components/charts/__tests__/health-chart-annotations.test.ts b/src/components/charts/__tests__/health-chart-annotations.test.ts index 70e148ea0..fff06fb22 100644 --- a/src/components/charts/__tests__/health-chart-annotations.test.ts +++ b/src/components/charts/__tests__/health-chart-annotations.test.ts @@ -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. diff --git a/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx b/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx index 8f379853b..4e8727718 100644 --- a/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx +++ b/src/components/charts/__tests__/health-chart-overlay-defaults.test.tsx @@ -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 `` 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) => { ... \{[\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", () => { diff --git a/src/components/charts/__tests__/health-chart-polish.test.tsx b/src/components/charts/__tests__/health-chart-polish.test.tsx index 9a69d0f85..789ca2b7f 100644 --- a/src/components/charts/__tests__/health-chart-polish.test.tsx +++ b/src/components/charts/__tests__/health-chart-polish.test.tsx @@ -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. diff --git a/src/components/charts/__tests__/health-chart-target-link-removed.test.ts b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts index 69b884356..ea9102dd0 100644 --- a/src/components/charts/__tests__/health-chart-target-link-removed.test.ts +++ b/src/components/charts/__tests__/health-chart-target-link-removed.test.ts @@ -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"); diff --git a/src/components/charts/__tests__/health-chart-type-label.test.tsx b/src/components/charts/__tests__/health-chart-type-label.test.tsx index d9880f3cf..aaefdf00d 100644 --- a/src/components/charts/__tests__/health-chart-type-label.test.tsx +++ b/src/components/charts/__tests__/health-chart-type-label.test.tsx @@ -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", diff --git a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts index 8b8a8ef6e..a6d5f91b1 100644 --- a/src/components/charts/__tests__/health-chart-vertical-markers.test.ts +++ b/src/components/charts/__tests__/health-chart-vertical-markers.test.ts @@ -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. diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index f42233f75..909ec7a4d 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1,591 +1,77 @@ "use client"; -import { chartSeriesParams } from "@/components/charts/chart-series-request"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { useMemo, useState } from "react"; import { - localDayKeyFor, - openEndedFetchWindow, -} from "@/lib/charts/fetch-window"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { queryKeys } from "@/lib/query-keys"; -import { apiGet } from "@/lib/api/api-fetch"; -import { - ResponsiveContainer, + CartesianGrid, ComposedChart, - Line, - Area, + Legend, + ResponsiveContainer, + Tooltip, XAxis, YAxis, - CartesianGrid, - Tooltip, - Legend, - ReferenceLine, - ReferenceArea, - ReferenceDot, } from "recharts"; -import { - useState, - useMemo, - useEffect, - useCallback, - useRef, - type ComponentType, -} from "react"; -import { Skeleton } from "@/components/ui/skeleton"; -import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; -import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; -import { ChartEmptyState } from "./chart-empty-state"; -import { ChartErrorState } from "./chart-error-state"; -import { TileHeader } from "@/components/insights/tile-header"; -import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; -import { computePaddedYDomain } from "@/lib/insights/chart-y-domain"; -import { - useDateFormatPreference, - useTranslations, - useFormatters, -} from "@/lib/i18n/context"; -import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; -import { - bucketTimeSeries, - pickBucket, - type ChartBucketType, -} from "@/lib/charts/bucket-time-series"; -import { - computeWindowTrend, - SPLIT_HALF_THRESHOLD_DAYS, -} from "@/lib/analytics/window-trend"; -import { resolveMiniRangePoints, type DataWindow } from "./mini-window"; -import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift"; -import type { - ChartOverlayKey, - ComparisonBaseline, -} from "@/lib/dashboard-layout"; -import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; -import { bucketCaptionKey } from "@/lib/charts/bucket-caption"; -import type { MeasurementType } from "@/generated/prisma/client"; -import { ChartOverlayControls } from "./chart-overlay-controls"; -import { ChartDataTable, type ChartDataTableColumn } from "./chart-data-table"; -import { ChartFooterReserve } from "./chart-skeleton"; -import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; -import { useViewportWidth } from "@/hooks/use-viewport-width"; -import { computeTickPositions } from "@/lib/charts/x-axis-density"; -import { - computeWindowStats, - type MetricWindowStats, -} from "@/lib/charts/window-stats"; -import { shouldFireDataReady } from "@/lib/charts/data-ready-latch"; -import { axisTickUnitSuffix } from "@/lib/charts/axis-unit"; + import { chartPointDayKey } from "@/components/day/chart-day"; import { ChartDayFooter, - OPEN_DAY_LINE, useChartDayLinks, } from "@/components/day/chart-day-links"; import { isWholeNumberType } from "@/components/day/use-day-value-format"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/use-auth"; +import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { useViewportWidth } from "@/hooks/use-viewport-width"; +import { axisTickUnitSuffix } from "@/lib/charts/axis-unit"; +import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { computeTickPositions } from "@/lib/charts/x-axis-density"; import { - ChartRangeTabs, - DEFAULT_CHART_RANGE, - rangeWindowDays, -} from "./chart-range-tabs"; -import { trendLineByTime } from "@/lib/stats/linear-fit"; -import { translateOrNull } from "@/lib/i18n/translate-code"; + useDateFormatPreference, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; -// The range tabs (7 / 30 / 90 / All, a calendar-day window ending now) and -// the "All" tab's ten-year fetch span live with the shared -// ``, so every chart drawn in days offers the same choice. - -interface HealthChartProps { - types: string[]; - title: string; - colors?: string[]; - unit?: string; - yAxisUnit?: string; - valueMode?: "raw" | "bmi"; - yAxisWidth?: number; - showYAxisUnit?: boolean; - valueBands?: Array<{ - min: number; - max: number; - color: string; - opacity?: number; - strokeOpacity?: number; - }>; - targetZones?: Array<{ - min: number; - max: number; - color: string; - opacity?: number; - label?: string; - textColor?: string; - lineOpacity?: number; - }>; - /** - * v1.4.16 — compact chart mode used by the Oura-style - * rationale card. Drops the range tabs, the moving-average / trend - * / target-band toggle row, and shrinks padding. Tooltip + gradient - * + personal-baseline behaviour stay intact so the small chart - * still tells the same visual story. - */ - mini?: boolean; - /** - * v1.4.16 — pin the chart to a specific rationale data - * window regardless of any parent UI state. Used by the rec-card's - * mini-chart so it always renders the window the rec is based on. - */ - windowOverride?: DataWindow; - /** - * v1.4.16 — when set to "lastMonth" / "lastYear", paint a - * dimmed prior-period overlay beneath the current series. The chart - * already fetches the full measurement history; the overlay is - * computed by `shiftDailySeriesForward` so the prior-period day at - * 30 / 365 days back lands directly under its current-period sibling - * on the visible x-axis. - * - * "none" or undefined renders the chart exactly as before — no - * regression for users who keep the toggle off. - */ - compareBaseline?: ComparisonBaseline; - /** - * v1.4.18 — per-chart overlay-prefs key. When supplied, the chart - * mounts an overlay-controls dropdown in the header and reads its - * three toggle states from the persisted user prefs. When omitted - * (mini mode, ad-hoc usage) the chart renders without controls and - * its toggles default to OFF (clean line). - */ - chartKey?: ChartOverlayKey; - /** - * v1.42 — 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), so binding a key to remember the range does not add - * controls the chart never had. Defaults to on. - */ - overlayControls?: boolean; - /** - * v1.4.20 — additive storyboard annotations. - * - * Each entry pins a vertical reference line + label to the data point - * whose Berlin-day key matches `date` (`YYYY-MM-DD`). The chart line - * + tooltip + tooltip-deltas remain untouched — Recharts' source-order - * layering paints `` between the cartesian grid and - * the data lines so the annotation reads as orientation, not data. - * - * Annotations whose date falls outside the visible window silently - * drop (we already use `ifOverflow="discard"` on every reference line - * that needs the same behaviour). - */ - annotations?: Array<{ date: string; label: string; color: string }>; - /** - * v1.4.25 — 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 (`var(--success)`) 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 — per-user display timezone. When passed (mount sites - * thread `useAuth().user?.timezone`), x-axis tick labels and the - * per-day bucket keys both render in the user's zone. When a mount - * passes none, the chart uses the signed-in account's zone (which - * `/api/auth/me` reports already resolved against the server default), - * and only before that has loaded the shared `DEFAULT_TIMEZONE` the - * server itself falls back to. - */ - userTimezone?: string; - /** - * v1.7.0 — display-time scalar applied to every raw value before it - * enters the chart pipeline (bucketing, y-domain, personal baseline, - * trend, tooltip). Used by the unit-fix mounts to render a metric in - * a derived display unit (e.g. WALKING_SPEED m/s → km/h via - * `valueScale={3.6}`) while raw storage stays canonical SI. - * - * Defaults to `1` — the identity scale — so every existing chart - * renders byte-identical to the pre-v1.7.0 behaviour. Recharts is - * untouched; the scale is folded into the value at the single read - * boundary so all downstream math operates on the scaled series - * uniformly. - */ - valueScale?: number; - /** - * v1.32.26 — display-time additive offset folded in alongside - * `valueScale` at the same read boundary: `display = raw * valueScale + - * valueOffset`. Required for the affine °C→°F conversion (a shift, not - * just a scale). Defaults to `0`. Because every tooltip delta / trend - * slope is computed as a DIFFERENCE of two offset values, the offset - * cancels there automatically — a rate never inherits the +32 shift. - */ - valueOffset?: number; - /** - * v1.12.8 — chart-reactive metric statistics. When supplied, the chart - * reports the per-type Min / Max / Median / Mean of the data currently - * visible under the active range tab (the 7 / 30 / 90 / All selector), - * keyed by `MeasurementType`. It fires on mount and whenever the range tab - * (or the underlying series) changes, so the `` a sub-page - * lifts the callback into always reflects the same window the chart paints. - * - * Mini mode never reports — the rationale card pins its own window and has - * no stat strip. The dashboard / ad-hoc mounts simply omit the callback. - */ - onVisibleStats?: (stats: Record | null) => void; - /** - * v1.12.8 — optional leading glyph for the chart card's ``. - * When supplied, the chart header renders the canonical icon + title row - * (same size + spacing as every other Insights tile) instead of the bare - * `

`. Omitted on the dashboard / mini mounts, which keep the compact - * heading. Accepts any component that takes a `className` (the contract - * `` and the stat strip's `icon` already use), so a page can - * thread the same glyph it hands the stat strip. - */ - titleIcon?: ComponentType<{ className?: string }>; - /** - * v1.16.0 — fires once the chart's data query has settled (initial - * load finished, success or error). The dashboard's shared reveal - * gate listens here so every chart cell swaps from skeleton to - * content in one frame instead of popping in one after another. - * Optional and repeat-safe: the dashboard's `markReady` is - * idempotent; non-dashboard mounts simply omit it. - */ - onDataReady?: () => void; - /** - * v1.18.6 — pre-fetched daily series, keyed by `MeasurementType`. When - * the dashboard batches every visible chart's series into ONE - * `/api/measurements/series-batch` round-trip (audit finding #2), it - * passes each chart its slice here. When supplied, the chart's queryFn - * consumes these rows for the covered types instead of issuing its own - * per-type `/api/measurements` fetch. Omitted on non-dashboard mounts - * (insights sub-pages, mini cards) which keep self-fetching. - * SLEEP_DURATION is never pre-fetched (it rides the per-night `/series` - * adapter), so a sleep chart self-fetches that branch regardless. - * - * The rows must already be scoped to the chart's active fetch window; - * the prop is keyed into the query cache so a range-tab change (wider - * window) re-fetches cleanly rather than blending a batched slice with - * a self-fetched one. - */ - preloadedSeries?: Record; - /** - * v1.19.0 — the day-span the `preloadedSeries` slice actually covers - * (the window the dashboard's batched fetch requested). The chart reads - * the preloaded slice ONLY when the active range tab's window fits - * within this coverage; a wider tab (90 / All on a 30-day batch) falls - * through to a real fetch for the requested window instead of silently - * re-rendering the narrow batched slice. - * - * Required whenever `preloadedSeries` is supplied. Mount sites set it to - * the exact day-span their batch fetches (the dashboard batch spans 31 - * days). Omitted on self-fetching mounts. - */ - preloadedCoverageDays?: number; - /** - * Render the drawn points as a table beneath the chart, behind a - * collapsed disclosure. Opt-in: the metric sub-pages want it (a chart - * shows the shape and hides the numbers, and an appointment needs the - * numbers), the dashboard tiles do not — a table under every tile would - * bury the dashboard it is meant to summarise. - */ - showDataTable?: boolean; - /** - * v1.42 — the chart is a door to its days. A click on a point opens that - * day over the page (on a touch screen the tooltip offers it), the open day - * is marked with a dashed line, and a row of dots under the axis marks the - * days that hold anything. Only while the chart is drawn in days, and never - * in `mini` mode: a tile's sparkline has its own destination and targets - * too small to hit. The metric sub-pages turn it on; the dashboard does not. - */ - dayLinks?: boolean; -} - -interface ChartDataPoint { - date: string; - timestamp: number; - pointIndex?: number; - // v1.8.5 — `${type}__range` keys carry a `[min, max]` tuple for the - // range-band ; every other key stays a scalar. - [key: string]: string | number | undefined | [number, number]; -} - -interface MeasurementApiRow { - measuredAt: string; - value: number; - // v1.8.5 — per-day min / max emitted by the rollup daily-aggregate - // path (non-cumulative metrics only). Drive the Apple-Health-style - // range band shaded around the mean line. Not drawn on the 7-day range - // and absent on cumulative metrics. - // v1.18.6 — `null` accepted alongside `undefined` so the batched - // series endpoint's row shape (which carries `number | null`) feeds - // `preloadedSeries` directly. The bucketing loop already guards each - // with a `typeof … === "number"` check, so a null is ignored exactly - // like an absent value. - minValue?: number | null; - maxValue?: number | null; - // v1.4.43 — the rollup / daily-aggregate paths return - // the underlying raw-row count per bucket. Used downstream to gate - // the "more days needed" empty-state copy on the actual measurement - // count rather than the number of distinct calendar days. Optional - // because the per-night sleep series does not carry it. - count?: number; -} - -interface VisibleTargetZone { - key: string; - low: number; - high: number; - topPct: number; - heightPct: number; - color: string; - opacity: number; - label: string | undefined; - textColor: string | undefined; - lineOpacity: number; -} - -const BASE_TYPE_LABEL_KEYS: Record = { - 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", -}; +import { ChartDataTable } from "./chart-data-table"; +import { ChartEmptyState } from "./chart-empty-state"; +import { ChartErrorState } from "./chart-error-state"; +import { DEFAULT_CHART_RANGE } from "./chart-range-tabs"; +import { ChartFooterReserve } from "./chart-skeleton"; +import { ChartBandOverlay } from "./health-chart/band-overlay"; +import { HealthChartHeader } from "./health-chart/chart-header"; +import { + annotationLayers, + baselineLayers, + openDayLayers, + seriesLayers, + targetZoneLayers, + verticalMarkerLayers, +} from "./health-chart/chart-layers"; +import { makeTooltipContent } from "./health-chart/chart-tooltip-content"; +import { + getTypeLabel, + makeDayKeyFormatter, + resolveAnnotationPositions, + resolveVerticalMarkerPositions, +} from "./health-chart/series-math"; +import type { ChartDataPoint, HealthChartProps } from "./health-chart/types"; +import { + useHealthChartLabels, + useHealthChartOverlays, +} from "./health-chart/use-health-chart-overlays"; +import { useHealthChartSeries } from "./health-chart/use-health-chart-series"; +import { useHealthChartView } from "./health-chart/use-health-chart-view"; +import { resolveMiniRangePoints } from "./mini-window"; /** - * 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. + * A metric over time: the line, the overlays a person can switch on (the + * 7-day average, the trend, the target range, the prior period), reference + * bands and zones, annotations and event markers, the range tabs, the + * tooltip, and the days as doors. The parts live in `./health-chart/`: the + * series read, the derived view, the overlays and labels, the header, the + * plot layers and the tooltip; this component wires them together. */ -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 - * (a resting-pulse trend read "RESTING_HEART_RATE"). - */ -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; -} - -/** - * Build a `YYYY-MM-DD` day-key formatter pinned to the requested - * timezone. Memoised per-tz inside the component so we don't allocate a - * new `Intl.DateTimeFormat` on every measurement-row pass. - */ -function makeDayKeyFormatter(tz: string): Intl.DateTimeFormat { - return new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); -} - -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}`; -} - -function dayKeyToTimestamp(dayKey: string): number { - const [year, month, day] = dayKey.split("-").map(Number); - return Date.UTC(year, month - 1, day, 12, 0, 0); -} - -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, - }; - }); -} - -/** - * v1.4.16 — personal-baseline helper. - * - * Computes the median (50th-percentile) value of a daily-aggregated - * series, capped at the most-recent 90 daily points (Apple Health uses - * the rolling-90-day window for "your normal" framing). Returns `null` - * when fewer than 5 points exist — a baseline drawn off 1-2 readings is - * misleading; surface nothing instead. - * - * Pure & deterministic so the unit test pins exact medians. - */ -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]; -} - -/** - * v1.4.20 — 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 `` 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; timestamp?: number }> | undefined, -): ResolvedVerticalMarker[] { - if (!markers || !chartData || chartData.length === 0) return []; - const indexByDate = new Map(); - 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. 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; -} - -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; -} - export function HealthChart({ types, title, @@ -616,46 +102,34 @@ export function HealthChart({ dayLinks = false, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); - // A mount without the prop used to pin Europe/Berlin, so those charts - // cut days in Berlin for every user whatever their profile said. + // A mount without the prop used to pin Europe/Berlin, so those charts cut + // days in Berlin for every user whatever their profile said. const userTimezone = userTimezoneProp || user?.timezone || DEFAULT_TIMEZONE; const { t, locale } = useTranslations(); const fmt = useFormatters(); - // Issue #490 — x-axis tick + tooltip date labels. Every timestamp this - // chart formats encodes a CALENDAR DAY (noon-UTC of a profile-tz day - // key, or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), - // so labels render UTC-pinned via `makeBucketLabelFormatters` — the - // encoded day paints verbatim in every profile zone. Rendering these - // encodings through a profile-tz formatter shifted week/month bucket - // labels a day/month back for zones west of Berlin. Day-key BUCKETING - // below still uses `userTimezone` — only labels are pinned. - // Issue #922 — the UTC pin decides WHICH day the label names; the - // profile's date order decides how it is spelled. Both travel. + // Every timestamp the chart labels encodes a CALENDAR DAY (noon UTC of a + // profile-zone day key, or a bucket start), so labels render UTC-pinned + // and the encoded day paints verbatim in every zone (#490); the profile's + // date order decides how it is spelled (#922). Bucketing still uses the + // profile zone. const dateFormat = useDateFormatPreference(); const tzFmt = useMemo( () => makeBucketLabelFormatters(locale, dateFormat), [locale, dateFormat], ); - // v1.4.25 — per-row day-key formatter used to bucket measurement - // rows by the user's local calendar day. Memoised on userTimezone so - // the inner per-row loop reuses a single Intl.DateTimeFormat. const dayKeyFormatter = useMemo( () => makeDayKeyFormatter(userTimezone), [userTimezone], ); - // v1.4.16: when a windowOverride is supplied, seed the range - // state from it so the chart pins to that window. Mini mode also - // hides the range tabs, so the user can't change it. + // A `windowOverride` pins the range (mini mode hides the tabs anyway). const initialRangePoints = windowOverride ? resolveMiniRangePoints(windowOverride) : DEFAULT_CHART_RANGE; const [rangePoints, setRangePoints] = useState(initialRangePoints); - // v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow - // / showTargetRange) are persisted per chart via the new - // `useChartOverlayPrefs` hook. When the chart isn't bound to a - // persistent key (mini mode / ad-hoc render) the hook short-circuits - // to the clean-line default and skips its dashboard-layout fetch. + // The overlay toggles and the comparison persist per chart; an unbound + // chart (mini mode, ad-hoc use) gets the clean-line default and skips the + // layout read. const overlayPrefs = useChartOverlayPrefs(chartKey); const showMA = overlayPrefs.prefs.showTrendIndicator; const showTrend = overlayPrefs.prefs.showTrendArrow; @@ -664,21 +138,11 @@ export function HealthChart({ ? overlayPrefs.prefs.comparisonBaseline : compareBaseline; - // v1.30.1 — hydrate the range tab from the persisted prefs once - // they arrive, mirroring the overlay toggles above. A plain - // `useState(30)` initializer can't see the persisted value because - // the dashboard-layout query resolves after mount; `rangePoints` also - // drives several derived memos below by identity, so it stays local - // state (unlike the toggles, which read `overlayPrefs.prefs` live) - // and is synced in exactly once. `windowOverride` charts (mini mode) - // are pinned and never adopt a persisted range. Adjusted during - // render (React's documented "adjust state during render" escape - // hatch, not an effect — the lint rule that bans ref reads during - // render is why this is a second piece of state rather than a ref) - // so the very first render that sees the resolved prefs already - // reflects them; `rangeHydrated` guards against re-running past the - // first hit, so a later cache refetch can't stomp a range the user - // already picked this session. + // Adopt the persisted range tab once the prefs arrive. Adjusted during + // render (React's documented escape hatch, not an effect), so the first + // render that sees the prefs already reflects them; `rangeHydrated` stops a + // later refetch from stomping a range picked this session. Pinned charts + // never adopt one. const [rangeHydrated, setRangeHydrated] = useState(false); if ( !windowOverride && @@ -690,771 +154,78 @@ export function HealthChart({ setRangePoints(overlayPrefs.prefs.rangePoints); } - // v1.4.19 — viewport-aware tick density. Recharts' default is one - // tick per data point; the helper caps that at 4-10 visible labels - // depending on viewport width so the X-axis reads consistently across - // every chart card. + // Viewport-aware tick density: 4 to 10 labels, whatever the point count. const viewportWidth = useViewportWidth(); - // v1.36.1 — `useAuth()` answers about the person at the keyboard, and under a - // switch that is the delegate. Dividing the OWNER's weights by the - // DELEGATE's height squared produces a plausible-looking BMI that is simply - // somebody else's, shaded by fixed category bands, on a page that names the - // owner. The same substitution was fixed for the reference bands in this - // release; this is the same mistake one component over, and a wrong health - // figure is worse than an absent one. - // - // Inside a shared record the chart therefore draws no BMI at all rather than - // a wrong one. Publishing the owner's height to every client that can read - // their weights is a bigger decision than this fix, and it belongs with the - // per-module scope work rather than here. + // `useAuth()` is the person at the keyboard, and under a switch that is the + // delegate: dividing the owner's weights by the delegate's height squared + // gives a plausible BMI that is somebody else's. Inside a shared record the + // chart draws no BMI rather than a wrong one. const { inSharedRecord } = useRecordCapabilities(); const bmiDivisor = valueMode === "bmi" && !inSharedRecord && user?.heightCm ? (user.heightCm / 100) ** 2 : null; - // v1.4.28 (R1.2 H0) — derive a bounded date window from the - // active range selector so the chart fetches only what it will - // actually render. The legacy `while (true)` paginated walk pulled - // tens of thousands of pulse rows on every visit; the bounded - // window + the server-side aggregation hint keeps every navigation - // payload-flat regardless of underlying density. - // - // Comparison overlays (lastMonth / lastYear) extend the `from` - // boundary backwards by the shift distance so the prior-period - // slice rides the same fetch and `shiftDailySeriesForward` finds - // its input. - // - // v1.19.0 — "All" range (rangePoints === 0) requests `ALL_RANGE_DAYS` - // (~10 years) rather than the old 365-day cap, which silently truncated - // any account holding more than a year of history. The window is a - // generous fixed bound (not derived per-account) so the cache key stays - // stable across the session. - // - // v1.19.2 — the server reader steps the bucket tier up to WEEK / MONTH - // for windows past the DAY cap, so the "All" tab now returns coverage - // across the whole history (downsampled by tier) rather than the most - // recent ~365 daily buckets. The client folds those coarse buckets and - // `bucketTimeSeries` re-buckets to the visible range as before. - // - // The window ends at the end of TODAY in the profile time zone, not at the - // mount instant: a reading saved after the chart mounted (measuredAt = now) - // has to fall inside the window the invalidated query refetches. `todayKey` - // re-derives on every render, so the window stays put all day (a stable - // cache key) and moves on by itself after midnight. - 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]); - - // v1.18.6 — honour the dashboard's batched slice ONLY for windows over - // 7 days and ONLY when EVERY non-sleep type the chart renders is present - // in the slice. SLEEP_DURATION is never batched (per-night `/series`), - // so a chart that includes it always self-fetches. The 7-day tab - // self-fetches its own window. - // - // v1.19.0 — also require that the requested window FITS within the - // batch's actual coverage. The dashboard batches only a ~30-day slice; - // pre-fix, picking the 90 / All tab kept reading that 30-day slice - // (`usePreloaded` never compared windows), so the chart showed ~30 days - // no matter the tab. The mount now threads `preloadedCoverageDays`; a - // tab wider than the coverage falls through to a real fetch for the - // requested window. Without an explicit coverage (legacy mount), treat - // it as 0 so any preloaded 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 fetch round-trip. - // Keeping the previous range's series visible is only honest because the - // render below pairs it with a visual dimming while `isPlaceholderData` - // is true — the old range must never masquerade as the new one. - placeholderData: keepPreviousData, - // v1.4.40 — route the chart-data key through `queryKeys.chartData` - // so the `["chart-data"]` prefix lands in `measurementDependentKeys` - // and a fresh measurement evicts every per-chart daily cache in one - // pass (audit C2). The factory-packed tuple is byte-identical with - // the pre-v1.4.40 inline literal so the cache layout stays stable. - // v1.15.20 — BMI is a pure transformation of the WEIGHT series - // (value / height²), so the BMI view shares the raw series' cache - // entry instead of re-fetching the same window under its own key. - // The key pins the literal "raw" / "no-bmi" discriminators (the - // factory tuple shape is unchanged) and the BMI division happens in - // the `select` callback below — the factory rule stays intact and - // the cached data is mode-agnostic. - queryKey: queryKeys.chartData( - types.join(","), - "raw", - "no-bmi", - // v1.4.25 — bucket keys + tick labels depend on the active - // user timezone, so re-key the cache when it changes. Without - // this, a tz change inside a session would render stale buckets. - userTimezone, - // v1.4.28 — bound the cache by the active fetch window so - // a range-tab change re-fetches the right slice rather than - // re-running the unbounded walk. - fetchWindow.from, - fetchWindow.to, - // v1.7.0 — re-key when the display scale changes so a km/h chart - // never reads a m/s-scaled sibling out of the cache. - valueScale, - // v1.18.6 — discriminate a batched-slice render from a self-fetch - // ONLY on the dashboard mounts that pass `preloadedSeries`. When the - // prop is absent (insights sub-pages, mini cards) the empty string - // keeps the factory tuple byte-identical with the pre-v1.18.6 - // 8-element key, so those callers' cache layout is unchanged. On a - // dashboard mount the discriminator flips "preloaded" → "fetch" when - // a range-tab change drops batched coverage, re-fetching cleanly. - preloadedSeries ? (usePreloaded ? "preloaded" : "fetch") : "", - ), - // v1.4.28 — cache the bounded window for a minute so tab - // navigation between insights sub-pages does not re-fire every - // chart's fetch. `gcTime` keeps the inactive cache for five - // minutes so a quick back-and-forth doesn't repay the cost. - staleTime: 60_000, - gcTime: 5 * 60_000, - queryFn: async () => { - const dailyAggregates = new Map< - string, - { - timestamp: number; - values: Record< - string, - { - sum: number; - count: number; - // v1.8.5 — per-bucket spread for the range band. Seeded - // from the rollup daily-aggregate's min / max columns; - // null when the API row carries no spread (raw-row path - // or cumulative metric). - min: number | null; - max: number | null; - } - >; - } - >(); - - // v1.11.5 — SLEEP_DURATION is stored one row per sleep STAGE per night - // (minutes). The generic `/api/measurements` daily-aggregate path - // averages those stage rows, which neither sums a night nor isolates - // the asleep stages — it surfaced a ~2× inflated, mislabeled chart. The - // `/api/measurements/series?kind=sleep` adapter reconstructs ONE point - // per night carrying the night's TIME-ASLEEP in HOURS (CORE + DEEP + REM, - // bare-ASLEEP only when no granular stage exists, IN_BED + AWAKE - // excluded, dual-source nights collapsed to one source). Routing the - // sleep chart through it keeps every sleep surface on the same - // reconstruction. One point per night → `sum/count` yields the night's - // hours unchanged. - async function fetchSleepNights() { - const sleepParams = new URLSearchParams(); - sleepParams.set("kind", "sleep"); - // The series route caps sleep at 365 days regardless; clamp the - // request so a longer comparison window still resolves. - sleepParams.set( - "days", - String( - Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays))), - ), - ); - // v1.16.8 — a failed fetch rejects the whole query instead of - // silently resolving an empty series: the swallowed-catch - // variant cached the empty result as fresh success, so an - // outage painted "no data in this range" with no retry path. - 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); - - // v1.18.6 — when the dashboard handed this chart a batched slice - // for the type, fold those rows instead of a per-type fetch. The - // rows came from `/api/measurements/series-batch`, which reads - // through the SAME rollup path the per-type daily fetch hits, so - // the bucketed result is byte-identical. `usePreloaded` already - // gated the window (`> 7`) + per-type coverage above; default to - // an empty slice (paints the no-data card) rather than fetching, - // so a covered chart never issues a network request. - const page = - usePreloaded && preloadedSeries - ? (preloadedSeries[type] ?? []) - : await (async () => { - // v1.16.8 — a failed per-type fetch rejects the whole - // query instead of silently dropping the series: the - // swallowed-catch variant cached the partial/empty result - // as fresh success, so an outage painted the empty state - // (or a half chart) with no retry path. Rejecting lets - // TanStack's retry semantics and the in-card error state - // below do their jobs. - const data = await apiGet<{ - measurements?: MeasurementApiRow[]; - }>(`/api/measurements?${typeParams}`); - return data?.measurements ?? []; - })(); - - for (const measurement of page) { - // v1.7.0 — fold the display-time scale into the raw value at - // the single read boundary so bucketing, the y-domain, the - // personal baseline, the trend, and the tooltip all operate - // on the scaled series uniformly. `valueScale` defaults to 1 - // (identity), so non-unit-fixed charts read byte-identical. - // v1.15.20 — the BMI division moved into the query's `select` - // callback so the cached series stays raw and the WEIGHT and - // BMI views share one cache entry. - 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; - // v1.8.5 — carry the rollup bucket's min / max through when the - // API supplies them (daily-aggregate path, non-cumulative). - // `valueScale` already folded into `value` above; apply the - // same scale to the spread so the band tracks the line. The - // BMI view drops the range tuple in its `select` callback (it - // has never rendered the band), so the raw cache always - // carries the spread. The 7-day range never drew the band (it - // used to read raw rows, which carry no spread) and still does - // not, so its picture is unchanged now that it reads days too. - 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)) { - // v1.4.29.1 — cumulative HealthKit types (steps, active energy, - // distance, flights, daylight) must reduce with sum, not the - // per-sample average. The server already returns one row per - // day (or, for the coarser tiers of the "All" range, one row per - // week or month carrying its average day); a day that arrives - // as several rows must still add up rather than average. - const isCumulative = CUMULATIVE_HK_TYPES.has( - type as MeasurementType, - ); - point[type] = isCumulative ? stats.sum : stats.sum / stats.count; - // v1.8.5 — emit the day's spread as a `[min, max]` tuple key - // so the range-band can shade it. Only when the - // rollup supplied both bounds and the metric is not - // cumulative (a SUM 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; - }, - // v1.15.20 — BMI view: transform the cached raw WEIGHT series into - // BMI at read time. The transformation lives in `select` (not the - // queryFn) so the cache entry stays raw and is shared with the - // weight chart; tanstack re-runs the projection without a refetch. - // No height on the profile → empty series (matches the previous - // behaviour where every point was skipped). The `${type}__range` - // tuples are dropped — the BMI view has never rendered 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: isAuthenticated, - }); const { data, isLoading, isError, error, isPlaceholderData, refetch } = - chartQuery; - - // v1.16.0 — report the settled data query to the dashboard's shared - // reveal gate. `isLoading` only covers the INITIAL fetch (a later - // range-tab change creates a new cache entry but the gate has long - // latched by then), so this fires exactly when the first paintable - // state — chart, empty-window card, or error fallback — is available. - // - // v1.20.1 — fire exactly once, gated on a ref rather than on the - // `onDataReady` prop identity. The dashboard passes an inline - // `() => markChartReady(id)` closure, so `onDataReady` is a NEW - // reference on every parent render; keying the effect on it re-ran the - // notify on every commit (e.g. a snapshot refetch on tab-resume), and - // that per-commit passive effect kept the chart-row's Radix-Popper - // anchors re-committing until React tripped its update-depth guard - // (#185). The ready signal is monotonic — once a chart has settled it - // stays settled — so latch it behind a ref and depend on `isLoading` - // alone. `onDataReady` is read through a ref so a late-arriving handler - // still fires without re-arming the effect. - 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]); - - // v1.4.6: aggregate to weekly / monthly when the visible range is - // long enough that drawing every daily point would clutter the - // chart. Picking the bucket from the *visible* range, not the - // total dataset, so a "30 days" toggle still shows daily even on - // a five-year-old account. - // - // Held in its own memo so the grain has ONE source. `chartData` bucket - // by it, and the data table below the chart captions its rows with it — - // a table that said "daily" over a weekly mean would be a lie, and - // recomputing the decision separately is how that lie starts. - // - // v1.37.29 — the slice is a CALENDAR-DAY window, matching what the - // range tabs promise and what the fetch window requests. The previous - // `slice(-rangePoints)` was a no-op on the self-fetched path (the fetch - // is already bounded to the window) but silently switched to - // "last N readings" on the dashboard's preloaded ~30-day batch slice — - // the same tab meant two different things depending on the mount path. - 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 (renders as "no data in this - // range") — returning null here would drop chartData back to the - // UNFILTERED series and quietly widen the window instead. - 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; - - const { sliced, bucketType } = visibleSlice; - - 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((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; - }, [data, visibleSlice, showMA, showTrend, types, tzFmt, userTimezone]); - - // v1.12.8 — chart-reactive metric statistics. - // - // Compute the per-type Min / Max / Median / Mean over the data currently - // visible under the active range tab — i.e. the day-window-filtered, - // bucketed `chartData` the chart already holds (no re-fetch). The result - // recomputes whenever the range tab changes the visible slice, so the - // `` a sub-page lifts the callback into always reflects the - // same window the chart paints. Skipped in mini mode (no stat strip). - const visibleStatsByType = useMemo | null>(() => { - if (mini || !chartData?.length) return null; - const out: Record = {}; - 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]); - - // Report the visible-range stats up to the sub-page so the shared - // `` can read them. Effect (not render-time call) so the - // parent state update never fires during this component's render. - // Only once the read has settled: a report during the initial 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]); - - // v1.4.16 — comparison overlay. - // - // When the toggle is active, derive a prior-period series from the - // ALREADY-FETCHED daily aggregates (`data`), shift the timestamps - // forward by 30 / 365 days, and stamp the values into the visible - // `chartData` under `${type}_compare` keys. The Recharts - // children below render those keys as dimmed dashed lines beneath - // the current series, and the tooltip picks them up so the user can - // read both numbers + the delta in one place. - // - // The merge is point-equal-day: a prior-period point only paints - // when its shifted timestamp lands on a visible day. Sparse prior - // periods are silently dropped (the chart's empty caption surfaces - // "Comparison unavailable — no data from last month yet" via the - // hasComparisonData flag below). - const chartDataWithCompare = useMemo(() => { - if (!chartData || effectiveCompareBaseline === "none") return chartData; - if (!data?.length) return chartData; - - const shifted = shiftDailySeriesForward( - data.map((row) => ({ - timestamp: row.timestamp, - values: Object.fromEntries( - types.map((type) => [type, row[type] as number | undefined]), - ), - })), + useHealthChartSeries({ + types, + valueMode, + bmiDivisor, + valueScale, + valueOffset, + userTimezone, + dayKeyFormatter, + tzFmt, + rangePoints, effectiveCompareBaseline, - ); - - // Index shifted rows by the same day-key the chart already uses. - const shiftedByDay = new Map>(); - 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; + preloadedSeries, + preloadedCoverageDays, + enabled: isAuthenticated, + onDataReady, }); - }, [chartData, effectiveCompareBaseline, data, types, tzFmt]); - /** - * v1.4.16 — true when at least one visible day has a prior- - * period value to overlay. Drives the "Comparison unavailable" caption - * fallback in the chart header. Empty input → false → caption shows; - * a partial overlay (some days have prior data, some don't) is treated - * as "available" so we don't surprise the user with a missing caption - * when they can clearly see SOME dimmed history. - */ - 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]); - - // v1.37.29 — the header chip reads the SAME grain decision `chartData` - // and the data table use. This was a second memo re-deriving the bucket - // from a `slice(-rangePoints)` of its own — exactly the "recomputing - // the decision separately" drift the visibleSlice doc-comment warns - // about, and the two had already diverged on the windowing semantics. - 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`)); - // v1.4.16 — extend the y-domain to fit the prior-period - // overlay. Without this the dimmed line can clip outside the - // visible range when last-month / last-year had a different scale. - 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); - }, [ + const { + visibleSlice, + chartData, + visibleStatsByType, chartDataWithCompare, - effectiveCompareBaseline, + hasComparisonData, + activeBucket, + bucketCaption, + yDomain, + } = useHealthChartView({ + data, + rangePoints, + types, + userTimezone, + tzFmt, showMA, showTrend, - types, - ]); + effectiveCompareBaseline, + mini, + isLoading, + isError, + onVisibleStats, + }); - const visibleBands = useMemo(() => { - if (!showBands || !valueBands?.length || !yDomain) return []; + const { visibleBands, visibleTargetZones, trendInfo, personalBaselines } = + useHealthChartOverlays({ + showBands, + showTrend, + valueBands, + targetZones, + yDomain, + chartData, + types, + unit, + valueMode, + t, + fmt, + }); - const [domainMin, domainMax] = yDomain; - const span = domainMax - domainMin; - if (!Number.isFinite(span) || span <= 0) return []; - - return valueBands - .map((band, index) => { - const low = Math.max(Math.min(band.min, band.max), domainMin); - const high = Math.min(Math.max(band.min, band.max), domainMax); - if (high <= low) return null; - - const topPct = ((domainMax - high) / span) * 100; - const heightPct = ((high - low) / span) * 100; - - return { - key: `${band.min}-${band.max}-${index}`, - topPct, - heightPct, - color: band.color, - opacity: band.opacity ?? 0.2, - strokeOpacity: band.strokeOpacity ?? 0.35, - }; - }) - .filter( - ( - entry, - ): entry is { - key: string; - topPct: number; - heightPct: number; - color: string; - opacity: number; - strokeOpacity: number; - } => entry !== null && entry.heightPct > 0, - ); - }, [showBands, valueBands, yDomain]); - - const visibleTargetZones = useMemo(() => { - if (!showBands || !targetZones?.length || !yDomain) return []; - const [domainMin, domainMax] = yDomain; - const span = domainMax - domainMin; - if (!Number.isFinite(span) || span <= 0) return []; - - const zones: Array<{ - key: string; - low: number; - high: number; - topPct: number; - heightPct: number; - color: string; - opacity: number; - label: string | undefined; - textColor: string | undefined; - lineOpacity: number; - }> = []; - - for (const [index, zone] of targetZones.entries()) { - const low = Math.max(Math.min(zone.min, zone.max), domainMin); - const high = Math.min(Math.max(zone.min, zone.max), domainMax); - if (high <= low) continue; - const topPct = ((domainMax - high) / span) * 100; - const heightPct = ((high - low) / span) * 100; - - zones.push({ - key: `${zone.min}-${zone.max}-${index}`, - low, - high, - topPct, - heightPct, - 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]); - - // v1.4.33 — preserve one decimal when the visible Y-domain span is - // narrow enough that two distinct gridlines would otherwise round to - // the same integer (e.g. weight at 82.8 vs 83.5 both rendering as - // "83 kg"). The threshold matches the body-composition cluster: any - // domain spanning < 6 units gets `0.1`-precision ticks; wider domains - // keep the integer formatter so BP / glucose / steps stay clean. + // One decimal on the axis while the visible domain spans under 6 units, so + // two gridlines never round to the same integer (82.8 vs 83.5 kg). const yDomainSpan = yDomain ? yDomain[1] - yDomain[0] : null; const useDecimalAxis = yDomainSpan !== null && yDomainSpan < 6; const formatAxisValue = (value: number) => @@ -1462,214 +233,37 @@ export function HealthChart({ const formatTooltipValue = (value: number) => fmt.number(value, 1); - // Accessibility — a concise spoken summary of the primary series so a screen - // reader announces the shape of the chart (range, latest value, direction) - // instead of an unlabelled graphic. Mirrors the visible-range stats the - // chart already holds; falls back to the raw min/max when the stat strip - // is suppressed (mini mode). - 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, + const { primaryType, chartAriaLabel, dataTableColumns } = + useHealthChartLabels({ + types, + valueMode, + t, + fmt, + chartData, + chartDataWithCompare, + visibleStatsByType, + showDataTable, + mini, }); - }, [ - primaryType, - valueMode, - t, - chartDataWithCompare, - chartData, - visibleStatsByType, - fmt, - ]); - - // Columns for the data table below the chart: one per series the chart - // actually drew a value for, labelled by the SAME helper that names the - // Recharts , so a header and a legend entry can never disagree. A - // type with no value anywhere in the visible window gets no column — a - // column of dashes states nothing the chart's own empty line does not. - const dataTableColumns = useMemo(() => { - 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, - ]); - - 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, - ); - - // v1.4.16 — delegate to the pure helper so the long- - // window split-half delta gets a unit-test that doesn't depend - // on Recharts. See `src/lib/analytics/window-trend.ts`. - 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}` : ""; - - // v1.4.16: when the visible window is long (e.g. "All" - // on a multi-year account), the per-week delta becomes vanishingly - // small and rounds to ±0.0 in the formatter — the maintainer's complaint - // "Wenn ich alle anklicke, dann wird einfach Null Veränderung - // angezeigt" was exactly this: a slope of ~0.5 kg/year prints - // as "+0.0 kg/week" and the user reads it as "no change". For - // windows ≥ SPLIT_HALF_THRESHOLD_DAYS we additionally surface - // the first-half vs. second-half mean delta — a single, - // meaningful number that cannot round to zero unless the metric - // truly didn't 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]); - - // v1.4.16 — personal baseline (90-day rolling median) per type. - // Painted as a faint dashed reference line labelled "Your normal" so - // the user can read each point against their own baseline, not an - // absolute value the chart axis happens to render. - const personalBaselines = useMemo(() => { - if (!chartData?.length) return new Map(); - const map = new Map(); - 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]); - - // v1.4.43 — keep the card shell mounted even when the loaded - // payload is empty. Pre-fix the early `return null` here erased the - // whole widget (header + range tabs + chart) and the user saw an - // unexplained gap on the dashboard. The in-card render branch below - // now paints a "no data in this range" empty state so the user - // understands the chart loaded but the selected window holds no - // measurements — distinct copy from the < 3-points "more days - // needed" hint so the two situations don't blur. + // An empty window keeps the card shell and paints "no data in this range" + // inside it, rather than leaving an unexplained gap. const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1); - // v1.4.20 — 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, ); - - // v1.4.25 — 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 - // below never paints a line for an out-of-range - // day. const verticalMarkerPositions = resolveVerticalMarkerPositions( verticalMarkers, chartData, ); - // v1.42 — the chart as a door to its days (`dayLinks`). Daily points only: - // a week or month point averages many days and has no one day to open. + // The days as doors: daily points only (a week or month point averages + // many days and has no one day to open). const dayLinksActive = dayLinks && !mini && activeBucket === "day"; - // What the day shows at its top when it is opened from a point: the - // chart's own name and the point's value as the tooltip reads it. + // What the day shows at its top when opened from a point: the chart's name + // and the point's value as the tooltip reads it. const dayFocusFor = (point: ChartDataPoint | undefined) => { if (!point) return null; const parts = types @@ -1705,20 +299,14 @@ export function HealthChart({ const showContextDetails = showMA || showTrend || showBands; const animationsEnabled = !prefersReducedMotion(); + const label = (type: string) => getTypeLabel(type, valueMode, t); - // v1.4.16 — mini mode tunes padding + margins so the chart fits - // inside the rationale card without overwhelming the surrounding - // rows. Range tabs + toggle row are suppressed entirely; the chart - // pins to `windowOverride` (or the default 30pt) instead. + // Mini mode fits the rationale card: tighter padding, no tabs or toggles. const containerClass = mini ? "bg-card border-border rounded-md border p-2" : "bg-card border-border rounded-xl border p-4 md:p-6"; - // v1.4.27 — chart height now reads from a CSS variable - // (`--chart-height`) so consumers can override the default per-mount - // without re-styling the component. The `mini` branch keeps the - // 140 px contract; the regular branch defaults to 240 px on mobile - // and 280 px from `md:` upwards (the wider container can absorb a - // taller chart without crowding the tile strip above). + // The height reads from `--chart-height` so a mount can override it: 140 px + // in mini mode, else 240 px, and 280 px from `md` up. const chartHeightClass = mini ? "h-[var(--chart-height,140px)]" : "h-[var(--chart-height,240px)] md:h-[var(--chart-height-md,280px)]"; @@ -1726,128 +314,28 @@ export function HealthChart({ return (
{!mini && ( - /* v1.4.19 — mobile-first header layout. - - Pre-fix: title + bucket chip + comparison chip + 4 range tabs + - cog all sat on a single justify-between flex row. On Pixel 5 - the chips ate enough horizontal space that the range tabs - wrapped to a second row INSIDE that flex (header height - jumped from 44 px → 92 px); on Galaxy Fold compact (280 px) - the tabs split into 3-4 rows. - - Mobile (default): two stacked rows. Title + non-essential - chips on row 1; range tabs + cog right-aligned on row 2. - Bucket-aggregation + comparison chips hide entirely below - `sm:` because they're decorative and the chart's range tabs - already communicate the visible window. The cog dropdown - stays accessible to surface those overlays explicitly. - - ≥sm: original side-by-side layout, all chips visible. */ -
-
- {/* v1.12.8 — when a `titleIcon` is supplied (every Insights - metric subpage), the chart card leads with the canonical - `` (icon + title at `text-base`, foreground - colour, h-5 w-5) so it matches the assessment / target / - stat tiles. The dashboard / ad-hoc mounts omit the icon and - keep the compact heading. - - That compact heading is an `

`, not an `

`. The card is - a sibling of the other cards in the same grid, and those name - themselves at the second level already — the dashboard's - "Recent unlocks" and "Recent workouts" tiles both render - ``, and the metric subpages' target / - usual-range cards are `

` too. Sitting one level lower made - the chart card the odd one out AND skipped a level under the - page `

`: the dashboard's rendered ladder read h1 → h3 → - h3 → h3 → h2 → h2, and /insights/bmi read h1 → h3 → h2. A - heading level is a claim about nesting, and the chart card is - not nested inside anything. */} - {titleIcon ? ( - - ) : ( -

{title}

- )} - {bucketCaption && ( - - {t(bucketCaption)} - - )} - {/* v1.4.16 — comparison caption. Inline with the - bucket-aggregation chip so the user reads "what window - am I looking at" + "what comparison is overlaid" in one - glance. The "Comparison unavailable" fallback uses - neutral muted styling so it doesn't read as an error. - - v1.4.19 — hidden on mobile to free up the title row. */} - {effectiveCompareBaseline !== "none" && hasComparisonData && ( - - {t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.captionLastMonth" - : "comparison.captionLastYear", - )} - - )} - {effectiveCompareBaseline !== "none" && !hasComparisonData && ( - - {t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.unavailable.lastMonth" - : "comparison.unavailable.lastYear", - )} - - )} - {/* v1.16.8 — the in-header "adjust targets" link is gone. - The page-level target-adjust control in the sub-page - header (``) owns the affordance; the - chart card stays a read surface. */} -

-
- { - // v1.12.8 — a range-tab change re-slices `chartData`; the - // visible-range stats memo recomputes off the new slice and - // the stat strip follows automatically. - setRangeHydrated(true); - setRangePoints(days); - // v1.30.1 — persist the pick per chart, same model - // as the overlay toggles, so the range survives a - // remount instead of resetting to 30 d every visit. - if (chartKey && !windowOverride) { - overlayPrefs.setPrefs({ - ...overlayPrefs.prefs, - rangePoints: days, - }); - } - }} - /> - {/* v1.4.18 — overlay-controls dropdown anchored next to - the range tabs. Only painted when the chart is bound - to a persistent chartKey; ad-hoc usages keep the - clean-line default. */} - {chartKey && overlayControls ? ( - - ) : null} -
-
+ { + setRangeHydrated(true); + setRangePoints(days); + // The pick persists per chart, like the overlay toggles, so it + // survives a remount. + if (chartKey && !windowOverride) { + overlayPrefs.setPrefs({ + ...overlayPrefs.prefs, + rangePoints: days, + }); + } + }} + overlayPrefs={overlayPrefs} + showOverlayControls={Boolean(chartKey && overlayControls)} + /> )} {mini && ( @@ -1865,12 +353,8 @@ export function HealthChart({ ) : null} {isLoading ? ( - // v1.16.0 — height-matched skeleton band instead of the former - // `h-48` spinner box. The loading state now occupies the exact - // box the painted chart will (same `chartHeightClass`), so the - // card height never jumps when the data lands — the dashboard's - // shared-reveal overlay and every insights mount stay - // layout-shift-free. + // A skeleton in the exact box the painted chart takes, so the card + // height never jumps when the data lands. <> {mini ? null : ( @@ -1881,10 +365,8 @@ export function HealthChart({ )} ) : isError ? ( - // v1.16.8 — a failed query paints as an ERROR with a retry - // affordance, not as the "no data in this range" empty state. - // Pre-fix `isError` fell through to the empty branch and an - // outage read as "you have no measurements". + // A failed read is an error with retry, never "no data in this + // range": an outage must not read as "you have no measurements". void refetch()} /> ) : !chartData?.length ? ( - // v1.4.43 — empty-window state. - // - // The chart only withholds rendering when the selected range - // holds zero daily points. Any real data — even a single day — - // renders the points below; a sparse-data caption (rather than - // a withholding card) explains that more days fill out the - // trend. The genuinely-empty window paints a distinct empty - // state so the user reaches for the range tabs or the quick-add - // instead of suspecting a broken widget. + // Only a window with no daily point at all withholds the plot; any + // real data, even one day, is drawn (with the sparse caption below).
{visibleBands.length > 0 ? ( - // v1.4.27 — band overlay positioning fix. The - // overlay used to inset `right: 18px` while the chart - // margin is `right: 8`, so the band rectangle drifted left - // of the plotted line by 10 px on every viewport. Pin the - // overlay to the same right edge the ComposedChart uses so - // the band tracks the line exactly. -
- {visibleBands.map((band) => ( -
- ))} -
+ ) : null}
- {visibleTargetZones.map((zone) => ( - - ))} - {visibleTargetZones.map((zone) => ( - - ))} - {visibleTargetZones.map((zone) => ( - - ))} - {/* v1.4.20 — storyboard annotations. - Vertical reference lines pinned to a specific - visible pointIndex; the label sits above with a - short truncation on `=sm. Annotations off-window silently - drop via `ifOverflow="discard"`. */} - {annotationPositions.map((annotation, i) => ( - - ))} - {/* v1.4.25 — 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) => ( - - ))} - {yDomain && - verticalMarkerPositions.map((marker, i) => ( - - ))} - {/* v1.42 — 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. */} - {openDayIndex !== undefined ? ( - - ) : null} - {openDayIndex !== undefined && openDayPoint - ? types.map((type, i) => { - const v = openDayPoint[type]; - if (typeof v !== "number") return null; - return ( - - ); - }) - : null} - {/* 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 - the user actively shows the trend overlay (matching - his rule: "only when a trend is being displayed"). - 90-day rolling median per type, faint dashed line, - only the first type gets the inline label so - multi-type charts don't paint duplicate labels. */} - {showTrend && - types.map((type, i) => { - const baseline = personalBaselines.get(type); - if (baseline == null) return null; - return ( - - ); - })} + {targetZoneLayers(visibleTargetZones, maxPointIndex)} + {annotationLayers(annotationPositions, viewportWidth)} + {verticalMarkerLayers(verticalMarkerPositions, yDomain)} + {openDayLayers(openDayIndex, openDayPoint, types, colors)} + {baselineLayers( + showTrend, + types, + colors, + personalBaselines, + t, + )} { - const { - active, - payload, - label: rechartsLabel, - } = props as unknown as { - active?: boolean; - payload?: Array<{ - name?: string; - value?: number; - color?: string; - dataKey?: string; - payload?: ChartDataPoint; - }>; - 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 ? tzFmt.date(new Date(ts)) : ""; - const rows: RichTooltipRow[] = []; - // Build a quick lookup of compare values for this - // hover-day so the current-period row can attach - // a "vs. last month / year" delta inline. Same-day - // current ↔ compare values 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 ?? ""); - // Skip auxiliary lines (`*_ma`, `*_trend`, - // `*_compare`) — the comparison value is rendered - // inline as the delta on the current-period row, - // and ma / trend already appear as dashed overlays - // on the chart itself. - if ( - dataKey.endsWith("_ma") || - dataKey.endsWith("_trend") || - dataKey.endsWith("_compare") - ) - continue; - const baseline = personalBaselines.get(dataKey); - let delta: string | undefined; - // v1.4.16 — prefer the comparison delta - // ("Δ −7 vs. last month") over the personal - // baseline delta when comparison is active and we - // have a prior value for this day. Otherwise fall - // back to the existing baseline-delta path. - const compareValue = - effectiveCompareBaseline !== "none" && hoverPoint - ? (hoverPoint[`${dataKey}_compare`] as - number | undefined) - : undefined; - if ( - effectiveCompareBaseline !== "none" && - typeof compareValue === "number" && - Number.isFinite(compareValue) - ) { - const diff = item.value - compareValue; - if (Math.abs(diff) < 0.05) { - delta = t("charts.deltaUnchanged"); - } else { - const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${fmt.number( - Math.abs(diff), - 1, - )}${unit ? ` ${unit}` : ""}`; - delta = t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.deltaVs.lastMonth" - : "comparison.deltaVs.lastYear", - ).replace("{delta}", formatted); - } - } else if (baseline != null) { - const diff = item.value - baseline; - if (Math.abs(diff) < 0.05) { - delta = t("charts.deltaUnchanged"); - } else { - const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${fmt.number( - Math.abs(diff), - 1, - )}${unit ? ` ${unit}` : ""}`; - delta = t("charts.deltaVsBaseline").replace( - "{delta}", - formatted, - ); - } - } - 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: `${formatTooltipValue(item.value)}${ - unit ? ` ${unit}` : "" - }`, - color: item.color ?? "var(--dracula-purple)", - delta, - }); - // v1.4.16 — also surface the prior-period - // value as its own row so the user reads both - // numbers (current AND last-month / last-year) - // alongside the delta. - if ( - effectiveCompareBaseline !== "none" && - typeof compareValue === "number" && - Number.isFinite(compareValue) - ) { - rows.push({ - name: `${item.name ?? dataKey} · ${t( - "comparison.tooltipPrior", - )}`, - value: `${formatTooltipValue(compareValue)}${ - unit ? ` ${unit}` : "" - }`, - color: item.color ?? "var(--dracula-purple)", - }); - } - } - if (rows.length === 0) return null; - return ( - - ); - }} + content={makeTooltipContent({ + chartData, + dayLinkSource, + personalBaselines, + compareBaseline: effectiveCompareBaseline, + bucketCaption, + unit, + t, + fmt, + dateLabelFor: tzFmt.date, + formatValue: formatTooltipValue, + tooltipAction: chartDays.tooltipAction, + })} /> {showContextDetails && ( )} - {/* v1.8.5 — min–max range band. An Apple-Health-style - shaded area between each day's min and max, painted - behind the mean line so it reads as context, not a - second series. Gated on `!mini` (the dashboard - sparkline stays clean) and on the daily-aggregate - path supplying a `[min, max]` tuple per point — single - non-cumulative metrics with intra-day spread (pulse, - weight, glucose, BP). The toggle rides the existing - target-range pref so the user controls both bands - from one switch. */} - {!mini && - showBands && - types.map((type, i) => ( - - ))} - {types.map((type, i) => ( - - ))} - {showMA && - types.map((type, i) => ( - - ))} - {showTrend && - types.map((type) => ( - - ))} - {/* v1.4.16 — comparison overlay. - A dimmed dashed line per type for the prior period - (lastMonth / lastYear), painted BENEATH the current - series via Recharts' source-order layering. Same - base colour as the current line but with reduced - stroke opacity (45%) and a thinner stroke (1.25) - so the user reads the current line first and the - overlay as orientation. */} - {effectiveCompareBaseline !== "none" && - hasComparisonData && - types.map((type, i) => ( - - ))} + {seriesLayers({ + types, + colors, + label, + t, + mini, + showBands, + showMA, + showTrend, + compareBaseline: effectiveCompareBaseline, + hasComparisonData, + animationsEnabled, + })}
- {/* Sparse-data caption. With fewer than three daily points the - chart still paints every reading (a single marker for one - day, a line for two), and this subtle note sets the - expectation that more days fill out the trend — rather than - withholding the data behind a placeholder card. */} + {/* With fewer than three daily points the chart still paints + every reading; this note says more days fill out the trend. */} {!mini && (chartData?.length ?? 0) < 3 ? (

) : null} - {/* The points the chart just drew, as a table. Reads the SAME - expression `` is handed above, so the two - cannot disagree. Sits inside this branch on purpose: an empty - window paints the empty state and no table at all, because - `` already says there is nothing here and a - disclosure opening onto nothing would say it twice. */} + {/* The points the chart just drew, as a table, read from the same + expression `` is handed. Inside this branch + on purpose: an empty window shows the empty state and no table. */} {showDataTable && !mini && dataTableColumns.length > 0 ? ( + {bands.map((band) => ( +

+ ))} +
+ ); +} diff --git a/src/components/charts/health-chart/chart-header.tsx b/src/components/charts/health-chart/chart-header.tsx new file mode 100644 index 000000000..8ee64d7fc --- /dev/null +++ b/src/components/charts/health-chart/chart-header.tsx @@ -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; + showOverlayControls: boolean; +}) { + const { t } = useTranslations(); + return ( +
+
+ {/* With a `titleIcon` (every metric page) the card leads with the + canonical ``; the dashboard and ad-hoc mounts keep the + compact heading. That heading is an `

`: 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 + `

`. */} + {titleIcon ? ( + + ) : ( +

{title}

+ )} + {bucketCaption && ( + + {t(bucketCaption)} + + )} + {/* 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 && ( + + {t( + compareBaseline === "lastMonth" + ? "comparison.captionLastMonth" + : "comparison.captionLastYear", + )} + + )} + {compareBaseline !== "none" && !hasComparisonData && ( + + {t( + compareBaseline === "lastMonth" + ? "comparison.unavailable.lastMonth" + : "comparison.unavailable.lastYear", + )} + + )} + {/* The page-level target control owns "adjust targets"; the chart + card stays a read surface. */} +

+
+ + {/* Only a chart bound to a persistent key offers the overlay + controls; ad-hoc charts keep the clean line. */} + {showOverlayControls ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/charts/health-chart/chart-layers.tsx b/src/components/charts/health-chart/chart-layers.tsx new file mode 100644 index 000000000..b17f2acb0 --- /dev/null +++ b/src/components/charts/health-chart/chart-layers.tsx @@ -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) => ( + + )), + zones.map((zone) => ( + + )), + zones.map((zone) => ( + + )), + ]; +} + +/** + * 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) => ( + + )); +} + +/** + * 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) => ( + + )), + yDomain && + markers.map((marker, i) => ( + + )), + ]; +} + +/** + * 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 ? ( + + ) : null, + openDayIndex !== undefined && openDayPoint + ? types.map((type, i) => { + const v = openDayPoint[type]; + if (typeof v !== "number") return null; + return ( + + ); + }) + : 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, + t: Translate, +) { + return ( + showTrend && + types.map((type, i) => { + const baseline = personalBaselines.get(type); + if (baseline == null) return null; + return ( + + ); + }) + ); +} + +/** + * 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) => ( + + )), + types.map((type, i) => ( + + )), + showMA && + types.map((type, i) => ( + + )), + showTrend && + types.map((type) => ( + + )), + compareBaseline !== "none" && + hasComparisonData && + types.map((type, i) => ( + + )), + ]; +} diff --git a/src/components/charts/health-chart/chart-tooltip-content.tsx b/src/components/charts/health-chart/chart-tooltip-content.tsx new file mode 100644 index 000000000..b2ebb84ad --- /dev/null +++ b/src/components/charts/health-chart/chart-tooltip-content.tsx @@ -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; + +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; + compareBaseline: ComparisonBaseline; + bucketCaption: string | null; + unit?: string; + t: Translate; + fmt: ReturnType; + 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 ( + + ); + }; +} diff --git a/src/components/charts/health-chart/series-math.ts b/src/components/charts/health-chart/series-math.ts new file mode 100644 index 000000000..b975c1ee5 --- /dev/null +++ b/src/components/charts/health-chart/series-math.ts @@ -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 = { + 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(); + 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; +} diff --git a/src/components/charts/health-chart/types.ts b/src/components/charts/health-chart/types.ts new file mode 100644 index 000000000..db6fef62e --- /dev/null +++ b/src/components/charts/health-chart/types.ts @@ -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 | null) => void; + /** + * Leading glyph for the card's ``. 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; + /** + * 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; +} diff --git a/src/components/charts/health-chart/use-health-chart-overlays.ts b/src/components/charts/health-chart/use-health-chart-overlays.ts new file mode 100644 index 000000000..3d23fefd9 --- /dev/null +++ b/src/components/charts/health-chart/use-health-chart-overlays.ts @@ -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; +type Formatters = ReturnType; + +/** 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(() => { + 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(() => { + 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(); + const map = new Map(); + 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 | 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(() => { + 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 }; +} diff --git a/src/components/charts/health-chart/use-health-chart-series.ts b/src/components/charts/health-chart/use-health-chart-series.ts new file mode 100644 index 000000000..a4b7b5b48 --- /dev/null +++ b/src/components/charts/health-chart/use-health-chart-series.ts @@ -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; + usePreloaded: boolean; + preloadedSeries?: Record; + valueScale: number; + valueOffset: number; + dayKeyFormatter: Intl.DateTimeFormat; + tzFmt: ReturnType; +}): Promise { + 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; + rangePoints: number; + effectiveCompareBaseline: ComparisonBaseline; + preloadedSeries?: Record; + 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; +} diff --git a/src/components/charts/health-chart/use-health-chart-view.ts b/src/components/charts/health-chart/use-health-chart-view.ts new file mode 100644 index 000000000..cafb4a2cc --- /dev/null +++ b/src/components/charts/health-chart/use-health-chart-view.ts @@ -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; + 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((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; + baseline: Exclude; + }, +): 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>(); + 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; + showMA: boolean; + showTrend: boolean; + effectiveCompareBaseline: ComparisonBaseline; + mini: boolean; + isLoading: boolean; + isError: boolean; + onVisibleStats?: (stats: Record | 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 | null>(() => { + if (mini || !chartData?.length) return null; + const out: Record = {}; + 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, + }; +} diff --git a/src/lib/charts/__tests__/data-ready-latch.test.ts b/src/lib/charts/__tests__/data-ready-latch.test.ts index 21e9a46c7..0c310dd0d 100644 --- a/src/lib/charts/__tests__/data-ready-latch.test.ts +++ b/src/lib/charts/__tests__/data-ready-latch.test.ts @@ -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", ]; diff --git a/src/lib/charts/__tests__/fetch-window.test.ts b/src/lib/charts/__tests__/fetch-window.test.ts index 96287d8b6..589af8553 100644 --- a/src/lib/charts/__tests__/fetch-window.test.ts +++ b/src/lib/charts/__tests__/fetch-window.test.ts @@ -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("));