refactor(insights): share one block skeleton across dynamic loaders, anchor the warm-assessments control

The six `next/dynamic` loaders on the insights overview each rendered a
bespoke `<div className="… h-[Xrem] animate-pulse …" />` with a guessed
fixed height. Those heights pinned each placeholder taller or shorter than
the resolved block, so the page shifted as each chunk landed. Route every
loader through one shared `BlockSkeleton` built on the `Skeleton` primitive
(which already honours reduced motion) and hold the row open with a
`min-h` floor rather than a hard-coded pixel guess, removing the
resolve-time layout shift. Decorative placeholders for the cards that can
un-mount stay `aria-hidden`.

Anchor the warm-assessments control too: it floated right-aligned with no
label, reading as a context-less affordance. Pair it with a left-aligned
caption that explains the nightly-refresh model so the button has a home.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 15:36:20 +02:00
1 parent e63fae564b
commit 0baad1e736
8 files changed
+120 -64

No files matched your search

+2
View File
@@ -1522,6 +1522,7 @@
"feedbackThanks": "Danke für dein Feedback",
"feedbackConfirmed": "Feedback gespeichert",
"feedbackAlreadyRated": "Bereits bewertet",
"feedbackError": "Dein Feedback konnte nicht gespeichert werden. Bitte versuche es erneut.",
"confidence": "Vertrauen",
"confidenceAria": "Vertrauen: {value} von 100",
"confidenceHigh": "Hohes Vertrauen",
@@ -1543,6 +1544,7 @@
"regenerateAnalysis": "Analyse neu starten",
"regenerateSuccess": "Analyse wurde neu erstellt",
"warmAssessments": "Auswertungen vorbereiten",
"warmAssessmentsHint": "Auswertungen werden über Nacht automatisch aktualisiert. Bereite sie jetzt vor, um den neuesten Stand zu sehen.",
"warmStarted": "Auswertungen werden im Hintergrund erstellt",
"narrativeTitle": "Dein Zeitraum im Rückblick",
"narrativeWeek": "Diese Woche",
+2
View File
@@ -1522,6 +1522,7 @@
"feedbackThanks": "Thanks for your feedback",
"feedbackConfirmed": "Feedback recorded",
"feedbackAlreadyRated": "Already rated",
"feedbackError": "Could not save your feedback. Please try again.",
"confidence": "Confidence",
"confidenceAria": "Confidence: {value} of 100",
"confidenceHigh": "High confidence",
@@ -1543,6 +1544,7 @@
"regenerateAnalysis": "Re-run analysis",
"regenerateSuccess": "Analysis refreshed",
"warmAssessments": "Prepare assessments",
"warmAssessmentsHint": "Assessments refresh overnight automatically. Prepare them now to read the latest.",
"warmStarted": "Assessments are being prepared in the background",
"narrativeTitle": "Your period in review",
"narrativeWeek": "This week",
+2
View File
@@ -1522,6 +1522,7 @@
"feedbackThanks": "Gracias por tu opinión",
"feedbackConfirmed": "Opinión guardada",
"feedbackAlreadyRated": "Ya valorado",
"feedbackError": "No se pudo guardar tu opinión. Inténtalo de nuevo.",
"confidence": "Confianza",
"confidenceAria": "Confianza: {value} de 100",
"confidenceHigh": "Confianza alta",
@@ -1543,6 +1544,7 @@
"regenerateAnalysis": "Volver a iniciar el análisis",
"regenerateSuccess": "Análisis regenerado",
"warmAssessments": "Preparar evaluaciones",
"warmAssessmentsHint": "Las evaluaciones se actualizan automáticamente durante la noche. Prepáralas ahora para ver lo más reciente.",
"warmStarted": "Las evaluaciones se están preparando en segundo plano",
"narrativeTitle": "Tu período en resumen",
"narrativeWeek": "Esta semana",
+2
View File
@@ -1522,6 +1522,7 @@
"feedbackThanks": "Merci pour ton retour",
"feedbackConfirmed": "Retour enregistré",
"feedbackAlreadyRated": "Déjà noté",
"feedbackError": "Impossible d’enregistrer ton retour. Réessaie.",
"confidence": "Confiance",
"confidenceAria": "Confiance : {value} sur 100",
"confidenceHigh": "Confiance élevée",
@@ -1543,6 +1544,7 @@
"regenerateAnalysis": "Relancer l’analyse",
"regenerateSuccess": "Analyse regénérée",
"warmAssessments": "Préparer les évaluations",
"warmAssessmentsHint": "Les évaluations sont actualisées automatiquement pendant la nuit. Préparez-les maintenant pour voir les dernières données.",
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
"narrativeTitle": "Votre période en revue",
"narrativeWeek": "Cette semaine",
+2
View File
@@ -1522,6 +1522,7 @@
"feedbackThanks": "Grazie per il riscontro",
"feedbackConfirmed": "Feedback salvato",
"feedbackAlreadyRated": "Già valutato",
"feedbackError": "Impossibile salvare il tuo riscontro. Riprova.",
"confidence": "Confidenza",
"confidenceAria": "Confidenza: {value} su 100",
"confidenceHigh": "Confidenza alta",
@@ -1543,6 +1544,7 @@
"regenerateAnalysis": "Riavvia l’analisi",
"regenerateSuccess": "Analisi rigenerata",
"warmAssessments": "Prepara le valutazioni",
"warmAssessmentsHint": "Le valutazioni si aggiornano automaticamente durante la notte. Preparale ora per vedere i dati più recenti.",
"warmStarted": "Le valutazioni vengono preparate in background",
"narrativeTitle": "Il tuo periodo in sintesi",
"narrativeWeek": "Questa settimana",
+2
View File
@@ -1522,6 +1522,7 @@
"feedbackThanks": "Dzięki za opinię",
"feedbackConfirmed": "Opinia zapisana",
"feedbackAlreadyRated": "Już ocenione",
"feedbackError": "Nie udało się zapisać opinii. Spróbuj ponownie.",
"confidence": "Pewność",
"confidenceAria": "Pewność: {value} na 100",
"confidenceHigh": "Wysoka pewność",
@@ -1543,6 +1544,7 @@
"regenerateAnalysis": "Uruchom analizę ponownie",
"regenerateSuccess": "Analiza wygenerowana ponownie",
"warmAssessments": "Przygotuj analizy",
"warmAssessmentsHint": "Analizy odświeżają się automatycznie w nocy. Przygotuj je teraz, aby zobaczyć najnowsze dane.",
"warmStarted": "Analizy są przygotowywane w tle",
"narrativeTitle": "Twój okres w skrócie",
"narrativeWeek": "Ten tydzień",
@@ -4,51 +4,79 @@ import { join } from "node:path";
/**
* v1.4.43 W11 — insights mother-page dynamic-skeleton guards.
* v1.11.3 — rewritten for the shared `BlockSkeleton` loader.
*
* The three `next/dynamic` loading placeholders for
* `DailyBriefing` / `CorrelationRow` / `TrendsRow` used to reserve
* heights (`h-48` / `h-32` / `h-64`) noticeably shorter than the
* loaded content (~24 rem / ~20 rem each), which CLS-shifted the
* page on slow networks. They also lacked `motion-reduce:animate-none`,
* so motion-sensitive users saw a continuous pulse.
* The `next/dynamic` loading placeholders for the below-the-hero blocks
* used to be bespoke `<div className="… h-[Xrem] animate-pulse …
* motion-reduce:animate-none" />` snippets with hard-coded guessed
* heights. Those fixed heights pinned each placeholder taller or shorter
* than the resolved block, so the page CLS-shifted as each chunk landed.
*
* This textual guard pins both the larger reserved heights and the
* motion-reduce class. The check is intentionally simple — render-
* mounting the page would haul in TanStack-Query / Auth / I18n
* scaffolding for a property a substring search already proves.
* E1 collapses all six loaders onto a single shared `BlockSkeleton` that
* routes through the `Skeleton` primitive (which carries
* `motion-reduce:animate-none`) and holds the row open with a `min-h`
* floor rather than a fixed `h-[Xrem]`. This guard pins the new contract:
* every loader uses `BlockSkeleton`, none re-introduces a hard-coded
* fixed height, and the decorative (un-mountable) cards stay `aria-hidden`.
*
* The check is intentionally textual — render-mounting the page would haul
* in TanStack-Query / Auth / I18n scaffolding for a property a substring
* search already proves.
*/
describe("insights mother-page dynamic-skeleton heights + motion-reduce", () => {
describe("insights mother-page dynamic-skeleton loaders use the shared BlockSkeleton", () => {
const src = readFileSync(
join(process.cwd(), "src/app/insights/page.tsx"),
"utf8",
);
it("DailyBriefing skeleton reserves h-[24rem]", () => {
expect(src).toMatch(
/DailyBriefing[\s\S]*?h-\[24rem\][^"]*motion-reduce:animate-none/,
);
it("defines the shared BlockSkeleton helper", () => {
expect(src).toMatch(/function BlockSkeleton\(/);
// The helper renders the shared Skeleton primitive, which carries
// motion-reduce:animate-none, so every consumer inherits the guard.
expect(src).toMatch(/<Skeleton[\s\S]*?rounded-xl/);
});
it("CorrelationRow skeleton reserves h-[20rem]", () => {
expect(src).toMatch(
/CorrelationRow[\s\S]*?h-\[20rem\][^"]*motion-reduce:animate-none/,
);
it("every dynamic loader routes through BlockSkeleton with a min-h floor", () => {
// Each below-the-hero loader pins a `min-h-*` floor (not a guessed
// fixed `h-[Xrem]`) so the row holds open without fighting the
// resolved block's true height.
const loaderMatches = [
...src.matchAll(
/loading:\s*\(\)\s*=>\s*<BlockSkeleton\s+minHeight="(min-h-[\w[\]-]+)"/g,
),
];
// Five of the six dynamic blocks declare a skeleton loader
// (RhythmEventsCard intentionally has none — it un-mounts itself when
// the user has no such events, with no skeleton-then-empty flash).
expect(loaderMatches.length).toBeGreaterThanOrEqual(5);
for (const match of loaderMatches) {
expect(match[1]).toMatch(/^min-h-/);
}
});
it("TrendsRow skeleton reserves h-[20rem]", () => {
expect(src).toMatch(
/TrendsRow[\s\S]*?h-\[20rem\][^"]*motion-reduce:animate-none/,
);
it("the decorative (un-mountable) card loaders stay aria-hidden via the decorative flag", () => {
// CoincidentDeviationCard + PeriodNarrativeCard can un-mount, so their
// placeholders must hide from assistive tech.
const decorativeLoaders = [
...src.matchAll(/<BlockSkeleton[^/]*\bdecorative\b/g),
];
expect(decorativeLoaders.length).toBeGreaterThanOrEqual(2);
});
it("no insights-page skeleton falls back to the legacy h-48/h-32/h-64 trio", () => {
// The legacy classes shouldn't be reintroduced on the mother
// page's three loading placeholders.
const dynamicBlock = src.match(/const DailyBriefing[\s\S]*?const TrendsRow[\s\S]*?\);\n/);
it("no loader re-introduces a guessed fixed h-[Xrem] / h-[Xpx] height", () => {
// The guessed fixed heights were the CLS root cause; the loaders now
// hold the row open with a `min-h-*` floor instead. A fixed
// `h-[Xrem]` / `h-[Xpx]` must not creep back onto the dynamic-loader
// placeholders.
const dynamicBlock = src.match(
/const DailyBriefing[\s\S]*?const PeriodNarrativeCard[\s\S]*?\);\n/,
);
expect(dynamicBlock).not.toBeNull();
const block = dynamicBlock?.[0] ?? "";
expect(block).not.toMatch(/"[^"]*\bh-48\b[^"]*"/);
expect(block).not.toMatch(/"[^"]*\bh-32\b[^"]*"/);
expect(block).not.toMatch(/"[^"]*\bh-64\b[^"]*"/);
expect(block).not.toMatch(/h-\[\d+rem\]/);
expect(block).not.toMatch(/h-\[\d+px\]/);
// No bespoke pulsing card div should remain — every placeholder is
// the shared BlockSkeleton.
expect(block).not.toMatch(/animate-pulse/);
});
});
+50 -34
View File
@@ -12,6 +12,8 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useInsightsWarm } from "@/components/insights/use-insights-warm";
@@ -23,16 +25,40 @@ import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import type { InsightsAnalyticsData as AnalyticsData } from "@/types/analytics";
/**
* v1.4.33 IW2 — defer the three below-the-fold mother-page blocks
* behind `next/dynamic`. `<HeroStrip>` (the only above-the-fold piece)
* stays an eager import so the initial paint shows the greeting and
* health-score badge without a flash; the briefing, correlation row
* and trends row each carry their own icon-set + chart wiring (a chart
* card alone weighs in at the lucide tree-shake limit) and used to
* land on every Insights cold mount. Loader skeletons match the
* existing fallback so the layout doesn't shift while the chunks
* resolve.
* v1.4.33 IW2 — defer the below-the-fold mother-page blocks behind
* `next/dynamic`. `<HeroStrip>` (the only above-the-fold piece) stays an
* eager import so the initial paint shows the greeting and health-score
* badge without a flash; the briefing, correlation row and trends row
* each carry their own icon-set + chart wiring (a chart card alone weighs
* in at the lucide tree-shake limit) and used to land on every Insights
* cold mount.
*
* v1.11.3 — every loader fallback now routes through the shared
* `BlockSkeleton` instead of a bespoke `h-[Xrem] animate-pulse` div. The
* fixed guessed heights pinned each placeholder taller (or shorter) than
* the resolved block, so the page jumped as each chunk landed. The shared
* skeleton carries a `min-h` floor — enough to hold the row open at cold
* mount — and lets the block grow into its true height without fighting a
* hard-coded pixel guess, killing the resolve-time layout shift. Reduced
* motion is honoured by the `Skeleton` primitive (`motion-reduce:animate-none`).
*/
function BlockSkeleton({
minHeight,
decorative = false,
}: {
/** Tailwind `min-h-*` utility holding the row open before the chunk lands. */
minHeight: string;
/** Decorative placeholders (cards that may un-mount) hide from a11y. */
decorative?: boolean;
}) {
return (
<Skeleton
{...(decorative ? { "aria-hidden": "true" } : {})}
className={cn("w-full rounded-xl", minHeight)}
/>
);
}
const DailyBriefing = dynamic(
() =>
import("@/components/insights/daily-briefing").then((mod) => ({
@@ -40,9 +66,7 @@ const DailyBriefing = dynamic(
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border h-[24rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
),
loading: () => <BlockSkeleton minHeight="min-h-96" />,
},
);
const CorrelationRow = dynamic(
@@ -52,9 +76,7 @@ const CorrelationRow = dynamic(
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
),
loading: () => <BlockSkeleton minHeight="min-h-80" />,
},
);
const TrendsRow = dynamic(
@@ -64,9 +86,7 @@ const TrendsRow = dynamic(
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
),
loading: () => <BlockSkeleton minHeight="min-h-80" />,
},
);
// v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of
@@ -80,9 +100,7 @@ const VitalsDashboard = dynamic(
})),
{
ssr: false,
loading: () => (
<div className="bg-card border-border h-[20rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
),
loading: () => <BlockSkeleton minHeight="min-h-80" />,
},
);
// v1.10.0 — categorical events (WX-B). The device-flagged event awareness
@@ -111,12 +129,7 @@ const CoincidentDeviationCard = dynamic(
})),
{
ssr: false,
loading: () => (
<div
aria-hidden="true"
className="bg-card border-border min-h-48 animate-pulse rounded-xl border motion-reduce:animate-none"
/>
),
loading: () => <BlockSkeleton minHeight="min-h-48" decorative />,
},
);
@@ -132,12 +145,7 @@ const PeriodNarrativeCard = dynamic(
})),
{
ssr: false,
loading: () => (
<div
aria-hidden="true"
className="bg-card border-border min-h-40 animate-pulse rounded-xl border motion-reduce:animate-none"
/>
),
loading: () => <BlockSkeleton minHeight="min-h-40" decorative />,
},
);
@@ -293,10 +301,18 @@ export default function InsightsPage() {
{flags.briefing && <PeriodNarrativeCard enabled={isAuthenticated} />}
<div className="flex justify-end">
{/* v1.11.3 — anchor the warm-assessments control. It used to float
right-aligned with no label, reading as an orphan affordance. The
left-aligned caption explains the nightly-refresh model and gives the
button context. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-xs">
{t("insights.warmAssessmentsHint")}
</p>
<Button
size="sm"
variant="outline"
className="self-start sm:self-auto"
onClick={warm}
disabled={isWarming}
>