feat(insights): recompute the stat strip for a brushed chart window

Wire the metric sub-pages so brushing a window in the chart recomputes the
Min/Max/Median/Mittelwert strip for the visible domain. The page lifts the
shared brushed-window state, passes `selectableDomain` + `onDomainStats` to
the chart, and threads the per-series window stats into the strip. With no
selection the strip keeps the precomputed full-range summary, so the default
is unchanged and costs no extra work.

The strip renders the windowed numbers when present and pins a "selected
range" pill to its header so the user reads the stats as their selection.
Blood pressure brushes both series at once — the single chart reports
systolic and diastolic stats and each strip reads its half.

Covers weight, pulse, blood pressure, and the ~37 HealthKit pages that ride
the shared scaffold. BMI (a derived metric with no first-class strip) and
sleep (no strip) are left as-is. New copy added in all six locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 13:35:54 +02:00
1 parent 0995664893
commit 4dde3e1d8b
11 files changed
+103 -18

No files matched your search

+4 -2
View File
@@ -1494,7 +1494,8 @@
"sat": "Samstag",
"sun": "Sonntag"
},
"rangeBand": "Bereich"
"rangeBand": "Bereich",
"selectRange": "Zeitraum auswählen"
},
"chart": {
"overlay": {
@@ -2104,7 +2105,8 @@
"min": "Min",
"max": "Max",
"median": "Median",
"mean": "Mittelwert"
"mean": "Mittelwert",
"selectedRange": "Ausgewählter Bereich"
},
"showAllValues": "Alle Werte anzeigen",
"valuesPageTitle": "{metric} — alle Werte",
+4 -2
View File
@@ -1494,7 +1494,8 @@
"sat": "Saturday",
"sun": "Sunday"
},
"rangeBand": "Range"
"rangeBand": "Range",
"selectRange": "Select a range"
},
"chart": {
"overlay": {
@@ -2104,7 +2105,8 @@
"min": "Min",
"max": "Max",
"median": "Median",
"mean": "Mean"
"mean": "Mean",
"selectedRange": "Selected range"
},
"showAllValues": "Show all readings",
"valuesPageTitle": "{metric} — all readings",
+4 -2
View File
@@ -1494,7 +1494,8 @@
"sat": "Sábado",
"sun": "Domingo"
},
"rangeBand": "Rango"
"rangeBand": "Rango",
"selectRange": "Seleccionar un intervalo"
},
"chart": {
"overlay": {
@@ -2104,7 +2105,8 @@
"min": "Mín",
"max": "Máx",
"median": "Mediana",
"mean": "Media"
"mean": "Media",
"selectedRange": "Intervalo seleccionado"
},
"showAllValues": "Mostrar todas las mediciones",
"valuesPageTitle": "{metric} — todas las mediciones",
+4 -2
View File
@@ -1494,7 +1494,8 @@
"sat": "Samedi",
"sun": "Dimanche"
},
"rangeBand": "Plage"
"rangeBand": "Plage",
"selectRange": "Sélectionner une plage"
},
"chart": {
"overlay": {
@@ -2104,7 +2105,8 @@
"min": "Min",
"max": "Max",
"median": "Médiane",
"mean": "Moyenne"
"mean": "Moyenne",
"selectedRange": "Plage sélectionnée"
},
"showAllValues": "Afficher toutes les mesures",
"valuesPageTitle": "{metric} — toutes les mesures",
+4 -2
View File
@@ -1494,7 +1494,8 @@
"sat": "Sabato",
"sun": "Domenica"
},
"rangeBand": "Intervallo"
"rangeBand": "Intervallo",
"selectRange": "Seleziona un intervallo"
},
"chart": {
"overlay": {
@@ -2104,7 +2105,8 @@
"min": "Min",
"max": "Max",
"median": "Mediana",
"mean": "Media"
"mean": "Media",
"selectedRange": "Intervallo selezionato"
},
"showAllValues": "Mostra tutte le misurazioni",
"valuesPageTitle": "{metric} — tutte le misurazioni",
+4 -2
View File
@@ -1494,7 +1494,8 @@
"sat": "Sobota",
"sun": "Niedziela"
},
"rangeBand": "Zakres"
"rangeBand": "Zakres",
"selectRange": "Wybierz zakres"
},
"chart": {
"overlay": {
@@ -2104,7 +2105,8 @@
"min": "Min",
"max": "Maks",
"median": "Mediana",
"mean": "Średnia"
"mean": "Średnia",
"selectedRange": "Wybrany zakres"
},
"showAllValues": "Pokaż wszystkie pomiary",
"valuesPageTitle": "{metric} — wszystkie pomiary",
+10
View File
@@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
@@ -50,6 +51,11 @@ 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();
if (isEmpty) {
return (
<SubPageShell
@@ -113,6 +119,7 @@ export default function InsightsBlutdruckPage() {
fractionDigits={0}
seriesLabel={t("charts.systolic")}
icon={ArrowUpRight}
windowStats={statsByType?.BLOOD_PRESSURE_SYS ?? null}
/>
<MetricStatStrip
summary={diaSummary}
@@ -120,6 +127,7 @@ export default function InsightsBlutdruckPage() {
fractionDigits={0}
seriesLabel={t("charts.diastolic")}
icon={ArrowDownRight}
windowStats={statsByType?.BLOOD_PRESSURE_DIA ?? null}
/>
</div>
}
@@ -142,6 +150,8 @@ export default function InsightsBlutdruckPage() {
targetZones={bpTargetZones}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
selectableDomain
onDomainStats={onDomainStats}
/>
{/* v1.12.4 — target card sits between the chart and the assessment on
+8
View File
@@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
@@ -50,6 +51,10 @@ export default function InsightsPulsPage() {
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
const pulseSummary = analytics?.summaries?.PULSE ?? null;
// v1.12.7 — brushed-window stats shared between the pulse chart and the
// strip (the VO2 chart-row below keeps its own full-range read).
const { statsByType, onDomainStats } = useChartDomainStats();
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
// Brand-new accounts (no manual logs, no Apple-Health upload yet)
// see a one-line empty-state with a CTA into
@@ -119,6 +124,7 @@ export default function InsightsPulsPage() {
unit="bpm"
seriesLabel={t("insights.pulseSectionTitle")}
icon={Heart}
windowStats={statsByType?.PULSE ?? null}
/>
}
diversityNudge={
@@ -140,6 +146,8 @@ export default function InsightsPulsPage() {
valueBands={pulseBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
selectableDomain
onDomainStats={onDomainStats}
/>
<MetricTargetSummary slug="pulse" />
+7
View File
@@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import { Button } from "@/components/ui/button";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
@@ -39,6 +40,9 @@ 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();
if (isEmpty) {
return (
<SubPageShell
@@ -81,6 +85,7 @@ export default function InsightsGewichtPage() {
unit="kg"
seriesLabel={t("insights.weightSectionTitle")}
icon={Scale}
windowStats={statsByType?.WEIGHT ?? null}
/>
}
diversityNudge={
@@ -102,6 +107,8 @@ export default function InsightsGewichtPage() {
valueBands={weightBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
selectableDomain
onDomainStats={onDomainStats}
/>
<MetricTargetSummary slug="weight" />
@@ -8,6 +8,7 @@ import { Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import { useTranslations } from "@/lib/i18n/context";
import type { InsightMetric } from "@/lib/insights/metric-availability";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
@@ -165,6 +166,12 @@ export function HealthKitMetricPage({
const { data: analytics, isEmpty } = 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();
const rawSummary = analytics?.summaries?.[measurementType] ?? null;
// The stat strip renders display-unit values. The summary holds stored
// values, so when the page renders a scaled unit (e.g. WALKING_SPEED
@@ -225,6 +232,7 @@ export function HealthKitMetricPage({
unit={yAxisUnit ?? unit}
seriesLabel={title}
icon={statIcon}
windowStats={statsByType?.[measurementType] ?? null}
/>
}
diversityNudge={
@@ -248,6 +256,8 @@ export function HealthKitMetricPage({
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
valueScale={valueScale}
selectableDomain
onDomainStats={onDomainStats}
/>
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />
+44 -6
View File
@@ -7,6 +7,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import type { DataSummary } from "@/lib/analytics/trends";
import type { MetricWindowStats } from "@/lib/charts/window-stats";
/**
* v1.8.5 — at-a-glance stat strip for the insights category pages.
@@ -66,6 +67,17 @@ interface MetricStatStripProps {
* complete header.
*/
icon?: ComponentType<{ className?: string }>;
/**
* v1.12.7 — chart-reactive metric statistics. When the user brushes a
* window in the metric's chart, the sub-page lifts the chart's per-series
* Min / Max / Median / Mean for that window and threads it here. When
* present (non-null), the strip renders these windowed numbers in place of
* the full-range `summary` and swaps the header copy to "selected range" so
* the user reads the stats as describing their selection. Null (no
* selection) falls back to the cheap precomputed `summary` — the default,
* unchanged.
*/
windowStats?: MetricWindowStats | null;
}
export function MetricStatStrip({
@@ -74,29 +86,37 @@ export function MetricStatStrip({
fractionDigits = 1,
seriesLabel,
icon,
windowStats,
}: MetricStatStripProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// Nothing to show until the summary lands or for a metric with no
// readings — the page's empty-state covers the zero-data case.
// readings — the page's empty-state covers the zero-data case. The gate
// rides the full-range summary even when a window is active, so a brush
// never resurrects the strip on a metric that has no data at all.
if (!summary || summary.count <= 0) return null;
// v1.12.7 — a window selection with at least one reading wins over the
// full-range summary; otherwise the strip reads the precomputed numbers.
const windowed = windowStats != null && windowStats.count > 0;
const source = windowed ? windowStats : summary;
const format = (value: number | null): string =>
value === null ? "—" : `${fmt.number(value, fractionDigits)} ${unit}`;
const cells: Array<{ key: string; label: string; value: number | null }> = [
{ key: "min", label: t("insights.subPage.stats.min"), value: summary.min },
{ key: "max", label: t("insights.subPage.stats.max"), value: summary.max },
{ key: "min", label: t("insights.subPage.stats.min"), value: source.min },
{ key: "max", label: t("insights.subPage.stats.max"), value: source.max },
{
key: "median",
label: t("insights.subPage.stats.median"),
value: summary.median,
value: source.median,
},
{
key: "mean",
label: t("insights.subPage.stats.mean"),
value: summary.mean,
value: source.mean,
},
];
@@ -107,6 +127,7 @@ export function MetricStatStrip({
// data-slots; the section semantics ride on `role` + `aria-label`.
<Card
data-slot="metric-stat-strip"
data-windowed={windowed ? "true" : undefined}
role="group"
aria-label={
seriesLabel
@@ -117,7 +138,24 @@ export function MetricStatStrip({
>
{seriesLabel ? (
<CardHeader className="pb-2">
<TileHeader icon={icon ?? Sigma} title={seriesLabel} />
<TileHeader
icon={icon ?? Sigma}
title={seriesLabel}
// v1.12.7 — when the stats describe a brushed chart window rather
// than the full range, pin a "selected range" pill to the header
// so the user reads the numbers as their selection. Falls away the
// moment the selection clears.
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
}
/>
</CardHeader>
) : null}
<CardContent>