diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 1a5782055..58d15478b 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -60,7 +60,7 @@ export default function InsightsBlutdruckPage() { // 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(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); const bpTargets = useBpTargetBand(); if (isEmpty) { @@ -142,6 +142,7 @@ export default function InsightsBlutdruckPage() { seriesLabel: t("charts.systolic"), icon: ArrowUpRight, windowStats: statsByType?.BLOOD_PRESSURE_SYS ?? null, + windowPending: !statsSettled, }, { dataKey: "dia", @@ -151,6 +152,7 @@ export default function InsightsBlutdruckPage() { seriesLabel: t("charts.diastolic"), icon: ArrowDownRight, windowStats: statsByType?.BLOOD_PRESSURE_DIA ?? null, + windowPending: !statsSettled, }, ]} /> diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 081748ee1..864032539 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -88,7 +88,7 @@ export default function InsightsPulsPage() { // v1.12.8 — visible-range stats shared between the pulse chart and the // strip (the VO2 chart-row below keeps its own full-range read). - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = 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) @@ -133,6 +133,7 @@ export default function InsightsPulsPage() { seriesLabel={t("insights.pulseSectionTitle")} icon={Heart} windowStats={statsByType?.PULSE ?? null} + windowPending={!statsSettled} /> } coachReadStrip={ diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 8539072d5..c1a5c25ea 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -109,7 +109,7 @@ export default function InsightsGewichtPage() { : weightSummary; // v1.12.8 — visible-range stats shared between the chart and the strip. - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); if (isEmpty) { return ( @@ -181,6 +181,7 @@ export default function InsightsGewichtPage() { seriesLabel={t("insights.weightSectionTitle")} icon={Scale} windowStats={statsByType?.WEIGHT ?? null} + windowPending={!statsSettled} /> } coachReadStrip={} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index beedff5eb..ab229be3c 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1309,10 +1309,13 @@ export function HealthChart({ // Report the visible-range stats up to the sub-page so the shared // `` can read them. Effect (not render-time call) so the // parent state update never fires during this component's render. + // Only once the read has settled: a report during the initial fetch would + // tell the strip "no window" and it would paint the full-range summary + // first. A failed read settles too, so the strip never waits forever. useEffect(() => { - if (mini) return; - onVisibleStats?.(visibleStatsByType); - }, [mini, visibleStatsByType, onVisibleStats]); + if (mini || isLoading) return; + onVisibleStats?.(isError ? null : visibleStatsByType); + }, [mini, isLoading, isError, visibleStatsByType, onVisibleStats]); // v1.4.16 phase B8 — comparison overlay. // diff --git a/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx index a6da4062a..357ad8ff8 100644 --- a/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx +++ b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx @@ -50,6 +50,15 @@ vi.mock("@/hooks/use-insights-layout-prefs", () => ({ // The coach-read strip paints nothing until it is client-mounted (React // #418). Force the mounted branch so the band sentence reaches the markup. vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +// The chart is stubbed, so it never reports its window; settle the strip so +// it paints the summary this guard reads. +vi.mock("@/hooks/use-chart-domain-stats", () => ({ + useChartDomainStats: () => ({ + statsByType: null, + statsSettled: true, + onVisibleStats: () => {}, + }), +})); const analyticsMock = vi.fn(); vi.mock("@/hooks/use-insights-analytics", () => ({ diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index 051a28c65..4268af7e9 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -198,3 +198,30 @@ describe("", () => { }); }); }); + +describe(" waits for the chart's window", () => { + // The chart opens on a 30-day window. Painting the all-time summary until + // the chart reports, then swapping to the window, made every number jump + // a beat after the page loaded. + it("holds the cells while the chart's window is still pending", () => { + const html = render( + , + ); + expect(html).toContain('data-stat="min"'); + expect(html).toContain('data-window-pending="true"'); + expect(html).not.toContain("58.0 bpm"); + expect(html).not.toContain("88.0 bpm"); + }); + + it("paints the window's numbers once the chart reported", () => { + const html = render( + , + ); + expect(html).toContain("60.0 bpm"); + expect(html).not.toContain("58.0 bpm"); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 9e023d7eb..d3eb28880 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -266,7 +266,7 @@ export function HealthKitMetricPage({ // 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 { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); // v1.17.0 — fallback-type swap. When the primary type has no rows but the // declared fallback does (HRV: SDNN empty, RMSSD present), key the chart, @@ -446,6 +446,7 @@ export function HealthKitMetricPage({ } icon={statIcon} windowStats={statsByType?.[effectiveType] ?? null} + windowPending={!statsSettled} medianLabel={statMedianLabel} /> } diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index d9682e215..63d68803c 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -118,6 +118,13 @@ export interface MetricStatSeries { * `h1 → h2` outline under `SubPageShell` stays nested with no skipped level. */ titleAs?: "h2"; + /** + * The chart this strip reads has not reported its window yet. The cells + * hold their place instead of painting the full-range summary: the chart + * opens on a narrower window, so the all-time numbers would be replaced a + * beat later and every value would jump. + */ + windowPending?: boolean; } type MetricStatStripProps = Partial & { @@ -165,6 +172,7 @@ function SeriesBlock({ medianLabel, description, titleAs, + windowPending = false, }: MetricStatSeries) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -181,6 +189,7 @@ function SeriesBlock({ // chart's range tab is the single, visible selector for both. const windowed = windowStats != null && windowStats.count > 0; const source = windowed ? windowStats : summary; + const holding = windowPending && !windowed; const format = (value: number | null): string => value === null ? "—" : `${fmt.number(value, fractionDigits)} ${unit}`; @@ -205,6 +214,7 @@ function SeriesBlock({ data-slot="metric-stat-series" data-series={dataKey} data-windowed={windowed ? "true" : undefined} + data-window-pending={holding ? "true" : undefined} className="space-y-1.5" > {seriesLabel ? ( @@ -228,9 +238,13 @@ function SeriesBlock({

{cell.label}

-

- {format(cell.value)} -

+ {holding ? ( + + ) : ( +

+ {format(cell.value)} +

+ )} ))} @@ -249,6 +263,7 @@ export function MetricStatStrip({ medianLabel, description, titleAs, + windowPending, series, groupLabel, pending = false, @@ -318,6 +333,7 @@ export function MetricStatStrip({ medianLabel={medianLabel} description={description} titleAs={titleAs} + windowPending={windowPending} /> diff --git a/src/components/insights/recovery/recovery-metric-block.tsx b/src/components/insights/recovery/recovery-metric-block.tsx index 262771972..6e7cc7aab 100644 --- a/src/components/insights/recovery/recovery-metric-block.tsx +++ b/src/components/insights/recovery/recovery-metric-block.tsx @@ -67,7 +67,7 @@ export function RecoveryMetricBlock({ }: RecoveryMetricBlockProps) { const { user, isAuthenticated } = useAuth(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); return (
@@ -86,6 +86,7 @@ export function RecoveryMetricBlock({ titleAs="h2" description={explainer} windowStats={statsByType?.[type] ?? null} + windowPending={!statsSettled} /> | null; + /** + * False until the chart has settled and reported once (even an empty + * window). The strip holds its cells until then rather than painting the + * full-range summary and swapping to the chart's window a beat later. + */ + statsSettled: boolean; /** Stable callback handed to ``. */ onVisibleStats: (stats: Record | null) => void; } @@ -40,10 +46,12 @@ export function useChartDomainStats(): ChartDomainStats { string, MetricWindowStats > | null>(null); + const [statsSettled, setStatsSettled] = useState(false); const lastRef = useRef("null"); const onVisibleStats = useCallback( (stats: Record | null) => { + setStatsSettled(true); // 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. @@ -57,6 +65,7 @@ export function useChartDomainStats(): ChartDomainStats { return { statsByType, + statsSettled, onVisibleStats, }; }