mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(insights): drive metric stat strip from the chart range tab; add chart tile header
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
278007ce36
commit
465c8b8e33
14 files changed
+121
-222
No files matched your search
+2
-4
@@ -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",
|
||||
|
||||
+2
-4
@@ -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",
|
||||
|
||||
+2
-4
@@ -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",
|
||||
|
||||
+2
-4
@@ -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",
|
||||
|
||||
+2
-4
@@ -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",
|
||||
|
||||
+2
-4
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<MetricTargetSummary slug="weight" />
|
||||
|
||||
@@ -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 `<MetricStatStrip>` a sub-page
|
||||
* lifts the callback into always reflects the same window the chart paints.
|
||||
*
|
||||
* 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).
|
||||
* 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<string, MetricWindowStats> | 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 `<MetricStatStrip>` 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 `<TileHeader>`.
|
||||
* When supplied, the chart header renders the canonical icon + title row
|
||||
* (same size + spacing as every other Insights tile) instead of the bare
|
||||
* `<h3>`. Omitted on the dashboard / mini mounts, which keep the compact
|
||||
* heading. Accepts any component that takes a `className` (the contract
|
||||
* `<TileHeader>` and the stat strip's `icon` already use), so a page can
|
||||
* thread the same glyph it hands the stat strip.
|
||||
*/
|
||||
onDomainStats?: (stats: Record<string, MetricWindowStats> | 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<Record<string, MetricWindowStats> | 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
|
||||
// `<MetricStatStrip>` 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<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;
|
||||
if (mini || !chartData?.length) return null;
|
||||
const out: Record<string, MetricWindowStats> = {};
|
||||
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
|
||||
// `<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;
|
||||
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. */
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{/* v1.12.8 — when a `titleIcon` is supplied (every Insights
|
||||
metric subpage), the chart card leads with the canonical
|
||||
`<TileHeader>` (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 `<h3>`. */}
|
||||
{titleIcon ? (
|
||||
<TileHeader icon={titleIcon} title={title} />
|
||||
) : (
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
)}
|
||||
{activeBucket !== "day" && (
|
||||
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
|
||||
{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 ? (
|
||||
<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>
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("<MetricStatStrip>", () => {
|
||||
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(
|
||||
<MetricStatStrip
|
||||
groupLabel="Blood pressure"
|
||||
@@ -158,10 +158,12 @@ describe("<MetricStatStrip>", () => {
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
<MetricTargetSummary slug={targetSummarySlug} />
|
||||
|
||||
@@ -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 ? (
|
||||
<TileHeader
|
||||
icon={icon ?? Sigma}
|
||||
title={seriesLabel}
|
||||
right={
|
||||
windowed ? (
|
||||
<span
|
||||
data-slot="metric-stat-window-badge"
|
||||
className="bg-dracula-purple/10 text-dracula-purple rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.subPage.stats.selectedRange")}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<TileHeader icon={icon ?? Sigma} title={seriesLabel} />
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4 [&>div]:min-h-[44px]">
|
||||
{cells.map((cell) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<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.
|
||||
* A metric sub-page lifts this hook so its `<HealthChart>` and its
|
||||
* `<MetricStatStrip>` 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<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;
|
||||
/** Stable callback handed to `<HealthChart onVisibleStats>`. */
|
||||
onVisibleStats: (stats: Record<string, MetricWindowStats> | null) => void;
|
||||
}
|
||||
|
||||
export function useChartDomainStats(): ChartDomainStats {
|
||||
@@ -46,11 +42,11 @@ export function useChartDomainStats(): ChartDomainStats {
|
||||
> | null>(null);
|
||||
const lastRef = useRef<string>("null");
|
||||
|
||||
const onDomainStats = useCallback(
|
||||
const onVisibleStats = 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.
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user