diff --git a/messages/de.json b/messages/de.json
index 6f08908a0..9a3b498ee 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index 13328d1ce..ab9ebc9ec 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index 68ab74986..220243670 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index ab478edbd..f84c2fe9d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index c4a4248a5..46aea4375 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index 312778ce2..acc63130d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx
index e6369be8e..cd3a5d339 100644
--- a/src/app/insights/blood-pressure/page.tsx
+++ b/src/app/insights/blood-pressure/page.tsx
@@ -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 (
}
@@ -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
diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx
index a6ce89b51..c74fae7ad 100644
--- a/src/app/insights/pulse/page.tsx
+++ b/src/app/insights/pulse/page.tsx
@@ -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}
/>
diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx
index 8fbf990ff..6ef57921d 100644
--- a/src/app/insights/weight/page.tsx
+++ b/src/app/insights/weight/page.tsx
@@ -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 (
}
diversityNudge={
@@ -102,6 +107,8 @@ export default function InsightsGewichtPage() {
valueBands={weightBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
+ selectableDomain
+ onDomainStats={onDomainStats}
/>
diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx
index 4732f4e7c..872791862 100644
--- a/src/components/insights/healthkit-metric-page.tsx
+++ b/src/components/insights/healthkit-metric-page.tsx
@@ -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 ? (
diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx
index b89aa63a5..430c7987e 100644
--- a/src/components/insights/metric-stat-strip.tsx
+++ b/src/components/insights/metric-stat-strip.tsx
@@ -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`.
{seriesLabel ? (
-
+
+ {t("insights.subPage.stats.selectedRange")}
+
+ ) : undefined
+ }
+ />
) : null}