This commit is contained in:
Bastien COIGNOUX
2026-04-24 15:23:23 +02:00
parent ca4c64bbb0
commit 19af51160a
14 changed files with 1026 additions and 78 deletions

View File

@ -1,3 +1,14 @@
import type { LaneLabelsConfig } from './laneDetection'
import { defaultLaneLabelsConfig, mergeLaneLabelsConfig } from './laneDetection'
import type { MilestoneKind } from './milestoneKinds'
import { normalizeMilestoneKind } from './milestoneKinds'
import type { StatusBucketConfig } from './statusBuckets'
import { defaultStatusBucketConfig, mergeStatusBucketConfig } from './statusBuckets'
export type { LaneLabelsConfig } from './laneDetection'
export type { MilestoneKind } from './milestoneKinds'
export type { StatusBucketConfig } from './statusBuckets'
export type Milestone = {
id: string
title: string
@ -7,15 +18,40 @@ export type Milestone = {
linkedStoryKeys?: string[]
/** Jalon critique : alerte dimpact si retard après la date. */
critical?: boolean
/** Nature du jalon (couleur frise, libellés). */
kind?: MilestoneKind
/** Actions ou livrables attendus à cette date (vue projet & synthèse). */
expectedActions?: string
}
import type { StatusBucketConfig } from './statusBuckets'
import { defaultStatusBucketConfig, mergeStatusBucketConfig } from './statusBuckets'
import type { LaneLabelsConfig } from './laneDetection'
import { defaultLaneLabelsConfig, mergeLaneLabelsConfig } from './laneDetection'
export function sanitizeMilestone(raw: unknown): Milestone | null {
if (!raw || typeof raw !== 'object') return null
const o = raw as Partial<Milestone>
if (!o.id || typeof o.id !== 'string') return null
const dateStr =
typeof o.date === 'string' && /^\d{4}-\d{2}-\d{2}/.test(o.date)
? o.date.slice(0, 10)
: new Date().toISOString().slice(0, 10)
return {
id: o.id,
title: typeof o.title === 'string' ? o.title : '',
date: dateStr,
linkedStoryKeys: Array.isArray(o.linkedStoryKeys)
? o.linkedStoryKeys.filter((k): k is string => typeof k === 'string' && k.trim().length > 0)
: [],
critical: Boolean(o.critical),
kind: normalizeMilestoneKind(o.kind),
expectedActions:
typeof o.expectedActions === 'string' && o.expectedActions.trim()
? o.expectedActions.trim()
: undefined,
}
}
export type { StatusBucketConfig } from './statusBuckets'
export type { LaneLabelsConfig } from './laneDetection'
export function sanitizeMilestonesArray(arr: unknown): Milestone[] {
if (!Array.isArray(arr)) return []
return arr.map(sanitizeMilestone).filter((m): m is Milestone => m != null)
}
export type DashboardConfig = {
version: 1
@ -34,6 +70,8 @@ export type DashboardConfig = {
myJiraEmail?: string
/** Filtre « Ma vue » (sous-tâches me concernant). */
myViewActive?: boolean
/** ID du champ Jira « Sprint » (ex. customfield_10020) — prioritaire sur VITE_JIRA_SPRINT_FIELD. */
sprintFieldId?: string
}
const STORAGE_KEY = 'dcc-dashboard-config-v1'
@ -56,7 +94,7 @@ export function loadDashboardConfig(): DashboardConfig {
if (!parsed || parsed.version !== 1) return defaultDashboardConfig()
return {
version: 1,
milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
milestones: sanitizeMilestonesArray(parsed.milestones),
statusBuckets: mergeStatusBucketConfig(
parsed.statusBuckets && typeof parsed.statusBuckets === 'object'
? (parsed.statusBuckets as Partial<StatusBucketConfig>)
@ -82,6 +120,10 @@ export function loadDashboardConfig(): DashboardConfig {
myJiraAccountId: parsed.myJiraAccountId,
myJiraEmail: parsed.myJiraEmail,
myViewActive: parsed.myViewActive,
sprintFieldId:
typeof parsed.sprintFieldId === 'string' && parsed.sprintFieldId.trim()
? parsed.sprintFieldId.trim()
: undefined,
}
} catch {
return defaultDashboardConfig()
@ -111,7 +153,7 @@ export function mergeImportedConfig(
if (o.version !== 1) return null
return {
version: 1,
milestones: Array.isArray(o.milestones) ? o.milestones : current.milestones,
milestones: Array.isArray(o.milestones) ? sanitizeMilestonesArray(o.milestones) : current.milestones,
statusBuckets:
o.statusBuckets && typeof o.statusBuckets === 'object'
? mergeStatusBucketConfig(o.statusBuckets as Partial<StatusBucketConfig>)
@ -135,5 +177,11 @@ export function mergeImportedConfig(
myJiraAccountId: o.myJiraAccountId ?? current.myJiraAccountId,
myJiraEmail: o.myJiraEmail ?? current.myJiraEmail,
myViewActive: o.myViewActive ?? current.myViewActive,
sprintFieldId:
o.sprintFieldId !== undefined
? typeof o.sprintFieldId === 'string' && o.sprintFieldId.trim()
? o.sprintFieldId.trim()
: undefined
: current.sprintFieldId,
}
}

View File

@ -0,0 +1,12 @@
import type { DashboardConfig } from './dashboardConfig'
/**
* Identifiant du champ personnalisé « Sprint » dans Jira (ex. customfield_10020).
* Priorité : réglages dashboard → variable denvironnement.
*/
export function resolveSprintFieldId(cfg: Pick<DashboardConfig, 'sprintFieldId'> | null | undefined): string | null {
const fromCfg = cfg?.sprintFieldId?.trim()
if (fromCfg) return fromCfg
const fromEnv = import.meta.env.VITE_JIRA_SPRINT_FIELD?.trim()
return fromEnv || null
}

58
src/lib/milestoneKinds.ts Normal file
View File

@ -0,0 +1,58 @@
export type MilestoneKind = 'deliverable' | 'governance' | 'dependency' | 'generic'
export const MILESTONE_KIND_OPTIONS: { value: MilestoneKind; label: string; hint: string }[] = [
{
value: 'deliverable',
label: 'Livrable',
hint: 'Recette, MEP, lot fonctionnel — suivi fin de réalisation.',
},
{
value: 'governance',
label: 'Gouvernance',
hint: 'Comité, GO/NO-GO, cadrage — date de décision ou de pilotage.',
},
{
value: 'dependency',
label: 'Dépendance',
hint: 'Autre équipe, infra, lot externe.',
},
{ value: 'generic', label: 'Générique', hint: 'Repère calendaire simple.' },
]
export function normalizeMilestoneKind(k: unknown): MilestoneKind {
if (k === 'deliverable' || k === 'governance' || k === 'dependency' || k === 'generic') return k
return 'generic'
}
export function milestoneKindLabel(kind: MilestoneKind | undefined): string {
const k = kind ?? 'generic'
return MILESTONE_KIND_OPTIONS.find((o) => o.value === k)?.label ?? 'Générique'
}
/** Classes pour pastilles / marqueurs sur la frise. */
export function milestoneKindChipClass(kind: MilestoneKind | undefined): string {
const base = 'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset'
switch (kind ?? 'generic') {
case 'deliverable':
return `${base} bg-emerald-500/15 text-emerald-200 ring-emerald-500/35`
case 'governance':
return `${base} bg-violet-500/15 text-violet-200 ring-violet-500/35`
case 'dependency':
return `${base} bg-amber-500/15 text-amber-100 ring-amber-500/35`
default:
return `${base} bg-slate-600/40 text-slate-200 ring-slate-500/30`
}
}
export function milestoneKindMarkerClass(kind: MilestoneKind | undefined): string {
switch (kind ?? 'generic') {
case 'deliverable':
return 'bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.55)]'
case 'governance':
return 'bg-violet-400 shadow-[0_0_12px_rgba(167,139,250,0.45)]'
case 'dependency':
return 'bg-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.45)]'
default:
return 'bg-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.45)]'
}
}

View File

@ -0,0 +1,74 @@
import type { StoryGroup } from '../types/jira'
import type { Milestone } from './dashboardConfig'
import type { StatusBucketConfig } from './statusBuckets'
import { resolveWorkBucketFromIssue } from './statusBuckets'
import {
milestoneAverageCompletionPercent,
milestoneLinkedGroups,
} from './milestoneStatus'
/** Jours calendaires restants jusquà la fin du jour du jalon (0 si déjà passé). */
export function calendarDaysInclusiveUntil(isoDate: string): number {
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date(isoDate + 'T23:59:59')
const diff = (end.getTime() - start.getTime()) / 86400000
return Math.max(0, Math.ceil(diff))
}
/** Sous-tâches encore actives (hors terminé / annulé) dans le périmètre du jalon. */
export function milestoneOpenSubtaskCount(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
): number {
let n = 0
for (const g of milestoneLinkedGroups(m, groups)) {
for (const s of g.subtasks) {
const b = resolveWorkBucketFromIssue(s, cfg)
if (b !== 'done' && b !== 'cancel') n += 1
}
}
return n
}
export type MilestoneVelocityRisk = 'ok' | 'tight' | 'unknown'
/**
* Compare le volume de sous-tâches ouvertes du périmètre à la vélocité globale (sous-tâches / jour
* calendaire, comme le burn-up). Si les jours « nécessaires » dépassent les jours calendaires
* restants avant le jalon → charge serrée (heuristique).
*/
export function milestoneVelocityRisk(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
velocitySubtasksPerCalendarDay: number,
): {
level: MilestoneVelocityRisk
openSubtasks: number
daysNeeded: number | null
calendarDaysLeft: number
} {
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
const open = milestoneOpenSubtaskCount(m, groups, cfg)
const calendarDaysLeft = calendarDaysInclusiveUntil(m.date)
if (pct >= 100) {
return { level: 'ok', openSubtasks: open, daysNeeded: 0, calendarDaysLeft }
}
if (open === 0) {
return { level: 'ok', openSubtasks: 0, daysNeeded: 0, calendarDaysLeft }
}
if (velocitySubtasksPerCalendarDay <= 0.001) {
return { level: 'unknown', openSubtasks: open, daysNeeded: null, calendarDaysLeft }
}
const daysNeeded = Math.ceil(open / velocitySubtasksPerCalendarDay)
const tight = calendarDaysLeft > 0 && daysNeeded > calendarDaysLeft
return {
level: tight ? 'tight' : 'ok',
openSubtasks: open,
daysNeeded,
calendarDaysLeft,
}
}

117
src/lib/sprintExtract.ts Normal file
View File

@ -0,0 +1,117 @@
import type { JiraIssue, StoryGroup } from '../types/jira'
/** Snapshot sprint tel que renvoyé par le champ Sprint Jira (souvent `customfield_10020`). */
export type JiraSprintSnapshot = {
id: number
name: string
state?: string
boardId?: number
startDate?: string
endDate?: string
goal?: string
}
function coerceSprintObject(o: Record<string, unknown>): JiraSprintSnapshot | null {
const id = Number(o.id)
const name = typeof o.name === 'string' ? o.name.trim() : ''
if (!Number.isFinite(id) || !name) return null
return {
id,
name,
state: typeof o.state === 'string' ? o.state : undefined,
boardId: typeof o.boardId === 'number' ? o.boardId : undefined,
startDate: typeof o.startDate === 'string' ? o.startDate : undefined,
endDate: typeof o.endDate === 'string' ? o.endDate : undefined,
goal: typeof o.goal === 'string' ? o.goal : undefined,
}
}
/** Parse la valeur brute du champ Sprint (tableau dobjets ou de chaînes JSON historiques). */
export function parseSprintFieldRaw(raw: unknown): JiraSprintSnapshot[] {
if (raw == null) return []
if (!Array.isArray(raw)) return []
const out: JiraSprintSnapshot[] = []
for (const item of raw) {
if (typeof item === 'string') {
try {
const o = JSON.parse(item) as Record<string, unknown>
const s = coerceSprintObject(o)
if (s) out.push(s)
} catch {
/* ignore */
}
} else if (typeof item === 'object' && item !== null) {
const s = coerceSprintObject(item as Record<string, unknown>)
if (s) out.push(s)
}
}
return out
}
export function getSprintsOnIssue(issue: JiraIssue, fieldId: string | null): JiraSprintSnapshot[] {
if (!fieldId) return []
const raw = (issue.fields as Record<string, unknown>)[fieldId]
return parseSprintFieldRaw(raw)
}
/** Sprints distincts sur la story et ses sous-tâches (par id). */
export function mergeSprintsForGroup(group: StoryGroup, fieldId: string | null): JiraSprintSnapshot[] {
if (!fieldId) return []
const map = new Map<number, JiraSprintSnapshot>()
for (const issue of [group.story, ...group.subtasks]) {
for (const sp of getSprintsOnIssue(issue, fieldId)) {
map.set(sp.id, sp)
}
}
return [...map.values()].sort((a, b) => {
const ae = a.endDate ?? ''
const be = b.endDate ?? ''
if (ae && be) return be.localeCompare(ae)
return a.name.localeCompare(b.name, 'fr')
})
}
/** La story ou une sous-tâche est dans le sprint `sprintId`. */
export function groupInSprint(group: StoryGroup, sprintId: number, fieldId: string | null): boolean {
if (!fieldId) return false
return mergeSprintsForGroup(group, fieldId).some((s) => s.id === sprintId)
}
export type SprintOption = JiraSprintSnapshot & { storyCount: number }
/** Sprints distincts sur tout le périmètre, avec nombre de stories touchées. */
export function collectSprintOptions(groups: StoryGroup[], fieldId: string | null): SprintOption[] {
if (!fieldId) return []
const acc = new Map<number, { sprint: JiraSprintSnapshot; keys: Set<string> }>()
for (const g of groups) {
const seen = new Set<number>()
for (const issue of [g.story, ...g.subtasks]) {
for (const sp of getSprintsOnIssue(issue, fieldId)) {
seen.add(sp.id)
const cur = acc.get(sp.id)
if (cur) {
cur.keys.add(g.story.key)
} else {
acc.set(sp.id, { sprint: sp, keys: new Set([g.story.key]) })
}
}
}
}
return [...acc.values()]
.map(({ sprint, keys }) => ({ ...sprint, storyCount: keys.size }))
.sort((a, b) => {
const ae = a.endDate ?? ''
const be = b.endDate ?? ''
if (ae && be) return be.localeCompare(ae)
return b.name.localeCompare(a.name, 'fr')
})
}
export function filterGroupsBySprint(
groups: StoryGroup[],
sprintId: number | null,
fieldId: string | null,
): StoryGroup[] {
if (!fieldId || sprintId == null) return groups
return groups.filter((g) => groupInSprint(g, sprintId, fieldId))
}