mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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.
175 lines
6.3 KiB
TypeScript
175 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { cn } from "@/lib/utils";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
|
|
/**
|
|
* v1.4.28 R3d (R1.2 H4) — layout-stable loading shell for every
|
|
* `next/dynamic` chart import on the dashboard and the insights
|
|
* sub-pages.
|
|
*
|
|
* The chart components (`<HealthChart>`, `<MoodChart>`, …) live behind
|
|
* `dynamic(() => import("…"), { ssr: false })`. Pre-fix, the loading
|
|
* slot was empty: the page hydrated, the Recharts chunk fetched, and
|
|
* the layout collapsed/expanded as the chart arrived. On a cold visit
|
|
* over a slow network the gap was visible for several seconds (R1.2
|
|
* H4).
|
|
*
|
|
* This skeleton mirrors the same container that `health-chart.tsx`
|
|
* paints (rounded card, border, `p-4 md:p-6`) plus a faux header row
|
|
* (title + range tabs) and a chart band sized to the CSS variable the
|
|
* chart itself reads (`--chart-height` → 240 px mobile / 280 px md+).
|
|
* The cumulative box matches the loaded chart's height to within a
|
|
* pixel so the page does not jump on hydration.
|
|
*
|
|
* v1.4.36 W2 — `mini` variant for the trends-row 140 px chart slot.
|
|
* The default skeleton's rounded-xl border + p-4/p-6 padding inflated
|
|
* past the 140 px wrapper and pushed the row taller than the loaded
|
|
* chart, re-introducing the very layout shift the wrapper now
|
|
* eliminates. The mini variant trades the card chrome for
|
|
* `rounded-md p-2` and pins the inner band at 140 px so the loading
|
|
* shell occupies the same visible box as the painted chart.
|
|
*
|
|
* v1.4.43 QoL (L4) — when the skeleton is still painted three seconds
|
|
* in, surface a small caption ("Computing analytics — this can take
|
|
* a moment") so the user understands the chart is being computed
|
|
* rather than broken. Today the `/api/analytics` correlations branch
|
|
* can sit on the skeleton for ~9 s; the caption sets expectation.
|
|
* Only fires on the full variant — the mini-skeleton sits in a
|
|
* dashboard row strip where a multi-line caption would overflow.
|
|
*
|
|
* `prefers-reduced-motion` is honoured automatically — the `<Skeleton>`
|
|
* primitive's `animate-pulse` is suppressed via Tailwind's
|
|
* `motion-reduce:animate-none` modifier.
|
|
*/
|
|
|
|
const SLOW_HINT_DELAY_MS = 3_000;
|
|
|
|
/**
|
|
* The footer a loaded `<HealthChart>` 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 (
|
|
<div aria-hidden="true" data-slot="chart-footer-reserve">
|
|
{dayLinks ? (
|
|
<>
|
|
<div className="h-3" />
|
|
<div className="mt-1 h-9 sm:h-4" />
|
|
</>
|
|
) : null}
|
|
{dataTable ? (
|
|
<div className="border-border mt-3 border-t pt-3">
|
|
<div className="h-11" />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ChartSkeleton({
|
|
className,
|
|
mini = false,
|
|
dayLinks = false,
|
|
dataTable = false,
|
|
}: {
|
|
className?: string;
|
|
/** Reserve the day-dot row + caption a day-linked chart paints. */
|
|
dayLinks?: boolean;
|
|
/** Reserve the collapsed data-table disclosure. */
|
|
dataTable?: boolean;
|
|
/**
|
|
* When true, render the compact variant used inside the trends-row
|
|
* chart slot (140 px band, lighter chrome). Defaults to false for
|
|
* the dashboard hero / sub-page charts.
|
|
*/
|
|
mini?: boolean;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
// v1.4.43 QoL (L4) — `showSlowHint` flips true 3 s into the load so
|
|
// the caption only appears for genuinely slow renders. Using a
|
|
// `useEffect` keeps the SSR markup identical (caption absent) so
|
|
// hydration never mismatches; the timer kicks in client-side once
|
|
// the skeleton actually paints.
|
|
const [showSlowHint, setShowSlowHint] = useState(false);
|
|
useEffect(() => {
|
|
if (mini) return undefined;
|
|
const handle = setTimeout(() => setShowSlowHint(true), SLOW_HINT_DELAY_MS);
|
|
return () => clearTimeout(handle);
|
|
}, [mini]);
|
|
|
|
if (mini) {
|
|
return (
|
|
<div
|
|
data-slot="chart-skeleton"
|
|
data-mini="true"
|
|
role="status"
|
|
aria-busy="true"
|
|
aria-live="polite"
|
|
className={cn("bg-card border-border rounded-md border p-2", className)}
|
|
>
|
|
<span className="sr-only">{t("charts.loadingLabel")}</span>
|
|
<Skeleton className="h-[140px] w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
data-slot="chart-skeleton"
|
|
role="status"
|
|
aria-busy="true"
|
|
aria-live="polite"
|
|
className={cn(
|
|
"bg-card border-border rounded-xl border p-4 md:p-6",
|
|
className,
|
|
)}
|
|
>
|
|
{/* v1.4.34 IW-G — sr-only announcement now reads the locale's
|
|
`charts.loadingLabel` key instead of literal English. */}
|
|
<span className="sr-only">{t("charts.loadingLabel")}</span>
|
|
|
|
{/* Header row — title + range tabs match the real chart's
|
|
`mb-4 flex flex-col gap-2 sm:flex-row` chrome. */}
|
|
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<Skeleton className="h-5 w-32" />
|
|
{/* The range tabs are 44 px tap targets, so the row is too. */}
|
|
<div className="flex h-11 items-center gap-2">
|
|
<Skeleton className="h-6 w-12" />
|
|
<Skeleton className="h-6 w-12" />
|
|
<Skeleton className="h-6 w-12" />
|
|
<Skeleton className="h-6 w-12" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chart band — mirrors the same `h-[var(--chart-height,…)]`
|
|
contract `health-chart.tsx` paints at line 1087-1089. */}
|
|
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
|
|
<ChartFooterReserve dayLinks={dayLinks} dataTable={dataTable} />
|
|
|
|
{showSlowHint && (
|
|
<p
|
|
data-slot="chart-skeleton-slow-hint"
|
|
className="text-muted-foreground mt-3 text-xs"
|
|
>
|
|
{t("charts.loadingSlowHint")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|