init
This commit is contained in:
18
src/lib/assigneeMatch.ts
Normal file
18
src/lib/assigneeMatch.ts
Normal 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
17
src/lib/boardGrouping.ts
Normal 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
36
src/lib/burnupHistory.ts
Normal 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 s’il 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
|
||||
}
|
||||
72
src/lib/dashboardConfig.ts
Normal file
72
src/lib/dashboardConfig.ts
Normal 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
96
src/lib/executiveKpis.ts
Normal 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
144
src/lib/groupIssues.ts
Normal 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 l’entré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 n’est 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
|
||||
64
src/lib/jiraFieldExtractors.ts
Normal file
64
src/lib/jiraFieldExtractors.ts
Normal 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
7
src/lib/jiraLinks.ts
Normal 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
53
src/lib/laneDetection.ts
Normal 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'
|
||||
}
|
||||
32
src/lib/milestoneStatus.ts
Normal file
32
src/lib/milestoneStatus.ts
Normal 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 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
|
||||
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
39
src/lib/parentResolve.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { JiraIssue } from '../types/jira'
|
||||
|
||||
/** Construit la table id numérique Jira → clé (DCC-xxx), indispensable quand `parent` n’a 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
25
src/lib/priorityLabel.ts
Normal 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
96
src/lib/statusPhase.ts
Normal 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
41
src/lib/storyMetrics.ts
Normal 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
|
||||
|
||||
/**
|
||||
* % d’avancement 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
11
src/lib/subtaskUtils.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { JiraIssue } from '../types/jira'
|
||||
|
||||
/** L’API Jira n’expose 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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user