Design pass on the portfolio table (critique: 27/40; P0 was the unmodelled state).
- onboard: a mixed-safe "not modelled yet" nudge above the table — shown whenever
any loaded property lacks a plan (so a part-modelled portfolio still gets it),
wired to Run modelling. Turns a wall of empty cells into a next step without
hiding real data for the rows that ARE modelled.
- harden: one empty-cell vocabulary (EmptyCell) replacing the four divergent
renderings (blank div, slate-300 "—", italic "No cost", "Unknown"); AA-legible
and aria-labelled. Empty EPC bubble is now a labelled placeholder ring, not an
invisible div. Export failures surface an inline error instead of dying silently.
- a11y: the quick-filter clear is no longer a <span role=button> nested inside a
<button> — pill is a container with sibling open/clear buttons.
- distill: exactly one navy CTA at a time — Add properties leads an empty
portfolio, Run modelling leads a populated one; the other demotes to neutral.
- colorize: black tokens in PropertyFilters + the Edit-Columns checkboxes now use
the brand navy/brandmidblue scale (was raw black, drifting off the toolbar).
- cleanup: removed the unreachable delete-property modal (no DELETE endpoint,
onDeleteProperty never called). Real property deletion is its own slice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the isolated "Assign tags from file" button with a single Tags ▾
dropdown in the toolbar (grouped with Export / Add properties), offering the
three bulk paths: upload from file, assign a tag, remove a tag.
Assign/Remove enter a lightweight contextual mode (TagActionBar) whose target
follows the live table — the row selection if any, else the active filter set,
else the whole portfolio — so ticking rows or changing filters re-scopes it in
place, and it supports removal, not just assignment. Row checkboxes stay visible
at all times; a "N selected" chip now sits by the results count. The upload
modal gains downloadable CSV templates (Property Ref / UPRN).
Removes TagBulkBar (superseded by TagsMenu + TagActionBar).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Reject `tagIds` in the modelling-runs trigger route (400) until the distributor
resolves tag_ids — the RunModellingClient guard was UI-only, so a direct POST
(or a future caller) could dispatch a tagged run that silently models the
unscoped superset (preview≠run, ADR-0008). Defence-in-depth now.
- Narrow the tags POST catch to Postgres 23505 (new isUniqueViolation helper) so
transient/other DB errors surface as 500, not a misleading duplicate-name 409.
- Wrap the PATCH rename in the same unique-violation handler — the pre-check is
racy, so a concurrent rename collision now returns 409, not an uncaught 500.
- TagsCard reuses usePortfolioTags / portfolioTagsKey / Tag instead of a copied
query + key, so every tag surface stays in sync if the key changes.
- Scope DataTable getRowId to selection-enabled tables only, leaving other
consumers (plan table) on TanStack's default row identity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared DataTable is also used by the plan table, whose PlanWithTotals rows
have no `id` (they carry planId/propertyId). A bare String(row.id) keyed every
plan row as "undefined" — colliding React keys. Key by id when present, else by
index (restoring the pre-change default for idless consumers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Impeccable pass. Replaces the solid full-saturation chips (a heavy-accent
inconsistency vs the settings card's soft chips) with a single quiet vocabulary
per PRODUCT.md: a soft tint of the Tag's freeform hex, a colour dot, and dark
hue-preserving ink whose contrast on the tint is guaranteed ≥4.5:1 for any
colour (new tagChipTokens, TDD — yellow and every hue verified). Settings,
table, filter, bulk bar, upload, and run UI now share one TagChip. Also drops a
stray dev scratch file and gitignores the pattern.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a Tags FilterColumn (per-portfolio options from GET /tags, colour dots,
counts) feeding filters.tagIds; folds it into filterCount/clearFilters and
removes the "Tags are coming" copy. The in-app preview already counts by tag
membership, so the selection previews exactly. The run button is guarded while
tags are selected — the distributor can't resolve tag_ids yet (backend ask,
ADR-0013), so previewing is honest but running stays disabled until it lands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 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>