Files
jira/src/lib/executiveHealth.ts
Bastien COIGNOUX ca4c64bbb0 init
2026-04-24 11:50:39 +02:00

101 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}