Commit graph

1820 commits

Author SHA1 Message Date
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
b6dcef41c7 Merge branch 'main' of github.com:Hestia-Homes/assessment-model 2026-07-08 14:51:22 +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
Khalim Conn-Kowlessar
d0fdb6ad72 fix(reporting): load the spend breakdown on demand, not on every page load
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>
2026-07-08 13:29:32 +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
Khalim Conn-Kowlessar
c638e59f20 feat/polish(reporting): compare-column picker + consistency fixes
- 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>
2026-07-08 11:41:56 +00:00
Khalim Conn-Kowlessar
178b63e7c5 a11y(reporting): focus rings, AA contrast, ARIA, non-colour cues
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>
2026-07-08 11:31:02 +00:00
Khalim Conn-Kowlessar
d385c57d2c fix(reporting): intuitive, configurable scenario filters
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>
2026-07-08 11:06:23 +00:00
Khalim Conn-Kowlessar
9d8395f3ac fix(reporting): handle string timestamp from raw db.execute in scenarios
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>
2026-07-08 10:54:22 +00:00
Khalim Conn-Kowlessar
97e24e0027 fix(reporting): stop the page timing out on large portfolios
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>
2026-07-08 10:45:15 +00:00
Khalim Conn-Kowlessar
bbd410b059 feat(reporting): measures via server action + skeleton; compact drill shelf
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>
2026-07-08 10:30:21 +00:00
Khalim Conn-Kowlessar
a17ffdefd1 perf/fix(reporting): faster measures query, cache scenarios, fix deep-link 404s
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>
2026-07-08 10:08:39 +00:00