import type { StoryGroup } from '../types/jira' import type { Milestone } from './dashboardConfig' import type { StatusBucketConfig } from './statusBuckets' import { isIssueDone, resolveWorkBucketFromIssue } from './statusBuckets' export type Verdict = 'on_track' | 'at_risk' | 'critical' export function isStoryDoneForReporting(g: StoryGroup, cfg: StatusBucketConfig): boolean { if (g.subtasks.length === 0) return isIssueDone(g.story, cfg) return g.subtasks.every((s) => isIssueDone(s, cfg)) } export function scopeCompletionPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number { if (groups.length === 0) return 0 const done = groups.filter((g) => isStoryDoneForReporting(g, cfg)).length return Math.round((done / groups.length) * 100) } /** Dernière échéance parmi les jalons (ISO yyyy-mm-dd). */ export function latestMilestoneDateIso(milestones: Milestone[]): string | null { if (milestones.length === 0) return null return [...milestones].sort((a, b) => b.date.localeCompare(a.date))[0]!.date } /** * Score 0–100 : marge calendaire entre atterrissage estimé et jalon final * (100 = atterrissage avant ou le jour du jalon, baisse si dépassement). */ export function deadlineHealthScore(estimatedLanding: Date | null, finalIso: string | null): number { if (!estimatedLanding || !finalIso) return 55 const end = new Date(finalIso + 'T23:59:59') const diffMs = end.getTime() - estimatedLanding.getTime() const diffDays = diffMs / 86400000 if (diffDays >= 5) return 100 if (diffDays >= 0) return Math.round(70 + (diffDays / 5) * 30) if (diffDays >= -5) return Math.round(Math.max(0, 70 + diffDays * 14)) return 0 } /** * Score 0–100 : marge de charge (100 = peu de sous-tâches ouvertes vs capacité nominale). */ export function resourceHealthScore( openSubtasks: number, teamCapacity: number, wipSlotsPerDev: number, ): number { const cap = Math.max(1, teamCapacity * Math.max(1, wipSlotsPerDev)) const util = openSubtasks / cap return Math.max(0, Math.min(100, Math.round(100 - util * 100))) } export function computeVerdict( deadlineScore: number, scopePct: number, resourceScore: number, ): Verdict { if (deadlineScore < 22 || scopePct < 28 || resourceScore < 18) return 'critical' if (deadlineScore < 52 || scopePct < 50 || resourceScore < 40) return 'at_risk' return 'on_track' } /** Messages d’impact si un jalon critique est en retard. */ export function criticalMilestoneImpactMessages( milestones: Milestone[], groups: StoryGroup[], velocityPerBusinessDay: number, cfg: StatusBucketConfig, ): string[] { const msgs: string[] = [] const v = velocityPerBusinessDay > 0.001 ? velocityPerBusinessDay : null for (const m of milestones) { if (!m.critical) continue const deadline = new Date(m.date + 'T23:59:59') if (new Date() <= deadline) continue const keys = m.linkedStoryKeys?.length ? m.linkedStoryKeys : groups.map((g) => g.story.key) const linked = groups.filter((g) => keys.includes(g.story.key)) const open = linked.reduce( (acc, g) => acc + g.subtasks.filter((s) => { const b = resolveWorkBucketFromIssue(s, cfg) return b !== 'done' && b !== 'cancel' }).length, 0, ) if (open === 0) continue if (v == null) { msgs.push( `« ${m.title} » (critique) : échéance dépassée — ${open} sous-tâche(s) encore ouvertes. Vélocité insuffisante pour quantifier le glissement.`, ) } else { const slip = Math.ceil(open / v) msgs.push( `« ${m.title} » (critique) : retard — ~${slip} jour(s) ouvrés de charge restante à la vélocité actuelle, impact probable sur la date finale.`, ) } } return msgs }