mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-08-02 21:08:21 +00:00
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:
parent
25bec3de7d
commit
202683cd3f
5 changed files with 79 additions and 17 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}.`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue