diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts new file mode 100644 index 000000000..6947dbd08 --- /dev/null +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -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* { + 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/, + ); + }); +}); diff --git a/src/app/page.tsx b/src/app/page.tsx index f457f6459..9c469aa12 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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 ( } @@ -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 && ( +