adjusted loading user experience to open drawer -> show loading skeletons -> show metrics

This commit is contained in:
Khalim Conn-Kowlessar 2026-02-07 20:47:56 +00:00
parent dce30edf00
commit c7e7f6c50e
2 changed files with 168 additions and 70 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper";
import { DashboardSummaryCards } from "./DashboardSummaryCards";
@ -124,6 +124,7 @@ export function ReportingClientArea({
scenarioId: selectedScenarioId!,
}),
enabled: measuresOpen && !!selectedScenarioId,
keepPreviousData: true,
});
const scenarioLoading = isLoading && !!selectedScenarioId;
@ -160,37 +161,40 @@ export function ReportingClientArea({
// Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline
// ----------------------------------------
const scenarioSpecific = scenarioData
? {
constructionCost: scenarioData.construction_cost,
pcCost: scenarioData.pc_cost,
contingency: scenarioData.contingency,
funding: scenarioData.total_funding,
costPerSap:
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
scenarioData.total_sap_uplift
: 0,
costPerCo2:
scenarioData.construction_cost > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
: 0,
netCost: scenarioData.net_cost,
grossPerUnit: scenarioData.gross_per_unit,
nUnits: scenarioData.n_units_upgraded,
totalCarbonSaved:
(baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon,
totalBillsSaved:
(baseline.totals.total_bills ?? 0) - scenarioData.total_bills,
averageCaribonSaved:
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) /
scenarioData.n_units_upgraded,
averageBillsSaved:
((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) /
scenarioData.n_units_upgraded,
}
: null;
const scenarioSpecific = useMemo(() => {
if (!scenarioData) return null;
return {
constructionCost: scenarioData.construction_cost,
pcCost: scenarioData.pc_cost,
contingency: scenarioData.contingency,
funding: scenarioData.total_funding,
costPerSap:
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
scenarioData.total_sap_uplift
: 0,
costPerCo2:
scenarioData.construction_cost > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
: 0,
netCost: scenarioData.net_cost,
grossPerUnit: scenarioData.gross_per_unit,
nUnits: scenarioData.n_units_upgraded,
totalCarbonSaved:
(baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon,
totalBillsSaved:
(baseline.totals.total_bills ?? 0) - scenarioData.total_bills,
averageCaribonSaved:
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) /
scenarioData.n_units_upgraded,
averageBillsSaved:
((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) /
scenarioData.n_units_upgraded,
};
}, [scenarioData, baseline]);
// Baseline stays baseline
const activeMetrics = baseline;
@ -272,7 +276,11 @@ export function ReportingClientArea({
subtitle="High-level insights on performance, energy, and EPC quality."
/>
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioSpecific} />
<ScenarioFinancialDrawer
open={drawerOpen}
metrics={scenarioSpecific}
loading={isFetching && !!scenarioData}
/>
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2">
<DashboardSummaryCards

View file

@ -26,6 +26,7 @@ import { Gauge } from "lucide-react";
interface ScenarioFinancialDrawerProps {
open: boolean;
metrics: any | null;
loading?: boolean;
}
/* ───────────────────────────────────────────── */
@ -56,7 +57,7 @@ function GradientCard({
className={clsx(
"relative rounded-lg p-[2px] gradient-card",
gradient,
`gradient-${variant}`
`gradient-${variant}`,
)}
>
<div className="rounded-[7px] bg-white h-full">{children}</div>
@ -75,6 +76,7 @@ function Metric({
color,
gradient,
variant = "green",
loading = false,
}: {
label: string;
value: string | number;
@ -82,15 +84,38 @@ function Metric({
color: string;
gradient: string;
variant?: "green" | "blue" | "purple";
loading?: boolean;
}) {
if (loading || !value) {
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full animate-pulse">
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
<div className="h-6 w-3/4 bg-gray-200 rounded" />
</div>
</GradientCard>
);
}
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="flex flex-col items-center justify-center p-4 h-full text-center">
<Icon className={clsx("h-6 w-6 mb-2", color)} />
<span className="text-3xl font-semibold text-gray-900">{value}</span>
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
{label}
</span>
{loading ? (
<div className="w-full animate-pulse space-y-3">
<div className="h-6 w-6 mx-auto rounded bg-gray-200" />
<div className="h-8 w-2/3 mx-auto rounded bg-gray-200" />
<div className="h-3 w-1/2 mx-auto rounded bg-gray-200" />
</div>
) : (
<>
<Icon className={clsx("h-6 w-6 mb-2", color)} />
<span className="text-3xl font-semibold text-gray-900">
{value}
</span>
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
{label}
</span>
</>
)}
</div>
</GradientCard>
);
@ -108,6 +133,7 @@ function PairedMetric({
gradient,
iconClassName = "text-gray-700",
variant = "green",
loading = false,
}: {
title: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
@ -116,30 +142,62 @@ function PairedMetric({
gradient: string;
iconClassName?: string;
variant?: "green" | "blue" | "purple";
loading?: boolean;
}) {
if (loading || !primary.value || !secondary.value) {
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full animate-pulse">
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
<div className="h-6 w-3/4 bg-gray-200 rounded" />
</div>
</GradientCard>
);
}
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full">
<div className="flex items-center gap-2 mb-3">
<Icon className={clsx("h-5 w-5", iconClassName)} />
<span className="text-sm font-semibold text-gray-900">{title}</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500">{primary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{primary.value}
</p>
{loading ? (
<div className="animate-pulse space-y-4">
<div className="h-4 w-1/3 rounded bg-gray-200" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="h-3 w-2/3 rounded bg-gray-200" />
<div className="h-6 w-full rounded bg-gray-200" />
</div>
<div className="space-y-2">
<div className="h-3 w-2/3 rounded bg-gray-200" />
<div className="h-6 w-full rounded bg-gray-200" />
</div>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<Icon className={clsx("h-5 w-5", iconClassName)} />
<span className="text-sm font-semibold text-gray-900">
{title}
</span>
</div>
<div>
<p className="text-xs text-gray-500">{secondary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{secondary.value}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500">{primary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{primary.value}
</p>
</div>
<div>
<p className="text-xs text-gray-500">{secondary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{secondary.value}
</p>
</div>
</div>
</>
)}
</div>
</GradientCard>
);
@ -172,7 +230,7 @@ function Section({
<div
className={clsx(
"rounded-lg p-2 bg-white shadow-sm border",
accentColor
accentColor,
)}
>
<Icon className="h-5 w-5" />
@ -191,6 +249,22 @@ function Section({
);
}
/* ───────────────────────────────────────────── */
/* Loading Skeleton for dashboard cards */
/* ───────────────────────────────────────────── */
function LoadingOverlay() {
return (
<div className="absolute inset-0 z-20 rounded-lg bg-white/70 backdrop-blur-sm">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-6 animate-pulse">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="h-28 rounded-lg bg-gray-200" />
))}
</div>
</div>
);
}
/* ───────────────────────────────────────────── */
/* Main Drawer */
/* ───────────────────────────────────────────── */
@ -198,10 +272,11 @@ function Section({
export function ScenarioFinancialDrawer({
open,
metrics,
loading = false,
}: ScenarioFinancialDrawerProps) {
return (
<AnimatePresence initial={false}>
{open && metrics && (
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
@ -228,14 +303,17 @@ export function ScenarioFinancialDrawer({
iconClassName="text-green-700"
primary={{
label: "Total carbon saved (t/yr)",
value: formatNumber(metrics.totalCarbonSaved),
value: metrics ? formatNumber(metrics.totalCarbonSaved) : "",
}}
secondary={{
label: "Average per unit (t/yr)",
value: formatNumber(metrics.averageCaribonSaved),
value: metrics
? formatNumber(metrics.averageCaribonSaved)
: "",
}}
gradient={gradients.green}
variant="green"
loading={loading}
/>
<PairedMetric
@ -244,23 +322,29 @@ export function ScenarioFinancialDrawer({
iconClassName="text-green-700"
primary={{
label: "Total bill savings (£/yr)",
value: `£${formatNumber(metrics.totalBillsSaved)}`,
value: metrics
? `£${formatNumber(metrics.totalBillsSaved)}`
: "",
}}
secondary={{
label: "Average per unit (£/yr)",
value: `£${formatNumber(metrics.averageBillsSaved)}`,
value: metrics
? `£${formatNumber(metrics.averageBillsSaved)}`
: "",
}}
gradient={gradients.green}
variant="green"
loading={loading}
/>
<Metric
label="Homes upgraded"
value={metrics.nUnits}
value={metrics ? metrics.nUnits : ""}
icon={HomeIcon}
color="text-green-700"
gradient={gradients.green}
variant="green"
loading={loading}
/>
</Section>
@ -278,32 +362,37 @@ export function ScenarioFinancialDrawer({
iconClassName="text-blue-600"
primary={{
label: "Construction works",
value: `£${formatNumber(metrics.constructionCost)}`,
value: metrics
? `£${formatNumber(metrics.constructionCost)}`
: "",
}}
secondary={{
label: "Project delivery",
value: `£${formatNumber(metrics.pcCost)}`,
value: metrics ? `£${formatNumber(metrics.pcCost)}` : "",
}}
gradient={gradients.blue}
variant="blue"
loading={loading}
/>
<Metric
label="Gross cost per unit"
value={`£${formatNumber(metrics.grossPerUnit)}`}
value={metrics ? `£${formatNumber(metrics.grossPerUnit)}` : ""}
icon={HomeIcon}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
loading={loading}
/>
<Metric
label="Contingency"
value={`£${formatNumber(metrics.contingency)}`}
value={metrics ? `£${formatNumber(metrics.contingency)}` : ""}
icon={Gauge}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
loading={loading}
/>
</Section>
@ -321,14 +410,15 @@ export function ScenarioFinancialDrawer({
iconClassName="text-purple-700"
primary={{
label: "£ per SAP point",
value: `£${formatNumber(metrics.costPerSap)}`,
value: metrics ? `£${formatNumber(metrics.costPerSap)}` : "",
}}
secondary={{
label: "£ per tonne CO₂",
value: `£${formatNumber(metrics.costPerCo2)}`,
value: metrics ? `£${formatNumber(metrics.costPerCo2)}` : "",
}}
gradient={gradients.purple}
variant="purple"
loading={loading}
/>
</Section>
</div>