Commit graph

1813 commits

Author SHA1 Message Date
Daniel Roth
a7a1f8ee98 feat(ara-projects): projects list, empty state and create-project modal
Setup wizard step 1/5 (#409).

The list is a Server Component, so the visibility rule runs before anything
reaches the browser and a project the user may not see is never serialised
to it. The rule itself is not restated: `listVisibleProjects` loads each
project's facts and asks `canViewProject` from `@/lib/projects/authz`.
Writing the `domna_admin_access OR owning-org OR contractor-org`
disjunction a second time in SQL would have given it two definitions free
to drift, and the acceptance criteria (client sees its own, contractor sees
what it is assigned to, internal sees all subject to `domna_admin_access`)
fall out of the one definition instead of being re-derived.

The cost is filtering in the app rather than the database, over two round
trips and no N+1. That is the right trade while a project is a works
programme and they number in the tens; if the table grows, the fix is a
superset prefilter that still lets `canViewProject` decide, not a WHERE
clause that re-implements it. Flagged in the function's own docs.

Creation reuses the same guard rather than inventing a "can create" rule:
`prospectiveProjectFacts` builds the facts the project *would* have, and
`canManageProject` judges them. One consequence is worth knowing, and is
covered by a test — `domna_admin_access` gates the `internal` role, so a
Domna user creating for an organisation they are not a member of must grant
that access. Without it they would be creating a project they could not
then see, and the guard refuses rather than producing an orphan. The modal
offers only organisations the user may pick, but the route handler re-checks
the submitted one: a hidden option is not a permission.

`createProjectSchema` is shared by the form (as a zodResolver) and the route
handler (as the body parser), so the two cannot disagree about what a valid
draft is. Empty strings from untouched inputs normalise to undefined rather
than reaching the columns as "".

Also updates projects-shell.cy.js. Its "renders the per-project dashboard
shell" test passed only because the authz stub could never return false;
with the real lib wired in, /projects/<id> is a 404 without standing on
that project, so the test now asserts that instead.

TESTS: the vitest unit tests pass (50, no database connection). Cypress was
NOT run — this environment's DATABASE_URL points at production. The new
spec is therefore unverified, and its end-to-end half, which INSERTs, is
gated behind CYPRESS_ARA_PROJECTS_E2E_WRITES so it cannot fire by accident;
it also needs the #407 seed migration (0274) applied, which has not been run
against any database yet either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:52:53 +00:00
Daniel Roth
2ba36470df refactor(projects): delete the authz stub, guard via the real lib
`src/app/projects/authz.ts` was a placeholder from the route-shell work
(#406): session checks only, with a TODO(#408) to replace its bodies once
the real rules landed. #408 has landed, so it goes.

The real lib (`src/lib/projects/authz.ts`) is deliberately pure — it takes
project facts explicitly and cannot read a session, hit the database, or
redirect. So the four route files do not import it directly; they import a
thin glue layer, `src/app/projects/guards.ts`, which resolves the session
and the facts, delegates every decision to the lib, and turns a refusal
into a Next.js navigation. It holds no permission rules of its own.

Two behaviour notes:

- The redirect-on-no-session behaviour is preserved as-is: no session (or
  a session whose email has no `user` row) still redirects to `/`, matching
  `src/middleware.ts`.
- Denial of a specific project now calls `notFound()` instead of
  redirecting to `/projects`, which is what the stub's own TODO asked for.
  It was redirecting only because `canViewProject` could never return
  false; now it can, and a 404 is what keeps Project existence from
  leaking to users outside its organisation.

`loadAuthzUserByEmail` joins the repository because the session carries no
usable id — `session.user.dbId` is set by the `jwt` callback at initial
sign-in only, so sessions issued before that field existed lack it. Email
is unique on `user`, so it resolves the same row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:39:35 +00:00
Daniel Roth
fa4f3f3ab0
Merge pull request #437 from Hestia-Homes/issue-408-projects-authz
Some checks are pending
Test Suite / unit-tests (push) Waiting to run
feat(projects): shared authorization lib for Ara Projects
2026-07-21 17:26:48 +01:00
Daniel Roth
7e54e64125
Merge pull request #436 from Hestia-Homes/issue-407-seed-reference-data
feat(ara-projects): seed project_type and workstream reference data
2026-07-21 17:26:37 +01:00
Daniel Roth
b7a7391908
Merge pull request #435 from Hestia-Homes/issue-406-projects-route-shell
feat(ara-projects): /projects route shell, layout and auth guard
2026-07-21 17:26:27 +01:00
Daniel Roth
1ce8a65faf refactor(projects): move authz persistence into a repositories layer
Reorganises the authz lib along DDD lines by separating the persistence
boundary from the domain:

  src/lib/projects/authz.ts                        domain — pure decisions
  src/app/repositories/projects/authzRepository.ts persistence — Drizzle

The repository sits alongside src/app/db/ and mirrors the existing
db/schema/projects/ layout, so schema and repository stay symmetrical as
Ara Projects grows more of both.

Dependencies point one way: the repository imports the domain's fact types
and returns them, so the domain never learns a table shape and continues to
import no database client. The 34 guard tests still run with no connection.

No behaviour change — the queries and guards are untouched. Note this is a
new pattern for the codebase: the prevailing convention is a colocated
queries.ts/server.ts per lib folder, which 88 files still follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:12:26 +00:00
Daniel Roth
c482f2d6e8 fix(ara-projects): require user onboarding before /projects
Code review: /onboarding is *user* onboarding, not Portfolio onboarding.
It captures the user's details and their required privacy-policy
acceptance (plus marketing opt-in) and touches no Portfolio at all —
confirmed by the form schema in src/app/onboarding/page.tsx, which has
no portfolio coupling and makes acceptedPrivacy mandatory.

The exemption added in e34f166f was therefore wrong: it let contractor
users into the app without the consent we are required to capture. A
contractor can complete onboarding perfectly well, so nobody needs the
bypass.

Removes the /projects exemption and isProjectsPath(). /projects stays in
the matcher, so the tree is still auth-guarded. Unit and Cypress tests
inverted to assert the corrected behaviour, plus a new case covering an
onboarded contractor reaching /projects.

Refs #406

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:08:47 +00:00
Daniel Roth
a1ecbd0fc0 feat(ara-projects): seed project_type and workstream reference data
Adds migration 0274, a data-only custom SQL migration seeding the two
reference tables the setup wizard needs (#407):

  - project_type: Retrofit, Planned Maintenance
  - workstream:   the canonical 8 from the UX wireframes, each with a
                  one-line description (the column is NOT NULL)

Note that #407 specifies the second project type as "New Build"; PR review
superseded that with "Planned Maintenance". The ticket text is stale, not
the code.

Idempotency uses INSERT ... SELECT ... WHERE NOT EXISTS rather than
ON CONFLICT DO NOTHING, because neither table has a unique constraint on
`name` — an ON CONFLICT (name) form would fail at runtime with "no unique
or exclusion constraint matching the ON CONFLICT specification". Re-running
is a no-op and leaves existing rows, including edited descriptions, alone.

The 0274 snapshot is a copy of 0273 with chained id/prevId, matching how
the previous data-only migration (0227) was recorded.

No UI for creating workstreams — deferred to #428.

UNVERIFIED AGAINST A LIVE DATABASE. This environment's DB credentials point
at production, so the migration was deliberately not executed. It was
verified statically only: both statements parse against the PostgreSQL
grammar (libpg_query), the target tables and columns were cross-checked
against the 0273 schema snapshot, every NOT NULL column without a default
is supplied, and the seeded values match the intended set exactly. A human
needs to run it against a non-production database and confirm both the
fresh-DB and re-run cases before it ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:06:42 +00:00
Daniel Roth
e34f166f9c feat(ara-projects): /projects route shell, layout and auth guard
Ara Projects gets its own top-level route tree rather than living under
the /portfolio/[slug] chrome: Projects are organisation-scoped
(project.organisation_id) and contractor users from external orgs will
work in these screens without ever having a Portfolio.

- src/app/projects/layout.tsx: shell + top-level nav
- src/app/projects/page.tsx: list stub (real list lands with #408's
  visibility rule)
- src/app/projects/[projectId]/{layout,page}.tsx: per-project nav
  (Dashboard, Work orders, Import, Settings) + dashboard shell
- ProjectsNav: the Toolbar.tsx shadcn navigation-menu pattern, taking
  its items as a prop so both tiers share one implementation

Middleware: adds /projects/:path* to the matcher and exempts the tree
from the portfolio onboarding redirect. A contractor is never
`onboarded`, so without the exemption they would be bounced to
/onboarding on every request with no way out. The decision is split into
a pure routeAuthenticatedRequest() so the rule is unit-testable without
minting a JWT.

Authorization is a thin seam in src/app/projects/authz.ts with TODOs
referencing #408 — the shell deliberately does not inline permission
logic, and user->organisation resolution does not exist yet either.

Tests: 8 unit tests over the middleware routing rules; Cypress smoke
test for the unauthenticated redirect, the authenticated render, the
contractor path, and the per-project shell.

Refs #406

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:56:29 +00:00
Daniel Roth
f358b874df feat(projects): shared authorization lib for Ara Projects (#408)
Adds src/lib/projects/authz.ts as the single source of permission truth
for every Ara Projects route handler — no route re-implements this inline.

Ara Projects are organisation-scoped, so none of the portfolio-scoped
team_portfolio_permissions machinery applies. Membership resolves through
team_members -> team -> org_id instead.

Role resolution is most-privileged-first: internal (@domna.homes) >
client (member of the owning org) > contractor (member of an org assigned
to any of the project's workstreams) > no access. project.domna_admin_access
gates the internal role only — with it off, a Domna user falls through to
whatever their own memberships earn them rather than losing access outright.
Client outranks contractor when one org is both owner and deliverer.

The two work-order guards check the permission flag on the *assignment*
rather than on the organisation: a contractor needs membership of that
work order's own assigned org plus the relevant flag, so holding a
permissive assignment on a sibling workstream grants nothing.

Structure follows the repo's model/queries split — authz.ts is pure and
imports no database client, so all 34 guard tests run on in-memory
fixtures with no connection. DB-backed fact loaders (getUserOrganisations
and friends) live in authzQueries.ts.

Note the guard signatures take the project facts explicitly —
canUpdateStage(user, project, workOrder) rather than the (user, workOrder)
in the ticket — since the role can only be resolved against the project.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:52:07 +00:00
Daniel Roth
a0680b0eea
Merge pull request #434 from Hestia-Homes/add-herdr-to-devcontainer
Add herdr to devcontainer, plus latest agentic-toolkit version
2026-07-21 11:31:57 +01:00
Daniel Roth
5342186ca4 pull latest agentic-toolkit version 2026-07-21 10:05:23 +00:00
Daniel Roth
02f6dc265e
Merge pull request #433 from Hestia-Homes/docs/ara-projects-domain-language
docs(ara-projects): ADR-0018 + CONTEXT.md domain language (#405)
2026-07-21 10:33:37 +01:00
Daniel Roth
b82b234fb2 add herdr to devcontainer 2026-07-21 08:55:30 +00:00
Daniel Roth
6d5598318e docs(ara-projects): ADR-0018 + CONTEXT.md Ara Projects domain language
Records that Ara Projects supersedes the HubSpot Live projects feature
long-term (coexisting for now, no v1 migration) and reserves the top-level
/projects namespace. Adds the canonical Ara Projects glossary to CONTEXT.md,
matching src/app/db/schema/projects/schema.md, and qualifies the ambiguous
'Project' references (HubSpot Project vs Ara Project).

Closes #405

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 08:30:32 +00:00
KhalimCK
0dd602070e
Merge pull request #403 from Hestia-Homes/fix/bulk-tag-upload-leading-zeros
fix(tags): preserve leading zeros in bulk tag CSV uploads and wire up…
2026-07-20 17:32:36 +01:00
KhalimCK
2e203c7d17
Merge pull request #430 from Hestia-Homes/docs/ara-projects-wireframes
Add Ara Projects UX wireframes
2026-07-20 17:32:11 +01:00
KhalimCK
b8a0743ddc
Merge pull request #431 from Hestia-Homes/fix/major-condition-issue-flag
fix(live): flag major condition issues by the issue, not a retired stage / S3 URL
2026-07-20 17:31:48 +01:00
Khalim Conn-Kowlessar
7cf6316431 refactor(live): remove the dead majorConditionDeals plumbing
majorConditionDeals was computed in computeLiveTrackerData and threaded through
LiveTrackerProps → LiveTracker → AnalyticsView, but never rendered — the
intended "Awaab's Law card" was never built. With the rendered "Flagged at
Survey" count now keyed on the same hasMajorConditionIssue signal, this set is
redundant. Removed the const, the LiveTrackerProps field, the prop plumbing in
LiveTracker + AnalyticsView, and its two tests. hasMajorConditionIssue stays
(used by the survey-flag count).

Typecheck + lint clean; live-tracker tests (64) + full suite (500) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:10:33 +01:00
Khalim Conn-Kowlessar
ae6a6ebf68 fix(live): flag major condition issues by the issue, not a retired stage / S3 URL
24 Charlesworth Street (deal 507693469922) has a major condition issue recorded
(major_condition_issue_description = "Damp in bathroom") but didn't appear in the
live tracker's condition-issue surfaces. Two causes, both now keyed on the issue
itself via a shared hasMajorConditionIssue(deal) predicate:

- majorConditionDeals filtered on dealstage === "3061261536" — a HubSpot stage
  that was retired, so the set was always empty. (This set is also not yet
  rendered in any card — noted for follow-up.)
- The rendered "Damp, Mould & Other Condition Issues → Flagged at Survey" count
  (computeDampMouldRisk.surveyFlagDeals) keyed on major_condition_issue_evidence_s3_url,
  which is null for this deal (its photos are in the raw HubSpot field). This is
  the count the property was actually missing from.

There is no yes/no column; the description is the "Yes" signal (only filled when
an issue exists). Removed the now-dead MAJOR_CONDITION_STAGE_ID. TDD:
red→green→refactor; 66 live-tracker tests, full suite (502) + typecheck + lint green.

Also gitignore .sandcastle/ (untracked agent worktree scratch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:42:55 +01:00
Daniel Roth
a0f865ad85 Add Ara Projects UX wireframes under docs/wireframes
Static Stitch export previously only available locally; referenced by the
Ara Projects issues (#405-#429). README explains which parts are spec vs
placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:15:21 +00:00
Daniel Roth
fe77a2e74e
Merge pull request #404 from Hestia-Homes/feature/ara-projects-schema
Feature/ara projects schema
2026-07-20 13:31:02 +01:00
Daniel Roth
fa9461167b Rework Projects schema after PR feedback
Post-PR structural changes to the Projects module schema (migration
regenerated; not yet applied):

- Delete the contractor table. Contractors are organisations:
  project_workstream_contractor.contractor_id -> organisation_id
  (uuid -> organisation).
- Rename project.client_id -> organisation_id (uuid -> organisation).
- uploaded_files: drop project_workstream_id and
  project_workstream_contractor_id; keep
  project_workstream_evidence_requirement_id; add nullable work_order_id
  (evidence is per work order, not per project workstream).
- Rewire relations accordingly and regenerate migration 0273.
- Reconcile schema.md with all of the above.

Note: deleting contractor drops its phone_number / email_address, which
have no equivalent on organisation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 11:27:16 +00:00
Daniel Roth
10992f41ae Note the file_type reuse trade-off in schema.md Evidence Model
Record that, because evidence requirements reference the file_type enum
rather than a document_type table, adding a new required document kind is
a schema change (enum value) rather than a data insert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:29:05 +00:00
Daniel Roth
aa0ac24868 Drop document_type table; reuse file_type enum for evidence requirements
Evidence requirements now reference the existing uploaded_files file_type
enum instead of a separate document_type reference table, so the required
and submitted document kinds share one taxonomy.

- Remove the document_type table and its relations.
- project_workstream_evidence_requirement.document_type_id ->
  file_type (file_type enum, NOT NULL).
- Extract fileType/fileSource enums into uploaded_files_enums.ts so the
  Projects module can reference fileType without an import cycle
  (uploaded_files.ts imports the Projects tables for its FK columns).
  uploaded_files.ts re-exports the enums for backwards compatibility.
- Regenerate migration 0273 (now 10 Projects tables, not 11).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:26:00 +00:00
Daniel Roth
6f9097d293 Fix drizzle-kit schema glob and generate Projects migration
drizzle-kit recurses the schema directory and tries to compile every
file as JS/TS. The new projects/schema.md broke generation with a
SyntaxError. Narrow the glob to *.ts so docs can live alongside schema.

Generate migration 0273 for the Projects module: 11 new tables, the
three nullable uploaded_files FK columns, and the new "projects"
file_source enum value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:36:08 +00:00
Jun-te Kim
042e082025 fix(tags): preserve leading zeros in bulk tag CSV uploads and wire up drag-and-drop
landlord_property_id values like "07510027001" were being read via SheetJS
without raw:false, so CSV parsing coerced numeric-looking cells to numbers
and dropped leading zeros. Also wires up drag-and-drop on the bulk tag
upload dropzone, which was styled as one but only supported click-to-browse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 15:23:47 +00: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
Daniel Roth
b21be2e5cd Reconcile Projects schema.md with implementation decisions
Update the proposal doc to match what was actually built:
- client_id is uuid -> organisation (Client maps to organisation).
- property_id FKs are bigint (Property has a bigint PK).
- status/priority columns documented as text (values TBC).
- Drop the free-text uploaded_by column; note the existing bigint
  FK -> user is reused.
- Document the new minimal project_type table.
- Record that a "projects" file_source value was added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:17:41 +00:00
Daniel Roth
e452fc9ab2 Add Projects module database schema
Introduce the Projects domain model as Drizzle table definitions
(schema-only; no migration generated/run yet).

New tables in src/app/db/schema/projects/projects.ts: project,
project_type, project_property, contractor, workstream,
project_workstream, project_workstream_stage,
project_workstream_contractor, document_type,
project_workstream_evidence_requirement, work_order.

Also:
- uploaded_files: add "projects" file_source value and three nullable
  bigint FK columns linking evidence to the Projects module.
- relations.ts: wire the Projects relations.
- db.ts: register the new schema module.

Deviations from schema.md, decided during implementation:
- client_id is uuid -> organisation (Client maps to organisation).
- property_id FKs are bigint (Property has a bigint PK, not integer).
- TBC status/priority columns modelled as text for now.
- No new uploaded_by column; reuse the existing bigint FK -> user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:14:30 +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