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>
Hyde 796 landlord descriptions include '100% secondary glazing (sap 9.94)'
(39 properties), currently flattened to 'Single glazing' (U = 4.8 instead of
secondary's 2.9). The calculator already supports secondary glazing (SAP10
code 5) — only this FE-owned enum and the model repo's GlazingType lacked a
member. Additive ALTER TYPE ... ADD VALUE only, generated via drizzle-kit.
Pairs with the model-repo GlazingType.SECONDARY change (Model#1416).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Overhaul the recommendation plan_id/material_* backfill for the 21M-row prod
table (see docs/runbooks/recommendation-denormalization-backfill.md):
- Single combined pass: set plan_id + all four material_* columns in one write
per row (was two full passes, rewriting every row twice). Guard + COALESCE keep
it idempotent and resumable — done rows are never rewritten.
- Session tuning: synchronous_commit=off, tunable work_mem / maintenance_work_mem.
- Range + phase controls (BACKFILL_START_ID/END_ID/SKIP_CARDINALITY/ONLY_FINALIZE)
to run disjoint id-range workers in parallel when the disk isn't the bottleneck.
- Percent-complete + ETA in the progress log.
- Finalize builds the two new indexes plain by default (faster in a quiet
window) with a CONCURRENTLY toggle.
The real speed-up in prod was dropping the 3 secondary indexes for the backfill
then rebuilding — the table is packed (fillfactor 100) so updates can't go HOT
and otherwise maintain every index per row. Index drop/rebuild + vacuum are kept
as standalone SQL so ops controls exactly when the app loses/regains them:
- sql/drop-recommendation-backfill-indexes.sql
- sql/rebuild-recommendation-backfill-indexes.sql (1GB maintenance_work_mem — safe
on the 4GB instance)
- sql/vacuum-recommendation.sql
run-sql.ts runs .sql files statement-by-statement, autocommitted (so VACUUM /
CREATE INDEX CONCURRENTLY work), for environments without the psql CLI. Wired up
as npm run db:run-sql and backfill:recommendation-restore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shadcn TooltipContent rendered inline, missing the Portal that upstream
shadcn ships. On the Current Efficiency card the info tooltip lives inside a
`relative z-10` div while the Lodged EPC badge is a sibling at `z-20`, so the
popup's own `z-50` only competed within the z-10 subtree and the badge painted
over it (also latently clipped by the card's `overflow-hidden`). Wrapping
TooltipContent in TooltipPrimitive.Portal renders it at <body>, escaping both
the stacking context and the overflow clip — fixes all 9 tooltips.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The merge of main renamed epc_property.energyMainsGas ->
energyGasConnectionAvailable (migration 0252), but the new-approach
resolver read the old property name, which main never touched so it
survived the merge with no conflict and broke the build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The modelling_e2e Lambda reads the active catalogue once per invocation
(SELECT * FROM material WHERE is_active ORDER BY id). Without an index this
was a full seq scan every time (~23ms), paid by all 32 concurrent Lambdas at
startup. Partial index on id WHERE is_active returns the active rows already
id-ordered — no sort, no filter.
Verified on dev: seq scan 23.2ms -> index scan 4.2ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A recursive persistence-fidelity guard surfaced 7 fields the SAP calculator
reads but the epc_property projection had no column for, so they were silently
lost on save->reload (same bug class as the PV arrays / floor flags). All
additive, nullable except the PV-diverter bool; no backfill (defaults match
the domain defaults).
- epc_main_heating_detail: community_heating_boiler_fuel_type,
community_heating_chp_fraction (community-heated CHP).
- epc_building_part: alt_wall_1_is_sheltered, alt_wall_2_is_sheltered, and
wall_insulation_thermal_conductivity (jsonb to preserve Union[int,str]).
- epc_property: energy_pv_diverter_present (NOT NULL DEFAULT false),
heating_cylinder_volume_measured_l, ventilation_air_permeability_ap50_m3_h_m2.
Includes generated migration 0246_minor_lady_bullseye.sql + snapshot/journal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add EPC persistence schema for two round-trip-fidelity gaps so the Model
backend can store the full EpcPropertyData faithfully:
- New child table epc_photovoltaic_array (one row per PV array), mirroring
the epc_window child-table pattern.
- epc_floor_dimension: add is_exposed_floor and is_above_partially_heated_space
boolean flags (NOT NULL DEFAULT false; additive, no backfill).
Includes generated migration 0245_magenta_nomad.sql + snapshot/journal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scenario measures modal came up empty because both measures routes
gated on EXISTS against plan_recommendations — a retired join table with
no rows for new-approach plans (25.7M legacy rows, none for e.g.
portfolio 812's plans), so the query returned zero measures.
Read the denormalised model instead: drive from the latest plan per
property (default or scenario) and JOIN recommendation by the indexed
property_id, scoped to the plan via recommendation.plan_id. Portfolio 812
default now returns 5 measures (solar_pv 54 homes/£266k, …) where it
returned 0. Also removes the stale commented query block that referenced
the retired plan_recommendations / recommendation_materials tables.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The breakdown chart looped only over baseline bands (epcBands), which the
query returns only for bands that have properties. So a band reached ONLY
after the scenario — e.g. A/B when no property starts that high — had no
row, and its scenario bar never rendered (portfolio 812: scenario yields
7 A + 2 B, baseline has none). Iterate a canonical A–G+Unknown order over
baseline ∪ scenario instead, so post-scenario-only bands still appear.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Display the UPRN beneath the selected address on the remote assessment
page so it's available for quicker debugging. The value is already on
the selected address object from the postcode lookup; it just wasn't
rendered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a NOT NULL DEFAULT false `marked_for_deletion` boolean to the
`property` table. Hard deletes are expensive (cascading child rows,
indexes), so a row is flagged here and a separate cron job performs the
physical delete later; reads can filter on the flag in the interim.
A constant default makes this a metadata-only ALTER on Postgres 11+ (no
table rewrite); existing rows backfill to false.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>