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
# 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
# VITE_JIRA_EPIC_KEY=DCC-5514

View File

@ -1,6 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
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 { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory'
import { isIssueDone } from './lib/statusBuckets'
@ -15,9 +21,12 @@ import {
} from './lib/executiveHealth'
import { countSubtasksByPhase } from './lib/phaseAggregate'
import {
exportSynologyBackupJson,
loadDashboardConfig,
sanitizeGanttSprintRowMetric,
saveDashboardConfig,
type DashboardConfig,
type GanttSprintRowMetric,
} from './lib/dashboardConfig'
import { assigneeMatchesMyView } from './lib/assigneeMatch'
import { isAxiosError } from 'axios'
@ -31,15 +40,18 @@ import { DashboardSettingsModal } from './components/DashboardSettingsModal'
import { ManagementOverview } from './components/ManagementOverview'
import { PhaseDistributionChart } from './components/PhaseDistributionChart'
import { ExportDashboardButton } from './components/ExportDashboardButton'
import { MacroCockpitStrip } from './components/MacroCockpitStrip'
import { StatusBucketProvider } from './context/StatusBucketContext'
import { LaneLabelsProvider } from './context/LaneLabelsContext'
import { PipelineOverview } from './components/PipelineOverview'
import { LaneTicketsListView } from './components/LaneTicketsListView'
import { ProjectTimelineView } from './components/ProjectTimelineView'
import { SprintGanttView } from './components/SprintGanttView'
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() {
const dashboardRef = useRef<HTMLDivElement>(null)
@ -51,6 +63,7 @@ export default function App() {
const [burnupData, setBurnupData] = useState<BurnupPoint[]>(() => loadBurnupHistory())
const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig())
const [settingsOpen, setSettingsOpen] = useState(false)
const [boardSprints, setBoardSprints] = useState<JiraSprintSnapshot[]>([])
const statusBucketsRef = useRef(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 next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive }
@ -146,9 +176,22 @@ export default function App() {
setError(null)
try {
const sprintField = resolveSprintFieldId({ sprintFieldId: dashboardCfg.sprintFieldId })
const issues = await fetchAllIssuesByJql(MIGRATION_JQL, signal, {
additionalFields: sprintField ? [sprintField] : [],
})
const boardId = resolveJiraBoardId()
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)
setGroups(grouped)
setUpdatedAt(new Date())
@ -177,6 +220,7 @@ export default function App() {
setError(e instanceof Error ? e.message : 'Erreur inconnue')
}
setGroups([])
setBoardSprints([])
} finally {
if (!signal?.aborted) setLoading(false)
}
@ -266,6 +310,18 @@ export default function App() {
>
Projet
</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
type="button"
onClick={() => setView('sprint')}
@ -281,7 +337,17 @@ export default function App() {
</div>
<div className="flex flex-wrap items-center gap-3">
{!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 && (
<span className="text-xs text-slate-500">
@ -329,20 +395,63 @@ export default function App() {
{!loading && !error && groups.length > 0 && (
<div ref={dashboardRef} className="space-y-10">
{view === 'project' ? (
<ProjectTimelineView
milestones={dashboardCfg.milestones}
groups={groups}
velocityPerCalendarDay={landing.effectiveVelocityPerDay}
onOpenSettings={() => setSettingsOpen(true)}
/>
<div className="space-y-10">
<MacroCockpitStrip
groups={groups}
dashboardCfg={dashboardCfg}
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' ? (
<SprintView
groups={displayGroups}
sprintFieldId={sprintFieldResolved}
onOpenSettings={() => setSettingsOpen(true)}
/>
<div className="space-y-10">
<MacroCockpitStrip
groups={groups}
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
milestones={dashboardCfg.milestones}
groups={groups}
@ -399,11 +508,16 @@ export default function App() {
key={g.story.key}
group={g}
sprintFieldId={sprintFieldResolved}
gapBadges={dashboardCfg.functionalGaps}
/>
))}
</div>
) : (
<BoardView groups={displayGroups} sprintFieldId={sprintFieldResolved} />
<BoardView
groups={displayGroups}
sprintFieldId={sprintFieldResolved}
gapBadges={dashboardCfg.functionalGaps}
/>
)}
</section>
</>
@ -417,6 +531,7 @@ export default function App() {
config={dashboardCfg}
onClose={() => setSettingsOpen(false)}
onSave={saveSettings}
boardSprints={boardSprints}
/>
</div>
</LaneLabelsProvider>

View File

@ -1,5 +1,6 @@
import axios from 'axios'
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
@ -155,3 +156,88 @@ export async function fetchAllIssuesByJql(
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 { FunctionalGapBadge } from '../lib/dashboardConfig'
import { groupStoriesByComponent } from '../lib/boardGrouping'
import { StoryCard } from './StoryCard'
type Props = {
groups: StoryGroup[]
sprintFieldId?: string | null
gapBadges?: FunctionalGapBadge[]
}
export function BoardView({ groups, sprintFieldId = null }: Props) {
export function BoardView({ groups, sprintFieldId = null, gapBadges }: Props) {
const columns = groupStoriesByComponent(groups)
return (
@ -25,7 +27,13 @@ export function BoardView({ groups, sprintFieldId = null }: Props) {
</div>
<div className="flex flex-col gap-3">
{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>

View File

@ -1,13 +1,20 @@
import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react'
import {
exportConfigJson,
exportSynologyBackupJson,
GANTT_SPRINT_METRIC_OPTIONS,
mergeImportedConfig,
normalizeFunctionalGapsForSave,
sanitizeExcludedSprintIds,
type DashboardConfig,
type FunctionalGapBadge,
type GanttSprintRowMetric,
type LaneLabelsConfig,
type Milestone,
type MilestoneKind,
type StatusBucketConfig,
} from '../lib/dashboardConfig'
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
import { MILESTONE_KIND_OPTIONS } from '../lib/milestoneKinds'
function parseBucketLines(raw: string): string[] {
@ -61,6 +68,8 @@ type Props = {
config: DashboardConfig
onClose: () => void
onSave: (next: DashboardConfig) => void
/** Sprints board (API) pour masquage sélectif ; optionnel si pas encore chargés. */
boardSprints?: JiraSprintSnapshot[]
}
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 fileRef = useRef<HTMLInputElement>(null)
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 merged = mergeImportedConfig(draft, parsed)
if (merged) setDraft(merged)
else alert('Fichier JSON invalide (version 1 attendue).')
else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).')
} catch {
alert('Impossible de lire ce fichier JSON.')
}
@ -322,6 +350,159 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
</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 className="mb-2 flex items-center justify-between">
<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)
</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
type="button"
onClick={() => fileRef.current?.click()}
@ -456,7 +645,11 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
<button
type="button"
onClick={() => {
onSave(draft)
onSave({
...draft,
functionalGaps: normalizeFunctionalGapsForSave(draft.functionalGaps),
excludedSprintIds: sanitizeExcludedSprintIds(draft.excludedSprintIds),
})
onClose()
}}
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 { FunctionalGapBadge } from '../lib/dashboardConfig'
import { fetchAllIssueKeysInSprint } from '../api/jiraClient'
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 { StoryCard } from './StoryCard'
type Props = {
groups: StoryGroup[]
sprintFieldId: string | null
/** Sprints actifs/futurs depuis lAPI Agile du board (sans élargir le périmètre JQL). */
boardSprintsFromApi?: JiraSprintSnapshot[]
onOpenSettings: () => void
gapBadges?: FunctionalGapBadge[]
}
export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
export function SprintView({
groups,
sprintFieldId,
boardSprintsFromApi = [],
onOpenSettings,
gapBadges,
}: Props) {
const cfg = useStatusBuckets()
const options = useMemo(
() => collectSprintOptions(groups, sprintFieldId),
[groups, sprintFieldId],
)
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(() => {
if (!sprintFieldId || focusId === 'all') return groups
return filterGroupsBySprint(groups, focusId, sprintFieldId)
}, [groups, focusId, sprintFieldId])
if (focusId === 'all') return groups
if (sprintFieldId) return filterGroupsBySprint(groups, focusId, sprintFieldId)
if (sprintIssuesLoading) return []
return filterGroupsBySprintIssueKeys(groups, sprintIssueKeys)
}, [groups, focusId, sprintFieldId, sprintIssueKeys, sprintIssuesLoading])
const stats = useMemo(() => {
let subs = 0
@ -39,20 +97,23 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
return { stories: filtered.length, subtasks: subs, avgPct }
}, [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 (
<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">
Vue Sprint configuration requise
</h2>
<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
les réglages ou via la variable{' '}
<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"> .env</code>, puis actualisez
les données. LID se trouve dans Jira : Administration Issues Champs personnalisés
Sprint.
les réglages ou via{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_SPRINT_FIELD</code>,
ou vérifiez le board (<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_BOARD_ID</code>
) puis actualisez.
</p>
<button
type="button"
@ -72,16 +133,25 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
Vue Sprint
</h2>
<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
champ configuré est bien le champ Sprint Scrum, et que vos tickets sont affectés à un
sprint dans Jira.
Aucun sprint actif ou futur sur le board, et aucun sprint détecté sur les tickets chargés.
</p>
</section>
)
}
const filterModeAgile = !sprintFieldId && boardSprintsFromApi.length > 0
return (
<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="flex flex-wrap items-end justify-between gap-4">
<div>
@ -89,9 +159,18 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
Vue Sprint
</h2>
<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
une sous-tâche est dans le sprint). Les pastilles sprint sur chaque carte proviennent
des données Jira.
{sprintFieldId ? (
<>
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>
</div>
<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) => (
<option key={s.id} value={String(s.id)}>
{s.name}
{s.state ? ` (${s.state})` : ''} {s.storyCount} stories
{s.state ? ` (${s.state})` : ''}
{sprintFieldId ? `${s.storyCount} stories` : ''}
</option>
))}
</select>
@ -133,14 +213,24 @@ export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
</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">
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>
) : (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{filtered.map((g) => (
<StoryCard key={g.story.key} group={g} sprintFieldId={sprintFieldId} />
<StoryCard
key={g.story.key}
group={g}
sprintFieldId={sprintFieldId}
gapBadges={gapBadges}
/>
))}
</div>
)}

View File

@ -1,6 +1,9 @@
import { useState } from 'react'
import type { PhaseId, StoryGroup } from '../types/jira'
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 { stepperStates, subtaskDoneRatioPercent } from '../lib/storyMetrics'
import { PhaseStepper } from './PhaseStepper'
@ -45,6 +48,8 @@ type Props = {
variant?: 'default' | 'board'
/** Champ Sprint Jira : affiche les pastilles sur la carte (vue Sprint ou debug). */
sprintFieldId?: string | null
/** Badges décarts fonctionnels (Panier, Checkout…) selon les réglages. */
gapBadges?: FunctionalGapBadge[]
}
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`
}
export function StoryCard({ group, variant = 'default', sprintFieldId = null }: Props) {
export function StoryCard({
group,
variant = 'default',
sprintFieldId = null,
gapBadges,
}: Props) {
const cfg = useStatusBuckets()
const laneCfg = useLaneLabels()
const { story, subtasks } = group
const [subsOpen, setSubsOpen] = useState(true)
const progress = subtaskDoneRatioPercent(subtasks, cfg)
const steps = stepperStates(subtasks, cfg)
const steps = stepperStates(subtasks, cfg, laneCfg)
const band = priorityBand(story)
const assignee = story.fields.assignee?.displayName
const blockerHint = blockingSummaryForTooltip(group, cfg, laneCfg)
@ -83,6 +93,7 @@ export function StoryCard({ group, variant = 'default', sprintFieldId = null }:
const remStory = getRemainingEstimateUnits(story)
const remSubs = subtasks.reduce((a, s) => a + getRemainingEstimateUnits(s), 0)
const sprintChips = sprintFieldId ? mergeSprintsForGroup(group, sprintFieldId) : []
const matchedGaps = (gapBadges ?? []).filter((b) => storyMatchesGapBadge(group, b))
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)]">
@ -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">
{story.fields.summary}
</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 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{sprintChips.map((sp) => (
@ -190,7 +218,7 @@ export function StoryCard({ group, variant = 'default', sprintFieldId = null }:
}`}
>
{subtasks.map((st) => {
const ph = statusToPhase(st.fields.status.name)
const ph = effectivePipelinePhase(st, cfg, laneCfg)
return (
<li
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)
}
/** 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 = {
version: 1
milestones: Milestone[]
@ -72,6 +168,12 @@ export type DashboardConfig = {
myViewActive?: boolean
/** ID du champ Jira « Sprint » (ex. customfield_10020) — prioritaire sur VITE_JIRA_SPRINT_FIELD. */
sprintFieldId?: string
/** Badges décarts fonctionnels (vue produit / pages critiques). */
functionalGaps: FunctionalGapBadge[]
/** IDs sprint Jira (API Agile) masqués dans le Gantt et la vue Sprint. */
excludedSprintIds: number[]
/** Lignes dinfo sous les barres de sprint (Gantt). */
ganttSprintRowMetric: GanttSprintRowMetric
}
const STORAGE_KEY = 'dcc-dashboard-config-v1'
@ -84,8 +186,25 @@ export const defaultDashboardConfig = (): DashboardConfig => ({
teamCapacity: 3,
baselineCapacity: 3,
wipSlotsPerDev: 5,
functionalGaps: defaultFunctionalGaps(),
excludedSprintIds: [],
ganttSprintRowMetric: defaultGanttSprintRowMetric(),
})
/** Import : configuration seule, ou bundle Synology `{ bundleVersion, dashboard }`. */
export function extractDashboardPayload(imported: unknown): Partial<DashboardConfig> | null {
if (!imported || typeof imported !== 'object') return null
const o = imported as Record<string, unknown>
if (o.bundleVersion === 1 && o.dashboard && typeof o.dashboard === 'object') {
const inner = o.dashboard as Partial<DashboardConfig>
if (inner.version !== 1) return null
return inner
}
const flat = imported as Partial<DashboardConfig>
if (flat.version !== 1) return null
return flat
}
export function loadDashboardConfig(): DashboardConfig {
try {
const raw = localStorage.getItem(STORAGE_KEY)
@ -124,6 +243,9 @@ export function loadDashboardConfig(): DashboardConfig {
typeof parsed.sprintFieldId === 'string' && parsed.sprintFieldId.trim()
? parsed.sprintFieldId.trim()
: undefined,
functionalGaps: sanitizeFunctionalGapsArray(parsed.functionalGaps, defaultFunctionalGaps()),
excludedSprintIds: sanitizeExcludedSprintIds(parsed.excludedSprintIds),
ganttSprintRowMetric: sanitizeGanttSprintRowMetric(parsed.ganttSprintRowMetric),
}
} catch {
return defaultDashboardConfig()
@ -144,13 +266,30 @@ export function exportConfigJson(cfg: DashboardConfig): void {
URL.revokeObjectURL(url)
}
/** Sauvegarde JSON enrichie pour NAS Synology (Docker / volume monté) : horodatage + enveloppe versionnée. */
export function exportSynologyBackupJson(cfg: DashboardConfig): void {
const bundle = {
bundleVersion: 1 as const,
exportedAt: new Date().toISOString(),
app: 'dcc-migration-cockpit' as const,
dashboard: cfg,
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
a.download = `dcc-synology-backup-${stamp}.json`
a.click()
URL.revokeObjectURL(url)
}
export function mergeImportedConfig(
current: DashboardConfig,
imported: unknown,
): DashboardConfig | null {
if (!imported || typeof imported !== 'object') return null
const o = imported as Partial<DashboardConfig>
if (o.version !== 1) return null
const o = extractDashboardPayload(imported)
if (!o) return null
return {
version: 1,
milestones: Array.isArray(o.milestones) ? sanitizeMilestonesArray(o.milestones) : current.milestones,
@ -183,5 +322,12 @@ export function mergeImportedConfig(
? o.sprintFieldId.trim()
: undefined
: current.sprintFieldId,
functionalGaps: sanitizeFunctionalGapsArray(o.functionalGaps, current.functionalGaps),
excludedSprintIds:
o.excludedSprintIds !== undefined ? sanitizeExcludedSprintIds(o.excludedSprintIds) : current.excludedSprintIds,
ganttSprintRowMetric:
o.ganttSprintRowMetric !== undefined
? sanitizeGanttSprintRowMetric(o.ganttSprintRowMetric)
: current.ganttSprintRowMetric,
}
}

60
src/lib/functionalGaps.ts Normal file
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',
'timetracking',
'subtasks',
'labels',
])
const extras: Record<string, unknown> = {}
for (const [k, v] of Object.entries(f)) {
if (!skip.has(k)) extras[k] = v
}
const labelsRaw = f.labels
const labels =
Array.isArray(labelsRaw) && labelsRaw.length > 0
? labelsRaw.filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
: undefined
return {
id: emb.id,
key: emb.key,
@ -35,6 +42,7 @@ function embeddedChildToIssue(parentKey: string, emb: JiraEmbeddedChildIssue): J
? issuetype
: { name: 'Sous-tâche', subtask: true },
parent: { key: parentKey },
labels,
components: f.components as JiraIssue['fields']['components'],
priority: (f.priority as JiraIssue['fields']['priority']) ?? null,
assignee: (f.assignee as JiraIssue['fields']['assignee']) ?? null,

View File

@ -10,3 +10,19 @@ export function resolveSprintFieldId(cfg: Pick<DashboardConfig, 'sprintFieldId'>
const fromEnv = import.meta.env.VITE_JIRA_SPRINT_FIELD?.trim()
return fromEnv || null
}
/**
* Board logiciel Jira (ex. URL `.../boards/1445/`) pour 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 { statusToPhase } from './statusPhase'
import type { LaneLabelsConfig } from './laneDetection'
import type { StatusBucketConfig } from './statusBuckets'
import { effectivePipelinePhase } from './pipelinePhase'
export function countSubtasksByPhase(groups: StoryGroup[]): Record<PhaseId, number> {
export function countSubtasksByPhase(
groups: StoryGroup[],
bucketCfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): Record<PhaseId, number> {
const acc: Record<PhaseId, number> = { analyse: 0, design: 0, integration: 0, done: 0 }
for (const g of groups) {
for (const s of g.subtasks) {
const p = statusToPhase(s.fields.status.name)
const p = effectivePipelinePhase(s, bucketCfg, laneCfg)
acc[p] += 1
}
}

19
src/lib/pipelinePhase.ts Normal file
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
}
function coerceSprintObject(o: Record<string, unknown>): JiraSprintSnapshot | null {
export function coerceSprintObject(o: Record<string, unknown>): JiraSprintSnapshot | null {
const id = Number(o.id)
const name = typeof o.name === 'string' ? o.name.trim() : ''
if (!Number.isFinite(id) || !name) return null
const boardIdRaw = o.boardId ?? o.originBoardId
const boardId =
typeof boardIdRaw === 'number' && Number.isFinite(boardIdRaw)
? boardIdRaw
: typeof boardIdRaw === 'string' && Number.isFinite(Number(boardIdRaw))
? Number(boardIdRaw)
: undefined
return {
id,
name,
state: typeof o.state === 'string' ? o.state : undefined,
boardId: typeof o.boardId === 'number' ? o.boardId : undefined,
boardId,
startDate: typeof o.startDate === 'string' ? o.startDate : undefined,
endDate: typeof o.endDate === 'string' ? o.endDate : undefined,
goal: typeof o.goal === 'string' ? o.goal : undefined,
@ -115,3 +122,75 @@ export function filterGroupsBySprint(
if (!fieldId || sprintId == null) return groups
return groups.filter((g) => groupInSprint(g, sprintId, fieldId))
}
/** Filtre par clés issues (ex. issues renvoyées par `GET .../sprint/{id}/issue`). */
export function filterGroupsBySprintIssueKeys(groups: StoryGroup[], issueKeys: Set<string>): StoryGroup[] {
if (issueKeys.size === 0) return []
return groups.filter(
(g) =>
issueKeys.has(g.story.key) || g.subtasks.some((st) => issueKeys.has(st.key)),
)
}
function sprintOrderRank(state?: string): number {
const s = (state ?? '').toLowerCase()
if (s === 'active') return 0
if (s === 'future') return 1
if (s === 'closed') return 2
return 3
}
/** Sprints board uniquement (`storyCount` = 0 tant que le champ Sprint 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 { PHASE_ORDER, statusToPhase } from './statusPhase'
import type { LaneLabelsConfig } from './laneDetection'
import { PHASE_ORDER } from './statusPhase'
import type { StatusBucketConfig } from './statusBuckets'
import { isIssueCanceled, isIssueDone } from './statusBuckets'
import { effectivePipelinePhase } from './pipelinePhase'
function phaseRank(p: PhaseId): number {
const i = PHASE_ORDER.indexOf(p)
@ -14,10 +16,14 @@ const MAX_PHASE_RANK = PHASE_ORDER.length - 1
* % davancement moyen des sous-tâches sur le pipeline Analyse → Design → Intégration → Terminé
* (0 % = tout en analyse, 100 % = tout terminé).
*/
export function storyProgressPercent(subtasks: JiraIssue[]): number {
export function storyProgressPercent(
subtasks: JiraIssue[],
bucketCfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): number {
if (subtasks.length === 0) return 0
const sum = subtasks.reduce(
(acc, st) => acc + phaseRank(statusToPhase(st.fields.status.name)),
(acc, st) => acc + phaseRank(effectivePipelinePhase(st, bucketCfg, laneCfg)),
0,
)
return Math.round((sum / (subtasks.length * MAX_PHASE_RANK)) * 100)
@ -27,10 +33,17 @@ export function storyProgressPercent(subtasks: JiraIssue[]): number {
* Étape A/D/I « passée » : toutes les sous-tâches sont **strictement** au-delà de cette phase
* (évite les barres vertes alors que tout est encore en analyse / ouvert).
*/
export function isStepComplete(subtasks: JiraIssue[], stepPhase: PhaseId): boolean {
export function isStepComplete(
subtasks: JiraIssue[],
stepPhase: PhaseId,
bucketCfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): boolean {
if (subtasks.length === 0) return false
const stepIdx = phaseRank(stepPhase)
return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx)
return subtasks.every(
(st) => phaseRank(effectivePipelinePhase(st, bucketCfg, laneCfg)) > stepIdx,
)
}
/**
@ -50,11 +63,12 @@ export function subtaskDoneRatioPercent(
export function stepperStates(
subtasks: JiraIssue[],
cfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): Record<PhaseId, boolean> {
return {
analyse: isStepComplete(subtasks, 'analyse'),
design: isStepComplete(subtasks, 'design'),
integration: isStepComplete(subtasks, 'integration'),
analyse: isStepComplete(subtasks, 'analyse', cfg, laneCfg),
design: isStepComplete(subtasks, 'design', cfg, laneCfg),
integration: isStepComplete(subtasks, 'integration', cfg, laneCfg),
done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)),
}
}

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

@ -14,6 +14,10 @@ interface ImportMetaEnv {
readonly VITE_JIRA_EPIC_KEY?: string
/** Taille de page `/search/jql` (1100, défaut 100). Sans maxResults, Jira renvoie souvent 20 issues. */
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 {