mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
e2ab52e4f9
commit
558e20f2fe
9 changed files with 242 additions and 18 deletions
|
|
@ -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)" },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<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).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue