4.5.2 Projection — sprint scope + `SprintSummaryDto` (committed/completed/remaining points + per-column totals) in `getBoard` for scrum boards
Estimate: 24m · Depends on: 3.1.4, 4.1, 4.3
The one backend change: teach the Story-3.1.4 boardsService.getBoard projection to render a scrum-type board scoped to its active sprint, and to carry the sprint summary the header needs — without regressing the bounded, never-load-all shape (finding #57) and without touching the kanban path.
Depends on Stories 4.1 + 4.3 (Epic-4 siblings, story-level forward refs while they are unexpanded stubs). Story 4.1 provides the sprint entity (goal, startDate, endDate, state planned·active·complete) + the issue→sprint association (workItem.sprintId or a join — whatever 4.1 ships); Story 4.3 provides the story-point field on issues. Both are unexpanded stubs today, so these are story-level deps; this subtask reads those fields, it does not define them. (When 4.1/4.3 expand, a re-plan can retarget to the exact subtasks.)
Sprint scope. For a board whose type == scrum, resolve the board's active sprint (the single state == active sprint for the board — the 4.4 one-active-per-board guard makes this unambiguous; resolve via a single repository read, SELECT … WHERE boardId/projectId AND state = active). FILTER every column query to issues associated with that sprint (an extra WHERE issue.sprintId = :activeSprintId on the SAME 3.1.4 column/page/count queries — not a new projection path). A type == kanban board is unchanged (no sprint scope — guard the new branch on board type). When a scrum board has no active sprint, return sprint: null and empty columns (the UI renders the empty state) — never silently fall back to the unscoped backlog as if it were a sprint.
SprintSummaryDto (drives the header — bounded aggregates). When an active sprint exists, getBoard returns sprint: { id, name, goal, startDate, endDate, state, daysRemaining, points: { committed, completed, remaining }, columnPoints: Record<columnId, number> }. The point figures are aggregate SUM(storyPoints) queries scoped to the sprint: committed = all sprint issues; completed = sprint issues in a terminal/done status; remaining = committed − completed; columnPoints = the per-column point sum (a grouped aggregate by the column's mapped statuses — the Jira "sprint health" number). These are computed by aggregate queries, NOT by summing the loaded card page (a sum over the bounded page would undercount — the board only loads a page per column). daysRemaining = calendar days from today to endDate, floored at 0 (an overdue sprint → 0, surfaced as "Ended" by the UI, never negative). Issues with a NULL story-point estimate contribute 0 to sums; if the WHOLE sprint is unestimated the UI shows "—" (the DTO still returns 0/0/0 — the UI decides the "—" presentation, finding-#57-style the data layer stays total).
Pagination + swimlanes stay intact (finding #57). Cards still page per column via the 3.1.4 first-page + loadColumnCards cursor; loadColumnCards ALSO takes the sprint scope so "load more" stays sprint-filtered. The 3.3.4 swimlaneGroupBy + swimlanes[] + per-card swimlaneKey continue to work (the sprint filter composes with the lane grouping — lanes are computed over the scoped issue set). The projection never loads every card to total points or discover lanes; both are aggregates.
4-layer + tenant gate. The active-sprint resolution + the point aggregates are repository single-op reads (the SUM/grouped aggregates via $queryRaw where needed); the service owns the orchestration + DTO mapping (mapper in lib/mappers/boardMappers.ts, DTO in lib/dto/boards.ts); the explicit application-layer workspaceId gate (finding #26) already on getBoard covers the new reads. No route change beyond the projection it already returns.
Out of scope here: any UI (4.5.3); the sprint entity / lifecycle / start-complete (Stories 4.1/4.4); the burndown chart aggregate (Story 4.6 — only the numeric remaining is here).
Acceptance criteria
- For a
scrumboardgetBoardresolves the active sprint and returns columns filtered to that sprint's issues; akanbanboard is byte-for-byte the 3.1.4 shape (no regression); a scrum board with no active sprint returnssprint: null+ empty columns (no backlog fallback). getBoardreturns aSprintSummaryDto(id, name, goal, startDate, endDate, state, daysRemaining, points {committed, completed, remaining}, columnPoints) when a sprint is active;daysRemainingis floored at 0; point figures come from SUM/grouped aggregates scoped to the sprint, NOT from the loaded card page.loadColumnCards(load-more) carries the sprint scope; per-column cursor pagination + the 3.3.4 swimlane grouping compose with the sprint filter; the projection never returns every card.- NULL-estimate issues contribute 0 to point sums (no
NaN); the DTO stays total (returns numbers, the UI owns the "—" presentation). - Returns DTOs mapped in
lib/mappers/boardMappers.ts; the new reads are repository single-ops; the finding-#26workspaceIdgate covers them. - Vitest (real Postgres) covers: scrum scope (only active-sprint issues), the kanban no-op, no-active-sprint →
sprint: null, the committed/completed/remaining +columnPointsaggregates (incl. an unestimated sprint → 0s),daysRemainingfloor, and that pagination/bounding/swimlanes are preserved (no load-all).
Context refs
lib/services/boardsService.tsgetBoard+loadColumnCards+lib/mappers/boardMappers.ts+lib/dto/boards.ts(Story 3.1.4) — the projection +BoardProjectionDto/BoardCardDtothis extends withsprint+ the scope filter- Story 3.3.4 — the
swimlaneGroupBy+swimlanes[]+ per-cardswimlaneKeyprojection the sprint filter must compose with - Story 4.1 (sprint entity + issue→sprint association) + Story 4.3 (story-point field) — the sibling Epic-4 model this reads (story-level dep; fields defined there, read here)
lib/repositories/boardRepository.ts/workItemRepository.ts— where the active-sprint read + the SUM/grouped point aggregates land (single-op,$queryRawfor aggregates)- finding #57 — bounded projection (aggregates, not load-all); finding #26 — the app-layer
workspaceIdgate;motir-core/CLAUDE.md— 4-layer (service owns DTO mapping, repo single-op)