4.6.3 Backend — `reportsService.getBurndownSeries(sprintId)`: day-bucketed remaining (guideline + actual) from the committed baseline + the 1.4.6 revision trail, bounded
Estimate: 28m · Depends on: 4.4.2, 4.3.3, 1.4.6
The hard part of this story: reconstruct the burndown's historical "remaining per day" from data that already exists, as a BOUNDED aggregate. Pure backend, no new write model, no migration. Per the 4-layer rule (CLAUDE.md): a new reportsService (the home Epic 6.3 extends) owning the logic; a new bounded grouped-aggregate read on the revision repository; a HTTP-only route; a BurndownSeriesDto.
reportsService.getBurndownSeries(sprintId) → a BurndownSeriesDto:
- Window — the sprint
startDate/endDate(stamped bystartSprint, 4.4.2). The day axis is the calendar days from start to end (a completed sprint stops the actual line atcompletedAt; a live sprint stops it at "today", clamped within the window). (Working-days shading is out of scope — no calendar config yet; calendar days for now, noted as a future refinement.) - Guideline — a straight line from the committed baseline (
committedPointsfrom 4.4.2, resolving the configured estimation statistic — points, else issue count) at the start day down to 0 at the end day. O(1) from the locked baseline; no per-issue scan. - Actual remaining (the derivation) — start at the committed baseline and walk the 1.4.6
work_item_revisiontrail for the sprint's issues: each transition INTO adone-category status subtracts that issue's points on its day; a transition back OUT (reopened) adds them back; each sprint-association ADD after start adds the issue's points (scope up) and each REMOVE subtracts them (scope down / carry-out). Resolve "done" the SAME way as 4.3.3 / 4.5.2 (workflow_status.category = 'done'viagetTerminalStatusKeys), so the end-of-series remaining MATCHESrollupForSprint(sprintId).remaining(4.3.3). Emit the per-day stepped series + the scope-change events (day + delta) the chart marks. - Bounded (finding #57) — the day buckets come from a grouped
$queryRawover the revision rows scoped to (the sprint's issues) ∧ (the sprint window) ∧ (status-transition or sprint-association event types), GROUPed by calendar day server-side. It does NOT load every revision row into Node and reduce in JS, and the day count is bounded by sprint length. The point deltas join the issue'sstoryPoints; an issue unestimated at the time contributes 0 to the points series (and the by-issue-count series counts it). - Degradation — an unestimated sprint returns the issue-count series (or a
null/empty points series the UI renders as "no point data"), neverNaN; an empty sprint returns a flat guideline at 0; a not-yet-started (planned) sprint is rejected / returns an empty series (no window).
Layering. reportsService.getBurndownSeries composes a new bounded repo read (e.g. workItemRevisionRepository.aggregateSprintEventsByDay(sprintId, window, doneStatusKeys) — a single grouped $queryRaw), estimationService.rollupForSprint (to reconcile the endpoint remaining), and sprintRepository reads for the window/baseline. Repo methods are single ops; the service owns the DTO mapping + typed errors; the route is HTTP-only. Reads only — no transaction, no writes. The finding-#26 workspaceId gate covers the route. The new repo aggregate has a direct empty-input-guard test (a sprint with no qualifying revisions → a flat-at-committed series, not a crash) per the coverage gate.
Acceptance criteria
reportsService.getBurndownSeries(sprintId)returns aBurndownSeriesDtowith the day axis (sprint window), the guideline (committed → 0), the stepped actual remaining series, and the scope-change events — derived from the committed baseline (4.4.2) + the 1.4.6 revision trail, with the end-of-series remaining equal torollupForSprint(sprintId).remaining(4.3.3, samedone-category predicate).- The actual line drops on the day an issue reached a done-category status and rises on the day scope was added (verified against seeded revisions at known dates); a reopened issue adds its points back.
- The day buckets come from ONE bounded grouped
$queryRawover revision rows scoped to the sprint issues + window + relevant event types (NOT an all-revisions load + JS reduce); the day count is bounded by sprint length; a forced large sprint (db:seed:large) stays bounded. - Unestimated → the issue-count series / "no point data" (never
NaN); empty sprint → flat guideline at 0; a planned (not-started) sprint returns an empty series / typed error; cross-workspace access is denied (finding #26). GET /api/sprints/[id]/burndownis HTTP-only (parse → one service call → map errors); the new repo aggregate is a single op with a direct empty-input-guard test;pnpm test:coveragekeeps the new files ≥90% branch/fn/line (motir-core-coverage-gate).
Context refs
- Story 1.4.6
workItemRevisionsService+ the revision repository /work_item_revisionmodel — the audit trail the actual line is derived from (status-transition + sprint-association event rows; their timestamps + types) - Story 4.4.2
startSprint(committedPoints/committedIssueCount+startDate/endDate) — the t=0 baseline + the window; Story 4.4.3 (completedAt) — the live-vs-completed cutoff - Story 4.3.3
rollupForSprint(sprintId)({ committed, completed, remaining }, bounded) — reconcile the endpoint remaining + reuse the statistic resolution;workflowsService.getTerminalStatusKeys— thecategory = 'done'split (resolve "done" identically to 4.5.2) motir-core/CLAUDE.md(4-layer; repo single-ops, service owns DTOs/errors); findings #57 (bounded grouped aggregate, not load-all), #26 (workspaceIdgate),motir-core-coverage-gate(≥90% + empty-input guard);motir-core-local-postgres(sandbox PG@5433)