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

@@ -31,3 +31,9 @@ export * from './issue-checklist-item-added.event';
export * from './issue-checklist-item-updated.event';
export * from './issue-checklist-item-removed.event';
export * from './issue-ranked.event';
export * from './sprint-created.event';
export * from './sprint-updated.event';
export * from './sprint-started.event';
export * from './sprint-closed.event';
export * from './sprint-deleted.event';
export * from './issue-sprint-changed.event';

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A ticket was moved in or out of a sprint. */
@AutowiredEvent
export class IssueSprintChangedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly sprintId: string | null,
readonly previousSprintId: string | null,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,19 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/**
* A sprint ended. Its unfinished tickets move to `carryOverTo` (null: back
* to the backlog); the SprintCarryOverSaga moves them.
*/
@AutowiredEvent
export class SprintClosedEvent extends TrackerEvent {
constructor(
actorId: string,
sprintId: string,
projectId: string,
readonly carryOverTo: string | null,
readonly unfinishedIssues: number,
) {
super(actorId, sprintId, projectId);
}
}

View File

@@ -0,0 +1,14 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { SprintProps } from '../sprint/sprint';
import { TrackerEvent } from './tracker.event';
/** A sprint was planned. */
@AutowiredEvent
export class SprintCreatedEvent extends TrackerEvent {
constructor(
actorId: string,
readonly sprint: SprintProps,
) {
super(actorId, sprint.id, sprint.projectId);
}
}

View File

@@ -0,0 +1,10 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A sprint was deleted: its tickets go back to the backlog. */
@AutowiredEvent
export class SprintDeletedEvent extends TrackerEvent {
constructor(actorId: string, sprintId: string, projectId: string) {
super(actorId, sprintId, projectId);
}
}

View File

@@ -0,0 +1,10 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A sprint got under way. */
@AutowiredEvent
export class SprintStartedEvent extends TrackerEvent {
constructor(actorId: string, sprintId: string, projectId: string) {
super(actorId, sprintId, projectId);
}
}

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { SprintDetails } from '../sprint/sprint';
import { TrackerEvent } from './tracker.event';
/** A sprint was renamed, re-dated or got another goal (only what changed). */
@AutowiredEvent
export class SprintUpdatedEvent extends TrackerEvent {
constructor(
actorId: string,
sprintId: string,
projectId: string,
readonly changes: SprintDetails,
) {
super(actorId, sprintId, projectId);
}
}

View File

@@ -2,13 +2,15 @@ import { CommentFactory } from './comment.factory';
import { IssueFactory } from './issue.factory';
import { ProjectFactory } from './project.factory';
import { ReleaseFactory } from './release.factory';
import { SprintFactory } from './sprint.factory';
export * from './issue.factory';
export { CommentFactory, ProjectFactory, ReleaseFactory };
export { CommentFactory, ProjectFactory, ReleaseFactory, SprintFactory };
export const TRACKER_FACTORIES = [
ProjectFactory,
IssueFactory,
CommentFactory,
ReleaseFactory,
SprintFactory,
];

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { newId } from '@task-stream/api/shared/domain';
import { SprintCreatedEvent } from '../events/sprint-created.event';
import { Sprint, SprintProps, normalizeSprintDetails } from '../sprint/sprint';
import { SprintStatus } from '../sprint/sprint-status';
@Injectable()
export class SprintFactory {
create(
actorId: string,
props: {
projectId: string;
name: string;
goal?: string | null;
startDate: string;
endDate: string;
},
): Sprint {
const details = normalizeSprintDetails({
name: props.name,
goal: props.goal ?? null,
startDate: props.startDate,
endDate: props.endDate,
});
const data: SprintProps = {
id: newId(),
projectId: props.projectId,
...details,
status: SprintStatus.PLANNED,
startedAt: null,
closedAt: null,
createdAt: new Date().toISOString(),
};
const sprint = new Sprint(data.id);
Object.assign(sprint, { ...data });
sprint.apply(new SprintCreatedEvent(actorId, data), { skipHandler: true });
return sprint;
}
}

View File

@@ -16,6 +16,7 @@ import { IssueLinkedEvent } from '../events/issue-linked.event';
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
import { IssueRankedEvent } from '../events/issue-ranked.event';
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
@@ -112,6 +113,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public checklist: ChecklistItem[] = [];
/** Place in the backlog once someone moved it (see `backlogRank`). */
public rank: string | null = null;
public sprintId: string | null = null;
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -246,6 +248,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
);
}
/** Moves the ticket in or out of a sprint (null: back to the backlog). */
setSprint(actorId: string, sprintId: string | null): void {
if (this.sprintId === sprintId) return;
this.apply(
new IssueSprintChangedEvent(
actorId,
this.id,
this.projectId,
sprintId,
this.sprintId,
),
);
}
/** Records a file stored for this ticket (or one of its comments). */
attach(actorId: string, attachment: IssueAttachment): void {
const next = normalizeAttachment(attachment);
@@ -519,6 +535,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.deleted = true;
}
onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void {
this.sprintId = event.sprintId;
this.updatedAt = event.occurredAt;
}
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
this.releaseId = event.releaseId;
this.updatedAt = event.occurredAt;

View File

@@ -12,12 +12,13 @@ export class IssueTimelineEntry {
issueId!: string;
projectId!: string;
at!: string;
kind!: 'status' | 'release';
kind!: 'status' | 'release' | 'sprint';
status?: string;
category?: StatusCategory;
/** Assignee when the status changed (throughput per person). */
assigneeId?: string | null;
releaseId?: string | null;
sprintId?: string | null;
}
export class DurationStats {

View File

@@ -8,3 +8,4 @@ export * from './project-stats.read-model';
export * from './analytics.read-model';
export * from './notification.read-model';
export * from './saved-filter.read-model';
export * from './sprint.read-model';

View File

@@ -70,6 +70,7 @@ export class IssueReadModel {
reporter!: UserRefReadModel;
assignee!: UserRefReadModel | null;
release!: { id: string; name: string } | null;
sprint!: { id: string; name: string } | null;
labels!: string[];
figmaUrl!: string | null;
storyPoints!: number | null;

View File

@@ -0,0 +1,40 @@
import { SprintStatus } from '../sprint/sprint-status';
export class SprintReadModel {
id!: string;
projectId!: string;
name!: string;
goal!: string | null;
/** `YYYY-MM-DD`, both included. */
startDate!: string;
endDate!: string;
status!: SprintStatus;
startedAt!: string | null;
closedAt!: string | null;
createdAt!: string;
version!: number;
}
/** How far a sprint's tickets have got. */
export class SprintProgressReadModel {
total!: number;
/** Done or cancelled. */
finished!: number;
percent!: number;
storyPoints!: number;
finishedStoryPoints!: number;
}
/**
* Per day of the sprint: tickets in it (cancelled ones aside), how many are
* still open and their points. Days still to come are null; `ideal` falls in
* a straight line from what was open on the first day to zero on the last.
*/
export class SprintBurndownReadModel {
sprintId!: string;
dates!: string[];
scope!: (number | null)[];
remaining!: (number | null)[];
remainingPoints!: (number | null)[];
ideal!: number[];
}

View File

@@ -0,0 +1,8 @@
export enum SprintStatus {
/** Being prepared: tickets come and go. */
PLANNED = 'PLANNED',
/** Under way (one per project at a time). */
ACTIVE = 'ACTIVE',
/** Over: what was left went to another sprint or back to the backlog. */
CLOSED = 'CLOSED',
}

View File

@@ -0,0 +1,79 @@
import {
BusinessRuleError,
ValidationError,
} from '@task-stream/api/shared/domain';
import { SprintClosedEvent } from '../events/sprint-closed.event';
import { SprintFactory } from '../factories/sprint.factory';
import { replay } from '../testing';
import { Sprint } from './sprint';
import { SprintStatus } from './sprint-status';
const factory = new SprintFactory();
const newSprint = (name = 'Sprint 1', projectId = 'p-1') =>
factory.create('u-pm', {
projectId,
name,
goal: ' Pagos con tarjeta ',
startDate: '2026-10-12',
endDate: '2026-10-23',
});
describe('Sprint', () => {
it('is planned with tidy details', () => {
const sprint = newSprint();
expect(sprint.status).toBe(SprintStatus.PLANNED);
expect(sprint.goal).toBe('Pagos con tarjeta');
});
it('refuses dates the wrong way round, too long or malformed', () => {
const create = (startDate: string, endDate: string) =>
factory.create('u', { projectId: 'p', name: 'S', startDate, endDate });
expect(() => create('2026-10-23', '2026-10-12')).toThrow(ValidationError);
expect(() => create('2026-01-01', '2026-12-31')).toThrow(/at most/);
expect(() => create('12/10/2026', '2026-10-23')).toThrow(ValidationError);
});
it('starts once, only when nothing else is under way, and closes', () => {
const sprint = newSprint();
const next = newSprint('Sprint 2');
expect(() => sprint.start('u', { name: 'Sprint 0' })).toThrow(
BusinessRuleError,
);
sprint.start('u', null);
expect(() => sprint.start('u', null)).toThrow(BusinessRuleError);
expect(() => sprint.update('u', { startDate: '2026-10-13' })).toThrow(
/start date/,
);
sprint.update('u', { endDate: '2026-10-26' });
sprint.close('u', next, 3);
const closed = sprint
.getUncommittedEvents()
.find((e) => e instanceof SprintClosedEvent);
expect(closed).toMatchObject({ carryOverTo: next.id, unfinishedIssues: 3 });
const { rebuilt } = replay(sprint, Sprint);
expect(rebuilt.status).toBe(SprintStatus.CLOSED);
expect(rebuilt.endDate).toBe('2026-10-26');
expect(() => rebuilt.assertAcceptsIssues()).toThrow(BusinessRuleError);
});
it('carries over only to another open sprint of the same project', () => {
const sprint = newSprint();
sprint.start('u', null);
expect(() => sprint.close('u', sprint, 0)).toThrow(BusinessRuleError);
expect(() => sprint.close('u', newSprint('X', 'p-2'), 0)).toThrow(
BusinessRuleError,
);
});
it('deletes planned sprints, and any on cascade', () => {
const planned = newSprint();
planned.delete('u');
expect(planned.deleted).toBe(true);
const active = newSprint();
active.start('u', null);
expect(() => active.delete('u')).toThrow(BusinessRuleError);
active.delete('u', true);
expect(active.deleted).toBe(true);
});
});

View File

@@ -0,0 +1,192 @@
import {
BusinessRuleError,
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
import { SprintClosedEvent } from '../events/sprint-closed.event';
import { SprintCreatedEvent } from '../events/sprint-created.event';
import { SprintDeletedEvent } from '../events/sprint-deleted.event';
import { SprintStartedEvent } from '../events/sprint-started.event';
import { SprintUpdatedEvent } from '../events/sprint-updated.event';
import { SprintStatus } from './sprint-status';
/** Data of a sprint (what SprintCreatedEvent carries). */
export interface SprintProps {
id: string;
projectId: string;
name: string;
goal: string | null;
/** First and last day, `YYYY-MM-DD`. */
startDate: string;
endDate: string;
status: SprintStatus;
startedAt: string | null;
closedAt: string | null;
createdAt: string;
}
export type SprintDetails = Partial<
Pick<SprintProps, 'name' | 'goal' | 'startDate' | 'endDate'>
>;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
/** Long enough for any real iteration, short enough to catch a typo. */
export const MAX_SPRINT_DAYS = 90;
/** Checks the name and the dates of a sprint; returns them tidied. */
export function normalizeSprintDetails(
details: Required<Pick<SprintProps, 'name' | 'startDate' | 'endDate'>> &
Pick<SprintProps, 'goal'>,
): Required<Pick<SprintProps, 'name' | 'startDate' | 'endDate' | 'goal'>> {
const name = details.name.trim();
assertThat(
name.length > 0 && name.length <= 80,
'A sprint needs a name (up to 80 characters)',
);
for (const date of [details.startDate, details.endDate])
assertThat(
DATE.test(date) && !Number.isNaN(Date.parse(date)),
'Sprint dates must be YYYY-MM-DD',
);
const days =
(Date.parse(details.endDate) - Date.parse(details.startDate)) / 86_400_000;
assertThat(days >= 0, 'A sprint cannot end before it starts');
assertThat(
days < MAX_SPRINT_DAYS,
`A sprint lasts at most ${MAX_SPRINT_DAYS} days`,
);
return {
name,
goal: details.goal?.trim() || null,
startDate: details.startDate,
endDate: details.endDate,
};
}
/** A time-boxed iteration: the tickets the team commits to for a few weeks. */
export class Sprint extends VersionedAggregateRoot implements SprintProps {
public projectId!: string;
public name!: string;
public goal: string | null = null;
public startDate!: string;
public endDate!: string;
public status: SprintStatus = SprintStatus.PLANNED;
public startedAt: string | null = null;
public closedAt: string | null = null;
public createdAt!: string;
public deleted = false;
constructor(id: string) {
super();
this.id = id;
}
/** Closed sprints keep what they had: their record is fixed. */
assertAcceptsIssues(): void {
if (this.status === SprintStatus.CLOSED)
throw new BusinessRuleError(`Sprint ${this.name} is closed`);
}
update(actorId: string, changes: SprintDetails): void {
this.assertAcceptsIssues();
const next = normalizeSprintDetails({
name: changes.name ?? this.name,
goal: changes.goal !== undefined ? changes.goal : this.goal,
startDate: changes.startDate ?? this.startDate,
endDate: changes.endDate ?? this.endDate,
});
if (
this.status === SprintStatus.ACTIVE &&
next.startDate !== this.startDate
)
throw new BusinessRuleError('A sprint under way keeps its start date');
const patch: SprintDetails = {};
for (const field of ['name', 'goal', 'startDate', 'endDate'] as const)
if (next[field] !== this[field])
(patch as Record<string, unknown>)[field] = next[field];
if (Object.keys(patch).length === 0) return;
this.apply(new SprintUpdatedEvent(actorId, this.id, this.projectId, patch));
}
/** `otherActive`: the sprint already under way in the project, if any. */
start(actorId: string, otherActive: { name: string } | null): void {
if (this.status !== SprintStatus.PLANNED)
throw new BusinessRuleError(`Sprint ${this.name} is ${this.status}`);
if (otherActive)
throw new BusinessRuleError(
`Sprint ${otherActive.name} is under way; close it first`,
);
this.apply(new SprintStartedEvent(actorId, this.id, this.projectId));
}
/**
* Ends the sprint. Its unfinished tickets go to `carryOverTo` (another
* open sprint of the project) or, with null, back to the backlog.
*/
close(
actorId: string,
carryOverTo: Sprint | null,
unfinishedIssues: number,
): void {
if (this.status !== SprintStatus.ACTIVE)
throw new BusinessRuleError(
`Only a sprint under way can be closed; ${this.name} is ${this.status}`,
);
if (carryOverTo) {
if (
carryOverTo.id === this.id ||
carryOverTo.projectId !== this.projectId
)
throw new BusinessRuleError(
'Unfinished tickets go to another sprint of the same project',
);
carryOverTo.assertAcceptsIssues();
}
this.apply(
new SprintClosedEvent(
actorId,
this.id,
this.projectId,
carryOverTo?.id ?? null,
unfinishedIssues,
),
);
}
/**
* Removes a sprint that never started (its tickets go back to the
* backlog), or any sprint when its project is deleted (`cascade`).
*/
delete(actorId: string, cascade = false): void {
if (this.deleted) return;
if (!cascade && this.status !== SprintStatus.PLANNED)
throw new BusinessRuleError(
`Sprint ${this.name} is ${this.status}; only planned sprints can be deleted`,
);
this.apply(new SprintDeletedEvent(actorId, this.id, this.projectId));
}
/* ---- State transitions (also replayed by loadFromHistory) ---- */
onSprintCreatedEvent({ sprint }: SprintCreatedEvent): void {
Object.assign(this, sprint);
}
onSprintUpdatedEvent(event: SprintUpdatedEvent): void {
Object.assign(this, event.changes);
}
onSprintStartedEvent(event: SprintStartedEvent): void {
this.status = SprintStatus.ACTIVE;
this.startedAt = event.occurredAt;
}
onSprintClosedEvent(event: SprintClosedEvent): void {
this.status = SprintStatus.CLOSED;
this.closedAt = event.occurredAt;
}
onSprintDeletedEvent(): void {
this.deleted = true;
}
}