Open stat strips on the chart's window instead of all time

The strip painted the all-time summary until the chart reported its
visible window, then swapped to it, so every number jumped a beat after
load. The chart now reports only once its read has settled (a failed
read settles too), and until then the strip holds its cells at their
final height.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 18:20:57 +02:00
1 parent f7b6ea5dad
commit 1a52384a1d
10 files changed
+81 -11

No files matched your search

+3 -1
View File
@@ -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,
},
]}
/>
+2 -1
View File
@@ -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={
+2 -1
View File
@@ -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={<CoachReadStrip metricType="WEIGHT" unit={weightUnit} />}
+6 -3
View File
@@ -1309,10 +1309,13 @@ export function HealthChart({
// Report the visible-range stats up to the sub-page so the shared
// `<MetricStatStrip>` 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.
//
@@ -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", () => ({
@@ -198,3 +198,30 @@ describe("<MetricStatStrip>", () => {
});
});
});
describe("<MetricStatStrip> 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(
<MetricStatStrip summary={populated} unit="bpm" windowPending />,
);
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(
<MetricStatStrip
summary={populated}
unit="bpm"
windowStats={{ count: 5, min: 60, max: 80, median: 70, mean: 70 }}
/>,
);
expect(html).toContain("60.0 bpm");
expect(html).not.toContain("58.0 bpm");
});
});
@@ -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}
/>
}
+19 -3
View File
@@ -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<MetricStatSeries> & {
@@ -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({
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{cell.label}
</p>
<p className="text-base font-semibold tabular-nums">
{format(cell.value)}
</p>
{holding ? (
<Skeleton className="h-6 w-16" />
) : (
<p className="text-base font-semibold tabular-nums">
{format(cell.value)}
</p>
)}
</div>
))}
</div>
@@ -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}
/>
</CardContent>
</Card>
@@ -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 (
<section data-slot={`recovery-block-${type}`} className="space-y-3">
@@ -86,6 +86,7 @@ export function RecoveryMetricBlock({
titleAs="h2"
description={explainer}
windowStats={statsByType?.[type] ?? null}
windowPending={!statsSettled}
/>
<HealthChartDynamic
+9
View File
@@ -31,6 +31,12 @@ export interface ChartDomainStats {
* falls back to the full-range summary for that series.
*/
statsByType: Record<string, MetricWindowStats> | 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 `<HealthChart onVisibleStats>`. */
onVisibleStats: (stats: Record<string, MetricWindowStats> | null) => void;
}
@@ -40,10 +46,12 @@ export function useChartDomainStats(): ChartDomainStats {
string,
MetricWindowStats
> | null>(null);
const [statsSettled, setStatsSettled] = useState(false);
const lastRef = useRef<string>("null");
const onVisibleStats = useCallback(
(stats: Record<string, MetricWindowStats> | 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,
};
}