Files
task-stream/libs/api/tracker/application/src/lib/analytics/insights.spec.ts
Josep Piguillem 0d158a2ed7 feat: dashboard forecasts releases, tells what changed and weighs the load
The insights reading grows (GraphQL insights(since:), REST ?since=, MCP):
- planned releases with a likely (p50) and safe (p85) finish date from a
  Monte Carlo over the last six weeks of finished work, one after another,
  and a verdict against their target;
- with `since` (the viewer's last visit, kept in the browser): tickets
  created, finished and sent back from QA, and their mentions;
- each person's open points against what they closed per sprint lately;
- open tickets without estimate, area or assignee, or untouched for 90
  days, each count opening the list filtered (new noEstimate, noArea and
  updatedBefore filters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:50:24 +02:00

306 lines
8.4 KiB
TypeScript

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> = {},
): 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<InsightsInput, 'issues' | 'timeline'> = {
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,
});
});
});