3.3.4 Projection — swimlane grouping (resolved `swimlaneKey` + bounded lane list) + WIP count, in `getBoard`
Estimate: 24m · Depends on: 3.3.3, 3.1.4
Extend the Story-3.1.4 boardsService.getBoard projection so the UI can render swimlanes and the WIP state — without regressing the bounded, never-load-all shape (finding #57). 3.1.4 already returns ordered columns, each with a bounded first page of BoardCardDto, a per-column total + cursor, and the wipLimit on each column. This subtask adds the lane dimension.
swimlaneGroupBy on the projection. getBoard returns the board's active swimlaneGroupBy. When it is none, the projection is exactly the 3.1.4 flat shape (no behaviour change). When it is assignee / epic / priority:
- Per-card
swimlaneKey(resolved server-side). EachBoardCardDtois stamped with aswimlaneKeyfor the active group-by so the client never re-derives it:assignee→assigneeId(or the catch-all key for unassigned);priority→ the priority value;epic→ the card's ancestor epic id (walk the parent chain to the epic, NOT the immediate parent — a task under a story under an epic groups by the epic), or the catch-all key when there is no epic ancestor. - The lane list (bounded aggregate, NOT load-all). A top-level
swimlanes: BoardSwimlaneDto[]—{ key, label, kind, count }— built from a grouped/distinct aggregate query over the project's issues (lanes that actually have cards + the catch-all), ordered by the documented lane-order rule (assignee alpha / priority rank / epic position; catch-all last). This is an aggregate, so it does NOT load every card to discover lanes. The label/kind let the UI render the lane header (assignee summary / epic key+title / priority) without extra fetches.
Pagination stays per column (finding #57). Cards continue to page per column via the 3.1.4 first-page + loadColumnCards cursor; the client buckets the loaded page into (lane, column) cells by swimlaneKey, and "load more" on a column pulls the rest. The projection does NOT page per (lane × column) cell (that CxL explosion is over-engineering with no stated need); per-lane TOTAL counts come from the aggregate, per-column totals from 3.1.4. Document this as the durable bounded shape.
Epic-ancestor resolution. Resolve each card's epic ancestor efficiently (a single batched lookup over the page's cards' ancestor chains / a recursive CTE), not an N+1 walk; reuse any Story-1.4 / 2.5 ancestor helper if one exists.
Out of scope here: WIP enforcement (there is none — it stays a UI soft warning, 3.3.6; the projection just carries wipLimit + the per-column count); any UI (3.3.5/3.3.6); the config writes (3.3.3).
Acceptance criteria
getBoardreturns the activeswimlaneGroupBy; withnonethe projection is byte-for-byte the 3.1.4 flat shape (no regression).- With a non-
nonegroup-by, eachBoardCardDtocarries a correctswimlaneKey(assignee / priority value / ancestor-epic id / catch-all), and a top-levelswimlanes: { key, label, kind, count }[]lists lanes-with-cards + the catch-all, in the documented order. - The lane list is built from a bounded grouped/distinct aggregate (no all-cards fetch); per-card cursor pagination per column is unchanged; the projection never returns every card.
- Epic grouping uses the ancestor epic (not the immediate parent), resolved without N+1.
wipLimit+ the per-column total count remain on each column (carried for 3.3.6; the projection does NOT enforce WIP).- Vitest (real Postgres) covers: each group-by resolving the right
swimlaneKey(incl. epic-ancestor + each catch-all), the lane list + per-lane counts, thenoneno-op, and that pagination/bounding is preserved (no load-all).
Context refs
lib/services/boardsService.tsgetBoard+lib/mappers/boardMappers.ts+lib/dto/boards.ts(Story 3.1.4) — the projection +BoardProjectionDto/BoardCardDtothis extendslib/repositories/workItemRepository.ts—findProjectIssuesFlat/ count + any ancestor/parent-chain helper (Story 1.4 / 2.5) for epic resolutionlib/dto/workItems.ts— the assignee-summary / priority / parent fields the lane labels reuse- finding #57 — bounded projection (no load-all);
motir-core/CLAUDE.md— service owns DTO mapping, mappers inlib/mappers/*