feat(reporting): compare view + data-quality page (Screens D & E)

Screen D (/reporting/compare): CompareClientArea runs N parallel client
calls to the existing metrics route (useQueries), rows ordered
outcome→investment→value-for-money, best-in-row marked via pickBestIndex
(TDD'd — nulls never win). Up to 4 columns; only modelled scenarios
listed (ADR-0003). Column headers link back to the scenario view.

Screen E (/reporting/data-quality): getDataQualityMetrics adds evidence
composition (in-date/expired/estimated, each home once) and band-movement
counts — likely downgrades AND the new likely-upgrades signal, both by
lodged-vs-effective band per CONTEXT.md. Page reuses the drill-down shelf
for every issue; unlock modules list columns with templates gated behind
'coming soon' pending the data-team spec.

Reporting page's Compare button and 'Review data quality' link now wired.
tsc clean, 435 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-08 08:47:43 +00:00
parent 42afd7a3cb
commit 4321fcd029
8 changed files with 727 additions and 2 deletions

View file

@ -294,9 +294,12 @@ export function ReportingClientArea({
</div>
<div className="flex gap-2">
{isScenario && (
<button className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium">
<a
href={`/portfolio/${portfolioId}/reporting/compare`}
className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium"
>
Compare scenarios
</button>
</a>
)}
<a
href={`/portfolio/${portfolioId}/reporting/pdf${isScenario && typeof view === "number" ? `?scenarioId=${view}` : ""}`}
@ -406,6 +409,7 @@ export function ReportingClientArea({
{/* Data confidence strip */}
<ConfidenceStrip
baseline={baseline}
portfolioId={portfolioId}
onDrill={openDrill}
/>
@ -425,9 +429,11 @@ export function ReportingClientArea({
function ConfidenceStrip({
baseline,
portfolioId,
onDrill,
}: {
baseline: BaselineMetrics;
portfolioId: number;
onDrill: (t: DrillTarget) => void;
}) {
const total = baseline.total;
@ -487,6 +493,12 @@ function ConfidenceStrip({
>
Likely upgrades
</button>
<a
href={`/portfolio/${portfolioId}/reporting/data-quality`}
className="ml-auto font-semibold text-midblue"
>
Review data quality
</a>
</div>
);
}

View file

@ -0,0 +1,311 @@
"use client";
import { useState } from "react";
import { useQueries } from "@tanstack/react-query";
import { sapToEpc } from "@/app/utils";
import { pickBestIndex, countBelowBand } from "@/lib/reporting/model";
import { EpcChip, moneyFull } from "../components/primitives";
interface ScenarioMeta {
id: number;
name: string;
goal?: string;
goalValue?: string | null;
budget?: number | null;
}
interface Baseline {
avgSap: number;
totalCarbon: number;
totalBills: number;
belowC: number;
}
/** A comparable metric row: how to read each scenario's answer and which way is better. */
interface RowSpec {
label: string;
group: string;
direction: "lower" | "higher" | null;
value: (m: ScenarioMetrics, b: Baseline) => number | null;
render: (v: number | null) => React.ReactNode;
}
interface ScenarioMetrics {
avg_sap: string;
scenario_epc_counts: Record<string, number>;
n_units_upgraded: number;
total_carbon: number;
total_bills: number;
construction_cost: number;
pc_cost: number;
contingency: number;
gross_cost: number;
total_funding: number;
net_cost: number;
total_sap_uplift: number;
}
const ROWS: RowSpec[] = [
{
group: "Outcome",
label: "Average EPC after",
direction: "higher",
value: (m) => Number(m.avg_sap),
render: (v) =>
v === null ? "—" : (
<span className="inline-flex items-center gap-1.5">
<EpcChip band={sapToEpc(v)} size="sm" /> {Math.round(v)}
</span>
),
},
{
group: "Outcome",
label: "Homes below C after",
direction: "lower",
value: (m) => countBelowBand(m.scenario_epc_counts, "C"),
render: (v) => (v === null ? "—" : v.toLocaleString("en-GB")),
},
{
group: "Outcome",
label: "Homes upgraded",
direction: "higher",
value: (m) => m.n_units_upgraded,
render: (v) => (v === null ? "—" : v.toLocaleString("en-GB")),
},
{
group: "Outcome",
label: "CO₂ saved /yr",
direction: "higher",
value: (m, b) => b.totalCarbon - m.total_carbon,
render: (v) => (v === null ? "—" : `${Math.round(v)} t`),
},
{
group: "Outcome",
label: "Bill savings /yr",
direction: "higher",
value: (m, b) => b.totalBills - m.total_bills,
render: (v) => (v === null ? "—" : moneyFull(v)),
},
{
group: "Investment",
label: "Gross cost",
direction: "lower",
value: (m) => m.gross_cost,
render: (v) => (v === null ? "—" : moneyFull(v)),
},
{
group: "Investment",
label: "Funding secured",
direction: "higher",
value: (m) => m.total_funding,
render: (v) => (v === null ? "—" : moneyFull(v)),
},
{
group: "Investment",
label: "Net cost",
direction: "lower",
value: (m) => m.net_cost,
render: (v) => (v === null ? "—" : moneyFull(v)),
},
{
group: "Value for money",
label: "Cost per SAP point",
direction: "lower",
value: (m) =>
m.total_sap_uplift > 0
? (m.construction_cost + m.pc_cost) / m.total_sap_uplift
: null,
render: (v) => (v === null ? "—" : moneyFull(v)),
},
{
group: "Value for money",
label: "Cost per tCO₂/yr",
direction: "lower",
value: (m, b) => {
const saved = b.totalCarbon - m.total_carbon;
return saved > 0 ? (m.construction_cost + m.pc_cost) / saved : null;
},
render: (v) => (v === null ? "—" : moneyFull(v)),
},
];
export function CompareClientArea({
portfolioId,
scenarios,
baseline,
}: {
portfolioId: number;
scenarios: ScenarioMeta[];
baseline: Baseline;
}) {
// Up to four columns stay readable at desk widths.
const [selected] = useState(() => scenarios.slice(0, 4).map((s) => s.id));
const columns = scenarios.filter((s) => selected.includes(s.id));
const results = useQueries({
queries: columns.map((s) => ({
queryKey: ["compare-metrics", portfolioId, s.id],
queryFn: () =>
fetch(
`/api/portfolio/${portfolioId}/scenario/${s.id}/metrics`,
).then((r) => {
if (!r.ok) throw new Error("Failed to load scenario");
return r.json() as Promise<ScenarioMetrics>;
}),
refetchOnWindowFocus: false,
})),
});
const loading = results.some((r) => r.isLoading);
const groups = Array.from(new Set(ROWS.map((r) => r.group)));
return (
<div className="space-y-4">
<div className="flex items-end justify-between gap-4">
<div>
<div className="text-[0.74rem] text-gray-400">
Portfolios / Reporting
</div>
<h1 className="mt-0.5 text-[1.35rem] font-semibold tracking-tight text-brandblue">
Compare scenarios
</h1>
</div>
<a
href={`/portfolio/${portfolioId}/reporting`}
className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium"
>
Back to reporting
</a>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table className="w-full text-[0.84rem]">
<thead>
<tr>
<th className="w-[22%] px-4 py-4 text-left" />
<th className="px-4 py-4 text-right align-bottom">
<span className="text-[0.86rem] font-semibold text-brandblue">
Current stock
</span>
<span className="block text-[0.68rem] font-normal text-gray-400">
No scenario applied
</span>
</th>
{columns.map((s) => (
<th key={s.id} className="px-4 py-4 text-right align-bottom">
<span className="text-[0.86rem] font-semibold text-brandblue">
{s.name}
</span>
<span className="block text-[0.68rem] font-normal text-gray-400">
{s.goal === "Increasing EPC" && s.goalValue
? `Target EPC ${s.goalValue}`
: s.goal}
{s.budget ? ` · £${(s.budget / 1000).toFixed(0)}k/home` : ""}
</span>
<a
href={`/portfolio/${portfolioId}/reporting?scenario=${s.id}`}
className="mt-1 block text-[0.7rem] font-medium text-midblue"
>
View
</a>
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td
colSpan={columns.length + 2}
className="px-4 py-10 text-center text-sm text-gray-400"
>
Loading scenarios
</td>
</tr>
) : (
groups.map((group) => (
<GroupRows
key={group}
group={group}
columns={columns.length}
results={results.map((r) => r.data as ScenarioMetrics)}
baseline={baseline}
/>
))
)}
</tbody>
</table>
</div>
<p className="mt-2.5 text-[0.75rem] text-gray-400">
marks the best value in each row no overall winner is crowned;
trade-offs stay visible.
</p>
</div>
);
}
function GroupRows({
group,
columns,
results,
baseline,
}: {
group: string;
columns: number;
results: ScenarioMetrics[];
baseline: Baseline;
}) {
const rows = ROWS.filter((r) => r.group === group);
return (
<>
<tr>
<td
colSpan={columns + 2}
className="px-4 pb-1.5 pt-4 text-[0.66rem] font-bold uppercase tracking-wide text-[#a07c42]"
>
{group}
</td>
</tr>
{rows.map((row) => {
const scenarioValues = results.map((m) =>
m ? row.value(m, baseline) : null,
);
const best =
row.direction === null
? null
: pickBestIndex(scenarioValues, row.direction);
// "before" cell: baseline has no scenario metric, so most rows show —.
const baselineCell =
row.label === "Average EPC after"
? row.render(baseline.avgSap)
: row.label === "Homes below C after"
? baseline.belowC.toLocaleString("en-GB")
: "—";
return (
<tr key={row.label} className="border-t border-gray-100">
<td className="px-4 py-2.5 text-left text-gray-500">{row.label}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-gray-900">
{baselineCell}
</td>
{scenarioValues.map((v, i) => (
<td
key={i}
className={`px-4 py-2.5 text-right tabular-nums ${
best === i
? "font-bold text-[#0c6b4a]"
: "text-gray-900"
}`}
>
{best === i && (
<span className="mr-1.5 align-middle text-[0.5rem]"></span>
)}
{row.render(v)}
</td>
))}
</tr>
);
})}
</>
);
}

View file

@ -0,0 +1,46 @@
import {
loadBaselineMetrics,
} from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions";
import { listScenariosWithStatus } from "@/lib/scenarios/queries";
import { CompareClientArea } from "./CompareClientArea";
export default async function ComparePage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const portfolioId = Number(slug);
const [baseline, scenarioRows] = await Promise.all([
loadBaselineMetrics(portfolioId),
listScenariosWithStatus(BigInt(portfolioId)),
]);
// Only modelled scenarios can be compared — the metrics route returns
// zeros for plan-less scenarios (ADR-0003).
const scenarios = scenarioRows
.filter((s) => s.status === "modelled")
.map((s) => ({
id: Number(s.id),
name: s.name ?? `Scenario ${s.id}`,
goal: s.goal,
goalValue: s.goalValue,
budget: s.budget,
}));
return (
<div className="max-w-8xl mx-auto px-6 pb-12 pt-5">
<CompareClientArea
portfolioId={portfolioId}
scenarios={scenarios}
baseline={{
avgSap: baseline.averages.avg_sap ?? 0,
totalCarbon: baseline.totals.total_carbon ?? 0,
totalBills: baseline.totals.total_bills ?? 0,
belowC: baseline.epcBands
.filter((b) => ["D", "E", "F", "G"].includes(b.epc))
.reduce((s, b) => s + b.actual + b.estimated, 0),
}}
/>
</div>
);
}

View file

@ -0,0 +1,246 @@
"use client";
import { useState } from "react";
import type { DataQualityMetrics } from "../databaseFunctions";
import {
DrillDownShelf,
type DrillTarget,
} from "../components/DrillDownShelf";
const UNLOCK_MODULES = [
{
name: "Condition & compliance",
unlocks:
"Awaab's Law warnings, HHSRS Category 1 & 2 hazard counts and Decent Homes compliance.",
columns: ["hazard_category", "hazard_severity", "survey_date", "decent_homes_pass"],
},
{
name: "Rent & valuation",
unlocks:
"Financial exposure, value uplift per scenario and payback framing in the investment ledger.",
columns: ["rent_pa", "valuation", "valuation_date"],
},
];
export function DataQualityClientArea({
portfolioId,
metrics,
}: {
portfolioId: number;
metrics: DataQualityMetrics;
}) {
const [drill, setDrill] = useState<DrillTarget | null>(null);
const [page, setPage] = useState(1);
const { total, inDate, expired, estimated, likelyDowngrades, likelyUpgrades } =
metrics;
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
function open(t: DrillTarget) {
setDrill(t);
setPage(1);
}
const composition = [
{ label: "In date · " + inDate, value: inDate, color: "#14163d" },
{ label: "Expired · " + expired, value: expired, color: "#a07c42" },
{ label: "Estimated · " + estimated, value: estimated, color: "#c5cad8" },
];
const issues = [
{
key: "estimated" as const,
name: "Estimated EPCs",
def: "No certificate exists; performance predicted from nearby homes of similar archetype.",
count: estimated,
impact:
"These homes' SAP, bills and carbon are modelled, not certificated — they move averages and band counts.",
good: false,
},
{
key: "expired" as const,
name: "Expired EPCs",
def: "Real certificate, older than 10 years.",
count: expired,
impact:
"Still counted as lodged, but the home may have changed since assessment. Re-assess before relying on these bands.",
good: false,
},
{
key: "likely-downgrade" as const,
name: "Likely downgrades",
def: "Lodged band sits above the effective band — a re-survey would likely certificate lower. Band movement only.",
count: likelyDowngrades,
impact:
"The certificate on file is better than the home's true position, so compliance demonstrated by these EPCs is fragile.",
good: false,
},
{
key: "likely-upgrade" as const,
name: "Likely upgrades",
def: "Effective band sits above the lodged band — Landlord overrides or SAP 10.2 scoring improved the picture.",
count: likelyUpgrades,
impact:
"These homes would likely certificate higher with a re-survey alone — the cheapest compliance moves in the portfolio.",
good: true,
},
];
return (
<div className="space-y-4">
<div className="flex items-end justify-between gap-4">
<div>
<div className="text-[0.74rem] text-gray-400">
Portfolios / Reporting
</div>
<h1 className="mt-0.5 text-[1.35rem] font-semibold tracking-tight text-brandblue">
Data quality
</h1>
</div>
<a
href={`/portfolio/${portfolioId}/reporting`}
className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium"
>
Back to reporting
</a>
</div>
{/* Evidence composition */}
<section className="rounded-lg border border-gray-200 bg-white px-5 pb-5 pt-4">
<h2 className="text-[0.88rem] font-semibold text-brandblue">
EPC evidence
<span className="ml-2 text-[0.74rem] font-normal text-gray-400">
every home counted once, by strongest evidence
</span>
</h2>
<div className="mt-4 flex h-[18px] overflow-hidden rounded">
{composition.map((c) => (
<span
key={c.label}
style={{
width: `${total > 0 ? (c.value / total) * 100 : 0}%`,
background: c.color,
}}
/>
))}
</div>
<div className="mt-3 flex flex-wrap gap-4 text-[0.72rem] text-gray-500">
{composition.map((c) => (
<span key={c.label} className="flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ background: c.color }}
/>
{c.label}
</span>
))}
</div>
</section>
{/* Issues */}
<section className="overflow-hidden rounded-lg border border-gray-200 bg-white">
{issues.map((issue, i) => (
<div
key={issue.key}
className={`grid grid-cols-[210px_70px_1fr_130px] items-start gap-4 px-5 py-4 text-[0.83rem] ${
i > 0 ? "border-t border-gray-100" : ""
}`}
>
<div>
<div className="font-semibold text-gray-900">{issue.name}</div>
<div className="mt-0.5 text-[0.73rem] leading-snug text-gray-400">
{issue.def}
</div>
</div>
<div
className={`text-right text-[1.1rem] font-semibold tabular-nums ${
issue.good ? "text-[#0c6b4a]" : "text-brandblue"
}`}
>
{issue.count}
<span className="block text-[0.68rem] font-medium text-gray-400">
{pct(issue.count)}%
</span>
</div>
<div className="text-[0.78rem] leading-relaxed text-gray-500">
{issue.impact}
</div>
<div className="text-right">
<button
onClick={() =>
open({
filter: issue.key,
title: issue.name,
})
}
className="text-[0.78rem] font-semibold text-midblue"
>
View homes
</button>
</div>
</div>
))}
</section>
{drill && (
<DrillDownShelf
portfolioId={portfolioId}
target={drill}
page={page}
onPage={setPage}
onClose={() => setDrill(null)}
/>
)}
{/* Not yet provided */}
<section className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<h2 className="px-5 pt-4 text-[0.88rem] font-semibold text-brandblue">
Not yet provided
<span className="ml-2 text-[0.74rem] font-normal text-gray-400">
data that unlocks new report sections
</span>
</h2>
{UNLOCK_MODULES.map((m, i) => (
<div
key={m.name}
className={`grid grid-cols-[180px_1fr_160px] items-start gap-5 px-5 py-4 ${
i > 0 ? "border-t border-gray-100" : "mt-2 border-t border-gray-100"
}`}
>
<div>
<div className="text-[0.83rem] font-semibold text-gray-900">
{m.name}
</div>
<span className="mt-1.5 inline-block rounded bg-[#faf1e2] px-1.5 py-0.5 text-[0.64rem] font-bold text-[#9a5b10]">
No data
</span>
</div>
<div>
<p className="text-[0.78rem] leading-relaxed text-gray-500">
Unlocks {m.unlocks}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{m.columns.map((c) => (
<span
key={c}
className="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[0.66rem] text-gray-500"
>
{c}
</span>
))}
<span className="text-[0.68rem] text-gray-400">
column set to confirm with the data team
</span>
</div>
</div>
<div className="flex flex-col items-end gap-1.5 text-right">
<span className="inline-flex h-8 items-center rounded-lg border border-gray-200 px-3 text-[0.78rem] font-medium text-gray-400">
Template coming soon
</span>
</div>
</div>
))}
</section>
</div>
);
}

View file

@ -0,0 +1,16 @@
import { getDataQualityMetrics } from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions";
import { DataQualityClientArea } from "./DataQualityClientArea";
export default async function DataQualityPage(props: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await props.params;
const portfolioId = Number(slug);
const metrics = await getDataQualityMetrics(portfolioId);
return (
<div className="max-w-8xl mx-auto px-6 pb-12 pt-5">
<DataQualityClientArea portfolioId={portfolioId} metrics={metrics} />
</div>
);
}

View file

@ -10,6 +10,7 @@ import {
isExpiredSql,
effectiveSapSql,
effectiveEpcBandSql,
lodgedEpcBandSql,
propertyTypeSql,
constructionYearSql,
} from "@/lib/services/epcSources";
@ -248,3 +249,53 @@ export async function getPortfolioGoal(portfolioId: number): Promise<string> {
`);
return result.rows[0]?.goal ?? "None";
}
export type DataQualityMetrics = {
total: number;
inDate: number;
expired: number;
estimated: number;
likelyDowngrades: number;
likelyUpgrades: number;
};
/**
* Evidence composition + band-movement counts for the data-quality page
* (Screen E). Every home is counted once by strongest evidence
* (in-date lodged expired estimated). Likely downgrade/upgrade follow
* the CONTEXT.md band-movement definitions (lodged band vs effective band;
* real certificates only lodgedEpcBandSql is NULL for predicted homes).
* Band letters compare lexically, so a lodged band that is a *better*
* letter than effective (lodged < effective) is a likely downgrade.
*/
export async function getDataQualityMetrics(
portfolioId: number,
): Promise<DataQualityMetrics> {
const result = await db.execute<DataQualityMetrics>(sql`
SELECT
COUNT(*)::int AS total,
SUM(CASE
WHEN ${estimatedSql(sql`e`)} = false AND ${isExpiredSql(sql`e`)} = false
THEN 1 ELSE 0 END)::int AS "inDate",
SUM(CASE
WHEN ${estimatedSql(sql`e`)} = false AND ${isExpiredSql(sql`e`)} = true
THEN 1 ELSE 0 END)::int AS expired,
SUM(CASE WHEN ${estimatedSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS estimated,
SUM(CASE
WHEN (${lodgedEpcBandSql}) IS NOT NULL
AND (${effectiveEpcBandSql}) IS NOT NULL
AND (${lodgedEpcBandSql}) < (${effectiveEpcBandSql})
THEN 1 ELSE 0 END)::int AS "likelyDowngrades",
SUM(CASE
WHEN (${lodgedEpcBandSql}) IS NOT NULL
AND (${effectiveEpcBandSql}) IS NOT NULL
AND (${lodgedEpcBandSql}) > (${effectiveEpcBandSql})
THEN 1 ELSE 0 END)::int AS "likelyUpgrades"
FROM property p
LEFT JOIN property_details_epc e ON e.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId};
`);
return result.rows[0];
}

View file

@ -5,6 +5,7 @@ import {
classifyBandMovement,
computeLedger,
isCompliantBeyondWindow,
pickBestIndex,
selectGoalCallout,
shapeKpiDelta,
toBandCounts,
@ -200,6 +201,27 @@ describe("classifyBandMovement", () => {
});
});
describe("pickBestIndex", () => {
// 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);
});
it("picks the highest for a higher-is-better row", () => {
expect(pickBestIndex([null, 312, 368, 219], "higher")).toBe(2);
});
it("returns null when every comparable value is missing", () => {
expect(pickBestIndex([null, null], "lower")).toBeNull();
});
it("ignores nulls rather than treating them as zero", () => {
expect(pickBestIndex([null, null, 5], "lower")).toBe(2);
});
});
describe("toBandCounts", () => {
it("totals actual + estimated per band into a plain record", () => {
expect(

View file

@ -148,6 +148,27 @@ const GOAL_DIMENSIONS: Partial<Record<PortfolioGoalType, GoalDimension>> = {
[PORTFOLIO_GOALS.VALUATION]: "valuation",
};
/**
* 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.
*/
export function pickBestIndex(
values: (number | null)[],
direction: "lower" | "higher",
): number | null {
let bestIndex: number | null = null;
let best = direction === "lower" ? Infinity : -Infinity;
values.forEach((v, i) => {
if (v === null) return;
if (direction === "lower" ? v < best : v > best) {
best = v;
bestIndex = i;
}
});
return bestIndex;
}
/** Collapses baseline band rows (actual + estimated) into a plain band→count map. */
export function toBandCounts(
bands: { epc: string; actual: number; estimated: number }[],