fix(dashboard): paint tile-strip skeleton during slow slim-analytics fetch

The tile strip only painted once `trendCards.length > 0`. When both
slim + thick `/api/analytics` slices lagged (cache eviction, cold
start) the user saw the page header + zero tiles + then a burst of
tiles seconds later. Reserve the strip's footprint by rendering N
silhouette cards (`auto-fit minmax(min(100%,11rem),1fr)` track,
matching the real strip) keyed off the user's configured tile count
while `analyticsSlimQuery.isLoading`. The placeholders swap to the
real tiles the moment slim resolves; the EmptyState branch is
suppressed for the loading window so the user no longer sees a
flash of "no data" when the network is just slow.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:09:12 +02:00
1 parent 9c9a99aea4
commit d02deb1769
2 files changed
+126 -1

No files matched your search

@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow slim
* fetches.
*
* Pre-fix the strip only painted once `trendCards.length > 0`. When
* both slim + thick `/api/analytics` slices lagged (cache eviction,
* cold start) the user saw the page header + zero tiles + then a
* burst of tiles 9 s later. The fix paints a layout-stable silhouette
* keyed off the user's configured tile count
* (`layout.widgets.filter(w => w.tileVisible ?? w.visible).length`)
* while `analyticsSlimQuery.isLoading` is still in flight, so the
* page reserves the strip's footprint immediately and swaps to the
* real tiles the moment slim resolves.
*
* Textual pins keep the contract honest — render-mounting the
* dashboard page would haul in TanStack-Query, auth, i18n, layout
* and analytics scaffolding for a guard a substring scan already
* proves.
*/
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
describe("v1.4.43 W11 — dashboard tile-strip skeleton", () => {
it("derives `configuredTileCount` from the resolved layout", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/const\s+configuredTileCount\s*=\s*layout\.widgets\.filter\([\s\S]*?w\.tileVisible\s*\?\?\s*w\.visible[\s\S]*?\)\.length;/,
);
});
it("gates the skeleton on slim-analytics loading + empty trend cards + configured tiles", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/const\s+showTileStripSkeleton\s*=\s*[\s\S]*?trendCards\.length\s*===\s*0[\s\S]*?analyticsSlimQuery\.isLoading[\s\S]*?configuredTileCount\s*>\s*0/,
);
});
it("renders the skeleton with the same auto-fit grid track the real strip uses", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/showTileStripSkeleton\s*&&\s*\(\s*<div[\s\S]*?\[grid-template-columns:repeat\(auto-fit,minmax\(min\(100%,11rem\),1fr\)\)\]/,
);
});
it("keys the skeleton card count off configuredTileCount", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/Array\.from\(\{\s*length:\s*configuredTileCount\s*\}\)/,
);
});
it("marks the skeleton aria-hidden + carries motion-reduce", () => {
const src = readFileSync(PAGE_PATH, "utf8");
const skeletonBlock = src.match(
/showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/,
);
expect(skeletonBlock).not.toBeNull();
const block = skeletonBlock?.[0] ?? "";
expect(block).toContain('aria-hidden="true"');
expect(block).toContain("motion-reduce:animate-none");
});
it("suppresses the EmptyState while the skeleton is showing", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/trendCards\.length\s*===\s*0\s*&&\s*charts\.length\s*===\s*0\s*&&\s*!showTileStripSkeleton/,
);
});
});
+54 -1
View File
@@ -1303,6 +1303,27 @@ export default function DashboardPage() {
charts.sort((a, b) => a.order - b.order);
// v1.4.43 W11-M5 — tile-strip skeleton during slow slim-analytics
// fetches.
//
// `trendCards` only fills once the slim `/api/analytics` slice
// resolves and reports per-type `count > 0` flags. The v1.4.39.2
// slim/thick split keeps the strip painting fast when slim wins,
// but when *both* slices lag (cache eviction, cold start) the
// user used to see the page header + 0 tiles + then 7 tiles
// appear at once 9 s later. The audit recommendation is to
// render a layout-stable tile silhouette keyed off the user's
// configured tile count so the strip's footprint is reserved
// during the slow window. The skeleton swaps in for the real
// strip the moment `analyticsSlimQuery.isLoading` flips false.
const configuredTileCount = layout.widgets.filter(
(w) => w.tileVisible ?? w.visible,
).length;
const showTileStripSkeleton =
trendCards.length === 0 &&
analyticsSlimQuery.isLoading &&
configuredTileCount > 0;
// v1.4.15 phase-C5: dashboard fully-empty state. When no tile
// and no chart has data the dashboard would otherwise paint a
// 0-px tile strip with the welcome banner above it — visually
@@ -1313,7 +1334,15 @@ export default function DashboardPage() {
// surface for very-new accounts; this empty state covers the
// case where the checklist has been dismissed but no data was
// logged afterwards.
if (trendCards.length === 0 && charts.length === 0) {
//
// v1.4.43 W11 — the empty-state only fires once the slim slice
// resolves with no tiles to show. While slim is in flight the
// skeleton strip below carries the layout footprint.
if (
trendCards.length === 0 &&
charts.length === 0 &&
!showTileStripSkeleton
) {
return (
<EmptyState
icon={<Activity className="size-6" />}
@@ -1357,6 +1386,30 @@ export default function DashboardPage() {
CSS-grid `auto-fit + minmax + auto-rows-fr` track that
continues to give every visible tile equal width / equal
height for any non-zero count. */}
{showTileStripSkeleton && (
<div
// v1.4.43 W11-M5 — tile-strip skeleton mirrors the real
// grid track (`auto-fit minmax(min(100%,11rem),1fr)`) so
// the layout footprint is reserved while slim-analytics
// is in flight. Cards are keyed off the user's
// configured tile count (`configuredTileCount`) — if the
// user trimmed the strip to 3 tiles in Settings, the
// skeleton shows 3 silhouettes, not 7.
aria-hidden="true"
data-slot="dashboard-tile-strip-skeleton"
className={cn(
"grid auto-rows-fr gap-3",
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]",
)}
>
{Array.from({ length: configuredTileCount }).map((_, idx) => (
<div
key={`tile-skeleton-${idx}`}
className="bg-card border-border min-h-[6rem] animate-pulse rounded-xl border p-4 motion-reduce:animate-none md:p-6"
/>
))}
</div>
)}
{trendCards.length > 0 && (
<div
// v1.4.33 A3 Win 2 + F3 — collapse the bifurcated