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}.`;
}