refactor(reporting): fold in duplication cleanups

- Money formatters: model.ts owns the single formatMoneyCompact + moneyFull;
  components/primitives re-exports them (moneyCompact = formatMoneyCompact) and
  the PDF's local money() is gone.
- ReportView: viewState.ts is the single definition; ScenarioCombobox re-exports
  it instead of re-declaring.
- EPC_BANDS: the reporting API routes import the canonical @/lib/epc/bands
  rather than the scenarios-model copy.
- compare/page uses countBelowBand(toBandCounts(...), "C") instead of a
  hardcoded ["D","E","F","G"] reduce.

Typecheck + 95 reporting/epc tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-20 14:18:14 +01:00
parent 966381ded2
commit 03e62a17ed
7 changed files with 28 additions and 25 deletions

View file

@ -13,7 +13,7 @@ import {
likelyUpgradeSql,
provenanceSignalSql,
} from "@/lib/services/epcSources";
import { EPC_BANDS } from "@/lib/scenarios/model";
import { EPC_BANDS } from "@/lib/epc/bands";
import { tagFilterCondition } from "@/lib/reporting/tagFilterSql";
import { parseReportingViewState } from "@/lib/reporting/viewState";

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { EPC_BANDS } from "@/lib/scenarios/model";
import { EPC_BANDS } from "@/lib/epc/bands";
import { DEFAULT_GOAL_BAND } from "@/lib/reporting/model";
import { getScenarioOverlay, type OverlayFilters } from "@/lib/reporting/overlay";
import { parseReportingViewState } from "@/lib/reporting/viewState";

View file

@ -2,6 +2,7 @@ import {
loadBaselineMetrics,
getReportingScenarios,
} from "@/lib/reporting/server";
import { countBelowBand, toBandCounts } from "@/lib/reporting/model";
import { CompareClientArea } from "./CompareClientArea";
export default async function ComparePage(props: {
@ -36,9 +37,7 @@ export default async function ComparePage(props: {
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),
belowC: countBelowBand(toBandCounts(baseline.epcBands), "C"),
}}
/>
</div>

View file

@ -9,7 +9,10 @@ import {
import { ChevronDown, Check, Search } from "lucide-react";
import type { ScenarioSummary } from "@/lib/reporting/types";
export type ReportView = "current-stock" | "recommended" | number;
// ReportView is part of the reporting view-state (viewState.ts owns it);
// re-exported here so existing imports from this component keep resolving.
export type { ReportView } from "@/lib/reporting/viewState";
import type { ReportView } from "@/lib/reporting/viewState";
const GOAL_SHORT: Record<string, string> = {
"Increasing EPC": "Target EPC",

View file

@ -139,16 +139,10 @@ export function Panel({
);
}
/** £2,477,000 → "£2.48m"; £850,000 → "£850k". Presentation only. */
export function moneyCompact(amount: number): string {
const abs = Math.abs(amount);
const sign = amount < 0 ? "" : "";
if (abs >= 1_000_000) return `${sign}£${(abs / 1_000_000).toFixed(2)}m`;
if (abs >= 1_000) return `${sign}£${Math.round(abs / 1_000)}k`;
return `${sign}£${Math.round(abs)}`;
}
export function moneyFull(amount: number): string {
const sign = amount < 0 ? "" : "";
return `${sign}£${Math.abs(Math.round(amount)).toLocaleString("en-GB")}`;
}
// Money formatters live in the reporting model (the single source, shared with
// the headline + PDF); re-exported here so components keep importing them from
// the primitives barrel. `moneyCompact` is the model's `formatMoneyCompact`.
export {
formatMoneyCompact as moneyCompact,
moneyFull,
} from "@/lib/reporting/model";

View file

@ -13,6 +13,7 @@ import {
shapeKpiDelta,
toBandCounts,
deriveLedgerView,
moneyFull as money,
type ScenarioOverlay,
} from "@/lib/reporting/model";
import { sapToEpc, formatNumber, getEpcColorClass } from "@/app/utils";
@ -67,11 +68,6 @@ async function fetchScenarioReport(
return res.json();
}
function money(n: number): string {
const sign = n < 0 ? "" : "";
return `${sign}£${Math.abs(Math.round(n)).toLocaleString("en-GB")}`;
}
const generatedOn = () =>
new Date().toLocaleDateString("en-GB", {
day: "numeric",

View file

@ -384,7 +384,12 @@ export function shapeKpiDelta(input: {
Board headline
------------------------------------------------------------------ */
/** £2,477,000 → "£2.48m"; £850,000 → "£850k"; £900 → "£900". */
/**
* £2,477,000 "£2.48m"; £850,000 "£850k"; £900 "£900". The single
* compact money formatter for every reporting surface (headline, KPI tiles,
* the money-allocation bar) re-exported from components/primitives as
* `moneyCompact`.
*/
export function formatMoneyCompact(amount: number): string {
const abs = Math.abs(amount);
const sign = amount < 0 ? "-" : "";
@ -393,6 +398,12 @@ export function formatMoneyCompact(amount: number): string {
return `${sign}£${Math.round(abs)}`;
}
/** £2,477,000 → "£2,477,000". The single full money formatter (ledger, PDF). */
export function moneyFull(amount: number): string {
const sign = amount < 0 ? "" : "";
return `${sign}£${Math.abs(Math.round(amount)).toLocaleString("en-GB")}`;
}
export interface HeadlineInput {
goal: string;
goalValue: string | null;