init
This commit is contained in:
@ -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 d’impact 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
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
|
||||
}
|
||||
@ -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
110
src/lib/executiveLanding.ts
Normal 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 l’historique 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** (lun–ven), 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 l’historique 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 d’atterrissage 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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 n’est 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 (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
|
||||
}
|
||||
|
||||
13
src/lib/phaseAggregate.ts
Normal file
13
src/lib/phaseAggregate.ts
Normal 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
125
src/lib/statusBuckets.ts
Normal 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 d’un 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'
|
||||
}
|
||||
@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user