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>
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>
invalidateQueries only refetches mounted queries; the property table isn't
mounted on the add page, and useProperties' refetchOnMount: false then
suppresses the refetch when it does mount — so the invalidation never
produced a fetch. Removing the cached query instead guarantees the initial
fetch on mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both mutations navigated with router.push alone, so the destination served
the pre-mutation render from the client router cache: new properties were
missing from the portfolio table (compounded by useProperties' 5-minute
staleTime, now invalidated too) and a first scenario still showed
"No scenarios yet". router.refresh() after push busts the cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-review UX feedback on the run journey:
- The post-trigger card computed its ETA from the just-cleared scenario
selection, so it always promised "~5 minutes" whatever the run size.
ETA now comes from the captured run (estimateRunMinutes, TDD'd) and the
done state is a compact status banner above a still-visible Recent runs
table, which polls every 5s while a run is moving (historyRefetchInterval,
TDD'd) — trigger, glance, leave.
- The only way back was a text-xs gray-400 breadcrumb (~2.5:1, sub-AA);
replaced with an accessible "← Back to {portfolio}" link.
- "Remove all filters" button on Step 2.
- Scenario rows were a checkbox nested inside a button (state invisible to
screen readers); now a label wrapping a real checkbox.
- Large-run confirm lists the scenario names and selection being committed,
is labelled, closes on Escape, and starts focus on "Go back".
- Postcode filter no longer fails silently: loading and error-with-retry
states.
- Run status "Dispatched" reads "Starting up"; scenarios page gains a
"Run modelling" entry point.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Card and folder menus move to Headless UI v2 (Menu / Popover with
anchor positioning): panels portal to the top layer so the list view
can't clip them, and Esc, outside-click close, arrow-key nav, and menu
semantics come from the primitive — replaces the details/summary menus
and the fixed-position workaround. The folder popover hosts the
two-step remove confirm. Cards and table rows are draggable onto rail
targets (folder = file, Starred = star, All portfolios = unfile) with
drop highlighting; folder-reorder drag is type-guarded from card drags.
Selected rail view persists in the URL via replaceState (?folder=id /
?view=starred), read by the server component; unknown folder ids fall
back to All, and deleting the folder being viewed returns to All.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Impeccable critique (25/40) fixes: window.confirm replaced by a
two-step confirm inside the folder menu (states the unfile consequence,
red confirm takes focus, Escape cancels); mutation failures now render
a dismissible notice as well as the live region, and folder-create
errors are no longer swallowed; list-view card menu uses fixed
positioning measured on open so the table's overflow container can't
clip it; Escape closes card/folder menus with focus returned. Also:
search placeholder and rail-count contrast to >=4.5:1, folder-menu and
New-folder hit targets to >=24px, th scope=col, decorative ticks
aria-hidden, rename input regains a visible focus state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Folder removal now confirms with copy stating portfolios are unfiled,
not deleted. Grid/list preference persists in a cookie read by the
server component, so the chosen view renders on first paint. Long lists
render the first 24 rows with a show-more button; the reveal count
resets when the view or search changes, and starred float keeps pinned
work on the first screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Client page driven by useQuery on /api/me/portfolio-home with optimistic
mutations (star, move-to-folder, folder create/rename/delete, drag or
menu-based reorder). TDD'd view-model in portfolioHomeView.ts (status
pill mapping, search over display language, sort incl needs-attention,
money/date formatting, optimistic config upsert);
groupPortfoliosForHome generalised over id type so the client re-groups
with wire-format string ids. List view is a semantic table; replaces
CardTiles/Card/AddNewCard (NewPortfolioModal retained).
Co-Authored-By: Claude Fable 5 <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>
Scenario 1275's modelling run crashed the engine: its exclusions carried
'room_roof_insulation', which isn't a backend MeasureType — the strict
_parse_exclusions raises for every property in the scenario. The authoring
vocabulary had seven such tokens (room_roof_insulation, boiler_upgrade,
secondary_heating, ventilation, fireplace, hot_water_tank_insulation,
cylinder_thermostat).
Scenario authoring now uses its own SCENARIO_MEASURES vocabulary — exactly
the backend MeasureType set (drifted names corrected, unimplemented
measures dropped, missing system_tune_up/_zoned + sloping_ceiling gained),
pinned by a test that fails if either side moves alone (the backend's
ADR-0056 shared-contract rule, applied app-side). room_roof_insulation
leaves DISRUPTIVE_MEASURES until the backend implements the measure;
legacy tokens on existing rows still display in constraint summaries.
The global measuresDisplayLabels vocabulary (recommendations display, old
file-based trigger) is untouched.
Co-Authored-By: Claude Fable 5 <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>
TDD'd pure planning core (planFolderReorder with payload validation,
groupPortfoliosForHome with starred float + recency, planFolderDeletion
encoding unfile-then-delete, buildStarChange owning starred_at-as-flag),
applied by thin route handlers: folder create/rename/delete/reorder,
per-portfolio config upsert, and the grouped home read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-user portfolio workspace layer for the home-page redesign: starred
portfolios (starred_at, non-null = starred, orderable) and personal
folders (name + position, drag-reorderable). Composite FK
(folder_id, user_id) -> (id, user_id) makes cross-user folder
assignment impossible at the database level. Config rows are created
lazily via upsert on (user_id, portfolio_id); portfolioUsers remains
pure access control.
Co-Authored-By: Claude Fable 5 <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>
The address2uprn lookup already returns the EPC certificate number
alongside the UPRN, but only the UPRN is persisted today. This adds
a nullable column so a follow-up change can start saving it.
Co-Authored-By: Claude Sonnet 5 <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>
- 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>
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>
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>
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>
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>
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>
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>
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>