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>
306 lines
8.4 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|