Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-251Done

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 by startSprint, 4.4.2). The day axis is the calendar days from start to end (a completed sprint stops the actual line at completedAt; 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 (committedPoints from 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_revision trail for the sprint's issues: each transition INTO a done-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' via getTerminalStatusKeys), so the end-of-series remaining MATCHES rollupForSprint(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 $queryRaw over 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's storyPoints; 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"), never NaN; 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 a BurndownSeriesDto with 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 to rollupForSprint(sprintId).remaining (4.3.3, same done-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 $queryRaw over 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]/burndown is 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:coverage keeps the new files ≥90% branch/fn/line (motir-core-coverage-gate).

Context refs

  • Story 1.4.6 workItemRevisionsService + the revision repository / work_item_revision model — 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 — the category = '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 (workspaceId gate), motir-core-coverage-gate (≥90% + empty-input guard); motir-core-local-postgres (sandbox PG@5433)