fix(reporting): age-band and property-type breakdowns for new-approach properties

getCountByAgeBand and getCountByPropertyType read year_built /
property_type straight off the property row, which is NULL for
new-approach properties — every one fell into the Unknown bucket on
/portfolio/[slug]/reporting. Branch them like their already-migrated
siblings (getCountByEpcBand, getEstimatedCounts):

- propertyTypeSql: epc_property (lodged, else predicted) with RdSAP
  code→label mapping and dwelling_type fallback, mirroring
  resolvePropertyDescriptors; legacy stays on property.property_type.
- constructionYearSql: the main building part's construction age band
  mapped to a representative start year (correlated subquery, since
  extension parts would multiply a plain JOIN), so both paths share the
  same year→band bucketing; legacy stays on numeric year_built.
- The RdSAP label maps move into the shared-fragments section as the
  single source of truth; AGE_BAND_YEARS becomes CONSTRUCTION_AGE_BANDS
  carrying label + representative year. Band I (1996–2002) straddles the
  1976–1999 / 2000+ boundary and buckets by its start year.

Verified live against portfolio 814 (all 338 properties resolve, buckets
match the epc_building_part distribution exactly) and legacy portfolios
419/433 (output identical to the pre-fix SQL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-02 11:43:32 +00:00
parent 9bf36ad4b6
commit 051927dda4
3 changed files with 200 additions and 59 deletions

View file

@ -11,6 +11,8 @@ import {
isExpiredSql,
effectiveSapSql,
effectiveEpcBandSql,
propertyTypeSql,
constructionYearSql,
} from "@/lib/services/epcSources";
import type {
@ -71,20 +73,21 @@ export async function getCountByAgeBand(
const result = await db.execute<AgeBandCount>(sql`
SELECT
CASE
WHEN year_built ~ '^[0-9]+$' THEN
CASE
WHEN CAST(year_built AS int) < 1900 THEN '<1900'
WHEN CAST(year_built AS int) BETWEEN 1900 AND 1929 THEN '19001929'
WHEN CAST(year_built AS int) BETWEEN 1930 AND 1949 THEN '19301949'
WHEN CAST(year_built AS int) BETWEEN 1950 AND 1975 THEN '19501975'
WHEN CAST(year_built AS int) BETWEEN 1976 AND 1999 THEN '19761999'
ELSE '2000+'
END
WHEN q.built_year < 1900 THEN '<1900'
WHEN q.built_year BETWEEN 1900 AND 1929 THEN '19001929'
WHEN q.built_year BETWEEN 1930 AND 1949 THEN '19301949'
WHEN q.built_year BETWEEN 1950 AND 1975 THEN '19501975'
WHEN q.built_year BETWEEN 1976 AND 1999 THEN '19761999'
WHEN q.built_year >= 2000 THEN '2000+'
ELSE 'Unknown'
END AS age_band,
COUNT(*)::int AS count
FROM property
WHERE portfolio_id = ${portfolioId}
FROM (
SELECT ${constructionYearSql} AS built_year
FROM property p
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId}
) q
GROUP BY age_band
ORDER BY age_band;
`);
@ -149,10 +152,11 @@ export async function getCountByPropertyType(
portfolioId: number,
): Promise<PropertyTypeCount[]> {
const result = await db.execute<PropertyTypeCount>(sql`
SELECT property_type AS type, COUNT(*)::int AS count
FROM property
WHERE portfolio_id = ${portfolioId}
GROUP BY property_type
SELECT ${propertyTypeSql} AS type, COUNT(*)::int AS count
FROM property p
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId}
GROUP BY type
ORDER BY count DESC;
`);

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
CONSTRUCTION_AGE_BANDS,
PROPERTY_TYPE_LABELS,
} from "./epcSources";
import { PROPERTY_TYPE_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
// the buckets the chart renders.
function reportingAgeBand(year: number): string {
if (year < 1900) return "<1900";
if (year <= 1929) return "19001929";
if (year <= 1949) return "19301949";
if (year <= 1975) return "19501975";
if (year <= 1999) return "19761999";
return "2000+";
}
describe("CONSTRUCTION_AGE_BANDS", () => {
it("keeps each representative year consistent with the band's label", () => {
for (const { label, year } of Object.values(CONSTRUCTION_AGE_BANDS)) {
if (label.startsWith("before")) {
expect(year).toBeLessThan(1900);
} else {
const start = Number(label.match(/\d{4}/)![0]);
expect(year).toBe(start);
}
}
});
it("buckets every RdSAP band into the expected reporting age band", () => {
const buckets = Object.fromEntries(
Object.entries(CONSTRUCTION_AGE_BANDS).map(([code, { year }]) => [
code,
reportingAgeBand(year),
]),
);
expect(buckets).toEqual({
A: "<1900",
B: "19001929",
C: "19301949",
D: "19501975",
E: "19501975",
F: "19761999",
G: "19761999",
H: "19761999",
// I (19962002) straddles the 19761999 / 2000+ boundary; it buckets by
// its start year.
I: "19761999",
J: "2000+",
K: "2000+",
L: "2000+",
M: "2000+",
});
});
});
describe("PROPERTY_TYPE_LABELS", () => {
it("maps codes to the labels the property-type filter options use", () => {
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);
}
});
});

View file

@ -193,6 +193,116 @@ export const isExpiredSql = (e: Alias) =>
export const mainfuelSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN NULL ELSE ${e}.mainfuel END`;
// ─────────────────────────────────────────────────────────────────────────────
// RdSAP code → label maps for the descriptive fields the new pipeline stores as
// codes on epc_property / epc_building_part (the property-row text columns
// aren't written for new-approach properties). Unknown codes fall back to the
// raw value. Shared by the SQL fragments below and resolvePropertyDescriptors —
// the single source of truth for code→label mapping.
// ─────────────────────────────────────────────────────────────────────────────
export const PROPERTY_TYPE_LABELS: Record<string, string> = {
"0": "House",
"1": "Bungalow",
"2": "Flat",
"3": "Maisonette",
"4": "Park home",
};
const BUILT_FORM_LABELS: Record<string, string> = {
"1": "Detached",
"2": "Semi-Detached",
"3": "End-Terrace",
"4": "Mid-Terrace",
"5": "Enclosed End-Terrace",
"6": "Enclosed Mid-Terrace",
};
// SAP tenure codes → the text labels already used in the data. NOTE: best-effort
// (standard SAP 1/2/3); most rows store the text label directly, only a few use
// codes. Confirm against the backend tenure enum.
const TENURE_LABELS: Record<string, string> = {
"1": "Owner Occupied",
"2": "Rented Social",
"3": "Rented Private",
};
/**
* Matches the backend ConstructionAgeBand enum (RdSAP England & Wales letters).
* `year` is a representative construction year (the band's start) for reporting
* queries that bucket by year band I (19962002) straddles the reporting
* 19761999 / 2000+ boundary, so it buckets with its start year.
*/
export const CONSTRUCTION_AGE_BANDS: Record<
string,
{ label: string; year: number }
> = {
A: { label: "before 1900", year: 1899 }, // no start year — any pre-1900 value
B: { label: "19001929", year: 1900 },
C: { label: "19301949", year: 1930 },
D: { label: "19501966", year: 1950 },
E: { label: "19671975", year: 1967 },
F: { label: "19761982", year: 1976 },
G: { label: "19831990", year: 1983 },
H: { label: "19911995", year: 1991 },
I: { label: "19962002", year: 1996 },
J: { label: "20032006", year: 2003 },
K: { label: "20072011", year: 2007 },
L: { label: "20122022", year: 2012 },
M: { label: "2023 onwards", year: 2023 },
};
/** `CASE <col> WHEN <code> THEN <label> … ELSE <col> END` from a code→label map. */
const codeLabelCaseSql = (col: Alias, labels: Record<string, string>) =>
sql`CASE ${col} ${sql.join(
Object.entries(labels).map(([code, label]) => sql`WHEN ${code} THEN ${label}`),
sql` `,
)} ELSE ${col} END`;
/** Property-type label from one epc_property alias (mirrors resolvePropertyDescriptors). */
const epcPropertyTypeSql = (ep: Alias) =>
sql`COALESCE(${codeLabelCaseSql(sql`${ep}.property_type`, PROPERTY_TYPE_LABELS)}, ${ep}.dwelling_type)`;
/**
* Property type label. New: epc_property (lodged, else predicted) RdSAP codes
* mapped via PROPERTY_TYPE_LABELS, text passed through, dwelling_type fallback,
* mirroring resolvePropertyDescriptors. Legacy: property.property_type.
*/
export const propertyTypeSql = sql`CASE
WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${epcPropertyTypeSql(sql`epl`)} ELSE ${epcPropertyTypeSql(sql`epp`)} END)
ELSE p.property_type
END`;
/**
* The main building part's construction age band for one epc_property alias, as
* a correlated scalar subquery properties can also have extension parts, so a
* plain JOIN on epc_building_part would multiply rows and break COUNTs.
*/
const mainPartAgeBandSql = (ep: Alias) => sql`(
SELECT ebp.construction_age_band
FROM epc_building_part ebp
WHERE ebp.epc_property_id = ${ep}.id
ORDER BY CASE WHEN ebp.identifier = 'main' THEN 0 ELSE 1 END, ebp.id
LIMIT 1
)`;
const ageBandYearCaseSql = (band: Alias) =>
sql`CASE ${band} ${sql.join(
Object.entries(CONSTRUCTION_AGE_BANDS).map(
([code, { year }]) => sql`WHEN ${code} THEN ${year}::int`,
),
sql` `,
)} END`;
/**
* Representative construction year (int) for age-band reporting buckets. New:
* the main building part's RdSAP construction age band (lodged EPC, else
* predicted) mapped to the band's start year via CONSTRUCTION_AGE_BANDS.
* Legacy: property.year_built when numeric. NULL the caller's Unknown bucket.
*/
export const constructionYearSql = sql`CASE
WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${ageBandYearCaseSql(mainPartAgeBandSql(sql`epl`))} ELSE ${ageBandYearCaseSql(mainPartAgeBandSql(sql`epp`))} END)
WHEN p.year_built ~ '^[0-9]+$' THEN CAST(p.year_built AS int)
ELSE NULL
END`;
// ─────────────────────────────────────────────────────────────────────────────
// Per-property resolvers (TypeScript object building).
// ─────────────────────────────────────────────────────────────────────────────
@ -586,49 +696,6 @@ export async function resolveDetailsEpcMeta(
};
}
// RdSAP code → label maps for the descriptive fields the new pipeline stores as
// codes on epc_property / epc_building_part (the property-row text columns aren't
// written for new-approach properties). Unknown codes fall back to the raw value.
const PROPERTY_TYPE_LABELS: Record<string, string> = {
"0": "House",
"1": "Bungalow",
"2": "Flat",
"3": "Maisonette",
"4": "Park home",
};
const BUILT_FORM_LABELS: Record<string, string> = {
"1": "Detached",
"2": "Semi-Detached",
"3": "End-Terrace",
"4": "Mid-Terrace",
"5": "Enclosed End-Terrace",
"6": "Enclosed Mid-Terrace",
};
// SAP tenure codes → the text labels already used in the data. NOTE: best-effort
// (standard SAP 1/2/3); most rows store the text label directly, only a few use
// codes. Confirm against the backend tenure enum.
const TENURE_LABELS: Record<string, string> = {
"1": "Owner Occupied",
"2": "Rented Social",
"3": "Rented Private",
};
// Matches the backend ConstructionAgeBand enum (RdSAP England & Wales letters).
const AGE_BAND_YEARS: Record<string, string> = {
A: "before 1900",
B: "19001929",
C: "19301949",
D: "19501966",
E: "19671975",
F: "19761982",
G: "19831990",
H: "19911995",
I: "19962002",
J: "20032006",
K: "20072011",
L: "20122022",
M: "2023 onwards",
};
/**
* Resolve the descriptive property fields (type / built form / age) from the
* epc_property graph for new-approach properties, since the property-row text
@ -692,7 +759,9 @@ async function resolvePropertyDescriptors(
? BUILT_FORM_LABELS[ep.builtForm] ?? ep.builtForm
: null;
const ageBand = mainPart?.constructionAgeBand ?? null;
const yearBuilt = ageBand ? AGE_BAND_YEARS[ageBand] ?? ageBand : null;
const yearBuilt = ageBand
? CONSTRUCTION_AGE_BANDS[ageBand]?.label ?? ageBand
: null;
const tenure = ep.tenure
? TENURE_LABELS[ep.tenure] ?? ep.tenure