The project dashboard opens with «Salud del proyecto»: good, attention or at risk, the worst of its signals, each with the cause and the tickets behind it: stuck work, overdue and blocked tickets, critical or several high bugs, QA sending back 30 % or more (with enough verdicts), releases late or at risk by the forecast, columns over their WIP limit. Worked out in the web from the insights it already reads (projectHealth). The portfolio shows each project's light too, with its causes on hover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
3.2 KiB
TypeScript
127 lines
3.2 KiB
TypeScript
import { IssuePriority } from './enums';
|
|
import { portfolioHealth, projectHealth } from './health';
|
|
import { ProjectInsights } from './insights';
|
|
|
|
const issue = (key: string) => ({ key }) as never;
|
|
|
|
const insights = (over: Partial<ProjectInsights> = {}): ProjectInsights =>
|
|
({
|
|
risks: {
|
|
overdue: [],
|
|
overdueCount: 0,
|
|
dueSoon: [],
|
|
dueSoonCount: 0,
|
|
blocked: [],
|
|
blockedCount: 0,
|
|
stuck: [],
|
|
stuckCount: 0,
|
|
stuckDays: 5,
|
|
},
|
|
quality: {
|
|
openBugsByPriority: [],
|
|
bugWeeks: [],
|
|
qaByArea: [],
|
|
escaped: [],
|
|
},
|
|
releases: [],
|
|
bottlenecks: [],
|
|
...over,
|
|
}) as ProjectInsights;
|
|
|
|
describe('project health', () => {
|
|
it('is good with nothing to say', () => {
|
|
expect(projectHealth(insights())).toEqual({ level: 'GOOD', signals: [] });
|
|
});
|
|
|
|
it('turns to attention, then risk, with the cause and the tickets behind it', () => {
|
|
const base = insights();
|
|
const stuck = projectHealth({
|
|
...base,
|
|
risks: { ...base.risks, stuck: [issue('WEB-1')], stuckCount: 1 },
|
|
});
|
|
expect(stuck.level).toBe('ATTENTION');
|
|
expect(stuck.signals[0]).toMatchObject({
|
|
key: 'stuck',
|
|
title: '1 ticket lleva 5 días o más sin moverse',
|
|
keys: ['WEB-1'],
|
|
});
|
|
|
|
const bad = projectHealth({
|
|
...base,
|
|
risks: { ...base.risks, overdueCount: 4, overdue: [] },
|
|
quality: {
|
|
...base.quality,
|
|
openBugsByPriority: [{ priority: IssuePriority.CRITICAL, count: 1 }],
|
|
qaByArea: [{ area: null, returned: 3, passed: 3 }],
|
|
},
|
|
releases: [
|
|
{
|
|
releaseId: 'r',
|
|
name: 'v2.0',
|
|
targetDate: '2026-11-15',
|
|
total: 10,
|
|
finished: 2,
|
|
likelyDate: '2026-12-01',
|
|
safeDate: null,
|
|
verdict: 'LATE',
|
|
},
|
|
],
|
|
bottlenecks: [
|
|
{
|
|
status: 'IN_PROGRESS',
|
|
averageDays: 1,
|
|
samples: 3,
|
|
open: 9,
|
|
wipLimit: 5,
|
|
overLimit: true,
|
|
},
|
|
],
|
|
});
|
|
expect(bad.level).toBe('RISK');
|
|
expect(bad.signals.map((s) => [s.key, s.level])).toEqual([
|
|
['overdue', 'RISK'],
|
|
['bugs', 'RISK'],
|
|
['qa', 'RISK'],
|
|
['releases', 'RISK'],
|
|
['wip', 'ATTENTION'],
|
|
]);
|
|
expect(bad.signals[3].title).toContain('v2.0 no llega a su fecha');
|
|
});
|
|
|
|
it('does not read a trend into a handful of QA verdicts', () => {
|
|
const base = insights();
|
|
expect(
|
|
projectHealth({
|
|
...base,
|
|
quality: {
|
|
...base.quality,
|
|
qaByArea: [{ area: null, returned: 2, passed: 1 }],
|
|
},
|
|
}).level,
|
|
).toBe('GOOD');
|
|
});
|
|
});
|
|
|
|
describe('portfolio health', () => {
|
|
it('reads the light from the counts and the forecasts', () => {
|
|
const project = {
|
|
stuck: 0,
|
|
overdue: 1,
|
|
blocked: 0,
|
|
releases: [{ name: 'v2.0', verdict: 'LATE' }],
|
|
} as never;
|
|
expect(portfolioHealth(project)).toEqual({
|
|
level: 'RISK',
|
|
causes: ['1 vencido', 'v2.0 llega tarde'],
|
|
});
|
|
expect(
|
|
portfolioHealth({
|
|
stuck: 0,
|
|
overdue: 0,
|
|
blocked: 0,
|
|
releases: [],
|
|
} as never),
|
|
).toEqual({ level: 'GOOD', causes: [] });
|
|
});
|
|
});
|