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:
@@ -5,6 +5,8 @@ import {
|
||||
ProjectAnalyticsReadModel,
|
||||
ReleaseBurnupReadModel,
|
||||
ReleaseReadModel,
|
||||
SprintBurndownReadModel,
|
||||
SprintReadModel,
|
||||
StatusCategory,
|
||||
UserRefReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -313,3 +315,85 @@ export function computeReleaseBurnup(
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SprintBurndownInput {
|
||||
sprint: SprintReadModel;
|
||||
issues: IssueReadModel[];
|
||||
timeline: IssueTimelineEntry[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per day of the sprint (start to end date, UTC): tickets in it, how many
|
||||
* are still open and their points, and the ideal line. Points are today's
|
||||
* estimates (their history is not kept). Days after today, or after the
|
||||
* sprint closed, are null.
|
||||
*/
|
||||
export function computeSprintBurndown(
|
||||
input: SprintBurndownInput,
|
||||
): SprintBurndownReadModel {
|
||||
const { sprint } = input;
|
||||
const steps = stepsByIssue(input.issues, input.timeline);
|
||||
const points = new Map(input.issues.map((i) => [i.id, i.storyPoints ?? 0]));
|
||||
const membership = new Map<
|
||||
string,
|
||||
{ at: number; sprintId: string | null }[]
|
||||
>();
|
||||
for (const entry of input.timeline) {
|
||||
if (entry.kind !== 'sprint') continue;
|
||||
const list = membership.get(entry.issueId) ?? [];
|
||||
list.push({ at: Date.parse(entry.at), sprintId: entry.sprintId ?? null });
|
||||
membership.set(entry.issueId, list);
|
||||
}
|
||||
for (const list of membership.values()) list.sort((a, b) => a.at - b.at);
|
||||
|
||||
const start = Date.parse(`${sprint.startDate}T00:00:00Z`);
|
||||
const end = Date.parse(`${sprint.endDate}T00:00:00Z`);
|
||||
const last = Math.min(
|
||||
sprint.closedAt ? Date.parse(sprint.closedAt) : input.now.getTime(),
|
||||
end + DAY - 1,
|
||||
);
|
||||
const dayEnds = days(start, end + DAY - 1);
|
||||
const scope: (number | null)[] = [];
|
||||
const remaining: (number | null)[] = [];
|
||||
const remainingPoints: (number | null)[] = [];
|
||||
for (const time of dayEnds) {
|
||||
// The day under way counts up to now; the ones to come are unknown.
|
||||
const at = Math.min(time, last);
|
||||
if (time - DAY >= last) {
|
||||
scope.push(null);
|
||||
remaining.push(null);
|
||||
remainingPoints.push(null);
|
||||
continue;
|
||||
}
|
||||
let inScope = 0;
|
||||
let open = 0;
|
||||
let openPoints = 0;
|
||||
for (const [issueId, list] of membership) {
|
||||
const current = list.filter((m) => m.at <= at).at(-1);
|
||||
if (current?.sprintId !== sprint.id) continue;
|
||||
const step = stepAt(steps.get(issueId) ?? [], at);
|
||||
if (!step || step.category === StatusCategory.CANCELLED) continue;
|
||||
inScope++;
|
||||
if (step.category !== StatusCategory.DONE) {
|
||||
open++;
|
||||
openPoints += points.get(issueId) ?? 0;
|
||||
}
|
||||
}
|
||||
scope.push(inScope);
|
||||
remaining.push(open);
|
||||
remainingPoints.push(openPoints);
|
||||
}
|
||||
const first = remaining[0] ?? 0;
|
||||
const span = Math.max(dayEnds.length - 1, 1);
|
||||
return {
|
||||
sprintId: sprint.id,
|
||||
dates: dayEnds.map(dayOf),
|
||||
scope,
|
||||
remaining,
|
||||
remainingPoints,
|
||||
ideal: dayEnds.map(
|
||||
(_, i) => Math.round((first * (span - i) * 100) / span) / 100,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user