This commit is contained in:
Bastien COIGNOUX
2026-04-24 11:50:39 +02:00
parent 745c8ae133
commit ca4c64bbb0
28 changed files with 2269 additions and 116 deletions

100
src/lib/executiveHealth.ts Normal file
View File

@ -0,0 +1,100 @@
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 0100 : 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 0100 : 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 dimpact 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
}