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,
};
}