78 lines
2.7 KiB
TypeScript
78 lines
2.7 KiB
TypeScript
import type { StoryGroup } from '../types/jira'
|
||
import type { Milestone } from './dashboardConfig'
|
||
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, cfg: StatusBucketConfig): number {
|
||
if (g.subtasks.length === 0) return 100
|
||
return subtaskDoneRatioPercent(g.subtasks, cfg)
|
||
}
|
||
|
||
function endOfDay(isoDate: string): Date {
|
||
const d = new Date(isoDate + 'T12:00:00')
|
||
d.setHours(23, 59, 59, 999)
|
||
return d
|
||
}
|
||
|
||
/** 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)
|
||
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 (0–100). */
|
||
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 n’est 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
|
||
}
|