Commit graph

1930 commits

Author SHA1 Message Date
Daniel Roth
939cfcbee5 Classify by design-document presence through the whole pipeline 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:50:30 +00:00
Daniel Roth
0acc9f5211 Gate Installation in Progress on Retrofit Design Document presence 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:49:36 +00:00
Daniel Roth
11a542b973 Gate Installation in Progress on Retrofit Design Document presence 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 13:47:34 +00:00
Daniel Roth
0d8adad1d9
Merge pull request #453 from Hestia-Homes/issue-413-wizard-contractor-assignment
feat(ara-projects): setup wizard 5/5 — contractor assignment and permissions
2026-07-23 17:01:12 +01:00
Daniel Roth
feee863010 feat(ara-projects): unroll the permission detail instead of popping it (#413)
The sub-row's wrapper is a one-row grid whose track animates 0fr → 1fr over
160ms, so the detail unrolls to whatever height its own content needs — no
measuring, no ref, and no effect to start it: mounting runs the animation.
Rows below slide down with it rather than jumping. motion-reduce:animate-none
respects a reduced-motion preference.

Adds the `disclosure-open` keyframe to tailwind.config.js — namespaced and
additive, affecting nothing else. tailwindcss-animate is installed but not
registered in `plugins`, so the animate-in/slide-in-from-* utilities the
shadcn components reference generate no CSS; enabling that plugin would
change animation behaviour across every dialog and tooltip in the app, which
is not this ticket's call to make.

Collapsing is still immediate — React unmounts the row, so there is no exit
animation without holding it mounted through one.
2026-07-23 15:55:49 +00:00
Daniel Roth
44774febe5
Merge pull request #451 from Hestia-Homes/issue-411-wizard-stage-configuration
feat(ara-projects): setup wizard 3/5 — stage configuration
2026-07-23 16:53:03 +01:00
Daniel Roth
d15eee3958 feat(ara-projects): drop the ladder header's terminal-stage sentence
The last row already says "Work is complete here", right next to the stage
it is talking about. Repeating it in the card header named the same stage a
second place away from itself, which is a place it can go stale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:48:51 +00:00
Daniel Roth
2fe4b5ea17 style(ara-projects): let the permission detail fill the expanded row (#413)
Drop the "What <contractor> may do" heading and the "Set per workstream —
Edit to change them" footnote, and span the detail table across all four
columns with no padding, so it fills the expanded space rather than sitting
inset within it. Its header row and cell padding now line up with the table
above.
2026-07-23 15:48:50 +00:00
Daniel Roth
b396aabb91 feat(ara-projects): workstreams down the left, actions in the header
The workstream list moves from above the ladder into a column beside it, as
the wireframe has it. Above the ladder its height pushed the thing being
edited off the fold; in a column it costs nothing, and a catalogue of forty
scrolls inside that column rather than growing the page.

The Back / primary buttons move up onto the title row, which is where the
wireframe puts them and which saves another row of height — the screen now
fits a laptop without scrolling. The "Next: <workstream>" button goes: with
every workstream visible in the left column, stepping is a click on the next
one, and it was competing with the primary action for attention.

Primary action is now "Submit All", with a note under it saying changes
already save as you go — it moves on, it does not save.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:38:45 +00:00
Daniel Roth
6d662e81bd feat(ara-projects): edit and show permissions per workstream (#413)
The switch was the wrong control. project_workstream_contractor stores both
flags per (workstream, organisation) pair, so one switch stood for n booleans:
it could report a per-workstream split but never author one, and clicking it
destroyed the split to stay decidable. The schema's central capability was
unreachable from the only screen that configures it.

Add/edit dialog is now a grid: one row per workstream the *project* covers —
not only the ones this contractor holds — with a tick to assign and a tick per
permission. Unassigned rows stay visible and greyed, so the shape of the
project and its uncovered workstreams are part of the decision. Un-ticking a
workstream clears its permissions, so re-ticking never silently restores a
grant. Column headers carry an all/none shortcut.

The main table no longer shows permissions at all: contractor, workstreams,
Actions. Clicking a row (or its disclosure button, which carries
aria-expanded/aria-controls for keyboards and screen readers) opens a sub-row
listing each assignment with its own two permissions, read-only. Several may
be open at once. Actions stay per contractor — Edit and Remove act on the
whole set.

Wire shape follows: POST/PATCH take assignments[{projectWorkstreamId,
updateStagesPermission, uploadDocumentsPermission}]. A workstream named twice
is a 400 — two answers to one question. ContractorGroup drops its
per-contractor summary and permissionState is gone; nothing needs the flags
reduced to one answer, and reducing them is how a contractor allowed on one
workstream comes to look allowed everywhere. Updates are grouped by flag pair,
so twenty workstreams still cost at most four UPDATEs.

ADR-0022 records this; ADR-0021's decision 1 is marked superseded, its other
two (removal never cascades; Continue leads to the import) stand.
2026-07-23 15:34:06 +00:00
Daniel Roth
3cad369315
Merge pull request #454 from Hestia-Homes/issue-419-admin-work-orders-table
feat(ara-projects): admin work-orders table (filters + pagination)
2026-07-23 16:27:36 +01:00
Daniel Roth
23c7b1dcdf fix(filters): bucket the oil boiler heating archetypes as Oil, not Unknown
#455 added three main_heating_system values — "Oil boiler, regular", "Oil
boiler, combi" and "Solid fuel boiler" — without a matching needle. The two
oil ones fell through every bucket to Unknown, which the vocabulary-coverage
test caught: an overridden component that displays but cannot be filtered
(ADR-0012).

The gap is specific to these archetypes. Every other oil value carries its
fuel *after* a comma ("Boiler and radiators, oil"), which the ", oil" needle
matches; these lead with it. Bare "oil" is unusable as a needle because it is
a substring of "b(oil)er", so the fix is the narrower "oil boiler" — which
cannot collide with "solid fuel boiler", and Solid Fuel is checked first
regardless. "Solid fuel boiler" already bucketed correctly.

One edit fixes both halves: the portfolio query's SQL bucketing reads the
same DESCRIPTOR_FILTER_CONFIG rather than restating the needles, so the
classifier and the SQL cannot drift here.

Also asserts these three by *bucket* rather than relying on the coverage
test's "not Unknown" — the failure that matters is a wrong bucket, and a
reordered needle list would silently make "Oil boiler, combi" a Gas Boiler
while the coverage test kept passing.
2026-07-23 15:19:28 +00:00
Daniel Roth
d5fee8902f chore(db): migration for work_order.priority text -> boolean (#419)
Generated with drizzle-kit, then hand-edited for two defects in its output.
The snapshot is left exactly as generated, so future diffs stay accurate;
only the SQL is corrected.

  1. drizzle emitted a bare `SET DATA TYPE boolean`, which Postgres refuses
     on a text column ("cannot be cast automatically to type boolean").
     Added the USING clause that performs the cast.
  2. drizzle recorded DEFAULT false in 0280_snapshot.json but left it out of
     the SQL. Applied as generated, the column would be NOT NULL with no
     default while drizzle believed a default existed — so no later diff
     would ever add it, and an insert omitting priority would fail.

Also added a NULL backfill ahead of SET NOT NULL. work_order held 0 rows
when this was written, so both the cast and the backfill are no-ops today;
they matter only if the programme import (#417) lands rows first, in which
case a value that will not cast should fail the migration loudly rather than
be coerced into a wrong answer.

Not applied. Running it against the shared production database is the repo
owner's call.
2026-07-23 15:13:09 +00:00
Daniel Roth
e92f6e91db feat(ara-projects): let stage setup be left at any point, not walked through
Continue is no longer gated on reaching the last workstream. Every
workstream is seeded with the standard ladder on arrival, so the screen
opens in a state that is already correct for a project happy with the
defaults — making someone step through forty workstreams to confirm they
changed nothing is a toll, not a safeguard. The real gate is the import
readiness check (#414), which re-derives from the database anyway.

So the picker becomes the primary navigation (jump to the two ladders you
care about), "Next: Doors" demotes to a secondary convenience for anyone who
does want to review each one, and the summary line says what leaving now
would leave behind — "All 8 on the standard ladder", or a warning naming the
workstreams whose missing stages will block import.

The picker also has to survive a catalogue that size: it is capped at a
fixed height, scrolls, and grows a filter box past ten workstreams so it
can't push the ladder being edited off the screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:11:05 +00:00
Daniel Roth
d3772c3f7f Merge remote-tracking branch 'origin/main' into issue-419-admin-work-orders-table 2026-07-23 15:07:33 +00:00
Daniel Roth
069464dad3
Merge pull request #452 from Hestia-Homes/issue-412-wizard-evidence-checklist
feat(ara-projects): setup wizard 4/5 — evidence checklist + SharePoint fields
2026-07-23 16:02:13 +01:00
Daniel Roth
7b65eae7bd feat(ara-projects): step through workstreams, and drop the per-stage dates
Ladders are no longer stacked. A project with eight workstreams has forty
stages, and one page of them turns configuring the last workstream into a
scroll — so one ladder is on screen at a time, the "Workstream overrides"
panel doubles as the picker, and the footer walks forwards through them
before it offers to leave. The stages *within* a ladder stay listed
together: they are dragged against each other, so they have to be seen
together.

`start_date` / `due_date` come out end-to-end — inputs, request schemas,
payload, insert and update — rather than being hidden in the UI. A stage is
a rung of a workflow, not a piece of scheduled work, so it has no date to
hold; delivery dates already belong to a work order. The columns stay on the
table and stay NULL until their keep-or-drop decision is made. This
supersedes one of #411's acceptance criteria, recorded in ADR-0021.

`updateStage` collapses to `renameStage` — the name is now the only field of
a stage a user owns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 15:00:14 +00:00
KhalimCK
25bec3de7d
Merge pull request #455 from Hestia-Homes/feat/oil-solid-boiler-archetypes
feat(db): oil & solid-fuel boiler archetypes for main_heating_system
2026-07-23 15:58:47 +01:00
Daniel Roth
0e69c080e1 feat(ara-projects): make work_order.priority a boolean and show it in the table (#419)
priority was one of the "enum values TBC" text columns. It turns out to be a
flag, not a scale — "look at this one first" — so there is no value set left
to agree, and text was modelling a decision nobody needs to make. Now
boolean NOT NULL DEFAULT false: a third "unknown" state has no meaning for a
flag, and the table either renders it or renders nothing.

Deliberately independent of overdue. Overdue is derived from the forecast
date and the stage ladder; priority is stored, because someone marked it. A
flagged work order may be well inside its dates and a late one nobody
flagged is not a priority, so they are separate columns and separate badges.

Read path: selected and grouped in the page query, carried through
RawWorkOrderRow -> WorkOrderRow, rendered as the wireframe's Priority column
between Docs and the row action. Shown only when true; a column of "No"
chips would give the exception the same weight as the rule.

Docs updated where they claimed otherwise: CONTEXT.md gains a Priority entry
and no longer lists the column as untouched, and schema.md no longer groups
it with the TBC-enum text columns.

MIGRATION NOT INCLUDED AND NOT APPLIED. This commit changes the Drizzle
schema only; work_order.priority is still `text` in the shared production
database, so the boolean the code expects is not there yet. Generating and
applying the migration is the repo owner's call. Two notes for whoever does:
the table holds 0 rows today (verified by a read-only SELECT), so the change
is lossless; and Postgres refuses ALTER COLUMN ... TYPE boolean on a text
column without a USING clause, which drizzle-kit will not emit.
2026-07-23 14:55:29 +00:00
Daniel Roth
4bec5b72d2 feat(ara-projects): rename the stages permission and explain "mixed" (#413)
Rename: "Update stages" → "Update stages of workstream(s)", in the table
header and the add/edit dialog. (Requested as "Updaet" — corrected to
"Update"; say the word if the typo was deliberate.)

"Mixed" on its own raised more questions than it answered — mixed how, and
what happens if I click it? It is now explained at three depths, all fed by
one new pure helper (splitByPermission, unit-tested):

- The chip carries the count — "Mixed · 2 of 3" — needing no interaction.
- Its tooltip names which workstreams allow the permission and which don't,
  and states what the switch will do: grant to all N, or revoke from all N.
- The switch's accessible name says the same in a sentence, so a screen
  reader never depends on hover. role="switch" admits no aria-checked="mixed"
  (ARIA 1.2), which is the other reason it has to be in the name.

The edit dialog gets the same treatment: rather than a bare "permissions
differ" note, it names each split before the user overwrites it.

The tooltip states plainly that saving clears the split, because no surface
in v1 writes divergent flags — per ADR-0021 they are readable, not authorable
here. PermissionToggle now takes an explicit testId rather than deriving one
from the label, so copy changes don't move the test hook.
2026-07-23 14:50:51 +00:00
Khalim Conn-Kowlessar
f1d4398fc6 feat(db): add oil & solid-fuel boiler archetypes to main_heating_system
A fuel-agnostic "Boiler" description defaults to a Gas boiler archetype, which is
wrong when the property's main_fuel is oil or a solid fuel — the enum had no
non-gas/non-electric wet-boiler member, so ~43 properties in portfolio 796 sit as
"Gas boiler, regular/combi" on oil / house coal / wood logs / dual fuel.

Adds `Oil boiler, regular`, `Oil boiler, combi`, `Solid fuel boiler` (+ ALTER TYPE
migration 0279). Fuel granularity (coal/wood/dual) is carried by the separate
main_fuel column, applied on top of the archetype (ADR-0041), so one solid-fuel
boiler member suffices.

Inert until the Model backend adds matching members + overlay handling and a
fuel-aware classifier mapping (Model#1676); the 43 existing rows get a
per-property, fuel-joined backfill afterwards.

Open question for the backend grilling: whether solid fuel should split into
biomass/wood vs mineral coal archetypes, or stay one member with the fuel column
driving carbon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:39:19 +00:00
Daniel Roth
235af64b43 feat(ara-projects): drag stages to reorder instead of up/down buttons
Rows are dragged by a grip rather than by their body — the body is full of
text inputs, and a row that dragged from anywhere would fight every attempt
to select a stage name.

@dnd-kit rather than native HTML5 drag events, because it brings a keyboard
path (tab to grip, space to lift, arrows, space to drop) and screen-reader
announcements with it. Without that, removing the up/down buttons would have
left the ladder mouse-only.

The drop writes the new order into the query cache before the request goes
out and rolls back if the server refuses it. Otherwise the row springs back
to where it started for as long as the PATCH takes, which reads as "the drag
didn't work" rather than "the drag is saving".

`moveStage` and its tests go with the buttons — dnd-kit's `arrayMove` does
that job now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:28:07 +00:00
Daniel Roth
df8e0315cb fix(ara-projects): align the add-document composer with the table it feeds (#412)
Three complaints, one cause: the composer was a separate block trying to
look like part of the table. It now *is* part of it — a `<tfoot>`, so its
fields sit in the same columns as the rows by construction, and the
table's own headings label them. No second caption set to drift out of
line, and no unlabelled fields either.

Its controls belong to a form declared below the table and are wired to
it by `form=`, because a `<form>` cannot sit between a `<table>` and its
rows; Enter still submits.

It stays distinct: a 4px rule above it, a tinted band, and its own "Add a
document" caption spanning the width.

Also: selects get `pr-8` so the native chevron stops overlapping the
option text, the Stage column widens to `w-52`, and the last column
widens to fit the Add button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:24:02 +00:00
Daniel Roth
5c94a538ed refactor(ara-projects): make the add-document composer read as a form (#412)
Labelled cells sitting directly under the table's own headings read as a
second header row: two near-identical sets of words that never quite
lined up, worst of all when the table was empty and they were the only
two things on screen.

The composer now announces itself — a thick divider, its own "Add a
document" heading — and lays its controls out as a form rather than as
twelfth-aligned columns: placeholders and aria-labels instead of column
captions, fields free to size themselves, a text "Add" button. The empty
table points at the heading by name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:57:04 +00:00
Daniel Roth
ab29a246a4 fix(ara-projects): key the stages routes on the shared [workstreamId] slug
Next.js refuses two different slug names for one dynamic segment, and #410
already owns `/api/projects/[projectId]/workstreams/[workstreamId]`. Adding
`[projectWorkstreamId]` beside it was a hard boot/build failure that took
the whole app down, not just these routes — `next dev` refused to start.

The URL shape is unchanged; the id in that segment is now the catalogue
`workstream.id` throughout, so `/workstreams/9` and `/workstreams/9/stages`
name the same workstream. `findLadder` translates to the
`project_workstream.id` the writes take, once per request, at the edge —
keeping that id out of URLs entirely. ADR-0021 records the trade-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:38:41 +00:00
Daniel Roth
feff631582 fix(ara-projects): stop the add-requirement button colliding with the toggle (#412)
The required checkbox and its label sat in one twelfth of the add row —
narrower than their own contents. Grid children default to
`min-width: auto`, so instead of shrinking they spilled over the Add
button beside them.

Every cell is now `min-w-0`, the toggle gets two columns with its label
above it like its siblings, and the button is `size="sm"` so it lines up
with the inputs rather than standing 4px taller. On a narrow screen the
button says "Add" instead of being a bare icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:26:32 +00:00
Daniel Roth
d75bdf0ac4 Merge origin/main into issue-419-admin-work-orders-table (pull in #450 build fix) 2026-07-23 13:10:11 +00:00
Daniel Roth
a7b1a3982b Merge origin/main into issue-413-wizard-contractor-assignment (pull in #450 build fix) 2026-07-23 13:10:09 +00:00
Daniel Roth
752686b562 Merge origin/main into issue-412-wizard-evidence-checklist (pull in #450 build fix) 2026-07-23 13:10:07 +00:00
Daniel Roth
42c027a5c5 Merge origin/main into issue-411-wizard-stage-configuration (pull in #450 build fix) 2026-07-23 13:10:04 +00:00
Daniel Roth
ac932524fe
Merge pull request #450 from Hestia-Homes/fix/setup-workstreams-authz-import
fix(ara-projects): repoint workstreams setup import to guards (fixes main build)
2026-07-23 14:08:43 +01:00
Daniel Roth
aa505bc84f fix(ara-projects): repoint workstreams setup import to guards
setup/workstreams/page.tsx imported requireProjectAccess from
../../../authz, the stub #406 created and #409 deleted when it moved the
guard into src/app/projects/guards.ts. #410 (workstreams) was built in
parallel against the stub path and merged after #409, so the two landed
cleanly by git yet left a dangling import — the only tsc error in the
repo and a next build failure.

The page calls requireProjectAccess purely for its redirect/notFound
side effect and discards the return, so repointing the import is the
whole fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:58:47 +00:00
Daniel Roth
7d271b06bc test(ara-projects): pin the seam between the toggles and the authz guards (#413)
Acceptance asks that the permission toggles "take effect in the authz lib
guards". Both ends are already tested — the guards' rules in #408's tests,
the endpoints in route.test.ts — but neither notices if they stop describing
the same two booleans.

This starts from a project_workstream_contractor row typed against the real
schema, flattens it the way ./queries does, and asks the real guards what the
contractor may do: a renamed column or wire field fails it, at compile time
or on the assertion. No database is involved; the row is a literal.
2026-07-23 12:53:40 +00:00
Daniel Roth
35f45837be feat(ara-projects): contractors and permissions setup step (#413)
The screen at /projects/[projectId]/setup/contractors — the wireframe's
06-contractors-permissions.html, one table row per contractor organisation
with its assigned workstreams and the two permission switches.

Revisitable, like the setup steps before it: step 5 of the first-run wizard
and the screen the hub (#444) links to afterwards, with ?from=hub the only
difference (it swaps Continue for a return to the hub). Assignments are read
back from the database on every visit, and every change persists immediately.

- Add contractor: searches *existing* organisations (creating one belongs to
  the invite-company flow), multi-selects workstreams, sets both permissions.
- Edit and remove per contractor, reacting to the API's 409s — a warning the
  user may confirm, or a refusal they cannot.
- Continue leads to the work-order import (#414), the wizard's terminal step,
  with the readiness helper — not a re-derived rule — rendering what is still
  outstanding rather than a disabled button that can't say why.

No useEffect: the form dialog is keyed and mounted only while open, so its
useState initialisers are the load step. router.refresh() alongside the query
invalidation keeps the readiness panel honest as the last gap is filled.

ADR-0021 records why permissions are per-assignment but shown per-contractor
(with a mixed state), and why a removal never cascades through work orders.
2026-07-23 12:52:10 +00:00
Daniel Roth
5355e0846b feat(ara-projects): stage configuration setup screen (#411)
Step 3 of the first run and a revisitable screen from the setup hub — the
same page either way, with `?from=hub` deciding only where the footer
leads. Entering it seeds each selected workstream's default ladder, under a
row lock so "seeded exactly once" survives two simultaneous visits.

The optional per-stage dates use DateInput, never `<input type="date">`
(ADR-0019): a date saves the moment it becomes real, and a clear waits for
blur, because DateInput reports "" for every keystroke of an incomplete
entry and committing that would wipe a stored date on first touch.

ADR-0021 records why the seed lives in the page render and why `status` is
written once as a placeholder — the column is NOT NULL with no default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:51:46 +00:00
Daniel Roth
0daae49587 feat(ara-projects): admin work-orders table page (#419)
/projects/[projectId]/work-orders — TanStack Table v8 over the shadcn Table,
columns factored like your-projects/live/PropertyTableColumns.tsx: WO ref,
asset ref, address, workstream, contractor, stage, forecast end, docs, and a
row action to the work-order detail.

The server owns filtering, ordering and paging; the client owns only the
question being asked. A Server Component renders the filter choices and the
unfiltered first page through the same loadWorkOrderPage the route handler
uses, and seeds TanStack Query v4 with it (keepPreviousData, not v5's
placeholderData), so there is no empty flash and no second implementation of
the first paint. No polling, so no refetchInterval signature involved.

Cursor stack rather than a page number: keyset pagination cannot derive
"previous", so the client remembers the cursors it used. Changing a filter
clears the stack — a cursor is a position in one filtered set.

Nothing in the UI decides anything. overdue / progress / evidence counts
arrive already derived by @/lib/projects/derivations; the stage badge takes
its name live from the stage FK and its colour from the derived progress, so
a rename propagates and a project that renames "Completed" keeps the
completed treatment. Docs badges are advisory-only per CONTEXT.md.

Forecast end renders through toLocaleDateString("en-GB") off a local-midnight
Date built by isoToLocalDate — dd/mm/yyyy, per CLAUDE.md and ADR-0019, with
no UTC-parse day shift.

Contractors get a note rather than a 404: they can see the project, so the
lie would be worse than the gap. Their scoped view is a later issue.
2026-07-23 12:51:42 +00:00
KhalimCK
2a9f6424be
Merge pull request #449 from Hestia-Homes/feat/roof-sloping-ceiling
feat(db): explicit 'Pitched, sloping ceiling' roof_type values
2026-07-23 13:51:41 +01:00
Daniel Roth
fcb72c05f5 feat(ara-projects): evidence checklist setup screen (#412)
Setup step 4 at /projects/[projectId]/setup/evidence — the slug the
settings hub's setupSteps.ts already links to. Revisitable by design:
the same screen serves the first-run wizard and a later visit from the
hub (?from=hub), and everything is read back from the database on every
visit.

A workstream rail with per-workstream doc counts, a requirements table
whose controls each save on their own (stage tag and required on change,
naming rule on blur), and the two project-level SharePoint fields as a
plain form — no sync, and no "Sync enabled" toggle for behaviour this
release does not have.

Nothing here is required: the footer's primary action reads "Skip step"
while the project has no requirements, because evidence is advisory and
never blocks an import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:47:09 +00:00
Daniel Roth
d8c531153a feat(ara-projects): evidence-requirement CRUD and SharePoint fields API (#412)
Per-workstream evidence requirements are configuration rows on
project_workstream_evidence_requirement: a file_type from the existing
uploaded_files enum (no new document-type table), an optional stage tag,
a required flag and a display-only naming rule.

The collection hangs off /workstreams/[workstreamId]/… rather than the
[projectWorkstreamId] the ticket names: Next.js permits only one slug
name per dynamic segment, and #410's deselect endpoint already owns that
one. The segment therefore keeps its existing meaning — the catalogue
workstream.id — and is resolved to the project_workstream here, which
doubles as the project-scoping guard.

Evidence stays advisory: nothing here feeds the import readiness gate,
and a workstream with an empty checklist is valid and importable.

The two project-level SharePoint columns get their own PATCH endpoint
rather than joining updateProjectSchema, so the settings hub's details
form cannot blank fields it does not send. Stored only — sync is #429.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:46:59 +00:00
Khalim Conn-Kowlessar
2303d41c2b feat(db): add explicit 'Pitched, sloping ceiling' roof_type values
Sloping-ceiling roofs (source EPC field PitchedWithSlopingCeiling) were being
flattened onto the horizontal-loft ladder ("Pitched, N mm loft insulation"),
which mis-models a roof whose insulation sits at the rafter/slope line. Add a
dedicated sloping-ceiling family to RoofTypeValues — an "as built" variant
(insulation deferred to construction age band) plus a 12 mm … 400+ mm depth
ladder mirroring the loft one — and the matching ALTER TYPE roof_type ADD VALUE
migration (0278).

property_overrides.override_value is text, so no change there; only the
landlord_roof_type_overrides.value pgEnum is constrained.

Backend counterpart (Model#1676): the roof overlay must model these at rafter
level, and the ColumnClassifier must map PitchedWithSlopingCeiling:<depth|As
Built|Unknown> to the new members. Once live, the affected property_overrides
snapshots + portfolio vocab rows get backfilled.

Note: numbered 0278 off current HEAD; if another in-flight migration also lands
0278, this needs a regenerate/renumber at merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:43:03 +00:00
Daniel Roth
a57fc5d0d2 feat(ara-projects): stage ladder CRUD for a Project Workstream (#411)
The ladder is v1's only lifecycle mechanism, so the guardrails are hard
rules rather than warnings: a ladder never drops below two stages, never
loses a stage a work order sits in, and never develops a hole in `order`.
Every decision is pure (`ladder.ts`) and every count is re-read inside the
same transaction that applies the outcome — the browser's counts signpost,
they never gate.

Dates cross the wire as YYYY-MM-DD only (ADR-0019); dd/mm/yyyy is rejected.
`current_stage_id` and `work_order.status` are never written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:39:29 +00:00
Daniel Roth
b6265b3fd5 feat(ara-projects): contractor assignment API (#413)
Route handlers under /api/projects/[projectId]/contractors for the
contractor-assignment setup step:

- GET/POST  /contractors — the permissions table, and adding a contractor
  (one project_workstream_contractor row per chosen workstream, additive).
- GET  /contractors/organisations — searching *existing* organisations for
  the picker. Read-only by design: creating organisations belongs to the
  "invite company to Ara" flow, not to project setup.
- PATCH/DELETE /contractors/[organisationId] — editing which workstreams a
  contractor delivers and with what permissions, and taking it off the
  project entirely.

The rules are pure and unit-tested (assignments.ts): permission flags belong
to the assignment, so a contractor whose assignments disagree reads "mixed"
rather than being rounded; a removal whose assignment has work orders is
refused (work_order.project_workstream_contractor_id is NOT NULL, so the
alternative is cascading through issued work, which #413 rules out); a
removal that merely leaves a workstream contractor-less warns first, because
that is exactly what the import gate stalls on.

Authorization is #408's, reached through #410's wiring — no permission rule
is restated here. Tests mock the database at the module boundary; no
connection is opened.
2026-07-23 12:38:40 +00:00
Daniel Roth
bbc2843fdf feat(ara-projects): work-orders API — filters, keyset pagination, shared derivations (#419)
GET /api/projects/[projectId]/work-orders serves one page of the admin
work-orders table: workstream / contractor / stage / overdue-only /
missing-required-evidence filters, keyset-paginated by work_order.id so a
cursor stays meaningful under any filter combination.

The rules stay in one place. Every derived filter is split at its AND, the
way #418's dashboard repository splits it, and the TypeScript half crosses
into SQL as data rather than as a re-implemented predicate:

  - overdue: buildStageLadders classifies the project's stages in JS and
    stageIdsByProgress("complete") hands the terminal ids to the query;
    SQL only counts forecast_end < asOf.
  - missing evidence: requiredEvidenceRequirementIds decides which
    requirement applies at which stage and hands over an explicit
    (stage_id, requirement_id) list.

Row badges come from toWorkOrderRow, which calls isOverdue /
progressOfWorkOrder / evidenceCompleteness directly, so the table and the
dashboard reconcile by construction.

Authz is canManageProject (internal + client-org). A contractor gets 403
rather than the whole programme; row scoping is a later issue.

Tests mock @/app/db/db and open no connection.
2026-07-23 12:38:39 +00:00
Daniel Roth
bf3f8aeb20
Merge pull request #448 from Hestia-Homes/issue-415-import-column-mapping
feat(ara-projects): work-order import 2/3 — column mapping + value mapping
2026-07-23 12:34:26 +01:00
Daniel Roth
08b6c28f87
Merge pull request #447 from Hestia-Homes/issue-410-wizard-workstream-selection
feat(ara-projects): setup wizard 2/5 — workstream selection
2026-07-23 12:34:18 +01:00
Daniel Roth
35440a6baf
Merge pull request #446 from Hestia-Homes/issue-444-project-settings-hub
feat(ara-projects): project settings / setup hub (/projects/[projectId]/settings)
2026-07-23 12:34:01 +01:00
KhalimCK
82897aa524
Merge pull request #445 from Hestia-Homes/feat/drop-room-in-roof-flattened-cols-1665
feat(db): drop the two superseded flattened room-in-roof columns (Model #1665 follow-up)
2026-07-23 12:03:47 +01:00
Daniel Roth
f76d5aa167 fix(ara-projects): count a blank workstream as blank, not as awaiting scope (#415)
unscopedRows seeded itself with the blank-workstream row count, so the
contractor table's note told the user those rows were "waiting on their
workstream value — map it above and their contractors appear here". They never
would: a blank cell has no value to map, and blankRows.workstream already
reports them. unscopedRows now counts only rows carrying a workstream value that
is not yet mapped — the rows the note's promise is actually true of.

Adds the counting invariant as a test (every row lands in exactly one workstream
entry or in blankRows.workstream), which is the AC's "value-mapping counts match
the file".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:25:24 +00:00
Daniel Roth
3b84ed58ad feat(ara-projects): import mapping screen — column + value mapping UI (#415)
Step 2 of 3's screen, on top of the pure logic in the previous commit. Follows
wireframes 08 (value mapping locked) and 09 (unlocked). Writes nothing.

Where the step lives
  Client state inside ImportUpload, not a /import/mapping route: the parsed rows
  live in that component (#414), so a URL step would drop them and force a
  re-upload on every Back. "Continue to mapping" — disabled since #414 —
  now opens the mapping screen.

ImportMapping — the screen
  Owns the two mapping layers and nothing else. Statuses, counts, which values
  need review and whether Continue is enabled are all derived inline from
  (file, columnMapping, valueMapping, targets) on each render, so re-pointing the
  workstream column immediately re-groups the contractor and stage tables — they
  were never a separate copy of the answer. No useEffect, no useMemo.

ColumnMappingTable — file column -> Ara field
  One row per file column with the wireframe's matched / needs-review
  affordances, an "e.g. …" sample from the preview, and a shadcn Select per row
  offering every field plus "Don't import this column". Picking a field another
  column already holds says so in the option hint rather than moving it
  silently. Beneath the table, the requirement blockers: which required fields
  are still unmapped and why. Unmapped *optional* fields are stated, never
  blocking.

ValueMappingTables — values -> the project's own entities
  Three tables. Workstream values map flat; contractor and stage values are
  grouped under the workstream they are scoped to, because a contractor
  assignment and a stage ladder belong to a workstream (ADR-0019) — the same
  "Acme" under two workstreams is two questions, and each picker offers only
  that workstream's assignments. Row counts per value, auto-matched marked as
  such. Notes carry the cases with no row of their own: rows waiting on their
  workstream mapping, blank contractors (an error downstream), and blank stages
  (not an error — they start at the workstream's first stage).

Gating (AC)
  The value tables are locked, exactly as wireframe 08 shows, until every
  required column is mapped — there is nothing to list until we know which
  column holds the workstream. Continue to validation is disabled until the same
  condition holds, and names what is missing. Unmapped *values* never block: they
  surface as "N of M rows will fail validation as mapped".

page.tsx
  Loads the value-mapping targets server-side alongside readiness (read-only, and
  skipped entirely for a gated project, which has nothing to map to). Container
  widened to max-w-5xl for the mapping tables; the eyebrow now covers steps 1–2.

ADR-0020 — records that the import session, and the mapping persisted alongside
it, are client-held. Persisting the mapping without the rows buys nothing: the
rows are client-held too, so a refresh loses them either way and a restored
mapping has no file to apply to. ImportSession is JSON-clean so a future
server-side session can store it unchanged; the ADR names moving the file
server-side as the thing that would change the answer. It also records the
production-database constraint honestly, as a constraint and not as the reason.

Stepping back to the drop zone and forward again keeps the mapping: the session
is held above the mapping component, so that component may unmount freely.

225 tests pass across src/lib/projects; tsc and eslint clean. No component tests
— this repo runs vitest in a node environment with no DOM setup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:22:18 +00:00
Khalim Conn-Kowlessar
eb823d3296 feat(db): drop the two superseded flattened room-in-roof columns
The full room-in-roof geometry now lives in the epc_room_in_roof /
epc_room_in_roof_surface child tables (#443 / Model #1665). The two flattened
columns on epc_building_part -- room_in_roof_floor_area and
room_in_roof_construction_age_band -- are redundant duplicates of
epc_room_in_roof.floor_area / construction_age_band and are no longer read or
written by the Model backend. No TS code in this repo references them either.

No data migration: the values already live in epc_room_in_roof for re-ingested
properties.

  ALTER TABLE epc_building_part DROP COLUMN room_in_roof_floor_area;
  ALTER TABLE epc_building_part DROP COLUMN room_in_roof_construction_age_band;

⚠️ Deploy ordering (destructive): apply this ONLY after the Model backend that
no longer writes these columns is deployed to every environment. Dropping them
while a backend that still writes them is live breaks every EPC save.

Refs Hestia-Homes/Model#1665

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:16:54 +00:00