101 lines
3.7 KiB
TypeScript
101 lines
3.7 KiB
TypeScript
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
|
||
}
|