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

@@ -59,6 +59,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
labels: filter.labels?.length ? filter.labels : null,
search: filter.search?.trim() || null,
unassigned: filter.unassigned ?? null,
sprintId: filter.sprintId ?? null,
})
.pipe(
map((d) =>

View File

@@ -18,6 +18,7 @@ import {
NotificationAddedDocument,
ProjectChangedDocument,
ReleaseChangedDocument,
SprintChangedDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import {
@@ -85,6 +86,14 @@ export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
);
}
sprintChanges(projectId: string): Observable<{ sprintId: string }> {
return this.signedIn(() =>
this.gql
.subscribe(SprintChangedDocument, { projectId })
.pipe(map((d) => ({ sprintId: d.sprintChanged.sprintId }))),
);
}
projectChanges(projectId: string): Observable<ProjectChanged> {
return this.signedIn(() =>
this.gql

View File

@@ -0,0 +1,84 @@
import { inject, Injectable } from '@angular/core';
import {
CommandResult,
CreateSprintInput,
Sprint,
SprintBurndown,
SprintsGateway,
UpdateSprintInput,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
CloseSprintDocument,
CreateSprintDocument,
DeleteSprintDocument,
SetIssueSprintDocument,
SprintBurndownDocument,
SprintsDocument,
StartSprintDocument,
UpdateSprintDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toCommandResult } from './mappers';
@Injectable()
export class GraphqlSprintsGateway extends SprintsGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string): Observable<Sprint[]> {
return this.gql
.query(SprintsDocument, { projectId })
.pipe(map((d) => d.sprints as Sprint[]));
}
burndown(sprintId: string): Observable<SprintBurndown> {
return this.gql
.query(SprintBurndownDocument, { id: sprintId })
.pipe(map((d) => d.sprint.burndown));
}
create(input: CreateSprintInput): Observable<CommandResult> {
return this.gql
.mutate(CreateSprintDocument, { input })
.pipe(map((d) => toCommandResult(d.createSprint)));
}
update(input: UpdateSprintInput): Observable<CommandResult> {
return this.gql
.mutate(UpdateSprintDocument, { input })
.pipe(map((d) => toCommandResult(d.updateSprint)));
}
start(sprintId: string): Observable<CommandResult> {
return this.gql
.mutate(StartSprintDocument, { sprintId })
.pipe(map((d) => toCommandResult(d.startSprint)));
}
close(
sprintId: string,
carryOverTo: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(CloseSprintDocument, {
sprintId,
carryOverToSprintId: carryOverTo,
})
.pipe(map((d) => toCommandResult(d.closeSprint)));
}
remove(sprintId: string): Observable<CommandResult> {
return this.gql
.mutate(DeleteSprintDocument, { sprintId })
.pipe(map((d) => toCommandResult(d.deleteSprint)));
}
setIssueSprint(
issueId: string,
sprintId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(SetIssueSprintDocument, { issueId, sprintId })
.pipe(map((d) => toCommandResult(d.setIssueSprint)));
}
}

View File

@@ -69,6 +69,10 @@ fragment IssueSummaryFields on Issue {
id
name
}
sprint {
id
name
}
parent {
id
key

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ query Board(
$labels: [String!]
$search: String
$unassigned: Boolean
$sprintId: ID
) {
project(idOrKey: $idOrKey) {
id
@@ -20,6 +21,7 @@ query Board(
labels: $labels
search: $search
unassigned: $unassigned
sprintId: $sprintId
perColumn: 100
) {
status

View File

@@ -0,0 +1,81 @@
fragment SprintFields on Sprint {
id
projectId
name
goal
startDate
endDate
status
startedAt
closedAt
progress {
total
finished
percent
storyPoints
finishedStoryPoints
}
}
query Sprints($projectId: ID!) {
sprints(projectId: $projectId) {
...SprintFields
}
}
query SprintBurndown($id: ID!) {
sprint(id: $id) {
id
burndown {
sprintId
dates
scope
remaining
remainingPoints
ideal
}
}
}
mutation CreateSprint($input: CreateSprintInput!) {
createSprint(input: $input) {
...CommandResultFields
}
}
mutation UpdateSprint($input: UpdateSprintInput!) {
updateSprint(input: $input) {
...CommandResultFields
}
}
mutation StartSprint($sprintId: ID!) {
startSprint(sprintId: $sprintId) {
...CommandResultFields
}
}
mutation CloseSprint($sprintId: ID!, $carryOverToSprintId: ID) {
closeSprint(sprintId: $sprintId, carryOverToSprintId: $carryOverToSprintId) {
...CommandResultFields
}
}
mutation DeleteSprint($sprintId: ID!) {
deleteSprint(sprintId: $sprintId) {
...CommandResultFields
}
}
mutation SetIssueSprint($issueId: ID!, $sprintId: ID) {
setIssueSprint(issueId: $issueId, sprintId: $sprintId) {
...CommandResultFields
}
}
subscription SprintChanged($projectId: ID!) {
sprintChanged(projectId: $projectId) {
action
sprintId
}
}

View File

@@ -11,6 +11,7 @@ import {
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
SprintsGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -26,6 +27,7 @@ import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
/** Ports → GraphQL adapters (and REST for files). */
export function provideTrackerInfrastructure(): EnvironmentProviders {
@@ -40,5 +42,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
]);
}