import { defaultProjectSettings, ReleaseReadModel, ReleaseStatus, IssueLinkType, IssueReadModel, IssueTimelineEntry, SprintReadModel, SprintStatus, StatusCategory as C, } from '@task-stream/api/tracker/domain'; import { computeInsights, InsightsInput, simulateDays, STALE_DAYS, STUCK_DAYS, } from './insights'; const NOW = new Date('2026-10-15T12:00:00Z'); const bob = { id: 'bob', name: 'Bob' }; const issue = ( id: string, status: string, category: C, extra: Partial = {}, ): IssueReadModel => ({ id, key: id, title: `Ticket ${id}`, status, statusCategory: category, assignee: bob, reporter: bob, sprint: null, storyPoints: null, dueDate: null, links: [], trashedAt: null, createdAt: '2026-09-01T09:00:00Z', updatedAt: '2026-10-14T09:00:00Z', ...extra, }) as IssueReadModel; let seq = 0; const step = ( issueId: string, at: string, status: string, category: C, ): IssueTimelineEntry => ({ id: `${issueId}:${++seq}`, issueId, projectId: 'p', at, kind: 'status', status, category, assigneeId: 'bob', }); const sprint: SprintReadModel = { id: 's', projectId: 'p', name: 'Sprint 7', goal: null, startDate: '2026-10-12', endDate: '2026-10-25', status: SprintStatus.ACTIVE, startedAt: '2026-10-12T08:00:00Z', closedAt: null, createdAt: '2026-10-01T08:00:00Z', version: 2, }; const inSprint = { sprint: { id: 's', name: 'Sprint 7' } }; const BASE: Omit = { settings: defaultProjectSettings(), activeSprint: null, sprints: [], releases: [], members: [], since: null, mentions: 0, now: NOW, }; const release = (id: string, targetDate: string | null): ReleaseReadModel => ({ id, name: id, targetDate, status: ReleaseStatus.PLANNED, }) as ReleaseReadModel; describe('computeInsights', () => { it('names what is late, due soon, blocked and stuck', () => { const issues = [ issue('LATE', 'TODO', C.TODO, { dueDate: '2026-10-10' }), issue('SOON', 'TODO', C.TODO, { dueDate: '2026-10-20' }), issue('LATER', 'TODO', C.TODO, { dueDate: '2026-11-30' }), issue('DONE_LATE', 'DONE', C.DONE, { dueDate: '2026-10-01' }), issue('WAITS', 'TODO', C.TODO, { links: [ { type: IssueLinkType.BLOCKS, direction: 'INWARD', issueId: 'SLOW', issueKey: 'SLOW', }, ], }), issue('SLOW', 'IN_PROGRESS', C.IN_PROGRESS), issue('FRESH', 'IN_QA', C.QA), ]; const timeline = [ step('SLOW', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS), step('FRESH', '2026-10-14T10:00:00Z', 'IN_QA', C.QA), ]; const { risks, aging } = computeInsights({ issues, timeline, ...BASE, settings: defaultProjectSettings(), }); expect(risks.overdue.map((i) => [i.key, i.days])).toEqual([['LATE', 5]]); expect(risks.dueSoon.map((i) => [i.key, i.days])).toEqual([['SOON', 5]]); expect(risks.blocked.map((i) => i.key)).toEqual(['WAITS']); expect(risks.stuck.map((i) => [i.key, i.days])).toEqual([['SLOW', 14]]); expect(risks.stuckDays).toBe(STUCK_DAYS); // Everything under way, the longest first. expect(aging.issues.map((i) => [i.key, i.days])).toEqual([ ['SLOW', 14], ['FRESH', 1], ]); }); it('measures the sprint under way against the calendar', () => { const issues = [ issue('A', 'DONE', C.DONE, { ...inSprint, storyPoints: 3 }), issue('B', 'IN_PROGRESS', C.IN_PROGRESS, { ...inSprint, storyPoints: 8 }), issue('C', 'TODO', C.TODO, { ...inSprint, storyPoints: 5 }), issue('X', 'CANCELLED', C.CANCELLED, { ...inSprint, storyPoints: 13 }), ]; const { sprint: pulse } = computeInsights({ issues, timeline: [], ...BASE, settings: defaultProjectSettings(), activeSprint: sprint, }); expect(pulse).toMatchObject({ totalDays: 14, daysLeft: 10, tickets: 3, doneTickets: 1, points: 16, donePoints: 3, // 3 points in 3 days; 13 left for 11 days. pacePerDay: 1, neededPerDay: 1.2, behind: true, }); }); it('finds where tickets wait, and columns over their limit', () => { const settings = defaultProjectSettings(); settings.statuses.find((s) => s.id === 'IN_REVIEW')!.wipLimit = 1; const issues = [ issue('A', 'DONE', C.DONE), issue('B', 'IN_REVIEW', C.IN_PROGRESS), issue('C', 'IN_REVIEW', C.IN_PROGRESS), ]; const timeline = [ step('A', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS), step('A', '2026-10-03T10:00:00Z', 'IN_REVIEW', C.IN_PROGRESS), step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE), ]; const rows = computeInsights({ issues, timeline, ...BASE, settings, }).bottlenecks; expect(rows.map((r) => r.status)).toEqual([ 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA', 'QA_FAILED', ]); expect(rows[0]).toMatchObject({ averageDays: 2, samples: 1, open: 0 }); expect(rows[1]).toMatchObject({ averageDays: 4, open: 2, wipLimit: 1, overLimit: true, }); }); it('forecasts planned releases at the pace of the last weeks', () => { // Two tickets finished a day, every day of the last six weeks. expect(simulateDays(10, [2, 2, 2])).toEqual({ likely: 5, safe: 5 }); expect(simulateDays(3, [0, 0])).toBeNull(); expect(simulateDays(0, [])).toEqual({ likely: 0, safe: 0 }); const done = Array.from({ length: 12 }, (_, n) => `D${n}`); const issues = [ ...done.map((id) => issue(id, 'DONE', C.DONE)), ...['A1', 'A2', 'A3'].map((id) => issue(id, 'TODO', C.TODO, { release: { id: 'a', name: 'a' } }), ), ...['B1', 'B2', 'B3'].map((id) => issue(id, 'TODO', C.TODO, { release: { id: 'b', name: 'b' } }), ), ]; // One finished every 3.5 days or so: 12 in the last 42 days. const timeline = done.map((id, n) => step( id, new Date(NOW.getTime() - (n * 3 + 1) * 86_400_000).toISOString(), 'DONE', C.DONE, ), ); const { releases } = computeInsights({ ...BASE, issues, timeline, releases: [release('b', '2026-10-20'), release('a', '2026-12-31')], }); // Nearer target first; the later one waits for it. expect(releases.map((r) => [r.releaseId, r.verdict])).toEqual([ ['b', 'LATE'], ['a', 'ON_TRACK'], ]); expect(releases[1].likelyDate! > releases[0].likelyDate!).toBe(true); }); it('tells what changed since a moment, and who carries what', () => { const ana = { id: 'ana', name: 'Ana' }; const issues = [ issue('NEW', 'TODO', C.TODO, { createdAt: '2026-10-15T08:00:00Z', assignee: ana, storyPoints: 8, }), issue('CLOSED', 'DONE', C.DONE, { sprint: { id: 'old', name: 'Old' }, storyPoints: 5, }), issue('BACK', 'QA_FAILED', C.IN_PROGRESS, { storyPoints: 2 }), issue('STALE', 'TODO', C.TODO, { assignee: null, area: 'FRONTEND', updatedAt: '2026-01-01T00:00:00Z', }), ]; const timeline = [ step('CLOSED', '2026-10-10T09:00:00Z', 'IN_QA', C.QA), step('CLOSED', '2026-10-15T09:00:00Z', 'DONE', C.DONE), step('BACK', '2026-10-10T09:00:00Z', 'IN_QA', C.QA), step('BACK', '2026-10-15T10:00:00Z', 'QA_FAILED', C.IN_PROGRESS), ]; const reading = computeInsights({ ...BASE, issues, timeline, since: new Date('2026-10-15T07:00:00Z'), mentions: 2, members: [{ user: ana, role: 'MEMBER' as never }], sprints: [ { id: 'old', closedAt: '2026-10-11T00:00:00Z' } as SprintReadModel, ], }); expect(reading.changes).toMatchObject({ createdCount: 1, finishedCount: 1, returnedCount: 1, mentions: 2, }); expect(reading.changes?.returned[0].key).toBe('BACK'); // Bob closed 5 points in the one closed sprint and holds 2 open. expect( reading.team.map((t) => [ t.user.id, t.openPoints, t.pointsPerSprint, t.overloaded, ]), ).toEqual([ ['ana', 8, 0, false], ['bob', 2, 5, false], ]); expect(reading.backlog).toEqual({ open: 3, unestimated: 1, noArea: 2, unassigned: 1, stale: 1, staleDays: STALE_DAYS, }); }); });