Commit graph

1671 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
06ad4eec0d Merge remote-tracking branch 'origin/main' into feature/bulk-trigger-modelling 2026-07-07 11:28:34 +00:00
KhalimCK
668588d3ce
Merge pull request #361 from Hestia-Homes/chore/tasks-inputs-column
feat(db): add inputs column to tasks
2026-07-07 12:27:40 +01:00
Jun-te Kim
c0087e08c3
Merge pull request #356 from Hestia-Homes/feat/landlord-overrides-nine-categories
feat(landlord-overrides): wire all 9 override categories through the app layer
2026-07-07 12:04:26 +01:00
Khalim Conn-Kowlessar
83117e90c5 feat(modelling-runs): run config on tasks.inputs; backend contract updates
- The run's config now lives on the task row's new `inputs` column
  (migration PR #361) instead of a config subtask — sub_tasks belong
  entirely to the distributor's execution work.
- Distributor path corrected to /v1/modelling/trigger-run.
- Sub-task granularity is batches, not properties: history shows progress
  as a percentage under the status chip; the Plans column always shows the
  plan count (batch counts are never comparable to property/plan numbers).
- ADR-0008 amended accordingly.

Live verification of the task write waits for PR #361 to be applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:46:29 +00:00
Khalim Conn-Kowlessar
fe521aa8d5 feat(db): add inputs column to tasks
Mirrors sub_task.inputs (JSON as text): the task's request inputs. For a
modelling run (ADR-0008, feature/bulk-trigger-modelling) the task row itself
carries the run's config — portfolio id, scenario ids, filters — with
sub_tasks holding the per-property execution work. Column-only change so it
can be applied to the database ahead of the feature merging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:31:11 +00:00
Daniel Roth
27254e7622
Merge pull request #357 from Hestia-Homes/feature/hubspot_deal_data_migration
add client booking reference to hubspot deal data
2026-07-07 09:47:01 +01:00
Khalim Conn-Kowlessar
10888e3fff refactor(modelling-runs): minimal dispatch payload
The distributor gets {task_id, portfolio_id, scenario_ids, filters} only —
previewed count and triggered_by are app provenance, already durable on the
config subtask keyed by the same task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:40:09 +00:00
Khalim Conn-Kowlessar
285d8a38a9 feat(modelling-runs): compute preview counts in-app, drop preview-run ask
previewModellingRun now resolves Run filters in one grouped SQL statement
(override snapshot -> EPC-derived via propertyTypeSql/new builtFormTypeSql
-> Unknown) and intersects the matched set with plan for per-scenario
already-modelled counts. The Model-backend ask shrinks to the distributor
alone; the app's preview is the reference implementation of the resolution
rule (ADR-0008 amended).

Verified live against 814: all=340 matched / 338 already modelled in
scenario 1271; B93 8SU=2 resolving House/Detached through override
snapshots; Bungalow/Unknown=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:33:41 +00:00
Khalim Conn-Kowlessar
6c479542a0 feat(modelling-runs): trigger journey — server module, routes, run page
Builds the bulk-trigger-modelling journey per ADR-0008 on the TDD'd core:

- Server module: triggerModellingRun (app-authored task + config subtask,
  BulkUpload trigger convention; dispatch to /v1/plan/trigger-run; failed
  dispatch marks the task failed), previewModellingRun (dry-run count proxy
  to /v1/plan/preview-run), listModellingRuns (one grouped portfolio-scoped
  query; config parsed back, progress from execution-subtask counts),
  scenarioIdsWithActiveRuns (third badge state).
- Routes: POST/GET /api/portfolio/[id]/modelling-runs, POST .../preview,
  GET /api/portfolio/[id]/postcodes (grouped distinct + counts).
- Page /portfolio/[slug]/modelling/run to the approved mockup in the
  scenarios-tab design language: scenario picker with the three badges and
  in-flight warning, three filter columns (postcodes from DB, canonical
  enums + Unknown), engine-counted summary tray with re-model callouts,
  large-run confirm (10k gate), done state with ETA, Recent runs table
  with live progress. TanStack Query v4; no useEffect/useMemo.
- "Run modelling" button beside Add properties in PropertyTable.

Backend contract (Model team): the distributor at /v1/plan/trigger-run
accepts {task_id, portfolio_id, scenario_ids, filters, ...} and attaches
execution subtasks to the given task; /v1/plan/preview-run returns
{matched_properties, per_scenario[]} through the same resolution code path.

Verified live against portfolio 814: postcodes, validation errors, unknown
scenarios, dispatch failure marks the task failed and history shows it;
simulated backend execution subtasks drove status to
{in_progress, ready 2, total 4} and the scenario badge to "Modelling in
progress" in the rendered page; portfolio page shows the button. tsc, lint
and all 365 vitest tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:08:57 +00:00
Khalim Conn-Kowlessar
35eb681f8f feat(modelling-runs): pure domain core — filters, run record, status, bounds
TDD'd per ADR-0008: normaliseRunFilters (canonical postcodes, enum
vocabularies + Unknown, 40-postcode cap, absence = all), buildRunRecord /
parseRunConfig (app-authored task + config-subtask inputs, null-safe
round-trip), selectionSummary, deriveRunStatus (dispatched / in-progress
with subtask progress / complete / failed), isLargeRun (10k gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:51:55 +00:00
Jun-te Kim
54cc71366b feat(landlord-overrides): wire all 9 override categories through the app layer
The DB schema, value enums, and migrations already defined all nine landlord
override categories, but the application layer was hardwired to the original
four (property_type, built_form_type, wall_type, roof_type). The other five
(main_fuel, glazing, construction_age_band, water_heating, main_heating_system)
were classified and written to their tables by the backend, but the frontend
never read them or offered edit dropdowns — so the verify panel rendered them
as "not classified", and any Unknown in those categories slipped past the
ADR-0006 finalise gate.

Replace the per-category switch statements (duplicated across ~6 sites) with a
single CATEGORY_TABLES registry (categories.ts) as the source of truth. Adding a
category is now one registry row. A completeness test asserts the registry,
CLASSIFIER_FIELDS, and the property_overrides override_component enum never
drift apart — the exact hole that left five categories unwired.

- categories.ts: registry + derived CATEGORY_VALUES + isLandlordCategory guard
- landlordOverrides/server.ts: getLandlordOverrides reads all 9 (fixes the
  latent crash where the page mapped 9 fields against a 4-key result)
- bulkUpload/server.ts: lookupOverrides / unknownForField / getUnknownOverrides
  / upsertUserOverride / validation now registry-driven, all 9
- OnboardingProgress.tsx: edit dropdowns use the shared 9-category CATEGORY_VALUES
- landlord-overrides page: SourceBadge now renders os_places (ADR-0007) distinctly
  instead of mislabelling it "classifier"

Enum value sets are already in sync FE<->Model (verified); only member order
drifts on 3 enums, which is cosmetic and left untouched (Postgres cannot reorder
enums). Follow-up (backend): Model's override_source SAEnum still lacks os_places.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 16:29:07 +00:00
Khalim Conn-Kowlessar
c7dfe95e15 docs(adr): run record is an app-created task, not a new table
Amends ADR-0008 after challenge: the Modelling run reuses the BulkUpload
trigger convention (app inserts the tasks row + config subtask with inputs
JSON, passes task_id to the distributor) instead of a dedicated
modelling_run table. No migration; status and progress come from the task
system by construction; dispatch failure marks the task failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:27:21 +00:00
Khalim Conn-Kowlessar
adcc9b8b83 docs(adr): record run-completion email notification as a known follow-up
The dispatch confirmation sets a time expectation only; the email promise
stays out of the UI until a completion notification exists (run_id -> task
correlation provides the signal to build it on).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:11:08 +00:00
Daniel Roth
5fb1f92d08 migration files 2026-07-06 15:46:41 +00:00
Daniel Roth
d49042eb7e add client booking reference to hubspot deal data 2026-07-06 15:46:18 +00:00
Khalim Conn-Kowlessar
e58a90a17b docs: ModellingRun + Run filter language and ADR-0008 (filters to distributor)
Grilling-session outcomes for the bulk-trigger-modelling feature: the
ModellingRun/Run-filter glossary entries and the decision record — insert-only
run rows, filters (not ids) to the distributor lambda, backend-owned filter
resolution with a shared-code-path count endpoint, run_id correlation into
task records for derived in-flight state, warn-don't-block concurrency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:45:43 +00:00
KhalimCK
c861d4a0b0
Merge pull request #353 from Hestia-Homes/feature/portfolio-scenarios
feat(scenarios): portfolio Scenarios tab — gallery, detail, and creation journey
2026-07-06 16:21:21 +01:00
KhalimCK
08fcb77734
Merge pull request #355 from Hestia-Homes/feature/add-properties-by-postcode
feat: add properties by postcode search
2026-07-06 16:21:07 +01:00
Khalim Conn-Kowlessar
b2d91504f0 docs(context): postcode-search entry + VocabularyMapping's second producer
Left uncommitted by the previous session; restores the "### Building parts"
heading it had accidentally swallowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:17:22 +00:00
Khalim Conn-Kowlessar
158d5ded92 fix(postcode-search): PR #355 review feedback
- Order address candidates naturally so Flat 2 sorts before Flat 10
  (TDD'd in the domain core; numeric locale compare).
- Disable spell check / autocomplete / autocorrect on the postcode input.
- Long result lists and the tray's postcode chips now scroll inside
  their containers instead of growing the page.
- gitignore docs/wip/** and untrack the postcode-search handover doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:13:49 +00:00
Khalim Conn-Kowlessar
8a0ad0c0dc feat(postcode-search): routes, add-properties page, PropertyTable entry point
Completes the add-properties-by-postcode journey (ADR-0007) on top of the
TDD'd domain core:

- GET /api/portfolio/[id]/properties/search?postcode= — postcodes.io for
  validation + geography (admin_district, parliamentary_constituency) every
  time; OS Places through the 30-day postcode_search read-through cache
  (refresh=1 bypasses it). Returns candidates with alreadyInPortfolio flags
  from one grouped uprn query. Cache reads accept legacy space-less keys;
  writes use the canonical spaced form.
- POST /api/portfolio/[id]/properties — re-derives facts server-side from
  classification codes (client propertyType never trusted), then one
  transaction: property rows via uq_property_portfolio_uprn ON CONFLICT DO
  NOTHING, vocabulary upserts with os_places provenance (DO NOTHING preserves
  user rows; snapshots mirror the resolved value), property_overrides
  snapshots (building_part 0, original description = OS code), and
  property_details_spatial per-property coordinates. Returns {added, skipped}.
- OS Places fetches now request output_srs=EPSG:4326 so records carry
  LAT/LNG (no mapper read the BNG X/Y coordinates).
- /portfolio/[slug]/properties/add built to the approved mockup: search card
  with cache-age line, per-postcode result cards, cross-postcode selection
  tray, done state. TanStack Query v4, no useEffect/useMemo.
- "Add properties" dropdown beside Export in PropertyTable (search by
  postcode + bulk excel import "Soon"); toolbar AddNew dropdown retired.
- New display/cache helpers (describeCandidate, postcodeCacheKeys,
  cacheAgeDays) TDD'd in the domain core.

Verified live against portfolio 814: search (cache + refresh), submit
(added 2, all four write layers checked in DB), dedup re-submit (skipped 2),
alreadyInPortfolio flags, page + dropdown render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:33:22 +00:00
Khalim Conn-Kowlessar
463e17debc Merge remote-tracking branch 'origin/main' into feature/add-properties-by-postcode 2026-07-06 14:04:30 +00:00
KhalimCK
03a427193f
Merge pull request #354 from Hestia-Homes/chore/override-source-os-places
feat(db): add os_places to override_source enum
2026-07-06 14:52:24 +01:00
Khalim Conn-Kowlessar
c8c00b87b5 feat(db): add os_places to override_source enum
New provenance value for the deterministic OS Places classification-code
mapping written by the postcode-search journey (ADR-0007, on
feature/add-properties-by-postcode). Enum-only change so it can be
applied to the database ahead of the feature merging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:46:33 +00:00
Khalim Conn-Kowlessar
302e200ed6 docs: handover for postcode-search build continuation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:38:54 +00:00
Khalim Conn-Kowlessar
5b224677dc feat(postcode-search): pure domain core — normalisation, OS-code mapping, write plan
TDD'd (9 tests): postcode normalisation to canonical form; the ADR-0007
deterministic OS classification mapping (RD02/03/04/06/07; other
residential codes selectable with no facts, Unknown never written;
RD10 + non-RD unselectable); 30-day cache freshness for postcode_search;
DPA-preferred UPRN-deduped address candidates with postcode-stripped
title-case addresses; buildWritePlan producing the property row
(incl. local_authority/constituency from postcodes.io — closing the
no-source gap) and 0-2 property_overrides facts keyed to the OS code.

Adds 'os_places' to OverrideSourceValues — enum migration still needs
generating via generate_migration.sh before deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:32:18 +00:00
Khalim Conn-Kowlessar
b86efddfdf chore(adr): stop gitignoring docs/adr; add ADR-0007 for postcode-search property creation
The docs/adr/** ignore rule silently ate ADRs 0004-0006 that CONTEXT.md
still references. Remove it so decisions actually land in the repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:07:43 +00:00
Khalim Conn-Kowlessar
a12215b839 feat(scenarios): sign-post disruptive measures in the creation journey
Internal wall and floor insulation (and room-in-roof) involve major
internal works, and landlords often exclude them. Encode that domain
knowledge once as DISRUPTIVE_MEASURES in the scenario model (with
plain-language reasons; guarded by a key-validity test) and surface it
three ways:

- measures step: allowed disruptive chips carry an amber "disruptive"
  tag + amber border, with the reason in the tooltip; a legend explains
  the marking
- "Low disruption" quick-start now excludes exactly this set, so the
  preset and the badges cannot drift apart
- review step: an amber nudge lists any disruptive measures still
  allowed before saving

New CONTEXT.md term: Disruptive measure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:34:51 +00:00
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
Daniel Roth
b94feae398
Merge pull request #352 from Hestia-Homes/hs-number_of_attempts
Add numberOfAttempts to hubspot deals table
2026-07-03 12:16:59 +01:00
Daniel Roth
cd8a43c798 migration files 2026-07-03 10:38:25 +00:00
Daniel Roth
a6c642966e add number of attempts to hubspot deals table 2026-07-03 10:38:09 +00:00
Jun-te Kim
f788c858c6
Merge pull request #350 from Hestia-Homes/fix/plan-recommendations-rec-id-index
Add index on plan_recommendations.recommendation_id to Drizzle schema
2026-07-02 17:37:49 +01:00
Jun-te Kim
8e39c00ec8 Add journal-tracked migration 0258 for the recommendation_id index
Generated with drizzle-kit generate, then hand-edited to IF NOT EXISTS:
the index already exists on the live DB (created CONCURRENTLY on
2026-07-02), so the migration must no-op there while still building the
index in fresh environments. Snapshot + journal are updated so the next
generated migration doesn't re-emit this index.

Applying it (drizzle-kit migrate) remains a human action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:30:42 +00:00
KhalimCK
96245a05af
Merge pull request #349 from Hestia-Homes/fix/reporting-carbon-effective-baseline
fix(reporting): negative scenario carbon impact — move carbon fragments to effective baseline
2026-07-02 17:29:08 +01:00
Jun-te Kim
b49fbc4265 Add index on plan_recommendations.recommendation_id to Drizzle schema
Plan deletes cascade to recommendation rows, and each cascaded delete
fires an FK check against plan_recommendations.recommendation_id. With
~26M rows and no index on that column the check seq-scans per row,
making any plan deletion path time out.

The index already exists on the live DB (created CONCURRENTLY on
2026-07-02 as idx_plan_recommendations_recommendation_id, 74s build);
this brings the Drizzle schema in line so it stops drifting. No
migration is included — generating/applying it is left as a human
action, and it must reuse the existing index name (IF NOT EXISTS) so
it no-ops against prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:24:57 +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
Jun-te Kim
767cb2af3f
Merge pull request #348 from Hestia-Homes/reporting-slow-query
amend migration files
2026-07-02 14:50:24 +01:00
Daniel Roth
8ebb0d7fec amend migration files 2026-07-02 13:48:46 +00:00
Jun-te Kim
32bf005c80
Merge pull request #347 from Hestia-Homes/reporting-slow-query
perf(db): index the EPC-graph FK columns hammering reporting
2026-07-02 14:45:40 +01:00
Daniel Roth
03ba8400ff Build the new indexes CONCURRENTLY (matches 0141)
The DB is under load; a plain CREATE INDEX blocks writes for the
duration of each build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 13:41:12 +00:00
Daniel Roth
7eb2705817 migration files 2026-07-02 13:39:05 +00:00
Daniel Roth
4956a95574 add db indexes 2026-07-02 13:38:42 +00:00
KhalimCK
ccd32fc6bb
Merge pull request #322 from Hestia-Homes/feature/update-ui-for-new-modelling
Feature/update UI for new modelling
2026-07-02 12:50:18 +01: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
Daniel Roth
007ee3cad4
Merge pull request #345 from Hestia-Homes/feat/glazing-secondary-enum
Add 'Secondary glazing' to the glazing pgEnum
2026-07-02 12:18:30 +01:00
Jun-te Kim
4ec648fb1a Add 'Secondary glazing' to the glazing pgEnum
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>
2026-07-02 10:55:36 +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