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

@@ -31,6 +31,13 @@ import {
InMemoryTrackerStreams,
} from '../../testing/in-memory';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import {
CreateSprintCommand,
SetIssueSprintCommand,
StartSprintCommand,
} from './sprint.commands';
import { IssueTree } from './issue-tree';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { RankIssueCommand } from './rank-issue.command';
@@ -63,6 +70,10 @@ import { WriteAccess } from './write-access';
async function setup() {
const store = new InMemoryEventStore();
const sprints = {
active: null as { id: string; name: string } | null,
findActive: async () => sprints.active,
};
const files = new InMemoryAttachmentStorage();
const users = new FakeUserDirectory()
.add('owner')
@@ -92,6 +103,20 @@ async function setup() {
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
// Read-model lookups: the sprint under way is set by each test.
{ provide: FindSprintsRepository, useValue: sprints },
{
provide: FindIssuesRepository,
useValue: {
sprintProgress: async () => ({
total: 0,
finished: 0,
percent: 0,
storyPoints: 0,
finishedStoryPoints: 0,
}),
},
},
],
}).compile();
await moduleRef.init();
@@ -103,6 +128,7 @@ async function setup() {
return {
store,
files,
sprints,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
};
@@ -405,6 +431,51 @@ describe('tracker commands (event sourced)', () => {
);
});
it('plans and starts sprints, one under way at a time, and fills them', async () => {
const { run, rehydrator, sprints } = await setup();
const projectId = await projectWithTeam(run);
const plan = (name: string) =>
run(
new CreateSprintCommand(
'owner',
projectId,
name,
'2026-10-12',
'2026-10-23',
),
);
const first = await plan('Sprint 1');
const second = await plan('Sprint 2');
await expect(
run(
new CreateSprintCommand(
'dev',
projectId,
'X',
'2026-10-12',
'2026-10-23',
),
),
).rejects.toThrow(ForbiddenError);
await run(new StartSprintCommand('owner', first.id));
sprints.active = { id: first.id, name: 'Sprint 1' };
await expect(
run(new StartSprintCommand('owner', second.id)),
).rejects.toThrow(/Sprint 1 is under way/);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
);
await run(new SetIssueSprintCommand('dev', issue.id, first.id));
expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe(
first.id,
);
await expect(
run(new SetIssueSprintCommand('pm', issue.id, null)),
).rejects.toThrow(ForbiddenError);
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);