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 ? ( ) : (