Commit graph

1820 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
f7d64ccbc4 feat(reporting): add a scenario description to the report PDF
Two requests in one: describe the scenario, and fill the blank space
under the EPC distribution (the left column was shorter than the ledger).
Both are solved by an "About this scenario" brief stacked beneath the EPC
distribution in the left column.

- New getScenarioConfig() reads the scenario's immutable config
  (goal/target, housing type, budget, measure exclusions, fabric-first;
  exclusions parsed from the `{a, b}` text column).
- The brief: a plain-language sentence ("what it takes to reach EPC C
  across the portfolio's social homes…") plus a spec list (Goal, Stock,
  Budget, Fabric-first, Excluded measures with human labels).
- Recommended plans gets a matching "About recommended plans" note.
- Tightened spacing so the fuller page still fits one A4 sheet.

Verified all three views (scenario / current stock / recommended) render
as one clean page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 12:15:02 +01:00
Khalim Conn-Kowlessar
4a91e2e817 fix(reporting): report PDF now fits one clean A4 portrait page
The exported PDF split awkwardly — page 1 half-empty, the EPC + ledger
block pushed wholesale to page 2 — and rendered too wide. Cause: the
print CSS used `@page { margin: 0 }` plus a `transform: scale(0.94);
width: 106.4%` hack on .print-root, which renders wider than the sheet
and paginates badly (a scaled/oversized root can't break cleanly).

- Print CSS: real `@page { size: A4 portrait; margin: 12mm }`, drop the
  transform-scale and width hack. (These classes are used only by the
  report, so no other page is affected.)
- Constrain the report to A4 content width (max-w-190mm, centred) so it
  can never render landscape/oversized, with on-screen padding that
  collapses in print.

Result: cover + headline + KPI tiles + EPC distribution and Investment
ledger (side by side) + footer all fit one portrait page; current-stock
likewise. Verified by generating the real PDFs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:11:13 +01:00
Khalim Conn-Kowlessar
0509836c42 feat(reporting): complete + restyle the Download report (PDF)
The report PDF was effectively broken/unfinished: it crashed locally
(self-fetch to a hardcoded :3000), only worked for a numeric scenario
("No scenario selected" from Current stock / Recommended), ignored the
new URL-driven tag + scenario filters, and printed bare because @media
print stripped background colours — so it "looked rubbish".

Rebuild it as the true print register of the reporting screen:
- Carries the full view state (view + filters + tags) from the Download
  report link, so the pack matches exactly what's on screen.
- Supports every view: Current stock (baseline-only), Recommended plans
  (default segment) and any scenario; filters/tags threaded through.
- Polished, print-safe layout: branded cover with the portfolio name,
  headline, KPI tiles, EPC distribution (div bars, not the button-based
  ladder that print hides), and the restyled Investment ledger reused
  verbatim. A "Filtered view" note when filters/tags are active.
- Fix colour printing (print-color-adjust: exact on .print-root) and
  drop the app chrome (portfolio-name banner + copyright footer) in print.
- Robust base URL (respects PORT in dev).

Also fixes a real bug the Recommended report surfaced: the default
metrics route omitted gross_cost (→ "£NaN gross" in the ledger) and
computed net as construction−funding with a contingency-less per-home.
It now uses computeLedger like the [scenarioId] route — fixing the
on-screen Recommended ledger too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 10:09:07 +01:00
Khalim Conn-Kowlessar
41b89c0d8f fix(reporting): interim guard — don't count already-at-target homes as upgraded
The modelling engine emits costed plans for homes already at a scenario's
target EPC band (e.g. ~£300–600 of low-energy lighting / system tune-ups
on effective-C homes in a "Reach EPC C" scenario). Counting them inflated
"Homes upgraded" and its costs, and stopped the KPI reconciling with the
below-target band distribution (portfolio 796 / scenario 1268: 9492
upgraded vs 9039 homes below C).

Add an interim guard to the scenario metrics upgrade aggregate (count +
cost): for Increasing-EPC scenarios, drop any home whose effective band
already meets the target (effective band <= goal_value; lexical, A best).
Unknown/NULL effective band is kept. Verified against the DB: scenario
1268 goes 9492 → 8871 upgraded, −£282k of works on compliant homes.

This is a reporting-only band-aid; the root cause is in the engine —
tracked in Hestia-Homes/Model#1652. The default/recommended view has no
single target band, so the guard doesn't apply there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 09:30:53 +01:00
Khalim Conn-Kowlessar
32da5bf079 feat(reporting): restyle scenario filter descriptions to match
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
Apply the new structured tooltip treatment to the scenario filters, so
their descriptions read like the cost figures': a summary line plus a
subtle footnote, in the same larger, rounded ⓘ card.

- Extract the tooltip body into a shared `tipContent.tsx` (TipBody +
  TipSpec); costHelp now consumes it instead of its own copy.
- FilterChips: the two toggle chips use the shared `InfoDot`; the
  "skip already-compliant" popover reuses the same structured copy.
  Filter help now lives in one `FILTER_HELP` map.

Copy/present only — no filter behaviour changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:03:57 +01:00
Khalim Conn-Kowlessar
8dd2036fa5 fix(reporting): add key to mapped tooltip elements (build lint)
The production build's `react/jsx-key` lint failed on the HelpBody
elements built inside COST_HELP's Object.fromEntries map. Add a key —
they're constructed in an array literal even though they're stored in an
object, so the rule applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:53:39 +01:00
Khalim Conn-Kowlessar
3bb1fc6b39 feat(reporting): make cost tooltips larger and scannable
The cost explanations were one dense paragraph in a small (0.75rem)
tooltip. Restructure them: a one-line definition followed by scannable
Includes (green ✓) and Excludes (grey −) lists, with a subtle noted
footnote where useful. Bump the tooltip text to 0.82rem, widen and
re-pad it, and give it a softer rounded card look.

costHelp.ts → costHelp.tsx: entries are now structured specs
(summary / includes / excludes / note) rendered to tooltip bodies;
InfoDot and KpiBand's `tip` accept ReactNode. No copy meaning changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:45:13 +01:00
Khalim Conn-Kowlessar
5e7b7aa071 feat(reporting): explain what each scenario cost figure covers
Users couldn't tell what the cost values include or exclude — e.g. does
"gross per home" cover contingency, is the principal contractor's price
in there. Add plain-language "what's included / excluded" disclosure
across the scenario overlay, sourced from one canonical copy module
(costHelp.ts) so every surface says it the same way and matches the
domain glossary.

- New shared `InfoDot` primitive (the one ⓘ affordance).
- Investment ledger: ⓘ on Net cost, gross/home, Construction works,
  Project delivery, Contingency, Bill savings, Carbon saved, and the two
  value-for-money ratios — each stating what it does/doesn't cover
  (e.g. construction = the principal contractor's works, excludes
  delivery + contingency; gross = works + delivery + contingency).
- KPI band gains an optional `tip`; the "Gross cost /home" KPI uses it.
- "Where the money goes" now notes it's construction works only, so it
  reconciles to the Construction works line, not gross cost.

Copy only — no figures or queries changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:34:44 +01:00
Khalim Conn-Kowlessar
a1bfde40f8 feat(reporting): restyle the Investment ledger
The scenario Investment ledger was a flat monochrome list. Give it a
visual hierarchy while keeping the reporting design vocabulary (quiet
navy-on-white, semantic green for benefits):
- lead with a Net cost hero block — the headline figure a board commits
  to — with gross/home and the gross · funding breakdown beneath it;
- turn the cost lines into a colour-keyed composition bar (navy → gold →
  grey, echoing the data-quality evidence bar) with matching dots;
- give annual benefits green icon-chip rows (they're recurring savings)
  and value-for-money its own iconography;
- add section and header icons throughout.

Purely presentational — same LedgerView data, no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 15:21:22 +01:00
Khalim Conn-Kowlessar
6b71e73e25 fix(reporting): align data-quality sections at one width
Follow-up to the layout tidy: the inset only applied to the issues table,
so it sat narrower than the cards above and below it. Constrain the whole
data-quality page at the container level (max-w-7xl) instead, so the
header and all three sections share the same edges, and dial the gutter
back from the earlier too-narrow max-w-6xl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:00:51 +01:00
Khalim Conn-Kowlessar
969216e636 fix(reporting): tidy the data-quality page layout
Three small visual fixes to the data-quality page:
- vertically centre the rows of the top issues table (was top-aligned, so
  the count/impact/link hugged the top of each tall row);
- inset that table (max-w-6xl, centred) so it isn't stretched full-width
  for its sparse content;
- replace the button-like "Template coming soon" box with a subtle muted
  "Coming soon" status pill (clock icon), so it no longer reads as a
  broken/disabled button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:49:26 +01:00
Khalim Conn-Kowlessar
1784325d00 feat(reporting): URL-drive the scenario view + filters
The whole reporting view (scenario `view`, the scenario filters, and the
tag filter) now lives in the URL, so it survives refresh and browser
back/forward. `ReportingClientArea` reads the full `ReportingViewState`
from `useSearchParams` as the single source of truth, replacing the
`useState` for view/filters.

A single `applyViewState` writer routes each change by what it affects:
- a changed tag filter re-renders the server baseline → `router.replace`
  (a brief metrics Suspense skeleton is correct; the data changes);
- a `view` or scenario-`filter` change only touches the client-fetched
  overlay → History API (`pushState` for a scenario switch so Back has an
  entry, `replaceState` for rapid filter toggles), so the baseline
  Suspense never re-flashes.

Adds a pure `tagFiltersEqual` (order-insensitive; `includeMode` only
counts with 2+ includes) to decide the server-vs-client route.

Verified in a headless browser on portfolio 796: view+filters persist
across refresh and back/forward; view/filter changes make zero server
round-trips (no baseline re-flash) while a tag change makes exactly one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:39:25 +01:00
Khalim Conn-Kowlessar
376627b2d0 feat(reporting): filter the view by tags (URL-driven)
Some checks are pending
Test Suite / unit-tests (push) Waiting to run
Adds a view-wide tag filter to the reporting page. A "Tags" control in the
header lists the portfolio's tags; each can be Included (home must carry it) or
Excluded (home must not) — mutually exclusive per tag — with an Any/All switch
once two or more are included.

The filter is URL-driven (?includeTags=…&excludeTags=…&includeMode=all): the
server reads it and renders the baseline (current-stock figures) filtered, so it
survives refresh and back/forward. Changing it navigates and streams the
filtered figures back in via the Suspense boundaries. The scenario overlay,
measure allocation and drill-down all send the same tag params (in their
react-query keys), so the whole view stays consistent with the headline counts.
Scenario selection + scenario filters remain client state for now (instant, no
round-trip) — migrating those into the URL too is the documented next step.

Verified in a headless browser on #796: including the tag drops the homes count
31,398 → 607 (its exact membership) and writes ?includeTags=13 to the URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:59:33 +01:00
Khalim Conn-Kowlessar
e80332fa64 feat(reporting): apply the tag filter across the data layer
Threads the tag filter through every reporting query so the whole view reflects
the tagged subset: baseline aggregate, age-band + property-type counts, likely
downgrades, both scenario-metrics routes (all three property scans each), the
drill-down list, and the measures allocation. Each query ANDs
tagFilterCondition() into its WHERE; routes parse the filter from the URL via
the shared parseReportingViewState so they read exactly what the client wrote.
Defaults to the empty (no-op) filter, so existing callers/tests are unchanged.

Verified on #796/scenario 1292: include tag → n_units = the tag's membership
(607), exclude → the complement (30705), and the two EPC distributions
reconcile to the full portfolio total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:44:34 +01:00
Khalim Conn-Kowlessar
77e5fcde20 feat(reporting): tag-filter + view-state core (TDD)
Foundation for filtering the reporting view by tags, URL-driven so the whole
view survives refresh and back/forward.

- viewState.ts: the reporting view as URL-serialisable state — scenario `view`,
  the scenario filters, and the tag filter {include, exclude, includeMode}.
  parse ⇄ serialize, defensively sanitised (numeric tag ids, dedupe,
  include/exclude overlap → exclude wins, defaults omitted for clean URLs).
- tagFilterSql.ts: tagFilterCondition() → a SQL WHERE fragment. include=any
  (EXISTS), include=all (COUNT DISTINCT = size), exclude (NOT EXISTS), empty =
  TRUE (never filters everything out). Verified on real data (#796, #824):
  include = membership count, exclude = complement, ANY = union, ALL =
  intersection.

27 unit tests. No wiring yet — next: thread through the reporting queries + UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:34:06 +01:00
Khalim Conn-Kowlessar
948a313c97 Merge remote-tracking branch 'origin/main' into feature/reporting-redesign
# Conflicts:
#	CONTEXT.md
#	src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx
#	src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts
#	src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx
2026-07-19 00:22:52 +01:00
Khalim Conn-Kowlessar
43667ec1fd feat(reporting): skeleton the KPI band on first scenario entry
Switching from Current stock into a scenario has no previous overlay to keep,
so the scenario KPIs were momentarily empty — an empty-bar flash before the
figures loaded. That's the "no data yet" case, so it now shows the same
KpiBandSkeleton the initial page load uses (extracted + shared), and drops the
now-redundant "Loading scenario…" line there.

The dim + "Updating figures…" path is unchanged and stays for the cases where
data is already on screen (scenario→scenario switch, filter changes), where the
previous figures are deliberately kept via keepPreviousData. Net: skeleton = no
data yet; dim = refreshing existing data — one consistent convention.

Verified in a headless browser on #796: first entry shows the KPI skeleton +
aria-busy and no empty flash; a filter toggle still dims the figures with
"Updating figures…".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:11:50 +01:00
Khalim Conn-Kowlessar
a23719aa34 feat(reporting): loading states for scenario switch + filter changes
The scenario overlay uses keepPreviousData, so switching scenario or changing
a filter left the previous figures on screen with nothing signalling they were
being recalculated. Now:

- The picker row shows a small spinner + "Updating…" beside the filter chips
  while the overlay refetches — immediate feedback right at the controls. It
  reads react-query's useIsFetching(["scenario-report"]) so the query stays
  owned by MetricsBody (no lifting).
- The figures (KPI band + EPC distribution + ledger) dim (opacity-50, aria-busy)
  while refetching with stale data on screen, and show a spinner + "Updating
  figures…" / "Loading scenario…" line above them.

Verified end-to-end with a headless browser on portfolio 796: switching to an
"Increasing EPC" scenario and toggling a filter both surface the spinner,
"Updating…"/"Loading…" text, aria-busy and the dimmed figures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:04:03 +01:00
Khalim Conn-Kowlessar
587dcacc25 perf(reporting): stream the page with Suspense boundaries
The page previously awaited a Promise.all of every query in the server
component, so nothing painted until the slowest query returned. Now the page
kicks the queries off without awaiting and hands the promises to the client
area, which unwraps them with use() inside Suspense boundaries.

Result: the header + "Download report" paint on first byte; the scenario
picker streams in as soon as the (fast) scenarios query lands; the metrics
body (KPI band, EPC distribution, stock breakdown, confidence strip) streams
in when the baseline aggregate lands — each behind a skeleton fallback. The
current-stock "N homes" count sits in its own inner boundary so it never
holds back the picker.

All existing logic (KPIs, ledger, goal callout, drill-down, scenario overlay
via react-query, measure allocation) is preserved verbatim — just relocated
into the streamed MetricsBody. Verified end-to-end against portfolio 796
(authenticated, HTTP 200): shell + skeletons emit first, sections stream in.

Uses the same use(promise) pattern already shipping elsewhere in the app
(property pages unwrap params/searchParams the same way).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 23:44:02 +01:00
Khalim Conn-Kowlessar
76c09e24f6 perf(reporting): scope the legacy EPC join to the portfolio
The overlay's dominant cost was a full sequential scan of property_details_epc
(the being-phased-out legacy table — ~600k rows, ~330ms warm, ~2.5s cold from
disk) on every query, because the LEFT JOIN was keyed on property_id alone.

property_details_epc has a (property_id, portfolio_id) composite index, so
adding `AND e.portfolio_id = <pid>` to the join turns the seq scan into an
index lookup. For migrated portfolios the legacy table holds ~0 rows, so the
join becomes near-free; for un-migrated ones it returns the same rows via the
index. EXPLAIN confirms 0 rows dropped and the exact (numeric) aggregates are
unchanged — only order-dependent ::float SUM accumulation jitters, which the
UI rounds away.

Applied to the scenario overlay (both [scenarioId] and default routes) and the
initial-load baseline aggregate. On portfolio #796: overlay handler ~1.6s →
~0.74s (warm; larger win cold), baseline query 790ms → 330ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 21:51:08 +01:00
Khalim Conn-Kowlessar
c3530e7e5c perf(reporting): collapse baseline + scenario queries, parallelize
Reporting was slow on large portfolios (e.g. #796) because every metric
re-scanned property ⋈ property_details_epc ⋈ newApproachJoins, and the
scenario overlay ran five queries strictly serially.

Baseline (initial load): the six queries that were the identical scan —
total, averages, totals, EPC-band distribution, estimated/actual split and
expired count — collapse into one conditional-aggregation pass. Age bands
(correlated subqueries) and likely downgrades (legacy-only join) stay
separate and run in parallel. 8 queries → 3; ~1945ms → ~787ms on #796,
results byte-identical.

Scenario overlay: Query 2 (portfolio-after aggregates) and Query 3 (EPC
distribution) were near-identical per-property LATERAL scans, and Q3
shipped one row per property to Node just to bucket bands in JS. Merged
into a single scan that buckets bands in SQL (thresholds matched to
sapToEpc exactly), and the three data queries now run in one Promise.all
wave instead of serially. Applied to both the [scenarioId] and default
routes. Verified identical aggregates + band counts on #796's ~31k-home
scenario.

All ADR-0002 / ADR-0010 semantics (effective baselines, compliance window,
lodged toggle) preserved unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 21:36:57 +01:00
Jun-te Kim
d29da8a848
Merge pull request #402 from Hestia-Homes/fix/remove-orphaned-queries-review-panel
Some checks failed
Test Suite / unit-tests (push) Has been cancelled
fix(analytics): remove orphaned QueriesReviewPanel import
2026-07-17 16:06:19 +01:00
Jun-te Kim
9c8184d48b fix(analytics): remove orphaned QueriesReviewPanel import
AnalyticsView.tsx imported and rendered QueriesReviewPanel, but that
component file was never added in any commit -- the import has been
dangling since it was introduced, breaking the production build with
"Module not found: Can't resolve './QueriesReviewPanel'".

Removes the import and its render block. The Queries/Review-with-Landlord
row can be reintroduced once the component actually exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:04:43 +00:00
Jun-te Kim
ddff8366cb
Merge pull request #401 from Hestia-Homes/feature/add-properties-search-filter
feat(add-properties): filter long address result lists
2026-07-17 15:58:35 +01:00
Jun-te Kim
90800d3786 feat(add-properties): filter long address result lists
Postcode searches can return ~100 addresses. Add a client-side filter
above the results that narrows the list live by address substring, so a
user can jump to a house number or street name without scrolling.

- Filter box only appears when a postcode returns more than 8 addresses
- Header count reflects the filtered subset (e.g. "12 of 97 addresses")
- Empty state when nothing matches; clear button and new searches reset it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:53:08 +00:00
Jun-te Kim
dd5811285f
Merge pull request #399 from Hestia-Homes/feature/add-properties-search
feat(add-properties): filter long address result lists
2026-07-17 15:00:03 +01:00
Daniel Roth
558633c128
Merge pull request #400 from Hestia-Homes/feature/abri-resource-hubspot-migration
Abri API: add third party surveyor id to hubspot deal data table
2026-07-17 14:29:10 +01:00
Daniel Roth
d0fd4ce498 migration files 2026-07-17 12:42:01 +00:00
Daniel Roth
4a73df3a25 add third party surveyor id to hubspot deal data table 2026-07-17 12:41:38 +00:00
Jun-te Kim
5810afa35e feat(add-properties): filter long address result lists
Postcode searches can return ~100 addresses. Add a client-side filter
above the results that narrows the list live by address substring, so a
user can jump to a house number or street name without scrolling.

- Filter box only appears when a postcode returns more than 8 addresses
- Header count reflects the filtered subset (e.g. "12 of 97 addresses")
- Empty state when nothing matches; clear button and new searches reset it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:56:42 +00:00
Jun-te Kim
769030ab49
Merge pull request #391 from Hestia-Homes/feature/design-extra-pm-columns
feat(live): add planning fields as toggleable property columns
2026-07-14 15:44:07 +01:00
Jun-te Kim
0ad66c25d4
Merge pull request #393 from Hestia-Homes/feature/epc-cards-count-certificates
fix(reporting): EPC cards count certificates, not predictions (ADR-0014)
2026-07-14 15:43:30 +01:00
Jun-te Kim
5dc9c49132 fix(reporting): quote the camelCase aliases on the EPC coverage query
The card rendered a blank value and "NaN%": Postgres returns column names
verbatim, so `AS without_epc` came back as `without_epc` while the code read
`withoutEpc` — undefined, and `undefined / total * 100` is NaN.

`db.execute<T>()` asserts the row shape rather than checking it, so tsc, lint and
the full suite all passed on the broken query. The previous field names (`estimated`,
`actual`) were single words and happened to survive the round trip; renaming to
camelCase did not. Quoted aliases are the existing convention (see utils.ts).

Verified against portfolio 839: keys come back as [withoutEpc, withEpc], and the
card renders 29 / "0.5% have no EPC on record".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:41:00 +00:00
KhalimCK
140383f18f
Merge pull request #392 from Hestia-Homes/feature/tag-select-all-matching
feat(tags): explicit select-all-matching in bulk tag mode
2026-07-14 13:31:30 +01:00
Khalim Conn-Kowlessar
5ed5a7f57c fix(tags): make "Select all N matching" a visible secondary button
Impeccable critique (P1): the escalation was a bare blue text-link on the bar's
pale-blue tint, no persistent underline — invisible next to the navy primary,
and it's the exact action users hunt for. Promote it to a secondary button
(white fill + brand-navy border + check icon, fills on hover) so it stands out
without competing with "Choose a tag"; demote "Clear selection" to a legible
neutral underlined link (was blue-on-blue).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:21:56 +00:00
Jun-te Kim
0933f05173 fix(reporting): EPC cards count certificates, not predictions (ADR-0014)
"Homes Without an EPC" and "Expired EPCs" were both derived from estimatedSql,
which asks "was this picture gap-filled?". That conflates two different questions:
provenance (was it modelled?) and coverage (does a certificate exist?).

They used to agree, because a gap-fill implied no certificate. Model ADR-0054 ends
that: the gov EPC API only serves certs registered since 2012, so a pre-2012
dwelling is gap-filled even though a real, stale certificate exists — and gets the
new source='expired'. On portfolio 824, 375 of the 403 "without an EPC" homes have
exactly that: a real certificate, just an out-of-date one. A different, and far more
actionable, problem than never having been certified.

Two defects fell out of the same conflation:

  - Every query matched source='predicted' literally, and `source` is plain text
    with no enum or CHECK. An 'expired' row matches neither the lodged nor the
    predicted alias, so the home would have read as having a VALID CURRENT cert —
    silently gone from both cards, no Estimated badge, nothing to catch it.
  - isExpiredSql read epl.registration_date alone, but 714 of 824's 4,873 lodged
    certs carry only inspection_date. All 714 counted as current; 170 are in fact
    >10 years old. lodgementDateSql already coalesced the two; expiry didn't.

The cards now partition on coverage: withoutEpcSql = no lodged AND no historical
record; isExpiredSql = a cert of either kind, out of date. estimatedSql keeps its
own meaning and spans the whole predicted slot, so an expired home is estimated AND
has an EPC — both true. The `AND estimated = false` guard is gone from the Expired
card: an expired home IS estimated, so it excluded the very homes being counted.

Legacy portfolios are untouched (632: 8/1662 before and after). 824 moves to
913 → 1,083 expired immediately (the inspection_date fix), then to 25 / 1,461 once
Model writes expired rows. Safe to deploy BEFORE Model — the predicates just find
none. The reverse was not safe, which is why this goes first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:19:02 +00:00
Khalim Conn-Kowlessar
a7781eca81 fix(tags): header checkbox selects all loaded rows, not just the page
With a 7-row page size, the header "select all" (toggleAllPageRowsSelected)
only selected the visible 7 — so it never reached "all loaded", the condition
that reveals the "Select all N matching" escalation. It now selects every loaded
row (toggleAllRowsSelected):
- when everything matching is already loaded, that IS the whole set (tag via ids);
- when more match than are loaded (e.g. 250 loaded of 10,000), the escalation
  appears and routes through mode:"filter" — the server resolves and tags the
  entire matching set (getFilteredPropertyIds, chunked), so memory is never the
  ceiling and no huge id list is sent from the client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:12:05 +00:00
Khalim Conn-Kowlessar
99025ce4b3 feat(tags): explicit select-all-matching in bulk tag mode
The header checkbox only selects loaded rows, so bulk-tagging a portfolio with
more properties than are loaded (250-row window) silently tagged just the page.

Bulk mode is now explicit (Gmail pattern): nothing selected = no target (Choose
a tag is disabled), so a tag can't be applied to everything by accident. Tick
rows to target them; once every loaded row is ticked and more match, a "Select
all N matching" escalation switches the target to the whole matching set,
resolved server-side via the existing assignments filter mode (no backend
change). Any manual checkbox change drops the escalation back to the explicit
selection; changing filters / leaving the mode clears it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:34:37 +00:00
KhalimCK
fc5fc80d88
Merge pull request #389 from Hestia-Homes/feature/portfolio-tagging
feat: portfolio tagging system (ADR-0013)
2026-07-14 11:52:58 +01:00
Khalim Conn-Kowlessar
0b4f88165c feat(live): add four more planning fields as toggleable columns
Surface Design Constraints, Planning Comments, Planning Status, and
Planning Suggested Approach in the Live Reporting properties table,
following the same read-path wiring as the previous planning columns
(query mapping, HubspotDeal type, column defs, hidden-by-default toggle,
CSV export, test fixtures). DB columns already exist via migration 0267.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:33:46 +00:00
Khalim Conn-Kowlessar
8d8d4b7fa1 feat(live): add planning fields as toggleable property columns
Surface the four planning columns from hubspot_deal_data — Planning
Authority, Designated Area, Article 4 PD Rights, Listed Building — as
optional columns in the Live Reporting properties table.

The DB columns already exist (migration 0267); this wires them through
the read path only: query mapping, HubspotDeal type, column defs, the
column-visibility toggle (hidden by default), and CSV export. Rendered
as plain text since they are text columns in HubSpot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:10:25 +00:00
Khalim Conn-Kowlessar
971767a62a fix(portfolio): don't show Run modelling twice in the no-plans state
The not-modelled banner and the toolbar both rendered a navy "Run modelling"
CTA whenever a portfolio had properties but no plans — two identical buttons.
The banner owns the action in that state, so the toolbar's Run modelling is now
hidden while the banner is up (it returns once everything is modelled or the
banner is dismissed). One primary CTA at a time, as intended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:30:58 +00:00
Khalim Conn-Kowlessar
af171f50d7 feat(tags): only show row-select checkboxes while bulk-tagging
The selection checkbox column was persistently visible. Now it appears only
when a bulk assign/remove mode is active (launched from the Tags menu) via a
select-less column set, and the selection clears on entering and leaving the
mode. The default table has no checkbox column; per-property tagging still uses
the inline Tags-cell popover, and the bulk bar's target still follows selection
→ filter → whole portfolio while the mode is open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:10:02 +00:00
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