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:
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user