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

@@ -18,3 +18,4 @@ export * from './search-my-issues.query';
export * from './get-public-projects.query';
export * from './get-attachment.query';
export * from './get-epics.query';
export * from './sprint.queries';

View File

@@ -17,6 +17,7 @@ import { GetReleasesQueryHandler } from './get-releases.query-handler';
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
import { GetEpicsQueryHandler } from './get-epics.query-handler';
import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [
@@ -38,6 +39,7 @@ export const QUERY_HANDLERS = [
GetUpcomingReleasesQueryHandler,
SearchIssuesQueryHandler,
GetEpicsQueryHandler,
...SPRINT_QUERY_HANDLERS,
SearchMyIssuesQueryHandler,
GetAttachmentQueryHandler,
];

View File

@@ -0,0 +1,159 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import {
SprintBurndownReadModel,
SprintProgressReadModel,
SprintReadModel,
} from '@task-stream/api/tracker/domain';
import { computeSprintBurndown } from '../analytics/analytics';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { ReadAccess } from './read-access';
export class GetSprintsQuery extends Query<SprintReadModel[]> {
constructor(
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
}
}
export class GetSprintQuery extends Query<SprintReadModel> {
constructor(
public readonly viewerId: string | null,
public readonly sprintId: string,
) {
super();
}
}
export class GetSprintProgressQuery extends Query<SprintProgressReadModel> {
constructor(
public readonly viewerId: string | null,
public readonly sprintId: string,
) {
super();
}
}
export class GetSprintBurndownQuery extends Query<SprintBurndownReadModel> {
constructor(
public readonly viewerId: string | null,
public readonly sprintId: string,
) {
super();
}
}
/** The sprint, if the viewer may see its project (same error otherwise). */
async function visibleSprint(
findSprints: FindSprintsRepository,
access: ReadAccess,
viewerId: string | null,
sprintId: string,
): Promise<SprintReadModel> {
const sprint = access.assertFound(
await findSprints.findById(sprintId),
'Sprint',
sprintId,
);
await access.assertCanView(sprint.projectId, viewerId, {
entity: 'Sprint',
ref: sprintId,
});
return sprint;
}
@QueryHandler(GetSprintsQuery)
export class GetSprintsQueryHandler
implements IQueryHandler<GetSprintsQuery, SprintReadModel[]>
{
constructor(
private readonly findSprints: FindSprintsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
return this.findSprints.findByProject(query.projectId);
}
}
@QueryHandler(GetSprintQuery)
export class GetSprintQueryHandler
implements IQueryHandler<GetSprintQuery, SprintReadModel>
{
constructor(
private readonly findSprints: FindSprintsRepository,
private readonly access: ReadAccess,
) {}
execute(query: GetSprintQuery): Promise<SprintReadModel> {
return visibleSprint(
this.findSprints,
this.access,
query.viewerId,
query.sprintId,
);
}
}
@QueryHandler(GetSprintProgressQuery)
export class GetSprintProgressQueryHandler
implements IQueryHandler<GetSprintProgressQuery, SprintProgressReadModel>
{
constructor(
private readonly findSprints: FindSprintsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(
query: GetSprintProgressQuery,
): Promise<SprintProgressReadModel> {
const sprint = await visibleSprint(
this.findSprints,
this.access,
query.viewerId,
query.sprintId,
);
return this.findIssues.sprintProgress(sprint.id);
}
}
@QueryHandler(GetSprintBurndownQuery)
export class GetSprintBurndownQueryHandler
implements IQueryHandler<GetSprintBurndownQuery, SprintBurndownReadModel>
{
constructor(
private readonly findSprints: FindSprintsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly access: ReadAccess,
) {}
async execute(
query: GetSprintBurndownQuery,
): Promise<SprintBurndownReadModel> {
const sprint = await visibleSprint(
this.findSprints,
this.access,
query.viewerId,
query.sprintId,
);
const [issues, timeline] = await Promise.all([
this.findIssues.findByProject(sprint.projectId),
this.findTimeline.findByProject(sprint.projectId),
]);
return computeSprintBurndown({ sprint, issues, timeline, now: new Date() });
}
}
export const SPRINT_QUERY_HANDLERS = [
GetSprintsQueryHandler,
GetSprintQueryHandler,
GetSprintProgressQueryHandler,
GetSprintBurndownQueryHandler,
];