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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
159
libs/api/tracker/application/src/lib/queries/sprint.queries.ts
Normal file
159
libs/api/tracker/application/src/lib/queries/sprint.queries.ts
Normal 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,
|
||||
];
|
||||
Reference in New Issue
Block a user