mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
0995664893
commit
4dde3e1d8b
11 files changed
+103
-18
No files matched your search
+4
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user