From 202683cd3fa5213dc46e15174a95e4bbe06ecbb1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 24 Jul 2026 13:43:45 +0000 Subject: [PATCH 01/13] 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}.`; } From ce417ef16b98ce3b0b9f83798578a48faea05002 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 27 Jul 2026 10:58:09 +0000 Subject: [PATCH 02/13] fix(reporting): make EPC-evidence bar sum to the portfolio total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Data-quality "EPC evidence" bar renders each segment as value/total, so homes with no epc_property rows yet (the ticketed backend gap) became empty space and the bar silently under-summed — breaking its "every home counted once" claim (e.g. 3 + 0 + 4 of 74). Add a client-only "No EPC record" segment = total - (in date + expired + estimated), hatched to read as an absence of evidence (vs Estimated's solid grey). Rendered only when non-zero, so it self-empties once the backend populates those rows. No SQL or backend change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data-quality/DataQualityClientArea.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/DataQualityClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/DataQualityClientArea.tsx index f3a6f1e4..3e797f4d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/DataQualityClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/data-quality/DataQualityClientArea.tsx @@ -5,6 +5,11 @@ import { Clock } from "lucide-react"; import type { DataQualityMetrics } from "@/lib/reporting/types"; import { DrillDownShelf, type DrillTarget } from "../components/DrillDownShelf"; +// Diagonal hatch marks the "No EPC record" segment as an absence of evidence, +// distinct from Estimated's solid grey (which is a real, if modelled, picture). +const NO_RECORD_FILL = + "repeating-linear-gradient(45deg, #e6e8ee, #e6e8ee 5px, #f6f7f9 5px, #f6f7f9 10px)"; + const UNLOCK_MODULES = [ { name: "Condition & compliance", @@ -50,10 +55,25 @@ export function DataQualityClientArea({ setPage(1); } + // Homes with no EPC evidence at all (no epc_property rows yet — the ticketed + // backend gap). Without this, the three evidence buckets under-sum the total + // and the bar silently drops them, breaking the "every home counted once" + // claim. Shown as its own segment so the bar always sums to `total`; it + // self-empties to nothing once the backend populates those rows. + const noRecord = Math.max(total - inDate - expired - estimated, 0); const composition = [ { label: "In date · " + inDate, value: inDate, color: "#14163d" }, { label: "Expired · " + expired, value: expired, color: "#a07c42" }, { label: "Estimated · " + estimated, value: estimated, color: "#c5cad8" }, + ...(noRecord > 0 + ? [ + { + label: "No EPC record · " + noRecord, + value: noRecord, + color: NO_RECORD_FILL, + }, + ] + : []), ]; const issues = [ From dcb301a9318cabd42a2e2a64cd5435df60123389 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 27 Jul 2026 11:28:00 +0000 Subject: [PATCH 03/13] fix(reporting): fair "Best" tagging on the scenario compare screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from feedback on Compare scenarios: - Ties no longer default to the leftmost column. pickBestIndex becomes pickBestIndices, returning every column that holds the joint-best value, so tied columns are all marked Best. Average EPC is compared on the rounded SAP shown, so a visible "71 = 71" reads as a real tie. - CO2 saved /yr renders to one decimal (shared formatTonnes), so two scenarios that both rounded to "2 t" but differ — the reason one is Best — are visibly distinct. - Funding secured no longer crowns £0 as Best (new hideBestAtZero flag); a zero isn't an achievement. Fields where 0 is a genuine best (Homes below C after) are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reporting/compare/CompareClientArea.tsx | 36 +++++++++++++++---- src/lib/reporting/model.test.ts | 22 ++++++++---- src/lib/reporting/model.ts | 30 +++++++++------- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx index 7702f9f1..9156c26e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx @@ -10,12 +10,13 @@ import { } from "@/app/shadcn_components/ui/popover"; import { sapToEpc } from "@/app/utils"; import { - pickBestIndex, + pickBestIndices, countBelowBand, amountSaved, capitalOutlay, costPerSapPoint, costPerCarbonSaved, + formatTonnes, } from "@/lib/reporting/model"; import { EpcChip, moneyFull } from "../components/primitives"; @@ -41,6 +42,13 @@ interface RowSpec { direction: "lower" | "higher" | null; value: (m: ScenarioMetrics, b: Baseline) => number | null; render: (v: number | null) => React.ReactNode; + /** + * Suppress the "Best" tag when the winning value is 0 — for fields where a + * zero isn't an achievement (e.g. Funding secured: £0 means none was secured, + * so crowning it "Best" is meaningless). Fields where 0 *is* a genuine best + * (Homes below C after) leave this off. + */ + hideBestAtZero?: boolean; } interface ScenarioMetrics { @@ -61,9 +69,12 @@ interface ScenarioMetrics { const ROWS: RowSpec[] = [ { group: "Outcome", + // Compare on the rounded SAP the cell shows, so two columns that both + // display e.g. 71 register as a genuine tie (both marked Best) rather than + // the tag hanging on a sub-point difference the reader can't see. label: "Average EPC after", direction: "higher", - value: (m) => Number(m.avg_sap), + value: (m) => Math.round(Number(m.avg_sap)), render: (v) => v === null ? "—" : ( @@ -87,10 +98,12 @@ const ROWS: RowSpec[] = [ }, { group: "Outcome", + // One decimal (shared formatTonnes) so two scenarios that both round to + // "2 t" but differ — the reason one is Best — are visibly distinct. label: "CO₂ saved /yr", direction: "higher", value: (m, b) => amountSaved(b.totalCarbon, m.total_carbon), - render: (v) => (v === null ? "—" : `${Math.round(v)} t`), + render: (v) => (v === null ? "—" : `${formatTonnes(v)} t`), }, { group: "Outcome", @@ -112,6 +125,7 @@ const ROWS: RowSpec[] = [ direction: "higher", value: (m) => m.total_funding, render: (v) => (v === null ? "—" : moneyFull(v)), + hideBestAtZero: true, }, { group: "Investment", @@ -351,8 +365,16 @@ function GroupRows({ ); const best = row.direction === null - ? null - : pickBestIndex(scenarioValues, row.direction); + ? new Set() + : pickBestIndices(scenarioValues, row.direction); + // A zero winner isn't an achievement on some fields (e.g. no funding + // secured) — drop the tag there rather than crowning £0. + if (row.hideBestAtZero) { + const anyWinner = best.values().next().value; + if (anyWinner !== undefined && (scenarioValues[anyWinner] ?? 0) === 0) { + best.clear(); + } + } // "before" cell: baseline has no scenario metric, so most rows show —. const baselineCell = row.label === "Average EPC after" @@ -370,10 +392,10 @@ function GroupRows({ - {best === i && ( + {best.has(i) && ( Best diff --git a/src/lib/reporting/model.test.ts b/src/lib/reporting/model.test.ts index 01ef6c4e..692a0e0a 100644 --- a/src/lib/reporting/model.test.ts +++ b/src/lib/reporting/model.test.ts @@ -11,7 +11,7 @@ import { deriveLedgerView, formatTonnes, isCompliantBeyondWindow, - pickBestIndex, + pickBestIndices, selectGoalCallout, shapeKpiDelta, toBandCounts, @@ -281,24 +281,32 @@ describe("classifyBandMovement", () => { }); }); -describe("pickBestIndex", () => { +describe("pickBestIndices", () => { // Compare view (Screen D): mark the best value in each row without // crowning an overall winner. Nulls (baseline / missing) never win. it("picks the lowest for a lower-is-better row", () => { - expect(pickBestIndex([null, 50, 74, 103], "lower")).toBe(1); + expect(pickBestIndices([null, 50, 74, 103], "lower")).toEqual(new Set([1])); }); it("picks the highest for a higher-is-better row", () => { - expect(pickBestIndex([null, 312, 368, 219], "higher")).toBe(2); + expect(pickBestIndices([null, 312, 368, 219], "higher")).toEqual( + new Set([2]), + ); }); - it("returns null when every comparable value is missing", () => { - expect(pickBestIndex([null, null], "lower")).toBeNull(); + it("marks every column that ties on the best value", () => { + expect(pickBestIndices([null, 71, 71, 68], "higher")).toEqual( + new Set([1, 2]), + ); + }); + + it("returns an empty set when every comparable value is missing", () => { + expect(pickBestIndices([null, null], "lower")).toEqual(new Set()); }); it("ignores nulls rather than treating them as zero", () => { - expect(pickBestIndex([null, null, 5], "lower")).toBe(2); + expect(pickBestIndices([null, null, 5], "lower")).toEqual(new Set([2])); }); }); diff --git a/src/lib/reporting/model.ts b/src/lib/reporting/model.ts index 5d574381..5eb956d8 100644 --- a/src/lib/reporting/model.ts +++ b/src/lib/reporting/model.ts @@ -269,24 +269,30 @@ const GOAL_DIMENSIONS: Partial> = { }; /** - * Index of the best value in a compare row (Screen D). Nulls — baseline - * columns and missing data — never win. Returns null when nothing is - * comparable, so no cell is marked. + * Indices of the best value in a compare row (Screen D) — *every* column that + * holds the joint-best value, so a tie marks all of them rather than silently + * crowning the leftmost. Nulls — baseline columns and missing data — never win. + * Returns an empty set when nothing is comparable, so no cell is marked. + * + * Ties are decided by exact equality: a caller that rounds for display (e.g. + * Average EPC → whole SAP) should round the compared value too, so a tie the + * reader can see is a tie the tag agrees with. */ -export function pickBestIndex( +export function pickBestIndices( values: (number | null)[], direction: "lower" | "higher", -): number | null { - let bestIndex: number | null = null; +): Set { let best = direction === "lower" ? Infinity : -Infinity; + for (const v of values) { + if (v === null) continue; + if (direction === "lower" ? v < best : v > best) best = v; + } + const winners = new Set(); + if (!Number.isFinite(best)) return winners; // every comparable value was null values.forEach((v, i) => { - if (v === null) return; - if (direction === "lower" ? v < best : v > best) { - best = v; - bestIndex = i; - } + if (v === best) winners.add(i); }); - return bestIndex; + return winners; } /** Collapses baseline band rows (actual + estimated) into a plain band→count map. */ From 194075c5b8a48bda1aa4d059deff3517eebf0d6f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 27 Jul 2026 11:43:00 +0000 Subject: [PATCH 04/13] fix(reporting): mark genuine ties on the compare "Best" tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row with more than one joint-best column is a real tie (e.g. two scenarios upgrading the same 7 homes — an integer count, no rounding). Show "Best · tie" on those tags so the shared Best reads as intentional, and note in the caption that every column tying for best is tagged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reporting/compare/CompareClientArea.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx index 9156c26e..6f102cc1 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/CompareClientArea.tsx @@ -330,8 +330,8 @@ export function CompareClientArea({

The Best tag marks - the best value in each row — no overall winner is crowned; trade-offs - stay visible. + the best value in each row — or every column that ties for it. No overall + winner is crowned; trade-offs stay visible.

); @@ -375,6 +375,10 @@ function GroupRows({ best.clear(); } } + // More than one joint-best column = a genuine tie (e.g. two scenarios + // upgrading the same 7 homes). Flag it on each tag so the reader knows + // the shared Best is intentional, not a bug. + const tie = best.size > 1; // "before" cell: baseline has no scenario metric, so most rows show —. const baselineCell = row.label === "Average EPC after" @@ -397,7 +401,7 @@ function GroupRows({ > {best.has(i) && ( - Best + {tie ? "Best · tie" : "Best"} )} {row.render(v)} From 4f6271641f91f062bf9760c9c5d2bbf1f6825917 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 27 Jul 2026 12:45:25 +0000 Subject: [PATCH 05/13] fix(reporting): reconcile measure breakdown to the ledger construction cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "where the money goes" breakdown (and its CSV export) summed recommendation.estimated_cost over every latest plan, while the Investment ledger's Construction works line sums plan.cost_of_works over only genuine upgrades. So the breakdown over-stated spend by counting plans the ledger excludes (verified on portfolio 850/scenario 1329: £59,366 vs £56,538 — 3 homes whose post-SAP sits below the effective baseline; plan.cost_of_works ties to the recommendation sum exactly, so there is no data residual). Gate queryScenarioMeasures to the same upgrade set as the ledger (cost_of_works > 0, post_sap >= effective baseline, stillNeedsUpgrade). Extract stillNeedsUpgradeSql so the ledger and breakdown share one definition, and add planCountsAsUpgrade as the pure twin with tests (including the post-SAP-below-baseline case). Verified: the breakdown now totals £56,538, matching the ledger. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/reporting/model.test.ts | 56 +++++++++++++++++++++++++++++++++ src/lib/reporting/model.ts | 47 +++++++++++++++++++++++++++ src/lib/reporting/overlay.ts | 17 +++------- src/lib/reporting/server.ts | 55 +++++++++++++++++++++++++++----- src/lib/services/epcSources.ts | 20 ++++++++++++ 5 files changed, 174 insertions(+), 21 deletions(-) diff --git a/src/lib/reporting/model.test.ts b/src/lib/reporting/model.test.ts index 692a0e0a..e754cb9b 100644 --- a/src/lib/reporting/model.test.ts +++ b/src/lib/reporting/model.test.ts @@ -12,6 +12,7 @@ import { formatTonnes, isCompliantBeyondWindow, pickBestIndices, + planCountsAsUpgrade, selectGoalCallout, shapeKpiDelta, toBandCounts, @@ -474,6 +475,61 @@ describe("buildHeadline", () => { }); }); +describe("planCountsAsUpgrade", () => { + // The pure twin of the ledger's plan gating — the measures breakdown must + // apply this identically so its total reconciles to the Construction works + // line (CONTEXT.md). Guards against the reporting divergence where the + // breakdown counted plans the ledger excludes. + const genuine = { + costOfWorks: 5000, + postSap: 72, + effectiveSap: 67, + effectiveBand: "D", + targetBand: "C", + }; + + it("counts a costed plan that lifts the home above baseline toward target", () => { + expect(planCountsAsUpgrade(genuine)).toBe(true); + }); + + it("excludes a plan with no costed works", () => { + expect(planCountsAsUpgrade({ ...genuine, costOfWorks: 0 })).toBe(false); + }); + + it("excludes a plan whose post-SAP sits below the effective baseline", () => { + // The Brockley-Flats case: 3 homes with costed plans that don't actually + // improve the home — the £2,828 the breakdown over-counted vs the ledger. + expect( + planCountsAsUpgrade({ ...genuine, postSap: 60, effectiveSap: 67 }), + ).toBe(false); + }); + + it("excludes a home already at the target band (Model#1652 guard)", () => { + expect( + planCountsAsUpgrade({ + ...genuine, + postSap: 75, + effectiveSap: 72, + effectiveBand: "C", + targetBand: "C", + }), + ).toBe(false); + }); + + it("keeps a plan when the baseline SAP is unknown (NULL comparison)", () => { + expect( + planCountsAsUpgrade({ ...genuine, effectiveSap: null, effectiveBand: null }), + ).toBe(true); + }); + + it("applies only the cost + post-SAP gates when there is no target band", () => { + expect(planCountsAsUpgrade({ ...genuine, targetBand: null })).toBe(true); + expect( + planCountsAsUpgrade({ ...genuine, targetBand: null, postSap: 60 }), + ).toBe(false); + }); +}); + describe("formatTonnes", () => { it("keeps one decimal but strips a trailing .0", () => { expect(formatTonnes(2.2)).toBe("2.2"); diff --git a/src/lib/reporting/model.ts b/src/lib/reporting/model.ts index 5eb956d8..c1008c79 100644 --- a/src/lib/reporting/model.ts +++ b/src/lib/reporting/model.ts @@ -168,6 +168,53 @@ export function deriveLedgerView( }; } +/* ------------------------------------------------------------------ + Plan gating — what counts toward a scenario's construction cost +------------------------------------------------------------------ */ + +export interface UpgradePlan { + /** The plan's costed works (£). */ + costOfWorks: number; + /** Modelled post-retrofit SAP, or null when unknown. */ + postSap: number | null; + /** The home's effective (re-baselined) SAP, or null when unknown. */ + effectiveSap: number | null; + /** The home's effective EPC band, or null when unknown. */ + effectiveBand: string | null; + /** The scenario's target band, or null for non-EPC goals / recommended view. */ + targetBand: string | null; +} + +/** + * Whether a plan's works count toward the scenario's construction cost — the + * pure twin of the Investment ledger's plan gating (`upgradedCostsSql` in + * overlay.ts, and `stillNeedsUpgradeSql` in epcSources). Both the ledger and + * the "where the money goes" measures breakdown must apply this identically, so + * the breakdown total reconciles to the Construction works line (CONTEXT.md). + * + * A plan counts iff it has costed works AND its modelled post-SAP is at least + * the home's effective baseline (a plan that doesn't lift the home above + * baseline isn't a real upgrade — ADR-0002) AND the home hasn't already met the + * scenario's target band (INTERIM guard for Model#1652). NULL comparisons keep + * the plan (mirrors the SQL's COALESCE(..., true)); bands compare lexically, + * A best. + */ +export function planCountsAsUpgrade(plan: UpgradePlan): boolean { + if (!(plan.costOfWorks > 0)) return false; + + const meetsBaseline = + plan.postSap === null || plan.effectiveSap === null + ? true + : plan.postSap >= plan.effectiveSap; + if (!meetsBaseline) return false; + + return ( + plan.targetBand === null || + plan.effectiveBand === null || + plan.effectiveBand > plan.targetBand + ); +} + /* ------------------------------------------------------------------ Compliance window — ADR-0010 ------------------------------------------------------------------ */ diff --git a/src/lib/reporting/overlay.ts b/src/lib/reporting/overlay.ts index a9605e3b..434bea00 100644 --- a/src/lib/reporting/overlay.ts +++ b/src/lib/reporting/overlay.ts @@ -7,7 +7,7 @@ import { carbonSql, billsSql, effectiveSapSql, - effectiveEpcBandSql, + stillNeedsUpgradeSql, isNewApproachSql, lodgedSapSql, } from "@/lib/services/epcSources"; @@ -202,18 +202,9 @@ export async function getScenarioOverlay( ? sql`AND plan.post_sap_points >= ${minSap}::float` : sql``; - /** - * INTERIM guard for a backend bug (Hestia-Homes/Model#1652): the engine emits - * costed plans for homes ALREADY at the target band, inflating "Homes - * upgraded" and its costs. Exclude any home whose EFFECTIVE band already meets - * the target from the upgrade aggregate. Bands compare lexically (A best), so - * "still needs work" = effective band > target. NULL target ⇒ no-op. - */ - const stillNeedsUpgrade: SQL = sql`( - ${targetBand}::text IS NULL - OR (${effectiveEpcBandSql}) IS NULL - OR (${effectiveEpcBandSql})::text > ${targetBand}::text - )`; + // The Model#1652 upgrade guard (definition + rationale in epcSources) — shared + // with the measures breakdown (queryScenarioMeasures) so the two reconcile. + const stillNeedsUpgrade: SQL = stillNeedsUpgradeSql(targetBand); /* -------------------------------------------------------- QUERY 1 — Scenario metrics (PLANS ONLY): the works-list count and the diff --git a/src/lib/reporting/server.ts b/src/lib/reporting/server.ts index 07069e3e..2c036df1 100644 --- a/src/lib/reporting/server.ts +++ b/src/lib/reporting/server.ts @@ -10,6 +10,7 @@ import { isExpiredSql, effectiveSapSql, effectiveEpcBandSql, + stillNeedsUpgradeSql, likelyDowngradeSql, likelyUpgradeSql, propertyTypeSql, @@ -384,6 +385,24 @@ export async function getDataQualityMetrics( * measure_type only, since the UI buckets by category and never reads the * `type` variant. */ +/** + * The scenario's target band for the Model#1652 upgrade guard — the goal value + * only for an EPC-target scenario (other goals / the recommended view have no + * target). Mirrors the overlay's targetBand derivation. + */ +async function resolveScenarioTargetBand( + portfolioId: bigint, + scenarioId: bigint, +): Promise { + const res = await db.execute<{ goal: string; goal_value: string | null }>(sql` + SELECT goal, goal_value FROM scenario + WHERE id = ${scenarioId} AND portfolio_id = ${portfolioId} + LIMIT 1 + `); + const row = res.rows[0]; + return row && row.goal === "Increasing EPC" ? row.goal_value : null; +} + export async function queryScenarioMeasures( portfolioId: number, scenarioId: number | "default", @@ -398,26 +417,46 @@ export async function queryScenarioMeasures( ? sql`is_default = true` : sql`scenario_id = ${BigInt(scenarioId)}`; + // Gate to the same "genuine upgrade" plans the Investment ledger's + // construction cost counts (overlay.ts upgradedCostsSql), so the breakdown + // total reconciles to the Construction works line rather than over-stating it + // with plans that don't lift the home above baseline or are already at target. + // Pure twin of the gate: planCountsAsUpgrade (@/lib/reporting/model). + const targetBand = + scenarioId === "default" + ? null + : await resolveScenarioTargetBand(pid, BigInt(scenarioId)); + const result = await db.execute<{ measure_type: string | null; homes_count: number; total_cost: number | null; average_cost: number | null; }>(sql` - SELECT - r.measure_type, - COUNT(DISTINCT r.property_id)::int AS homes_count, - SUM(r.estimated_cost)::float AS total_cost, - AVG(r.estimated_cost)::float AS average_cost - FROM ( + WITH latest_plans AS ( SELECT DISTINCT ON (property_id) - id, property_id + id, property_id, cost_of_works, post_sap_points FROM plan WHERE portfolio_id = ${pid} AND ${planScope} AND ${tagFilterCondition(tags, sql`plan.property_id`)} ORDER BY property_id, created_at DESC - ) lp + ), + upgrade_plans AS ( + SELECT lp.id, lp.property_id + FROM latest_plans lp + JOIN property p ON p.id = lp.property_id + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id + WHERE lp.cost_of_works > 0 + AND COALESCE(lp.post_sap_points >= (${effectiveSapSql}), true) + AND ${stillNeedsUpgradeSql(targetBand)} + ) + SELECT + r.measure_type, + COUNT(DISTINCT r.property_id)::int AS homes_count, + SUM(r.estimated_cost)::float AS total_cost, + AVG(r.estimated_cost)::float AS average_cost + FROM upgrade_plans lp JOIN recommendation r ON r.plan_id = lp.id AND r.default = true diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index 3bf662b0..02622087 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -136,6 +136,26 @@ export const epcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_epc_b export const effectiveSapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_sap_score ELSE p.current_sap_points END`; export const effectiveEpcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_epc_band ELSE p.current_epc_rating END`; +/** + * "Still needs upgrading" — the home hasn't already met the scenario's target + * band. INTERIM guard for backend bug Hestia-Homes/Model#1652 (the engine emits + * costed plans for homes ALREADY at target, inflating "Homes upgraded" and its + * costs). Bands compare lexically (A best), so "still needs work" = effective + * band worse than target; a NULL target (non-EPC goals / the recommended view) + * makes it inert. Requires `p` + `bp` in scope (newApproachJoins or a + * property_baseline_performance join). + * + * Shared by the Investment ledger (overlay.ts) and the "where the money goes" + * measures breakdown (server.ts) so the two reconcile. Pure twin: + * planCountsAsUpgrade (@/lib/reporting/model) folds this with the cost + + * post-SAP gates — keep them in sync. + */ +export const stillNeedsUpgradeSql = (targetBand: string | null) => sql`( + ${targetBand}::text IS NULL + OR (${effectiveEpcBandSql}) IS NULL + OR (${effectiveEpcBandSql})::text > ${targetBand}::text +)`; + /** * Lodged EPC band for the "Lodged EPC" column — only when a real certificate * exists (`source = lodged`, i.e. the lodged epc_property row `epl`). A From fe91f002a7aa0a8780ea7aae78088813d12669bc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 27 Jul 2026 13:27:18 +0000 Subject: [PATCH 06/13] feat(reporting): show lodged EPC distribution on dashboard and PDF Client request: alongside the modelled effective EPC distribution, let users see the distribution of lodged (register) ratings. - getBaselineAggregates also buckets the lodged band (real certificates only, via lodgedEpcBandSql), counting homes with no certificate separately as lodgedNoCertificate. Rides the existing baseline scan. - Dashboard: an Effective/Lodged toggle on the EPC-distribution panel. Lodged shows the register bands with a "N of M have a lodged certificate" note and no scenario overlay/drill. - PDF: a second page comparing Lodged (register) vs Effective (modelled) side by side, with the no-certificate count called out. EpcDistribution gains a caption prop so the lodged column isn't mislabelled effective. Given the ticketed backend lodged-ingestion gap, "No certificate" is large for now (e.g. 71/74 on portfolio 850); it fills in as real source='lodged' rows land. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reporting/ReportingClientArea.tsx | 128 ++++++++++++++---- .../[slug]/(portfolio)/reporting/pdf/page.tsx | 89 +++++++++++- src/lib/reporting/server.ts | 39 +++++- src/lib/reporting/types.ts | 8 ++ 4 files changed, 231 insertions(+), 33 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index db6b7c72..b363801b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -313,6 +313,41 @@ function HomesCount({ ); } +/** Segmented control switching the EPC distribution between the modelled + * Effective bands and the register's Lodged bands. */ +function EpcViewToggle({ + value, + onChange, +}: { + value: "effective" | "lodged"; + onChange: (v: "effective" | "lodged") => void; +}) { + return ( +
+ {(["effective", "lodged"] as const).map((v) => ( + + ))} +
+ ); +} + // ── Metrics body ───────────────────────────────────────────────────────────── function MetricsBody({ @@ -341,6 +376,10 @@ function MetricsBody({ const [drill, setDrill] = useState(null); const [drillPage, setDrillPage] = useState(1); + // EPC-distribution basis: the modelled Effective bands (default, with any + // scenario overlay + drill) or the register's Lodged bands (real certificates + // only). A display toggle — local state, not URL-persisted. + const [epcView, setEpcView] = useState<"effective" | "lodged">("effective"); const segment = scenarioIdSegment(view); const isScenario = segment !== null; @@ -578,43 +617,74 @@ function MetricsBody({
} meta={ - isScenario - ? "Current vs scenario · click a band for its homes" - : "Effective performance · click a band for its homes" + epcView === "lodged" + ? "Lodged register certificates" + : isScenario + ? "Current vs scenario · click a band for its homes" + : "Effective performance · click a band for its homes" } >
- openDrill({ - filter: "band", - band, - chipBand: band, - title: `Band ${band}`, - }) + bands={ + epcView === "lodged" ? baseline.lodgedBands : baseline.epcBands + } + scenarioBands={epcView === "lodged" ? undefined : scenarioBands} + selectedBand={ + epcView === "effective" && drill?.filter === "band" + ? drill.band + : null + } + onSelectBand={ + epcView === "effective" + ? (band) => + openDrill({ + filter: "band", + band, + chipBand: band, + title: `Band ${band}`, + }) + : undefined } />
- - openDrill({ - filter: "band", - band: callout.band, - chipBand: callout.band, - title: `Below EPC ${callout.band}`, - }) - : undefined - } - /> + {epcView === "lodged" ? ( +
+ + {(total - baseline.lodgedNoCertificate).toLocaleString()} + {" "} + of {total.toLocaleString()} homes have a lodged certificate + {baseline.lodgedNoCertificate > 0 && ( + <> + {" · "} + + {baseline.lodgedNoCertificate.toLocaleString()} + {" "} + have none + + )} + . +
+ ) : ( + + openDrill({ + filter: "band", + band: callout.band, + chipBand: callout.band, + title: `Below EPC ${callout.band}`, + }) + : undefined + } + /> + )}
{isScenario && ledger ? ( diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx index 66fa5f1e..4f12051a 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx @@ -162,9 +162,11 @@ function Delta({ text, improved }: { text: string; improved: boolean }) { function EpcDistribution({ bandCounts, scenarioBands, + caption, }: { bandCounts: Record; scenarioBands?: Record; + caption?: string; }) { const bands = EPC_BANDS.filter( (b) => (bandCounts[b] ?? 0) > 0 || (scenarioBands?.[b] ?? 0) > 0, @@ -217,9 +219,10 @@ function EpcDistribution({ ); })}

- {scenarioBands - ? "Bar = current stock (band colour); line beneath = after scenario (navy)." - : "Homes by current effective EPC band."} + {caption ?? + (scenarioBands + ? "Bar = current stock (band colour); line beneath = after scenario (navy)." + : "Homes by current effective EPC band.")}

); @@ -324,6 +327,68 @@ function RecommendedBrief({ className = "" }: { className?: string }) { ); } +/** + * Report page 2 — the register (lodged) EPC distribution beside the modelled + * (effective) one, so a reader can see how the certificated position compares + * with the modelled reality. `page-break` starts it on a fresh sheet. + */ +function LodgedComparisonPage({ + title, + subtitle, + effectiveBands, + lodgedBands, + lodgedNoCertificate, + total, +}: { + title: string; + subtitle: string; + effectiveBands: Record; + lodgedBands: Record; + lodgedNoCertificate: number; + total: number; +}) { + const withCert = total - lodgedNoCertificate; + return ( +
+ + EPC distribution — register vs modelled +

+ The portfolio's lodged EPC certificates from the register, beside + the modelled effective ratings the rest of this report is based on. +

+
+
+

+ Lodged (register) +

+ +

+ {withCert.toLocaleString()} of {total.toLocaleString()} homes have a + lodged certificate + {lodgedNoCertificate > 0 + ? ` · ${lodgedNoCertificate.toLocaleString()} have none` + : ""} + . +

+
+
+

+ Effective (modelled) +

+ +
+
+
+
+ ); +} + function Footer() { return (