gantt
This commit is contained in:
33
src/lib/assigneeRadar.ts
Normal file
33
src/lib/assigneeRadar.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
|
||||
export type AssigneeLoadRow = {
|
||||
name: string
|
||||
openCount: number
|
||||
overload: boolean
|
||||
}
|
||||
|
||||
/** Radar de charge : sous-tâches ouvertes par assigné vs plafond WIP (réglages). */
|
||||
export function assigneeOpenLoadRadar(
|
||||
groups: StoryGroup[],
|
||||
cfg: StatusBucketConfig,
|
||||
wipCap: number,
|
||||
): AssigneeLoadRow[] {
|
||||
const map = new Map<string, number>()
|
||||
for (const g of groups) {
|
||||
for (const st of g.subtasks) {
|
||||
const b = resolveWorkBucketFromIssue(st, cfg)
|
||||
if (b === 'done' || b === 'cancel') continue
|
||||
const name = st.fields.assignee?.displayName?.trim() || 'Non assigné'
|
||||
map.set(name, (map.get(name) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([name, openCount]) => ({
|
||||
name,
|
||||
openCount,
|
||||
overload: openCount > wipCap,
|
||||
}))
|
||||
.sort((a, b) => b.openCount - a.openCount)
|
||||
}
|
||||
@ -53,6 +53,102 @@ export function sanitizeMilestonesArray(arr: unknown): Milestone[] {
|
||||
return arr.map(sanitizeMilestone).filter((m): m is Milestone => m != null)
|
||||
}
|
||||
|
||||
/** Badges « Golden Carbon » : écarts fonctionnels (Panier, Checkout…) par mots-clés configurables. */
|
||||
export type FunctionalGapBadge = {
|
||||
id: string
|
||||
label: string
|
||||
/** Termes cherchés dans clés, résumés et étiquettes (insensible à la casse / accents). */
|
||||
terms: string[]
|
||||
/** Flux critique : renforce le feu rouge macro si conflit de phases. */
|
||||
criticalFlow?: boolean
|
||||
}
|
||||
|
||||
export function sanitizeFunctionalGapBadge(raw: unknown): FunctionalGapBadge | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const o = raw as Partial<FunctionalGapBadge>
|
||||
if (!o.id || typeof o.id !== 'string' || !o.label || typeof o.label !== 'string') return null
|
||||
const terms = Array.isArray(o.terms)
|
||||
? o.terms
|
||||
.filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
|
||||
.map((t) => t.trim())
|
||||
: []
|
||||
if (terms.length === 0) return null
|
||||
return {
|
||||
id: o.id.trim(),
|
||||
label: o.label.trim(),
|
||||
terms,
|
||||
criticalFlow: Boolean(o.criticalFlow),
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultFunctionalGaps(): FunctionalGapBadge[] {
|
||||
return [
|
||||
{ id: 'cart', label: 'Panier', terms: ['panier', 'cart', 'basket', 'caddie'], criticalFlow: true },
|
||||
{ id: 'checkout', label: 'Checkout', terms: ['checkout', 'caisse', 'commande'], criticalFlow: true },
|
||||
{ id: 'payment', label: 'Paiement', terms: ['paiement', 'payment', 'stripe', 'psp'], criticalFlow: false },
|
||||
]
|
||||
}
|
||||
|
||||
function sanitizeFunctionalGapsArray(
|
||||
arr: unknown,
|
||||
fallback: FunctionalGapBadge[],
|
||||
): FunctionalGapBadge[] {
|
||||
if (!Array.isArray(arr)) return fallback
|
||||
const out = arr.map(sanitizeFunctionalGapBadge).filter((b): b is FunctionalGapBadge => b != null)
|
||||
return out.length > 0 ? out : fallback
|
||||
}
|
||||
|
||||
/** À l’enregistrement des réglages : retire les badges invalides, garde au moins les défauts. */
|
||||
export function normalizeFunctionalGapsForSave(arr: FunctionalGapBadge[]): FunctionalGapBadge[] {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return defaultFunctionalGaps()
|
||||
const out = arr.map(sanitizeFunctionalGapBadge).filter((b): b is FunctionalGapBadge => b != null)
|
||||
return out.length > 0 ? out : defaultFunctionalGaps()
|
||||
}
|
||||
|
||||
/** Infos affichées sous chaque sprint sur le Gantt. */
|
||||
export type GanttSprintRowMetric =
|
||||
| 'none'
|
||||
| 'time_remaining'
|
||||
| 'subtasks_done_count'
|
||||
| 'subtasks_done_percent'
|
||||
| 'combined'
|
||||
|
||||
export function defaultGanttSprintRowMetric(): GanttSprintRowMetric {
|
||||
return 'combined'
|
||||
}
|
||||
|
||||
export const GANTT_SPRINT_METRIC_OPTIONS: { value: GanttSprintRowMetric; label: string }[] = [
|
||||
{ value: 'combined', label: 'Temps restant + sous-tâches' },
|
||||
{ value: 'time_remaining', label: 'Temps restant (dates sprint)' },
|
||||
{ value: 'subtasks_done_count', label: 'Sous-tâches terminées (nombre)' },
|
||||
{ value: 'subtasks_done_percent', label: 'Sous-tâches terminées (%)' },
|
||||
{ value: 'none', label: 'Masquer' },
|
||||
]
|
||||
|
||||
export function sanitizeGanttSprintRowMetric(raw: unknown): GanttSprintRowMetric {
|
||||
const v = typeof raw === 'string' ? raw : ''
|
||||
if (
|
||||
v === 'none' ||
|
||||
v === 'time_remaining' ||
|
||||
v === 'subtasks_done_count' ||
|
||||
v === 'subtasks_done_percent' ||
|
||||
v === 'combined'
|
||||
) {
|
||||
return v
|
||||
}
|
||||
return defaultGanttSprintRowMetric()
|
||||
}
|
||||
|
||||
export function sanitizeExcludedSprintIds(raw: unknown): number[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: number[] = []
|
||||
for (const x of raw) {
|
||||
const n = typeof x === 'number' ? x : Number(x)
|
||||
if (Number.isFinite(n) && n > 0) out.push(Math.floor(n))
|
||||
}
|
||||
return [...new Set(out)]
|
||||
}
|
||||
|
||||
export type DashboardConfig = {
|
||||
version: 1
|
||||
milestones: Milestone[]
|
||||
@ -72,6 +168,12 @@ export type DashboardConfig = {
|
||||
myViewActive?: boolean
|
||||
/** ID du champ Jira « Sprint » (ex. customfield_10020) — prioritaire sur VITE_JIRA_SPRINT_FIELD. */
|
||||
sprintFieldId?: string
|
||||
/** Badges d’écarts fonctionnels (vue produit / pages critiques). */
|
||||
functionalGaps: FunctionalGapBadge[]
|
||||
/** IDs sprint Jira (API Agile) masqués dans le Gantt et la vue Sprint. */
|
||||
excludedSprintIds: number[]
|
||||
/** Lignes d’info sous les barres de sprint (Gantt). */
|
||||
ganttSprintRowMetric: GanttSprintRowMetric
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dcc-dashboard-config-v1'
|
||||
@ -84,8 +186,25 @@ export const defaultDashboardConfig = (): DashboardConfig => ({
|
||||
teamCapacity: 3,
|
||||
baselineCapacity: 3,
|
||||
wipSlotsPerDev: 5,
|
||||
functionalGaps: defaultFunctionalGaps(),
|
||||
excludedSprintIds: [],
|
||||
ganttSprintRowMetric: defaultGanttSprintRowMetric(),
|
||||
})
|
||||
|
||||
/** Import : configuration seule, ou bundle Synology `{ bundleVersion, dashboard }`. */
|
||||
export function extractDashboardPayload(imported: unknown): Partial<DashboardConfig> | null {
|
||||
if (!imported || typeof imported !== 'object') return null
|
||||
const o = imported as Record<string, unknown>
|
||||
if (o.bundleVersion === 1 && o.dashboard && typeof o.dashboard === 'object') {
|
||||
const inner = o.dashboard as Partial<DashboardConfig>
|
||||
if (inner.version !== 1) return null
|
||||
return inner
|
||||
}
|
||||
const flat = imported as Partial<DashboardConfig>
|
||||
if (flat.version !== 1) return null
|
||||
return flat
|
||||
}
|
||||
|
||||
export function loadDashboardConfig(): DashboardConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
@ -124,6 +243,9 @@ export function loadDashboardConfig(): DashboardConfig {
|
||||
typeof parsed.sprintFieldId === 'string' && parsed.sprintFieldId.trim()
|
||||
? parsed.sprintFieldId.trim()
|
||||
: undefined,
|
||||
functionalGaps: sanitizeFunctionalGapsArray(parsed.functionalGaps, defaultFunctionalGaps()),
|
||||
excludedSprintIds: sanitizeExcludedSprintIds(parsed.excludedSprintIds),
|
||||
ganttSprintRowMetric: sanitizeGanttSprintRowMetric(parsed.ganttSprintRowMetric),
|
||||
}
|
||||
} catch {
|
||||
return defaultDashboardConfig()
|
||||
@ -144,13 +266,30 @@ export function exportConfigJson(cfg: DashboardConfig): void {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/** Sauvegarde JSON enrichie pour NAS Synology (Docker / volume monté) : horodatage + enveloppe versionnée. */
|
||||
export function exportSynologyBackupJson(cfg: DashboardConfig): void {
|
||||
const bundle = {
|
||||
bundleVersion: 1 as const,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: 'dcc-migration-cockpit' as const,
|
||||
dashboard: cfg,
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
||||
a.download = `dcc-synology-backup-${stamp}.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
|
||||
const o = extractDashboardPayload(imported)
|
||||
if (!o) return null
|
||||
return {
|
||||
version: 1,
|
||||
milestones: Array.isArray(o.milestones) ? sanitizeMilestonesArray(o.milestones) : current.milestones,
|
||||
@ -183,5 +322,12 @@ export function mergeImportedConfig(
|
||||
? o.sprintFieldId.trim()
|
||||
: undefined
|
||||
: current.sprintFieldId,
|
||||
functionalGaps: sanitizeFunctionalGapsArray(o.functionalGaps, current.functionalGaps),
|
||||
excludedSprintIds:
|
||||
o.excludedSprintIds !== undefined ? sanitizeExcludedSprintIds(o.excludedSprintIds) : current.excludedSprintIds,
|
||||
ganttSprintRowMetric:
|
||||
o.ganttSprintRowMetric !== undefined
|
||||
? sanitizeGanttSprintRowMetric(o.ganttSprintRowMetric)
|
||||
: current.ganttSprintRowMetric,
|
||||
}
|
||||
}
|
||||
|
||||
60
src/lib/functionalGaps.ts
Normal file
60
src/lib/functionalGaps.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { FunctionalGapBadge } from './dashboardConfig'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
|
||||
function norm(s: string): string {
|
||||
return s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
}
|
||||
|
||||
function groupHaystack(group: StoryGroup): string {
|
||||
const { story, subtasks } = group
|
||||
const parts: string[] = [
|
||||
story.key,
|
||||
story.fields.summary,
|
||||
...(story.fields.labels ?? []),
|
||||
]
|
||||
for (const st of subtasks) {
|
||||
parts.push(st.key, st.fields.summary, ...(st.fields.labels ?? []))
|
||||
}
|
||||
return norm(parts.join(' '))
|
||||
}
|
||||
|
||||
function haystackMatchesTerms(hay: string, terms: string[]): boolean {
|
||||
return terms.some((t) => {
|
||||
const n = norm(t)
|
||||
return n.length > 0 && hay.includes(n)
|
||||
})
|
||||
}
|
||||
|
||||
export function storyMatchesGapBadge(group: StoryGroup, badge: FunctionalGapBadge): boolean {
|
||||
return haystackMatchesTerms(groupHaystack(group), badge.terms)
|
||||
}
|
||||
|
||||
/** Sous-tâches encore ouvertes sur les stories qui matchent chaque badge (vue écarts). */
|
||||
export function countOpenGapsByBadge(
|
||||
groups: StoryGroup[],
|
||||
badges: FunctionalGapBadge[],
|
||||
cfg: StatusBucketConfig,
|
||||
): { id: string; label: string; openCount: number; criticalFlow: boolean }[] {
|
||||
return badges.map((badge) => {
|
||||
let openCount = 0
|
||||
for (const g of groups) {
|
||||
if (!storyMatchesGapBadge(g, badge)) continue
|
||||
for (const st of g.subtasks) {
|
||||
const b = resolveWorkBucketFromIssue(st, cfg)
|
||||
if (b !== 'done' && b !== 'cancel') openCount += 1
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: badge.id,
|
||||
label: badge.label,
|
||||
openCount,
|
||||
criticalFlow: Boolean(badge.criticalFlow),
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -19,11 +19,18 @@ function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): J
|
||||
'assignee',
|
||||
'timetracking',
|
||||
'subtasks',
|
||||
'labels',
|
||||
])
|
||||
const extras: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(f)) {
|
||||
if (!skip.has(k)) extras[k] = v
|
||||
}
|
||||
const labelsRaw = f.labels
|
||||
const labels =
|
||||
Array.isArray(labelsRaw) && labelsRaw.length > 0
|
||||
? labelsRaw.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
id: emb.id,
|
||||
key: emb.key,
|
||||
@ -35,6 +42,7 @@ function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): J
|
||||
? issuetype
|
||||
: { name: 'Sous-tâche', subtask: true },
|
||||
parent: { key: parentKey },
|
||||
labels,
|
||||
components: f.components as JiraIssue['fields']['components'],
|
||||
priority: (f.priority as JiraIssue['fields']['priority']) ?? null,
|
||||
assignee: (f.assignee as JiraIssue['fields']['assignee']) ?? null,
|
||||
|
||||
@ -10,3 +10,19 @@ export function resolveSprintFieldId(cfg: Pick<DashboardConfig, 'sprintFieldId'>
|
||||
const fromEnv = import.meta.env.VITE_JIRA_SPRINT_FIELD?.trim()
|
||||
return fromEnv || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Board logiciel Jira (ex. URL `.../boards/1445/`) pour l’API Agile `.../board/{id}/sprint`.
|
||||
* Sert à lister les sprints **actifs** et **futurs** sans élargir le JQL du cockpit.
|
||||
* `0` ou `false` = désactiver l’appel (repli sur les sprints déduits des tickets chargés).
|
||||
* Variable absente ou vide → **1445** (board DCC).
|
||||
*/
|
||||
export function resolveJiraBoardId(): number | null {
|
||||
const rawOpt = import.meta.env.VITE_JIRA_BOARD_ID
|
||||
if (rawOpt === undefined || rawOpt === null) return 1445
|
||||
const raw = String(rawOpt).trim().toLowerCase()
|
||||
if (raw === '' || raw === '0' || raw === 'false') return null
|
||||
const n = Number(String(rawOpt).trim())
|
||||
if (Number.isFinite(n) && n > 0) return Math.floor(n)
|
||||
return 1445
|
||||
}
|
||||
|
||||
68
src/lib/macroTrafficLight.ts
Normal file
68
src/lib/macroTrafficLight.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { FunctionalGapBadge } from './dashboardConfig'
|
||||
import { detectWorkLane, type LaneLabelsConfig } from './laneDetection'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { isIssueCanceled, isIssueDone } from './statusBuckets'
|
||||
import { storyMatchesGapBadge } from './functionalGaps'
|
||||
import { stepperStates } from './storyMetrics'
|
||||
|
||||
export type MacroTrafficLight = 'green' | 'amber' | 'red'
|
||||
|
||||
export type MacroPipelineHealth = {
|
||||
light: MacroTrafficLight
|
||||
title: string
|
||||
detail: string
|
||||
violatingStoryKeys: string[]
|
||||
hasCriticalViolation: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Feux macro (DSI) : rouge / orange si l’intégration (piste I) est encore ouverte
|
||||
* alors que l’étape Design du stepper n’est pas validée à 100 %.
|
||||
*/
|
||||
export function computeMacroPipelineHealth(
|
||||
groups: StoryGroup[],
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg: LaneLabelsConfig,
|
||||
gapBadges: FunctionalGapBadge[] | undefined,
|
||||
): MacroPipelineHealth {
|
||||
const violating: { key: string; critical: boolean }[] = []
|
||||
const badges = gapBadges ?? []
|
||||
|
||||
for (const g of groups) {
|
||||
const { story, subtasks } = g
|
||||
if (subtasks.length === 0) continue
|
||||
const steps = stepperStates(subtasks, bucketCfg, laneCfg)
|
||||
const designValidated = steps.design
|
||||
const integrationStillOpen = subtasks.some((st) => {
|
||||
if (isIssueDone(st, bucketCfg) || isIssueCanceled(st, bucketCfg)) return false
|
||||
return detectWorkLane(st, laneCfg) === 'integration'
|
||||
})
|
||||
if (integrationStillOpen && !designValidated) {
|
||||
const critical = badges.some((b) => b.criticalFlow && storyMatchesGapBadge(g, b))
|
||||
violating.push({ key: story.key, critical })
|
||||
}
|
||||
}
|
||||
|
||||
if (violating.length === 0) {
|
||||
return {
|
||||
light: 'green',
|
||||
title: 'Séquence A → D → I',
|
||||
detail:
|
||||
'Aucun conflit détecté : pas d’intégration active tant que le design n’est pas validé sur une même story.',
|
||||
violatingStoryKeys: [],
|
||||
hasCriticalViolation: false,
|
||||
}
|
||||
}
|
||||
|
||||
const hasCriticalViolation = violating.some((v) => v.critical)
|
||||
const light: MacroTrafficLight = hasCriticalViolation ? 'red' : 'amber'
|
||||
|
||||
return {
|
||||
light,
|
||||
title: hasCriticalViolation ? 'Alerte — flux critique' : 'Attention — séquence',
|
||||
detail: `${violating.length} story(s) avec intégration ouverte sans validation design complète.`,
|
||||
violatingStoryKeys: violating.map((v) => v.key),
|
||||
hasCriticalViolation,
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,17 @@
|
||||
import type { PhaseId, StoryGroup } from '../types/jira'
|
||||
import { statusToPhase } from './statusPhase'
|
||||
import type { LaneLabelsConfig } from './laneDetection'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { effectivePipelinePhase } from './pipelinePhase'
|
||||
|
||||
export function countSubtasksByPhase(groups: StoryGroup[]): Record<PhaseId, number> {
|
||||
export function countSubtasksByPhase(
|
||||
groups: StoryGroup[],
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): 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)
|
||||
const p = effectivePipelinePhase(s, bucketCfg, laneCfg)
|
||||
acc[p] += 1
|
||||
}
|
||||
}
|
||||
|
||||
19
src/lib/pipelinePhase.ts
Normal file
19
src/lib/pipelinePhase.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { JiraIssue, PhaseId } from '../types/jira'
|
||||
import { detectWorkLane, type LaneLabelsConfig } from './laneDetection'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { isIssueCanceled, isIssueDone } from './statusBuckets'
|
||||
import { statusToPhase } from './statusPhase'
|
||||
|
||||
/**
|
||||
* Phase « pipeline » pour une sous-tâche : cartographie statuts, ou piste A/D/I
|
||||
* (étiquettes + heuristique) lorsque `laneCfg` est fourni — aligné sur `PhaseLaneIcons`.
|
||||
*/
|
||||
export function effectivePipelinePhase(
|
||||
issue: JiraIssue,
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): PhaseId {
|
||||
if (isIssueCanceled(issue, bucketCfg) || isIssueDone(issue, bucketCfg)) return 'done'
|
||||
if (laneCfg) return detectWorkLane(issue, laneCfg) as PhaseId
|
||||
return statusToPhase(issue.fields.status.name)
|
||||
}
|
||||
19
src/lib/scheduleDelay.ts
Normal file
19
src/lib/scheduleDelay.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { LandingEstimate } from './executiveLanding'
|
||||
|
||||
/** Compare l’atterrissage projeté au dernier jalon (calendrier civil). */
|
||||
export function calendarDelayVsLastMilestone(
|
||||
landing: LandingEstimate,
|
||||
milestoneIso: string | null,
|
||||
): { delayDays: number; message: string } | null {
|
||||
if (!landing.estimatedLanding || !milestoneIso || !/^\d{4}-\d{2}-\d{2}/.test(milestoneIso)) {
|
||||
return null
|
||||
}
|
||||
const end = new Date(milestoneIso.slice(0, 10) + 'T12:00:00').getTime()
|
||||
const land = landing.estimatedLanding.getTime()
|
||||
const delayDays = Math.ceil((land - end) / 86400000)
|
||||
if (delayDays <= 0) return null
|
||||
return {
|
||||
delayDays,
|
||||
message: `Retard estimé : +${delayDays} jour(s) calendaire(s) vs dernier jalon (vélocité actuelle).`,
|
||||
}
|
||||
}
|
||||
@ -11,15 +11,22 @@ export type JiraSprintSnapshot = {
|
||||
goal?: string
|
||||
}
|
||||
|
||||
function coerceSprintObject(o: Record<string, unknown>): JiraSprintSnapshot | null {
|
||||
export 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
|
||||
const boardIdRaw = o.boardId ?? o.originBoardId
|
||||
const boardId =
|
||||
typeof boardIdRaw === 'number' && Number.isFinite(boardIdRaw)
|
||||
? boardIdRaw
|
||||
: typeof boardIdRaw === 'string' && Number.isFinite(Number(boardIdRaw))
|
||||
? Number(boardIdRaw)
|
||||
: undefined
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
state: typeof o.state === 'string' ? o.state : undefined,
|
||||
boardId: typeof o.boardId === 'number' ? o.boardId : undefined,
|
||||
boardId,
|
||||
startDate: typeof o.startDate === 'string' ? o.startDate : undefined,
|
||||
endDate: typeof o.endDate === 'string' ? o.endDate : undefined,
|
||||
goal: typeof o.goal === 'string' ? o.goal : undefined,
|
||||
@ -115,3 +122,75 @@ export function filterGroupsBySprint(
|
||||
if (!fieldId || sprintId == null) return groups
|
||||
return groups.filter((g) => groupInSprint(g, sprintId, fieldId))
|
||||
}
|
||||
|
||||
/** Filtre par clés issues (ex. issues renvoyées par `GET .../sprint/{id}/issue`). */
|
||||
export function filterGroupsBySprintIssueKeys(groups: StoryGroup[], issueKeys: Set<string>): StoryGroup[] {
|
||||
if (issueKeys.size === 0) return []
|
||||
return groups.filter(
|
||||
(g) =>
|
||||
issueKeys.has(g.story.key) || g.subtasks.some((st) => issueKeys.has(st.key)),
|
||||
)
|
||||
}
|
||||
|
||||
function sprintOrderRank(state?: string): number {
|
||||
const s = (state ?? '').toLowerCase()
|
||||
if (s === 'active') return 0
|
||||
if (s === 'future') return 1
|
||||
if (s === 'closed') return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
/** Sprints board uniquement (`storyCount` = 0 tant que le champ Sprint n’est pas configuré). */
|
||||
export function sprintOptionsFromBoardOnly(boardSprints: JiraSprintSnapshot[]): SprintOption[] {
|
||||
if (boardSprints.length === 0) return []
|
||||
return boardSprints
|
||||
.map((s) => ({ ...s, storyCount: 0 }))
|
||||
.sort((a, b) => {
|
||||
const ra = sprintOrderRank(a.state)
|
||||
const rb = sprintOrderRank(b.state)
|
||||
if (ra !== rb) return ra - rb
|
||||
const as = a.startDate ?? ''
|
||||
const bs = b.startDate ?? ''
|
||||
if (as && bs) return as.localeCompare(bs)
|
||||
return a.name.localeCompare(b.name, 'fr')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprints renvoyés par l’API Agile du board (actifs / futurs), enrichis du nombre de **stories**
|
||||
* du périmètre chargé présentes dans chaque sprint (via le champ Sprint sur les issues).
|
||||
*/
|
||||
export function sprintOptionsFromBoardAndGroups(
|
||||
boardSprints: JiraSprintSnapshot[],
|
||||
groups: StoryGroup[],
|
||||
fieldId: string | null,
|
||||
): SprintOption[] {
|
||||
if (!fieldId || boardSprints.length === 0) return []
|
||||
const boardIds = new Set(boardSprints.map((s) => s.id))
|
||||
const keysBySprintId = new Map<number, 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)) {
|
||||
if (!boardIds.has(sp.id) || seen.has(sp.id)) continue
|
||||
seen.add(sp.id)
|
||||
if (!keysBySprintId.has(sp.id)) keysBySprintId.set(sp.id, new Set())
|
||||
keysBySprintId.get(sp.id)!.add(g.story.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return boardSprints
|
||||
.map((sprint) => ({
|
||||
...sprint,
|
||||
storyCount: keysBySprintId.get(sprint.id)?.size ?? 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const ra = sprintOrderRank(a.state)
|
||||
const rb = sprintOrderRank(b.state)
|
||||
if (ra !== rb) return ra - rb
|
||||
const as = a.startDate ?? ''
|
||||
const bs = b.startDate ?? ''
|
||||
if (as && bs) return as.localeCompare(bs)
|
||||
return a.name.localeCompare(b.name, 'fr')
|
||||
})
|
||||
}
|
||||
|
||||
320
src/lib/sprintGantt.ts
Normal file
320
src/lib/sprintGantt.ts
Normal file
@ -0,0 +1,320 @@
|
||||
import type { GanttSprintRowMetric, Milestone } from './dashboardConfig'
|
||||
import { milestoneKindLabel } from './milestoneKinds'
|
||||
import type { StoryGroup } from '../types/jira'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||
import type { JiraSprintSnapshot } from './sprintExtract'
|
||||
import { groupInSprint } from './sprintExtract'
|
||||
|
||||
export const MS_DAY = 86400000
|
||||
|
||||
/** Échelle d’affichage du Gantt (densité des graduations). */
|
||||
export type GanttTimeScale = 'day' | 'week' | 'month'
|
||||
|
||||
export type GanttTick = { ms: number; label: string; major?: boolean }
|
||||
|
||||
/** Facteurs de zoom (indice ↑ = plus de pixels par jour = loupe « + »). */
|
||||
export const GANTT_ZOOM_FACTORS = [0.35, 0.5, 0.68, 0.9, 1.2, 1.6, 2.1, 2.8] as const
|
||||
|
||||
const BASE_PPD: Record<GanttTimeScale, number> = {
|
||||
/** ~2,5 semaines lisibles à zoom neutre sur un écran large */
|
||||
day: 22,
|
||||
week: 8,
|
||||
month: 2.4,
|
||||
}
|
||||
|
||||
export function pixelsPerDay(scale: GanttTimeScale, zoomIndex: number): number {
|
||||
const i = Math.max(0, Math.min(GANTT_ZOOM_FACTORS.length - 1, zoomIndex))
|
||||
return BASE_PPD[scale] * GANTT_ZOOM_FACTORS[i]!
|
||||
}
|
||||
|
||||
export function timelineWidthPx(startMs: number, endMs: number, ppd: number): number {
|
||||
const days = Math.max(1 / 24, (endMs - startMs) / MS_DAY)
|
||||
return Math.max(720, Math.ceil(days * ppd))
|
||||
}
|
||||
|
||||
export function msToX(ms: number, startMs: number, endMs: number, widthPx: number): number {
|
||||
const span = Math.max(1, endMs - startMs)
|
||||
return ((ms - startMs) / span) * widthPx
|
||||
}
|
||||
|
||||
/**
|
||||
* Graduations selon l’échelle ; `maxTicks` limite le nombre de lignes / libellés.
|
||||
*/
|
||||
export function timelineTicks(
|
||||
scale: GanttTimeScale,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
maxTicks: number,
|
||||
): GanttTick[] {
|
||||
const cap = Math.max(8, Math.min(72, maxTicks))
|
||||
const ticks: GanttTick[] = []
|
||||
|
||||
if (scale === 'month') {
|
||||
const d = new Date(startMs)
|
||||
d.setDate(1)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
while (d.getTime() <= endMs) {
|
||||
if (d.getTime() >= startMs - MS_DAY / 2) {
|
||||
ticks.push({
|
||||
ms: d.getTime(),
|
||||
label: d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }),
|
||||
major: true,
|
||||
})
|
||||
}
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
if (ticks.length > 120) break
|
||||
}
|
||||
return thinTicks(ticks, cap)
|
||||
}
|
||||
|
||||
if (scale === 'week') {
|
||||
const stepWeeks = Math.max(1, Math.ceil(((endMs - startMs) / MS_DAY / 7) / cap))
|
||||
let t = startOfWeekMondayLocal(startMs)
|
||||
let back = 0
|
||||
while (t + 6 * MS_DAY < startMs && back++ < 104) {
|
||||
t += stepWeeks * 7 * MS_DAY
|
||||
}
|
||||
let guard = 0
|
||||
while (t <= endMs && guard++ < 200) {
|
||||
if (t + 6 * MS_DAY >= startMs) {
|
||||
ticks.push({
|
||||
ms: t,
|
||||
label: formatWeekRangeFr(t),
|
||||
major: true,
|
||||
})
|
||||
}
|
||||
t += stepWeeks * 7 * MS_DAY
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
/* day */
|
||||
const totalDays = (endMs - startMs) / MS_DAY
|
||||
const stepDays = totalDays > cap ? Math.max(1, Math.ceil(totalDays / cap)) : 1
|
||||
let cur = startMs
|
||||
let g = 0
|
||||
while (cur <= endMs && g++ < 500) {
|
||||
const dd = new Date(cur)
|
||||
ticks.push({
|
||||
ms: cur,
|
||||
label:
|
||||
stepDays === 1
|
||||
? dd.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
: dd.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' }),
|
||||
major: dd.getDay() === 1,
|
||||
})
|
||||
cur += stepDays * MS_DAY
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
function startOfWeekMondayLocal(ms: number): number {
|
||||
const d = new Date(ms)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
const day = d.getDay()
|
||||
const diff = day === 0 ? -6 : 1 - day
|
||||
d.setDate(d.getDate() + diff)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
function formatWeekRangeFr(weekStartMs: number): string {
|
||||
const a = new Date(weekStartMs)
|
||||
const b = new Date(weekStartMs + 6 * MS_DAY)
|
||||
const sameMonth = a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear()
|
||||
if (sameMonth) {
|
||||
return `${a.getDate()}–${b.getDate()} ${a.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' })}`
|
||||
}
|
||||
return `${a.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} – ${b.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: '2-digit' })}`
|
||||
}
|
||||
|
||||
function thinTicks(ticks: GanttTick[], max: number): GanttTick[] {
|
||||
if (ticks.length <= max) return ticks
|
||||
const step = Math.ceil(ticks.length / max)
|
||||
return ticks.filter((_, i) => i % step === 0)
|
||||
}
|
||||
|
||||
export function parseIsoMs(iso?: string | null): number | null {
|
||||
if (!iso || typeof iso !== 'string') return null
|
||||
const t = Date.parse(iso)
|
||||
return Number.isFinite(t) ? t : null
|
||||
}
|
||||
|
||||
/** Fenêtre calendaire du sprint à partir des dates Jira Agile (`startDate` / `endDate`). */
|
||||
export function sprintBarBounds(s: JiraSprintSnapshot): { startMs: number; endMs: number } | null {
|
||||
const startRaw = parseIsoMs(s.startDate)
|
||||
const endRaw = parseIsoMs(s.endDate)
|
||||
if (startRaw == null && endRaw == null) return null
|
||||
let startMs = startRaw ?? endRaw! - 14 * MS_DAY
|
||||
let endMs = endRaw ?? startRaw! + 14 * MS_DAY
|
||||
if (endMs <= startMs) endMs = startMs + MS_DAY
|
||||
return { startMs, endMs }
|
||||
}
|
||||
|
||||
export function ganttRangeFromSprintsAndMilestones(
|
||||
sprints: JiraSprintSnapshot[],
|
||||
milestones: Milestone[],
|
||||
): { startMs: number; endMs: number } {
|
||||
const now = Date.now()
|
||||
let sMin = now
|
||||
let sMax = now + 30 * MS_DAY
|
||||
let has = false
|
||||
for (const sp of sprints) {
|
||||
const b = sprintBarBounds(sp)
|
||||
if (!b) continue
|
||||
has = true
|
||||
sMin = Math.min(sMin, b.startMs)
|
||||
sMax = Math.max(sMax, b.endMs)
|
||||
}
|
||||
for (const m of milestones) {
|
||||
const t = parseIsoMs(`${m.date}T12:00:00`)
|
||||
if (t != null) {
|
||||
has = true
|
||||
sMin = Math.min(sMin, t)
|
||||
sMax = Math.max(sMax, t)
|
||||
}
|
||||
}
|
||||
if (!has) {
|
||||
return { startMs: now - 14 * MS_DAY, endMs: now + 90 * MS_DAY }
|
||||
}
|
||||
sMin = Math.min(sMin, now - 7 * MS_DAY)
|
||||
sMax = Math.max(sMax, now + 28 * MS_DAY)
|
||||
return { startMs: sMin, endMs: sMax }
|
||||
}
|
||||
|
||||
export function monthTicksBetween(startMs: number, endMs: number): { ms: number; label: string }[] {
|
||||
const ticks: { ms: number; label: string }[] = []
|
||||
const d = new Date(startMs)
|
||||
d.setDate(1)
|
||||
d.setHours(12, 0, 0, 0)
|
||||
while (d.getTime() <= endMs) {
|
||||
if (d.getTime() >= startMs) {
|
||||
ticks.push({
|
||||
ms: d.getTime(),
|
||||
label: d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' }),
|
||||
})
|
||||
}
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
}
|
||||
return ticks
|
||||
}
|
||||
|
||||
export function pctOnSpan(ms: number, startMs: number, endMs: number): number {
|
||||
const span = Math.max(1, endMs - startMs)
|
||||
return ((ms - startMs) / span) * 100
|
||||
}
|
||||
|
||||
/** Avancement livraison : sous-tâches Done / (non annulées) pour le périmètre épique dans ce sprint. */
|
||||
export function epicScopeSprintProgress(
|
||||
groups: StoryGroup[],
|
||||
sprintId: number,
|
||||
fieldId: string | null,
|
||||
cfg: StatusBucketConfig,
|
||||
): { done: number; total: number; percent: number } {
|
||||
if (!fieldId) return { done: 0, total: 0, percent: 0 }
|
||||
let done = 0
|
||||
let total = 0
|
||||
for (const g of groups) {
|
||||
if (!groupInSprint(g, sprintId, fieldId)) continue
|
||||
for (const st of g.subtasks) {
|
||||
const b = resolveWorkBucketFromIssue(st, cfg)
|
||||
if (b === 'cancel') continue
|
||||
total += 1
|
||||
if (b === 'done') done += 1
|
||||
}
|
||||
}
|
||||
const percent = total > 0 ? Math.round((done / total) * 100) : 0
|
||||
return { done, total, percent }
|
||||
}
|
||||
|
||||
/** Avancement temporel du sprint (début → fin vs aujourd’hui). */
|
||||
export function sprintTimeElapsedPercent(s: JiraSprintSnapshot, nowMs = Date.now()): number {
|
||||
const b = sprintBarBounds(s)
|
||||
if (!b) return 0
|
||||
const { startMs, endMs } = b
|
||||
if (nowMs <= startMs) return 0
|
||||
if (nowMs >= endMs) return 100
|
||||
return Math.round(((nowMs - startMs) / (endMs - startMs)) * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplissage de la barre : priorité au % sous-tâches terminées (périmètre + champ Sprint),
|
||||
* sinon avancement calendaire du sprint.
|
||||
*/
|
||||
export function sprintBarFillPercent(
|
||||
s: JiraSprintSnapshot,
|
||||
groups: StoryGroup[],
|
||||
fieldId: string | null,
|
||||
cfg: StatusBucketConfig,
|
||||
): number {
|
||||
const delivery = epicScopeSprintProgress(groups, s.id, fieldId, cfg)
|
||||
if (fieldId && delivery.total > 0) return Math.min(100, delivery.percent)
|
||||
return sprintTimeElapsedPercent(s)
|
||||
}
|
||||
|
||||
export function milestoneTooltipText(m: Milestone): string {
|
||||
const lines: string[] = [m.title || 'Jalon', `Date : ${m.date}`]
|
||||
if (m.expectedActions?.trim()) lines.push(`Actions attendues :\n${m.expectedActions.trim()}`)
|
||||
if (m.critical) lines.push('Jalon critique')
|
||||
lines.push(`Type : ${milestoneKindLabel(m.kind)}`)
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
|
||||
export function formatSprintRangeFr(s: JiraSprintSnapshot): string {
|
||||
const fmt = (iso?: string) => {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
return `${fmt(s.startDate)} → ${fmt(s.endDate)}`
|
||||
}
|
||||
|
||||
export function formatSprintTimeRemaining(s: JiraSprintSnapshot): string | null {
|
||||
const b = sprintBarBounds(s)
|
||||
if (!b) return null
|
||||
const now = Date.now()
|
||||
if (now >= b.endMs) return 'Sprint terminé (dates)'
|
||||
if (now < b.startMs) {
|
||||
const j = Math.ceil((b.startMs - now) / MS_DAY)
|
||||
if (j <= 1) return 'Démarre demain ou aujourd’hui'
|
||||
return `Début dans ${j} j`
|
||||
}
|
||||
const j = Math.ceil((b.endMs - now) / MS_DAY)
|
||||
if (j <= 1) return '< 1 j restant'
|
||||
return j === 1 ? '1 jour restant' : `${j} jours restants`
|
||||
}
|
||||
|
||||
export function formatGanttSprintSubtitleLines(
|
||||
metric: GanttSprintRowMetric,
|
||||
s: JiraSprintSnapshot,
|
||||
groups: StoryGroup[],
|
||||
fieldId: string | null,
|
||||
cfg: StatusBucketConfig,
|
||||
): string[] {
|
||||
const time = formatSprintTimeRemaining(s)
|
||||
const prog = epicScopeSprintProgress(groups, s.id, fieldId, cfg)
|
||||
switch (metric) {
|
||||
case 'none':
|
||||
return []
|
||||
case 'time_remaining':
|
||||
return time ? [time] : []
|
||||
case 'subtasks_done_count':
|
||||
if (!fieldId) return ['Champ Sprint requis pour compter les sous-tâches']
|
||||
if (prog.total === 0) return ['Aucune sous-tâche dans ce sprint (périmètre chargé)']
|
||||
return [`${prog.done} / ${prog.total} sous-tâches terminées`]
|
||||
case 'subtasks_done_percent':
|
||||
if (!fieldId) return ['Champ Sprint requis pour le %']
|
||||
if (prog.total === 0) return ['Aucune sous-tâche dans ce sprint']
|
||||
return [`${prog.percent} % sous-tâches terminées`]
|
||||
case 'combined':
|
||||
default: {
|
||||
const lines: string[] = []
|
||||
if (time) lines.push(time)
|
||||
if (fieldId && prog.total > 0) lines.push(`${prog.done} / ${prog.total} st · ${prog.percent} %`)
|
||||
return lines.length > 0 ? lines : [time ?? '—']
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import type { JiraIssue, PhaseId } from '../types/jira'
|
||||
import { PHASE_ORDER, statusToPhase } from './statusPhase'
|
||||
import type { LaneLabelsConfig } from './laneDetection'
|
||||
import { PHASE_ORDER } from './statusPhase'
|
||||
import type { StatusBucketConfig } from './statusBuckets'
|
||||
import { isIssueCanceled, isIssueDone } from './statusBuckets'
|
||||
import { effectivePipelinePhase } from './pipelinePhase'
|
||||
|
||||
function phaseRank(p: PhaseId): number {
|
||||
const i = PHASE_ORDER.indexOf(p)
|
||||
@ -14,10 +16,14 @@ 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 {
|
||||
export function storyProgressPercent(
|
||||
subtasks: JiraIssue[],
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): number {
|
||||
if (subtasks.length === 0) return 0
|
||||
const sum = subtasks.reduce(
|
||||
(acc, st) => acc + phaseRank(statusToPhase(st.fields.status.name)),
|
||||
(acc, st) => acc + phaseRank(effectivePipelinePhase(st, bucketCfg, laneCfg)),
|
||||
0,
|
||||
)
|
||||
return Math.round((sum / (subtasks.length * MAX_PHASE_RANK)) * 100)
|
||||
@ -27,10 +33,17 @@ export function storyProgressPercent(subtasks: JiraIssue[]): number {
|
||||
* É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 {
|
||||
export function isStepComplete(
|
||||
subtasks: JiraIssue[],
|
||||
stepPhase: PhaseId,
|
||||
bucketCfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): boolean {
|
||||
if (subtasks.length === 0) return false
|
||||
const stepIdx = phaseRank(stepPhase)
|
||||
return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx)
|
||||
return subtasks.every(
|
||||
(st) => phaseRank(effectivePipelinePhase(st, bucketCfg, laneCfg)) > stepIdx,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -50,11 +63,12 @@ export function subtaskDoneRatioPercent(
|
||||
export function stepperStates(
|
||||
subtasks: JiraIssue[],
|
||||
cfg: StatusBucketConfig,
|
||||
laneCfg?: LaneLabelsConfig | null,
|
||||
): Record<PhaseId, boolean> {
|
||||
return {
|
||||
analyse: isStepComplete(subtasks, 'analyse'),
|
||||
design: isStepComplete(subtasks, 'design'),
|
||||
integration: isStepComplete(subtasks, 'integration'),
|
||||
analyse: isStepComplete(subtasks, 'analyse', cfg, laneCfg),
|
||||
design: isStepComplete(subtasks, 'design', cfg, laneCfg),
|
||||
integration: isStepComplete(subtasks, 'integration', cfg, laneCfg),
|
||||
done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user