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>
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
import {
|
|
DurationStats,
|
|
IssueReadModel,
|
|
IssueTimelineEntry,
|
|
ProjectAnalyticsReadModel,
|
|
ReleaseBurnupReadModel,
|
|
ReleaseReadModel,
|
|
SprintBurndownReadModel,
|
|
SprintReadModel,
|
|
StatusCategory,
|
|
UserRefReadModel,
|
|
} from '@task-stream/api/tracker/domain';
|
|
|
|
/*
|
|
* Statistics are computed from each ticket's timeline (status and release
|
|
* steps). Pure functions: no Nest, no database, easy to test.
|
|
*/
|
|
|
|
const DAY = 86_400_000;
|
|
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
|
|
|
|
interface Step {
|
|
at: number;
|
|
status: string;
|
|
category: StatusCategory;
|
|
assigneeId: string | null;
|
|
}
|
|
|
|
/** "2026-10-06" for a timestamp (UTC). */
|
|
export const dayOf = (time: number) =>
|
|
new Date(time).toISOString().slice(0, 10);
|
|
|
|
/** Monday of the ISO week of a timestamp (UTC), as "YYYY-MM-DD". */
|
|
export function weekOf(time: number): string {
|
|
const date = new Date(time);
|
|
const shift = (date.getUTCDay() + 6) % 7;
|
|
return dayOf(
|
|
Date.UTC(
|
|
date.getUTCFullYear(),
|
|
date.getUTCMonth(),
|
|
date.getUTCDate() - shift,
|
|
),
|
|
);
|
|
}
|
|
|
|
/** End of each UTC day between two instants (inclusive). */
|
|
function days(from: number, to: number): number[] {
|
|
const out: number[] = [];
|
|
const first = Date.UTC(
|
|
new Date(from).getUTCFullYear(),
|
|
new Date(from).getUTCMonth(),
|
|
new Date(from).getUTCDate(),
|
|
);
|
|
for (let day = first; day <= to; day += DAY) out.push(day + DAY - 1);
|
|
return out;
|
|
}
|
|
|
|
function weeks(from: number, to: number): string[] {
|
|
const out: string[] = [];
|
|
for (let day = Date.parse(weekOf(from)); day <= to; day += 7 * DAY)
|
|
out.push(dayOf(day));
|
|
return out;
|
|
}
|
|
|
|
/** Status steps per ticket; tickets with no timeline get one from their creation. */
|
|
function stepsByIssue(
|
|
issues: IssueReadModel[],
|
|
timeline: IssueTimelineEntry[],
|
|
): Map<string, Step[]> {
|
|
const steps = new Map<string, Step[]>();
|
|
for (const entry of timeline) {
|
|
if (entry.kind !== 'status' || !entry.status || !entry.category) continue;
|
|
const list = steps.get(entry.issueId) ?? [];
|
|
list.push({
|
|
at: Date.parse(entry.at),
|
|
status: entry.status,
|
|
category: entry.category,
|
|
assigneeId: entry.assigneeId ?? null,
|
|
});
|
|
steps.set(entry.issueId, list);
|
|
}
|
|
for (const issue of issues) {
|
|
if (!steps.has(issue.id)) {
|
|
steps.set(issue.id, [
|
|
{
|
|
at: Date.parse(issue.createdAt),
|
|
status: issue.status,
|
|
category: issue.statusCategory,
|
|
assigneeId: issue.assignee?.id ?? null,
|
|
},
|
|
]);
|
|
}
|
|
steps.get(issue.id)?.sort((a, b) => a.at - b.at);
|
|
}
|
|
return steps;
|
|
}
|
|
|
|
/** Last step at or before `time`. */
|
|
function stepAt(steps: Step[], time: number): Step | null {
|
|
let current: Step | null = null;
|
|
for (const step of steps) {
|
|
if (step.at > time) break;
|
|
current = step;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function durations(values: number[]): DurationStats {
|
|
if (values.length === 0)
|
|
return { count: 0, averageDays: null, p85Days: null };
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const average = values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
const p85 = sorted[Math.max(0, Math.ceil(0.85 * sorted.length) - 1)];
|
|
const round = (ms: number) => Math.round((ms / DAY) * 10) / 10;
|
|
return {
|
|
count: values.length,
|
|
averageDays: round(average),
|
|
p85Days: round(p85),
|
|
};
|
|
}
|
|
|
|
export interface AnalyticsInput {
|
|
issues: IssueReadModel[];
|
|
timeline: IssueTimelineEntry[];
|
|
/** Status ids in the project's order (series follow it). */
|
|
statusOrder: string[];
|
|
from: Date;
|
|
to: Date;
|
|
}
|
|
|
|
export function computeProjectAnalytics(
|
|
input: AnalyticsInput,
|
|
): ProjectAnalyticsReadModel {
|
|
const from = input.from.getTime();
|
|
const to = input.to.getTime();
|
|
const inRange = (time: number) => time >= from && time <= to;
|
|
const steps = stepsByIssue(input.issues, input.timeline);
|
|
const byId = new Map(input.issues.map((issue) => [issue.id, issue]));
|
|
const people = new Map<string, UserRefReadModel>();
|
|
for (const issue of input.issues) {
|
|
if (issue.assignee) people.set(issue.assignee.id, issue.assignee);
|
|
people.set(issue.reporter.id, issue.reporter);
|
|
}
|
|
|
|
// Created vs resolved, per week.
|
|
const weekKeys = weeks(from, to);
|
|
const created = new Map(weekKeys.map((week) => [week, 0]));
|
|
const resolved = new Map(weekKeys.map((week) => [week, 0]));
|
|
for (const issue of input.issues) {
|
|
const at = Date.parse(issue.createdAt);
|
|
if (inRange(at))
|
|
created.set(weekOf(at), (created.get(weekOf(at)) ?? 0) + 1);
|
|
}
|
|
|
|
// Walk every transition once: resolutions, cycle/lead time, throughput, QA.
|
|
const cycle: number[] = [];
|
|
const lead: number[] = [];
|
|
const throughput = new Map<
|
|
string,
|
|
{ week: string; user: string | null; count: number }
|
|
>();
|
|
let passed = 0;
|
|
let failed = 0;
|
|
for (const [issueId, list] of steps) {
|
|
const issue = byId.get(issueId);
|
|
for (let i = 1; i < list.length; i++) {
|
|
const previous = list[i - 1];
|
|
const step = list[i];
|
|
if (!inRange(step.at)) continue;
|
|
if (
|
|
previous.category === StatusCategory.QA &&
|
|
step.category !== StatusCategory.QA
|
|
) {
|
|
if (step.category === StatusCategory.DONE) passed++;
|
|
else if (step.category !== StatusCategory.CANCELLED) failed++;
|
|
}
|
|
if (
|
|
step.category !== StatusCategory.DONE ||
|
|
previous.category === StatusCategory.DONE
|
|
)
|
|
continue;
|
|
const week = weekOf(step.at);
|
|
resolved.set(week, (resolved.get(week) ?? 0) + 1);
|
|
if (issue) lead.push(step.at - Date.parse(issue.createdAt));
|
|
const start = list.slice(0, i).find((s) => STARTED.includes(s.category));
|
|
if (start) cycle.push(step.at - start.at);
|
|
const key = `${week}|${step.assigneeId ?? ''}`;
|
|
const row = throughput.get(key) ?? {
|
|
week,
|
|
user: step.assigneeId,
|
|
count: 0,
|
|
};
|
|
row.count++;
|
|
throughput.set(key, row);
|
|
}
|
|
}
|
|
|
|
// Cumulative flow: tickets per status at the end of each day.
|
|
const dayEnds = days(from, to);
|
|
const counts = new Map<string, number[]>();
|
|
for (const list of steps.values()) {
|
|
dayEnds.forEach((end, index) => {
|
|
const step = stepAt(list, end);
|
|
if (!step) return;
|
|
const series =
|
|
counts.get(step.status) ?? new Array(dayEnds.length).fill(0);
|
|
series[index]++;
|
|
counts.set(step.status, series);
|
|
});
|
|
}
|
|
const order = (status: string) => {
|
|
const index = input.statusOrder.indexOf(status);
|
|
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
|
};
|
|
|
|
return {
|
|
from: input.from.toISOString(),
|
|
to: input.to.toISOString(),
|
|
createdVsResolved: weekKeys.map((week) => ({
|
|
week,
|
|
created: created.get(week) ?? 0,
|
|
resolved: resolved.get(week) ?? 0,
|
|
})),
|
|
cumulativeFlow: {
|
|
dates: dayEnds.map(dayOf),
|
|
series: [...counts.entries()]
|
|
.sort(([a], [b]) => order(a) - order(b))
|
|
.map(([status, values]) => ({ status, counts: values })),
|
|
},
|
|
cycleTime: durations(cycle),
|
|
leadTime: durations(lead),
|
|
throughput: [...throughput.values()]
|
|
.sort((a, b) => a.week.localeCompare(b.week) || b.count - a.count)
|
|
.map((row) => ({
|
|
week: row.week,
|
|
user: row.user
|
|
? (people.get(row.user) ?? { id: row.user, name: '?' })
|
|
: null,
|
|
count: row.count,
|
|
})),
|
|
qa: {
|
|
passed,
|
|
failed,
|
|
failureRate:
|
|
passed + failed
|
|
? Math.round((failed / (passed + failed)) * 1000) / 1000
|
|
: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface BurnupInput {
|
|
release: ReleaseReadModel;
|
|
issues: IssueReadModel[];
|
|
timeline: IssueTimelineEntry[];
|
|
now: Date;
|
|
}
|
|
|
|
/**
|
|
* Per day, from the release's creation until it ships (or today): tickets in
|
|
* it (scope, cancelled ones aside) and how many of those are done.
|
|
*/
|
|
export function computeReleaseBurnup(
|
|
input: BurnupInput,
|
|
): ReleaseBurnupReadModel {
|
|
const { release } = input;
|
|
const steps = stepsByIssue(input.issues, input.timeline);
|
|
const membership = new Map<
|
|
string,
|
|
{ at: number; releaseId: string | null }[]
|
|
>();
|
|
for (const entry of input.timeline) {
|
|
if (entry.kind !== 'release') continue;
|
|
const list = membership.get(entry.issueId) ?? [];
|
|
list.push({ at: Date.parse(entry.at), releaseId: entry.releaseId ?? null });
|
|
membership.set(entry.issueId, list);
|
|
}
|
|
for (const issue of input.issues) {
|
|
if (!membership.has(issue.id) && issue.release) {
|
|
membership.set(issue.id, [
|
|
{ at: Date.parse(issue.createdAt), releaseId: issue.release.id },
|
|
]);
|
|
}
|
|
membership.get(issue.id)?.sort((a, b) => a.at - b.at);
|
|
}
|
|
|
|
const end = release.releasedAt
|
|
? Date.parse(release.releasedAt)
|
|
: input.now.getTime();
|
|
// At most the last 180 days, so a long-lived release stays readable.
|
|
const start = Math.max(Date.parse(release.createdAt), end - 179 * DAY);
|
|
const dayEnds = days(start, end);
|
|
const scope: number[] = [];
|
|
const done: number[] = [];
|
|
for (const time of dayEnds) {
|
|
let inScope = 0;
|
|
let finished = 0;
|
|
for (const [issueId, list] of membership) {
|
|
const last = list.filter((m) => m.at <= time).at(-1);
|
|
if (last?.releaseId !== release.id) continue;
|
|
const step = stepAt(steps.get(issueId) ?? [], time);
|
|
if (!step || step.category === StatusCategory.CANCELLED) continue;
|
|
inScope++;
|
|
if (step.category === StatusCategory.DONE) finished++;
|
|
}
|
|
scope.push(inScope);
|
|
done.push(finished);
|
|
}
|
|
|
|
return {
|
|
releaseId: release.id,
|
|
targetDate: release.targetDate,
|
|
dates: dayEnds.map(dayOf),
|
|
scope,
|
|
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,
|
|
),
|
|
};
|
|
}
|