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

@@ -26,6 +26,10 @@ export const issuePageEvents = eventGroup({
targetKey: string;
}>(),
unlinkRequested: type<LinkIssuesInput>(),
sprintChangeRequested: type<{
issueId: string;
sprintId: string | null;
}>(),
releaseChangeRequested: type<{
issueId: string;
releaseId: string | null;

View File

@@ -10,6 +10,10 @@ import {
ProjectStats,
Release,
ReleaseBurnup,
CreateSprintInput,
Sprint,
SprintBurndown,
UpdateSprintInput,
UserDirectoryEntry,
} from '@task-stream/web/tracker/domain';
@@ -23,6 +27,15 @@ export const projectPageEvents = eventGroup({
releaseCreateRequested: type<CreateReleaseInput>(),
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
releaseArchiveRequested: type<{ releaseId: string }>(),
sprintCreateRequested: type<CreateSprintInput>(),
sprintUpdateRequested: type<UpdateSprintInput>(),
sprintStartRequested: type<{ sprintId: string }>(),
sprintCloseRequested: type<{
sprintId: string;
carryOverTo: string | null;
}>(),
sprintDeleteRequested: type<{ sprintId: string }>(),
burndownRequested: type<{ sprintId: string }>(),
userSearchRequested: type<string>(),
settingsSaveRequested: type<{
projectId: string;
@@ -42,6 +55,8 @@ export const projectApiEvents = eventGroup({
loadFailed: type<string>(),
statsLoaded: type<ProjectStats>(),
releasesLoaded: type<Release[]>(),
sprintsLoaded: type<Sprint[]>(),
burndownLoaded: type<SprintBurndown>(),
activityLoaded: type<Activity[]>(),
commandSucceeded: type<string>(),
commandFailed: type<string>(),
@@ -63,5 +78,6 @@ export const projectRealtimeEvents = eventGroup({
projectDeleted: type<Project>(),
activityAdded: type<Activity>(),
releaseChanged: type<Release>(),
sprintChanged: type<{ sprintId: string }>(),
},
});

View File

@@ -13,6 +13,7 @@ import {
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
SprintsGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -70,6 +71,14 @@ function gateways() {
download: vi.fn(() => of(new Blob(['x']))),
remove: vi.fn(() => of(ok)),
},
sprints: {
list: vi.fn(() => of([])),
burndown: vi.fn(() => NEVER),
create: vi.fn(() => of(ok)),
start: vi.fn(() => of(ok)),
close: vi.fn(() => of(ok)),
setIssueSprint: vi.fn(() => of(ok)),
},
views: {
list: vi.fn(() => of([])),
save: vi.fn(() => of('v1')),
@@ -80,6 +89,7 @@ function gateways() {
commentChanges: () => NEVER,
activity: () => NEVER,
releaseChanges: () => NEVER,
sprintChanges: () => NEVER,
projectChanges: vi.fn((): unknown => NEVER),
},
};
@@ -113,6 +123,7 @@ function setup() {
{ provide: UsersGateway, useValue: { search: () => of([]) } },
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
{ provide: SavedFiltersGateway, useValue: fakes.views },
{ provide: SprintsGateway, useValue: fakes.sprints },
{
provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() },
@@ -219,6 +230,28 @@ describe('ProjectFacade', () => {
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
});
it('plans, starts and closes sprints of the open project', () => {
const { project, fakes } = setup();
project.open('WEB');
project.createSprint({
name: 'Sprint 1',
startDate: '2026-10-12',
endDate: '2026-10-23',
});
project.startSprint('s1');
project.closeSprint('s1', null);
expect(fakes.sprints.list).toHaveBeenCalledWith('p1');
expect(fakes.sprints.create).toHaveBeenCalledWith({
projectId: 'p1',
name: 'Sprint 1',
startDate: '2026-10-12',
endDate: '2026-10-23',
});
expect(fakes.sprints.start).toHaveBeenCalledWith('s1');
expect(fakes.sprints.close).toHaveBeenCalledWith('s1', null);
});
it('makes the project public and deletes it, then goes home', () => {
const { project, fakes, navigate, notifications } = setup();
project.open('WEB');

View File

@@ -79,6 +79,13 @@ export class IssueFacade {
);
}
/** null: back to the backlog. */
setSprint(sprintId: string | null): void {
this.withIssue((issueId) =>
this.dispatch.sprintChangeRequested({ issueId, sprintId }),
);
}
setRelease(releaseId: string | null): void {
this.withIssue((issueId) =>
this.dispatch.releaseChangeRequested({ issueId, releaseId }),

View File

@@ -2,8 +2,11 @@ import { computed, inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
CreateReleaseInput,
CreateSprintInput,
ProjectRole,
ProjectSettings,
SprintStatus,
UpdateSprintInput,
} from '@task-stream/web/tracker/domain';
import { projectPageEvents } from '../events/project.events';
import { ProjectStore } from '../stores/project.store';
@@ -21,6 +24,20 @@ export class ProjectFacade {
readonly members = this.store.members;
readonly stats = this.store.stats;
readonly releases = this.store.releases;
/** Under way, planned, then closed; with their progress. */
readonly sprints = this.store.sprints;
/** The sprint under way, if any. */
readonly activeSprint = computed(
() =>
this.store.sprints().find((s) => s.status === SprintStatus.ACTIVE) ??
null,
);
/** Sprints that still take tickets (under way or planned). */
readonly openSprints = computed(() =>
this.store.sprints().filter((s) => s.status !== SprintStatus.CLOSED),
);
/** Burndown of the sprint asked for with `loadBurndown`. */
readonly burndown = this.store.burndown;
readonly activity = this.store.activity;
/** Pireon directory results for `searchUsers()`. */
readonly userResults = this.store.userResults;
@@ -80,6 +97,33 @@ export class ProjectFacade {
}
/** `force` publishes even with unfinished tickets. */
createSprint(sprint: Omit<CreateSprintInput, 'projectId'>): void {
const projectId = this.projectId();
if (projectId)
this.dispatch.sprintCreateRequested({ projectId, ...sprint });
}
updateSprint(changes: UpdateSprintInput): void {
this.dispatch.sprintUpdateRequested(changes);
}
startSprint(sprintId: string): void {
this.dispatch.sprintStartRequested({ sprintId });
}
/** Unfinished tickets go to `carryOverTo` (null: back to the backlog). */
closeSprint(sprintId: string, carryOverTo: string | null): void {
this.dispatch.sprintCloseRequested({ sprintId, carryOverTo });
}
deleteSprint(sprintId: string): void {
this.dispatch.sprintDeleteRequested({ sprintId });
}
loadBurndown(sprintId: string): void {
this.dispatch.burndownRequested({ sprintId });
}
publishRelease(releaseId: string, force = false): void {
this.dispatch.releasePublishRequested({ releaseId, force });
}

View File

@@ -19,6 +19,7 @@ import {
Issue,
IssuesGateway,
IssueSummary,
SprintsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
@@ -122,6 +123,7 @@ export const IssueStore = signalStore(
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
attachments = inject(AttachmentsGateway),
sprints = inject(SprintsGateway),
) => {
/** One file after another: the ticket's version moves with each. */
const uploadAll = (
@@ -410,6 +412,20 @@ export const IssueStore = signalStore(
),
),
),
events
.on(issuePageEvents.sprintChangeRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
sprints.setIssueSprint(payload.issueId, payload.sprintId),
payload.sprintId
? 'Metido en el sprint'
: 'De vuelta al backlog',
done,
failed,
),
),
),
events
.on(issuePageEvents.releaseChangeRequested)
.pipe(

View File

@@ -19,6 +19,9 @@ import {
Release,
ReleaseBurnup,
ReleasesGateway,
Sprint,
SprintBurndown,
SprintsGateway,
TrackerRealtimeGateway,
UserDirectoryEntry,
UsersGateway,
@@ -50,6 +53,10 @@ interface ProjectState {
project: Project | null;
stats: ProjectStats | null;
releases: Release[];
/** Under way, planned, then closed; with their progress. */
sprints: Sprint[];
/** Burndown of the sprint asked for with `burndownRequested`. */
burndown: SprintBurndown | null;
activity: Activity[];
userResults: UserDirectoryEntry[];
analytics: ProjectAnalytics | null;
@@ -71,6 +78,8 @@ export const ProjectStore = signalStore(
project: null,
stats: null,
releases: [],
sprints: [],
burndown: null,
activity: [],
userResults: [],
analytics: null,
@@ -109,6 +118,8 @@ export const ProjectStore = signalStore(
project: null,
stats: null,
releases: [],
sprints: [],
burndown: null,
activity: [],
analytics: null,
analyticsError: null,
@@ -153,6 +164,10 @@ export const ProjectStore = signalStore(
error: payload,
})),
on(projectApiEvents.statsLoaded, ({ payload }) => ({ stats: payload })),
on(projectApiEvents.sprintsLoaded, ({ payload }) => ({ sprints: payload })),
on(projectApiEvents.burndownLoaded, ({ payload }) => ({
burndown: payload,
})),
on(projectApiEvents.releasesLoaded, ({ payload }) => ({
releases: payload,
})),
@@ -175,6 +190,7 @@ export const ProjectStore = signalStore(
events = inject(Events),
projects = inject(ProjectsGateway),
releases = inject(ReleasesGateway),
sprints = inject(SprintsGateway),
activity = inject(ActivityGateway),
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
@@ -191,6 +207,23 @@ export const ProjectStore = signalStore(
}),
);
const reloadSprints = (projectId: string) =>
sprints.list(projectId).pipe(
mapResponse({
next: (list) => projectApiEvents.sprintsLoaded(list),
error: (error) =>
projectApiEvents.commandFailed(describeError(error)),
}),
);
const loadBurndown = (sprintId: string) =>
sprints.burndown(sprintId).pipe(
mapResponse({
next: (burndown) => projectApiEvents.burndownLoaded(burndown),
error: (error) =>
projectApiEvents.commandFailed(describeError(error)),
}),
);
return {
load$: events.on(projectPageEvents.opened).pipe(
switchMap(({ payload }) =>
@@ -216,6 +249,7 @@ export const ProjectStore = signalStore(
.stats(payload.id)
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
reloadReleases(payload.id),
reloadSprints(payload.id),
activity
.list(payload.id)
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
@@ -240,6 +274,9 @@ export const ProjectStore = signalStore(
realtime
.releaseChanges(payload.id)
.pipe(map((r) => projectRealtimeEvents.releaseChanged(r))),
realtime
.sprintChanges(payload.id)
.pipe(map((c) => projectRealtimeEvents.sprintChanged(c))),
),
),
),
@@ -267,6 +304,24 @@ export const ProjectStore = signalStore(
return id ? reloadReleases(id) : [];
}),
),
/** Progress and burndown follow the tickets: refetch when they or a sprint change. */
refreshSprints$: merge(
events.on(projectRealtimeEvents.sprintChanged),
events
.on(projectRealtimeEvents.activityAdded)
.pipe(debounceTime(800)),
).pipe(
switchMap(() => {
const id = store.project()?.id;
const shown = store.burndown()?.sprintId;
return id
? merge(reloadSprints(id), shown ? loadBurndown(shown) : [])
: [];
}),
),
burndown$: events
.on(projectPageEvents.burndownRequested)
.pipe(switchMap(({ payload }) => loadBurndown(payload.sprintId))),
analytics$: events.on(projectPageEvents.analyticsRequested).pipe(
switchMap(({ payload }) =>
projects.analytics(payload.projectId, payload.days).pipe(
@@ -376,6 +431,68 @@ export const ProjectStore = signalStore(
),
),
),
events
.on(projectPageEvents.sprintCreateRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
sprints.create(payload),
`Sprint ${payload.name} planificado`,
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.sprintUpdateRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
sprints.update(payload),
'Sprint actualizado',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.sprintStartRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
sprints.start(payload.sprintId),
'Sprint en marcha 🏁',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.sprintCloseRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
sprints.close(payload.sprintId, payload.carryOverTo),
payload.carryOverTo
? 'Sprint cerrado: lo pendiente pasa al siguiente'
: 'Sprint cerrado: lo pendiente vuelve al backlog',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.sprintDeleteRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
sprints.remove(payload.sprintId),
'Sprint eliminado',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.releasePublishRequested)
.pipe(