From 0baad1e736b38812fa3e338b709049c9b0bc53cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 15:29:07 +0200 Subject: [PATCH] refactor(insights): share one block skeleton across dynamic loaders, anchor the warm-assessments control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six `next/dynamic` loaders on the insights overview each rendered a bespoke `
` 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. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../insights/__tests__/page-skeletons.test.ts | 88 ++++++++++++------- src/app/insights/page.tsx | 84 +++++++++++------- 8 files changed, 120 insertions(+), 64 deletions(-) diff --git a/messages/de.json b/messages/de.json index 3c69a1f9a..0631b2cae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 0004b2dcf..aaae6856f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index eb336d0f6..6ef303f74 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 40ed28bfe..eee9b4a73 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 921291c6a..9d71167f1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index af00afbcc..bd93e4d39 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ń", diff --git a/src/app/insights/__tests__/page-skeletons.test.ts b/src/app/insights/__tests__/page-skeletons.test.ts index 017a434eb..1b225f1a2 100644 --- a/src/app/insights/__tests__/page-skeletons.test.ts +++ b/src/app/insights/__tests__/page-skeletons.test.ts @@ -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 `
` 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(/ { - 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* { - 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(/ { - // 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/); }); }); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 8c771c38b..bd35614f5 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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`. `` (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`. `` (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 ( + + ); +} + const DailyBriefing = dynamic( () => import("@/components/insights/daily-briefing").then((mod) => ({ @@ -40,9 +66,7 @@ const DailyBriefing = dynamic( })), { ssr: false, - loading: () => ( -
- ), + loading: () => , }, ); const CorrelationRow = dynamic( @@ -52,9 +76,7 @@ const CorrelationRow = dynamic( })), { ssr: false, - loading: () => ( -
- ), + loading: () => , }, ); const TrendsRow = dynamic( @@ -64,9 +86,7 @@ const TrendsRow = dynamic( })), { ssr: false, - loading: () => ( -
- ), + loading: () => , }, ); // v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of @@ -80,9 +100,7 @@ const VitalsDashboard = dynamic( })), { ssr: false, - loading: () => ( -
- ), + loading: () => , }, ); // v1.10.0 — categorical events (WX-B). The device-flagged event awareness @@ -111,12 +129,7 @@ const CoincidentDeviationCard = dynamic( })), { ssr: false, - loading: () => ( -