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>
193 lines
6.1 KiB
TypeScript
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;
|
|
}
|
|
}
|