feat(reporting): add a scenario description to the report PDF

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-20 12:15:02 +01:00
parent 4a91e2e817
commit f7d64ccbc4
2 changed files with 162 additions and 20 deletions

View file

@ -283,6 +283,58 @@ export async function getPortfolioGoal(portfolioId: number): Promise<string> {
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<ScenarioConfig | null> {
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;

View file

@ -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 (
<div className="flex items-baseline justify-between gap-4 border-t border-gray-100 py-1 text-[0.76rem] first:border-t-0">
<span className="flex-none text-gray-500">{label}</span>
<span className="text-right font-medium text-[#14163d]">{value}</span>
</div>
);
}
/**
* 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 (
<section className="avoid-break rounded-lg border border-gray-200 p-4">
<h2 className="mb-2 text-[0.82rem] font-semibold text-[#14163d]">
About this scenario
</h2>
<p className="mb-2 text-[0.78rem] leading-relaxed text-gray-600">
This scenario asks what it takes to {goalLead} across the
portfolio&apos;s {stock}, and models the works and costs to get there.
</p>
<div>
<BriefRow
label="Goal"
value={goalPhrase(config.goal, config.goalValue)}
/>
<BriefRow label="Stock" value={config.housingType} />
<BriefRow label="Budget" value={budget} />
<BriefRow
label="Fabric-first"
value={config.fabricFirst ? "Enabled" : "Off"}
/>
<BriefRow label="Excluded measures" value={excluded} />
</div>
</section>
);
}
function RecommendedBrief() {
return (
<section className="avoid-break rounded-lg border border-gray-200 p-4">
<h2 className="mb-2 text-[0.82rem] font-semibold text-[#14163d]">
About recommended plans
</h2>
<p className="text-[0.78rem] leading-relaxed text-gray-600">
Recommended plans take the engine&apos;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.
</p>
</section>
);
}
function Footer() {
return (
<footer className="mt-2 border-t border-gray-200 pt-3 text-[0.64rem] leading-relaxed text-gray-400">
@ -265,7 +348,7 @@ export default async function ReportingPdfPage(props: {
const belowC = countBelowBand(bandCounts, "C");
return (
<div className="print-page mx-auto max-w-[190mm] bg-white">
<div className="print-root space-y-4 p-8 text-[#14163d] print:p-0">
<div className="print-root space-y-3 p-8 text-[#14163d] print:p-0">
<AutoPrint />
<Cover title="Current stock" subtitle={portfolioName} />
@ -328,21 +411,19 @@ export default async function ReportingPdfPage(props: {
}
// ── Scenario / recommended ──────────────────────────────────────────────
const [scenarioData, scenarios, portfolioGoal] = await Promise.all([
const [scenarioData, config, portfolioGoal] = await Promise.all([
fetchScenarioReport(portfolioId, segment, state),
getReportingScenarios(portfolioId),
typeof segment === "number"
? getScenarioConfig(segment)
: Promise.resolve(null),
getPortfolioGoal(portfolioId),
]);
const scenario =
typeof segment === "number"
? scenarios.find((s) => Number(s.id) === segment)
: undefined;
const goal = scenario?.goal ?? portfolioGoal;
const goal = config?.goal ?? portfolioGoal;
const title =
segment === "default"
? "Recommended plans"
: (scenario?.name ?? `Scenario ${segment}`);
: (config?.name ?? `Scenario ${segment}`);
const carbonSaved =
(baseline.totals.total_carbon ?? 0) - Number(scenarioData.total_carbon);
@ -371,7 +452,7 @@ export default async function ReportingPdfPage(props: {
const headline = buildHeadline({
goal,
goalValue: scenario?.goalValue ?? null,
goalValue: config?.goalValue ?? null,
homesUpgraded: scenarioData.n_units_upgraded,
netCost: scenarioData.net_cost,
carbonSavedPerYear: carbonSaved,
@ -390,7 +471,7 @@ export default async function ReportingPdfPage(props: {
return (
<div className="print-page mx-auto max-w-[190mm] bg-white">
<div className="print-root space-y-4 p-8 text-[#14163d] print:p-0">
<div className="print-root space-y-3 p-8 text-[#14163d] print:p-0">
<AutoPrint />
<Cover title={title} subtitle={portfolioName} />
@ -449,14 +530,23 @@ export default async function ReportingPdfPage(props: {
/>
</div>
<div className="grid grid-cols-[1.1fr_1fr] items-start gap-5">
<section className="avoid-break">
<SectionTitle>EPC distribution current vs scenario</SectionTitle>
<EpcDistribution
bandCounts={bandCounts}
scenarioBands={scenarioBands}
/>
</section>
<div className="grid grid-cols-[1.1fr_1fr] items-start gap-4">
<div className="space-y-4">
<section className="avoid-break">
<SectionTitle>
EPC distribution current vs scenario
</SectionTitle>
<EpcDistribution
bandCounts={bandCounts}
scenarioBands={scenarioBands}
/>
</section>
{config ? (
<ScenarioBrief config={config} />
) : segment === "default" ? (
<RecommendedBrief />
) : null}
</div>
<section className="avoid-break">
<SectionTitle>Investment ledger</SectionTitle>