Commit graph

1622 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
34dcb0a54f fix(scenarios): band picker order, letter contrast, quieter excluded chips
Design feedback:
- Target-band picker reads A→G left-to-right (reading order; the
  gallery/detail meters stay G→A as they mirror the building passport's
  efficiency gauge, which fills low→high).
- Unselected picker chips are now light band-colour tints with one
  consistent dark ink — no ink has legal contrast across all seven band
  colours at full saturation, so the old full-colour+opacity fade made
  letter colours look inconsistent and washed out. Only the selected
  chip goes full colour, with luminance-correct ink (shared bandInk()).
- Excluded measure chips recede (gray, struck through) instead of
  filling dark navy — "Exclude all" no longer paints a wall of blue;
  the measures still in play are the visually dominant ones. State is
  never colour-only: ✓/✕ icon + strikethrough carry it too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:56:52 +00:00
Khalim Conn-Kowlessar
fdae966461 feat(scenarios): type-to-confirm delete modal, replacing browser confirm()
Swap the native confirm() popup for the app's shadcn Dialog pattern
(as in Settings → Danger Zone): the destructive button stays disabled
until the user types "delete" (case-insensitive), with Enter-to-submit
and a proper explanation of what's removed. On a delete error the
dialog closes so the inline error is visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:51:24 +00:00
Khalim Conn-Kowlessar
639cee6c4c feat(scenarios): portfolio Scenarios tab — gallery, detail, creation journey
Users could only create scenarios buried inside the CSV-upload modal, at
trigger time. This adds a first-class Scenarios tab: a gallery of the
portfolio's scenarios, a read-only detail page, and a 3-step creation
journey (goal → measures → review) that saves app-authored scenario rows
per ADR-0003.

Domain rules (TDD'd in src/lib/scenarios/model.test.ts, 19 tests):
- goal semantics: "Increasing EPC" requires a target band; all other
  goals are maximise-within-budget with goal_value forced null
- exclusions-only measure model: normalised (dedupe+sort), unknown keys
  rejected, all-excluded rejected, empty set valid; serialised to the
  backend's brace text format (parses the legacy space-padded variant)
- insert mapping: multi_plan=true, is_default=false, budget per property
- exact-config duplicate detection ignoring name (review-step warning)
- status derived from plan existence — no stored status column

Queries follow the ADR's discipline: gallery status = one grouped
COUNT(DISTINCT property_id) per portfolio; detail = one scoped count
(index-only, powers the blocked-delete message); delete = single atomic
DELETE guarded on NOT EXISTS(plans). Rename is the only other mutation —
configuration is immutable.

Verified live on portfolio 814: create (400 on missing band, 201 on CO₂
draft), derived Awaiting/Modelled in the gallery, rename, delete of the
draft (200) and blocked delete of the modelled scenario (409, "338
properties…" matching the portfolio's true count).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:34:47 +00:00
Khalim Conn-Kowlessar
1cd48bc78e fix(reporting): negative scenario carbon impact — move carbon fragments to effective baseline
"Total carbon saved" on the reporting scenario overlay went negative
(portfolio 796: -10,673 t/yr, £/tCO2 -8,195). Cause: the baseline side
(getTotals → carbonSql) summed bp.lodged_co2_emissions_t_per_yr, which
is NULL for predicted-only properties (12,064 of 796's 31,919 — no
mirrored lodged estimate is written), so they contributed nothing to
the baseline. The scenario side counted their plans' post_co2_emissions
(21,046 t) in full, dragging saved below zero. The same subtraction fed
£-per-tonne-CO2 and average-per-unit.

carbonSql and energyConsumptionSql were left on lodged_* when ADR-0002
(effective performance is the canonical current, superseding "reporting
stays on lodged") migrated the SAP fragments — the same reporting cards
were already mixing effective SAP with lodged carbon. Move both to
effective_*, which is populated for every property with a baseline row
and is the figure the plans' post_* values were scored against. All
call sites (reporting averages/totals, both scenario metrics routes,
portfolio table CO2 column/filter) are current-state surfaces, so none
wanted lodged. Legacy branch untouched.

Verified live on 796: baseline and scenario totals computed in one
snapshot with the shared fragment give saved = +4,059 t/yr (a plan
regeneration was writing 796 mid-diagnosis, so values drift run-to-run;
pre-fix the same snapshot maths gives ~ -13k and worsening as plans
land). Legacy portfolio 433 totals unchanged.

No regression-test seam exists for cross-query consistency (no DB test
harness); consistency is structural — both sides compose the same
carbonSql fragment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:52:21 +00:00
Khalim Conn-Kowlessar
051927dda4 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>
2026-07-02 11:43:32 +00:00
Khalim Conn-Kowlessar
9bf36ad4b6 perf(backfill): faster, resumable recommendation denormalization + ops tooling
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>
2026-07-02 10:00:46 +00:00
Khalim Conn-Kowlessar
1dfb1e1534 fix(building-passport): portal tooltip content so it isn't trapped by stacking context
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>
2026-07-02 09:56:59 +00:00
Khalim Conn-Kowlessar
4941e75683 fix(epc): follow energyMainsGas rename in new-approach resolver
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>
2026-07-01 15:56:16 +00:00
Khalim Conn-Kowlessar
59297c3495 Merge remote-tracking branch 'origin/main' into feature/update-ui-for-new-modelling 2026-07-01 15:34:29 +00:00
Daniel Roth
9b9a718b65
Merge pull request #342 from Hestia-Homes/main-heating-system-migration-2
New main heating system value
2026-07-01 15:45:45 +01:00
Daniel Roth
34d8301f24 migration files 2026-07-01 14:44:05 +00:00
Daniel Roth
d59aa7f079 new main heating system value 2026-07-01 14:43:39 +00:00
Daniel Roth
e63d80dd65
Merge pull request #341 from Hestia-Homes/water-heating-overrides-migration
Migration to add new water heating types
2026-07-01 15:23:38 +01:00
Daniel Roth
003913a90e migration files 2026-07-01 14:22:16 +00:00
Daniel Roth
ca781771f2 add new water heating types 2026-07-01 14:21:58 +00:00
Daniel Roth
ea4fee27d9
Merge pull request #340 from Hestia-Homes/rename-energy-column
Rename energyMainsGas to energyGasConnectionAvailable
2026-07-01 15:16:28 +01:00
Daniel Roth
eda1079e65 migration files 2026-07-01 14:08:01 +00:00
Daniel Roth
0a67aa5e4f rename energyMainsGas to energyGasConnectionAvailable 2026-07-01 14:07:26 +00:00
Daniel Roth
b2451651f5
Merge pull request #339 from Hestia-Homes/mixed-glazing-migration
Add Mixed glazing to GlazingValues
2026-07-01 13:38:18 +01:00
Daniel Roth
7a0d2a4d64 migration files 2026-07-01 12:09:14 +00:00
Daniel Roth
57d91cae1e Add Mixed glazing to GlazingValues 2026-07-01 12:08:57 +00:00
Daniel Roth
f5912070ee
Merge pull request #338 from Hestia-Homes/roof-type-migration
Roof type migration
2026-07-01 12:18:33 +01:00
Daniel Roth
6833bee645 migration files 2026-07-01 11:00:30 +00:00
Daniel Roth
c7ce0860c4 add flat room insulation values to RoofTypeValues 2026-07-01 10:59:53 +00:00
Daniel Roth
2ba084f858
Merge pull request #334 from Hestia-Homes/feature/dedupe-documents
Dedupe documents by only fetching the most recent for each S3 key
2026-07-01 09:27:18 +01:00
Daniel Roth
1969043cee
Merge pull request #336 from Hestia-Homes/not-null-lodged-on-baseline
Drop NotNull constraint on propertyBaselinePerformance lodged_ columns
2026-07-01 08:50:19 +01:00
Daniel Roth
782ad7b1d0 migration files 2026-07-01 07:42:14 +00:00
Daniel Roth
e36fb5398d lodged_ columns on property_baseline_performance are nullable 2026-07-01 07:41:55 +00:00
Daniel Roth
c9c1d37b1b
Merge pull request #335 from Hestia-Homes/main-heating-system-migration
Extend MainHeatingSystem PgEnum
2026-07-01 08:40:41 +01:00
Daniel Roth
813311f535 migration files 2026-07-01 07:24:19 +00:00
Daniel Roth
8789c9bab0 extend enum 2026-07-01 07:23:36 +00:00
Daniel Roth
54cf807f2b correct comment following previous commit 2026-06-30 11:10:19 +00:00
Daniel Roth
7c9faba215 sort dupe files based on upload timestamp rather than id 2026-06-30 11:06:58 +00:00
Daniel Roth
ac6983ae68 only get most recent version of each uploaded file per s3 key 2026-06-30 11:02:49 +00:00
KhalimCK
861bf144ae
Merge pull request #333 from Hestia-Homes/migration/material-active-index
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
Add partial index on material(is_active) for the catalogue read
2026-06-29 14:16:22 +01:00
Jun-te Kim
f51fdca3d8 Add partial index on material(is_active) for the catalogue read
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>
2026-06-29 11:53:19 +00:00
KhalimCK
4ae4487055
Merge pull request #331 from Hestia-Homes/feature/epc-calculator-read-fields-schema
feat(epc): persist 7 calculator-read fields dropped on save
2026-06-26 19:37:49 +01:00
Khalim Conn-Kowlessar
e4960db2c1 feat(epc): persist 7 calculator-read fields dropped on save
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>
2026-06-26 18:34:45 +00:00
KhalimCK
138aca3ae8
Merge pull request #330 from Hestia-Homes/feature/epc-pv-and-floor-heatloss-schema
feat(epc): persist PV arrays and floor heat-loss flags
2026-06-26 17:41:26 +01:00
Khalim Conn-Kowlessar
a1548ca3a9 feat(epc): persist PV arrays and floor heat-loss flags
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>
2026-06-26 16:38:36 +00:00
Khalim Conn-Kowlessar
2f0f937fb6 fix(reporting): read scenario measures via denormalised recommendation.plan_id
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>
2026-06-26 11:42:19 +00:00
Khalim Conn-Kowlessar
eef9fdc171 fix(reporting): show scenario bars for EPC bands absent from the baseline
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>
2026-06-26 11:25:45 +00:00
Jun-te Kim
e96c5ced16
Merge pull request #329 from Hestia-Homes/feature/show-uprn-remote-assessment
Show UPRN on selected address in remote assessment
2026-06-26 09:34:01 +01:00
Jun-te Kim
163412506c Show UPRN on selected address in remote assessment
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>
2026-06-25 14:50:36 +00:00
Jun-te Kim
165858b387
Merge pull request #328 from Hestia-Homes/feature/mark_for_deletion
feat(db): add property.marked_for_deletion migration
2026-06-25 15:04:28 +01:00
Jun-te Kim
752ffe1935 feat(db): add property.marked_for_deletion soft-delete flag
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>
2026-06-25 12:18:32 +00:00
Khalim Conn-Kowlessar
7de48448c0 fix(reporting): temp guards for sub-baseline scenario post_sap
The modelling writes a target-level post_sap (e.g. ~C) even for homes
already above the scenario target, so plans can carry cost while modelling
post_sap BELOW the effective baseline. That skewed three reporting
surfaces. Three TEMP (demo) guards, all keyed on the effective baseline
(ADR-0002); revert once the Model team fixes the sub-baseline plans:

1. EPC band chart: post-scenario SAP clamped to GREATEST(baseline, post)
   so already-compliant properties aren't shown "improving" down a band
   (portfolio 796: EPC B 4,244 -> 1,660 wrongly, now 4,479).
2. n_units_upgraded + cost: exclude plans whose post_sap is below the
   effective baseline (not real upgrades) -- 796: 10,283 -> 9,765, -£1.28M.
3. total_sap_uplift / £-per-SAP: baseline is the effective SAP, not the
   null-for-new-approach current_sap_points, and counts genuine gains
   only -- uplift 0 -> 89,724, so £/SAP £0 -> £536.

Also fixes the no-plan branch to use the effective baseline instead of
the null current_sap_points (Unknown-band leakage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:44:22 +00:00
Khalim Conn-Kowlessar
7bb2b093e5 perf(portfolio): only join EPC graph / plan LATERAL in count when a filter needs it
getPropertiesCount returns the portfolio's total property count for
pagination, but it dragged the whole read model through the COUNT: the
property_details_epc + property_baseline_performance + two epc_property
joins, plus a correlated default-plan LATERAL that ran once per property
(31k+ plan lookups for a large portfolio). None of those joins change a
COUNT (none multiply rows), so for an unfiltered load they were pure cost
— pushing the query to ~14.7s, past Vercel's 15s limit (intermittent
timeout on /api/properties).

Join only what an active filter references: no filters -> plain COUNT over
property; EPC/provenance filter -> add the epc-graph joins; Expected-EPC
filter -> add the plan LATERAL. Unfiltered count 14,667ms -> 93ms
(portfolio 796); provenance-filtered 156ms. getProperties is unchanged
(its LIMIT 1000 already bounds the LATERALs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:51:54 +00:00
Daniel Roth
4b0133ac5c
Merge pull request #327 from Hestia-Homes/feature/wall-insulation-jsonb
SQL correction for wall insulation thickness column migration
2026-06-24 16:08:49 +01:00
Daniel Roth
1b7d314fdd SQL correction 2026-06-24 15:06:43 +00:00