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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 17:02:01 +02:00
1 parent 3e1f630fa1
commit 4d18c4e101
9 files changed
+188 -72

No files matched your search

+6 -2
View File
@@ -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.
<MetricStatStrip
pending={analyticsLoading}
groupLabel={t("insights.bloodPressureSectionTitle")}
series={[
{
+6 -1
View File
@@ -63,7 +63,11 @@ export default function InsightsPulsPage() {
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
const {
data: analytics,
isEmpty,
isLoading: analyticsLoading,
} = useInsightsAnalytics("PULSE");
// VO₂ max rides on the same `/api/analytics` bundle; we only need the
// sample count to decide whether to surface the cross-link to the
// dedicated cardio-fitness page.
@@ -123,6 +127,7 @@ export default function InsightsPulsPage() {
explainerMetric="pulse"
statStrip={
<MetricStatStrip
pending={analyticsLoading}
summary={pulseSummary}
unit="bpm"
seriesLabel={t("insights.pulseSectionTitle")}
+6 -1
View File
@@ -48,7 +48,11 @@ export default function InsightsGewichtPage() {
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const unitDisplay = useUnitDisplay();
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const {
data: analytics,
isEmpty,
isLoading: analyticsLoading,
} = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
// v1.34 — the user's own weight target, read through the shared
@@ -170,6 +174,7 @@ export default function InsightsGewichtPage() {
explainerMetric="weight"
statStrip={
<MetricStatStrip
pending={analyticsLoading}
summary={displaySummary}
unit={weightUnit}
fractionDigits={weightTransform.decimals}
@@ -618,3 +618,23 @@ describe("<InsightsTabStrip> — pill row clip box (v1.16.8)", () => {
expect(scroller).toContain("-mx-1");
});
});
describe("<InsightsTabStrip> — 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(<InsightsTabStrip />);
for (const slot of [
"insights-tab-strip-scroll-left",
"insights-tab-strip-scroll-right",
]) {
const tag = html.match(
new RegExp(`<button[^>]*data-slot="${slot}"[^>]*>`),
);
expect(tag?.[0]).toBeDefined();
expect(tag?.[0]).toContain("invisible");
expect(tag?.[0]).toContain('tabindex="-1"');
}
});
});
@@ -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: () => <div data-slot="duration-chart" />,
}));
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(
<I18nProvider initialLocale="en">
<SleepOverview />
</I18nProvider>,
);
expect(html).not.toContain(en.insights.sleep.stages.unavailable);
expect(html).toContain('data-slot="duration-chart"');
});
});
@@ -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={<StatStripSkeleton />}
statStrip={<MetricStatStripSkeleton />}
>
<ChartSkeleton />
</SubPageShell>
@@ -538,32 +539,3 @@ export function HealthKitMetricPage({
</SubPageShell>
);
}
/**
* v1.12.7 — layout-stable loading shell for the stat strip slot. Mirrors
* the loaded `<MetricStatStrip>` 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 (
<Card
data-slot="metric-stat-strip-skeleton"
aria-hidden="true"
className="gap-2 py-3 md:py-4"
>
<CardContent className="space-y-3">
<Skeleton className="h-5 w-32" />
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="min-h-[44px] space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
+39 -34
View File
@@ -822,23 +822,27 @@ function InsightsTabStripImpl({
)}
>
<div className="flex items-center gap-2">
{pillsOverflow && (
<button
type="button"
onClick={() => scrollPills(-1)}
disabled={!canScrollLeft}
aria-label={t("insights.pillScrollLeft")}
data-slot="insights-tab-strip-scroll-left"
className={cn(
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-30",
)}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
)}
{/* 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. */}
<button
type="button"
onClick={() => scrollPills(-1)}
disabled={!canScrollLeft}
aria-hidden={pillsOverflow ? undefined : true}
tabIndex={pillsOverflow ? undefined : -1}
aria-label={t("insights.pillScrollLeft")}
data-slot="insights-tab-strip-scroll-left"
className={cn(
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
!pillsOverflow && "invisible",
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-30",
)}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
<div
ref={scrollerRef}
onScroll={updateScrollAffordance}
@@ -971,23 +975,24 @@ function InsightsTabStripImpl({
);
})}
</div>
{pillsOverflow && (
<button
type="button"
onClick={() => scrollPills(1)}
disabled={!canScrollRight}
aria-label={t("insights.pillScrollRight")}
data-slot="insights-tab-strip-scroll-right"
className={cn(
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-30",
)}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={() => scrollPills(1)}
disabled={!canScrollRight}
aria-hidden={pillsOverflow ? undefined : true}
tabIndex={pillsOverflow ? undefined : -1}
aria-label={t("insights.pillScrollRight")}
data-slot="insights-tab-strip-scroll-right"
className={cn(
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
!pillsOverflow && "invisible",
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-30",
)}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
{/* 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
@@ -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<MetricStatSeries> & {
* 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 <MetricStatStripSkeleton seriesCount={series?.length ?? 1} />;
}
// 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({
</Card>
);
}
/**
* 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 (
<Card
data-slot="metric-stat-strip-skeleton"
aria-hidden="true"
className="gap-2 py-3 md:py-4"
>
<CardContent>
<div
className={
seriesCount > 1
? "grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2"
: undefined
}
>
{Array.from({ length: Math.max(1, seriesCount) }).map((_, s) => (
<div key={s} className="space-y-1.5">
{/* h-6: the TileHeader row is one 24 px line. */}
<Skeleton className="h-6 w-32" />
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4">
{Array.from({ length: 4 }).map((__, i) => (
<div key={i} className="min-h-[44px] space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-16" />
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
+7 -1
View File
@@ -122,7 +122,13 @@ export function SleepOverview() {
return (
<div className="space-y-4">
{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 ? (
<ChartSkeleton />
) : sleepStages ? (
<SleepStageStackedBar breakdown={sleepStages} />
) : (
<Card>