From 03e62a17ed7917d9600c6f3531a9ccc1946293f7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Jul 2026 14:18:14 +0100 Subject: [PATCH] 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) --- .../reporting/properties/route.ts | 2 +- .../scenario/[scenarioId]/metrics/route.ts | 2 +- .../(portfolio)/reporting/compare/page.tsx | 5 ++--- .../reporting/components/ScenarioCombobox.tsx | 5 ++++- .../reporting/components/primitives.tsx | 20 +++++++------------ .../[slug]/(portfolio)/reporting/pdf/page.tsx | 6 +----- src/lib/reporting/model.ts | 13 +++++++++++- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts b/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts index 3c298e0b..d1eb59e7 100644 --- a/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts +++ b/src/app/api/portfolio/[portfolioId]/reporting/properties/route.ts @@ -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"; diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index 77a81ab8..b5aa9bba 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -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"; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/page.tsx index 8b6fc30d..714eca81 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/compare/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/compare/page.tsx @@ -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"), }} /> diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/ScenarioCombobox.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/ScenarioCombobox.tsx index f39bfc23..c2455cc3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/components/ScenarioCombobox.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/ScenarioCombobox.tsx @@ -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 = { "Increasing EPC": "Target EPC", diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/components/primitives.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/components/primitives.tsx index 59099b7a..a4a24973 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/components/primitives.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/components/primitives.tsx @@ -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"; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx index 7dd2f9ec..2dd89432 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx @@ -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", diff --git a/src/lib/reporting/model.ts b/src/lib/reporting/model.ts index 08d25e5a..f688ef6e 100644 --- a/src/lib/reporting/model.ts +++ b/src/lib/reporting/model.ts @@ -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;