Files
task-stream/libs/api/tracker/application/src/lib/analytics/analytics.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

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