mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(charts): add opt-in brush that reports per-type windowed stats
Add an optional Recharts <Brush> to the shared HealthChart, gated behind a new `selectableDomain` prop. When set, the chart mounts the brush beneath the plot and reports the Min/Max/Median/Mean over the brushed window per type through `onDomainStats`. A range-tab change or a series refetch clears or clamps the selection so the brush never indexes past the data. The dashboard, trends-row, VO2 row, sleep, and rationale mounts do not pass the prop, so they render unchanged — the feature is fully isolated behind the flag. `computeWindowStats` is a pure helper (no React/Recharts) so the chart can summarise the array it already holds without a refetch and the maths is unit-tested directly. The median uses the same linear-interpolated midpoint as `summarize()` so a window and the full-range summary read on one algorithm. `useChartDomainStats` lifts the per-type window into shared state with a stable callback and a shallow guard against repeat windows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d4e3533b64
commit
0995664893
4 files changed
+362
-2
No files matched your search
@@ -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 `<Brush>` 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 `<MetricStatStrip>` reads, so the strip recomputes for
|
||||
* the visible domain while the default stays the cheap full-range summary.
|
||||
*/
|
||||
onDomainStats?: (stats: Record<string, MetricWindowStats> | 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<Record<string, MetricWindowStats> | 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<string, MetricWindowStats> = {};
|
||||
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
|
||||
// `<MetricStatStrip>` 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 `<Brush>` 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 ? (
|
||||
<Brush
|
||||
dataKey="pointIndex"
|
||||
height={24}
|
||||
travellerWidth={8}
|
||||
gap={1}
|
||||
stroke="var(--dracula-purple)"
|
||||
fill="var(--muted)"
|
||||
fillOpacity={0.3}
|
||||
ariaLabel={t("charts.selectRange")}
|
||||
startIndex={effectiveBrushWindow?.startIndex}
|
||||
endIndex={effectiveBrushWindow?.endIndex}
|
||||
onChange={(next) => {
|
||||
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}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -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 `<HealthChart selectableDomain>`
|
||||
* (which mounts a Recharts `<Brush>`) and its `<MetricStatStrip>` 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<string, MetricWindowStats> | null;
|
||||
/** True while a sub-range is selected (drives the "selected range" label). */
|
||||
isWindowed: boolean;
|
||||
/** Stable callback handed to `<HealthChart onDomainStats>`. */
|
||||
onDomainStats: (stats: Record<string, MetricWindowStats> | null) => void;
|
||||
}
|
||||
|
||||
export function useChartDomainStats(): ChartDomainStats {
|
||||
const [statsByType, setStatsByType] = useState<Record<
|
||||
string,
|
||||
MetricWindowStats
|
||||
> | null>(null);
|
||||
const lastRef = useRef<string>("null");
|
||||
|
||||
const onDomainStats = useCallback(
|
||||
(stats: Record<string, MetricWindowStats> | 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,
|
||||
};
|
||||
}
|
||||
@@ -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<number | null>);
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<number | null | undefined>,
|
||||
): 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 };
|
||||
}
|
||||
Reference in new issue
Block a user