feat: sprints with burndown and carry-over

Plan sprints with dates and a goal, run one at a time per project, and
put tickets in them from the ticket page. The Sprints tab follows the one
under way with its progress and a burndown in tickets or points against
the ideal line. Closing a sprint sends what was left to the next one or
back to the backlog; the board can be filtered by sprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:59:01 +02:00
parent d2e31789a7
commit 50d3ffd1dd
104 changed files with 7209 additions and 33 deletions

View File

@@ -4,10 +4,13 @@ import {
ReleaseReadModel,
ReleaseStatus,
StatusCategory as C,
SprintReadModel,
SprintStatus,
} from '@task-stream/api/tracker/domain';
import {
computeProjectAnalytics,
computeReleaseBurnup,
computeSprintBurndown,
weekOf,
} from './analytics';
@@ -182,3 +185,62 @@ describe('release burnup', () => {
expect(at(burnup.done, '2026-10-14')).toBe(2);
});
});
describe('sprint burndown', () => {
const sprint = {
id: 's1',
projectId: 'p',
startDate: '2026-10-12',
endDate: '2026-10-16',
status: SprintStatus.ACTIVE,
closedAt: null,
} as SprintReadModel;
const into = (issueId: string, at: string): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'sprint',
sprintId: 's1',
});
// X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday.
// X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon.
const issues = [
{ ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 },
{
...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED),
storyPoints: 5,
},
{ ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 },
] as IssueReadModel[];
const timeline = [
step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
into('x', '2026-10-12T08:00:00Z'),
into('y', '2026-10-12T08:00:00Z'),
into('z', '2026-10-13T10:00:00Z'),
step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE),
step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED),
];
it('counts open tickets and points per day, and leaves the future blank', () => {
const burndown = computeSprintBurndown({
sprint,
issues,
timeline,
now: new Date('2026-10-14T12:00:00Z'),
});
expect(burndown.dates).toEqual([
'2026-10-12',
'2026-10-13',
'2026-10-14',
'2026-10-15',
'2026-10-16',
]);
expect(burndown.scope).toEqual([2, 3, 2, null, null]);
expect(burndown.remaining).toEqual([2, 2, 1, null, null]);
expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]);
expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]);
});
});