This commit is contained in:
Bastien COIGNOUX
2026-04-24 21:08:34 +02:00
parent 19af51160a
commit 020f5d11de
22 changed files with 2032 additions and 71 deletions

View File

@ -25,6 +25,10 @@ JIRA_API_KEY=
# Champ Sprint (Scrum) pour la vue Sprint — ID souvent proche de 10020 selon les instances # Champ Sprint (Scrum) pour la vue Sprint — ID souvent proche de 10020 selon les instances
# VITE_JIRA_SPRINT_FIELD=customfield_10020 # VITE_JIRA_SPRINT_FIELD=customfield_10020
# Board logiciel DCC (URL …/boards/1445/) : sprints actifs/futurs via API Agile (sans élargir le JQL).
# 0 ou false = désactiver (liste déduite uniquement des tickets chargés).
# VITE_JIRA_BOARD_ID=1445
# Clé de lépopée : utilisée dans le JQL (parentEpic + exclusion) et pour le groupement UI # Clé de lépopée : utilisée dans le JQL (parentEpic + exclusion) et pour le groupement UI
# VITE_JIRA_EPIC_KEY=DCC-5514 # VITE_JIRA_EPIC_KEY=DCC-5514

View File

@ -1,6 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { StoryGroup } from './types/jira' import type { StoryGroup } from './types/jira'
import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient' import {
fetchAllIssuesByJql,
fetchBoardSprints,
MIGRATION_EPIC_KEY,
MIGRATION_JQL,
jiraClient,
} from './api/jiraClient'
import { groupSubtasksUnderStories } from './lib/groupIssues' import { groupSubtasksUnderStories } from './lib/groupIssues'
import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory' import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory'
import { isIssueDone } from './lib/statusBuckets' import { isIssueDone } from './lib/statusBuckets'
@ -15,9 +21,12 @@ import {
} from './lib/executiveHealth' } from './lib/executiveHealth'
import { countSubtasksByPhase } from './lib/phaseAggregate' import { countSubtasksByPhase } from './lib/phaseAggregate'
import { import {
exportSynologyBackupJson,
loadDashboardConfig, loadDashboardConfig,
sanitizeGanttSprintRowMetric,
saveDashboardConfig, saveDashboardConfig,
type DashboardConfig, type DashboardConfig,
type GanttSprintRowMetric,
} from './lib/dashboardConfig' } from './lib/dashboardConfig'
import { assigneeMatchesMyView } from './lib/assigneeMatch' import { assigneeMatchesMyView } from './lib/assigneeMatch'
import { isAxiosError } from 'axios' import { isAxiosError } from 'axios'
@ -31,15 +40,18 @@ import { DashboardSettingsModal } from './components/DashboardSettingsModal'
import { ManagementOverview } from './components/ManagementOverview' import { ManagementOverview } from './components/ManagementOverview'
import { PhaseDistributionChart } from './components/PhaseDistributionChart' import { PhaseDistributionChart } from './components/PhaseDistributionChart'
import { ExportDashboardButton } from './components/ExportDashboardButton' import { ExportDashboardButton } from './components/ExportDashboardButton'
import { MacroCockpitStrip } from './components/MacroCockpitStrip'
import { StatusBucketProvider } from './context/StatusBucketContext' import { StatusBucketProvider } from './context/StatusBucketContext'
import { LaneLabelsProvider } from './context/LaneLabelsContext' import { LaneLabelsProvider } from './context/LaneLabelsContext'
import { PipelineOverview } from './components/PipelineOverview' import { PipelineOverview } from './components/PipelineOverview'
import { LaneTicketsListView } from './components/LaneTicketsListView' import { LaneTicketsListView } from './components/LaneTicketsListView'
import { ProjectTimelineView } from './components/ProjectTimelineView' import { ProjectTimelineView } from './components/ProjectTimelineView'
import { SprintGanttView } from './components/SprintGanttView'
import { SprintView } from './components/SprintView' import { SprintView } from './components/SprintView'
import { resolveSprintFieldId } from './lib/jiraSprintField' import { resolveJiraBoardId, resolveSprintFieldId } from './lib/jiraSprintField'
import type { JiraSprintSnapshot } from './lib/sprintExtract'
type ViewMode = 'list' | 'board' | 'project' | 'sprint' type ViewMode = 'list' | 'board' | 'project' | 'gantt' | 'sprint'
export default function App() { export default function App() {
const dashboardRef = useRef<HTMLDivElement>(null) const dashboardRef = useRef<HTMLDivElement>(null)
@ -51,6 +63,7 @@ export default function App() {
const [burnupData, setBurnupData] = useState<BurnupPoint[]>(() => loadBurnupHistory()) const [burnupData, setBurnupData] = useState<BurnupPoint[]>(() => loadBurnupHistory())
const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig()) const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig())
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [boardSprints, setBoardSprints] = useState<JiraSprintSnapshot[]>([])
const statusBucketsRef = useRef(dashboardCfg.statusBuckets) const statusBucketsRef = useRef(dashboardCfg.statusBuckets)
statusBucketsRef.current = dashboardCfg.statusBuckets statusBucketsRef.current = dashboardCfg.statusBuckets
@ -128,7 +141,24 @@ export default function App() {
], ],
) )
const phaseCounts = useMemo(() => countSubtasksByPhase(groups), [groups]) const phaseCounts = useMemo(
() => countSubtasksByPhase(groups, dashboardCfg.statusBuckets, dashboardCfg.laneLabels),
[groups, dashboardCfg.statusBuckets, dashboardCfg.laneLabels],
)
const boardSprintsVisible = useMemo(() => {
const ex = new Set(dashboardCfg.excludedSprintIds)
return boardSprints.filter((s) => !ex.has(s.id))
}, [boardSprints, dashboardCfg.excludedSprintIds])
const setGanttSprintRowMetric = useCallback((metric: GanttSprintRowMetric) => {
const m = sanitizeGanttSprintRowMetric(metric)
setDashboardCfg((prev) => {
const next: DashboardConfig = { ...prev, ganttSprintRowMetric: m }
saveDashboardConfig(next)
return next
})
}, [])
const toggleMyView = () => { const toggleMyView = () => {
const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive } const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive }
@ -146,9 +176,22 @@ export default function App() {
setError(null) setError(null)
try { try {
const sprintField = resolveSprintFieldId({ sprintFieldId: dashboardCfg.sprintFieldId }) const sprintField = resolveSprintFieldId({ sprintFieldId: dashboardCfg.sprintFieldId })
const issues = await fetchAllIssuesByJql(MIGRATION_JQL, signal, { const boardId = resolveJiraBoardId()
additionalFields: sprintField ? [sprintField] : [], const sprintListPromise =
}) boardId != null
? fetchBoardSprints(boardId, signal, ['active', 'future']).catch((err) => {
console.warn('[jira] Sprints board Agile (active/future) :', err)
return [] as JiraSprintSnapshot[]
})
: Promise.resolve([] as JiraSprintSnapshot[])
const [issues, sprintsFromBoard] = await Promise.all([
fetchAllIssuesByJql(MIGRATION_JQL, signal, {
additionalFields: sprintField ? [sprintField] : [],
}),
sprintListPromise,
])
setBoardSprints(sprintsFromBoard)
const grouped = groupSubtasksUnderStories(issues) const grouped = groupSubtasksUnderStories(issues)
setGroups(grouped) setGroups(grouped)
setUpdatedAt(new Date()) setUpdatedAt(new Date())
@ -177,6 +220,7 @@ export default function App() {
setError(e instanceof Error ? e.message : 'Erreur inconnue') setError(e instanceof Error ? e.message : 'Erreur inconnue')
} }
setGroups([]) setGroups([])
setBoardSprints([])
} finally { } finally {
if (!signal?.aborted) setLoading(false) if (!signal?.aborted) setLoading(false)
} }
@ -266,6 +310,18 @@ export default function App() {
> >
Projet Projet
</button> </button>
<button
type="button"
onClick={() => setView('gantt')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
view === 'gantt'
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
: 'text-slate-400 hover:text-white'
}`}
title="Gantt sprints (dates Jira) + jalons, avancement"
>
Gantt
</button>
<button <button
type="button" type="button"
onClick={() => setView('sprint')} onClick={() => setView('sprint')}
@ -281,7 +337,17 @@ export default function App() {
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{!loading && groups.length > 0 && ( {!loading && groups.length > 0 && (
<ExportDashboardButton targetRef={dashboardRef} /> <>
<ExportDashboardButton targetRef={dashboardRef} />
<button
type="button"
onClick={() => exportSynologyBackupJson(dashboardCfg)}
className="rounded-xl border border-emerald-500/35 bg-emerald-500/10 px-3 py-2 text-xs font-semibold text-emerald-100 transition hover:border-emerald-400/50 hover:bg-emerald-500/15"
title="JSON avec horodatage pour sauvegarde sur NAS Synology (volume Docker)."
>
Backup JSON NAS
</button>
</>
)} )}
{updatedAt && !loading && ( {updatedAt && !loading && (
<span className="text-xs text-slate-500"> <span className="text-xs text-slate-500">
@ -329,20 +395,63 @@ export default function App() {
{!loading && !error && groups.length > 0 && ( {!loading && !error && groups.length > 0 && (
<div ref={dashboardRef} className="space-y-10"> <div ref={dashboardRef} className="space-y-10">
{view === 'project' ? ( {view === 'project' ? (
<ProjectTimelineView <div className="space-y-10">
milestones={dashboardCfg.milestones} <MacroCockpitStrip
groups={groups} groups={groups}
velocityPerCalendarDay={landing.effectiveVelocityPerDay} dashboardCfg={dashboardCfg}
onOpenSettings={() => setSettingsOpen(true)} landing={landing}
/> finalMilestoneIso={finalMilestoneIso}
/>
<ProjectTimelineView
milestones={dashboardCfg.milestones}
groups={groups}
velocityPerCalendarDay={landing.effectiveVelocityPerDay}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div>
) : view === 'gantt' ? (
<div className="space-y-10">
<MacroCockpitStrip
groups={groups}
dashboardCfg={dashboardCfg}
landing={landing}
finalMilestoneIso={finalMilestoneIso}
/>
<SprintGanttView
sprints={boardSprintsVisible}
milestones={dashboardCfg.milestones}
groups={groups}
sprintFieldId={sprintFieldResolved}
ganttSprintRowMetric={dashboardCfg.ganttSprintRowMetric}
onGanttSprintRowMetricChange={setGanttSprintRowMetric}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div>
) : view === 'sprint' ? ( ) : view === 'sprint' ? (
<SprintView <div className="space-y-10">
groups={displayGroups} <MacroCockpitStrip
sprintFieldId={sprintFieldResolved} groups={groups}
onOpenSettings={() => setSettingsOpen(true)} dashboardCfg={dashboardCfg}
/> landing={landing}
finalMilestoneIso={finalMilestoneIso}
/>
<SprintView
groups={displayGroups}
sprintFieldId={sprintFieldResolved}
boardSprintsFromApi={boardSprintsVisible}
onOpenSettings={() => setSettingsOpen(true)}
gapBadges={dashboardCfg.functionalGaps}
/>
</div>
) : ( ) : (
<> <>
<MacroCockpitStrip
groups={groups}
dashboardCfg={dashboardCfg}
landing={landing}
finalMilestoneIso={finalMilestoneIso}
/>
<MilestonesTimeline <MilestonesTimeline
milestones={dashboardCfg.milestones} milestones={dashboardCfg.milestones}
groups={groups} groups={groups}
@ -399,11 +508,16 @@ export default function App() {
key={g.story.key} key={g.story.key}
group={g} group={g}
sprintFieldId={sprintFieldResolved} sprintFieldId={sprintFieldResolved}
gapBadges={dashboardCfg.functionalGaps}
/> />
))} ))}
</div> </div>
) : ( ) : (
<BoardView groups={displayGroups} sprintFieldId={sprintFieldResolved} /> <BoardView
groups={displayGroups}
sprintFieldId={sprintFieldResolved}
gapBadges={dashboardCfg.functionalGaps}
/>
)} )}
</section> </section>
</> </>
@ -417,6 +531,7 @@ export default function App() {
config={dashboardCfg} config={dashboardCfg}
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
onSave={saveSettings} onSave={saveSettings}
boardSprints={boardSprints}
/> />
</div> </div>
</LaneLabelsProvider> </LaneLabelsProvider>

View File

@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import type { JiraIssue, JiraSearchJqlResponse } from '../types/jira' import type { JiraIssue, JiraSearchJqlResponse } from '../types/jira'
import { coerceSprintObject, type JiraSprintSnapshot } from '../lib/sprintExtract'
/** /**
* Même périmètre quun filtre Jira type filter=25111 : tout ce qui est sous lépopée * Même périmètre quun filtre Jira type filter=25111 : tout ce qui est sous lépopée
@ -155,3 +156,88 @@ export async function fetchAllIssuesByJql(
return collected return collected
} }
type AgileSprintPage = {
values?: Record<string, unknown>[]
isLast?: boolean
}
/**
* Sprints dun board logiciel (API Agile Jira), avec pagination.
* @see https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-sprint-get
*/
export async function fetchBoardSprints(
boardId: number,
signal: AbortSignal | undefined,
states: ('active' | 'future' | 'closed')[],
): Promise<JiraSprintSnapshot[]> {
const base = clientBaseUrl()
if (!base || !Number.isFinite(boardId) || boardId <= 0) return []
const stateParam = states.join(',')
const all: JiraSprintSnapshot[] = []
let startAt = 0
const maxResults = 50
for (let page = 0; page < 40; page += 1) {
const { data } = await jiraClient.get<AgileSprintPage>(
`/rest/agile/1.0/board/${boardId}/sprint`,
{
params: { state: stateParam, startAt, maxResults },
signal,
},
)
const values = data.values ?? []
for (const row of values) {
if (row && typeof row === 'object') {
const s = coerceSprintObject(row as Record<string, unknown>)
if (s) all.push(s)
}
}
if (values.length === 0) break
if (data.isLast === true) break
if (values.length < maxResults) break
startAt += values.length
}
return all
}
type AgileSprintIssuesPage = {
issues?: { key?: string }[]
isLast?: boolean
maxResults?: number
startAt?: number
}
/**
* Toutes les clés dissues dun sprint (API Agile), pour filtrer sans `customfield` Sprint.
* @see https://developer.atlassian.com/cloud/jira/software/rest/api-group-sprint/#api-rest-agile-1-0-sprint-sprintid-issue-get
*/
export async function fetchAllIssueKeysInSprint(
sprintId: number,
signal: AbortSignal | undefined,
): Promise<Set<string>> {
const base = clientBaseUrl()
if (!base || !Number.isFinite(sprintId) || sprintId <= 0) return new Set()
const keys = new Set<string>()
let startAt = 0
const maxResults = 100
for (let page = 0; page < 100; page += 1) {
const { data } = await jiraClient.get<AgileSprintIssuesPage>(
`/rest/agile/1.0/sprint/${sprintId}/issue`,
{
params: { startAt, maxResults, fields: 'key' },
signal,
},
)
const issues = data.issues ?? []
for (const row of issues) {
if (row?.key && typeof row.key === 'string') keys.add(row.key)
}
if (issues.length === 0) break
if (data.isLast === true) break
if (issues.length < maxResults) break
startAt += issues.length
}
return keys
}

View File

@ -1,13 +1,15 @@
import type { StoryGroup } from '../types/jira' import type { StoryGroup } from '../types/jira'
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
import { groupStoriesByComponent } from '../lib/boardGrouping' import { groupStoriesByComponent } from '../lib/boardGrouping'
import { StoryCard } from './StoryCard' import { StoryCard } from './StoryCard'
type Props = { type Props = {
groups: StoryGroup[] groups: StoryGroup[]
sprintFieldId?: string | null sprintFieldId?: string | null
gapBadges?: FunctionalGapBadge[]
} }
export function BoardView({ groups, sprintFieldId = null }: Props) { export function BoardView({ groups, sprintFieldId = null, gapBadges }: Props) {
const columns = groupStoriesByComponent(groups) const columns = groupStoriesByComponent(groups)
return ( return (
@ -25,7 +27,13 @@ export function BoardView({ groups, sprintFieldId = null }: Props) {
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{col.map((g) => ( {col.map((g) => (
<StoryCard key={g.story.key} group={g} variant="board" sprintFieldId={sprintFieldId} /> <StoryCard
key={g.story.key}
group={g}
variant="board"
sprintFieldId={sprintFieldId}
gapBadges={gapBadges}
/>
))} ))}
</div> </div>
</div> </div>

View File

@ -1,13 +1,20 @@
import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react' import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react'
import { import {
exportConfigJson, exportConfigJson,
exportSynologyBackupJson,
GANTT_SPRINT_METRIC_OPTIONS,
mergeImportedConfig, mergeImportedConfig,
normalizeFunctionalGapsForSave,
sanitizeExcludedSprintIds,
type DashboardConfig, type DashboardConfig,
type FunctionalGapBadge,
type GanttSprintRowMetric,
type LaneLabelsConfig, type LaneLabelsConfig,
type Milestone, type Milestone,
type MilestoneKind, type MilestoneKind,
type StatusBucketConfig, type StatusBucketConfig,
} from '../lib/dashboardConfig' } from '../lib/dashboardConfig'
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
import { MILESTONE_KIND_OPTIONS } from '../lib/milestoneKinds' import { MILESTONE_KIND_OPTIONS } from '../lib/milestoneKinds'
function parseBucketLines(raw: string): string[] { function parseBucketLines(raw: string): string[] {
@ -61,6 +68,8 @@ type Props = {
config: DashboardConfig config: DashboardConfig
onClose: () => void onClose: () => void
onSave: (next: DashboardConfig) => void onSave: (next: DashboardConfig) => void
/** Sprints board (API) pour masquage sélectif ; optionnel si pas encore chargés. */
boardSprints?: JiraSprintSnapshot[]
} }
function newMilestone(): Milestone { function newMilestone(): Milestone {
@ -75,7 +84,26 @@ function newMilestone(): Milestone {
} }
} }
export function DashboardSettingsModal({ open, config, onClose, onSave }: Props) { function newGapBadge(): FunctionalGapBadge {
return {
id:
typeof crypto !== 'undefined' && crypto.randomUUID
? `gap-${crypto.randomUUID().slice(0, 10)}`
: `gap-${Date.now()}`,
label: '',
terms: [''],
criticalFlow: false,
}
}
function parseGapTerms(raw: string): string[] {
return raw
.split(/[,;]+/)
.map((s) => s.trim())
.filter(Boolean)
}
export function DashboardSettingsModal({ open, config, onClose, onSave, boardSprints }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null) const dialogRef = useRef<HTMLDialogElement>(null)
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const titleId = useId() const titleId = useId()
@ -119,7 +147,7 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
const parsed = JSON.parse(String(reader.result)) as unknown const parsed = JSON.parse(String(reader.result)) as unknown
const merged = mergeImportedConfig(draft, parsed) const merged = mergeImportedConfig(draft, parsed)
if (merged) setDraft(merged) if (merged) setDraft(merged)
else alert('Fichier JSON invalide (version 1 attendue).') else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).')
} catch { } catch {
alert('Impossible de lire ce fichier JSON.') alert('Impossible de lire ce fichier JSON.')
} }
@ -322,6 +350,159 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
</div> </div>
</div> </div>
<div className="rounded-xl border border-indigo-500/25 bg-indigo-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-indigo-200/90">
Gantt & vue Sprint
</p>
<label className="mt-2 block text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Infos sous les barres (Gantt)
</label>
<select
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-2 text-sm text-slate-200 outline-none"
value={draft.ganttSprintRowMetric}
onChange={(e) =>
setDraft((d) => ({
...d,
ganttSprintRowMetric: e.target.value as GanttSprintRowMetric,
}))
}
>
{GANTT_SPRINT_METRIC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<p className="mt-3 text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
Sprints à masquer
</p>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
Sprints cochés : retirés du Gantt et du menu de la vue Sprint. Rechargez les données si la
liste est vide.
</p>
{boardSprints && boardSprints.length > 0 ? (
<ul className="mt-2 max-h-44 space-y-2 overflow-y-auto pr-1">
{boardSprints.map((sp) => (
<li key={sp.id} className="flex items-start gap-2 text-xs text-slate-200">
<input
type="checkbox"
id={`ex-sp-${sp.id}`}
checked={draft.excludedSprintIds.includes(sp.id)}
onChange={(e) =>
setDraft((d) => {
const next = new Set(d.excludedSprintIds)
if (e.target.checked) next.add(sp.id)
else next.delete(sp.id)
return { ...d, excludedSprintIds: [...next] }
})
}
className="mt-0.5 rounded border-indigo-400/50"
/>
<label htmlFor={`ex-sp-${sp.id}`} className="cursor-pointer leading-snug">
<span className="font-medium">{sp.name}</span>
<span className="ml-2 font-mono text-[10px] text-slate-500">#{sp.id}</span>
{sp.state ? (
<span className="ml-2 text-[10px] uppercase text-slate-500">{sp.state}</span>
) : null}
</label>
</li>
))}
</ul>
) : (
<p className="mt-2 text-[11px] text-slate-500">
Aucun sprint en mémoire : actualisez le cockpit puis rouvrez les réglages.
</p>
)}
</div>
<div className="rounded-xl border border-rose-500/20 bg-rose-500/[0.06] p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-rose-100/90">
Badges « gaps » (PO)
</span>
<button
type="button"
onClick={() =>
setDraft((d) => ({ ...d, functionalGaps: [...d.functionalGaps, newGapBadge()] }))
}
className="text-xs font-medium text-rose-200/90 hover:text-rose-100"
>
+ Ajouter
</button>
</div>
<p className="text-[10px] leading-relaxed text-slate-500">
Termes cherchés dans clés, résumés et étiquettes (insensible casse / accents). Cochez
« flux critique » pour renforcer le feu rouge macro (Panier, Checkout).
</p>
<ul className="mt-3 max-h-48 space-y-3 overflow-y-auto pr-1">
{draft.functionalGaps.map((g) => (
<li
key={g.id}
className="rounded-lg border border-white/10 bg-black/25 p-2 text-xs text-slate-200"
>
<div className="flex flex-wrap gap-2">
<input
className="min-w-[100px] flex-1 rounded border border-white/10 bg-transparent px-2 py-1"
value={g.label}
onChange={(e) =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.map((x) =>
x.id === g.id ? { ...x, label: e.target.value } : x,
),
}))
}
placeholder="Libellé (ex. Panier)"
/>
<label className="flex cursor-pointer items-center gap-1.5 text-[11px] text-rose-100/90">
<input
type="checkbox"
checked={Boolean(g.criticalFlow)}
onChange={(e) =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.map((x) =>
x.id === g.id ? { ...x, criticalFlow: e.target.checked } : x,
),
}))
}
className="rounded border-rose-400/50"
/>
Flux critique
</label>
<button
type="button"
onClick={() =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.filter((x) => x.id !== g.id),
}))
}
className="text-[11px] text-rose-400 hover:text-rose-300"
>
Supprimer
</button>
</div>
<textarea
rows={2}
spellCheck={false}
className="mt-2 w-full resize-y rounded border border-white/10 bg-black/30 px-2 py-1 font-mono text-[11px] outline-none"
value={g.terms.join(', ')}
onChange={(e) =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.map((x) =>
x.id === g.id ? { ...x, terms: parseGapTerms(e.target.value) } : x,
),
}))
}
placeholder="panier, cart, basket…"
/>
</li>
))}
</ul>
</div>
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500"> <span className="text-xs font-medium uppercase tracking-wide text-slate-500">
@ -428,6 +609,14 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
> >
Exporter configuration (JSON) Exporter configuration (JSON)
</button> </button>
<button
type="button"
onClick={() => exportSynologyBackupJson(draft)}
className="rounded-lg border border-emerald-400/35 bg-emerald-950/40 px-3 py-2 text-xs font-semibold text-emerald-50"
title="Enveloppe bundleVersion + exportedAt pour sauvegarde NAS / Docker Synology."
>
Bundle Synology (JSON)
</button>
<button <button
type="button" type="button"
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
@ -456,7 +645,11 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
onSave(draft) onSave({
...draft,
functionalGaps: normalizeFunctionalGapsForSave(draft.functionalGaps),
excludedSprintIds: sanitizeExcludedSprintIds(draft.excludedSprintIds),
})
onClose() onClose()
}} }}
className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400" className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"

View File

@ -0,0 +1,198 @@
import type { StoryGroup } from '../types/jira'
import type { DashboardConfig } from '../lib/dashboardConfig'
import type { LandingEstimate } from '../lib/executiveLanding'
import { computeMacroPipelineHealth } from '../lib/macroTrafficLight'
import { assigneeOpenLoadRadar } from '../lib/assigneeRadar'
import { countOpenGapsByBadge } from '../lib/functionalGaps'
import { calendarDelayVsLastMilestone } from '../lib/scheduleDelay'
import { resolveWorkBucketFromIssue } from '../lib/statusBuckets'
type Props = {
groups: StoryGroup[]
dashboardCfg: DashboardConfig
landing: LandingEstimate
finalMilestoneIso: string | null
}
function trafficLightClasses(light: 'green' | 'amber' | 'red'): { ring: string; bg: string; dot: string } {
switch (light) {
case 'green':
return {
ring: 'ring-emerald-400/50',
bg: 'bg-emerald-500/20',
dot: 'bg-emerald-400 shadow-[0_0_14px_rgba(52,211,153,0.7)]',
}
case 'amber':
return {
ring: 'ring-amber-400/55',
bg: 'bg-amber-500/20',
dot: 'bg-amber-400 shadow-[0_0_14px_rgba(251,191,36,0.65)]',
}
case 'red':
return {
ring: 'ring-rose-500/60',
bg: 'bg-rose-600/25',
dot: 'bg-rose-500 shadow-[0_0_16px_rgba(244,63,94,0.75)]',
}
}
}
export function MacroCockpitStrip({ groups, dashboardCfg, landing, finalMilestoneIso }: Props) {
const macro = computeMacroPipelineHealth(
groups,
dashboardCfg.statusBuckets,
dashboardCfg.laneLabels,
dashboardCfg.functionalGaps,
)
const delay = calendarDelayVsLastMilestone(landing, finalMilestoneIso)
const radar = assigneeOpenLoadRadar(
groups,
dashboardCfg.statusBuckets,
dashboardCfg.wipSlotsPerDev,
).slice(0, 6)
const gaps = countOpenGapsByBadge(groups, dashboardCfg.functionalGaps, dashboardCfg.statusBuckets)
const openSamples = groups
.flatMap((g) =>
g.subtasks
.filter((st) => {
const b = resolveWorkBucketFromIssue(st, dashboardCfg.statusBuckets)
return b === 'in_progress' || b === 'blocked'
})
.map((st) => ({
key: st.key,
who: st.fields.assignee?.displayName ?? '—',
summary: st.fields.summary,
})),
)
.slice(0, 5)
const cls = trafficLightClasses(macro.light)
return (
<section className="rounded-2xl border border-white/[0.08] bg-gradient-to-br from-slate-950/90 to-slate-900/40 p-4 shadow-[0_12px_48px_rgba(0,0,0,0.35)] backdrop-blur-xl sm:p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
Cockpit macro DSI · Projet · PO · Exécution
</h2>
<span className="text-[10px] text-slate-500">
Données = instantané Jira au dernier chargement (Actualiser).
</span>
</div>
<div className="grid gap-4 lg:grid-cols-12">
<div
className={`lg:col-span-4 rounded-xl border border-white/10 p-4 ring-2 ring-inset ${cls.ring} ${cls.bg}`}
>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Feux phases (DSI)
</p>
<div className="mt-2 flex items-start gap-3">
<span
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full ${cls.dot}`}
title={macro.title}
aria-hidden
/>
<div className="min-w-0">
<p className="text-sm font-semibold text-white">{macro.title}</p>
<p className="mt-1 text-xs leading-relaxed text-slate-300">{macro.detail}</p>
{macro.violatingStoryKeys.length > 0 && (
<p className="mt-2 font-mono text-[11px] text-rose-200/90">
{macro.violatingStoryKeys.join(', ')}
</p>
)}
</div>
</div>
</div>
<div className="lg:col-span-4 rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Jalons &amp; vélocité (Directeur de projet)
</p>
{delay ? (
<p className="mt-2 text-sm font-medium text-amber-100">{delay.message}</p>
) : (
<p className="mt-2 text-sm text-slate-300">
Aucun retard calendaire détecté par rapport au dernier jalon (ou date / vélocité
indisponible).
</p>
)}
<p className="mt-2 text-[11px] text-slate-500">
{landing.businessDaysToFinish != null
? `~${landing.businessDaysToFinish} j. ouvrés restants (sous-tâches), vélocité ajustée effectif / baseline.`
: 'Vélocité nulle ou données insuffisantes pour estimer la fin.'}
</p>
</div>
<div className="lg:col-span-4 rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Écarts fonctionnels (PO)
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{gaps.map((g) => (
<li
key={g.id}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 ring-inset ${
g.criticalFlow
? 'bg-rose-500/15 text-rose-100 ring-rose-400/40'
: 'bg-slate-600/30 text-slate-200 ring-slate-500/35'
}`}
title="Sous-tâches encore ouvertes sur les stories correspondant aux termes configurés."
>
{g.label}
<span className="ml-1 tabular-nums opacity-90">({g.openCount})</span>
</li>
))}
</ul>
</div>
<div className="lg:col-span-6 rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Radar de charge (Chef de projet)
</p>
<p className="mt-1 text-[10px] text-slate-500">
Plafond WIP : {dashboardCfg.wipSlotsPerDev} sous-tâches ouvertes / personne.
</p>
<ul className="mt-2 space-y-1.5 text-xs">
{radar.length === 0 ? (
<li className="text-slate-500">Aucune sous-tâche ouverte.</li>
) : (
radar.map((r) => (
<li
key={r.name}
className={`flex justify-between gap-2 rounded-lg px-2 py-1 ${
r.overload ? 'bg-rose-500/15 text-rose-100' : 'bg-white/[0.04] text-slate-200'
}`}
>
<span className="truncate">{r.name}</span>
<span className="shrink-0 tabular-nums font-semibold">
{r.openCount}
{r.overload ? ' ⚠' : ''}
</span>
</li>
))
)}
</ul>
</div>
<div className="lg:col-span-6 rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
En cours (aperçu)
</p>
<ul className="mt-2 space-y-1.5 text-[11px] text-slate-300">
{openSamples.length === 0 ? (
<li className="text-slate-500">Aucun ticket « en cours » ou « bloqué ».</li>
) : (
openSamples.map((x) => (
<li key={x.key} className="truncate" title={x.summary}>
<span className="font-mono text-cyan-300/90">{x.key}</span>{' '}
<span className="text-slate-500">·</span> {x.who}
</li>
))
)}
</ul>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,447 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type { StoryGroup } from '../types/jira'
import { GANTT_SPRINT_METRIC_OPTIONS, type GanttSprintRowMetric, type Milestone } from '../lib/dashboardConfig'
import { useStatusBuckets } from '../context/StatusBucketContext'
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
import { milestoneKindMarkerClass } from '../lib/milestoneKinds'
import {
GANTT_ZOOM_FACTORS,
epicScopeSprintProgress,
formatGanttSprintSubtitleLines,
formatSprintRangeFr,
ganttRangeFromSprintsAndMilestones,
milestoneTooltipText,
msToX,
parseIsoMs,
pixelsPerDay,
timelineTicks,
timelineWidthPx,
type GanttTimeScale,
sprintBarBounds,
sprintBarFillPercent,
} from '../lib/sprintGantt'
type Props = {
sprints: JiraSprintSnapshot[]
milestones: Milestone[]
groups: StoryGroup[]
sprintFieldId: string | null
ganttSprintRowMetric: GanttSprintRowMetric
onGanttSprintRowMetricChange: (m: GanttSprintRowMetric) => void
onOpenSettings: () => void
}
function sprintOrderRank(state?: string): number {
const s = (state ?? '').toLowerCase()
if (s === 'active') return 0
if (s === 'future') return 1
return 2
}
function IconLoupeMinus({ className }: { className?: string }) {
return (
<svg
className={className}
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
>
<circle cx="10" cy="10" r="6" />
<path d="M15 15l5 5" />
<path d="M7.5 10h5" />
</svg>
)
}
function IconLoupePlus({ className }: { className?: string }) {
return (
<svg
className={className}
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
>
<circle cx="10" cy="10" r="6" />
<path d="M15 15l5 5" />
<path d="M10 7.5v5M7.5 10h5" />
</svg>
)
}
const SCALE_LABELS: Record<GanttTimeScale, string> = {
day: 'Jour',
week: 'Semaine',
month: 'Mois',
}
export function SprintGanttView({
sprints,
milestones,
groups,
sprintFieldId,
ganttSprintRowMetric,
onGanttSprintRowMetricChange,
onOpenSettings,
}: Props) {
const cfg = useStatusBuckets()
const scrollRef = useRef<HTMLDivElement>(null)
const [timeScale, setTimeScale] = useState<GanttTimeScale>('week')
const [zoomIndex, setZoomIndex] = useState(3)
const { datedSprints, undatedSprints } = useMemo(() => {
const dated: JiraSprintSnapshot[] = []
const undated: JiraSprintSnapshot[] = []
for (const s of sprints) {
if (sprintBarBounds(s)) dated.push(s)
else undated.push(s)
}
dated.sort((a, b) => {
const ra = sprintOrderRank(a.state)
const rb = sprintOrderRank(b.state)
if (ra !== rb) return ra - rb
const ba = sprintBarBounds(a)!
const bb = sprintBarBounds(b)!
return ba.startMs - bb.startMs
})
return { datedSprints: dated, undatedSprints: undated }
}, [sprints])
const sortedMilestones = useMemo(
() => [...milestones].sort((a, b) => a.date.localeCompare(b.date)),
[milestones],
)
const { startMs, endMs } = useMemo(
() => ganttRangeFromSprintsAndMilestones(datedSprints, sortedMilestones),
[datedSprints, sortedMilestones],
)
const ppd = useMemo(() => pixelsPerDay(timeScale, zoomIndex), [timeScale, zoomIndex])
const widthPx = useMemo(
() => timelineWidthPx(startMs, endMs, ppd),
[startMs, endMs, ppd],
)
const maxTicks = useMemo(() => Math.floor(widthPx / 72), [widthPx])
const ticks = useMemo(
() => timelineTicks(timeScale, startMs, endMs, maxTicks),
[timeScale, startMs, endMs, maxTicks],
)
const todayLine = useMemo(() => {
const now = Date.now()
if (now < startMs) return { x: 0, clamped: 'before' as const }
if (now > endMs) return { x: widthPx, clamped: 'after' as const }
return { x: msToX(now, startMs, endMs, widthPx), clamped: 'inside' as const }
}, [startMs, endMs, widthPx])
const scrollToToday = useCallback(() => {
const el = scrollRef.current
if (!el) return
const now = Date.now()
const clamped = Math.min(Math.max(now, startMs), endMs)
const x = msToX(clamped, startMs, endMs, widthPx)
el.scrollLeft = Math.max(0, x - el.clientWidth / 2 + 110)
}, [startMs, endMs, widthPx])
if (sprints.length === 0) {
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-5 py-8 text-center backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Gantt sprints
</h2>
<p className="mt-3 text-sm text-slate-400">
Aucun sprint actif ou futur renvoyé par le board Jira. Vérifiez{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_BOARD_ID</code> et
actualisez.
</p>
</section>
)
}
if (datedSprints.length === 0) {
return (
<section className="mb-10 rounded-2xl border border-amber-500/25 bg-amber-500/5 px-5 py-8 backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-amber-200/90">
Gantt sprints dates manquantes
</h2>
<p className="mx-auto mt-3 max-w-xl text-sm text-amber-100/90">
Jira na pas renvoyé de <code className="rounded bg-black/30 px-1 font-mono text-xs">startDate</code> /{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">endDate</code> pour ces sprints. Le
Gantt nécessite ces champs (sprints Scrum classiques).
</p>
{undatedSprints.length > 0 && (
<ul className="mx-auto mt-4 max-w-lg list-inside list-disc text-left text-xs text-amber-200/80">
{undatedSprints.map((s) => (
<li key={s.id}>
{s.name} ({s.state ?? '?'})
</li>
))}
</ul>
)}
</section>
)
}
const zoomFactor = GANTT_ZOOM_FACTORS[Math.max(0, Math.min(GANTT_ZOOM_FACTORS.length - 1, zoomIndex))]!
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-5 backdrop-blur-xl sm:px-6">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Gantt sprints & jalons
</h2>
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-slate-500">
Échelle <span className="text-slate-400">jour / semaine / mois</span> et zoom (loupe) : la
timeline sétire en pixels par jour faites défiler horizontalement. Barre = charge
(champ Sprint) ou avancement calendaire. Losanges = jalons (survol pour le détail).
</p>
</div>
<button
type="button"
onClick={onOpenSettings}
className="shrink-0 rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-500/20"
>
Réglages jalons / Sprint
</button>
</div>
{!sprintFieldId && (
<p className="mb-3 rounded-lg border border-sky-500/20 bg-sky-500/10 px-3 py-2 text-xs text-sky-100/90">
Sans champ Sprint, la barre reflète surtout l
<span className="font-medium text-sky-50">avancement temporel</span>. Ajoutez{' '}
<code className="rounded bg-black/30 px-1 font-mono">customfield_</code> pour la charge réelle.
</p>
)}
<div className="mb-3 flex flex-wrap items-center gap-2 border-b border-white/[0.08] pb-3">
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Échelle
</span>
{(['day', 'week', 'month'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setTimeScale(s)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
timeScale === s
? 'bg-cyan-500/25 text-cyan-100 ring-1 ring-cyan-400/50'
: 'bg-slate-900/80 text-slate-400 ring-1 ring-white/10 hover:text-white'
}`}
>
{SCALE_LABELS[s]}
</button>
))}
<span className="mx-1 hidden h-6 w-px bg-white/15 sm:inline-block" aria-hidden />
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">Zoom</span>
<button
type="button"
aria-label="Zoom arrière — voir plus de période"
title="Zoom arrière"
disabled={zoomIndex <= 0}
onClick={() => setZoomIndex((z) => Math.max(0, z - 1))}
className="inline-flex items-center justify-center rounded-lg border border-white/15 bg-slate-900/80 p-2 text-slate-300 transition hover:border-cyan-500/40 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
>
<IconLoupeMinus />
</button>
<span
className="min-w-[3rem] text-center font-mono text-[11px] text-slate-400"
title="Facteur de zoom sur la densité horizontale"
>
×{zoomFactor.toFixed(2)}
</span>
<button
type="button"
aria-label="Zoom avant — agrandir le détail"
title="Zoom avant"
disabled={zoomIndex >= GANTT_ZOOM_FACTORS.length - 1}
onClick={() => setZoomIndex((z) => Math.min(GANTT_ZOOM_FACTORS.length - 1, z + 1))}
className="inline-flex items-center justify-center rounded-lg border border-white/15 bg-slate-900/80 p-2 text-slate-300 transition hover:border-cyan-500/40 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
>
<IconLoupePlus />
</button>
<span className="ml-auto hidden text-[10px] text-slate-600 sm:inline">
{ppd.toFixed(1)} px/j · {Math.round(widthPx)} px
</span>
<button
type="button"
onClick={scrollToToday}
className="rounded-lg border border-emerald-500/35 bg-emerald-500/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wide text-emerald-100/95 hover:bg-emerald-500/20"
title="Fait défiler la timeline pour centrer la date du jour"
>
Centrer sur aujourdhui
</button>
<label className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
<span className="hidden sm:inline">Sous les barres</span>
<select
className="max-w-[11rem] rounded-lg border border-white/10 bg-slate-900/90 px-2 py-1 text-[11px] font-medium normal-case text-slate-200 outline-none"
value={ganttSprintRowMetric}
onChange={(e) => onGanttSprintRowMetricChange(e.target.value as GanttSprintRowMetric)}
title="Identique à loption dans Réglages"
>
{GANTT_SPRINT_METRIC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
</div>
<div
ref={scrollRef}
className="max-w-full overflow-x-auto overflow-y-visible rounded-xl border border-white/[0.08] bg-slate-950/50"
>
<div
className="grid"
style={{
gridTemplateColumns: `220px ${widthPx}px`,
width: `${220 + widthPx}px`,
}}
>
<div className="sticky left-0 z-30 border-b border-r border-white/[0.08] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm">
Piste
</div>
<div className="relative h-9 border-b border-white/[0.08] bg-slate-900/40">
{ticks.map((t) => {
const x = msToX(t.ms, startMs, endMs, widthPx)
return (
<span
key={t.ms}
className="absolute top-1 -translate-x-1/2 whitespace-nowrap text-[10px] text-slate-400"
style={{ left: `${x}px` }}
>
{t.label}
</span>
)
})}
</div>
<div className="sticky left-0 z-30 flex items-center border-b border-r border-white/[0.06] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-violet-300/90 backdrop-blur-sm">
Jalons
</div>
<div className="relative h-12 border-b border-white/[0.06] bg-slate-900/50">
{ticks.map((t) => (
<div
key={`jg-${t.ms}`}
className={`pointer-events-none absolute bottom-0 top-0 w-px ${
t.major ? 'bg-slate-500/50' : 'border-l border-dashed border-slate-600/40'
}`}
style={{ left: `${msToX(t.ms, startMs, endMs, widthPx)}px` }}
/>
))}
<div
className={`pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-emerald-400 shadow-[0_0_10px_rgba(52,211,153,0.65)] ${
todayLine.clamped !== 'inside' ? 'opacity-45' : ''
}`}
style={{ left: `${todayLine.x}px` }}
title={
todayLine.clamped === 'inside'
? 'Aujourdhui'
: todayLine.clamped === 'before'
? 'Aujourdhui (avant la période affichée)'
: 'Aujourdhui (après la période affichée)'
}
/>
{sortedMilestones.map((m) => {
const ms = parseIsoMs(`${m.date}T12:00:00`)
if (ms == null || ms < startMs || ms > endMs) return null
const x = msToX(ms, startMs, endMs, widthPx)
const left = Math.max(6, Math.min(widthPx - 6, x))
return (
<span
key={m.id}
role="img"
tabIndex={0}
title={milestoneTooltipText(m)}
className={`absolute top-1/2 z-20 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 cursor-help rotate-45 ring-2 ring-slate-950 ${milestoneKindMarkerClass(m.kind)}`}
style={{ left: `${left}px` }}
aria-label={m.title}
/>
)
})}
</div>
{datedSprints.map((s) => {
const b = sprintBarBounds(s)!
const x0 = msToX(b.startMs, startMs, endMs, widthPx)
const x1 = msToX(b.endMs, startMs, endMs, widthPx)
const barW = Math.max(10, x1 - x0)
const fill = sprintBarFillPercent(s, groups, sprintFieldId, cfg)
const delivery = epicScopeSprintProgress(groups, s.id, sprintFieldId, cfg)
const subtitleLines = formatGanttSprintSubtitleLines(
ganttSprintRowMetric,
s,
groups,
sprintFieldId,
cfg,
)
const barTitle =
sprintFieldId && delivery.total > 0
? `${s.name}\n${formatSprintRangeFr(s)}\nSous-tâches : ${delivery.done} / ${delivery.total} (${delivery.percent} %)\n${subtitleLines.join('\n')}`
: `${s.name}\n${formatSprintRangeFr(s)}\nAvancée calendaire : ${fill} %\n${subtitleLines.join('\n')}`
return (
<div key={s.id} className="contents">
<div className="sticky left-0 z-30 flex flex-col justify-center border-b border-r border-white/[0.06] bg-slate-950/95 px-2 py-2 text-xs backdrop-blur-sm">
<span className="font-medium text-slate-200">{s.name}</span>
<span className="mt-0.5 text-[10px] uppercase text-slate-500">{s.state ?? '—'}</span>
<span className="mt-0.5 font-mono text-[10px] text-slate-500">{formatSprintRangeFr(s)}</span>
{subtitleLines.map((line, i) => (
<span key={i} className="mt-0.5 font-mono text-[10px] leading-snug text-sky-200/85">
{line}
</span>
))}
</div>
<div className="relative min-h-[48px] border-b border-white/[0.06] bg-slate-900/30 py-2">
<div className="relative mx-0 h-10">
{ticks.map((t) => (
<div
key={`sg-${s.id}-${t.ms}`}
className="pointer-events-none absolute bottom-1 top-1 w-px bg-slate-700/35"
style={{ left: `${msToX(t.ms, startMs, endMs, widthPx)}px` }}
/>
))}
<div
className={`pointer-events-none absolute bottom-1 top-1 z-10 w-px bg-emerald-400/85 ${
todayLine.clamped !== 'inside' ? 'opacity-40' : ''
}`}
style={{ left: `${todayLine.x}px` }}
/>
<div
className="absolute bottom-1 top-1 overflow-hidden rounded-full bg-gradient-to-r from-slate-700/90 to-sky-950/50 ring-1 ring-sky-500/25"
style={{ left: `${x0}px`, width: `${barW}px` }}
title={barTitle}
>
<div
className="h-full rounded-l-full bg-gradient-to-r from-sky-600 via-sky-500 to-sky-400/90 shadow-[inset_0_0_12px_rgba(255,255,255,0.12)]"
style={{ width: `${fill}%` }}
/>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
{undatedSprints.length > 0 && (
<p className="mt-4 text-[11px] text-slate-500">
Sprints sans dates affichables : {undatedSprints.map((u) => u.name).join(', ')}.
</p>
)}
</section>
)
}

View File

@ -1,28 +1,86 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import type { StoryGroup } from '../types/jira' import type { StoryGroup } from '../types/jira'
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
import { fetchAllIssueKeysInSprint } from '../api/jiraClient'
import { useStatusBuckets } from '../context/StatusBucketContext' import { useStatusBuckets } from '../context/StatusBucketContext'
import { collectSprintOptions, filterGroupsBySprint } from '../lib/sprintExtract' import type { JiraSprintSnapshot } from '../lib/sprintExtract'
import {
collectSprintOptions,
filterGroupsBySprint,
filterGroupsBySprintIssueKeys,
sprintOptionsFromBoardAndGroups,
sprintOptionsFromBoardOnly,
} from '../lib/sprintExtract'
import { subtaskDoneRatioPercent } from '../lib/storyMetrics' import { subtaskDoneRatioPercent } from '../lib/storyMetrics'
import { StoryCard } from './StoryCard' import { StoryCard } from './StoryCard'
type Props = { type Props = {
groups: StoryGroup[] groups: StoryGroup[]
sprintFieldId: string | null sprintFieldId: string | null
/** Sprints actifs/futurs depuis lAPI Agile du board (sans élargir le périmètre JQL). */
boardSprintsFromApi?: JiraSprintSnapshot[]
onOpenSettings: () => void onOpenSettings: () => void
gapBadges?: FunctionalGapBadge[]
} }
export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) { export function SprintView({
groups,
sprintFieldId,
boardSprintsFromApi = [],
onOpenSettings,
gapBadges,
}: Props) {
const cfg = useStatusBuckets() const cfg = useStatusBuckets()
const options = useMemo(
() => collectSprintOptions(groups, sprintFieldId),
[groups, sprintFieldId],
)
const [focusId, setFocusId] = useState<number | 'all'>('all') const [focusId, setFocusId] = useState<number | 'all'>('all')
const [sprintIssueKeys, setSprintIssueKeys] = useState<Set<string>>(new Set())
const [sprintIssuesLoading, setSprintIssuesLoading] = useState(false)
const options = useMemo(() => {
if (boardSprintsFromApi.length > 0) {
if (sprintFieldId) {
const fromBoard = sprintOptionsFromBoardAndGroups(
boardSprintsFromApi,
groups,
sprintFieldId,
)
if (fromBoard.length > 0) return fromBoard
} else {
return sprintOptionsFromBoardOnly(boardSprintsFromApi)
}
}
if (sprintFieldId) return collectSprintOptions(groups, sprintFieldId)
return []
}, [boardSprintsFromApi, groups, sprintFieldId])
useEffect(() => {
if (sprintFieldId || focusId === 'all') {
setSprintIssueKeys(new Set())
setSprintIssuesLoading(false)
return
}
const sprintId = focusId as number
const ac = new AbortController()
setSprintIssuesLoading(true)
setSprintIssueKeys(new Set())
void fetchAllIssueKeysInSprint(sprintId, ac.signal)
.then((set) => {
if (!ac.signal.aborted) setSprintIssueKeys(set)
})
.catch(() => {
if (!ac.signal.aborted) setSprintIssueKeys(new Set())
})
.finally(() => {
if (!ac.signal.aborted) setSprintIssuesLoading(false)
})
return () => ac.abort()
}, [focusId, sprintFieldId])
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!sprintFieldId || focusId === 'all') return groups if (focusId === 'all') return groups
return filterGroupsBySprint(groups, focusId, sprintFieldId) if (sprintFieldId) return filterGroupsBySprint(groups, focusId, sprintFieldId)
}, [groups, focusId, sprintFieldId]) if (sprintIssuesLoading) return []
return filterGroupsBySprintIssueKeys(groups, sprintIssueKeys)
}, [groups, focusId, sprintFieldId, sprintIssueKeys, sprintIssuesLoading])
const stats = useMemo(() => { const stats = useMemo(() => {
let subs = 0 let subs = 0
@ -39,20 +97,23 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
return { stories: filtered.length, subtasks: subs, avgPct } return { stories: filtered.length, subtasks: subs, avgPct }
}, [filtered, cfg]) }, [filtered, cfg])
if (!sprintFieldId) { /** Aucune liste possible : ni board Agile, ni champ Sprint sur les tickets. */
const mustConfigureSprintField = options.length === 0 && !sprintFieldId && boardSprintsFromApi.length === 0
if (mustConfigureSprintField) {
return ( return (
<section className="mb-10 rounded-2xl border border-amber-500/25 bg-amber-500/5 px-5 py-8 text-center backdrop-blur-xl sm:px-8"> <section className="mb-10 rounded-2xl border border-amber-500/25 bg-amber-500/5 px-5 py-8 text-center backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-amber-200/90"> <h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-amber-200/90">
Vue Sprint configuration requise Vue Sprint configuration requise
</h2> </h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-amber-100/90"> <p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-amber-100/90">
Indiquez lidentifiant du champ personnalisé Sprint Jira (souvent{' '} Aucun sprint actif/futur na é reçu du board Agile, et le champ Sprint nest pas
renseigné. Indiquez lidentifiant du champ personnalisé Sprint (souvent{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">customfield_10020</code>) dans <code className="rounded bg-black/30 px-1 font-mono text-xs">customfield_10020</code>) dans
les réglages ou via la variable{' '} les réglages ou via{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_SPRINT_FIELD</code> dans <code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_SPRINT_FIELD</code>,
<code className="rounded bg-black/30 px-1 font-mono text-xs"> .env</code>, puis actualisez ou vérifiez le board (<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_BOARD_ID</code>
les données. LID se trouve dans Jira : Administration Issues Champs personnalisés ) puis actualisez.
Sprint.
</p> </p>
<button <button
type="button" type="button"
@ -72,16 +133,25 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
Vue Sprint Vue Sprint
</h2> </h2>
<p className="mt-3 text-sm text-slate-400"> <p className="mt-3 text-sm text-slate-400">
Aucun sprint détecté sur les tickets chargés (story ou sous-tâches). Vérifiez que le Aucun sprint actif ou futur sur le board, et aucun sprint détecté sur les tickets chargés.
champ configuré est bien le champ Sprint Scrum, et que vos tickets sont affectés à un
sprint dans Jira.
</p> </p>
</section> </section>
) )
} }
const filterModeAgile = !sprintFieldId && boardSprintsFromApi.length > 0
return ( return (
<section className="mb-10 space-y-6"> <section className="mb-10 space-y-6">
{filterModeAgile && (
<div className="rounded-xl border border-sky-500/25 bg-sky-500/10 px-4 py-3 text-xs leading-relaxed text-sky-100/95">
Filtre sprint via lAPI Agile Jira (issues du sprint). Pour afficher les pastilles sprint
sur chaque carte, renseignez aussi le champ Sprint (ID{' '}
<code className="rounded bg-black/30 px-1 font-mono text-[11px]">customfield_</code>) dans
les réglages.
</div>
)}
<div className="rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-4 backdrop-blur-xl sm:px-6"> <div className="rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-4 backdrop-blur-xl sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-end justify-between gap-4">
<div> <div>
@ -89,9 +159,18 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
Vue Sprint Vue Sprint
</h2> </h2>
<p className="mt-1 max-w-2xl text-xs text-slate-500"> <p className="mt-1 max-w-2xl text-xs text-slate-500">
Choisissez un sprint pour filtrer les stories (une story reste visible si la story ou {sprintFieldId ? (
une sous-tâche est dans le sprint). Les pastilles sprint sur chaque carte proviennent <>
des données Jira. Sprints actifs/futurs du board Jira Agile ; le nombre de stories compte le
périmètre chargé (épopée) avec le champ Sprint sur les tickets.
</>
) : (
<>
Sprints actifs/futurs du board (API Agile). Le filtre par sprint interroge les
issues du sprint côté Jira ; le périmètre affiché reste celui de lépopée Golden
Carbon.
</>
)}
</p> </p>
</div> </div>
<div className="flex min-w-[200px] flex-1 flex-col gap-1 sm:max-w-md"> <div className="flex min-w-[200px] flex-1 flex-col gap-1 sm:max-w-md">
@ -110,7 +189,8 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
{options.map((s) => ( {options.map((s) => (
<option key={s.id} value={String(s.id)}> <option key={s.id} value={String(s.id)}>
{s.name} {s.name}
{s.state ? ` (${s.state})` : ''} {s.storyCount} stories {s.state ? ` (${s.state})` : ''}
{sprintFieldId ? `${s.storyCount} stories` : ''}
</option> </option>
))} ))}
</select> </select>
@ -133,14 +213,24 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
</div> </div>
</div> </div>
{filtered.length === 0 ? ( {sprintIssuesLoading && focusId !== 'all' && !sprintFieldId ? (
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400"> <p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
Aucune story dans ce sprint pour le périmètre actuel (filtre « Ma vue » inclus). Chargement des tickets du sprint
</p>
) : filtered.length === 0 ? (
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
Aucune story du périmètre dans ce sprint (filtre « Ma vue » inclus), ou erreur lors du
chargement des issues du sprint.
</p> </p>
) : ( ) : (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{filtered.map((g) => ( {filtered.map((g) => (
<StoryCard key={g.story.key} group={g} sprintFieldId={sprintFieldId} /> <StoryCard
key={g.story.key}
group={g}
sprintFieldId={sprintFieldId}
gapBadges={gapBadges}
/>
))} ))}
</div> </div>
)} )}

View File

@ -1,6 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import type { PhaseId, StoryGroup } from '../types/jira' import type { PhaseId, StoryGroup } from '../types/jira'
import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase' import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase'
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
import { storyMatchesGapBadge } from '../lib/functionalGaps'
import { effectivePipelinePhase } from '../lib/pipelinePhase'
import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel' import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel'
import { stepperStates, subtaskDoneRatioPercent } from '../lib/storyMetrics' import { stepperStates, subtaskDoneRatioPercent } from '../lib/storyMetrics'
import { PhaseStepper } from './PhaseStepper' import { PhaseStepper } from './PhaseStepper'
@ -45,6 +48,8 @@ type Props = {
variant?: 'default' | 'board' variant?: 'default' | 'board'
/** Champ Sprint Jira : affiche les pastilles sur la carte (vue Sprint ou debug). */ /** Champ Sprint Jira : affiche les pastilles sur la carte (vue Sprint ou debug). */
sprintFieldId?: string | null sprintFieldId?: string | null
/** Badges décarts fonctionnels (Panier, Checkout…) selon les réglages. */
gapBadges?: FunctionalGapBadge[]
} }
function sprintChipClass(state?: string): string { function sprintChipClass(state?: string): string {
@ -67,13 +72,18 @@ function phaseChipClass(phase: PhaseId): string {
return map[phase] ?? `${base} bg-slate-500/15 text-slate-200 ring-slate-500/35` return map[phase] ?? `${base} bg-slate-500/15 text-slate-200 ring-slate-500/35`
} }
export function StoryCard({ group, variant = 'default', sprintFieldId = null }: Props) { export function StoryCard({
group,
variant = 'default',
sprintFieldId = null,
gapBadges,
}: Props) {
const cfg = useStatusBuckets() const cfg = useStatusBuckets()
const laneCfg = useLaneLabels() const laneCfg = useLaneLabels()
const { story, subtasks } = group const { story, subtasks } = group
const [subsOpen, setSubsOpen] = useState(true) const [subsOpen, setSubsOpen] = useState(true)
const progress = subtaskDoneRatioPercent(subtasks, cfg) const progress = subtaskDoneRatioPercent(subtasks, cfg)
const steps = stepperStates(subtasks, cfg) const steps = stepperStates(subtasks, cfg, laneCfg)
const band = priorityBand(story) const band = priorityBand(story)
const assignee = story.fields.assignee?.displayName const assignee = story.fields.assignee?.displayName
const blockerHint = blockingSummaryForTooltip(group, cfg, laneCfg) const blockerHint = blockingSummaryForTooltip(group, cfg, laneCfg)
@ -83,6 +93,7 @@ export function StoryCard({ group, variant = 'default', sprintFieldId = null }:
const remStory = getRemainingEstimateUnits(story) const remStory = getRemainingEstimateUnits(story)
const remSubs = subtasks.reduce((a, s) => a + getRemainingEstimateUnits(s), 0) const remSubs = subtasks.reduce((a, s) => a + getRemainingEstimateUnits(s), 0)
const sprintChips = sprintFieldId ? mergeSprintsForGroup(group, sprintFieldId) : [] const sprintChips = sprintFieldId ? mergeSprintsForGroup(group, sprintFieldId) : []
const matchedGaps = (gapBadges ?? []).filter((b) => storyMatchesGapBadge(group, b))
return ( return (
<article className="group flex flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-gradient-to-br from-white/[0.07] to-white/[0.02] shadow-[0_8px_40px_rgba(0,0,0,0.35)] backdrop-blur-xl transition hover:border-cyan-400/25 hover:shadow-[0_0_28px_rgba(34,211,238,0.08)]"> <article className="group flex flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-gradient-to-br from-white/[0.07] to-white/[0.02] shadow-[0_8px_40px_rgba(0,0,0,0.35)] backdrop-blur-xl transition hover:border-cyan-400/25 hover:shadow-[0_0_28px_rgba(34,211,238,0.08)]">
@ -103,6 +114,23 @@ export function StoryCard({ group, variant = 'default', sprintFieldId = null }:
<h3 className="mt-1.5 text-base font-semibold leading-snug text-white sm:text-lg"> <h3 className="mt-1.5 text-base font-semibold leading-snug text-white sm:text-lg">
{story.fields.summary} {story.fields.summary}
</h3> </h3>
{matchedGaps.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{matchedGaps.map((b) => (
<span
key={b.id}
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset ${
b.criticalFlow
? 'bg-rose-500/20 text-rose-100 ring-rose-500/45'
: 'bg-amber-500/15 text-amber-100 ring-amber-500/40'
}`}
title={`Écart fonctionnel (termes : ${b.terms.join(', ')})`}
>
{b.label}
</span>
))}
</div>
)}
{sprintChips.length > 0 && ( {sprintChips.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{sprintChips.map((sp) => ( {sprintChips.map((sp) => (
@ -190,7 +218,7 @@ export function StoryCard({ group, variant = 'default', sprintFieldId = null }:
}`} }`}
> >
{subtasks.map((st) => { {subtasks.map((st) => {
const ph = statusToPhase(st.fields.status.name) const ph = effectivePipelinePhase(st, cfg, laneCfg)
return ( return (
<li <li
key={st.key} key={st.key}

33
src/lib/assigneeRadar.ts Normal file
View 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)
}

View File

@ -53,6 +53,102 @@ export function sanitizeMilestonesArray(arr: unknown): Milestone[] {
return arr.map(sanitizeMilestone).filter((m): m is Milestone => m != null) 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
}
/** À lenregistrement 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 = { export type DashboardConfig = {
version: 1 version: 1
milestones: Milestone[] milestones: Milestone[]
@ -72,6 +168,12 @@ export type DashboardConfig = {
myViewActive?: boolean myViewActive?: boolean
/** ID du champ Jira « Sprint » (ex. customfield_10020) — prioritaire sur VITE_JIRA_SPRINT_FIELD. */ /** ID du champ Jira « Sprint » (ex. customfield_10020) — prioritaire sur VITE_JIRA_SPRINT_FIELD. */
sprintFieldId?: string 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 dinfo sous les barres de sprint (Gantt). */
ganttSprintRowMetric: GanttSprintRowMetric
} }
const STORAGE_KEY = 'dcc-dashboard-config-v1' const STORAGE_KEY = 'dcc-dashboard-config-v1'
@ -84,8 +186,25 @@ export const defaultDashboardConfig = (): DashboardConfig => ({
teamCapacity: 3, teamCapacity: 3,
baselineCapacity: 3, baselineCapacity: 3,
wipSlotsPerDev: 5, 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 { export function loadDashboardConfig(): DashboardConfig {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
@ -124,6 +243,9 @@ export function loadDashboardConfig(): DashboardConfig {
typeof parsed.sprintFieldId === 'string' && parsed.sprintFieldId.trim() typeof parsed.sprintFieldId === 'string' && parsed.sprintFieldId.trim()
? parsed.sprintFieldId.trim() ? parsed.sprintFieldId.trim()
: undefined, : undefined,
functionalGaps: sanitizeFunctionalGapsArray(parsed.functionalGaps, defaultFunctionalGaps()),
excludedSprintIds: sanitizeExcludedSprintIds(parsed.excludedSprintIds),
ganttSprintRowMetric: sanitizeGanttSprintRowMetric(parsed.ganttSprintRowMetric),
} }
} catch { } catch {
return defaultDashboardConfig() return defaultDashboardConfig()
@ -144,13 +266,30 @@ export function exportConfigJson(cfg: DashboardConfig): void {
URL.revokeObjectURL(url) 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( export function mergeImportedConfig(
current: DashboardConfig, current: DashboardConfig,
imported: unknown, imported: unknown,
): DashboardConfig | null { ): DashboardConfig | null {
if (!imported || typeof imported !== 'object') return null const o = extractDashboardPayload(imported)
const o = imported as Partial<DashboardConfig> if (!o) return null
if (o.version !== 1) return null
return { return {
version: 1, version: 1,
milestones: Array.isArray(o.milestones) ? sanitizeMilestonesArray(o.milestones) : current.milestones, milestones: Array.isArray(o.milestones) ? sanitizeMilestonesArray(o.milestones) : current.milestones,
@ -183,5 +322,12 @@ export function mergeImportedConfig(
? o.sprintFieldId.trim() ? o.sprintFieldId.trim()
: undefined : undefined
: current.sprintFieldId, : 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
View 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),
}
})
}

View File

@ -19,11 +19,18 @@ function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): J
'assignee', 'assignee',
'timetracking', 'timetracking',
'subtasks', 'subtasks',
'labels',
]) ])
const extras: Record<string, unknown> = {} const extras: Record<string, unknown> = {}
for (const [k, v] of Object.entries(f)) { for (const [k, v] of Object.entries(f)) {
if (!skip.has(k)) extras[k] = v 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 { return {
id: emb.id, id: emb.id,
key: emb.key, key: emb.key,
@ -35,6 +42,7 @@ function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): J
? issuetype ? issuetype
: { name: 'Sous-tâche', subtask: true }, : { name: 'Sous-tâche', subtask: true },
parent: { key: parentKey }, parent: { key: parentKey },
labels,
components: f.components as JiraIssue['fields']['components'], components: f.components as JiraIssue['fields']['components'],
priority: (f.priority as JiraIssue['fields']['priority']) ?? null, priority: (f.priority as JiraIssue['fields']['priority']) ?? null,
assignee: (f.assignee as JiraIssue['fields']['assignee']) ?? null, assignee: (f.assignee as JiraIssue['fields']['assignee']) ?? null,

View File

@ -10,3 +10,19 @@ export function resolveSprintFieldId(cfg: Pick<DashboardConfig, 'sprintFieldId'>
const fromEnv = import.meta.env.VITE_JIRA_SPRINT_FIELD?.trim() const fromEnv = import.meta.env.VITE_JIRA_SPRINT_FIELD?.trim()
return fromEnv || null return fromEnv || null
} }
/**
* Board logiciel Jira (ex. URL `.../boards/1445/`) pour lAPI Agile `.../board/{id}/sprint`.
* Sert à lister les sprints **actifs** et **futurs** sans élargir le JQL du cockpit.
* `0` ou `false` = désactiver lappel (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
}

View 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 lintégration (piste I) est encore ouverte
* alors que létape Design du stepper nest 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 dintégration active tant que le design nest 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,
}
}

View File

@ -1,11 +1,17 @@
import type { PhaseId, StoryGroup } from '../types/jira' 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 } const acc: Record<PhaseId, number> = { analyse: 0, design: 0, integration: 0, done: 0 }
for (const g of groups) { for (const g of groups) {
for (const s of g.subtasks) { for (const s of g.subtasks) {
const p = statusToPhase(s.fields.status.name) const p = effectivePipelinePhase(s, bucketCfg, laneCfg)
acc[p] += 1 acc[p] += 1
} }
} }

19
src/lib/pipelinePhase.ts Normal file
View 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
View File

@ -0,0 +1,19 @@
import type { LandingEstimate } from './executiveLanding'
/** Compare latterrissage 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).`,
}
}

View File

@ -11,15 +11,22 @@ export type JiraSprintSnapshot = {
goal?: string 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 id = Number(o.id)
const name = typeof o.name === 'string' ? o.name.trim() : '' const name = typeof o.name === 'string' ? o.name.trim() : ''
if (!Number.isFinite(id) || !name) return null 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 { return {
id, id,
name, name,
state: typeof o.state === 'string' ? o.state : undefined, 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, startDate: typeof o.startDate === 'string' ? o.startDate : undefined,
endDate: typeof o.endDate === 'string' ? o.endDate : undefined, endDate: typeof o.endDate === 'string' ? o.endDate : undefined,
goal: typeof o.goal === 'string' ? o.goal : undefined, goal: typeof o.goal === 'string' ? o.goal : undefined,
@ -115,3 +122,75 @@ export function filterGroupsBySprint(
if (!fieldId || sprintId == null) return groups if (!fieldId || sprintId == null) return groups
return groups.filter((g) => groupInSprint(g, sprintId, fieldId)) 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 nest 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 lAPI 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
View 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 daffichage 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 aujourdhui). */
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 aujourdhui'
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 ?? '—']
}
}
}

View File

@ -1,7 +1,9 @@
import type { JiraIssue, PhaseId } from '../types/jira' 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 type { StatusBucketConfig } from './statusBuckets'
import { isIssueCanceled, isIssueDone } from './statusBuckets' import { isIssueCanceled, isIssueDone } from './statusBuckets'
import { effectivePipelinePhase } from './pipelinePhase'
function phaseRank(p: PhaseId): number { function phaseRank(p: PhaseId): number {
const i = PHASE_ORDER.indexOf(p) const i = PHASE_ORDER.indexOf(p)
@ -14,10 +16,14 @@ const MAX_PHASE_RANK = PHASE_ORDER.length - 1
* % davancement moyen des sous-tâches sur le pipeline Analyse → Design → Intégration → Terminé * % davancement moyen des sous-tâches sur le pipeline Analyse → Design → Intégration → Terminé
* (0 % = tout en analyse, 100 % = tout 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 if (subtasks.length === 0) return 0
const sum = subtasks.reduce( const sum = subtasks.reduce(
(acc, st) => acc + phaseRank(statusToPhase(st.fields.status.name)), (acc, st) => acc + phaseRank(effectivePipelinePhase(st, bucketCfg, laneCfg)),
0, 0,
) )
return Math.round((sum / (subtasks.length * MAX_PHASE_RANK)) * 100) 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 * É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). * (é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 if (subtasks.length === 0) return false
const stepIdx = phaseRank(stepPhase) 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( export function stepperStates(
subtasks: JiraIssue[], subtasks: JiraIssue[],
cfg: StatusBucketConfig, cfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): Record<PhaseId, boolean> { ): Record<PhaseId, boolean> {
return { return {
analyse: isStepComplete(subtasks, 'analyse'), analyse: isStepComplete(subtasks, 'analyse', cfg, laneCfg),
design: isStepComplete(subtasks, 'design'), design: isStepComplete(subtasks, 'design', cfg, laneCfg),
integration: isStepComplete(subtasks, 'integration'), integration: isStepComplete(subtasks, 'integration', cfg, laneCfg),
done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)), done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)),
} }
} }

4
src/vite-env.d.ts vendored
View File

@ -14,6 +14,10 @@ interface ImportMetaEnv {
readonly VITE_JIRA_EPIC_KEY?: string readonly VITE_JIRA_EPIC_KEY?: string
/** Taille de page `/search/jql` (1100, défaut 100). Sans maxResults, Jira renvoie souvent 20 issues. */ /** Taille de page `/search/jql` (1100, défaut 100). Sans maxResults, Jira renvoie souvent 20 issues. */
readonly VITE_JIRA_PAGE_SIZE?: string readonly VITE_JIRA_PAGE_SIZE?: string
/** ID du board logiciel Jira pour lister les sprints actifs/futurs (API Agile). 0 = désactiver. */
readonly VITE_JIRA_BOARD_ID?: string
/** Champ Sprint Scrum (ex. customfield_10020). */
readonly VITE_JIRA_SPRINT_FIELD?: string
} }
interface ImportMeta { interface ImportMeta {