import { IssueReadModel, IssueTimelineEntry, ReleaseReadModel, ReleaseStatus, StatusCategory as C, SprintReadModel, SprintStatus, } from '@task-stream/api/tracker/domain'; import { computeProjectAnalytics, computeReleaseBurnup, computeSprintBurndown, weekOf, } from './analytics'; const ana = { id: 'ana', name: 'Ana' }; const bob = { id: 'bob', name: 'Bob' }; const issue = ( id: string, createdAt: string, status: string, category: C, releaseId: string | null = null, ): IssueReadModel => ({ id, key: id, createdAt, status, statusCategory: category, reporter: ana, assignee: bob, release: releaseId ? { id: releaseId, name: 'v1' } : null, }) as IssueReadModel; let seq = 0; const step = ( issueId: string, at: string, status: string, category: C, assigneeId: string | null = 'bob', ): IssueTimelineEntry => ({ id: `${issueId}:${++seq}`, issueId, projectId: 'p', at, kind: 'status', status, category, assigneeId, }); /* * A: created Mon 5 Oct, started Tue 6, to QA Wed 7, QA failed Thu 8, back to QA and done Fri 9. * B: created Mon 5 Oct, started Mon 12, QA Tue 13, done Wed 14 (by Ana). * C: created Tue 13 Oct, still in backlog. */ const issues = [ issue('A', '2026-10-05T09:00:00Z', 'DONE', C.DONE, 'rel'), issue('B', '2026-10-05T10:00:00Z', 'DONE', C.DONE, 'rel'), issue('C', '2026-10-13T10:00:00Z', 'BACKLOG', C.TODO), ]; const timeline: IssueTimelineEntry[] = [ step('A', '2026-10-05T09:00:00Z', 'BACKLOG', C.TODO), { id: 'A:r', issueId: 'A', projectId: 'p', at: '2026-10-05T09:00:01Z', kind: 'release', releaseId: 'rel', }, step('A', '2026-10-06T09:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS), step('A', '2026-10-07T09:00:00Z', 'IN_QA', C.QA), step('A', '2026-10-08T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS), step('A', '2026-10-09T08:00:00Z', 'IN_QA', C.QA), step('A', '2026-10-09T09:00:00Z', 'DONE', C.DONE), step('B', '2026-10-05T10:00:00Z', 'BACKLOG', C.TODO), { id: 'B:r', issueId: 'B', projectId: 'p', at: '2026-10-12T08:00:00Z', kind: 'release', releaseId: 'rel', }, step('B', '2026-10-12T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS), step('B', '2026-10-13T10:00:00Z', 'IN_QA', C.QA), step('B', '2026-10-14T10:00:00Z', 'DONE', C.DONE, 'ana'), ]; describe('project analytics', () => { const stats = computeProjectAnalytics({ issues, timeline, statusOrder: ['BACKLOG', 'IN_PROGRESS', 'IN_QA', 'QA_FAILED', 'DONE'], from: new Date('2026-10-05T00:00:00Z'), to: new Date('2026-10-14T23:59:59Z'), }); it('counts created and resolved tickets per week (Monday)', () => { expect(weekOf(Date.parse('2026-10-11T12:00:00Z'))).toBe('2026-10-05'); expect(stats.createdVsResolved).toEqual([ { week: '2026-10-05', created: 2, resolved: 1 }, { week: '2026-10-12', created: 1, resolved: 1 }, ]); }); it('measures cycle time (start → done) and lead time (created → done)', () => { // A: 3 days of cycle, 4 of lead. B: 2 and 9. expect(stats.cycleTime).toEqual({ count: 2, averageDays: 2.5, p85Days: 3 }); expect(stats.leadTime).toEqual({ count: 2, averageDays: 6.5, p85Days: 9 }); }); it('counts QA passes and failures', () => { expect(stats.qa).toEqual({ passed: 2, failed: 1, failureRate: 0.333 }); }); it('attributes throughput to who had the ticket', () => { expect(stats.throughput).toEqual([ { week: '2026-10-05', user: bob, count: 1 }, { week: '2026-10-12', user: ana, count: 1 }, ]); }); it('builds the cumulative flow day by day, in the workflow order', () => { const flow = stats.cumulativeFlow; expect(flow.dates).toHaveLength(10); expect(flow.series.map((s) => s.status)).toEqual([ 'BACKLOG', 'IN_PROGRESS', 'IN_QA', 'QA_FAILED', 'DONE', ]); const at = (status: string, day: string) => flow.series.find((s) => s.status === status)?.counts[ flow.dates.indexOf(day) ]; expect(at('BACKLOG', '2026-10-05')).toBe(2); expect(at('QA_FAILED', '2026-10-08')).toBe(1); expect(at('DONE', '2026-10-14')).toBe(2); expect(at('BACKLOG', '2026-10-14')).toBe(1); // C }); it('falls back to creation + current status for tickets without timeline', () => { const old = computeProjectAnalytics({ issues: [issue('OLD', '2026-10-01T00:00:00Z', 'TODO', C.TODO)], timeline: [], statusOrder: [], from: new Date('2026-10-01T00:00:00Z'), to: new Date('2026-10-02T23:00:00Z'), }); expect(old.cumulativeFlow.series).toEqual([ { status: 'TODO', counts: [1, 1] }, ]); }); }); describe('release burnup', () => { it('tracks scope and done tickets of the release per day', () => { const release = { id: 'rel', createdAt: '2026-10-05T08:00:00Z', releasedAt: '2026-10-14T18:00:00Z', targetDate: '2026-10-15', status: ReleaseStatus.RELEASED, } as ReleaseReadModel; const burnup = computeReleaseBurnup({ release, issues, timeline, now: new Date('2026-10-20T00:00:00Z'), }); const at = (series: number[], day: string) => series[burnup.dates.indexOf(day)]; expect(burnup.dates[0]).toBe('2026-10-05'); expect(burnup.dates.at(-1)).toBe('2026-10-14'); expect(at(burnup.scope, '2026-10-05')).toBe(1); // A expect(at(burnup.scope, '2026-10-12')).toBe(2); // + B expect(at(burnup.done, '2026-10-09')).toBe(1); expect(at(burnup.done, '2026-10-14')).toBe(2); }); }); describe('sprint burndown', () => { const sprint = { id: 's1', projectId: 'p', startDate: '2026-10-12', endDate: '2026-10-16', status: SprintStatus.ACTIVE, closedAt: null, } as SprintReadModel; const into = (issueId: string, at: string): IssueTimelineEntry => ({ id: `${issueId}:${++seq}`, issueId, projectId: 'p', at, kind: 'sprint', sprintId: 's1', }); // X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday. // X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon. const issues = [ { ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 }, { ...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED), storyPoints: 5, }, { ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 }, ] as IssueReadModel[]; const timeline = [ step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO), step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO), step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), into('x', '2026-10-12T08:00:00Z'), into('y', '2026-10-12T08:00:00Z'), into('z', '2026-10-13T10:00:00Z'), step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE), step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED), ]; it('counts open tickets and points per day, and leaves the future blank', () => { const burndown = computeSprintBurndown({ sprint, issues, timeline, now: new Date('2026-10-14T12:00:00Z'), }); expect(burndown.dates).toEqual([ '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-15', '2026-10-16', ]); expect(burndown.scope).toEqual([2, 3, 2, null, null]); expect(burndown.remaining).toEqual([2, 2, 1, null, null]); expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]); expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]); }); });