mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
e63fae564b
commit
0baad1e736
8 files changed
+120
-64
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user