diff --git a/src/app/components/building-passport/CurrentEfficiencyCard.tsx b/src/app/components/building-passport/CurrentEfficiencyCard.tsx index 723cb630..7aad49fc 100644 --- a/src/app/components/building-passport/CurrentEfficiencyCard.tsx +++ b/src/app/components/building-passport/CurrentEfficiencyCard.tsx @@ -5,6 +5,9 @@ import { LodgedEpcTooltip } from "./LodgedEpcTooltip"; interface CurrentEfficiencyCardProps { epcRating: string | null; sapPoints: number; + provenanceSignal?: "estimated" | "remodelled" | "none"; + lodgedEpcRating?: string | null; + lodgedSapPoints?: number | null; originalSapPoints?: number | null; } @@ -58,11 +61,24 @@ function getEpcDescription(letter: string | null | undefined): string { export function CurrentEfficiencyCard({ epcRating, sapPoints, + provenanceSignal = "none", + lodgedEpcRating, + lodgedSapPoints, originalSapPoints, }: CurrentEfficiencyCardProps) { const epcHex = getEpcHex(epcRating); - const lodgedLetter = originalSapPoints ? sapToEpc(originalSapPoints) : null; - const showLodgedBadge = lodgedLetter !== null && lodgedLetter !== epcRating; + // New-approach: show the lodged certificate whenever the property was + // re-modelled (effective diverged from lodged), even within the same band. + // Legacy: fall back to the row's originalSapPoints, only when the band differs. + const legacyLodgedLetter = originalSapPoints ? sapToEpc(originalSapPoints) : null; + const remodelled = provenanceSignal === "remodelled"; + const lodgedLetter = remodelled + ? lodgedEpcRating ?? null + : legacyLodgedLetter !== epcRating + ? legacyLodgedLetter + : null; + const lodgedBadgeSap = remodelled ? lodgedSapPoints : originalSapPoints; + const showLodgedBadge = lodgedLetter !== null; const lodgedHex = getEpcHex(lodgedLetter); return ( @@ -87,9 +103,9 @@ export function CurrentEfficiencyCard({ > {lodgedLetter} - {originalSapPoints != null && ( + {lodgedBadgeSap != null && ( - / {Math.round(originalSapPoints)} + / {Math.round(lodgedBadgeSap)} )} diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts index 3ae12aeb..8cb86c42 100644 --- a/src/app/db/schema/property.ts +++ b/src/app/db/schema/property.ts @@ -38,6 +38,14 @@ export interface PropertyMeta { currentEpcRating: string; currentSapPoints: number; originalSapPoints: number | null; + // Provenance of the displayed "current" figures (see ADR-0002 / provenance.ts). + // "estimated" = no certificate (predicted); "remodelled" = lodged but + // rebaselined; "none" = lodged == effective, or legacy. + provenanceSignal: "estimated" | "remodelled" | "none"; + // Lodged (gov-register) headline, only when a real certificate exists; feeds + // the "Lodged EPC" badge alongside the effective "current". + lodgedEpcRating: string | null; + lodgedSapPoints: number | null; updatedAt: string; currentValuation: number | null; detailsEpc: { @@ -378,6 +386,13 @@ export interface PropertyWithRelations extends Record { // New fields landlordPropertyId: string | null; originalSapPoints: number | null; + // Effective "current" (ADR-0002) is in currentEpcRating/currentSapPoints; these + // carry the separate lodged certificate + the default plan's post-retrofit + // rating + the provenance signal that drives the "Predicted" pill. + lodgedEpcRating: string | null; + postEpcRating: string | null; + postSapPoints: number | null; + provenanceSignal: "estimated" | "remodelled" | "none"; epcLodgementDate: string | null; epcIsExpired: boolean | null; // Optional columns (hidden by default) diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index db61d9df..4fbe71d1 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -9,8 +9,8 @@ import { energyConsumptionSql, estimatedSql, isExpiredSql, - sapSql, - epcBandSql, + effectiveSapSql, + effectiveEpcBandSql, } from "@/lib/services/epcSources"; import type { @@ -38,7 +38,7 @@ export async function getAverages( ): Promise { const result = await db.execute(sql` SELECT - AVG(${sapSql})::float AS avg_sap, + AVG(${effectiveSapSql})::float AS avg_sap, AVG(${carbonSql(sql`e`)})::float AS avg_carbon, AVG(${billsSql(sql`e`)})::float AS avg_bills, AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption @@ -99,7 +99,7 @@ export async function getCountByEpcBand( SELECT * FROM ( SELECT - COALESCE((${epcBandSql})::text, 'Unknown') AS epc, + COALESCE((${effectiveEpcBandSql})::text, 'Unknown') AS epc, COUNT(*) FILTER ( WHERE ${estimatedSql(sql`e`)} = false )::int AS actual, diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx index a2123cb9..dacb6205 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx @@ -238,6 +238,9 @@ export default async function PreAssessmentReport(props: { diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx index 174f274b..92c12eed 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx @@ -86,6 +86,9 @@ export default async function BuildingPassportHome(props: { diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx index 546b2b75..7add0920 100644 --- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx +++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx @@ -16,6 +16,7 @@ import { TENURE_OPTIONS, YEAR_BUILT_OPTIONS, MAINFUEL_OPTIONS, + PROVENANCE_OPTIONS, } from "@/app/utils/propertyFilters"; /* ----------------------------------------------------------------------- @@ -33,6 +34,7 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [ { value: "builtForm", label: "Built Form" }, { value: "tenure", label: "Tenure" }, { value: "yearBuilt", label: "Year Built" }, + { value: "provenance", label: "Provenance" }, { value: "floorArea", label: "Floor Area (m²)" }, { value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" }, { value: "mainfuel", label: "Main Fuel" }, @@ -74,6 +76,7 @@ const ENUM_FIELD_OPTIONS: Record = { builtForm: BUILT_FORM_OPTIONS, tenure: TENURE_OPTIONS, yearBuilt: YEAR_BUILT_OPTIONS, + provenance: PROVENANCE_OPTIONS, mainfuel: MAINFUEL_OPTIONS, }; diff --git a/src/app/portfolio/[slug]/components/expectedEpc.test.ts b/src/app/portfolio/[slug]/components/expectedEpc.test.ts new file mode 100644 index 00000000..50a09aac --- /dev/null +++ b/src/app/portfolio/[slug]/components/expectedEpc.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { expectedEpcRating } from "./expectedEpc"; + +describe("expectedEpcRating", () => { + it("uses the default plan's post EPC rating when present", () => { + expect( + expectedEpcRating({ postEpcRating: "C", postSapPoints: 71.4 }), + ).toBe("C"); + }); + + it("derives the band from post SAP when the plan has no stored rating", () => { + expect( + expectedEpcRating({ postEpcRating: null, postSapPoints: 64 }), + ).toBe("D"); + }); + + it("is blank when the property has no default plan", () => { + expect(expectedEpcRating(null)).toBe(""); + }); +}); diff --git a/src/app/portfolio/[slug]/components/expectedEpc.ts b/src/app/portfolio/[slug]/components/expectedEpc.ts new file mode 100644 index 00000000..686e5cf1 --- /dev/null +++ b/src/app/portfolio/[slug]/components/expectedEpc.ts @@ -0,0 +1,19 @@ +import { sapToEpc } from "@/app/utils"; + +/** + * The portfolio "Expected EPC" — the post-retrofit band of a property's default + * plan. The plan engine already models interactions, SAP 10 and the effective + * baseline, so we trust `plan.post_epc_rating` (falling back to deriving it from + * `post_sap_points`) rather than adding raw recommendation SAP onto the current + * score, which double-counts across plans and overshoots. See ADR-0002 and the + * grilling notes for property 732385 (read A, should read C). + * + * Returns "" when there is no default plan — the column renders blank, not the + * current rating. + */ +export function expectedEpcRating( + plan: { postEpcRating: string | null; postSapPoints: number | null } | null, +): string { + if (!plan) return ""; + return plan.postEpcRating ?? sapToEpc(plan.postSapPoints); +} diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 7ef022eb..9d2955d5 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -15,6 +15,7 @@ import { FunnelIcon } from "@heroicons/react/24/outline"; import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils"; import { cn } from "@/lib/utils"; import { PropertyWithRelations } from "@/app/db/schema/property"; +import { expectedEpcRating } from "./expectedEpc"; import { X } from "lucide-react"; import { EnumOption, @@ -72,6 +73,20 @@ const EpcLetterBubble = ({ letter }: { letter: string }) => { ); }; +/** + * Marks a property whose EPC was estimated from nearby homes (no certificate) — + * the "trust this less" signal. Re-modelled properties aren't marked (the + * Lodged-EPC column differing from Current already conveys that). See ADR-0002. + */ +const PredictedPill = () => ( + + Predicted + +); + /* ----------------------------------------------------------------------- Column header with dropdown filter ------------------------------------------------------------------------ */ @@ -229,19 +244,27 @@ const coreColumns: ColumnDef[] = [ ), cell: ({ row }) => ( -
+
+ {row.original.provenanceSignal === "estimated" && }
), }, { - accessorKey: "originalSapPoints", + accessorKey: "lodgedEpcRating", header: () => (
Lodged EPC
), cell: ({ row }) => { - const originalSap = row.original.originalSapPoints; - const letter = originalSap ? sapToEpc(originalSap) : null; + // Effective is the "current"; this column shows the real lodged + // certificate, blank when there is none (predicted properties). For legacy + // rows the query returns the row's rating; new-approach returns lodged or + // NULL. originalSapPoints remains a fallback for legacy rows lacking a band. + const letter = + row.original.lodgedEpcRating ?? + (row.original.originalSapPoints + ? sapToEpc(row.original.originalSapPoints) + : null); return (
@@ -255,21 +278,17 @@ const coreColumns: ColumnDef[] = [
Expected EPC
), cell: ({ row }) => { - const currentSapPoints = row.original.currentSapPoints || 0; - const expectedSapPoints = row.original.totalRecommendationSapPoints || 0; - - if (currentSapPoints + expectedSapPoints === 0) { - return ( -
- -
- ); - } - const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints); - + const expectedEpc = expectedEpcRating( + row.original.postEpcRating != null || row.original.postSapPoints != null + ? { + postEpcRating: row.original.postEpcRating, + postSapPoints: row.original.postSapPoints, + } + : null, + ); return (
- +
); }, diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index 40e612a5..b429f6c8 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -26,8 +26,11 @@ import { lodgementDateSql, isExpiredSql, mainfuelSql, - sapSql, - epcBandSql, + effectiveSapSql, + effectiveEpcBandSql, + lodgedEpcBandSql, + lodgedSapSql, + provenanceSignalSql, } from "@/lib/services/epcSources"; import { FilterGroups, @@ -37,6 +40,7 @@ import { TENURE_OPTIONS, YEAR_BUILT_OPTIONS, MAINFUEL_OPTIONS, + PROVENANCE_OPTIONS, EnumOption, } from "@/app/utils/propertyFilters"; import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc"; @@ -46,6 +50,7 @@ const ENUM_FIELD_DB_OPTIONS: Record = { builtForm: BUILT_FORM_OPTIONS, tenure: TENURE_OPTIONS, yearBuilt: YEAR_BUILT_OPTIONS, + provenance: PROVENANCE_OPTIONS, mainfuel: MAINFUEL_OPTIONS, }; @@ -523,10 +528,11 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul return null; case "currentEpc": - return buildEpcSapCondition(sql`p.current_sap_points`, filter.operator, filter.value); + // "Current" is the effective baseline (ADR-0002), not the null row column. + return buildEpcSapCondition(effectiveSapSql, filter.operator, filter.value); case "lodgedEpc": - return buildEpcSapCondition(sql`p.original_sap_points`, filter.operator, filter.value); + return buildEpcSapCondition(lodgedSapSql, filter.operator, filter.value); case "expectedEpc": { if (filter.operator === "epc_at_least") { @@ -574,6 +580,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul case "builtForm": case "tenure": case "yearBuilt": + case "provenance": case "mainfuel": { if (filter.operator !== "enum_one_of") return null; @@ -591,6 +598,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul builtForm: sql`p.built_form`, tenure: sql`p.tenure`, yearBuilt: sql`p.year_built`, + provenance: provenanceSignalSql, // GAP: new-approach properties have no main-fuel string → NULL, so they // only match the "no value" filter branch. See epcSources.ts. mainfuel: mainfuelSql(sql`epc`), @@ -718,15 +726,20 @@ export async function getProperties( p.postcode AS postcode, p.status AS status, p.creation_status AS "creationStatus", - -- Current EPC/SAP: new-approach properties have null headline columns on - -- the property row, so source from the lodged baseline. The "Expected EPC" - -- column is currentSapPoints + recommendation SAP, so this also fixes it. - ${epcBandSql} AS "currentEpcRating", - ${sapSql} AS "currentSapPoints", - -- property_targets is no longer read (the "Expected EPC" column derives - -- from currentSapPoints + recommendation SAP). Kept as NULL to preserve - -- the PropertyWithRelations shape. + -- "Current" is the effective (re-baselined) figure — the canonical current + -- per ADR-0002. The lodged certificate is a separate column. New-approach + -- properties have null headline columns on the property row, so both source + -- from property_baseline_performance. + ${effectiveEpcBandSql} AS "currentEpcRating", + ${effectiveSapSql} AS "currentSapPoints", + ${lodgedEpcBandSql} AS "lodgedEpcRating", + ${provenanceSignalSql} AS "provenanceSignal", + -- "Expected EPC" is the default plan's modelled post-retrofit rating, not + -- current + Σ recommendation SAP (which double-counts across plans and + -- overshoots). See expectedEpc.ts / ADR-0002. NULL AS "targetEpc", + pl.post_epc_rating AS "postEpcRating", + pl.post_sap_points AS "postSapPoints", pl.id AS "planId", -- funding_package is no longer read here (the proposals table fetches it -- separately). Kept as NULL to preserve the PropertyWithRelations shape. @@ -749,7 +762,7 @@ export async function getProperties( -- LATERAL one-row lookups keep this query at "N properties × index probe" -- instead of hash-joining the multi-million-row plan/recommendation tables. LEFT JOIN LATERAL ( - SELECT id, post_sap_points FROM plan + SELECT id, post_sap_points, post_epc_rating FROM plan WHERE property_id = p.id AND portfolio_id = p.portfolio_id AND is_default = true @@ -768,6 +781,11 @@ export async function getProperties( COALESCE(SUM(estimated_cost), 0) AS cost FROM recommendation WHERE property_id = p.id + -- Scope to the default plan. property_id is the indexed access path, so + -- this only filters the property's own (tiny) rec set in memory — no + -- unindexed plan_id scan — while stopping the SUM double-counting a + -- measure once per plan when a property has several plans. + AND plan_id = pl.id AND "default" = true AND already_installed = false ) rec ON true diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts index e67309ae..1f85675e 100644 --- a/src/app/utils/propertyFilters.ts +++ b/src/app/utils/propertyFilters.ts @@ -10,6 +10,7 @@ export type FilterField = | "builtForm" | "tenure" | "yearBuilt" + | "provenance" | "floorArea" | "co2Emissions" | "mainfuel"; @@ -89,6 +90,14 @@ export const TENURE_OPTIONS: EnumOption[] = [ { label: "Not Recorded", dbValues: ["__null__"] }, ]; +// Provenance signal (ADR-0002). dbValues are the raw signal strings emitted by +// provenanceSignalSql / resolveProvenanceSignal. +export const PROVENANCE_OPTIONS: EnumOption[] = [ + { label: "Predicted", dbValues: ["estimated"] }, + { label: "Re-modelled", dbValues: ["remodelled"] }, + { label: "Standard", dbValues: ["none"] }, +]; + export const YEAR_BUILT_OPTIONS: EnumOption[] = [ "1900","1930","1950","1967","1976","1983","1991","1996", "2003","2007","2008","2009","2010","2011","2012","2013", diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index cbec24b5..d1a77dc0 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -29,6 +29,7 @@ */ import { db } from "@/app/db/db"; import { and, eq, sql } from "drizzle-orm"; +import { resolveProvenanceSignal, type ProvenanceSignal } from "./provenance"; import { epcProperty, epcEnergyElement, @@ -98,6 +99,44 @@ export const sapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_sap_score /** Baseline EPC band. New: lodged band from property_baseline_performance. Legacy: property.current_epc_rating. */ export const epcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_epc_band ELSE p.current_epc_rating END`; +/** + * Effective (re-baselined) SAP score / EPC band — the canonical "current" for + * display and reporting (ADR-0002). New: bp.effective_*; legacy: the property + * row. These replaced the lodged `sapSql`/`epcBandSql` above as the table + + * reporting source; those lodged fragments are intentionally retained (unused + * for now) for a possible future register-fidelity reporting view (ADR-0002). + * The "Lodged EPC" column itself uses the source-gated `lodgedEpcBandSql` below. + */ +export const effectiveSapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_sap_score ELSE p.current_sap_points END`; +export const effectiveEpcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_epc_band ELSE p.current_epc_rating END`; + +/** + * Lodged EPC band for the "Lodged EPC" column — only when a real certificate + * exists (`source = lodged`, i.e. the lodged epc_property row `epl`). A + * predicted property carries lodged_* (mirrored estimates) that must not + * surface, so it renders NULL. Legacy: derive from the property row's + * original_sap_points (handled in the column, kept as the row value here). + */ +export const lodgedEpcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN bp.lodged_epc_band ELSE NULL END) ELSE p.current_epc_rating END`; + +/** Lodged SAP score for the "Lodged EPC" filter — same source/gate as lodgedEpcBandSql. Legacy: the row's original_sap_points. */ +export const lodgedSapSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN bp.lodged_sap_score ELSE NULL END) ELSE p.original_sap_points END`; + +/** + * Provenance signal for the portfolio table (mirrors resolveProvenanceSignal / + * provenance.ts). New-approach only; legacy → 'none'. + */ +export const provenanceSignalSql = sql`CASE + WHEN ${isNewApproachSql} THEN ( + CASE + WHEN epp.id IS NOT NULL AND epl.id IS NULL THEN 'estimated' + WHEN epl.id IS NOT NULL AND bp.rebaseline_reason IS NOT NULL AND bp.rebaseline_reason <> 'none' THEN 'remodelled' + ELSE 'none' + END + ) + ELSE 'none' +END`; + /** * Annual energy bill (£). New: sum of the individual cost columns on * property_baseline_performance (NULL when no baseline row exists, so it's @@ -438,6 +477,57 @@ export async function resolvePropertyHeadline( }; } +/** + * Provenance signal + the lodged (gov-register) headline for the EPC card. + * `provenanceSignal` drives the "estimated"/"re-modelled" UI signals; the lodged + * band/SAP feed the "Lodged EPC" badge and are only meaningful when a real + * certificate exists (`source = lodged`). Legacy properties get `none` and null + * lodged figures — the card falls back to the row's `originalSapPoints`. + * See provenance.ts (pure precedence), CONTEXT.md and ADR-0002. + */ +export async function resolveProvenance( + propertyId: bigint, + updatedAt: Date | string | null | undefined, +): Promise<{ + provenanceSignal: ProvenanceSignal; + lodgedEpcRating: string | null; + lodgedSapPoints: number | null; +}> { + if (!isNewApproach(updatedAt)) { + return { provenanceSignal: "none", lodgedEpcRating: null, lodgedSapPoints: null }; + } + const [lodged, predicted, baseline] = await Promise.all([ + db.query.epcProperty.findFirst({ + columns: { id: true }, + where: and(eq(epcProperty.propertyId, propertyId), eq(epcProperty.source, "lodged")), + }), + db.query.epcProperty.findFirst({ + columns: { id: true }, + where: and(eq(epcProperty.propertyId, propertyId), eq(epcProperty.source, "predicted")), + }), + db.query.propertyBaselinePerformance.findFirst({ + columns: { rebaselineReason: true, lodgedSapScore: true, lodgedEpcBand: true }, + where: eq(propertyBaselinePerformance.propertyId, propertyId), + }), + ]); + + const hasLodgedEpc = !!lodged; + const provenanceSignal = resolveProvenanceSignal({ + isNewApproach: true, + hasLodgedEpc, + hasPredictedEpc: !!predicted, + rebaselineReason: baseline?.rebaselineReason, + }); + + // Lodged figures only when a real certificate exists — a predicted property + // still carries lodged_* (mirrored estimates) which must not surface. + return { + provenanceSignal, + lodgedEpcRating: hasLodgedEpc ? baseline?.lodgedEpcBand ?? null : null, + lodgedSapPoints: hasLodgedEpc ? baseline?.lodgedSapScore ?? null : null, + }; +} + /** The minimal EPC meta the `/api/property-meta` route embeds as `detailsEpc`. */ export interface DetailsEpcMeta { currentEnergyDemand: number | null; @@ -654,16 +744,20 @@ export async function resolvePropertyMeta(propertyId: string) { if (!propertyMeta) return null; const newApproach = isNewApproach(propertyMeta.updatedAt); - const [detailsEpc, headline, descriptors] = await Promise.all([ + const [detailsEpc, headline, descriptors, provenance] = await Promise.all([ resolveDetailsEpcMeta(propertyMeta.id, propertyMeta.updatedAt), resolvePropertyHeadline(propertyMeta.id, propertyMeta.updatedAt), newApproach ? resolvePropertyDescriptors(propertyMeta.id) : Promise.resolve(null), + resolveProvenance(propertyMeta.id, propertyMeta.updatedAt), ]); return { ...propertyMeta, + provenanceSignal: provenance.provenanceSignal, + lodgedEpcRating: provenance.lodgedEpcRating, + lodgedSapPoints: provenance.lodgedSapPoints, propertyType: descriptors?.propertyType ?? propertyMeta.propertyType, builtForm: descriptors?.builtForm ?? propertyMeta.builtForm, yearBuilt: descriptors?.yearBuilt ?? propertyMeta.yearBuilt, diff --git a/src/lib/services/provenance.test.ts b/src/lib/services/provenance.test.ts new file mode 100644 index 00000000..77c95423 --- /dev/null +++ b/src/lib/services/provenance.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { resolveProvenanceSignal } from "./provenance"; + +describe("resolveProvenanceSignal", () => { + it("flags a predicted-only property as estimated, even when rebaselined", () => { + expect( + resolveProvenanceSignal({ + isNewApproach: true, + hasLodgedEpc: false, + hasPredictedEpc: true, + rebaselineReason: "pre_sap10", + }), + ).toBe("estimated"); + }); + + it("flags a lodged, rebaselined property as remodelled", () => { + expect( + resolveProvenanceSignal({ + isNewApproach: true, + hasLodgedEpc: true, + hasPredictedEpc: false, + rebaselineReason: "pre_sap10", + }), + ).toBe("remodelled"); + }); + + it("returns none for a lodged property that was not rebaselined", () => { + expect( + resolveProvenanceSignal({ + isNewApproach: true, + hasLodgedEpc: true, + hasPredictedEpc: false, + rebaselineReason: "none", + }), + ).toBe("none"); + }); + + it("prefers the real certificate when both lodged and predicted EPCs exist", () => { + expect( + resolveProvenanceSignal({ + isNewApproach: true, + hasLodgedEpc: true, + hasPredictedEpc: true, + rebaselineReason: "pre_sap10", + }), + ).toBe("remodelled"); + }); + + it("returns none for legacy properties regardless of EPC inputs", () => { + expect( + resolveProvenanceSignal({ + isNewApproach: false, + hasLodgedEpc: false, + hasPredictedEpc: true, + rebaselineReason: "pre_sap10", + }), + ).toBe("none"); + }); +}); diff --git a/src/lib/services/provenance.ts b/src/lib/services/provenance.ts new file mode 100644 index 00000000..b16e49a6 --- /dev/null +++ b/src/lib/services/provenance.ts @@ -0,0 +1,36 @@ +/** + * Provenance signal — what the user is told about an EPC's trustworthiness. + * See CONTEXT.md ("EPC provenance", "Provenance signal") and ADR-0002. + * + * Pure decision over a property's EPC provenance (`epc_property.source`) and its + * Rebaseline (`property_baseline_performance.rebaseline_reason`). The DB-reading + * resolver gathers the inputs; this function owns the precedence. + */ +export type ProvenanceSignal = "estimated" | "remodelled" | "none"; + +export function resolveProvenanceSignal(input: { + isNewApproach: boolean; + hasLodgedEpc: boolean; + hasPredictedEpc: boolean; + rebaselineReason: string | null | undefined; +}): ProvenanceSignal { + // Legacy (pre-cutoff) properties have no effective/lodged split and no + // provenance concept — they read the property row directly. + if (!input.isNewApproach) { + return "none"; + } + // No EPC certificate exists — the picture was estimated from nearby homes. + // Dominant: wins even when the baseline was also rebaselined. + if (input.hasPredictedEpc && !input.hasLodgedEpc) { + return "estimated"; + } + // A real certificate exists but the modelling baseline diverged from it. + if ( + input.hasLodgedEpc && + input.rebaselineReason != null && + input.rebaselineReason !== "none" + ) { + return "remodelled"; + } + return "none"; +}