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 { const steps = new Map(); 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(); 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(); 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, ), }; }