feat(reporting): loading states for scenario switch + filter changes

The scenario overlay uses keepPreviousData, so switching scenario or changing
a filter left the previous figures on screen with nothing signalling they were
being recalculated. Now:

- The picker row shows a small spinner + "Updating…" beside the filter chips
  while the overlay refetches — immediate feedback right at the controls. It
  reads react-query's useIsFetching(["scenario-report"]) so the query stays
  owned by MetricsBody (no lifting).
- The figures (KPI band + EPC distribution + ledger) dim (opacity-50, aria-busy)
  while refetching with stale data on screen, and show a spinner + "Updating
  figures…" / "Loading scenario…" line above them.

Verified end-to-end with a headless browser on portfolio 796: switching to an
"Increasing EPC" scenario and toggling a filter both surface the spinner,
"Updating…"/"Loading…" text, aria-busy and the dimmed 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:04:03 +01:00
parent 587dcacc25
commit a23719aa34

View file

@ -1,19 +1,26 @@
"use client";
import { Suspense, use, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useIsFetching } from "@tanstack/react-query";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { formatNumber, sapToEpc } from "@/app/utils";
import {
selectGoalCallout,
shapeKpiDelta,
toBandCounts,
} from "@/lib/reporting/model";
import { ScenarioCombobox, type ReportView } from "./components/ScenarioCombobox";
import {
ScenarioCombobox,
type ReportView,
} from "./components/ScenarioCombobox";
import { KpiBand, type Kpi } from "./components/KpiBand";
import { EpcChip, Panel } from "./components/primitives";
import { EpcLadder } from "./components/EpcLadder";
import { GoalCallout } from "./components/GoalCallout";
import { InvestmentLedger, type LedgerView } from "./components/InvestmentLedger";
import {
InvestmentLedger,
type LedgerView,
} from "./components/InvestmentLedger";
import { FilterChips, type ScenarioFilters } from "./components/FilterChips";
import { DrillDownShelf, type DrillTarget } from "./components/DrillDownShelf";
import { StockBreakdown } from "./components/StockBreakdown";
@ -164,6 +171,12 @@ function ScenarioRow({
typeof view === "number" ? scenarios.find((s) => s.id === view) : undefined;
const showEpcFilters = selectedScenario?.goal === "Increasing EPC";
// Subscribe to the scenario overlay fetch (owned by MetricsBody) without
// lifting it — so applying a filter shows immediate feedback right here at
// the controls, not only down in the figures.
const fetchingCount = useIsFetching({ queryKey: ["scenario-report"] });
const busy = isScenario && fetchingCount > 0;
return (
<div className="flex flex-wrap items-center gap-3">
<ScenarioCombobox scenarios={scenarios} value={view} onChange={onView} />
@ -183,7 +196,9 @@ function ScenarioRow({
{selectedScenario.goal === "Increasing EPC" && (
<>
Target{" "}
<b className="text-gray-900">EPC {selectedScenario.goalValue}</b>{" "}
<b className="text-gray-900">
EPC {selectedScenario.goalValue}
</b>{" "}
·{" "}
</>
)}
@ -199,7 +214,17 @@ function ScenarioRow({
)
)}
{isScenario && (
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
{busy && (
<span
className="flex items-center gap-1 text-[0.72rem] text-gray-500"
role="status"
aria-live="polite"
>
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin text-gray-400" />
Updating
</span>
)}
<FilterChips
filters={filters}
onChange={onFilters}
@ -433,12 +458,12 @@ function MetricsBody({
const ledger: LedgerView | null = scenarioData
? (() => {
const carbonSaved =
(baseline.totals.total_carbon ?? 0) - Number(scenarioData.total_carbon);
(baseline.totals.total_carbon ?? 0) -
Number(scenarioData.total_carbon);
const billSaved =
(baseline.totals.total_bills ?? 0) - Number(scenarioData.total_bills);
const n = scenarioData.n_units_upgraded || 1;
const capital =
scenarioData.construction_cost + scenarioData.pc_cost;
const capital = scenarioData.construction_cost + scenarioData.pc_cost;
return {
constructionCost: scenarioData.construction_cost,
projectDelivery: scenarioData.pc_cost,
@ -459,65 +484,85 @@ function MetricsBody({
})()
: null;
const scenarioBusy = isScenario && isFetching;
return (
<div className="space-y-4">
{/* KPI band */}
<KpiBand kpis={isScenario ? scenarioKpis : currentStockKpis} />
{isScenario && isFetching && !scenarioData && (
<p className="text-sm text-gray-600">Loading scenario</p>
{scenarioBusy && (
<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…"}
</div>
)}
{/* Charts + ledger */}
<div className="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-[1.55fr_1fr]">
<Panel
title="EPC distribution"
meta={
isScenario
? "Current vs scenario · click a band for its homes"
: "Effective performance · click a band for its homes"
}
>
<div className="mb-4">
<EpcLadder
bands={baseline.epcBands}
scenarioBands={scenarioBands}
selectedBand={drill?.filter === "band" ? drill.band : null}
onSelectBand={(band) =>
openDrill({
filter: "band",
band,
chipBand: band,
title: `Band ${band}`,
})
{/* Scenario figures dim while the overlay refetches keepPreviousData
leaves the old numbers on screen, so this signals they're being
recalculated rather than letting them look silently up to date. */}
<div
className={`space-y-4 transition-opacity duration-200 ${
scenarioBusy && scenarioData ? "opacity-50" : ""
}`}
aria-busy={scenarioBusy}
>
{/* KPI band */}
<KpiBand kpis={isScenario ? scenarioKpis : currentStockKpis} />
{/* Charts + ledger */}
<div className="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-[1.55fr_1fr]">
<Panel
title="EPC distribution"
meta={
isScenario
? "Current vs scenario · click a band for its homes"
: "Effective performance · click a band for its homes"
}
>
<div className="mb-4">
<EpcLadder
bands={baseline.epcBands}
scenarioBands={scenarioBands}
selectedBand={drill?.filter === "band" ? drill.band : null}
onSelectBand={(band) =>
openDrill({
filter: "band",
band,
chipBand: band,
title: `Band ${band}`,
})
}
/>
</div>
<GoalCallout
callout={callout}
total={total}
dimensionTotals={{ carbon: baseline.totals.total_carbon ?? 0 }}
onDrill={
callout.kind === "below-band"
? () =>
openDrill({
filter: "band",
band: callout.band,
chipBand: callout.band,
title: `Below EPC ${callout.band}`,
})
: undefined
}
/>
</div>
<GoalCallout
callout={callout}
total={total}
dimensionTotals={{ carbon: baseline.totals.total_carbon ?? 0 }}
onDrill={
callout.kind === "below-band"
? () =>
openDrill({
filter: "band",
band: callout.band,
chipBand: callout.band,
title: `Below EPC ${callout.band}`,
})
: undefined
}
/>
</Panel>
</Panel>
{isScenario && ledger ? (
<InvestmentLedger v={ledger} />
) : (
<StockBreakdown
propertyTypes={propertyTypes}
ageBands={baseline.ageBands}
/>
)}
{isScenario && ledger ? (
<InvestmentLedger v={ledger} />
) : (
<StockBreakdown
propertyTypes={propertyTypes}
ageBands={baseline.ageBands}
/>
)}
</div>
</div>
{/* Where the money goes (scenario only) */}