diff --git a/src/app/portfolio/[slug]/components/PropertyFilters.tsx b/src/app/portfolio/[slug]/components/PropertyFilters.tsx index 7add0920..a89bc9ad 100644 --- a/src/app/portfolio/[slug]/components/PropertyFilters.tsx +++ b/src/app/portfolio/[slug]/components/PropertyFilters.tsx @@ -33,7 +33,7 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [ { value: "propertyType", label: "Property Type" }, { value: "builtForm", label: "Built Form" }, { value: "tenure", label: "Tenure" }, - { value: "yearBuilt", label: "Year Built" }, + { value: "yearBuilt", label: "Construction Age" }, { value: "provenance", label: "Provenance" }, { value: "floorArea", label: "Floor Area (m²)" }, { value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" }, diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index b5a87197..eaa8867b 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -85,7 +85,7 @@ function exportToCsv(data: PropertyWithRelations[]) { "Property Type", "Built Form", "Tenure", - "Year Built", + "Construction Age", "Floor Area (m²)", "CO₂ Emissions (kg/m²/yr)", "Main Fuel", diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 9d2955d5..f2aee81a 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -181,7 +181,7 @@ const OPTIONAL_COLUMN_LABELS: Record = { propertyType: "Property Type", builtForm: "Built Form", tenure: "Tenure", - yearBuilt: "Year Built", + yearBuilt: "Construction Age", totalFloorArea: "Floor Area (m²)", co2Emissions: "CO₂ Emissions", mainfuel: "Main Fuel", @@ -434,7 +434,7 @@ const optionalColumns: ColumnDef[] = [ { id: "yearBuilt", accessorKey: "yearBuilt", - header: () =>
Year Built
, + header: () =>
Construction Age
, cell: ({ row }) => (
{row.original.yearBuilt ?? "—"}
), diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index e8d6a91f..b6b2859e 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -35,6 +35,8 @@ import { resolvedPropertyTypeSql, builtFormOverrideJoin, resolvedBuiltFormSql, + constructionAgeOverrideJoin, + resolvedConstructionAgeSql, } from "@/lib/services/epcSources"; import { FilterGroups, @@ -603,7 +605,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul propertyType: resolvedPropertyTypeSql, builtForm: resolvedBuiltFormSql, tenure: sql`p.tenure`, - yearBuilt: sql`p.year_built`, + yearBuilt: resolvedConstructionAgeSql, provenance: provenanceSignalSql, // GAP: new-approach properties have no main-fuel string → NULL, so they // only match the "no value" filter branch. See epcSources.ts. @@ -703,6 +705,7 @@ const EPC_JOIN_FILTER_FIELDS = new Set([ // Override-resolved (ADR-0012): needs the EPC graph joins + the override join. "propertyType", "builtForm", + "yearBuilt", ]); const PLAN_JOIN_FILTER_FIELDS = new Set(["expectedEpc"]); @@ -730,7 +733,8 @@ export async function getPropertiesCount( ? sql`LEFT JOIN property_details_epc epc ON epc.property_id = p.id ${newApproachJoins} ${propertyTypeOverrideJoin} - ${builtFormOverrideJoin}` + ${builtFormOverrideJoin} + ${constructionAgeOverrideJoin}` : sql``; const planJoin = needsPlanJoin ? sql`LEFT JOIN LATERAL ( @@ -805,7 +809,9 @@ export async function getProperties( ${resolvedPropertyTypeSql} AS "propertyType", ${resolvedBuiltFormSql} AS "builtForm", p.tenure AS tenure, - p.year_built AS "yearBuilt", + -- Construction Age is band-native and override-resolved (ADR-0012); the + -- "yearBuilt" key is retained to avoid a wide rename. See resolveConstructionAge. + ${resolvedConstructionAgeSql} AS "yearBuilt", ${lodgementDateSql(sql`epc`)}::text AS "epcLodgementDate", ${isExpiredSql(sql`epc`)} AS "epcIsExpired", ${totalFloorAreaSql(sql`epc`)} AS "totalFloorArea", @@ -848,6 +854,7 @@ export async function getProperties( ${newApproachJoins} ${propertyTypeOverrideJoin} ${builtFormOverrideJoin} + ${constructionAgeOverrideJoin} WHERE p.portfolio_id = ${portfolioId} -- Unmatched (no-UPRN) properties live in the "Needs attention" tab until -- resolved, so they're excluded from the main table. diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts index 88add78a..b1db3479 100644 --- a/src/app/utils/propertyFilters.ts +++ b/src/app/utils/propertyFilters.ts @@ -109,12 +109,15 @@ export const PROVENANCE_OPTIONS: EnumOption[] = [ { label: "Standard", dbValues: ["none"] }, ]; +// Construction Age is band-native (ADR-0012): labels + dbValues are the RdSAP +// band labels emitted by resolvedConstructionAgeSql. These MUST match +// CONSTRUCTION_AGE_BANDS[*].label in epcSources.ts exactly (note the en-dash); +// epcSources.test.ts guards against drift. "Unknown" is the never-null terminal. export const YEAR_BUILT_OPTIONS: EnumOption[] = [ - "1900","1930","1950","1967","1976","1983","1991","1996", - "2003","2007","2008","2009","2010","2011","2012","2013", - "2014","2015","2016","2017","2018","2019","2020","2021", - "2022","2023","2024","2025", -].map((y) => ({ label: y, dbValues: [y] })); + "before 1900", "1900–1929", "1930–1949", "1950–1966", "1967–1975", + "1976–1982", "1983–1990", "1991–1995", "1996–2002", "2003–2006", + "2007–2011", "2012–2022", "2023 onwards", "Unknown", +].map((label) => ({ label, dbValues: [label] })); export const MAINFUEL_OPTIONS: EnumOption[] = [ { label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used"] }, diff --git a/src/lib/services/descriptorResolution.test.ts b/src/lib/services/descriptorResolution.test.ts index 2defc884..763ee857 100644 --- a/src/lib/services/descriptorResolution.test.ts +++ b/src/lib/services/descriptorResolution.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveBuiltForm, resolvePropertyType } from "./descriptorResolution"; +import { + resolveBuiltForm, + resolveConstructionAge, + resolvePropertyType, +} from "./descriptorResolution"; describe("resolvePropertyType", () => { it("prefers a landlord override over the EPC-derived value", () => { @@ -192,3 +196,96 @@ describe("resolveBuiltForm", () => { ).toBe("Unknown"); }); }); + +describe("resolveConstructionAge", () => { + it("maps a landlord override band letter to its band label", () => { + expect( + resolveConstructionAge({ + override: "B", + isNewApproach: true, + lodgedAgeBand: "F", // EPC says a different band — override still wins + predictedAgeBand: null, + legacyYearBuilt: null, + }), + ).toBe("1900–1929"); + }); + + it("maps the lodged EPC band letter to its label when there is no override", () => { + expect( + resolveConstructionAge({ + override: null, + isNewApproach: true, + lodgedAgeBand: "C", + predictedAgeBand: null, + legacyYearBuilt: null, + }), + ).toBe("1930–1949"); + }); + + it("falls back to the predicted EPC band when there is no lodged band", () => { + expect( + resolveConstructionAge({ + override: null, + isNewApproach: true, + lodgedAgeBand: null, + predictedAgeBand: "M", + legacyYearBuilt: null, + }), + ).toBe("2023 onwards"); + }); + + it("buckets a legacy year_built into its band (mid-range)", () => { + expect(legacyAge("1985")).toBe("1983–1990"); + }); + + it("buckets a legacy year before 1900 into band A", () => { + expect(legacyAge("1899")).toBe("before 1900"); + }); + + it("buckets the first year of a band correctly (boundary)", () => { + expect(legacyAge("1900")).toBe("1900–1929"); + }); + + it("buckets a recent legacy year into the open-ended final band", () => { + expect(legacyAge("2025")).toBe("2023 onwards"); + }); + + it("resolves a non-numeric legacy year_built to 'Unknown'", () => { + expect(legacyAge("N/A")).toBe("Unknown"); + }); + + it("resolves to 'Unknown' when nothing is available; ignores the legacy row for new-approach", () => { + expect( + resolveConstructionAge({ + override: null, + isNewApproach: true, + lodgedAgeBand: null, + predictedAgeBand: null, + legacyYearBuilt: "1985", // must NOT surface for a new-approach property + }), + ).toBe("Unknown"); + }); + + it("lets an explicit 'Unknown' override win", () => { + expect( + resolveConstructionAge({ + override: "Unknown", + isNewApproach: true, + lodgedAgeBand: "C", + predictedAgeBand: null, + legacyYearBuilt: null, + }), + ).toBe("Unknown"); + }); +}); + +/** Legacy-property helper: only year_built is set, cutoff is false. */ +function legacyAge(year: string): string { + return resolveConstructionAge({ + override: null, + isNewApproach: false, + lodgedAgeBand: null, + predictedAgeBand: null, + legacyYearBuilt: year, + }); +} diff --git a/src/lib/services/descriptorResolution.ts b/src/lib/services/descriptorResolution.ts index 8abc0047..5fd3a45d 100644 --- a/src/lib/services/descriptorResolution.ts +++ b/src/lib/services/descriptorResolution.ts @@ -10,7 +10,11 @@ * landlord override → EPC-derived (lodged over predicted, codes mapped; * legacy property-row fallback) → 'Unknown' (never null) */ -import { BUILT_FORM_LABELS, PROPERTY_TYPE_LABELS } from "./epcSources"; +import { + BUILT_FORM_LABELS, + CONSTRUCTION_AGE_BANDS, + PROPERTY_TYPE_LABELS, +} from "./epcSources"; export interface PropertyTypeInputs { /** property_overrides.override_value for component 'property_type', building_part 0. */ @@ -72,3 +76,53 @@ function epcBuiltForm(builtForm: string | null): string | null { if (builtForm == null) return null; return BUILT_FORM_LABELS[builtForm] ?? builtForm; } + +export interface ConstructionAgeInputs { + /** property_overrides.override_value for 'construction_age_band', building_part 0 — an RdSAP letter (A..M) or "Unknown". */ + override: string | null; + isNewApproach: boolean; + /** The main building part's epc_building_part.construction_age_band — an RdSAP letter. */ + lodgedAgeBand: string | null; + predictedAgeBand: string | null; + /** property.year_built — a raw year string (legacy). Bucketed into a band. */ + legacyYearBuilt: string | null; +} + +/** + * Construction age as an RdSAP band LABEL ("before 1900" … "2023 onwards", + * "Unknown") — the band is the underlying datum, never a single year (ADR-0012). + * Override and EPC both store the band letter; legacy year_built is bucketed into + * a band. Mirrors resolvedConstructionAgeSql. + */ +export function resolveConstructionAge(i: ConstructionAgeInputs): string { + if (i.override) return bandLetterToLabel(i.override); + const band = i.isNewApproach + ? i.lodgedAgeBand ?? i.predictedAgeBand + : bucketLegacyYearToBand(i.legacyYearBuilt); + return band != null ? bandLetterToLabel(band) : "Unknown"; +} + +/** RdSAP band letter → band label; unrecognised values (e.g. "Unknown") pass through. */ +function bandLetterToLabel(letter: string): string { + return CONSTRUCTION_AGE_BANDS[letter]?.label ?? letter; +} + +/** + * Bucket a legacy raw year_built into its RdSAP band letter: the band with the + * greatest start year not after the given year. Non-numeric text → null (Unknown); + * a year below the earliest start is pre-1900 → band A. Mirrors the legacy branch + * of resolvedConstructionAgeSql. + */ +function bucketLegacyYearToBand(yearBuilt: string | null): string | null { + if (yearBuilt == null || !/^[0-9]+$/.test(yearBuilt)) return null; + const year = parseInt(yearBuilt, 10); + let best: string | null = null; + let bestStart = -Infinity; + for (const [letter, { year: start }] of Object.entries(CONSTRUCTION_AGE_BANDS)) { + if (start <= year && start > bestStart) { + best = letter; + bestStart = start; + } + } + return best ?? "A"; +} diff --git a/src/lib/services/epcSources.test.ts b/src/lib/services/epcSources.test.ts index 1d85a6ab..5b859fd9 100644 --- a/src/lib/services/epcSources.test.ts +++ b/src/lib/services/epcSources.test.ts @@ -3,7 +3,10 @@ import { CONSTRUCTION_AGE_BANDS, PROPERTY_TYPE_LABELS, } from "./epcSources"; -import { PROPERTY_TYPE_OPTIONS } from "@/app/utils/propertyFilters"; +import { + PROPERTY_TYPE_OPTIONS, + YEAR_BUILT_OPTIONS, +} from "@/app/utils/propertyFilters"; // The reporting age-band bucketing in getCountByAgeBand (databaseFunctions.ts), // mirrored so the CONSTRUCTION_AGE_BANDS representative years stay anchored to @@ -57,12 +60,26 @@ describe("CONSTRUCTION_AGE_BANDS", () => { }); describe("PROPERTY_TYPE_LABELS", () => { - it("maps codes to the labels the property-type filter options use", () => { + it("maps every code to a label the property-type filter offers as an option", () => { const optionLabels = new Set(PROPERTY_TYPE_OPTIONS.map((o) => o.label)); for (const label of Object.values(PROPERTY_TYPE_LABELS)) { - // Park home has no filter option — it's rare and charts render it as-is. - if (label === "Park home") continue; expect(optionLabels).toContain(label); } }); }); + +describe("YEAR_BUILT_OPTIONS (Construction Age)", () => { + // Guards the band-native filter (ADR-0012) against label drift — a mismatched + // en-dash or reworded band would silently make the filter match nothing. + it("is exactly the RdSAP band labels in order, plus Unknown", () => { + const bandLabels = Object.values(CONSTRUCTION_AGE_BANDS).map((b) => b.label); + expect(YEAR_BUILT_OPTIONS.map((o) => o.label)).toEqual([ + ...bandLabels, + "Unknown", + ]); + }); + + it("uses each band label as its own dbValue", () => { + for (const o of YEAR_BUILT_OPTIONS) expect(o.dbValues).toEqual([o.label]); + }); +}); diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index 88473813..5f8466c3 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -352,6 +352,52 @@ export const constructionYearSql = sql`CASE ELSE NULL END`; +// ─── Construction age as a band LABEL (ADR-0012) ───────────────────────────── +// Unlike constructionYearSql (which flattens the band to a representative year +// for reporting buckets), the portfolio list keeps the band itself. Mirrors +// resolveConstructionAge in descriptorResolution.ts — keep in sync. + +/** RdSAP band letter → band label (e.g. B → "1900–1929"). */ +const AGE_BAND_LABELS: Record = Object.fromEntries( + Object.entries(CONSTRUCTION_AGE_BANDS).map(([letter, { label }]) => [letter, label]), +); + +/** Bucket a legacy year_built expression into its band label; non-numeric → NULL. */ +const legacyYearBandLabelSql = (yearExpr: Alias) => { + const y = sql`CAST(${yearExpr} AS int)`; + const thresholds = Object.entries(CONSTRUCTION_AGE_BANDS) + .filter(([letter]) => letter !== "A") + .sort((a, b) => b[1].year - a[1].year) + .map(([, { year, label }]) => sql`WHEN ${y} >= ${year} THEN ${label}`); + // The numeric guard fires the CAST only on digit strings; below the earliest + // band start falls through to band A. + return sql`CASE WHEN ${yearExpr} ~ '^[0-9]+$' THEN (CASE ${sql.join( + thresholds, + sql` `, + )} ELSE ${CONSTRUCTION_AGE_BANDS.A.label} END) ELSE NULL END`; +}; + +/** EPC-or-legacy band label: new-approach main-part band (lodged over predicted); legacy year bucketed. */ +const constructionAgeBandLabelSql = sql`CASE + WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${codeLabelCaseSql(mainPartAgeBandSql(sql`epl`), AGE_BAND_LABELS)} ELSE ${codeLabelCaseSql(mainPartAgeBandSql(sql`epp`), AGE_BAND_LABELS)} END) + ELSE ${legacyYearBandLabelSql(sql`p.year_built`)} +END`; + +/** LEFT JOIN exposing the construction_age_band override snapshot (alias `pab`). */ +export const constructionAgeOverrideJoin = sql` + LEFT JOIN property_overrides pab + ON pab.property_id = p.id + AND pab.override_component = 'construction_age_band' + AND pab.building_part = 0 +`; + +/** construction age: override → EPC band → legacy year bucket → 'Unknown'. Mirrors resolveConstructionAge. */ +export const resolvedConstructionAgeSql = sql`COALESCE( + ${codeLabelCaseSql(sql`pab.override_value`, AGE_BAND_LABELS)}, + ${constructionAgeBandLabelSql}, + 'Unknown' +)`; + // ───────────────────────────────────────────────────────────────────────────── // Per-property resolvers (TypeScript object building). // ─────────────────────────────────────────────────────────────────────────────