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}