mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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
@@ -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