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; /** 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 = { 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( (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 }; }