Phase 4: the portfolio landing page now renders the property table directly.
UPRN matching is confirmed during onboarding, so properties arrive matched and
the post-onboarding "Unmatched" tab is no longer needed (tab components left in
place pending a legacy-no-UPRN reconciliation follow-up).
Phase 5: AddressesPanel labels the new "low_score" status "Low-confidence match".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
At awaiting_review, when address2uprn flagged rows, OnboardingProgress now
shows two tabs: Classification (existing verify/unknown/ordering panels) and
Addresses. The Addresses tab (AddressesPanel) lists each flagged combiner row
with the OS Places matcher (reused search → residential candidates) and a
"No UPRN" action, saving corrections via useSaveUprnCorrection. Finalise is
gated on unresolvedAddresses === 0 with a matching disabled reason; the tab
carries a badge of the unresolved count.
tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dispatchFinaliser now:
- blocks (before the CAS claim) when any flagged combiner row is still
unresolved -> new "unresolved_addresses" outcome; finalize route returns 409.
- overlays the user's confirmed UPRNs onto the combiner CSV (buildConfirmedCombinerUri
writes a sibling .confirmed.csv) and points the finaliser at it, so a confirmed
UPRN drives identity + property_overrides in one pass. No corrections -> original
URI unchanged.
tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server module + two routes for the pre-finalise Addresses tab:
- getFlaggedAddresses: reads the combiner CSV from combinedOutputS3Uri,
returns rows address2uprn could not confidently match (empty UPRN or a
non-"matched" status), joined with any saved correction.
- upsertUprnCorrection: upserts a confirmed UPRN / no-UPRN per source_row_id.
- GET .../address-matches, POST .../address-corrections (thin, auth-guarded).
tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for the "Addresses" review tab: captures the user's confirmed
UPRN per combiner row, keyed by source_row_id (no property.id exists
pre-finalise). At dispatchFinaliser these are overlaid onto the combiner
CSV so the finaliser builds identity + property_overrides from the
confirmed UPRN in one pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The BulkUploadComingSoonModal (drag/drop upload, template download, header
validation → creates the upload and routes into the map-columns onboarding
step) existed but was never wired to anything. The "Bulk excel import" menu
item routed to the uploads list instead. Point it at the dialog so the bulk
upload onboarding starts from there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- MatchAddress: long OS addresses overflowed the candidate row (text column
wasn't width-constrained). Give it min-w-0 flex-1 and truncate both lines
(full address on hover) so the dialog layout holds.
- PortfolioTabs: when nothing is unmatched, render just the properties table
with no tab strip (the "Needs attention" tab only appears when count > 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turns the "Needs attention" tab into an actionable per-property match flow:
edit the address, confirm the postcode, search Ordnance Survey, and pick from
the RESIDENTIAL addresses in that postcode. Every candidate is a real OS
address, so picking one assigns a guaranteed valid, residential UPRN — the
property then leaves the tab and joins the main table.
- MatchAddress dialog: postcode search + residential-only candidate list
(non-residential filtered out; already-in-portfolio shown but non-selectable,
each row shows its UPRN). Replaces the edit-only EditAddress.
- PATCH /properties/[propertyId]: assigns uprn + address + postcode (user input
kept as the fallback); (portfolio_id, uprn) clash → friendly 409.
- client.ts: searchAddresses() + useAssignUprn().
Reuses the existing /properties/search endpoint, which read-through caches OS
Places results per postcode in postcode_search (30-day TTL) — repeat searches
of the same postcode hit our DB, not the OS API (surfaced as a "from cache"
hint in the dialog). No backend trigger needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the separation from a card stacked above the table into a proper
second tab, and takes unmatched properties out of the main list entirely:
- PortfolioTabs: "Properties" | "Needs attention" tab shell with a count badge
on the second tab. Both panels stay mounted (hidden with CSS) so the main
table keeps its filter/pagination state across tab switches.
- getProperties / getPropertiesCount now exclude uprn IS NULL, so unmatched
properties no longer appear (or count) in the main table until resolved.
(Both functions are only used by the main table's /api/properties route.)
- UnmatchedProperties: lives in the tab; shows an "all matched" empty state
instead of rendering nothing, and copy updated for the edit-only scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Add-properties menu had "Bulk excel import" disabled behind a "Soon" badge.
The bulk-upload flow exists (/portfolio/[id]/bulk-upload), so make the item
clickable and route to it, matching the "Search by postcode" item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening the building passport of an unmatched property 500'd: the whole page is
UPRN-keyed and getSpatialData did BigInt(propertyMeta.uprn) with a null uprn
("Cannot convert null to a BigInt"). Since a property with no UPRN has no
spatial data, installed measures, or EPC to show, render a clear "not matched
yet" state with a link back to the portfolio instead of crashing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each unmatched-property row now opens a small dialog to correct its address and
postcode, saved via a new endpoint. UPRN matching (the address search) is left
for a later change — this only fixes the address text so the record is right
before matching.
- PATCH /api/portfolio/[portfolioId]/properties/[propertyId]: auth + zod,
updates address/postcode scoped by (property, portfolio).
- properties/client.ts: useUpdateAddress() mutation.
- EditAddress: the edit dialog; on save the page re-fetches (router.refresh)
so the row reflects the new values.
- UnmatchedProperties: row action swapped from the placeholder to the live
Edit dialog; portfolioId threaded through from the page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The portfolio landing page listed every property in one table, mixing in the
ones that never matched to an Ordnance Survey UPRN. Those unmatched properties
need a different workflow (fix the address/postcode, then re-run the address→
UPRN search), so surface them separately above the main table.
This change does the separation only:
- getUnmatchedProperties(portfolioId): portfolio properties with uprn IS NULL
(excluding soft-deleted), falling back to the user-inputted address/postcode
when the canonical fields are empty.
- UnmatchedProperties: an amber "needs attention" card listing address +
postcode per row, with a placeholder "Search UPRN" action where the advanced
ARA search will hang off. Renders nothing when every property is matched, so
healthy portfolios stay uncluttered.
- Portfolio page renders the section above the existing PropertyTable, which is
left exactly as-is.
Follow-up (the actual feature): make address/postcode editable per row and wire
the "Search UPRN" button to the advanced ARA search.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
"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>
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>