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.