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.
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>
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.
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>
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.
#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.
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.
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>
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>
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.
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.
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>
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>
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>
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>
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>
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>
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>
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.
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.
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>
/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.
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>
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>
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>
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>
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.
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.
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>
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>
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>