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:
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal file
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user