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 (