4.6.4 Backend — `reportsService.getVelocity({ projectId, lastN })`: last-N completed sprints committed (4.4.2 baseline) vs completed (4.3.3 roll-up) + average, bounded
Estimate: 22m · Depends on: 4.4.2, 4.4.3, 4.3.3
The velocity aggregate — simpler than the burndown, purely a bounded read over the completed-sprint history. Pure backend, no new write model. Lives in the same new reportsService as 4.6.3 (the Epic-6.3 home), with a HTTP-only route + a VelocityDto.
reportsService.getVelocity({ projectId, lastN = 7 }) → a VelocityDto:
- Enumerate the project's completed sprints (
state = 'complete', ordered bycompletedAt/sequencedesc),LIMIT lastN(default 7, the Jira default) — a bounded read viasprintRepository.listByProjectfiltered to complete (extend it with a state filter + limit if needed; do NOT load every sprint). - Per sprint: committed = the locked
committedPointsbaseline (4.4.2, O(1) stored) and completed =estimationService.rollupForSprint(sprintId).completed(4.3.3 — the same done-category aggregate the scrum header + the sprint report use, so the bars match those surfaces). Resolve the configured statistic the same way 4.3.3 does. - Average completed across the returned sprints (the planning forecast). Return the sprints oldest→newest for the X axis + the average + the statistic label.
- Low-history — 0 completed sprints → an empty
VelocityDtothe UI renders as "not enough history yet"; 1 sprint returns the single bar + that sprint as the average (the UI may note "need ≥2 for a trend"). Unestimated sprints contribute 0 (or count, per the statistic), neverNaN.
Layering + bound. The service does LIMIT N sprints, then N bounded rollupForSprint calls (N ≤ 7) — a bounded fan-out, not an all-issues scan; it never iterates every issue of every sprint in Node. Repo reads are single ops; the service owns the DTO + the average; the route is HTTP-only; the finding-#26 workspaceId/project gate covers it. Any new repo method (e.g. a state-filtered + limited sprint list) gets a direct empty-input-guard test (coverage gate).
Acceptance criteria
reportsService.getVelocity({ projectId, lastN })returns the last N completed sprints (ordered oldest→newest) with committed (the 4.4.2 locked baseline) + completed (4.3.3rollupForSprint, same done-category predicate as the scrum header) per sprint, plus the average completed + the statistic label.- The read is bounded: a
LIMIT Nsprint query + N (≤7) bounded roll-up aggregates — NOT a load of every sprint or every issue;db:seed:large(many completed sprints) stays bounded. - 0 completed sprints → an empty/low-history DTO (no crash, no axis-of-one); 1 sprint → the single bar; unestimated sprints contribute 0 / the issue-count value, never
NaN. GET /api/projects/[id]/velocity(or/api/boards/[id]/velocity) is HTTP-only; cross-workspace access denied (finding #26); any new repo method has a direct empty-input-guard test;pnpm test:coveragekeeps the new files ≥90% branch/fn/line.
Context refs
- Story 4.4.3
completeSprint(setsstate = complete+completedAt) — the completed-sprint history this enumerates; Story 4.4.2 (committedPointsbaseline) — the committed bar - Story 4.3.3
rollupForSprint(sprintId).completed— the completed bar (REUSE, do not re-sum); resolve the statistic identically so the bars match the scrum header + report - Story 4.1.2
sprintRepository.listByProject/countByProjectAndState— the sprint list to filter tocomplete+ limit (extend minimally if a state-filter+limit variant is needed) reportsService(introduced in 4.6.3) — the shared service home;motir-core/CLAUDE.md(4-layer); findings #57 (bounded), #26 (workspaceIdgate);motir-core-coverage-gate