Adds row multi-select to the property table (new checkbox column + stable
id-keyed selection surviving pagination) with a bulk bar that tags the
selection ({mode:"properties"}) or every property matching the active filter
({mode:"filter"}, whole set resolved server-side). Plus a Bulk tag assignment
upload modal: client-side SheetJS → parseTagIdentifierRows → pick a target tag
→ {mode:"identifiers"} → match summary (matched / already-tagged / unmatched).
Selection is filter-scoped and clears on filter change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "Tags" field to PropertyFilters with per-portfolio options fetched
via GET /tags plus a synthetic "Untagged" bucket. Emits enum_one_of with
value = JSON of tag ids (+ "__untagged__"); the server SQL already resolves
this (any-of, ADR-0013). Condition chips label by tag name and render
"(deleted tag)" for dangling ids.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chips with "+N" overflow in a default-visible, toggle-able column; the
cell doubles as an assign popover that toggles membership via
POST …/assignments {mode:"properties"}, optimistic across every cached
property-table page and reconciled on settle. Freeform-hex chips get an
auto-contrast foreground (new pure colour util, TDD). Shared usePortfolioTags
hook keeps settings/table/filter in sync.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend, end to end (ADR-0013):
- getProperties now returns each property's tags (name-ordered JSON); the
PropertyWithRelations type gains a `tags: PropertyTag[]` field.
- A "tags" property-table filter (EXISTS/NOT-EXISTS on property_tag, any-of +
an "Untagged" bucket) — PK-indexed by property_id, so no join added.
- getFilteredPropertyIds resolves the whole matching set for filter-driven Bulk
tag assignment; the assignments route's `filter` mode uses it.
- Run filter: previewModellingRun joins property_tag for tag_ids; the two
modelling routes accept tagIds. Distributor ask documented in
docs/backend-asks/tag-ids-run-filter.md.
UI:
- Settings → Tags: create (name + colour), edit, delete-with-count-confirm,
list with property counts (TanStack v4). Sidebar link added.
Typecheck clean; 431 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Run filter gains an optional tag_ids key (ADR-0013): normaliseRunFilters
coerces/dedupes ids, buildRunRecord + parseRunConfig round-trip it, and
selectionSummary counts it. Pure, TDD (9 new cases).
- parseTagIdentifierRows (pure, TDD): a CSV/Excel 2D array → the identifier
column to match on (landlord_property_id or uprn, header-detected), trimmed,
deduped, capped at 10k.
- Routes (write-gated via shared getPortfolioRole/canWriteTags):
GET/POST /tags (list w/ counts, create), PATCH/DELETE /tags/[id] (edit,
delete+cascade), POST /tags/[id]/assignments (properties add/remove +
identifiers bulk-match with a summary). Names validated by validateTagDraft.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
validateTagDraft: trims + requires a name, caps it at 30 chars, rejects a
case-insensitive duplicate against the portfolio's existing tag names (the
caller excludes the tag's own name on edit), and validates + lowercases a
freeform hex colour (#rgb or #rrggbb). The DB's case-insensitive unique index
remains the ultimate guard; this gives a friendly pre-check for the tag routes
and settings UI. 8 behaviours, built red-green. ADR-0013.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures the grill-with-docs outcome: flat app-owned Tags, four assignment
paths (incl. in-app bulk upload, no FastAPI), tags as a Run-filter key extending
ADR-0008, hard-delete + cascade, reporting-by-tag deferred to the reporting
redesign. Design only — no implementation yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flat, portfolio-owned, many-to-many label for grouping Properties for bulk
actions. Captured during the tagging grill-with-docs session.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The schema substrate for the portfolio tagging system (ADR-0013): a flat,
portfolio-owned Tag (name + freeform hex colour, case-insensitively unique per
portfolio) and the many-to-many property_tag membership. Both FKs cascade so a
tag delete drops its memberships and a property/portfolio delete drops its tags.
A tag_id-leading index backs the reverse lookup (chips, filter, run resolution).
Migration only — not yet applied to prod. Feature code stacks on this branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Updates the portfolio Property-table flagged-ambiguity note for the two changes
in the previous commit: Main Fuel now reads epc_main_heating_detail beneath the
override (no longer override-only for new-approach), and Wall/Roof/Heating are
now filterable via coarse prefix/substring buckets while still displaying the
full free-text description.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The portfolio list resolves Main Fuel per row via a correlated subquery on
epc_main_heating_detail.epc_property_id (mainfuelSql, ADR-0012). The table has
no index on that column, so the subquery seq-scans it once per property in the
count/export path. At ~41k rows it stays in memory (a 32k-property Main-Fuel
count measures ~1.2s), but this index makes it an index probe (~50ms) and
guards against larger new-approach portfolios.
Migration only — not yet applied to prod. Companion to PR #384.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Main Fuel no longer reads "Unknown" for new-approach properties. The new EPC
graph stores the main fuel as a JSONB scalar on
epc_main_heating_detail.main_fuel_type — a numeric RdSAP main_fuel code (~95%)
or an EPR text string (~5%). mainfuelSql now reads it (lodged over predicted)
instead of NULL; resolvedMainFuelSql keeps the override → EPC → 'Unknown'
precedence. The RdSAP code table (Model epc_codes.csv, cross-checked against
prod) + the EPR strings fold into MAINFUEL_OPTIONS, so the column label and the
filter work unchanged. resolveMainFuel gains the EPC branch as its pure twin;
coverage tests assert every code + string + override value is folded.
Verified: portfolio 824 now resolves code 26 → "Mains Gas".
Wall Type / Roof Type / Heating System become filterable. Their resolved values
are free-text (override vocab / epc_energy_element.description / legacy column)
with high cardinality, so exact dbValues can't scale — each coarse bucket is a
set of case-insensitive needles matched by prefix (wall/roof) or substring
(heating). classifyDescriptor is the pure twin of the SQL bucketing in
buildConditionSql; the "Unknown" bucket matches the complement of every known
needle. Buckets validated against the full prod vocabulary (heating Unknown =
28/640k rows). Wired through FilterField, FIELD_OPTIONS, buildConditionSql,
EPC_JOIN_FILTER_FIELDS and the count-query override joins; columns stay
free-text display. Coverage tests assert every Wall/Roof/MainHeatingSystem
override value buckets to a real, non-Unknown label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three property-list improvements:
1. Fetch 250 rows initially, not 1000 (DEFAULT_LIMIT/DISPLAY_LIMIT). The table
paginates at 7/page and isn't bulk-scrolled, so the join-heavy query stays
fast; load-more still pulls the rest on demand.
2. Export now fetches its own batch (up to EXPORT_LIMIT) for the current filters
instead of dumping the on-screen rows — so the small display fetch doesn't cap
it — and the cap is raised 1000 -> 6000. Export button shows a loading state.
3. Add Wall Type / Roof Type / Heating System optional columns, resolved through
the same override precedence as the others (override -> EPC epc_energy_element
description, lodged over predicted -> legacy property_details_epc -> 'Unknown').
Per-part components (wall/roof) read the main building part. New generic
resolveOverrideDescriptor (+5 tests) with SQL twins in epcSources. Columns are
display-only (no filter — the wall/roof override vocab is too large for chips)
and included in the CSV export.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The property-list query (getProperties) resolves each property's latest default
plan via a LATERAL. The existing plan indexes lead with (portfolio_id,
scenario_id, …); with scenario_id unconstrained the lookup scans the whole
portfolio's plans per property. On portfolio 796 (~32k properties) EXPLAIN ANALYZE
showed this LATERAL at 11.5s of an 11.9s query (2.97M shared buffer hits,
11.5ms × 1000 loops).
idx_plan_property_default_latest leads with property_id (partial on is_default)
so the lookup becomes a direct index seek + LIMIT 1 — expected ~12s -> sub-second.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 6. The override → EPC-derived → 'Unknown' rule was inlined in
previewModellingRun and duplicated by the portfolio list. Replace the inline
COALESCE expressions and property_overrides joins with the shared
resolvedPropertyTypeSql / resolvedBuiltFormSql / *OverrideJoin fragments — a
byte-identical substitution — so the modelling preview and the list are provably
in sync (the divergence ADR-0008 warns about is now structurally impossible).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 5. Tenure was broken for new-approach (read p.tenure, NULL) but has no
override component and its epc_property.tenure values don't reconcile with the
filter dbValues, so it's removed from the list rather than fixed (ADR-0012):
the optional column, the filter field, TENURE_OPTIONS, and the CSV column. The
per-property building passport (resolvePropertyMeta/resolvePropertyDescriptors)
still resolves tenure, and property.tenure is retained.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 4. The new EPC graph exposes no main-fuel string (documented gap), so for
new-approach properties the landlord override is the only source. resolveMainFuel
returns the raw value (override → legacy EPC → 'Unknown'); the cell's
resolveEnumLabel folds it to a chip. MAINFUEL_OPTIONS.dbValues extended with the
override vocabulary so overridden fuels are both displayable and filterable —
guarded by a coverage test over MainFuelValues. resolvedMainFuelSql mirrors it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 3. Construction age is modelled as an RdSAP band, not a year (ADR-0012) —
the only representation that faithfully carries an 'Unknown' override. resolve-
ConstructionAge owns override(letter)→label / EPC band(letter)→label / legacy
year→band bucketing / 'Unknown', with 10 unit tests incl. band boundaries.
resolvedConstructionAgeSql mirrors it (emits the band label). YEAR_BUILT_OPTIONS
becomes the band labels + Unknown, guarded against label/en-dash drift by a new
epcSources.test. Column + filter + CSV header renamed to 'Construction Age'
(internal 'yearBuilt' key retained to avoid a wide rename).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 2. Same shape as slice 1: resolveBuiltForm + builtFormOverrideJoin /
resolvedBuiltFormSql, wired into getProperties SELECT, the filter colMap, and the
count query's join set (builtForm added to EPC_JOIN_FILTER_FIELDS). BUILT_FORM_OPTIONS
gains Unknown (Not Recorded now matches only an explicit override of that literal).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 1 of the portfolio-list descriptor fix. Property Type was read from the
raw property row (p.property_type), which is NULL for new-approach properties
(updated_at >= 2026-06-01), so the column and filter broke for new portfolios.
- New pure resolver resolvePropertyType (descriptorResolution.ts, mirrors the
provenance.ts split) owning override → EPC-derived (lodged over predicted,
RdSAP codes mapped, legacy fallback) → 'Unknown', with 9 unit tests.
- SQL twin resolvedPropertyTypeSql + propertyTypeOverrideJoin in epcSources.ts.
- Wired into getProperties SELECT, the filter colMap, and the count query's
conditional join set (propertyType added to EPC_JOIN_FILTER_FIELDS).
- PROPERTY_TYPE_OPTIONS gains Park home + Unknown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records the decision to make the portfolio property-list override-aware for
Property Type / Built Form / Construction Age / Main Fuel, extending ADR-0008's
override → EPC-derived → Unknown precedence to a second surface via shared
epcSources fragments. Adds a CONTEXT.md Flagged-ambiguities note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The plain bordered checkbox blended into the Measures step. Give it the
emerald "Fabric first" identity used by the list/detail indicators — a
layers icon, an "Optimisation order" eyebrow, and a state-reactive tint
so the same green signals fabric-first across create, list and detail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the fabric-first constraint to the app-authored scenario flow:
- Create: a "Fabric first" toggle in the Measures step (Step 2) with an
explanation, mirrored in the Step 3 review and carried through the
"Use as template" (?from=) flow.
- List card: a "Fabric first" chip in the constraints box, shown only
when the scenario is fabric-first.
- Detail page: an always-shown "Fabric first" config row (chip +
explanation, or "Off").
Domain layer (TDD): validateScenarioConfig carries the flag and defaults
it to false; scenarioToInsertRow persists it; findExactDuplicate treats
it as part of the immutable config identity, so two scenarios differing
only in fabric-first are distinct. Threaded through ScenarioListItem,
both query mappers, the create route, and the new-scenario loader.
Backend already reads scenario.fabric_first for id-based modelling runs,
so no dispatch changes are needed.
Docs: adds a "Fabric first" glossary entry to CONTEXT.md, softens the
stale "only measure-constraint" line, and pins the distinction from the
"Fabric only" quick-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `fabric_first boolean NOT NULL DEFAULT false` to the scenario table
via the drizzle model, with the generated migration (0268).
Migration to be run via `npm run migration:migrate` once merged to main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7 Leyton Avenue (UPRN 100031252876) came back from OS Places as RH03
("HMO Not Further Divided") and rendered as "Non-residential", so it
couldn't be added from the postcode-search "add properties" journey.
classifyOsCode decided "residential?" purely on an RD prefix, dropping the
whole RH (HMO) family — even though RD07 (the RD-family HMO code) was
already mapped to House. Extend the residential check to RH and map
RH01/RH02/RH03 to House (built form unknowable), consistent with RD07.
Updates ADR-0007's enumerated mapping table to stay truthful.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The status poll only declared "ready" when it could parse presigned_url out
of sub_task.outputs, so a finished job whose link used a different key (or
whose completion was only reflected in status) sat on "preparing" forever.
- Derive terminal state from completion/failure *status* (task or sub_task,
incl. jobCompleted), not just the parsed link.
- Tolerate link-key variants (presigned_url / presignedUrl / download_url /
url; package_s3_key / s3_key).
- When completed but the link can't be surfaced in-app, show a "ready — check
your email" card instead of hanging.
- Ease the poll cadence to 8s (assembly takes minutes; email is the real
channel).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The header tick now selects the current page (its intuitive meaning). When
the whole page is ticked and more filtered rows exist, a Gmail-style banner
offers "Select all N across pages" (and "Clear selection" once everything
is selected) — so users can grab a page-sized chunk without selecting the
entire project.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add eight nullable text columns to the hubspot_deal_data table to hold
planning and address-profiling attributes synced from HubSpot:
design_constraints, planning_comments, planning_status,
planning_suggested_approach, planning_authority, designated_area,
article_4_pd_rights, listed_building
Typed as text to match how the table already stores HubSpot
dropdown/enum-style properties (e.g. dampmould_growth, domna_survey_type).
Migration: 0267_add_hubspot_deal_planning_columns.sql
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add bulk document download as a row-selection flow on the Live Reporting
Documents tab: a "Bulk download" button flips the existing DocumentTable
into selection mode (per-row tickers + select-all across all filtered
pages); "Download selected" zips those properties' documents and emails a
link (shown in-app while the page stays open). Scoped by the tab's
existing Project/group selector.
Two-step trigger (ADR-0008 convention): insert an app-owned tasks row
(task_source "app:bulk_document_download", selection config JSON in
tasks.inputs, source_id = portfolio) then dispatch only { task_id } to
POST /v1/documents/bulk-download. Documents are matched by
landlord_property_id, so the ticked ids ARE the selection — a property
with no landlord id has no documents and is un-tickable.
Resilience: the client now parses responses defensively (a platform 504's
HTML body no longer surfaces as "Unexpected token 'A'…"), and the POST
route sets maxDuration=60 so the synchronous backend resolution has
headroom before the browser gets a 504.
Docs: CONTEXT.md gains Project + Bulk document download; ADR-0011 records
why the marker lives in task_source (worker re-reads the task) vs service.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The measures aggregate is heavy on large portfolios (434). Auto-firing it
on mount meant every reporting-page load spawned the query; when it exceeded
the function timeout the Vercel function was killed but the Postgres query
kept running, so repeated loads piled up zombie queries, saturated the
connection pool, and started timing out the reporting page itself
(app-wide cascade).
The 'Where the money goes' panel now loads on demand: a 'Show spend
breakdown' button triggers the query (enabled:false until clicked, retry:
false), with an explicit Retry on error. A slow breakdown can no longer
knock over the page. Pairs with the covering index (#374) which makes the
query itself fast once applied.
tsc clean, reporting tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reporting 'where the money goes' panel joins the latest plan per
property to its recommendations ON plan_id, filters to the default active
set, and sums estimated_cost / counts property_id per measure_type. The
only plan_id index (idx_recommendation_plan_id) is plain, so the aggregate
heap-fetches every recommendation to apply the filter and read those
columns — hundreds of thousands of fetches on large portfolios (434),
which times the panel out.
idx_recommendation_plan_default carries plan_id + measure_type +
property_id + estimated_cost as key columns (drizzle-orm has no INCLUDE),
partial on default = true AND already_installed = false, so the aggregate
runs index-only. Added to the schema and generated via drizzle-kit.
OPS: build CONCURRENTLY out-of-band on prod before the migration runs so it
no-ops there (0258 pattern); the migration uses IF NOT EXISTS for fresh
environments. See the migration file header.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Compare view: columns are no longer frozen to the first 4 scenarios.
Add/remove columns (up to 4) via an 'Add scenario' popover and a per-column
remove button — the compare page's core action now works. (Critique P1.)
- Money formatting: deleted the local moneyShort, routed the allocation bar
through the shared moneyCompact so '£2.48m' and the ledger's full figures
come from one rule. (Critique P2.)
- Drill shelf scrolls into view on open (in the handler, not an effect;
reduced-motion aware) so a click below the fold isn't inert.
tsc clean, 446 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the P1 accessibility cluster from the impeccable critique (WCAG
AA is a stated procurement requirement):
- Focus: one scoped [data-reporting] :focus-visible outline in globals.css
covers every control (was 0 focus styles across 17 buttons); EPC ladder
bands restructured from display:contents (no focus box) to real focusable
grid-row buttons with aria-pressed.
- Contrast: swept content-bearing text-gray-400 (~2.5:1) → text-gray-600
(~7:1) across the surface (43 occurrences) — KPI labels/units, scenario
sublines, data-quality definitions.
- Semantics: ScenarioCombobox gains role=listbox/option, aria-selected,
aria-expanded, aria-haspopup, labelled search; filter toggles get
aria-pressed and their ⓘ tooltip triggers become focusable buttons
(were non-focusable spans); ledger ⓘ likewise.
- States: role=alert on the drill/measures error messages; reduced-motion
disables the loading-skeleton pulse (scoped media query).
- Colour-only cue: compare 'best' cell is now a 'Best' tag, not a 0.5rem
green dot.
tsc clean, 446 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The filter chips were opaque (no explanation of what they do) and the
compliance-window chip was a static toggle — its band and date couldn't be
changed, despite the grilling decision that both should be configurable.
- Each filter now carries a plain-language ⓘ tooltip and a clearer label:
'Hide non-compliant homes' → 'Only homes that reach target';
'Measure against lodged EPCs' → 'Judge need by lodged EPC'.
- The compliance-window chip opens a popover: an on/off checkbox, an EPC
band select (A–G, 'or better'), and a date picker for the cut-off, with a
sentence explaining the filter. Label reflects the live config
('Skip homes compliant past 1 Jan 2030 at C+').
tsc clean, 446 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getReportingScenarios uses raw db.execute, which returns created_at as a
string (Drizzle .select() returns a Date). Calling .toISOString() on it
threw 'created_at.toISOString is not a function' and 500'd
/portfolio/[id]/reporting.
Extracted the row→ReportingScenario mapping into a pure, tested module
(reportingScenarios.ts, toReportingScenario) that normalises created_at
whether it arrives as a string or a Date, and covers the modelled-status
and bigint-id / null-name cases. databaseFunctions re-exports the type.
TDD: 4 tests; full suite 446 green, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The redesign swapped the reporting page's lightweight scenario fetch for
listScenariosWithStatus, whose COUNT(DISTINCT property_id) GROUP BY
scenario_id scans and de-dupes every plan row — and ran it serially after
the baseline batch. On large portfolios (e.g. 434) that blew the 15s
function timeout.
New getReportingScenarios: config + a cheap existence-based modelled status
(DISTINCT scenario_id, index-only on idx_plan_portfolio_scenario — reporting
needs the boolean, not the per-scenario property count). Reporting, compare
and PDF pages now use it, and the reporting page runs all four fetches in one
Promise.all instead of awaiting scenarios separately.
listScenariosWithStatus is unchanged — the scenarios gallery still needs the
counts for delete-gating (ADR-0003).
tsc clean, 442 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1) 'Where the money goes' now loads through a server action
(loadScenarioMeasures → shared queryScenarioMeasures, server-only) instead
of a client fetch to an API route: the query runs server-side with no HTTP
round-trip and background-loads behind an animated skeleton while the rest
of the page stays interactive. Shared query handles both a real scenario
(scenario_id) and the recommended pseudo-scenario (is_default). The two now
dead measures API routes are deleted.
2) Drill-down shelf was too tall — it listed 25 homes. Reduced to an 8-row
compact preview (DRILL_PAGE_SIZE); the footer paginates and 'Open in
property table' remains the full-list escape hatch.
tsc clean, 442 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measures query: join recommendations on plan_id alone (dropping the
redundant property_id predicate so idx_recommendation_plan_id is used) and
GROUP BY measure_type only — the UI buckets by category and never read the
'type' variant, which was multiplying the grouped row count. Documents the
partial-index follow-up that makes it near index-only.
Caching: scenario metrics and measures now hold a 5-min staleTime /
30-min cacheTime, so switching scenarios and back is instant rather than a
re-query.
Deep-link 404s: /your-projects has no index route — it never existed. Drill
'Open in property table' now goes to the portfolio property table
(/portfolio/[id]); per-row 'Open' goes to the building passport
(/building-passport/[propertyId]); 'View upgraded homes' to the table.
tsc clean, tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>