mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
4a91e2e817
commit
f7d64ccbc4
2 changed files with 162 additions and 20 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'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'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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue