feat(web): project health as a traffic light, with its causes

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>
This commit is contained in:
2026-10-20 21:08:21 +02:00
parent 8ed16f133e
commit 79b8094011
8 changed files with 504 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ export * from './lib/models/models';
export * from './lib/models/bulk';
export * from './lib/models/reminders';
export * from './lib/models/my-day';
export * from './lib/models/health';
export * from './lib/models/inputs';
export * from './lib/models/workflow';
export * from './lib/ports/gateways';

View File

@@ -0,0 +1,126 @@
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: [] });
});
});

View File

@@ -0,0 +1,173 @@
import { IssuePriority } from './enums';
import { PortfolioProject, ProjectInsights } from './insights';
/** How a project looks, as a traffic light. */
export type HealthLevel = 'GOOD' | 'ATTENTION' | 'RISK';
/** One thing that colours the light, with its cause. */
export interface HealthSignal {
key: 'stuck' | 'overdue' | 'blocked' | 'bugs' | 'qa' | 'releases' | 'wip';
level: Exclude<HealthLevel, 'GOOD'>;
/** What is wrong, in a line. */
title: string;
/** Tickets behind it (keys), when there are. */
keys: string[];
/** Where to look: a dashboard section id. */
section: string;
}
export interface ProjectHealth {
level: HealthLevel;
/** Worst first. */
signals: HealthSignal[];
}
const RANK: Record<HealthLevel, number> = { GOOD: 0, ATTENTION: 1, RISK: 2 };
const plural = (n: number, one: string, many: string) =>
`${n} ${n === 1 ? one : many}`;
const shortDate = (day: string) =>
new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
/**
* The project's health from its insights: stuck work, overdue and blocked
* tickets, urgent bugs, QA sending too much back, releases that will not
* make it and columns over their WIP limit. The light is the worst signal.
*/
export function projectHealth(insights: ProjectInsights): ProjectHealth {
const signals: HealthSignal[] = [];
const { risks, quality, releases, bottlenecks } = insights;
const keys = (issues: { key: string }[]) => issues.map((i) => i.key);
if (risks.stuckCount)
signals.push({
key: 'stuck',
level: risks.stuckCount >= 3 ? 'RISK' : 'ATTENTION',
title: `${plural(risks.stuckCount, 'ticket lleva', 'tickets llevan')} ${risks.stuckDays} días o más sin moverse`,
keys: keys(risks.stuck),
section: 'risks',
});
if (risks.overdueCount)
signals.push({
key: 'overdue',
level: risks.overdueCount >= 3 ? 'RISK' : 'ATTENTION',
title: `${plural(risks.overdueCount, 'ticket ha pasado', 'tickets han pasado')} su fecha límite`,
keys: keys(risks.overdue),
section: 'risks',
});
if (risks.blockedCount)
signals.push({
key: 'blocked',
level: risks.blockedCount >= 5 ? 'RISK' : 'ATTENTION',
title: `${plural(risks.blockedCount, 'ticket espera', 'tickets esperan')} a otro que lo bloquea`,
keys: keys(risks.blocked),
section: 'risks',
});
const bugs = (priority: IssuePriority) =>
quality.openBugsByPriority.find((b) => b.priority === priority)?.count ?? 0;
const critical = bugs(IssuePriority.CRITICAL);
const high = bugs(IssuePriority.HIGH);
if (critical || high >= 3)
signals.push({
key: 'bugs',
level: critical ? 'RISK' : 'ATTENTION',
title: critical
? `${plural(critical, 'bug crítico abierto', 'bugs críticos abiertos')}${high ? ` y ${plural(high, 'de prioridad alta', 'de prioridad alta')}` : ''}`
: `${high} bugs de prioridad alta abiertos`,
keys: [],
section: 'quality',
});
const returned = quality.qaByArea.reduce((n, a) => n + a.returned, 0);
const passed = quality.qaByArea.reduce((n, a) => n + a.passed, 0);
const reviewed = returned + passed;
// Too few to tell a trend from bad luck.
if (reviewed >= 5 && returned / reviewed >= 0.3) {
const share = Math.round((returned / reviewed) * 100);
signals.push({
key: 'qa',
level: share >= 50 ? 'RISK' : 'ATTENTION',
title: `QA devuelve el ${share} % de lo que revisa (90 días)`,
keys: [],
section: 'quality',
});
}
for (const release of releases) {
if (release.verdict !== 'LATE' && release.verdict !== 'AT_RISK') continue;
const target = release.targetDate ? shortDate(release.targetDate) : '';
const likely = release.likelyDate ? shortDate(release.likelyDate) : '';
signals.push({
key: 'releases',
level: release.verdict === 'LATE' ? 'RISK' : 'ATTENTION',
title:
release.verdict === 'LATE'
? `${release.name} no llega a su fecha (${target}): al ritmo real, hacia el ${likely}`
: `${release.name} va justa para el ${target}: la mitad de las veces no llega`,
keys: [],
section: 'releases',
});
}
const over = bottlenecks.filter((b) => b.overLimit);
if (over.length)
signals.push({
key: 'wip',
level: 'ATTENTION',
title: `${plural(over.length, 'columna pasa', 'columnas pasan')} de su límite WIP`,
keys: [],
section: 'bottlenecks',
});
signals.sort((a, b) => RANK[b.level] - RANK[a.level]);
return {
level: signals.reduce<HealthLevel>(
(worst, s) => (RANK[s.level] > RANK[worst] ? s.level : worst),
'GOOD',
),
signals,
};
}
/**
* The same light from what the portfolio knows of a project (overdue,
* stuck and blocked tickets, release forecasts), with its causes in a line.
*/
export function portfolioHealth(project: PortfolioProject): {
level: HealthLevel;
causes: string[];
} {
const causes: string[] = [];
let level: HealthLevel = 'GOOD';
const raise = (to: HealthLevel) => {
if (RANK[to] > RANK[level]) level = to;
};
if (project.stuck) {
raise(project.stuck >= 3 ? 'RISK' : 'ATTENTION');
causes.push(plural(project.stuck, 'atascado', 'atascados'));
}
if (project.overdue) {
raise(project.overdue >= 3 ? 'RISK' : 'ATTENTION');
causes.push(plural(project.overdue, 'vencido', 'vencidos'));
}
if (project.blocked >= 5) raise('RISK');
else if (project.blocked) raise('ATTENTION');
if (project.blocked)
causes.push(plural(project.blocked, 'bloqueado', 'bloqueados'));
for (const release of project.releases) {
if (release.verdict === 'LATE') {
raise('RISK');
causes.push(`${release.name} llega tarde`);
} else if (release.verdict === 'AT_RISK') {
raise('ATTENTION');
causes.push(`${release.name} va justa`);
}
}
return { level, causes };
}