From 465c8b8e333ae5ba53f4f883c74b0af3d325e354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:51:28 +0200 Subject: [PATCH] feat(insights): drive metric stat strip from the chart range tab; add chart tile header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the draggable chart brush. The Min/Max/Median/Mittelwert strip now always reflects the data visible under the chart's active range tab (7/30/90/ all): the chart reports per-type stats for its range-sliced series and the sub-page threads them into the strip. No drag, no "selected range" pill — the range tab is the single selector for both chart and strip. Blood pressure recomputes both systolic and diastolic. Give each metric chart card the canonical TileHeader (icon + title, same size and spacing as the assessment/target/stat tiles) via an optional titleIcon; the dashboard and mini mounts keep the compact heading. Drop the now-unused charts.selectRange and insights.subPage.stats.selectedRange keys from all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- src/app/insights/blood-pressure/page.tsx | 12 +- src/app/insights/weight/page.tsx | 8 +- src/components/charts/health-chart.tsx | 175 +++++------------- .../__tests__/metric-stat-strip.test.tsx | 10 +- .../insights/healthkit-metric-page.tsx | 13 +- src/components/insights/metric-stat-strip.tsx | 26 +-- src/components/insights/sub-page-shell.tsx | 10 +- src/hooks/use-chart-domain-stats.ts | 53 +++--- 14 files changed, 121 insertions(+), 222 deletions(-) diff --git a/messages/de.json b/messages/de.json index bf9eb7544..ef739e2c7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1497,8 +1497,7 @@ "sat": "Samstag", "sun": "Sonntag" }, - "rangeBand": "Bereich", - "selectRange": "Zeitraum auswählen" + "rangeBand": "Bereich" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Max", "median": "Median", - "mean": "Mittelwert", - "selectedRange": "Ausgewählter Bereich" + "mean": "Mittelwert" }, "showAllValues": "Alle Werte anzeigen", "valuesPageTitle": "{metric} — alle Werte", diff --git a/messages/en.json b/messages/en.json index 12615cafd..0113f3ab5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1497,8 +1497,7 @@ "sat": "Saturday", "sun": "Sunday" }, - "rangeBand": "Range", - "selectRange": "Select a range" + "rangeBand": "Range" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Max", "median": "Median", - "mean": "Mean", - "selectedRange": "Selected range" + "mean": "Mean" }, "showAllValues": "Show all readings", "valuesPageTitle": "{metric} — all readings", diff --git a/messages/es.json b/messages/es.json index 037b7afba..85772d59c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1497,8 +1497,7 @@ "sat": "Sábado", "sun": "Domingo" }, - "rangeBand": "Rango", - "selectRange": "Seleccionar un intervalo" + "rangeBand": "Rango" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Mín", "max": "Máx", "median": "Mediana", - "mean": "Media", - "selectedRange": "Intervalo seleccionado" + "mean": "Media" }, "showAllValues": "Mostrar todas las mediciones", "valuesPageTitle": "{metric} — todas las mediciones", diff --git a/messages/fr.json b/messages/fr.json index 2503a8966..76a80ca9e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1497,8 +1497,7 @@ "sat": "Samedi", "sun": "Dimanche" }, - "rangeBand": "Plage", - "selectRange": "Sélectionner une plage" + "rangeBand": "Plage" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Max", "median": "Médiane", - "mean": "Moyenne", - "selectedRange": "Plage sélectionnée" + "mean": "Moyenne" }, "showAllValues": "Afficher toutes les mesures", "valuesPageTitle": "{metric} — toutes les mesures", diff --git a/messages/it.json b/messages/it.json index edeec0b39..75d2f312a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1497,8 +1497,7 @@ "sat": "Sabato", "sun": "Domenica" }, - "rangeBand": "Intervallo", - "selectRange": "Seleziona un intervallo" + "rangeBand": "Intervallo" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Max", "median": "Mediana", - "mean": "Media", - "selectedRange": "Intervallo selezionato" + "mean": "Media" }, "showAllValues": "Mostra tutte le misurazioni", "valuesPageTitle": "{metric} — tutte le misurazioni", diff --git a/messages/pl.json b/messages/pl.json index 42c8a52bb..4ce4a7ddc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1497,8 +1497,7 @@ "sat": "Sobota", "sun": "Niedziela" }, - "rangeBand": "Zakres", - "selectRange": "Wybierz zakres" + "rangeBand": "Zakres" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Maks", "median": "Mediana", - "mean": "Średnia", - "selectedRange": "Wybrany zakres" + "mean": "Średnia" }, "showAllValues": "Pokaż wszystkie pomiary", "valuesPageTitle": "{metric} — wszystkie pomiary", diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 7b0b87828..7cb330f84 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -51,10 +51,10 @@ export default function InsightsBlutdruckPage() { const sysSummary = analytics?.summaries?.BLOOD_PRESSURE_SYS ?? null; const diaSummary = analytics?.summaries?.BLOOD_PRESSURE_DIA ?? null; - // v1.12.7 — chart-reactive metric statistics. Blood pressure brushes BOTH - // series at once: the single chart reports per-type windowed stats and each - // strip reads its own half. - const { statsByType, onDomainStats } = useChartDomainStats(); + // v1.12.8 — chart-reactive metric statistics. Blood pressure tracks BOTH + // series at once: the single chart reports per-type visible-range stats and + // each strip column reads its own half. + const { statsByType, onVisibleStats } = useChartDomainStats(); if (isEmpty) { return ( @@ -155,14 +155,14 @@ export default function InsightsBlutdruckPage() { chartKey="bp" types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} title={t("charts.bloodPressure")} + titleIcon={HeartPulse} colors={["#ff79c6", "#8be9fd"]} unit="mmHg" yAxisUnit="mmHg" targetZones={bpTargetZones} compareBaseline={compareBaseline} userTimezone={user?.timezone} - selectableDomain - onDomainStats={onDomainStats} + onVisibleStats={onVisibleStats} /> {/* v1.12.4 — target card sits between the chart and the assessment on diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 6ef57921d..b31c56ede 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -40,8 +40,8 @@ export default function InsightsGewichtPage() { const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT"); const weightSummary = analytics?.summaries?.WEIGHT ?? null; - // v1.12.7 — brushed-window stats shared between the chart and the strip. - const { statsByType, onDomainStats } = useChartDomainStats(); + // v1.12.8 — visible-range stats shared between the chart and the strip. + const { statsByType, onVisibleStats } = useChartDomainStats(); if (isEmpty) { return ( @@ -102,13 +102,13 @@ export default function InsightsGewichtPage() { chartKey="weight" types={["WEIGHT"]} title={t("charts.weight")} + titleIcon={Scale} colors={["#bd93f9"]} unit="kg" valueBands={weightBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} - selectableDomain - onDomainStats={onDomainStats} + onVisibleStats={onVisibleStats} /> diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 2035eaf77..0ea841468 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -16,12 +16,12 @@ import { ReferenceLine, ReferenceArea, ReferenceDot, - Brush, } from "recharts"; import { Loader2 } from "lucide-react"; -import { useState, useMemo, useEffect } from "react"; +import { useState, useMemo, useEffect, type ComponentType } from "react"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; +import { TileHeader } from "@/components/insights/tile-header"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { Button } from "@/components/ui/button"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -194,26 +194,27 @@ interface HealthChartProps { */ valueScale?: number; /** - * v1.12.7 — chart-reactive metric statistics (opt-in). + * 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. * - * 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). + * 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. */ - selectableDomain?: boolean; + onVisibleStats?: (stats: Record | null) => void; /** - * 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. + * 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. */ - onDomainStats?: (stats: Record | null) => void; + titleIcon?: ComponentType<{ className?: string }>; } interface ChartDataPoint { @@ -515,8 +516,8 @@ export function HealthChart({ verticalMarkers, userTimezone = "Europe/Berlin", valueScale = 1, - selectableDomain = false, - onDomainStats, + onVisibleStats, + titleIcon, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -544,18 +545,6 @@ 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 @@ -940,59 +929,36 @@ export function HealthChart({ return enriched; }, [data, rangePoints, showMA, showTrend, types, tzFmt]); - // v1.12.7 — chart-reactive metric statistics. + // v1.12.8 — 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>( + // Compute the per-type Min / Max / Median / Mean over the data currently + // visible under the active range tab — i.e. the `rangePoints`-sliced, + // 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 (!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; + if (mini || !chartData?.length) return null; const out: Record = {}; for (const type of types) { const stats = computeWindowStats( - slice.map((point) => point[type] as number | undefined), + chartData.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], + [mini, chartData, types], ); - // Report the windowed stats up to the sub-page so the shared + // 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. 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; + if (mini) return; + onVisibleStats?.(visibleStatsByType); + }, [mini, visibleStatsByType, onVisibleStats]); // v1.4.16 phase B8 — comparison overlay. // @@ -1388,7 +1354,17 @@ export function HealthChart({ ≥sm: original side-by-side layout, all chips visible. */
-

{title}

+ {/* 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 `

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

{title}

+ )} {activeBucket !== "day" && ( {t( @@ -1441,11 +1417,9 @@ export function HealthChart({ size="sm" className="min-h-11 px-2 text-xs sm:px-3" 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); + // 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. setRangePoints(r.points); }} title={t(r.titleKey)} @@ -1989,55 +1963,6 @@ 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/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index 4cbddc0eb..d52e11c9c 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -136,7 +136,7 @@ describe("", () => { expect(html).toBe(""); }); - it("pins the 'selected range' pill on a brushed series", () => { + it("reads the chart's visible-range stats over the full-range summary, with no pill", () => { const html = render( ", () => { ]} />, ); - // The brushed sys block flags windowed + carries the pill; dia does not. + // The sys block flags windowed (visible-range stats present); dia does not. expect(html).toContain('data-series="sys" data-windowed="true"'); - expect(html).toContain('data-slot="metric-stat-window-badge"'); - // Windowed numbers win over the full-range summary for sys. + // v1.12.8 — no "selected range" pill: the chart's range tab is the + // single, visible selector for both chart and strip. + expect(html).not.toContain('data-slot="metric-stat-window-badge"'); + // Visible-range numbers win over the full-range summary for sys. expect(html).toContain("120"); }); }); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 608590570..fc426f673 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -175,11 +175,10 @@ export function HealthKitMetricPage({ refetch, } = useInsightsAnalytics(insightMetric); - // v1.12.7 — shared brushed-window state. The chart reports the per-type - // Min / Max / Median / Mean for the selected domain; the stat strip reads - // it back for this page's single series. No selection → null → the strip - // keeps the full-range summary. - const { statsByType, onDomainStats } = useChartDomainStats(); + // v1.12.8 — shared visible-range state. The chart reports the per-type + // Min / Max / Median / Mean for the data under its active range tab; the + // stat strip reads it back for this page's single series. + const { statsByType, onVisibleStats } = useChartDomainStats(); const rawSummary = analytics?.summaries?.[measurementType] ?? null; // The stat strip renders display-unit values. The summary holds stored @@ -309,6 +308,7 @@ export function HealthKitMetricPage({ chartKey={chartKey} types={[measurementType]} title={t(`${i18nPrefix}.chartTitle`)} + titleIcon={statIcon} colors={[color]} unit={unit} yAxisUnit={yAxisUnit} @@ -316,8 +316,7 @@ export function HealthKitMetricPage({ compareBaseline={compareBaseline} userTimezone={user?.timezone} valueScale={valueScale} - selectableDomain - onDomainStats={onDomainStats} + onVisibleStats={onVisibleStats} /> {targetSummarySlug ? ( diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index f3a4318eb..3c8e08b3e 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -134,12 +134,15 @@ function SeriesBlock({ const fmt = useFormatters(); // Nothing to show until the summary lands or for a metric with no - // readings. The gate rides the full-range summary even when a window is - // active, so a brush never resurrects a series that has no data at all. + // readings. The gate rides the full-range summary even when the chart + // reports a narrower visible range, so a range change never resurrects a + // series that has no data at all. if (!summary || summary.count <= 0) return null; - // A window selection with at least one reading wins over the full-range - // summary; otherwise the block reads the precomputed numbers. + // v1.12.8 — the chart reports stats for the data under its active range tab; + // when present those visible-range numbers win over the full-range summary + // so the strip always reads the same window the chart paints. No pill — the + // chart's range tab is the single, visible selector for both. const windowed = windowStats != null && windowStats.count > 0; const source = windowed ? windowStats : summary; @@ -169,20 +172,7 @@ function SeriesBlock({ className="space-y-2" > {seriesLabel ? ( - - {t("insights.subPage.stats.selectedRange")} - - ) : undefined - } - /> + ) : null}
{cells.map((cell) => ( diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index c4dc0a8d1..32bfaac3a 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -262,11 +262,11 @@ export function SubPageShell({ brand-new-metric paths. Numbers-first is the Apple-Health / Withings / Oura detail-screen convention. - v1.12.7 — the stats are chart-reactive: when the user brushes a - window in the chart below, the page lifts the chart's per-type - windowed Min / Max / Median / Mean and threads it back into the - strip, which swaps the header to a "selected range" pill. No - selection falls back to the cheap full-range summary. */} + v1.12.8 — the stats are chart-reactive: the chart reports the + per-type Min / Max / Median / Mean for the data under its active + range tab (7 / 30 / 90 / All), and the page threads it back into the + strip so the numbers always reflect the range the chart paints. No + drag, no pill — the range tab is the single selector. */} {statStrip} {/* v1.12.6 — the canonical spine body: intro (header) → stat strip (above) → chart → target card → assessment. The page renders diff --git a/src/hooks/use-chart-domain-stats.ts b/src/hooks/use-chart-domain-stats.ts index 90f06361d..a243d0b65 100644 --- a/src/hooks/use-chart-domain-stats.ts +++ b/src/hooks/use-chart-domain-stats.ts @@ -5,38 +5,34 @@ import { useCallback, useRef, useState } from "react"; import type { MetricWindowStats } from "@/lib/charts/window-stats"; /** - * v1.12.7 — chart-reactive metric statistics. + * v1.12.8 — 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. + * A metric sub-page lifts this hook so its `` and its + * `` read the same window. The chart reports the per-type + * Min / Max / Median / Mean for the data currently visible under its active + * range tab (the 7 / 30 / 90 / All selector) through `onVisibleStats`; the + * strip reads `statsByType[type]` and renders those windowed numbers. * - * `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. + * `null` (no data, or mini mode) means "show the full range" — the strip + * falls back to the precomputed summary. 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. + * 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 + * re-render that recomputes the same slice 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. + * Per-type windowed stats, keyed by `MeasurementType`. Null means the chart + * has not reported a slice yet (or has no data). A type may be absent from + * the map when its series has no readings in the visible range — 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; + /** Stable callback handed to ``. */ + onVisibleStats: (stats: Record | null) => void; } export function useChartDomainStats(): ChartDomainStats { @@ -46,11 +42,11 @@ export function useChartDomainStats(): ChartDomainStats { > | null>(null); const lastRef = useRef("null"); - const onDomainStats = useCallback( + const onVisibleStats = 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. + // Cheap shallow signature so an identical window (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; @@ -61,7 +57,6 @@ export function useChartDomainStats(): ChartDomainStats { return { statsByType, - isWindowed: statsByType !== null, - onDomainStats, + onVisibleStats, }; }