diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index 4fbe71d1..44d349c3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -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(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 '1900–1929' - WHEN CAST(year_built AS int) BETWEEN 1930 AND 1949 THEN '1930–1949' - WHEN CAST(year_built AS int) BETWEEN 1950 AND 1975 THEN '1950–1975' - WHEN CAST(year_built AS int) BETWEEN 1976 AND 1999 THEN '1976–1999' - ELSE '2000+' - END + WHEN q.built_year < 1900 THEN '<1900' + WHEN q.built_year BETWEEN 1900 AND 1929 THEN '1900–1929' + WHEN q.built_year BETWEEN 1930 AND 1949 THEN '1930–1949' + WHEN q.built_year BETWEEN 1950 AND 1975 THEN '1950–1975' + WHEN q.built_year BETWEEN 1976 AND 1999 THEN '1976–1999' + 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 { const result = await db.execute(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; `); diff --git a/src/lib/services/epcSources.test.ts b/src/lib/services/epcSources.test.ts new file mode 100644 index 00000000..1d85a6ab --- /dev/null +++ b/src/lib/services/epcSources.test.ts @@ -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 "1900–1929"; + if (year <= 1949) return "1930–1949"; + if (year <= 1975) return "1950–1975"; + if (year <= 1999) return "1976–1999"; + 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: "1900–1929", + C: "1930–1949", + D: "1950–1975", + E: "1950–1975", + F: "1976–1999", + G: "1976–1999", + H: "1976–1999", + // I (1996–2002) straddles the 1976–1999 / 2000+ boundary; it buckets by + // its start year. + I: "1976–1999", + 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); + } + }); +}); diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index d644a1a9..b50cdd78 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -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 = { + "0": "House", + "1": "Bungalow", + "2": "Flat", + "3": "Maisonette", + "4": "Park home", +}; +const BUILT_FORM_LABELS: Record = { + "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 = { + "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 (1996–2002) straddles the reporting + * 1976–1999 / 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: "1900–1929", year: 1900 }, + C: { label: "1930–1949", year: 1930 }, + D: { label: "1950–1966", year: 1950 }, + E: { label: "1967–1975", year: 1967 }, + F: { label: "1976–1982", year: 1976 }, + G: { label: "1983–1990", year: 1983 }, + H: { label: "1991–1995", year: 1991 }, + I: { label: "1996–2002", year: 1996 }, + J: { label: "2003–2006", year: 2003 }, + K: { label: "2007–2011", year: 2007 }, + L: { label: "2012–2022", year: 2012 }, + M: { label: "2023 onwards", year: 2023 }, +}; + +/** `CASE WHEN THEN