From a0c5ab6591fa1b08abfdb596c4b6b70bbd431e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 18:34:11 +0200 Subject: [PATCH] Hold data-shaped page bodies until their reads settle Several pages stacked cards whose final height is the data's: the illness day timeline, the measurement list, the insights arrange list, the system status cards, the score notices that land with the slower report. Each resolving on its own pushed the rest down the page, for a CLS of 0.11 to 0.18 at desktop width. RevealWhenSettled mounts the body at once but keeps it out of layout until no query is in flight (two frames running, two seconds at most), with a placeholder in its place. The chart loading shells were also ~100 px shorter than the card they became: they now reserve the day-dot row, its caption and the collapsed data-table disclosure, and the range-tab row at its 44 px height. --- src/app/admin/[section]/renderer.tsx | 19 +- src/app/measurements/page.tsx | 23 +- src/components/charts/chart-skeleton.tsx | 46 ++- .../charts/health-chart-dynamic.tsx | 42 +- src/components/charts/health-chart.tsx | 11 +- .../illness/illness-episode-detail.tsx | 84 ++-- src/components/settings/insights-section.tsx | 11 +- src/components/settings/score-section.tsx | 362 +++++++++--------- .../ui/__tests__/reveal-when-settled.test.tsx | 27 ++ src/components/ui/reveal-when-settled.tsx | 79 ++++ 10 files changed, 464 insertions(+), 240 deletions(-) create mode 100644 src/components/ui/__tests__/reveal-when-settled.test.tsx create mode 100644 src/components/ui/reveal-when-settled.tsx diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 89cb04f39..d927a353e 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -36,6 +36,8 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit import { RemindersSection } from "@/components/admin/reminders-section"; import { ServicesSection } from "@/components/admin/services-section"; import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { RevealWhenSettled } from "@/components/ui/reveal-when-settled"; +import { Skeleton } from "@/components/ui/skeleton"; import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card"; import { InviteTokensSection } from "@/components/admin/invite-tokens-section"; import { UserManagementSection } from "@/components/admin/user-management-section"; @@ -59,11 +61,18 @@ export function AdminSectionRenderer({ switch (slug) { case "system-status": return ( - - - {/* v1.42 — the table maintenance after the tombstone purge. */} - - + // The status cards grow as their reads land (host metrics, workers, + // queues); revealed together so the maintenance card below is not + // pushed down the page three times while they load. + } + > + + + {/* v1.42 — the table maintenance after the tombstone purge. */} + + + ); case "general": return ( diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 64e0d653a..ac0efe6cc 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -17,6 +17,8 @@ import { PageHeader } from "@/components/ui/page-header"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { RevealWhenSettled } from "@/components/ui/reveal-when-settled"; +import { Skeleton } from "@/components/ui/skeleton"; import { Plus } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "@/lib/i18n/context"; @@ -203,14 +205,21 @@ export default function MeasurementsPage() { /> - { - setReturnTo(null); - setDialogOpen(true); - }} - /> + {/* The list's height is the data's; the custom metrics sit below it. + Revealed together once both reads settle, so neither pushes the + other down the page while it loads. */} + } + > + { + setReturnTo(null); + setDialogOpen(true); + }} + /> - + + ); } diff --git a/src/components/charts/chart-skeleton.tsx b/src/components/charts/chart-skeleton.tsx index 6e1c0e7c3..2d10370cd 100644 --- a/src/components/charts/chart-skeleton.tsx +++ b/src/components/charts/chart-skeleton.tsx @@ -48,11 +48,51 @@ import { useTranslations } from "@/lib/i18n/context"; const SLOW_HINT_DELAY_MS = 3_000; +/** + * The footer a loaded `` paints under its plot: the row of day + * dots and its caption on a day-linked chart, and the collapsed "show as + * table" disclosure. A loading shell without it was ~100 px shorter than the + * card it turned into (CLS on every metric page, the BMI page most visibly), + * so the shells reserve the same boxes: `h-3` rug, `mt-1` caption (two lines + * on a phone, one from `sm`), and the disclosure's `mt-3 border-t pt-3` frame + * around its `min-h-11` button. + */ +export function ChartFooterReserve({ + dayLinks = false, + dataTable = false, +}: { + dayLinks?: boolean; + dataTable?: boolean; +}) { + if (!dayLinks && !dataTable) return null; + return ( +