From f7d64ccbc40587fb118009897c12767ed25beacd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Jul 2026 12:15:02 +0100 Subject: [PATCH] feat(reporting): add a scenario description to the report PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two requests in one: describe the scenario, and fill the blank space under the EPC distribution (the left column was shorter than the ledger). Both are solved by an "About this scenario" brief stacked beneath the EPC distribution in the left column. - New getScenarioConfig() reads the scenario's immutable config (goal/target, housing type, budget, measure exclusions, fabric-first; exclusions parsed from the `{a, b}` text column). - The brief: a plain-language sentence ("what it takes to reach EPC C across the portfolio's social homes…") plus a spec list (Goal, Stock, Budget, Fabric-first, Excluded measures with human labels). - Recommended plans gets a matching "About recommended plans" note. - Tightened spacing so the fuller page still fits one A4 sheet. Verified all three views (scenario / current stock / recommended) render as one clean page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reporting/databaseFunctions.ts | 52 +++++++ .../[slug]/(portfolio)/reporting/pdf/page.tsx | 130 +++++++++++++++--- 2 files changed, 162 insertions(+), 20 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index 07378968..62e0d859 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -283,6 +283,58 @@ export async function getPortfolioGoal(portfolioId: number): Promise { return result.rows[0]?.goal ?? "None"; } +export interface ScenarioConfig { + name: string | null; + goal: string; + goalValue: string | null; + budget: number | null; + housingType: string; + exclusions: string[]; + fabricFirst: boolean; +} + +/** `scenario.exclusions` is a text column holding a `{a, b}`-style literal. */ +function parseExclusions(raw: string | null): string[] { + if (!raw) return []; + const inner = raw.replace(/^\{|\}$/g, "").trim(); + if (!inner) return []; + return inner + .split(",") + .map((s) => s.trim().replace(/^"|"$/g, "")) + .filter(Boolean); +} + +/** The immutable configuration of a single scenario — for the report brief. */ +export async function getScenarioConfig( + scenarioId: number, +): Promise { + const result = await db.execute<{ + name: string | null; + goal: string; + goal_value: string | null; + budget: number | null; + housing_type: string; + exclusions: string | null; + fabric_first: boolean; + }>(sql` + SELECT name, goal, goal_value, budget, housing_type, exclusions, fabric_first + FROM scenario + WHERE id = ${BigInt(scenarioId)} + LIMIT 1; + `); + const row = result.rows[0]; + if (!row) return null; + return { + name: row.name, + goal: row.goal, + goalValue: row.goal_value, + budget: row.budget, + housingType: row.housing_type, + exclusions: parseExclusions(row.exclusions), + fabricFirst: row.fabric_first, + }; +} + export type DataQualityMetrics = { total: number; inDate: number; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx index 00ce4ec1..4c9825c3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx @@ -1,9 +1,11 @@ import Image from "next/image"; import { loadBaselineMetrics, - getReportingScenarios, + getScenarioConfig, getPortfolioGoal, + type ScenarioConfig, } from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions"; +import { measureLabel } from "@/lib/reporting/measures"; import { buildHeadline, carsOffTheRoad, @@ -225,6 +227,87 @@ function SectionTitle({ children }: { children: React.ReactNode }) { ); } +function goalPhrase(goal: string, goalValue: string | null): string { + if (goal === "Increasing EPC") return `Reach EPC ${goalValue ?? "target"}`; + if (goal === "Reducing CO2 emissions") return "Reduce CO₂ emissions"; + if (goal === "Energy Savings") return "Cut energy use"; + if (goal === "Valuation Improvement") return "Improve valuation"; + return goal; +} + +function BriefRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +/** + * Plain-language brief of the scenario's (immutable) configuration — the + * question it answers and the constraints it was modelled under. Fills the + * left column beneath the EPC distribution. + */ +function ScenarioBrief({ config }: { config: ScenarioConfig }) { + const stock = + config.housingType?.toLowerCase() === "social" + ? "social homes" + : config.housingType?.toLowerCase() === "private" + ? "private homes" + : "homes"; + const excluded = config.exclusions.length + ? config.exclusions.map(measureLabel).join(", ") + : "None — every measure available"; + const budget = + config.budget != null + ? `£${config.budget.toLocaleString("en-GB")} per home` + : "No cap"; + // Lowercase the leading verb for mid-sentence use, keeping EPC/CO₂ caps. + const gp = goalPhrase(config.goal, config.goalValue); + const goalLead = gp.charAt(0).toLowerCase() + gp.slice(1); + + return ( +
+

+ About this scenario +

+

+ This scenario asks what it takes to {goalLead} across the + portfolio's {stock}, and models the works and costs to get there. +

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

+ About recommended plans +

+

+ Recommended plans take the engine's best plan for each home — the + most cost-effective route to the biggest improvement — rather than one + portfolio-wide target. The figures aggregate those per-home plans. +

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