From 4d18c4e1015348c53e55622fdb009ec72ec401b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:02:01 +0200 Subject: [PATCH] Stop insights sub-pages from shifting after first paint Weight, blood pressure and pulse painted no stat strip until their read landed, so the read strip and chart jumped down a card's height; the strip now holds its place with the skeleton the HealthKit pages already used, shared from the strip itself. The tab strip's scroll arrows render from the start and only hide while the row fits, instead of mounting once it measured as overflowing and shoving every pill sideways. The sleep page shows the stage chart's skeleton while loading rather than a 'no stage data yet' card. Load shift on weight, blood pressure and pulse drops from 0.3 to under 0.01. --- src/app/insights/blood-pressure/page.tsx | 8 +- src/app/insights/pulse/page.tsx | 7 +- src/app/insights/weight/page.tsx | 7 +- .../__tests__/insights-tab-strip.test.tsx | 20 +++++ .../__tests__/sleep-overview-loading.test.tsx | 40 ++++++++++ .../insights/healthkit-metric-page.tsx | 38 ++-------- .../insights/insights-tab-strip.tsx | 73 ++++++++++--------- src/components/insights/metric-stat-strip.tsx | 59 +++++++++++++++ src/components/insights/sleep-overview.tsx | 8 +- 9 files changed, 188 insertions(+), 72 deletions(-) create mode 100644 src/components/insights/__tests__/sleep-overview-loading.test.tsx diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 754327c50..1a5782055 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -46,8 +46,11 @@ export default function InsightsBlutdruckPage() { const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(user != null); - const { data: analytics, isEmpty } = - useInsightsAnalytics("BLOOD_PRESSURE_SYS"); + const { + data: analytics, + isEmpty, + isLoading: analyticsLoading, + } = useInsightsAnalytics("BLOOD_PRESSURE_SYS"); // v1.12.4 — blood pressure is two series, so it stacks two stat strips // (systolic / diastolic) rather than the single-series strip the other // metrics carry. Both summaries ride the same `summaries` slice. @@ -128,6 +131,7 @@ export default function InsightsBlutdruckPage() { // brushing the single chart reports per-type windowed stats and each // column reads its own half. — pill row clip box (v1.16.8)", () => { expect(scroller).toContain("-mx-1"); }); }); + +describe(" — scroll arrows hold their slot", () => { + it("renders both arrows before the row is measured, hidden but in place", () => { + // Mounting the arrows only once the row measured as overflowing shifted + // every pill sideways right after first paint (a layout shift on every + // insights page). They render from the start and only hide. + const html = render(); + for (const slot of [ + "insights-tab-strip-scroll-left", + "insights-tab-strip-scroll-right", + ]) { + const tag = html.match( + new RegExp(`]*data-slot="${slot}"[^>]*>`), + ); + expect(tag?.[0]).toBeDefined(); + expect(tag?.[0]).toContain("invisible"); + expect(tag?.[0]).toContain('tabindex="-1"'); + } + }); +}); diff --git a/src/components/insights/__tests__/sleep-overview-loading.test.tsx b/src/components/insights/__tests__/sleep-overview-loading.test.tsx new file mode 100644 index 000000000..0e6164dd7 --- /dev/null +++ b/src/components/insights/__tests__/sleep-overview-loading.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * While the analytics read is in flight the stage chart holds its place with + * a skeleton. Painting the "no stage data yet" card first said something + * untrue and pushed the duration chart down when the bar replaced it. + */ + +const state = { isLoading: true, data: undefined as unknown }; + +vi.mock("@/lib/queries/use-analytics-query", () => ({ + useAnalyticsQuery: () => state, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { timezone: "UTC" } }), +})); +vi.mock("@/hooks/use-insights-layout-prefs", () => ({ + useInsightsLayoutPrefs: () => ({ compareBaseline: "none" }), +})); +vi.mock("../sleep-duration-chart", () => ({ + SleepDurationChart: () =>
, +})); + +import { SleepOverview } from "../sleep-overview"; +import en from "../../../../messages/en.json"; + +describe("SleepOverview — loading", () => { + it("paints a skeleton, not the 'no stage data' card, while loading", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).not.toContain(en.insights.sleep.stages.unavailable); + expect(html).toContain('data-slot="duration-chart"'); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 60c6a2109..9e023d7eb 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -17,13 +17,14 @@ import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import { metricFractionDigits } from "@/lib/measurements/value-domain"; import { Button } from "@/components/ui/button"; import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { Card, CardContent } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { MetricStatusCard } from "@/components/insights/metric-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; -import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { + MetricStatStrip, + MetricStatStripSkeleton, +} from "@/components/insights/metric-stat-strip"; import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; @@ -371,7 +372,7 @@ export function HealthKitMetricPage({ description={description} explainerMetric={explainerMetric} explainerParams={explainerParams} - statStrip={} + statStrip={} > @@ -538,32 +539,3 @@ export function HealthKitMetricPage({ ); } - -/** - * v1.12.7 — layout-stable loading shell for the stat strip slot. Mirrors - * the loaded `` card chrome (denser `py-3` rhythm, one - * header row + a four-up grid) so the page does not jump when the analytics - * read lands. Decorative — hidden from assistive tech; the chart skeleton - * below it carries the `aria-busy` announcement. - */ -function StatStripSkeleton() { - return ( - - ); -} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 9bbcf005c..3e8c9f920 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -822,23 +822,27 @@ function InsightsTabStripImpl({ )} >
- {pillsOverflow && ( - - )} + {/* Both arrows always hold their slot (sm+) and only hide while the + row fits: mounting them once the row measured as overflowing + shifted every pill sideways a moment after first paint. */} +
- {pillsOverflow && ( - - )} + {/* v1.4.27 MB7 / CF-72 — right-edge fade. The gradient absolute-positions over the rightmost ~24 px of the strip so the last visible pill softly fades into the background diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 229b8d7a0..d9682e215 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -5,6 +5,7 @@ import { Sigma } from "lucide-react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import type { DataSummary } from "@/lib/analytics/trends"; import type { MetricWindowStats } from "@/lib/charts/window-stats"; @@ -139,6 +140,13 @@ type MetricStatStripProps = Partial & { * Single-series mode derives the label from `seriesLabel` instead. */ groupLabel?: string; + /** + * The read behind the summary is still in flight. The strip then holds its + * place with a card of the same anatomy instead of painting nothing: it + * sits at the top of the sub-page, so arriving later pushed the read strip, + * the chart and everything below it down the page. + */ + pending?: boolean; }; /** @@ -243,9 +251,14 @@ export function MetricStatStrip({ titleAs, series, groupLabel, + pending = false, }: MetricStatStripProps) { const { t } = useTranslations(); + if (pending) { + return ; + } + // v1.12.7 — multi-series mode: ONE Card, columns side by side. The card // self-gates to nothing only when EVERY series is empty so a one-sided // metric (e.g. only systolic logged) still paints its half. @@ -310,3 +323,49 @@ export function MetricStatStrip({ ); } + +/** + * Layout-stable loading shell for the stat strip: the loaded card's chrome + * (compact rhythm, a header row, a four-up grid per series, two series side + * by side from `md`) so the page does not jump when the read lands. + * Decorative — hidden from assistive tech; the chart skeleton below carries + * the busy announcement. + */ +export function MetricStatStripSkeleton({ + seriesCount = 1, +}: { + seriesCount?: number; +}) { + return ( + + ); +} diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index 58117e271..dadd2e58c 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -122,7 +122,13 @@ export function SleepOverview() { return (
- {sleepStages ? ( + {/* While the read is in flight the bar's own skeleton holds its + place: the "stages unavailable" card is a statement about the data, + and painting it before the data arrived both said something untrue + and pushed the duration chart down when the bar replaced it. */} + {isLoading ? ( + + ) : sleepStages ? ( ) : (