Commit graph

1755 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
46421279fb feat(portfolio): address impeccable critique — no-plans state, contrast, focus
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>
2026-07-13 21:50:27 +00:00
Khalim Conn-Kowlessar
5872e7c335 feat(tags): consolidate bulk-tag actions into a toolbar Tags menu
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>
2026-07-13 21:17:18 +00:00
Khalim Conn-Kowlessar
47fd733b45 fix(tags): address PR #389 review — server guards, error handling, reuse
- 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>
2026-07-13 19:50:10 +00:00
Khalim Conn-Kowlessar
7cd33ab18d Merge remote-tracking branch 'origin/main' into feature/portfolio-tagging
# Conflicts:
#	src/app/portfolio/[slug]/components/PropertyFilters.tsx
#	src/app/portfolio/[slug]/utils.ts
#	src/app/utils/propertyFilters.ts
2026-07-13 19:49:47 +00:00
Khalim Conn-Kowlessar
e80803c50a fix(tags): fall back to row index in DataTable getRowId for idless rows
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>
2026-07-13 19:16:51 +00:00
Khalim Conn-Kowlessar
1d12fa1baf refactor(tags): one calm, AA-safe chip vocabulary across every surface
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>
2026-07-13 19:09:27 +00:00
Khalim Conn-Kowlessar
9749e8525b feat(tags): tags multi-select in the Run modelling filter
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>
2026-07-13 19:00:12 +00:00
Khalim Conn-Kowlessar
f98e36ad87 feat(tags): bulk tag assignment — selection, filter-driven, and file upload
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>
2026-07-13 17:14:30 +00:00
Khalim Conn-Kowlessar
5abf4a3a10 feat(tags): dynamic Tags filter in the property-table filter panel
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>
2026-07-13 16:58:19 +00:00
Khalim Conn-Kowlessar
b9d7bb4bba feat(tags): property-table Tags column + inline assign popover
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>
2026-07-13 16:52:48 +00:00
KhalimCK
f801025ce3
Merge pull request #387 from Hestia-Homes/feature/tags-schema
feat(db): portfolio_tag + property_tag tables (tagging system)
2026-07-13 17:26:13 +01:00
Khalim Conn-Kowlessar
c5d58dbc96 feat(tags): table query + tag filter, filter-driven bulk assign, preview join, settings UI
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>
2026-07-13 16:23:49 +00:00
KhalimCK
aa38c916d2
Merge pull request #384 from Hestia-Homes/feature/portfolio-list-override-descriptors
feat(portfolio): resolve list descriptors via landlord-override precedence
2026-07-13 17:09:40 +01:00
Khalim Conn-Kowlessar
88c0c5b13d feat(tags): CRUD + assignment routes, bulk-file parser, run-filter tag_ids
- 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>
2026-07-13 16:04:28 +00:00
Khalim Conn-Kowlessar
ed1dae4e99 feat(tags): pure Tag draft validation/normalisation (TDD)
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>
2026-07-13 15:50:09 +00:00
Khalim Conn-Kowlessar
af81827b14 docs: ADR-0013 tagging system + CONTEXT (Bulk tag assignment, Run filter tags)
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>
2026-07-13 15:45:14 +00:00
Khalim Conn-Kowlessar
4a841c20d4 docs(context): add Tag glossary entry (grill WIP)
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>
2026-07-13 15:45:14 +00:00
Khalim Conn-Kowlessar
0a26f9ed93 feat(db): portfolio_tag + property_tag tables (tagging system)
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>
2026-07-13 15:44:26 +00:00
KhalimCK
84a485923c
Merge pull request #386 from Hestia-Homes/feature/epc-main-heating-detail-index
feat(db): index epc_main_heating_detail(epc_property_id)
2026-07-13 16:22:16 +01:00
Khalim Conn-Kowlessar
0873fbc5c1 docs(context): Main Fuel reads EPC detail; Wall/Roof/Heating filter by bucket
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>
2026-07-13 14:38:42 +00:00
Khalim Conn-Kowlessar
576b8a0b33 feat(db): index epc_main_heating_detail(epc_property_id)
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>
2026-07-13 14:36:38 +00:00
Khalim Conn-Kowlessar
3b04de622b feat(portfolio): Main Fuel EPC fallback + filterable Wall/Roof/Heating
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>
2026-07-13 14:30:50 +00:00
Khalim Conn-Kowlessar
0131586a68 docs(context): note Wall/Roof/Heating join the override-resolved list columns
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:37:11 +00:00
Khalim Conn-Kowlessar
4f40c0b13d feat(portfolio): smaller list fetch, larger export, wall/roof/heating columns
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>
2026-07-13 13:36:26 +00:00
KhalimCK
3148ab8bba
Merge pull request #385 from Hestia-Homes/feature/plan-default-plan-index
perf(plan): property-leading index for the default-plan LATERAL (portfolio list load)
2026-07-13 14:04:57 +01:00
Khalim Conn-Kowlessar
8d9927b115 perf(plan): add property-leading partial index for the default-plan lookup
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>
2026-07-13 09:06:13 +00:00
Khalim Conn-Kowlessar
b393b5efe0 refactor(modelling): previewModellingRun consumes shared epcSources fragments
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>
2026-07-11 00:00:50 +00:00
Khalim Conn-Kowlessar
78db9815ba feat(portfolio): remove Tenure from the property list column + filter
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>
2026-07-10 23:56:42 +00:00
Khalim Conn-Kowlessar
fc2f7d5b30 feat(portfolio): resolve Main Fuel column + filter via override precedence
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>
2026-07-10 23:51:40 +00:00
Khalim Conn-Kowlessar
558e20f2fe feat(portfolio): band-native Construction Age column + filter (was Year Built)
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>
2026-07-10 23:35:14 +00:00
Khalim Conn-Kowlessar
e2ab52e4f9 feat(portfolio): resolve Built Form column + filter via override precedence
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>
2026-07-10 23:26:07 +00:00
Khalim Conn-Kowlessar
3bf816130c feat(portfolio): resolve Property Type column + filter via override precedence
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>
2026-07-10 23:21:22 +00:00
Khalim Conn-Kowlessar
6871aff62a docs(portfolio): ADR-0012 — list resolves descriptors via override precedence
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>
2026-07-10 23:21:21 +00:00
KhalimCK
a88c6abf54
Merge pull request #382 from Hestia-Homes/feature/scenario-fabric-first-ui
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
feat(scenarios): surface and set fabric-first across scenario pages
2026-07-09 17:46:34 +01:00
Khalim Conn-Kowlessar
48bfac490f style(scenarios): make the fabric-first toggle distinctive
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>
2026-07-09 14:18:05 +00:00
Khalim Conn-Kowlessar
2f3e4b4c8d feat(scenarios): surface and set fabric-first across scenario pages
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>
2026-07-09 13:51:55 +00:00
KhalimCK
e48a5f4ae2
Merge pull request #381 from Hestia-Homes/feature/scenario-fabric-first
feat(db): add fabric_first flag to scenario
2026-07-09 12:37:01 +01:00
Khalim Conn-Kowlessar
9e57596b64 feat(db): add fabric_first flag to scenario
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>
2026-07-09 11:32:58 +00:00
KhalimCK
9fa11b0b6d
Merge pull request #379 from Hestia-Homes/fix/hmo-classification-code
fix(postcode-search): classify RH (HMO) family as addable residential
2026-07-09 11:54:12 +01:00
Khalim Conn-Kowlessar
fac425762e fix(postcode-search): classify RH (HMO) family as addable residential
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>
2026-07-09 10:33:07 +00:00
KhalimCK
8bbcd77974
Merge pull request #376 from Hestia-Homes/feature/bulk-document-download
feat(live-reporting): bulk document download (Documents tab)
2026-07-08 17:40:53 +01:00
Khalim Conn-Kowlessar
098a5ccffc fix(live-reporting): bulk download poller no longer hangs after completion
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>
2026-07-08 16:34:00 +00:00
Khalim Conn-Kowlessar
7a7f7e157e feat(live-reporting): select page vs all pages in bulk download
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>
2026-07-08 16:04:53 +00:00
Jun-te Kim
e9b03fef1a
Merge pull request #377 from Hestia-Homes/feature/hubspot-deal-planning-columns
feat(crm): add planning/address-profiling columns to hubspot_deal_data
2026-07-08 15:50:34 +01:00
Jun-te Kim
177ffd0b83 feat(crm): add planning/address-profiling columns to hubspot_deal_data
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>
2026-07-08 14:43:58 +00:00
Khalim Conn-Kowlessar
34dd3fe7ae feat(live-reporting): bulk document download from the Documents tab
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>
2026-07-08 14:11:42 +00:00
KhalimCK
153c64195b
Merge pull request #374 from Hestia-Homes/perf/measures-plan-default-index
perf(db): covering partial index for the reporting measures aggregate
2026-07-08 13:57:19 +01:00
Khalim Conn-Kowlessar
4fd1e00e63 perf(db): covering partial index for the reporting measures aggregate
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>
2026-07-08 11:52:35 +00:00
Jun-te Kim
4c65bca0c3
Merge pull request #367 from Hestia-Homes/feature/uprn-confirmation-onboarding
Confirm UPRNs before finalise: two-tab bulk-upload review + retire Unmatched tab (ADR-0057)
2026-07-08 10:28:09 +01:00
Jun-te Kim
72c6d9dd5a Merge remote-tracking branch 'origin/main' into feature/uprn-confirmation-onboarding
# Conflicts:
#	src/app/portfolio/[slug]/components/PropertyTable.tsx
2026-07-08 08:59:17 +00:00