mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
a1bfde40f8
commit
5e7b7aa071
6 changed files with 116 additions and 41 deletions
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue