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

View File

@ -5,11 +5,31 @@ export type Milestone = {
date: string
/** Clés de stories à contrôler pour le statut « retard » ; vide = toutes les stories chargées */
linkedStoryKeys?: string[]
/** Jalon critique : alerte dimpact si retard après la date. */
critical?: boolean
}
import type { StatusBucketConfig } from './statusBuckets'
import { defaultStatusBucketConfig, mergeStatusBucketConfig } from './statusBuckets'
import type { LaneLabelsConfig } from './laneDetection'
import { defaultLaneLabelsConfig, mergeLaneLabelsConfig } from './laneDetection'
export type { StatusBucketConfig } from './statusBuckets'
export type { LaneLabelsConfig } from './laneDetection'
export type DashboardConfig = {
version: 1
milestones: Milestone[]
/** Cartographie statuts Jira → To do / In progress / Blocked / Done / Cancel. */
statusBuckets: StatusBucketConfig
/** Étiquettes Jira par piste (Analyse / Design / Intégration) et pour le marquage bloqué. */
laneLabels: LaneLabelsConfig
/** Effectif pris en compte pour la projection (développeurs actifs). */
teamCapacity: number
/** Effectif de référence pour léchelle de vélocité (ex. 3). */
baselineCapacity: number
/** Sous-tâches ouvertes « nominale » par personne pour la jauge charge vs capacité. */
wipSlotsPerDev: number
myJiraAccountId?: string
myJiraEmail?: string
/** Filtre « Ma vue » (sous-tâches me concernant). */
@ -21,6 +41,11 @@ const STORAGE_KEY = 'dcc-dashboard-config-v1'
export const defaultDashboardConfig = (): DashboardConfig => ({
version: 1,
milestones: [],
statusBuckets: defaultStatusBucketConfig(),
laneLabels: defaultLaneLabelsConfig(),
teamCapacity: 3,
baselineCapacity: 3,
wipSlotsPerDev: 5,
})
export function loadDashboardConfig(): DashboardConfig {
@ -32,6 +57,28 @@ export function loadDashboardConfig(): DashboardConfig {
return {
version: 1,
milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
statusBuckets: mergeStatusBucketConfig(
parsed.statusBuckets && typeof parsed.statusBuckets === 'object'
? (parsed.statusBuckets as Partial<StatusBucketConfig>)
: undefined,
),
laneLabels: mergeLaneLabelsConfig(
parsed.laneLabels && typeof parsed.laneLabels === 'object'
? (parsed.laneLabels as Partial<LaneLabelsConfig>)
: undefined,
),
teamCapacity:
typeof parsed.teamCapacity === 'number' && Number.isFinite(parsed.teamCapacity)
? Math.max(0.25, parsed.teamCapacity)
: 3,
baselineCapacity:
typeof parsed.baselineCapacity === 'number' && Number.isFinite(parsed.baselineCapacity)
? Math.max(0.25, parsed.baselineCapacity)
: 3,
wipSlotsPerDev:
typeof parsed.wipSlotsPerDev === 'number' && Number.isFinite(parsed.wipSlotsPerDev)
? Math.max(1, Math.floor(parsed.wipSlotsPerDev))
: 5,
myJiraAccountId: parsed.myJiraAccountId,
myJiraEmail: parsed.myJiraEmail,
myViewActive: parsed.myViewActive,
@ -65,6 +112,26 @@ export function mergeImportedConfig(
return {
version: 1,
milestones: Array.isArray(o.milestones) ? o.milestones : current.milestones,
statusBuckets:
o.statusBuckets && typeof o.statusBuckets === 'object'
? mergeStatusBucketConfig(o.statusBuckets as Partial<StatusBucketConfig>)
: current.statusBuckets,
laneLabels:
o.laneLabels && typeof o.laneLabels === 'object'
? mergeLaneLabelsConfig(o.laneLabels as Partial<LaneLabelsConfig>)
: current.laneLabels,
teamCapacity:
typeof o.teamCapacity === 'number' && Number.isFinite(o.teamCapacity)
? Math.max(0.25, o.teamCapacity)
: current.teamCapacity,
baselineCapacity:
typeof o.baselineCapacity === 'number' && Number.isFinite(o.baselineCapacity)
? Math.max(0.25, o.baselineCapacity)
: current.baselineCapacity,
wipSlotsPerDev:
typeof o.wipSlotsPerDev === 'number' && Number.isFinite(o.wipSlotsPerDev)
? Math.max(1, Math.floor(o.wipSlotsPerDev))
: current.wipSlotsPerDev,
myJiraAccountId: o.myJiraAccountId ?? current.myJiraAccountId,
myJiraEmail: o.myJiraEmail ?? current.myJiraEmail,
myViewActive: o.myViewActive ?? current.myViewActive,

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
}

View File

@ -1,5 +1,8 @@
import type { JiraIssue, StoryGroup } from '../types/jira'
import { statusToPhase } from './statusPhase'
import type { LaneLabelsConfig } from './laneDetection'
import { issueHasBlockedLaneLabel } from './laneDetection'
import type { StatusBucketConfig } from './statusBuckets'
import { isIssueBlocked, isIssueCanceled, isIssueDone } from './statusBuckets'
function norm(s: string): string {
return s
@ -34,12 +37,14 @@ export function goldenCarbonSubtasks(groups: StoryGroup[]): JiraIssue[] {
)
}
/** Progression globale : sous-tâches terminées / sous-tâches totales. */
export function globalProgressPercent(groups: StoryGroup[]): number {
/** Progression globale : sous-tâches Done / (toutes sauf Cancel). */
export function globalProgressPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
const subs = allSubtasks(groups)
if (subs.length === 0) return 0
const done = subs.filter((s) => statusToPhase(s.fields.status.name) === 'done').length
return Math.round((done / subs.length) * 100)
const active = subs.filter((s) => !isIssueCanceled(s, cfg))
if (active.length === 0) return 0
const done = active.filter((s) => isIssueDone(s, cfg)).length
return Math.round((done / active.length) * 100)
}
/** Sous-tâches considérées comme « maquette » (libellé à ajuster selon votre vocabulaire Jira). */
@ -49,11 +54,13 @@ export function isMaquetteRelated(st: JiraIssue): boolean {
}
/** % de maquettes validées parmi les sous-tâches identifiées comme maquettes. */
export function designHealthPercent(groups: StoryGroup[]): number {
export function designHealthPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
const candidates = maquetteRelatedSubtasks(groups)
if (candidates.length === 0) return 0
const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length
return Math.round((ok / candidates.length) * 100)
const active = candidates.filter((s) => !isIssueCanceled(s, cfg))
if (active.length === 0) return 0
const ok = active.filter((s) => isIssueDone(s, cfg)).length
return Math.round((ok / active.length) * 100)
}
function textWithComponents(st: JiraIssue, story: JiraIssue): string {
@ -68,29 +75,55 @@ function isGoldenCarbonRelated(st: JiraIssue, story: JiraIssue): boolean {
return /golden\s*carbon|goldencarbon/i.test(textWithComponents(st, story))
}
export function goldenCarbonHealthPercent(groups: StoryGroup[]): number {
export function goldenCarbonHealthPercent(groups: StoryGroup[], cfg: StatusBucketConfig): number {
const candidates = goldenCarbonSubtasks(groups)
if (candidates.length === 0) return 0
const ok = candidates.filter((s) => statusToPhase(s.fields.status.name) === 'done').length
return Math.round((ok / candidates.length) * 100)
const active = candidates.filter((s) => !isIssueCanceled(s, cfg))
if (active.length === 0) return 0
const ok = active.filter((s) => isIssueDone(s, cfg)).length
return Math.round((ok / active.length) * 100)
}
export function blockingTicketsCount(groups: StoryGroup[]): number {
function isBlockingIssue(
i: JiraIssue,
statusCfg: StatusBucketConfig,
laneCfg: LaneLabelsConfig,
): boolean {
return (
isIssueBlocked(i, statusCfg) ||
isBlockingStatus(i.fields.status.name) ||
issueHasBlockedLaneLabel(i, laneCfg)
)
}
export function blockingTicketsCount(
groups: StoryGroup[],
statusCfg: StatusBucketConfig,
laneCfg: LaneLabelsConfig,
): number {
const issues: JiraIssue[] = [
...groups.map((g) => g.story),
...allSubtasks(groups),
]
return issues.filter((i) => isBlockingStatus(i.fields.status.name)).length
return issues.filter((i) => isBlockingIssue(i, statusCfg, laneCfg)).length
}
export function blockingIssuesInGroup(group: StoryGroup): JiraIssue[] {
export function blockingIssuesInGroup(
group: StoryGroup,
statusCfg: StatusBucketConfig,
laneCfg: LaneLabelsConfig,
): JiraIssue[] {
return [group.story, ...group.subtasks].filter((i) =>
isBlockingStatus(i.fields.status.name),
isBlockingIssue(i, statusCfg, laneCfg),
)
}
export function blockingSummaryForTooltip(group: StoryGroup): string {
const list = blockingIssuesInGroup(group)
export function blockingSummaryForTooltip(
group: StoryGroup,
statusCfg: StatusBucketConfig,
laneCfg: LaneLabelsConfig,
): string {
const list = blockingIssuesInGroup(group, statusCfg, laneCfg)
if (list.length === 0) return 'Aucun ticket bloquant sur cette story.'
return list.map((i) => `${i.key}${i.fields.status.name}: ${i.fields.summary}`).join('\n')
}

110
src/lib/executiveLanding.ts Normal file
View File

@ -0,0 +1,110 @@
import type { StoryGroup } from '../types/jira'
import type { BurnupPoint } from './burnupHistory'
import type { StatusBucketConfig } from './statusBuckets'
import { resolveWorkBucketFromIssue } from './statusBuckets'
function daySpanInclusive(a: string, b: string): number {
const t0 = new Date(a + 'T12:00:00').getTime()
const t1 = new Date(b + 'T12:00:00').getTime()
return Math.max(1, Math.round((t1 - t0) / 86400000))
}
/**
* Vélocité moyenne : sous-tâches **terminées par jour calendaire** sur les `windowDays` derniers jours,
* à partir des deltas de lhistorique burnup (un point par date).
*/
export function velocitySubtasksDonePerDay(
points: BurnupPoint[],
windowDays = 14,
): number {
if (points.length === 0) return 0
const sorted = [...points].sort((a, b) => a.date.localeCompare(b.date))
const last = sorted[sorted.length - 1]!
const end = new Date(last.date + 'T12:00:00')
const start = new Date(end)
start.setDate(start.getDate() - windowDays)
const startStr = start.toISOString().slice(0, 10)
const slice = sorted.filter((p) => p.date >= startStr)
if (slice.length < 2) {
const first = sorted[0]!
const span = daySpanInclusive(first.date, last.date)
return Math.max(0, last.done - first.done) / span
}
let sum = 0
let n = 0
for (let i = 1; i < slice.length; i++) {
const prev = slice[i - 1]!
const cur = slice[i]!
const delta = Math.max(0, cur.done - prev.done)
const span = daySpanInclusive(prev.date, cur.date)
sum += delta / span
n += 1
}
return n > 0 ? sum / n : 0
}
/** Sous-tâches encore à traiter (hors Done et hors Cancel). */
export function countOpenSubtasks(groups: StoryGroup[], cfg: StatusBucketConfig): number {
return groups.reduce(
(acc, g) =>
acc +
g.subtasks.filter((s) => {
const b = resolveWorkBucketFromIssue(s, cfg)
return b !== 'done' && b !== 'cancel'
}).length,
0,
)
}
/** Avance le calendrier en **jours ouvrés** (lunven), sans compter week-ends. */
export function addBusinessDays(from: Date, businessDays: number): Date {
const d = new Date(from.getFullYear(), from.getMonth(), from.getDate())
let left = Math.max(0, Math.ceil(businessDays))
while (left > 0) {
d.setDate(d.getDate() + 1)
const w = d.getDay()
if (w !== 0 && w !== 6) left -= 1
}
return d
}
export type LandingEstimate = {
/** Sous-tâches restantes (non terminées). */
remainingSubtasks: number
/** Moyenne issue de lhistorique burnup (terminées / jour calendaire). */
rawVelocityPerDay: number
/** Après prise en compte de la capacité vs baseline. */
effectiveVelocityPerDay: number
/** Jours ouvrés estimés pour finir (null si vélocité nulle). */
businessDaysToFinish: number | null
/** Date datterrissage projetée (jours ouvrés). */
estimatedLanding: Date | null
}
export function computeLandingEstimate(
groups: StoryGroup[],
burnup: BurnupPoint[],
teamCapacity: number,
baselineCapacity: number,
cfg: StatusBucketConfig,
windowDays = 14,
): LandingEstimate {
const remainingSubtasks = countOpenSubtasks(groups, cfg)
const rawVelocityPerDay = velocitySubtasksDonePerDay(burnup, windowDays)
const base = Math.max(1, baselineCapacity)
const cap = Math.max(0.25, teamCapacity)
const effectiveVelocityPerDay = rawVelocityPerDay * (cap / base)
const businessDaysToFinish =
effectiveVelocityPerDay > 0.001
? Math.ceil(remainingSubtasks / effectiveVelocityPerDay)
: null
const estimatedLanding =
businessDaysToFinish != null ? addBusinessDays(new Date(), businessDaysToFinish) : null
return {
remainingSubtasks,
rawVelocityPerDay,
effectiveVelocityPerDay,
businessDaysToFinish,
estimatedLanding,
}
}

View File

@ -2,12 +2,76 @@ import type { JiraIssue, JiraStatus } from '../types/jira'
export type WorkLane = 'analyse' | 'design' | 'integration'
/** Regroupe les sous-tâches par « piste » Analyse / Design / Intégration (mots-clés + repli sur le statut). */
export function detectWorkLane(subtask: JiraIssue): WorkLane {
/** Étiquettes Jira (comparaison insensible à la casse et aux accents) par piste et pour le marquage bloqué. */
export type LaneLabelsConfig = {
analyse: string[]
design: string[]
integration: string[]
blocked: string[]
}
function normLabel(s: string): string {
return s
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/\p{M}/gu, '')
}
function issueNormalizedLabels(issue: JiraIssue): Set<string> {
const raw = issue.fields.labels ?? []
return new Set(raw.map((l) => normLabel(String(l))))
}
function matchesConfiguredLabels(issue: JiraIssue, configured: string[]): boolean {
if (configured.length === 0) return false
const set = issueNormalizedLabels(issue)
return configured.some((c) => set.has(normLabel(c)))
}
export function defaultLaneLabelsConfig(): LaneLabelsConfig {
return {
analyse: ['analyse'],
design: ['design'],
integration: ['integration'],
blocked: ['blocked'],
}
}
export function mergeLaneLabelsConfig(partial?: Partial<LaneLabelsConfig>): LaneLabelsConfig {
const d = defaultLaneLabelsConfig()
if (!partial) return d
const pick = (key: keyof LaneLabelsConfig): string[] => {
const v = partial[key]
return Array.isArray(v) && v.length > 0 ? [...v] : d[key]
}
return {
analyse: pick('analyse'),
design: pick('design'),
integration: pick('integration'),
blocked: pick('blocked'),
}
}
/**
* Piste déduite des étiquettes uniquement (ordre : analyse → design → intégration).
* `null` si aucune étiquette configurée ne correspond.
*/
export function workLaneFromLabels(issue: JiraIssue, cfg: LaneLabelsConfig): WorkLane | null {
if (matchesConfiguredLabels(issue, cfg.analyse)) return 'analyse'
if (matchesConfiguredLabels(issue, cfg.design)) return 'design'
if (matchesConfiguredLabels(issue, cfg.integration)) return 'integration'
return null
}
export function issueHasBlockedLaneLabel(issue: JiraIssue, cfg: LaneLabelsConfig): boolean {
return matchesConfiguredLabels(issue, cfg.blocked)
}
/** Repli historique : résumé + nom de statut (si pas détiquette de piste reconnue). */
export function detectWorkLaneHeuristic(subtask: JiraIssue): WorkLane {
const s = subtask.fields.summary.toLowerCase()
if (
/\banalyse\b|spécification|spec\b|cadrage|refinement|backlog\s*analyse/i.test(s)
) {
if (/\banalyse\b|spécification|spec\b|cadrage|refinement|backlog\s*analyse/i.test(s)) {
return 'analyse'
}
if (/\bdesign\b|maquette|figma|wireframe|zoning|ui\b|ux\b/i.test(s)) {
@ -24,6 +88,13 @@ export function detectWorkLane(subtask: JiraIssue): WorkLane {
return 'integration'
}
/** Piste de travail : étiquettes Jira (réglages) puis heuristique résumé / statut. */
export function detectWorkLane(subtask: JiraIssue, laneLabels: LaneLabelsConfig): WorkLane {
const fromLabels = workLaneFromLabels(subtask, laneLabels)
if (fromLabels) return fromLabels
return detectWorkLaneHeuristic(subtask)
}
export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' | 'done' | 'unknown' {
const k = status.statusCategory?.key
if (k === 'new') return 'new'
@ -32,17 +103,20 @@ export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' |
return 'unknown'
}
/** Couleur logique pour une piste : vert = tout terminé, bleu = en cours, gris = à faire / inconnu. */
/**
* Couleur logique pour une piste : vert = tout terminé (via cartographie statuts),
* bleu = mix, gris = à faire.
*/
export function laneAggregateState(
subtasks: JiraIssue[],
lane: WorkLane,
isDone: (st: JiraIssue) => boolean,
laneLabels: LaneLabelsConfig,
): 'empty' | 'grey' | 'blue' | 'green' {
const inLane = subtasks.filter((st) => detectWorkLane(st) === lane)
const inLane = subtasks.filter((st) => detectWorkLane(st, laneLabels) === lane)
if (inLane.length === 0) return 'empty'
const cats = inLane.map((st) => statusCategoryKey(st.fields.status))
if (cats.every((c) => c === 'done')) return 'green'
if (cats.some((c) => c === 'indeterminate')) return 'blue'
if (cats.some((c) => c === 'done') && cats.some((c) => c !== 'done')) return 'blue'
if (inLane.every(isDone)) return 'green'
if (inLane.some(isDone) && inLane.some((st) => !isDone(st))) return 'blue'
const names = inLane.map((st) => st.fields.status.name.toLowerCase()).join(' ')
if (
/en cours|in progress|review|recette|test|qa|intégration|integration|development/i.test(names)

View File

@ -1,11 +1,14 @@
import type { StoryGroup } from '../types/jira'
import type { Milestone } from './dashboardConfig'
import { storyProgressPercent } from './storyMetrics'
import { getRemainingEstimateUnits } from './jiraFieldExtractors'
import type { StatusBucketConfig } from './statusBuckets'
import { resolveWorkBucketFromIssue } from './statusBuckets'
import { subtaskDoneRatioPercent } from './storyMetrics'
/** Pour les jalons : story sans sous-tâche = considérée comme « livrée » côté sous-tâches. */
function storyCompletionForMilestone(g: StoryGroup): number {
function storyCompletionForMilestone(g: StoryGroup, cfg: StatusBucketConfig): number {
if (g.subtasks.length === 0) return 100
return storyProgressPercent(g.subtasks)
return subtaskDoneRatioPercent(g.subtasks, cfg)
}
function endOfDay(isoDate: string): Date {
@ -14,19 +17,61 @@ function endOfDay(isoDate: string): Date {
return d
}
/** Jalon en retard : date dépassée et au moins une story concernée nest pas à 100 %. */
export function isMilestoneLate(m: Milestone, groups: StoryGroup[]): boolean {
if (groups.length === 0) return false
const deadline = endOfDay(m.date)
if (new Date() <= deadline) return false
/** Stories du périmètre du jalon (clés liées si renseignées, sinon tout le chargement). */
export function milestoneLinkedGroups(m: Milestone, groups: StoryGroup[]): StoryGroup[] {
const keys =
m.linkedStoryKeys && m.linkedStoryKeys.length > 0
? m.linkedStoryKeys
: groups.map((g) => g.story.key)
for (const key of keys) {
const g = groups.find((x) => x.story.key === key)
if (!g) continue
if (storyCompletionForMilestone(g) < 100) return true
const set = new Set(keys)
return groups.filter((g) => set.has(g.story.key))
}
/** Moyenne des % sous-tâches terminées sur les stories du périmètre (0100). */
export function milestoneAverageCompletionPercent(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
): number {
const linked = milestoneLinkedGroups(m, groups)
if (linked.length === 0) return 100
const sum = linked.reduce((acc, g) => acc + storyCompletionForMilestone(g, cfg), 0)
return Math.round(sum / linked.length)
}
/** Somme du reste à faire (unités Jira) sur sous-tâches non terminées / non annulées du périmètre. */
export function milestoneOpenRemainingUnits(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
): number {
let u = 0
for (const g of milestoneLinkedGroups(m, groups)) {
for (const s of g.subtasks) {
const b = resolveWorkBucketFromIssue(s, cfg)
if (b !== 'done' && b !== 'cancel') u += getRemainingEstimateUnits(s)
}
}
return u
}
/** Jours calendaires jusquà la fin du jour du jalon (négatif = retard). */
export function milestoneCalendarDaysUntil(m: Milestone): number {
const end = endOfDay(m.date)
return Math.ceil((end.getTime() - Date.now()) / 86400000)
}
/** Jalon en retard : date dépassée et au moins une story concernée nest pas à 100 %. */
export function isMilestoneLate(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
): boolean {
if (groups.length === 0) return false
const deadline = endOfDay(m.date)
if (new Date() <= deadline) return false
for (const g of milestoneLinkedGroups(m, groups)) {
if (storyCompletionForMilestone(g, cfg) < 100) return true
}
return false
}

13
src/lib/phaseAggregate.ts Normal file
View File

@ -0,0 +1,13 @@
import type { PhaseId, StoryGroup } from '../types/jira'
import { statusToPhase } from './statusPhase'
export function countSubtasksByPhase(groups: StoryGroup[]): Record<PhaseId, number> {
const acc: Record<PhaseId, number> = { analyse: 0, design: 0, integration: 0, done: 0 }
for (const g of groups) {
for (const s of g.subtasks) {
const p = statusToPhase(s.fields.status.name)
acc[p] += 1
}
}
return acc
}

125
src/lib/statusBuckets.ts Normal file
View File

@ -0,0 +1,125 @@
import type { JiraIssue } from '../types/jira'
/** Catégories de suivi (paramétrables selon vos libellés Jira). */
export type WorkStatusBucket = 'todo' | 'in_progress' | 'blocked' | 'done' | 'cancel'
export type StatusBucketConfig = {
todo: string[]
in_progress: string[]
blocked: string[]
done: string[]
cancel: string[]
}
export function normalizeStatusLabel(s: string): string {
return s
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/\p{M}/gu, '')
}
function matchesList(statusName: string, list: string[]): boolean {
const n = normalizeStatusLabel(statusName)
return list.some((entry) => normalizeStatusLabel(entry) === n)
}
/** Libellés Jira par défaut (insensible à la casse / accents au match). */
export function defaultStatusBucketConfig(): StatusBucketConfig {
return {
todo: [
'To Do',
'Todo',
'À faire',
'A faire',
'Open',
'Ouvert',
'0-OUVERT',
'0-Ouvert',
'New',
'Nouveau',
'Backlog',
'Sélectionné',
'Selectionne',
],
in_progress: [
'In Progress',
'En cours',
'In development',
'Code Review',
'Review',
'Recette',
'Test',
'QA',
'Intégration',
'Integration',
'Prêt pour développement',
'Pret pour developpement',
],
blocked: ['Blocked', 'Bloqué', 'Bloque'],
done: [
'Done',
'Terminé',
'Termine',
'Closed',
'Resolved',
'Résolu',
'Resolu',
'Livré',
'Livre',
'Fermé',
'Ferme',
],
cancel: ['Cancelled', 'Canceled', 'Annulé', 'Annule', "Won't fix", 'Wontfix'],
}
}
/** Fusionne la config sauvegardée avec les défauts (liste vide sur un seau → défaut). */
export function mergeStatusBucketConfig(partial?: Partial<StatusBucketConfig>): StatusBucketConfig {
const d = defaultStatusBucketConfig()
if (!partial) return d
const pick = (key: keyof StatusBucketConfig): string[] => {
const v = partial[key]
return Array.isArray(v) && v.length > 0 ? [...v] : d[key]
}
return {
todo: pick('todo'),
in_progress: pick('in_progress'),
blocked: pick('blocked'),
done: pick('done'),
cancel: pick('cancel'),
}
}
/**
* Résout le seau dun ticket : listes explicites puis repli sur `statusCategory` Jira.
*/
export function resolveWorkBucketFromIssue(
issue: JiraIssue,
cfg: StatusBucketConfig,
): WorkStatusBucket {
const name = issue.fields.status?.name ?? ''
if (matchesList(name, cfg.blocked)) return 'blocked'
if (matchesList(name, cfg.cancel)) return 'cancel'
if (matchesList(name, cfg.done)) return 'done'
if (matchesList(name, cfg.in_progress)) return 'in_progress'
if (matchesList(name, cfg.todo)) return 'todo'
const cat = issue.fields.status?.statusCategory?.key
if (cat === 'done') return 'done'
if (cat === 'new') return 'todo'
if (cat === 'indeterminate') return 'in_progress'
return 'todo'
}
export function isIssueDone(issue: JiraIssue, cfg: StatusBucketConfig): boolean {
return resolveWorkBucketFromIssue(issue, cfg) === 'done'
}
export function isIssueCanceled(issue: JiraIssue, cfg: StatusBucketConfig): boolean {
return resolveWorkBucketFromIssue(issue, cfg) === 'cancel'
}
export function isIssueBlocked(issue: JiraIssue, cfg: StatusBucketConfig): boolean {
return resolveWorkBucketFromIssue(issue, cfg) === 'blocked'
}

View File

@ -1,5 +1,7 @@
import type { JiraIssue, PhaseId } from '../types/jira'
import { PHASE_ORDER, statusToPhase } from './statusPhase'
import type { StatusBucketConfig } from './statusBuckets'
import { isIssueCanceled, isIssueDone } from './statusBuckets'
function phaseRank(p: PhaseId): number {
const i = PHASE_ORDER.indexOf(p)
@ -31,11 +33,28 @@ export function isStepComplete(subtasks: JiraIssue[], stepPhase: PhaseId): boole
return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx)
}
export function stepperStates(subtasks: JiraIssue[]): Record<PhaseId, boolean> {
/**
* % de sous-tâches **Done** parmi les tickets encore pertinents (hors **Cancel**).
* Aligné sur la cartographie des statuts (Réglages).
*/
export function subtaskDoneRatioPercent(
subtasks: JiraIssue[],
cfg: StatusBucketConfig,
): number {
const active = subtasks.filter((s) => !isIssueCanceled(s, cfg))
if (active.length === 0) return 0
const done = active.filter((s) => isIssueDone(s, cfg)).length
return Math.round((done / active.length) * 100)
}
export function stepperStates(
subtasks: JiraIssue[],
cfg: StatusBucketConfig,
): Record<PhaseId, boolean> {
return {
analyse: isStepComplete(subtasks, 'analyse'),
design: isStepComplete(subtasks, 'design'),
integration: isStepComplete(subtasks, 'integration'),
done: subtasks.length > 0 && subtasks.every((st) => statusToPhase(st.fields.status.name) === 'done'),
done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)),
}
}