fix(reporting): address retrofit report PDF feedback

Six fixes from feedback on the report PDF (portfolio 849):

- Pluralise the "cars off the road" analogy ("1 car", not "1 cars").
- Keep the analogy in the headline only — drop it from the ledger's
  Carbon-saved row and the "Homes below EPC C" tile, where a carbon
  proxy sat on a homes-count figure.
- Derive the SAP delta from the rounded endpoints shown, so 67 -> 71
  reads +4 not +3 (fixed on the PDF and the on-screen KPI band).
- Format carbon to one decimal everywhere via a shared formatTonnes
  helper, so one saved figure stops rendering as 2 t / 2.2 t / 2 t
  across headline, card and ledger.
- Label the EPC-distribution count column "Homes".
- Print a one-line methodology for the value-for-money ratios (the
  on-screen InfoDot buttons are hidden in print).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-24 13:43:45 +00:00
parent 25bec3de7d
commit 202683cd3f
5 changed files with 79 additions and 17 deletions

View file

@ -438,9 +438,12 @@ function MetricsBody({
),
},
(() => {
// Round both endpoints first so the delta pill agrees with the
// rounded SAP figures shown here and in the sub (a 67 → 71 tile must
// read +4, not +3 off the raw averages).
const d = shapeKpiDelta({
current: avg.avg_sap ?? 0,
after: Number(scenarioData.avg_sap),
current: Math.round(avg.avg_sap ?? 0),
after: Math.round(Number(scenarioData.avg_sap)),
improvesWhenLower: false,
});
return {

View file

@ -14,7 +14,7 @@ import {
} from "lucide-react";
import { InfoDot, moneyFull } from "./primitives";
import { COST_HELP } from "./costHelp";
import { carsOffTheRoad, type LedgerView } from "@/lib/reporting/model";
import { formatTonnes, type LedgerView } from "@/lib/reporting/model";
// LedgerView now lives in the reporting model (derived by deriveLedgerView);
// re-exported here so existing imports from this component keep resolving.
@ -196,8 +196,7 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
<IconRow
icon={Leaf}
label="Carbon saved"
value={`${Math.round(v.carbonSaved)} t`}
note={`${carsOffTheRoad(v.carbonSaved)} cars`}
value={`${formatTonnes(v.carbonSaved)} t`}
tip={COST_HELP.carbonSaved}
tone="benefit"
/>
@ -218,6 +217,15 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
value={moneyFull(v.costPerCarbon)}
tip={COST_HELP.costPerCarbon}
/>
{/* Print-only: on screen the InfoDots carry this, but buttons are hidden
in print, so the printed report would otherwise show the ratios with
no basis. States what they're divided by and that it's portfolio-wide,
not per home. */}
<p className="mt-2 hidden text-[0.66rem] leading-snug text-gray-500 print:block">
Capital cost (construction works + project delivery) ÷ the total SAP
points, and tonnes of CO saved a year, across all upgraded homes a
portfolio total, not per home. Lower is better.
</p>
</div>
</div>
);

View file

@ -8,7 +8,7 @@ import {
import { measureLabel } from "@/lib/reporting/measures";
import {
buildHeadline,
carsOffTheRoad,
formatTonnes,
countBelowBand,
shapeKpiDelta,
toBandCounts,
@ -175,6 +175,14 @@ function EpcDistribution({
);
return (
<div className="flex flex-col gap-2">
{/* Label the right-hand column so a skimmer reads "9 → 15" as homes. */}
<div className="grid grid-cols-[24px_1fr_96px] items-center gap-x-3">
<span />
<span />
<span className="text-right text-[0.58rem] font-semibold uppercase tracking-wide text-gray-400">
Homes
</span>
</div>
{bands.map((band) => {
const before = bandCounts[band] ?? 0;
const after = scenarioBands?.[band];
@ -437,7 +445,7 @@ export default async function ReportingPdfPage(props: {
totalCarbon: baseline.totals.total_carbon,
totalBills: baseline.totals.total_bills,
});
// Bound for the headline + the "cars off the road" / CO₂-saved tiles below.
// Bound for the headline + the CO₂-saved tile below.
const carbonSaved = ledger.carbonSaved;
const headline = buildHeadline({
@ -453,9 +461,11 @@ export default async function ReportingPdfPage(props: {
const belowCBefore = countBelowBand(bandCounts, "C");
const belowCAfter = countBelowBand(scenarioBands, "C");
// Derive the delta from the same rounded endpoints the tile displays, so the
// pill agrees with them (67 → 71 must read +4, not +3 off the raw 70.6 67.4).
const sapDelta = shapeKpiDelta({
current: avg.avg_sap ?? 0,
after: Number(scenarioData.avg_sap),
current: Math.round(avg.avg_sap ?? 0),
after: Math.round(Number(scenarioData.avg_sap)),
improvesWhenLower: false,
});
@ -501,7 +511,7 @@ export default async function ReportingPdfPage(props: {
<b>{belowCAfter.toLocaleString()}</b>
</span>
}
sub={`${carsOffTheRoad(carbonSaved)} cars off the road`}
sub={`${Math.max(belowCBefore - belowCAfter, 0).toLocaleString()} moved to C or above`}
/>
<Tile
label="Bills saved / yr"
@ -510,7 +520,7 @@ export default async function ReportingPdfPage(props: {
/>
<Tile
label="Carbon saved / yr"
value={`${formatNumber(carbonSaved)} t`}
value={`${formatTonnes(carbonSaved)} t`}
sub="vs current stock"
/>
<Tile

View file

@ -9,6 +9,7 @@ import {
costPerCarbonSaved,
costPerSapPoint,
deriveLedgerView,
formatTonnes,
isCompliantBeyondWindow,
pickBestIndex,
selectGoalCallout,
@ -449,4 +450,26 @@ describe("buildHeadline", () => {
}),
).toBe("Improving valuation across 187 homes costs £850k net.");
});
it("uses singular 'car' and one-decimal tonnes for a small saving", () => {
expect(
buildHeadline({
goal: "Increasing EPC",
goalValue: "C",
homesUpgraded: 7,
netCost: 42_916,
carbonSavedPerYear: 2.2,
}),
).toBe(
"Reaching EPC C across 7 homes costs £43k net and cuts carbon by 2.2 tonnes a year — like taking 1 car off the road.",
);
});
});
describe("formatTonnes", () => {
it("keeps one decimal but strips a trailing .0", () => {
expect(formatTonnes(2.2)).toBe("2.2");
expect(formatTonnes(2)).toBe("2");
expect(formatTonnes(312)).toBe("312");
});
});

View file

@ -365,6 +365,15 @@ export function carsOffTheRoad(tonnesPerYear: number): number {
return Math.round(tonnesPerYear / TONNES_CO2_PER_CAR_PER_YEAR);
}
/**
* Carbon tonnes for display one decimal, trailing ".0" stripped
* (2.2 "2.2", 2 "2"). The single carbon formatter shared by the headline,
* the KPI card and the ledger, so one saved figure never renders three ways.
*/
export function formatTonnes(tonnes: number): string {
return tonnes.toFixed(1).replace(/\.0$/, "");
}
export interface KpiDelta {
delta: number;
improved: boolean;
@ -418,20 +427,29 @@ export interface HeadlineInput {
*/
export function buildHeadline(input: HeadlineInput): string {
const money = formatMoneyCompact(input.netCost);
const tonnes = Math.round(input.carbonSavedPerYear);
const cars = carsOffTheRoad(input.carbonSavedPerYear);
const carClause = ` — like taking ${cars} cars off the road.`;
const tonnesNum = input.carbonSavedPerYear;
const hasCarbon = Math.round(tonnesNum) > 0;
const tonnes = formatTonnes(tonnesNum);
const cars = carsOffTheRoad(tonnesNum);
// The "cars off the road" analogy lives here in the headline only (the one
// board-quotable line) — not repeated on the tiles or ledger. Dropped when it
// rounds below one car, where "0 cars" would read as noise. Singular "car"
// for exactly one.
const carClause =
cars > 0
? ` — like taking ${cars} car${cars === 1 ? "" : "s"} off the road.`
: "";
if (input.goal === GOALS.EPC) {
const base = `Reaching EPC ${input.goalValue ?? DEFAULT_GOAL_BAND} across ${input.homesUpgraded} homes costs ${money} net`;
return tonnes > 0
return hasCarbon
? `${base} and cuts carbon by ${tonnes} tonnes a year${carClause}`
: `${base}.`;
}
if (input.goal === GOALS.CO2) {
const base = `Cutting carbon by ${tonnes} tonnes a year across ${input.homesUpgraded} homes costs ${money} net`;
return tonnes > 0 ? `${base}${carClause}` : `${base}.`;
return hasCarbon ? `${base}${carClause}` : `${base}.`;
}
const lead =
@ -439,7 +457,7 @@ export function buildHeadline(input: HeadlineInput): string {
? "Cutting energy use"
: "Improving valuation";
const base = `${lead} across ${input.homesUpgraded} homes costs ${money} net`;
return tonnes > 0
return hasCarbon
? `${base} and cuts carbon by ${tonnes} tonnes a year${carClause}`
: `${base}.`;
}