init
This commit is contained in:
100
src/lib/executiveHealth.ts
Normal file
100
src/lib/executiveHealth.ts
Normal 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 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
|
||||
}
|
||||
Reference in New Issue
Block a user