diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 6e73f0a20..c3f2efb74 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -16,9 +16,10 @@ import { ReferenceLine, ReferenceArea, ReferenceDot, + Brush, } from "recharts"; import { Loader2 } from "lucide-react"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; @@ -46,6 +47,10 @@ import { ChartOverlayControls } from "./chart-overlay-controls"; 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"; const TIME_RANGES_KEYS = [ { @@ -188,6 +193,27 @@ interface HealthChartProps { * uniformly. */ valueScale?: number; + /** + * v1.12.7 — chart-reactive metric statistics (opt-in). + * + * When `true`, the chart mounts a Recharts `` beneath the plot so + * the user can drag a window over the visible range. The dashboard and the + * mini / rationale mounts do NOT pass it, so they render byte-identical to + * the pre-v1.12.7 chart — the feature is fully isolated behind this flag. + * + * Mini mode ignores the flag (the rationale card pins its own window and + * has no room for a brush). + */ + selectableDomain?: boolean; + /** + * v1.12.7 — companion to `selectableDomain`. Receives the per-type + * Min / Max / Median / Mean computed over the brushed window, keyed by + * `MeasurementType`, or `null` when the selection spans the whole range + * (or collapses to under one point). A metric sub-page lifts this into a + * shared state the `` reads, so the strip recomputes for + * the visible domain while the default stays the cheap full-range summary. + */ + onDomainStats?: (stats: Record | null) => void; } interface ChartDataPoint { @@ -489,6 +515,8 @@ export function HealthChart({ verticalMarkers, userTimezone = "Europe/Berlin", valueScale = 1, + selectableDomain = false, + onDomainStats, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -516,6 +544,18 @@ export function HealthChart({ : 30; const [rangePoints, setRangePoints] = useState(initialRangePoints); + // v1.12.7 — chart-reactive metric statistics. The brush reports a + // `[startIndex, endIndex]` window into the rendered `chartData`; `null` + // means the full range is selected. Gated behind `selectableDomain` and + // suppressed in mini mode, so the dashboard + rationale charts never mount + // the brush and stay byte-identical. The range-tab change below clears the + // selection so the brush window can't outlive the data it indexed into. + const brushEnabled = selectableDomain && !mini; + const [brushWindow, setBrushWindow] = useState<{ + startIndex: number; + endIndex: number; + } | null>(null); + // 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 @@ -900,6 +940,60 @@ export function HealthChart({ return enriched; }, [data, rangePoints, showMA, showTrend, types, tzFmt]); + // v1.12.7 — chart-reactive metric statistics. + // + // When a brush window is active, compute the per-type Min / Max / Median / + // Mean over just the selected slice of `chartData` (the array the chart + // already holds — no re-fetch). A selection that spans the whole range, or + // collapses to under one point, reports `null` so the strip falls back to + // the cheap full-range summary. Only runs when `brushEnabled`; the + // dashboard / mini mounts compute nothing. + const windowStatsByType = useMemo | null>( + () => { + if (!brushEnabled || !brushWindow || !chartData?.length) return null; + const last = chartData.length - 1; + const start = Math.max(0, Math.min(brushWindow.startIndex, last)); + const end = Math.max(0, Math.min(brushWindow.endIndex, last)); + const lo = Math.min(start, end); + const hi = Math.max(start, end); + // A full-range selection is the default; reporting `null` keeps the + // strip on its precomputed summary rather than a redundant recompute. + if (lo <= 0 && hi >= last) return null; + const slice = chartData.slice(lo, hi + 1); + // Fewer than one data point in the window is not a meaningful summary; + // fall back to the full range. + if (slice.length < 1) return null; + const out: Record = {}; + for (const type of types) { + const stats = computeWindowStats( + slice.map((point) => point[type] as number | undefined), + ); + if (stats.count > 0) out[type] = stats; + } + return Object.keys(out).length > 0 ? out : null; + }, + [brushEnabled, brushWindow, chartData, types], + ); + + // Report the windowed 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. + useEffect(() => { + if (!brushEnabled) return; + onDomainStats?.(windowStatsByType); + }, [brushEnabled, windowStatsByType, onDomainStats]); + + // Clamp a stale brush window in render (never via setState-in-effect) so the + // controlled `` never points past the end of `chartData` after a + // refetch shrinks the series. A window whose end falls past the new last + // index is treated as "no selection" — the stats memo above already ignores + // it, and the brush re-seeds to the full range. + const chartLength = chartData?.length ?? 0; + const effectiveBrushWindow = + brushWindow && brushWindow.endIndex <= Math.max(0, chartLength - 1) + ? brushWindow + : null; + // v1.4.16 phase B8 — comparison overlay. // // When the toggle is active, derive a prior-period series from the @@ -1346,7 +1440,14 @@ export function HealthChart({ variant={rangePoints === r.points ? "default" : "ghost"} size="sm" className="min-h-11 px-2 text-xs sm:px-3" - onClick={() => setRangePoints(r.points)} + onClick={() => { + // v1.12.7 — a range-tab change re-slices `chartData`, so + // any active brush window would now index into a different + // array. Clear it so the stat strip falls back to the new + // full range rather than a stale selection. + setBrushWindow(null); + setRangePoints(r.points); + }} title={t(r.titleKey)} data-slot="chart-range-tab" > @@ -1888,6 +1989,55 @@ export function HealthChart({ data-slot={`chart-compare-line-${type}`} /> ))} + {/* v1.12.7 — chart-reactive metric statistics. The brush + lets the user drag a window over the visible range; the + `onChange` handler stores the `[startIndex, endIndex]` + pair, and the windowed-stats memo above recomputes the + stat strip's Min / Max / Median / Mean from the brushed + slice. Only mounts when `brushEnabled` (sub-pages opt in + via `selectableDomain`); the dashboard / mini mounts skip + it entirely so their render stays unchanged. `gap={1}` + reports every index so a one-day drag still resolves; the + travellers carry an `ariaLabel` so the brush announces + itself to assistive tech. */} + {brushEnabled ? ( + { + const range = next as { + startIndex?: number; + endIndex?: number; + }; + if ( + typeof range.startIndex !== "number" || + typeof range.endIndex !== "number" + ) { + return; + } + setBrushWindow({ + startIndex: range.startIndex, + endIndex: range.endIndex, + }); + }} + tickFormatter={(value) => + tzFmt.date( + new Date( + chartData?.[Math.round(Number(value))]?.timestamp ?? + Date.now(), + ), + ) + } + /> + ) : null} diff --git a/src/hooks/use-chart-domain-stats.ts b/src/hooks/use-chart-domain-stats.ts new file mode 100644 index 000000000..90f06361d --- /dev/null +++ b/src/hooks/use-chart-domain-stats.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +import type { MetricWindowStats } from "@/lib/charts/window-stats"; + +/** + * v1.12.7 — chart-reactive metric statistics. + * + * A metric sub-page lifts this hook so its `` + * (which mounts a Recharts ``) and its `` read the + * same brushed window. The chart reports the per-type Min / Max / Median / + * Mean for the selected domain through `onDomainStats`; the strip reads + * `statsByType[type]` and, when present, renders the windowed numbers in + * place of the full-range summary. + * + * `null` (no selection, or a selection that collapsed to under one point) + * means "show the full range" — the strip falls back to the precomputed + * summary, so the default is unchanged and costs no extra work. The chart + * owns the maths (it already holds the bucketed series), so the hook only + * stores what it is handed and exposes a stable callback. + * + * The callback is referentially stable across renders so it can sit in the + * chart's effect dependency list without re-firing. A shallow guard skips + * the state write when the chart reports the same window twice in a row, + * keeping a drag that lands on the same indices from thrashing the strip. + */ +export interface ChartDomainStats { + /** + * Per-type windowed stats, keyed by `MeasurementType`. Null means no + * active selection (full range). A type may be absent from the map when + * its series has no readings inside the brushed window — the strip falls + * back to the full-range summary for that series. + */ + statsByType: Record | null; + /** True while a sub-range is selected (drives the "selected range" label). */ + isWindowed: boolean; + /** Stable callback handed to ``. */ + onDomainStats: (stats: Record | null) => void; +} + +export function useChartDomainStats(): ChartDomainStats { + const [statsByType, setStatsByType] = useState | null>(null); + const lastRef = useRef("null"); + + const onDomainStats = useCallback( + (stats: Record | null) => { + // Cheap shallow signature so an identical brush window (a drag that + // settles on the same indices, or a re-render that recomputes the + // same slice) doesn't re-set state and re-render the strip. + const signature = stats === null ? "null" : JSON.stringify(stats); + if (signature === lastRef.current) return; + lastRef.current = signature; + setStatsByType(stats); + }, + [], + ); + + return { + statsByType, + isWindowed: statsByType !== null, + onDomainStats, + }; +} diff --git a/src/lib/charts/__tests__/window-stats.test.ts b/src/lib/charts/__tests__/window-stats.test.ts new file mode 100644 index 000000000..c75f3fe21 --- /dev/null +++ b/src/lib/charts/__tests__/window-stats.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; + +import { computeWindowStats } from "@/lib/charts/window-stats"; + +describe("computeWindowStats", () => { + it("computes min / max / median / mean over an odd-length series", () => { + const stats = computeWindowStats([3, 1, 2]); + expect(stats).toEqual({ + count: 3, + min: 1, + max: 3, + median: 2, + mean: 2, + }); + }); + + it("uses the linear-interpolated midpoint on an even-length series", () => { + // Matches the PERCENTILE_CONT(0.5) definition `summarize()` uses. + const stats = computeWindowStats([1, 2, 3, 4]); + expect(stats.median).toBe(2.5); + expect(stats.mean).toBe(2.5); + expect(stats.min).toBe(1); + expect(stats.max).toBe(4); + expect(stats.count).toBe(4); + }); + + it("drops non-finite / gap entries before the fold", () => { + const stats = computeWindowStats([ + 10, + undefined, + null, + Number.NaN, + Number.POSITIVE_INFINITY, + 20, + ]); + expect(stats.count).toBe(2); + expect(stats.min).toBe(10); + expect(stats.max).toBe(20); + expect(stats.mean).toBe(15); + expect(stats.median).toBe(15); + }); + + it("returns the all-null shape for an empty or all-gap window", () => { + for (const input of [[], [undefined, null, Number.NaN]]) { + const stats = computeWindowStats(input as Array); + expect(stats).toEqual({ + count: 0, + min: null, + max: null, + median: null, + mean: null, + }); + } + }); + + it("summarises a single-point window without dividing by zero", () => { + const stats = computeWindowStats([42]); + expect(stats).toEqual({ + count: 1, + min: 42, + max: 42, + median: 42, + mean: 42, + }); + }); +}); diff --git a/src/lib/charts/window-stats.ts b/src/lib/charts/window-stats.ts new file mode 100644 index 000000000..c3acc84eb --- /dev/null +++ b/src/lib/charts/window-stats.ts @@ -0,0 +1,77 @@ +/** + * v1.12.7 — chart-reactive metric statistics. + * + * When the user brushes a window in a metric sub-page chart, the + * Min / Max / Median / Mittelwert strip recomputes over just the visible + * domain rather than the full range. This helper is the single pure + * computation behind that: hand it the visible per-type values and it + * returns the four central statistics. + * + * The four-statistic shape matches the subset of `DataSummary` the strip + * renders (`min` / `max` / `median` / `mean`), and the median uses the same + * linear-interpolated midpoint definition as `summarize()` in + * `src/lib/analytics/trends.ts` so a brushed window and the full-range + * summary read on the same algorithm — only the window differs. + * + * The helper is deliberately free of any Recharts / React dependency so the + * chart can compute windowed stats off the array it already holds without + * re-fetching, and the unit suite can pin the maths without mounting a chart. + */ + +export interface MetricWindowStats { + /** Number of finite values in the window. */ + count: number; + min: number | null; + max: number | null; + median: number | null; + mean: number | null; +} + +const EMPTY: MetricWindowStats = { + count: 0, + min: null, + max: null, + median: null, + mean: null, +}; + +/** + * Compute Min / Max / Median / Mean over a list of values. Non-finite + * entries (NaN, Infinity, null, undefined) are dropped before the fold so a + * sparse series with gaps still summarises cleanly. An empty (or all-gap) + * input returns the all-null shape so the caller can fall back to the + * full-range summary rather than paint zeros. + */ +export function computeWindowStats( + values: Array, +): MetricWindowStats { + const finite: number[] = []; + for (const v of values) { + if (typeof v === "number" && Number.isFinite(v)) finite.push(v); + } + if (finite.length === 0) return { ...EMPTY }; + + // Single-pass sum/min/max — mirrors the bounded fold in `summarize()` + // so a multi-year power user's brushed window never spreads a huge + // argument list onto the stack. + let sum = 0; + let min = finite[0]; + let max = finite[0]; + for (const v of finite) { + sum += v; + if (v < min) min = v; + if (v > max) max = v; + } + const mean = sum / finite.length; + + // Linear-interpolated midpoint on an even-length series — the standard + // PERCENTILE_CONT(0.5) definition, matching `summarize()`. + const sorted = [...finite].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; + + return { count: finite.length, min, max, median, mean }; +}