3.3 Swimlanes + WIP limits
Two board flow-management features that turn the 3.2 Kanban surface from "columns of cards" into a tool a team manages flow with: swimlanes (group the board into horizontal rows by a dimension) and WIP limits (a per-column ceiling with a soft over-limit warning). Both are board configuration persisted on the board entity (the stub): the swimlane group-by is a new field on board; the per-column WIP limit reuses the board_column.wipLimit Int? column Story 3.1.1 already shipped FOR this story (no WIP migration needed). This story adds the config write path, the projection extension that groups cards into lanes, and the UI for both. It changes NO part of the 3.2 move contract — a drag is still a workflow transition; swimlanes and WIP sit on top.
Swimlanes — the durable shape (mirror product = Jira; decision-ladder rung 1). A swimlane is a horizontal row that slices EVERY column by a group-by dimension, so the board becomes a grid of (column × lane) cells. The group-by values this story ships, per the stub, are none / assignee / epic / priority — a BoardSwimlaneGroupBy enum on board (default none, i.e. the flat 3.2 board). Jira behaviours we mirror: lanes render only for group values present on the board (no empty 200-row assignee list); a card with no value for the dimension (unassigned / no epic) falls into a catch-all lane ("No assignee" / "No epic") that always sorts last; each lane has a header with its label + an aggregate card count and is collapsible (collapsed lanes persist client-side). Group-by epic groups by a card's ancestor epic, not its immediate parent — so the projection resolves each card's lane membership server-side (below), the client never re-derives it.
Cross-lane drag = reassign the grouped field (mirror-faithful, reuses existing endpoints). Once the board is sliced into lanes, dragging a card between lanes must DO something — a lane a card can be dropped into that silently snaps back is a broken affordance. Jira reassigns the grouped field on a cross-lane drop, so we do the same: dropping a card into another assignee lane reassigns the assignee, another priority lane changes priority, another epic lane reparents to that epic — each via the existing Story-2.5 issue-field update endpoints (the same inline-edit paths IssueInlineEdit already calls), NOT a new backend and NOT the board/move endpoint (which is status only). A drag may change BOTH column and lane (a diagonal drop): the column change is the 3.2 transition, the lane change is the field reassign, applied as the two appropriate writes; both reconcile optimistically and snap back independently on rejection (the 3.2.4 pattern). A drop into the catch-all lane clears the field (unassign / remove epic) where that is legal. This is the justified-deviation rung-1 default, not added complexity for its own sake — it reuses shipped endpoints and is the standard board behaviour.
WIP limits — per-column, SOFT (the stub says "soft over-limit warnings"). A column may carry an optional integer WIP limit (the existing board_column.wipLimit). When a column holds MORE cards than its limit it is shown over-limit — a warning treatment on the column header and its count (e.g. 6/5), NOT signalled by colour alone (finding #35: pair the hue with an icon/label). Crucially soft means advisory, never blocking: an over-limit column does NOT reject drops, so the 3.2.4 move contract is unchanged — WIP only warns. The limit is per-column total (the Jira-classic shape), counted across all swimlanes when lanes are on; per-lane WIP is a deviation with no stated use case, so we match the mirror (no complexity for nothing). The limit is edited inline via the column actions [⋯] menu the 3.2.1 design already reserved — no separate settings page.
Where the config lives + who can set it. The group-by selector is a control in the board header (3.2.1 reserved "a place for the 3.3 controls"); the WIP limit is set per column via its [⋯] menu. Both are board-config writes that go through the 4-layer architecture (Route → Service → Repository → Prisma) with the explicit application-layer workspaceId gate (finding #26). Roles/permissions are Epic 6.4 (not built yet), so — like the Story-2.2.5 workflow editor, which is a project-settings write any member can make today — these config writes are membership-gated now and will be role-gated when 6.4 lands (a note in the service, not an early RBAC build — rung-2 consistency with shipped code).
Scale shape — lanes stay bounded, never "load every row" (finding #57). Swimlanes must not regress the 3.1/3.2 bounded projection. The projection stays paged per column; each card already carries its lane membership (the service stamps a resolved swimlaneKey per the active group-by), and the projection additionally returns the ordered lane list (key + label + per-lane total count) via a bounded grouped/distinct aggregate (lanes-with-cards only, + the catch-all) — NOT by loading every card to discover the lanes. The UI buckets the loaded per-column page into (lane, column) cells and a column's 3.2.5 "load more" pulls the rest; the per-column 3.2.5 virtualization still applies. A board that fetched all cards to build lanes would be prototype-thinking; the lane list is an aggregate and the cards stay paged.
Completeness — the real-product states. Switching group-by re-lays the board with a loading transition (not a flash of the old layout); a board with a single lane value collapses to the flat view sensibly; the catch-all lane renders even when it is the only populated lane; an over-limit column at exactly the limit is NOT warned (strictly greater than); clearing a WIP limit removes the warning. Swimlanes + WIP must remain keyboard- and screen-reader-navigable (lane headers are landmarks/regions; collapse is operable; over-limit state is announced, not colour-only).
Out of scope (Epic-3 siblings / later): the sprint-scoped Scrum board (Story 4.5 — moved to Epic 4 per mistake #32); the cross-cutting drag + WIP + swimlane Playwright journey at scale (Story 3.5 — this story ships its OWN component tests + the focused swimlane/WIP E2E, the same split 3.1.7 / 3.2.7 used); per-lane (rather than per-column) WIP limits (no stated use case → match the mirror); the column↔status mapping admin (Story 3.6, which extends THIS story’s board-config service/API seam) and board CRUD / multi-board (Story 3.7); saved swimlane queries / JQL-style custom lanes (Jira has them; not in the stub, no stated use case → deferred). The flat board, the drag-as-transition contract, the per-column count, and the load-more/virtualization all come from Stories 3.1 + 3.2 and are reused, not rebuilt.
Verification
- Pull the Story branch,
pnpm install,pnpm prisma migrate dev(picks up theboard.swimlaneGroupBycolumn),pnpm db:seed,pnpm dev. pnpm test— vitest covers: theswimlaneGroupBywrite +wipLimitwrite services (workspace-gated), the projection lane-grouping (correctswimlaneKeyper group-by incl. epic-ancestor resolution, the catch-all lane, per-lane counts, still bounded/paged), the over-limit predicate (strictly-greater, exactly-at-limit not warned), and the cross-lane reassign reducer.pnpm test:e2e --grep board-swimlanes— Playwright drives the real board: set group-by → lanes render; collapse a lane; set a column WIP limit → over-limit warning shows; an over-limit drop still SUCCEEDS (soft); a cross-lane drag reassigns the field.- Swimlane render check: open
/boardson the seededmoooon→motirproject, switch group-by to Assignee → the board re-lays into one row per assignee that has cards + a "No assignee" catch-all lane last; each lane header shows a label + an aggregate count; switching to Priority and Epic regroups; None returns the flat 3.2 board. The layout matchesdesign/boards/swimlanes-wip.mock.html. - Group-by epic check: with group-by Epic, a story/task lands in the lane of its ancestor epic (not its immediate parent); a card with no epic ancestor falls into the "No epic" catch-all.
- Cross-lane reassign check: with group-by Assignee, drag a card from one assignee lane into another → the assignee is reassigned (re-open the issue / quick-view to confirm) with no status change; drag into "No assignee" → the card is unassigned. Under group-by Priority, the same drag changes priority. A diagonal drag (different column AND lane) both transitions and reassigns; an illegal transition snaps the column part back while the lane part is unaffected (independent reconcile).
- WIP soft-warning check: set a column WIP limit of 2 via the column
[⋯]menu on a column holding 3 cards → the column header + count render the over-limit warning (3/2) paired with an icon/label (not colour-alone, finding #35). Drag a 4th card in → the drop succeeds (soft, never blocked) and the warning persists. Remove the limit → the warning clears. A column at exactly its limit (2/2) is NOT warned. - Scale check (finding #57):
pnpm db:seed:large, group by Assignee on a project with hundreds of cards → lanes render from the bounded aggregate (no all-cards fetch), each (lane, column) cell shows the loaded cards + the column "load more" still pages, and the DOM row count stays bounded (virtualization intact). - a11y check: group-by selector + lane collapse are keyboard-operable; lane headers expose region/landmark roles; the over-limit state is announced to assistive tech, not signalled by colour alone.