feat(reporting): skeleton the KPI band on first scenario entry

Switching from Current stock into a scenario has no previous overlay to keep,
so the scenario KPIs were momentarily empty — an empty-bar flash before the
figures loaded. That's the "no data yet" case, so it now shows the same
KpiBandSkeleton the initial page load uses (extracted + shared), and drops the
now-redundant "Loading scenario…" line there.

The dim + "Updating figures…" path is unchanged and stays for the cases where
data is already on screen (scenario→scenario switch, filter changes), where the
previous figures are deliberately kept via keepPreviousData. Net: skeleton = no
data yet; dim = refreshing existing data — one consistent convention.

Verified in a headless browser on #796: first entry shows the KPI skeleton +
aria-busy and no empty flash; a filter toggle still dims the figures with
"Updating figures…".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-19 00:11:50 +01:00
parent a23719aa34
commit 43667ec1fd

View file

@ -485,17 +485,25 @@ function MetricsBody({
: null;
const scenarioBusy = isScenario && isFetching;
// First entry into a scenario from Current stock: there's no previous overlay
// to keep, so the scenario KPIs are momentarily empty. Skeleton that band
// (the "no data yet" case → same pattern as first page load) rather than
// flashing an empty bar. A scenario→scenario switch keeps the old figures, so
// it stays on the dim+spinner path below instead.
const scenarioLoadingFirst = scenarioBusy && !scenarioData;
return (
<div className="space-y-4">
{scenarioBusy && (
{/* Refetch-with-data only: the figures stay on screen and dim, so a label
clarifies the dim means "updating". First load speaks via its skeleton. */}
{scenarioBusy && scenarioData && (
<div
className="flex items-center gap-1.5 text-[0.8rem] text-gray-600"
role="status"
aria-live="polite"
>
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin text-gray-400" />
{scenarioData ? "Updating figures…" : "Loading scenario…"}
Updating figures
</div>
)}
@ -509,7 +517,11 @@ function MetricsBody({
aria-busy={scenarioBusy}
>
{/* KPI band */}
<KpiBand kpis={isScenario ? scenarioKpis : currentStockKpis} />
{scenarioLoadingFirst ? (
<KpiBandSkeleton />
) : (
<KpiBand kpis={isScenario ? scenarioKpis : currentStockKpis} />
)}
{/* Charts + ledger */}
<div className="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-[1.55fr_1fr]">
@ -610,21 +622,34 @@ function ScenarioRowSkeleton() {
);
}
/**
* The five-cell KPI band in its loading shape. Shared by the initial page-load
* skeleton and the first Current-stock scenario switch, so both "no data yet"
* moments read identically.
*/
function KpiBandSkeleton() {
return (
<div
className="grid grid-cols-2 rounded-lg border border-gray-200 bg-white sm:grid-cols-3 lg:grid-cols-5"
aria-hidden
>
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className={`space-y-2 px-5 py-4 ${i > 0 ? "border-l border-gray-100" : ""}`}
>
<div className="h-2.5 w-16 animate-pulse rounded bg-gray-100" />
<div className="h-6 w-20 animate-pulse rounded bg-gray-100" />
</div>
))}
</div>
);
}
function MetricsSkeleton() {
return (
<div className="space-y-4" aria-hidden>
{/* KPI band */}
<div className="grid grid-cols-2 rounded-lg border border-gray-200 bg-white sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className={`space-y-2 px-5 py-4 ${i > 0 ? "border-l border-gray-100" : ""}`}
>
<div className="h-2.5 w-16 animate-pulse rounded bg-gray-100" />
<div className="h-6 w-20 animate-pulse rounded bg-gray-100" />
</div>
))}
</div>
<KpiBandSkeleton />
{/* Charts + side panel */}
<div className="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-[1.55fr_1fr]">
<div className="h-64 animate-pulse rounded-lg border border-gray-200 bg-white" />