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

@@ -0,0 +1,65 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintStatus,
SprintUpdatedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UpsertMaterializedSprintRepository } from '../ports/upsert-materialized-sprint.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
type SprintEvent =
| SprintCreatedEvent
| SprintUpdatedEvent
| SprintStartedEvent
| SprintClosedEvent
| SprintDeletedEvent;
/** The sprints view (`sprints_view`), one document per sprint. */
@EventsHandler(
SprintCreatedEvent,
SprintUpdatedEvent,
SprintStartedEvent,
SprintClosedEvent,
SprintDeletedEvent,
)
export class SprintEventHandler implements IEventHandler<SprintEvent> {
constructor(
private readonly sprints: UpsertMaterializedSprintRepository,
private readonly issues: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: SprintEvent) {
const version = versionOf(event);
const id = event.aggregateId;
if (event instanceof SprintCreatedEvent) {
await this.sprints.upsert({ ...event.sprint, version });
} else if (event instanceof SprintUpdatedEvent) {
await this.sprints.upsert({ id, ...event.changes, version });
// Tickets show its name.
if (event.changes.name)
await this.issues.renameSprint(id, event.changes.name);
} else if (event instanceof SprintStartedEvent) {
await this.sprints.upsert({
id,
status: SprintStatus.ACTIVE,
startedAt: event.occurredAt,
version,
});
} else if (event instanceof SprintClosedEvent) {
await this.sprints.upsert({
id,
status: SprintStatus.CLOSED,
closedAt: event.occurredAt,
version,
});
} else {
await this.sprints.remove(id);
}
await this.notifier.sprint(id, event);
}
}