sprint
This commit is contained in:
@ -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 d’impact 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,
|
||||
}
|
||||
}
|
||||
|
||||
12
src/lib/jiraSprintField.ts
Normal file
12
src/lib/jiraSprintField.ts
Normal 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 d’environnement.
|
||||
*/
|
||||
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
58
src/lib/milestoneKinds.ts
Normal 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)]'
|
||||
}
|
||||
}
|
||||
74
src/lib/milestoneLoadRisk.ts
Normal file
74
src/lib/milestoneLoadRisk.ts
Normal 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
117
src/lib/sprintExtract.ts
Normal 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 d’objets 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))
|
||||
}
|
||||
Reference in New Issue
Block a user