Files
task-stream/libs/api/tracker/domain/src/lib/sprint/sprint.ts
Josep Piguillem 50d3ffd1dd 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>
2026-10-08 14:59:01 +02:00

193 lines
6.1 KiB
TypeScript

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;
}
}