mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-08-03 05:18:20 +00:00
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:
parent
42afd7a3cb
commit
4321fcd029
8 changed files with 727 additions and 2 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }[],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue