feat(portfolio): band-native Construction Age column + filter (was Year Built)

Slice 3. Construction age is modelled as an RdSAP band, not a year (ADR-0012) —
the only representation that faithfully carries an 'Unknown' override. resolve-
ConstructionAge owns override(letter)→label / EPC band(letter)→label / legacy
year→band bucketing / 'Unknown', with 10 unit tests incl. band boundaries.
resolvedConstructionAgeSql mirrors it (emits the band label). YEAR_BUILT_OPTIONS
becomes the band labels + Unknown, guarded against label/en-dash drift by a new
epcSources.test. Column + filter + CSV header renamed to 'Construction Age'
(internal 'yearBuilt' key retained to avoid a wide rename).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-10 23:35:14 +00:00
parent e2ab52e4f9
commit 558e20f2fe
9 changed files with 242 additions and 18 deletions

View file

@ -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)" },

View file

@ -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",

View file

@ -181,7 +181,7 @@ const OPTIONAL_COLUMN_LABELS: Record<OptionalColumnId, string> = {
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<PropertyWithRelations>[] = [
{
id: "yearBuilt",
accessorKey: "yearBuilt",
header: () => <div className="text-xs">Year Built</div>,
header: () => <div className="text-xs">Construction Age</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.yearBuilt ?? "—"}</div>
),

View file

@ -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<typeof sql> | 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<FilterField>([
// Override-resolved (ADR-0012): needs the EPC graph joins + the override join.
"propertyType",
"builtForm",
"yearBuilt",
]);
const PLAN_JOIN_FILTER_FIELDS = new Set<FilterField>(["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.

View file

@ -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", "19001929", "19301949", "19501966", "19671975",
"19761982", "19831990", "19911995", "19962002", "20032006",
"20072011", "20122022", "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"] },

View file

@ -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("19001929");
});
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("19301949");
});
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("19831990");
});
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("19001929");
});
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,
});
}

View file

@ -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";
}

View file

@ -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]);
});
});

View file

@ -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 → "19001929"). */
const AGE_BAND_LABELS: Record<string, string> = 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).
// ─────────────────────────────────────────────────────────────────────────────