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