feat(reporting): explain what each scenario cost figure covers

Users couldn't tell what the cost values include or exclude — e.g. does
"gross per home" cover contingency, is the principal contractor's price
in there. Add plain-language "what's included / excluded" disclosure
across the scenario overlay, sourced from one canonical copy module
(costHelp.ts) so every surface says it the same way and matches the
domain glossary.

- New shared `InfoDot` primitive (the one ⓘ affordance).
- Investment ledger: ⓘ on Net cost, gross/home, Construction works,
  Project delivery, Contingency, Bill savings, Carbon saved, and the two
  value-for-money ratios — each stating what it does/doesn't cover
  (e.g. construction = the principal contractor's works, excludes
  delivery + contingency; gross = works + delivery + contingency).
- KPI band gains an optional `tip`; the "Gross cost /home" KPI uses it.
- "Where the money goes" now notes it's construction works only, so it
  reconciles to the Construction works line, not gross cost.

Copy only — no figures or queries changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-19 15:34:44 +01:00
parent a1bfde40f8
commit 5e7b7aa071
6 changed files with 116 additions and 41 deletions

View file

@ -31,6 +31,7 @@ import {
type LedgerView,
} from "./components/InvestmentLedger";
import { FilterChips, type ScenarioFilters } from "./components/FilterChips";
import { COST_HELP } from "./components/costHelp";
import { DrillDownShelf, type DrillTarget } from "./components/DrillDownShelf";
import { StockBreakdown } from "./components/StockBreakdown";
import { MeasureAllocation } from "./components/MeasureAllocation";
@ -491,6 +492,7 @@ function MetricsBody({
label: "Gross cost /home",
value: `£${formatNumber(scenarioData.gross_per_unit ?? 0)}`,
sub: `${scenarioData.n_units_upgraded} homes`,
tip: COST_HELP.grossPerHome,
},
]
: [];

View file

@ -1,13 +1,6 @@
"use client";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import {
Info,
Wallet,
Coins,
Landmark,
@ -19,8 +12,9 @@ import {
Cloud,
type LucideIcon,
} from "lucide-react";
import { moneyFull } from "./primitives";
import { carsOffTheRoad, PROJECT_DELIVERY_RATE } from "@/lib/reporting/model";
import { InfoDot, moneyFull } from "./primitives";
import { COST_HELP } from "./costHelp";
import { carsOffTheRoad } from "@/lib/reporting/model";
export interface LedgerView {
constructionCost: number;
@ -37,8 +31,6 @@ export interface LedgerView {
costPerCarbon: number;
}
const deliveryPct = Math.round(PROJECT_DELIVERY_RATE * 100);
// The cost-composition palette echoes the data-quality evidence bar: quiet
// navy → gold → grey, so the reporting surfaces read as one system.
const COST_COLORS = {
@ -47,27 +39,6 @@ const COST_COLORS = {
contingency: "#c5cad8",
} as const;
function InfoDot({ label, tip }: { label: string; tip: string }) {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`About: ${label}`}
className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border border-gray-300 text-gray-600 hover:text-gray-900"
>
<Info className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-xs leading-relaxed">
{tip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function GroupLabel({
icon: Icon,
children,
@ -89,12 +60,14 @@ function IconRow({
label,
value,
note,
tip,
tone = "neutral",
}: {
icon: LucideIcon;
label: string;
value: string;
note?: string;
tip?: string;
tone?: "neutral" | "benefit";
}) {
const chip =
@ -111,6 +84,7 @@ function IconRow({
<Icon className="h-3.5 w-3.5" />
</span>
{label}
{tip && <InfoDot label={label} tip={tip} />}
</span>
<span
className={`whitespace-nowrap tabular-nums font-semibold ${valueColor}`}
@ -128,16 +102,19 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
label: "Construction works",
value: v.constructionCost,
color: COST_COLORS.construction,
help: COST_HELP.construction,
},
{
label: "Project delivery",
value: v.projectDelivery,
color: COST_COLORS.delivery,
help: COST_HELP.projectDelivery,
},
{
label: "Contingency",
value: v.contingency,
color: COST_COLORS.contingency,
help: COST_HELP.contingency,
},
];
const grossForBar = v.grossCost || 1;
@ -156,14 +133,12 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-1 text-[0.68rem] font-bold uppercase tracking-wide text-gray-500">
Net cost
<InfoDot
label="Net cost"
tip={`Gross cost = construction works + project delivery + contingency. Funding is deducted to give net. Excludes VAT and internal staff time. Project delivery is currently estimated at ${deliveryPct}% of construction.`}
/>
<InfoDot label="Net cost" tip={COST_HELP.net} />
</span>
<span className="text-[0.72rem] font-medium text-gray-500">
{moneyFull(v.grossPerHome)}{" "}
<span className="inline-flex items-center gap-1 text-[0.72rem] font-medium text-gray-500">
{moneyFull(v.grossPerHome)}
<span className="text-gray-400">gross/home</span>
<InfoDot label="Gross cost per home" tip={COST_HELP.grossPerHome} />
</span>
</div>
<div className="mt-1 text-[1.6rem] font-bold leading-none tabular-nums text-brandblue">
@ -200,6 +175,7 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
style={{ background: p.color }}
/>
{p.label}
<InfoDot label={p.label} tip={p.help} />
</span>
<span className="tabular-nums font-semibold text-brandblue">
{moneyFull(p.value)}
@ -225,6 +201,7 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
label="Bill savings"
value={moneyFull(v.billSavings)}
note={`${moneyFull(v.billSavingsPerHome)}/home`}
tip={COST_HELP.billSavings}
tone="benefit"
/>
<IconRow
@ -232,6 +209,7 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
label="Carbon saved"
value={`${Math.round(v.carbonSaved)} t`}
note={`${carsOffTheRoad(v.carbonSaved)} cars`}
tip={COST_HELP.carbonSaved}
tone="benefit"
/>
</div>
@ -243,11 +221,13 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
icon={Gauge}
label="Cost per SAP point"
value={moneyFull(v.costPerSap)}
tip={COST_HELP.costPerSap}
/>
<IconRow
icon={Cloud}
label="Cost per tCO₂/yr"
value={moneyFull(v.costPerCarbon)}
tip={COST_HELP.costPerCarbon}
/>
</div>
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { EpcChip, DeltaPill } from "./primitives";
import { EpcChip, DeltaPill, InfoDot } from "./primitives";
export interface Kpi {
label: string;
@ -8,6 +8,8 @@ export interface Kpi {
unit?: string;
sub?: React.ReactNode;
delta?: { text: string; improved: boolean };
/** Optional "what this includes/excludes" note, shown as an ⓘ by the label. */
tip?: string;
}
/**
@ -22,8 +24,9 @@ export function KpiBand({ kpis }: { kpis: Kpi[] }) {
key={k.label}
className={`px-5 py-4 ${i > 0 ? "border-l border-gray-100" : ""}`}
>
<div className="text-[0.68rem] font-bold uppercase tracking-wide text-gray-600">
<div className="flex items-center gap-1 text-[0.68rem] font-bold uppercase tracking-wide text-gray-600">
{k.label}
{k.tip && <InfoDot label={k.label} tip={k.tip} />}
</div>
<div className="mt-1.5 flex items-baseline gap-1.5 text-[1.6rem] font-semibold leading-none tracking-tight text-brandblue tabular-nums">
{k.value}

View file

@ -8,7 +8,8 @@ import {
type Measure,
type MeasureGroup,
} from "@/lib/reporting/measures";
import { moneyCompact, moneyFull } from "./primitives";
import { InfoDot, moneyCompact, moneyFull } from "./primitives";
import { COST_HELP } from "./costHelp";
import { loadScenarioMeasures } from "../actions";
import type { DrillTarget } from "./DrillDownShelf";
import type { TagFilter } from "@/lib/reporting/viewState";
@ -171,6 +172,14 @@ export function MeasureAllocation({
</span>
))}
</div>
<p className="mt-3 flex items-center gap-1 text-[0.72rem] text-gray-500">
Construction works only excludes project delivery and
contingency.
<InfoDot
label="Where the money goes"
tip={COST_HELP.whereMoneyGoes}
/>
</p>
</>
)}

View file

@ -0,0 +1,48 @@
import { PROJECT_DELIVERY_RATE } from "@/lib/reporting/model";
const deliveryPct = Math.round(PROJECT_DELIVERY_RATE * 100);
/**
* One canonical set of plain-language definitions for the scenario cost
* figures what each one includes and excludes so the KPI band, the
* Investment ledger and the "where the money goes" breakdown all explain them
* the same way. Wording follows the domain glossary (CONTEXT.md Reporting):
* Gross cost = construction works + project delivery + contingency; Net cost =
* gross secured funding.
*/
export const COST_HELP = {
grossPerHome:
"Gross cost ÷ the homes upgraded in this scenario. Gross is all-in — " +
"construction works + project delivery + contingency — before any funding. " +
"Excludes VAT and internal staff time.",
net:
"Gross cost (construction works + project delivery + contingency) minus " +
"secured funding — the figure a board commits to. Excludes VAT and internal " +
"staff time.",
construction:
"The principal contractor's price for the physical works — the measures " +
"installed. Excludes project delivery and contingency.",
projectDelivery:
"Professional and management costs of delivering the works (design, project " +
"management, overheads), separate from the construction works. Currently " +
`estimated at ${deliveryPct}% of construction works.`,
contingency:
"A risk allowance for unforeseen costs, on top of construction works and " +
"project delivery. Part of gross cost (and so of net).",
billSavings:
"Modelled reduction in residents' annual energy bills once the works are " +
"in — this scenario versus current stock.",
carbonSaved:
"Modelled annual carbon saved once the works are in — this scenario versus " +
"current stock.",
costPerSap:
"Capital cost (construction works + project delivery) ÷ total SAP points " +
"gained. Excludes contingency and funding. Lower is better.",
costPerCarbon:
"Capital cost (construction works + project delivery) ÷ tonnes of CO₂ saved " +
"a year. Excludes contingency and funding. Lower is better.",
whereMoneyGoes:
"The construction works only — the cost of the measures installed. Excludes " +
'project delivery and contingency, so this totals the "Construction works" ' +
"line in the ledger, not gross cost.",
} as const;

View file

@ -6,8 +6,41 @@
* green/red; everything else is quiet navy-on-white.
*/
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import { Info } from "lucide-react";
import { getEpcColorClass } from "@/app/utils";
/**
* A small "ⓘ" affordance that reveals an explanation on hover/focus. The one
* way the reporting surfaces disclose what a figure includes or excludes, so
* the KPI band, ledger and spend breakdown all read the same.
*/
export function InfoDot({ label, tip }: { label: string; tip: string }) {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`About: ${label}`}
className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border border-gray-300 text-gray-600 hover:text-gray-900"
>
<Info className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[280px] text-xs leading-relaxed">
{tip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
const EPC_TEXT_ON: Record<string, string> = {
A: "text-white",
B: "text-white",