This commit is contained in:
Bastien COIGNOUX
2026-04-24 07:41:55 +02:00
commit 7cd2d6dc40
42 changed files with 4453 additions and 0 deletions

18
src/lib/assigneeMatch.ts Normal file
View File

@ -0,0 +1,18 @@
import type { JiraIssue } from '../types/jira'
import type { DashboardConfig } from './dashboardConfig'
/** Correspondance utilisateur « Ma vue » : compte Atlassian ou e-mail. */
export function assigneeMatchesMyView(issue: JiraIssue, cfg: DashboardConfig): boolean {
const a = issue.fields.assignee
if (!a) return false
const cfgId = cfg.myJiraAccountId?.trim()
const cfgEmail = cfg.myJiraEmail?.trim().toLowerCase()
const envId = import.meta.env.VITE_MY_JIRA_ACCOUNT_ID?.trim()
const envEmail = import.meta.env.VITE_MY_JIRA_EMAIL?.trim().toLowerCase()
if (cfgId && a.accountId && a.accountId === cfgId) return true
if (envId && a.accountId && a.accountId === envId) return true
if (cfgEmail && a.emailAddress && a.emailAddress.toLowerCase() === cfgEmail) return true
if (envEmail && a.emailAddress && a.emailAddress.toLowerCase() === envEmail) return true
return false
}

17
src/lib/boardGrouping.ts Normal file
View File

@ -0,0 +1,17 @@
import type { StoryGroup } from '../types/jira'
/** Regroupe les stories par premier composant Jira, sinon « Autres ». */
export function groupStoriesByComponent(groups: StoryGroup[]): Map<string, StoryGroup[]> {
const map = new Map<string, StoryGroup[]>()
for (const g of groups) {
const comps = g.story.fields.components
const label =
comps && comps.length > 0 ? comps[0]!.name : 'Autres'
if (!map.has(label)) map.set(label, [])
map.get(label)!.push(g)
}
const keys = [...map.keys()].sort((a, b) => a.localeCompare(b, 'fr'))
const sorted = new Map<string, StoryGroup[]>()
for (const k of keys) sorted.set(k, map.get(k)!)
return sorted
}

36
src/lib/burnupHistory.ts Normal file
View File

@ -0,0 +1,36 @@
const STORAGE_KEY = 'jira-descours-burnup-v1'
export type BurnupPoint = {
date: string
done: number
total: number
}
function todayISO(): string {
const d = new Date()
return d.toISOString().slice(0, 10)
}
export function loadBurnupHistory(): BurnupPoint[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as BurnupPoint[]
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
/** Enregistre le snapshot du jour (remplace le point sil existe déjà pour cette date). */
export function appendBurnupSnapshot(done: number, total: number): BurnupPoint[] {
const day = todayISO()
const prev = loadBurnupHistory()
const filtered = prev.filter((p) => p.date !== day)
const next = [...filtered, { date: day, done, total }].sort((a, b) =>
a.date.localeCompare(b.date),
)
const trimmed = next.slice(-45)
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
return trimmed
}

View File

@ -0,0 +1,72 @@
export type Milestone = {
id: string
title: string
/** ISO date (yyyy-mm-dd) */
date: string
/** Clés de stories à contrôler pour le statut « retard » ; vide = toutes les stories chargées */
linkedStoryKeys?: string[]
}
export type DashboardConfig = {
version: 1
milestones: Milestone[]
myJiraAccountId?: string
myJiraEmail?: string
/** Filtre « Ma vue » (sous-tâches me concernant). */
myViewActive?: boolean
}
const STORAGE_KEY = 'dcc-dashboard-config-v1'
export const defaultDashboardConfig = (): DashboardConfig => ({
version: 1,
milestones: [],
})
export function loadDashboardConfig(): DashboardConfig {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultDashboardConfig()
const parsed = JSON.parse(raw) as Partial<DashboardConfig>
if (!parsed || parsed.version !== 1) return defaultDashboardConfig()
return {
version: 1,
milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
myJiraAccountId: parsed.myJiraAccountId,
myJiraEmail: parsed.myJiraEmail,
myViewActive: parsed.myViewActive,
}
} catch {
return defaultDashboardConfig()
}
}
export function saveDashboardConfig(cfg: DashboardConfig): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg))
}
export function exportConfigJson(cfg: DashboardConfig): void {
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dcc-dashboard-config-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
}
export function mergeImportedConfig(
current: DashboardConfig,
imported: unknown,
): DashboardConfig | null {
if (!imported || typeof imported !== 'object') return null
const o = imported as Partial<DashboardConfig>
if (o.version !== 1) return null
return {
version: 1,
milestones: Array.isArray(o.milestones) ? o.milestones : current.milestones,
myJiraAccountId: o.myJiraAccountId ?? current.myJiraAccountId,
myJiraEmail: o.myJiraEmail ?? current.myJiraEmail,
myViewActive: o.myViewActive ?? current.myViewActive,
}
}

96
src/lib/executiveKpis.ts Normal file
View File

@ -0,0 +1,96 @@
import type { JiraIssue, StoryGroup } from '../types/jira'
import { statusToPhase } from './statusPhase'
function norm(s: string): string {
return s
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/\p{M}/gu, '')
}
export function isBlockingStatus(statusName: string): boolean {
const n = norm(statusName)
return (
n.includes('bloque') ||
n.includes('blocked') ||
n.includes('recette ko') ||
n.includes('recetteko') ||
n.includes('recette nok')
)
}
function allSubtasks(groups: StoryGroup[]): JiraIssue[] {
return groups.flatMap((g) => g.subtasks)
}
export function maquetteRelatedSubtasks(groups: StoryGroup[]): JiraIssue[] {
return allSubtasks(groups).filter(isMaquetteRelated)
}
export function goldenCarbonSubtasks(groups: StoryGroup[]): JiraIssue[] {
return groups.flatMap((g) =>
g.subtasks.filter((st) => isGoldenCarbonRelated(st, g.story)),
)
}
/** Progression globale : sous-tâches terminées / sous-tâches totales. */
export function globalProgressPercent(groups: StoryGroup[]): 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)
}
/** Sous-tâches considérées comme « maquette » (libellé à ajuster selon votre vocabulaire Jira). */
export function isMaquetteRelated(st: JiraIssue): boolean {
const t = `${st.fields.summary} ${st.key}`.toLowerCase()
return /maquette|mockup|figma|wireframe|zoning|ui\s*design/i.test(t)
}
/** % de maquettes validées parmi les sous-tâches identifiées comme maquettes. */
export function designHealthPercent(groups: StoryGroup[]): 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)
}
function textWithComponents(st: JiraIssue, story: JiraIssue): string {
const comps = [...(st.fields.components ?? []), ...(story.fields.components ?? [])]
.map((c) => c.name)
.join(' ')
return `${st.fields.summary} ${comps}`.toLowerCase()
}
/** Intégration « Golden Carbon » : filtre par mot-clé ou composant. */
function isGoldenCarbonRelated(st: JiraIssue, story: JiraIssue): boolean {
return /golden\s*carbon|goldencarbon/i.test(textWithComponents(st, story))
}
export function goldenCarbonHealthPercent(groups: StoryGroup[]): 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)
}
export function blockingTicketsCount(groups: StoryGroup[]): number {
const issues: JiraIssue[] = [
...groups.map((g) => g.story),
...allSubtasks(groups),
]
return issues.filter((i) => isBlockingStatus(i.fields.status.name)).length
}
export function blockingIssuesInGroup(group: StoryGroup): JiraIssue[] {
return [group.story, ...group.subtasks].filter((i) =>
isBlockingStatus(i.fields.status.name),
)
}
export function blockingSummaryForTooltip(group: StoryGroup): string {
const list = blockingIssuesInGroup(group)
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')
}

144
src/lib/groupIssues.ts Normal file
View File

@ -0,0 +1,144 @@
import type { JiraEmbeddedChildIssue, JiraIssue, StoryGroup } from '../types/jira'
import { MIGRATION_EPIC_KEY } from '../api/jiraClient'
import { getStoryPoints } from './jiraFieldExtractors'
import { isJiraSubtask } from './subtaskUtils'
import { buildIssueIdToKeyMap, resolveParentIssueKey } from './parentResolve'
/** Certaines réponses ne listent les sous-tâches que sous `fields.subtasks` du parent. */
function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): JiraIssue {
const f = emb.fields ?? {}
const status = f.status as JiraIssue['fields']['status'] | undefined
const issuetype = f.issuetype as JiraIssue['fields']['issuetype'] | undefined
const skip = new Set([
'summary',
'status',
'issuetype',
'parent',
'components',
'priority',
'assignee',
'timetracking',
'subtasks',
])
const extras: Record<string, unknown> = {}
for (const [k, v] of Object.entries(f)) {
if (!skip.has(k)) extras[k] = v
}
return {
id: emb.id,
key: emb.key,
fields: {
summary: typeof f.summary === 'string' ? f.summary : '—',
status: status && typeof status.name === 'string' ? status : { name: '—' },
issuetype:
issuetype && typeof issuetype.name === 'string'
? issuetype
: { name: 'Sous-tâche', subtask: true },
parent: { key: parentKey },
components: f.components as JiraIssue['fields']['components'],
priority: (f.priority as JiraIssue['fields']['priority']) ?? null,
assignee: (f.assignee as JiraIssue['fields']['assignee']) ?? null,
timetracking: f.timetracking as JiraIssue['fields']['timetracking'],
...extras,
} as JiraIssue['fields'],
}
}
function mergeEmbeddedSubtasksFromParents(issues: JiraIssue[]): JiraIssue[] {
const byKey = new Map(issues.map((i) => [i.key, i]))
const merged = [...issues]
for (const parent of issues) {
const subs = parent.fields.subtasks
if (!Array.isArray(subs) || subs.length === 0) continue
for (const emb of subs) {
if (!emb?.key || byKey.has(emb.key)) continue
const row = embeddedChildToIssue(parent.key, emb)
byKey.set(emb.key, row)
merged.push(row)
}
}
return merged
}
/** En cas de doublon de clé, garde lentrée la plus riche (SP + nombre de champs) pour éviter un double comptage des SP. */
function dedupeIssuesByKey(list: JiraIssue[]): JiraIssue[] {
const map = new Map<string, JiraIssue>()
const score = (x: JiraIssue) => {
const n = Object.keys(x.fields as object).length
return getStoryPoints(x) * 10 + n
}
for (const i of list) {
const prev = map.get(i.key)
if (!prev || score(i) >= score(prev)) map.set(i.key, i)
}
return [...map.values()]
}
function placeholderStory(key: string, parent?: JiraIssue['fields']['parent']): JiraIssue {
return {
key,
fields: {
summary: parent?.fields?.summary ?? `Story ${key}`,
status: { name: '—' },
issuetype: { name: 'Story', subtask: false },
parent: undefined,
},
}
}
/**
* Rattache les tickets « enfants » au parent présent dans le lot :
* - **Sous-tâche Jira** (`Sub-task` / `Sous-tâche`…) → parent (même hors lot : placeholder).
* - **Tâche, Bug, autre** dont le `parent` est une **autre issue du même résultat** et **≠ épopée** → rangée sous ce parent (ex. tâche liée à un Récit).
* Les tickets dont le seul parent est lépopée (`MIGRATION_EPIC_KEY`) restent des cartes racine (un groupe = une ligne métier).
*/
export function groupSubtasksUnderStories(issues: JiraIssue[]): StoryGroup[] {
issues = dedupeIssuesByKey(mergeEmbeddedSubtasksFromParents(issues))
const idToKey = buildIssueIdToKeyMap(issues)
const byKey = new Map(issues.map((i) => [i.key, i]))
const epicKey = MIGRATION_EPIC_KEY
const childrenByParent = new Map<string, JiraIssue[]>()
const nestedIssueKeys = new Set<string>()
for (const issue of issues) {
const parentKey = resolveParentIssueKey(issue, idToKey)
if (!parentKey) continue
const parentInBatch = byKey.has(parentKey)
const parentIsEpic = parentKey === epicKey
const sub = isJiraSubtask(issue)
const nestUnderParentInBatch = parentInBatch && !parentIsEpic
/** Sous-tâche Jira dont le parent nest pas dans ce lot (ex. story hors JQL) : groupe placeholder. */
const nestSubtaskPlaceholder = sub && !parentInBatch
if (!nestUnderParentInBatch && !nestSubtaskPlaceholder) continue
nestedIssueKeys.add(issue.key)
if (!childrenByParent.has(parentKey)) childrenByParent.set(parentKey, [])
childrenByParent.get(parentKey)!.push(issue)
}
const roots = issues.filter((i) => !nestedIssueKeys.has(i.key))
const groups = new Map<string, StoryGroup>()
for (const root of roots) {
groups.set(root.key, {
story: root,
subtasks: dedupeIssuesByKey([...(childrenByParent.get(root.key) ?? [])]),
})
}
for (const [parentKey, list] of childrenByParent) {
if (!groups.has(parentKey)) {
groups.set(parentKey, {
story: placeholderStory(parentKey, list[0]?.fields.parent),
subtasks: dedupeIssuesByKey([...list]),
})
}
}
for (const g of groups.values()) {
g.subtasks = dedupeIssu

View File

@ -0,0 +1,64 @@
import type { JiraIssue } from '../types/jira'
import { buildIssueIdToKeyMap, resolveParentIssueKey } from './parentResolve'
/** ID du champ Story Points (souvent `customfield_10028` — à vérifier dans Jira). */
export function getStoryPointsFieldId(): string {
return import.meta.env.VITE_JIRA_STORY_POINTS_FIELD?.trim() || 'customfield_10028'
}
function coerceNumber(v: unknown): number | null {
if (v == null || v === '') return null
if (typeof v === 'number' && Number.isFinite(v)) return v
if (typeof v === 'string') {
const n = Number(v)
return Number.isFinite(n) ? n : null
}
if (typeof v === 'object' && v !== null && 'value' in v) {
return coerceNumber((v as { value: unknown }).value)
}
return null
}
/** Story Points bruts depuis le champ custom Jira (nombre, chaîne, ou `{ value }`). */
export function getStoryPoints(issue: JiraIssue): number {
const id = getStoryPointsFieldId()
const raw = (issue.fields as Record<string, unknown>)[id]
const n = coerceNumber(raw)
return n ?? 0
}
/**
* Reste « en unités » comme dans ton export : secondes / 27 000
* (27 000 s ≈ 7,5 h — une journée-type Atlassian).
*/
export function getRemainingEstimateUnits(issue: JiraIssue): number {
const sec = issue.fields.timetracking?.remainingEstimateSeconds
if (sec == null || !Number.isFinite(sec)) return 0
const v = sec / 27000
return Number.isFinite(v) ? v : 0
}
/** Objet plat proche de ton ancien `issues.map` + clé parent résolue pour le debug. */
export function toTicketRow(issue: JiraIssue, allIssues: JiraIssue[]): {
key: string
summary: string
sp: number
remaining: number
status: string
issuetype: string
assignee: string
parentKey?: string
} {
const idToKey = buildIssueIdToKeyMap(allIssues)
const parentKey = resolveParentIssueKey(issue, idToKey)
return {
key: issue.key,
summary: issue.fields.summary,
sp: getStoryPoints(issue),
remaining: getRemainingEstimateUnits(issue),
status: issue.fields.status?.name ?? 'Inconnu',
issuetype: issue.fields.issuetype?.name ?? 'Inconnu',
assignee: issue.fields.assignee?.displayName ?? 'Inconnu',
...(parentKey ? { parentKey } : {}),
}
}

7
src/lib/jiraLinks.ts Normal file
View File

@ -0,0 +1,7 @@
/** URL « Ouvrir dans Jira » : `VITE_JIRA_BROWSE_BASE_URL` en priorité, sinon `JIRA_DOMAIN` via Vite (`__JIRA_ORIGIN__`). */
export function jiraBrowseIssueUrl(issueKey: string): string | null {
const explicit = import.meta.env.VITE_JIRA_BROWSE_BASE_URL?.trim().replace(/\/$/, '')
const fromEnv = explicit || __JIRA_ORIGIN__.trim().replace(/\/$/, '')
if (!fromEnv) return null
return `${fromEnv}/browse/${encodeURIComponent(issueKey)}`
}

53
src/lib/laneDetection.ts Normal file
View File

@ -0,0 +1,53 @@
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 {
const s = subtask.fields.summary.toLowerCase()
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)) {
return 'design'
}
if (
/\bintégration\b|\bintegration\b|recette|développement|developpement|dev\b|golden|carbon|déploiement|deploy/i.test(s)
) {
return 'integration'
}
const st = subtask.fields.status.name.toLowerCase()
if (/analyse|spec|backlog|nouveau|à faire|todo|open/i.test(st)) return 'analyse'
if (/design|maquette|mockup/i.test(st)) return 'design'
return 'integration'
}
export function statusCategoryKey(status: JiraStatus): 'new' | 'indeterminate' | 'done' | 'unknown' {
const k = status.statusCategory?.key
if (k === 'new') return 'new'
if (k === 'done') return 'done'
if (k === 'indeterminate') return 'indeterminate'
return 'unknown'
}
/** Couleur logique pour une piste : vert = tout terminé, bleu = en cours, gris = à faire / inconnu. */
export function laneAggregateState(
subtasks: JiraIssue[],
lane: WorkLane,
): 'empty' | 'grey' | 'blue' | 'green' {
const inLane = subtasks.filter((st) => detectWorkLane(st) === 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'
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)
) {
return 'blue'
}
return 'grey'
}

View File

@ -0,0 +1,32 @@
import type { StoryGroup } from '../types/jira'
import type { Milestone } from './dashboardConfig'
import { storyProgressPercent } 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 {
if (g.subtasks.length === 0) return 100
return storyProgressPercent(g.subtasks)
}
function endOfDay(isoDate: string): Date {
const d = new Date(isoDate + 'T12:00:00')
d.setHours(23, 59, 59, 999)
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
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
}
return false
}

39
src/lib/parentResolve.ts Normal file
View File

@ -0,0 +1,39 @@
import type { JiraIssue } from '../types/jira'
/** Construit la table id numérique Jira → clé (DCC-xxx), indispensable quand `parent` na pas `key`. */
export function buildIssueIdToKeyMap(issues: JiraIssue[]): Map<string, string> {
const map = new Map<string, string>()
for (const issue of issues) {
if (issue.id) map.set(String(issue.id), issue.key)
}
return map
}
type ParentField = NonNullable<JiraIssue['fields']['parent']>
function parentKeyFromSelf(self: string): string | undefined {
const m = /\/rest\/api\/(?:\d+|latest)\/issue\/([^/?]+)/.exec(self)
if (m?.[1]) return m[1]
const m2 = /\/browse\/([^/?]+)/.exec(self)
if (m2?.[1]) return m2[1]
return undefined
}
/** Résout la clé du parent : `parent.key`, sinon `id` → clé, sinon `parent.self`. */
export function resolveParentIssueKey(
issue: JiraIssue,
idToKey: Map<string, string>,
): string | undefined {
const p = issue.fields.parent as ParentField | undefined
if (!p) return undefined
if (typeof p === 'object' && p !== null && 'key' in p && p.key) return p.key
const rawId = (p as { id?: string | number }).id
if (rawId !== undefined && rawId !== null) {
const idStr = String(rawId)
const fromMap = idToKey.get(idStr)
if (fromMap) return fromMap
}
const self = (p as { self?: string }).self
if (typeof self === 'string' && self.length > 0) return parentKeyFromSelf(self)
return undefined
}

25
src/lib/priorityLabel.ts Normal file
View File

@ -0,0 +1,25 @@
import type { JiraIssue } from '../types/jira'
export type PriorityBand = 'Haute' | 'Moyenne' | 'Basse'
export function priorityBand(issue: JiraIssue): PriorityBand | null {
const raw = issue.fields.priority?.name?.trim()
if (!raw) return null
const n = raw.toLowerCase()
if (/highest|critical|blocker|haute|maximale|p1/i.test(n)) return 'Haute'
if (/high|élev|eleve|major|p2/i.test(n)) return 'Haute'
if (/medium|moyen|normale|p3/i.test(n)) return 'Moyenne'
if (/low|lowest|mineur|faible|p4|p5/i.test(n)) return 'Basse'
return 'Moyenne'
}
export function priorityBadgeClass(band: PriorityBand): string {
switch (band) {
case 'Haute':
return 'bg-rose-500/20 text-rose-100 ring-rose-400/50 shadow-[0_0_12px_rgba(244,63,94,0.35)]'
case 'Moyenne':
return 'bg-amber-500/15 text-amber-100 ring-amber-400/40 shadow-[0_0_10px_rgba(251,191,36,0.2)]'
case 'Basse':
return 'bg-slate-500/20 text-slate-200 ring-slate-400/35'
}
}

96
src/lib/statusPhase.ts Normal file
View File

@ -0,0 +1,96 @@
import type { PhaseId } from '../types/jira'
/**
* Ajustez ce mapping pour refléter exactement vos 14 statuts Jira → 4 phases.
* Les clés sont comparées en insensible à la casse (trim).
*/
const STATUS_TO_PHASE: Record<string, PhaseId> = {
// Analyse
backlog: 'analyse',
nouveau: 'analyse',
new: 'analyse',
'à faire': 'analyse',
'a faire': 'analyse',
'to do': 'analyse',
todo: 'analyse',
open: 'analyse',
sélectionné: 'analyse',
selectionne: 'analyse',
'en attente': 'analyse',
'à analyser': 'analyse',
'a analyser': 'analyse',
analyse: 'analyse',
refinement: 'analyse',
// Design
spécification: 'design',
specification: 'design',
spec: 'design',
design: 'design',
maquette: 'design',
'design review': 'design',
'en design': 'design',
// Intégration
'prêt pour développement': 'integration',
'pret pour developpement': 'integration',
'ready for development': 'integration',
'ready for dev': 'integration',
'en cours': 'integration',
'in progress': 'integration',
développement: 'integration',
developpement: 'integration',
dev: 'integration',
'code review': 'integration',
review: 'integration',
'en test': 'integration',
test: 'integration',
qa: 'integration',
recette: 'integration',
'en recette': 'integration',
staging: 'integration',
bloqué: 'integration',
bloque: 'integration',
blocked: 'integration',
'en intégration': 'integration',
'en integration': 'integration',
// Terminé
terminé: 'done',
termine: 'done',
done: 'done',
closed: 'done',
resolved: 'done',
livré: 'done',
livre: 'done',
déployé: 'done',
deploye: 'done',
annulé: 'done',
annule: 'done',
cancelled: 'done',
canceled: 'done',
wontfix: 'done',
"won't fix": 'done',
}
function normalizeStatus(name: string): string {
return name
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/\p{M}/gu, '')
}
export function statusToPhase(statusName: string): PhaseId {
const key = normalizeStatus(statusName)
return STATUS_TO_PHASE[key] ?? 'analyse'
}
export const PHASE_LABELS: Record<PhaseId, string> = {
analyse: 'Analyse',
design: 'Design',
integration: 'Intégration',
done: 'Terminé',
}
export const PHASE_ORDER: PhaseId[] = ['analyse', 'design', 'integration', 'done']

41
src/lib/storyMetrics.ts Normal file
View File

@ -0,0 +1,41 @@
import type { JiraIssue, PhaseId } from '../types/jira'
import { PHASE_ORDER, statusToPhase } from './statusPhase'
function phaseRank(p: PhaseId): number {
const i = PHASE_ORDER.indexOf(p)
return i >= 0 ? i : 0
}
const MAX_PHASE_RANK = PHASE_ORDER.length - 1
/**
* % davancement moyen des sous-tâches sur le pipeline Analyse → Design → Intégration → Terminé
* (0 % = tout en analyse, 100 % = tout terminé).
*/
export function storyProgressPercent(subtasks: JiraIssue[]): number {
if (subtasks.length === 0) return 0
const sum = subtasks.reduce(
(acc, st) => acc + phaseRank(statusToPhase(st.fields.status.name)),
0,
)
return Math.round((sum / (subtasks.length * MAX_PHASE_RANK)) * 100)
}
/**
* Étape A/D/I « passée » : toutes les sous-tâches sont **strictement** au-delà de cette phase
* (évite les barres vertes alors que tout est encore en analyse / ouvert).
*/
export function isStepComplete(subtasks: JiraIssue[], stepPhase: PhaseId): boolean {
if (subtasks.length === 0) return false
const stepIdx = phaseRank(stepPhase)
return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx)
}
export function stepperStates(subtasks: JiraIssue[]): 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'),
}
}

11
src/lib/subtaskUtils.ts Normal file
View File

@ -0,0 +1,11 @@
import type { JiraIssue } from '../types/jira'
/** LAPI Jira nexpose pas toujours `issuetype.subtask` ; on complète par le nom du type. */
export function isJiraSubtask(issue: JiraIssue): boolean {
const t = issue.fields.issuetype
if (t.subtask === true) return true
const name = (t.name ?? '').toLowerCase()
return /sub-task|subtask|sous-tâche|sous-tache|sous tâche|sub task|technical task|tech task/i.test(
name,
)
}