mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-08-02 12:58:24 +00:00
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:
parent
587dcacc25
commit
a23719aa34
1 changed files with 105 additions and 60 deletions
|
|
@ -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) */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue