From 202683cd3fa5213dc46e15174a95e4bbe06ecbb1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 24 Jul 2026 13:43:45 +0000 Subject: [PATCH] fix(reporting): address retrofit report PDF feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../reporting/ReportingClientArea.tsx | 7 +++-- .../reporting/components/InvestmentLedger.tsx | 14 +++++++-- .../[slug]/(portfolio)/reporting/pdf/page.tsx | 22 ++++++++++---- src/lib/reporting/model.test.ts | 23 ++++++++++++++ src/lib/reporting/model.ts | 30 +++++++++++++++---- 5 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index baf74d3d..db6b7c72 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -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 { diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/InvestmentLedger.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/InvestmentLedger.tsx index ae43d86e..fd5601ae 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/components/InvestmentLedger.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/InvestmentLedger.tsx @@ -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 }) { @@ -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. */} +

+ 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. +

); diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx index 2dd89432..66fa5f1e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx @@ -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 (
+ {/* Label the right-hand column so a skimmer reads "9 → 15" as homes. */} +
+ + + + Homes + +
{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: { {belowCAfter.toLocaleString()} } - sub={`≈ ${carsOffTheRoad(carbonSaved)} cars off the road`} + sub={`${Math.max(belowCBefore - belowCAfter, 0).toLocaleString()} moved to C or above`} /> { }), ).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"); + }); }); diff --git a/src/lib/reporting/model.ts b/src/lib/reporting/model.ts index f688ef6e..5d574381 100644 --- a/src/lib/reporting/model.ts +++ b/src/lib/reporting/model.ts @@ -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}.`; }