From a23719aa34fba775fe9bca6b098767060146c99d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 19 Jul 2026 00:04:03 +0100 Subject: [PATCH] feat(reporting): loading states for scenario switch + filter changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../reporting/ReportingClientArea.tsx | 165 +++++++++++------- 1 file changed, 105 insertions(+), 60 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index a2259636..8a6054b5 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -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 (
@@ -183,7 +196,9 @@ function ScenarioRow({ {selectedScenario.goal === "Increasing EPC" && ( <> Target{" "} - EPC {selectedScenario.goalValue}{" "} + + EPC {selectedScenario.goalValue} + {" "} ·{" "} )} @@ -199,7 +214,17 @@ function ScenarioRow({ ) )} {isScenario && ( -
+
+ {busy && ( + + + Updating… + + )} { 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 (
- {/* KPI band */} - - {isScenario && isFetching && !scenarioData && ( -

Loading scenario…

+ {scenarioBusy && ( +
+ + {scenarioData ? "Updating figures…" : "Loading scenario…"} +
)} - {/* Charts + ledger */} -
- -
- - 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. */} +
+ {/* KPI band */} + + + {/* Charts + ledger */} +
+ +
+ + openDrill({ + filter: "band", + band, + chipBand: band, + title: `Band ${band}`, + }) + } + /> +
+ + openDrill({ + filter: "band", + band: callout.band, + chipBand: callout.band, + title: `Below EPC ${callout.band}`, + }) + : undefined } /> -
- - openDrill({ - filter: "band", - band: callout.band, - chipBand: callout.band, - title: `Below EPC ${callout.band}`, - }) - : undefined - } - /> - + - {isScenario && ledger ? ( - - ) : ( - - )} + {isScenario && ledger ? ( + + ) : ( + + )} +
{/* Where the money goes (scenario only) */}