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

View File

@ -22,6 +22,9 @@ JIRA_API_KEY=
# Champ Story Points dans Jira (Administration → Issues → champs personnalisés → ID) # Champ Story Points dans Jira (Administration → Issues → champs personnalisés → ID)
# VITE_JIRA_STORY_POINTS_FIELD=customfield_10028 # VITE_JIRA_STORY_POINTS_FIELD=customfield_10028
# Champ Sprint (Scrum) pour la vue Sprint — ID souvent proche de 10020 selon les instances
# VITE_JIRA_SPRINT_FIELD=customfield_10020
# 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

@ -35,8 +35,11 @@ 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 { SprintView } from './components/SprintView'
import { resolveSprintFieldId } from './lib/jiraSprintField'
type ViewMode = 'list' | 'board' type ViewMode = 'list' | 'board' | 'project' | 'sprint'
export default function App() { export default function App() {
const dashboardRef = useRef<HTMLDivElement>(null) const dashboardRef = useRef<HTMLDivElement>(null)
@ -54,6 +57,11 @@ export default function App() {
const myViewActive = Boolean(dashboardCfg.myViewActive) const myViewActive = Boolean(dashboardCfg.myViewActive)
const sprintFieldResolved = useMemo(
() => resolveSprintFieldId({ sprintFieldId: dashboardCfg.sprintFieldId }),
[dashboardCfg.sprintFieldId],
)
const displayGroups = useMemo(() => { const displayGroups = useMemo(() => {
if (!myViewActive) return groups if (!myViewActive) return groups
return groups.filter((g) => return groups.filter((g) =>
@ -137,7 +145,10 @@ export default function App() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const issues = await fetchAllIssuesByJql(MIGRATION_JQL, signal) const sprintField = resolveSprintFieldId({ sprintFieldId: dashboardCfg.sprintFieldId })
const issues = await fetchAllIssuesByJql(MIGRATION_JQL, signal, {
additionalFields: sprintField ? [sprintField] : [],
})
const grouped = groupSubtasksUnderStories(issues) const grouped = groupSubtasksUnderStories(issues)
setGroups(grouped) setGroups(grouped)
setUpdatedAt(new Date()) setUpdatedAt(new Date())
@ -169,7 +180,7 @@ export default function App() {
} finally { } finally {
if (!signal?.aborted) setLoading(false) if (!signal?.aborted) setLoading(false)
} }
}, []) }, [dashboardCfg.sprintFieldId])
useEffect(() => { useEffect(() => {
const ac = new AbortController() const ac = new AbortController()
@ -243,6 +254,30 @@ export default function App() {
> >
Board Board
</button> </button>
<button
type="button"
onClick={() => setView('project')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
view === 'project'
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
: 'text-slate-400 hover:text-white'
}`}
title="Frise calendaire, types de jalons et actions attendues"
>
Projet
</button>
<button
type="button"
onClick={() => setView('sprint')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
view === 'sprint'
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
: 'text-slate-400 hover:text-white'
}`}
title="Filtrer les stories par sprint Jira"
>
Sprint
</button>
</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 && (
@ -293,64 +328,86 @@ 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">
<MilestonesTimeline {view === 'project' ? (
milestones={dashboardCfg.milestones} <ProjectTimelineView
groups={groups} milestones={dashboardCfg.milestones}
onOpenSettings={() => setSettingsOpen(true)} groups={groups}
impactMessages={impactMessages} velocityPerCalendarDay={landing.effectiveVelocityPerDay}
/> onOpenSettings={() => setSettingsOpen(true)}
/>
) : view === 'sprint' ? (
<SprintView
groups={displayGroups}
sprintFieldId={sprintFieldResolved}
onOpenSettings={() => setSettingsOpen(true)}
/>
) : (
<>
<MilestonesTimeline
milestones={dashboardCfg.milestones}
groups={groups}
velocityPerCalendarDay={landing.effectiveVelocityPerDay}
onOpenSettings={() => setSettingsOpen(true)}
impactMessages={impactMessages}
/>
<ManagementOverview <ManagementOverview
deadlineScore={health.deadlineScore} deadlineScore={health.deadlineScore}
scopeScore={health.scopeScore} scopeScore={health.scopeScore}
resourceScore={health.resourceScore} resourceScore={health.resourceScore}
verdict={health.verdict} verdict={health.verdict}
landing={landing} landing={landing}
finalMilestoneDate={finalMilestoneIso} finalMilestoneDate={finalMilestoneIso}
/> />
<ExecutiveSummary groups={displayGroups} /> <ExecutiveSummary groups={displayGroups} />
<PipelineOverview groups={displayGroups} /> <PipelineOverview groups={displayGroups} />
<LaneTicketsListView groups={displayGroups} /> <LaneTicketsListView groups={displayGroups} />
<section> <section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400"> <h2 className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Reporting Reporting
</h2> </h2>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<BurnupChart data={burnupData} /> <BurnupChart data={burnupData} />
<PhaseDistributionChart counts={phaseCounts} /> <PhaseDistributionChart counts={phaseCounts} />
</div> </div>
</section> </section>
<section> <section>
<div className="mb-4 flex flex-wrap items-end justify-between gap-2"> <div className="mb-4 flex flex-wrap items-end justify-between gap-2">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400"> <h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
{view === 'list' ? 'Stories (liste)' : 'Stories par composant'} {view === 'list' ? 'Stories (liste)' : 'Stories par composant'}
</h2> </h2>
{myViewActive && ( {myViewActive && (
<span className="text-xs text-cyan-300/90"> <span className="text-xs text-cyan-300/90">
Filtre actif : {displayGroups.length} / {groups.length} stories Filtre actif : {displayGroups.length} / {groups.length} stories
</span> </span>
)} )}
</div> </div>
{displayGroups.length === 0 ? ( {displayGroups.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 backdrop-blur-md"> <p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400 backdrop-blur-md">
Aucune story ne correspond à « Ma vue ». Vérifiez vos assignations ou configurez Aucune story ne correspond à « Ma vue ». Vérifiez vos assignations ou configurez
votre accountId / e-mail dans les réglages. votre accountId / e-mail dans les réglages.
</p> </p>
) : view === 'list' ? ( ) : view === 'list' ? (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{displayGroups.map((g) => ( {displayGroups.map((g) => (
<StoryCard key={g.story.key} group={g} /> <StoryCard
))} key={g.story.key}
</div> group={g}
) : ( sprintFieldId={sprintFieldResolved}
<BoardView groups={displayGroups} /> />
)} ))}
</section> </div>
) : (
<BoardView groups={displayGroups} sprintFieldId={sprintFieldResolved} />
)}
</section>
</>
)}
</div> </div>
)} )}
</main> </main>

View File

@ -63,6 +63,7 @@ export async function fetchJqlApproximateCount(
export async function fetchAllIssuesByJql( export async function fetchAllIssuesByJql(
jql: string, jql: string,
signal?: AbortSignal, signal?: AbortSignal,
options?: { additionalFields?: string[] },
): Promise<JiraIssue[]> { ): Promise<JiraIssue[]> {
const base = clientBaseUrl() const base = clientBaseUrl()
if (!base) { if (!base) {
@ -74,7 +75,7 @@ export async function fetchAllIssuesByJql(
const maxResults = pageSize() const maxResults = pageSize()
const storyPointsField = const storyPointsField =
import.meta.env.VITE_JIRA_STORY_POINTS_FIELD?.trim() || 'customfield_10028' import.meta.env.VITE_JIRA_STORY_POINTS_FIELD?.trim() || 'customfield_10028'
const fields = [ const baseFields = [
'summary', 'summary',
'status', 'status',
'issuetype', 'issuetype',
@ -86,7 +87,9 @@ export async function fetchAllIssuesByJql(
'timetracking', 'timetracking',
'labels', 'labels',
storyPointsField, storyPointsField,
] as const ]
const extra = (options?.additionalFields ?? []).map((f) => f.trim()).filter(Boolean)
const fields = [...new Set([...baseFields, ...extra])]
const collected: JiraIssue[] = [] const collected: JiraIssue[] = []
let nextPageToken: string | undefined let nextPageToken: string | undefined
@ -96,7 +99,7 @@ export async function fetchAllIssuesByJql(
for (let page = 0; page < MAX_PAGES; page += 1) { for (let page = 0; page < MAX_PAGES; page += 1) {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
jql, jql,
fields: [...fields], fields,
maxResults, maxResults,
...(nextPageToken ? { nextPageToken } : {}), ...(nextPageToken ? { nextPageToken } : {}),
} }

View File

@ -4,9 +4,10 @@ import { StoryCard } from './StoryCard'
type Props = { type Props = {
groups: StoryGroup[] groups: StoryGroup[]
sprintFieldId?: string | null
} }
export function BoardView({ groups }: Props) { export function BoardView({ groups, sprintFieldId = null }: Props) {
const columns = groupStoriesByComponent(groups) const columns = groupStoriesByComponent(groups)
return ( return (
@ -24,7 +25,7 @@ export function BoardView({ groups }: 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" /> <StoryCard key={g.story.key} group={g} variant="board" sprintFieldId={sprintFieldId} />
))} ))}
</div> </div>
</div> </div>

View File

@ -5,8 +5,10 @@ import {
type DashboardConfig, type DashboardConfig,
type LaneLabelsConfig, type LaneLabelsConfig,
type Milestone, type Milestone,
type MilestoneKind,
type StatusBucketConfig, type StatusBucketConfig,
} from '../lib/dashboardConfig' } from '../lib/dashboardConfig'
import { MILESTONE_KIND_OPTIONS } from '../lib/milestoneKinds'
function parseBucketLines(raw: string): string[] { function parseBucketLines(raw: string): string[] {
return raw return raw
@ -68,6 +70,8 @@ function newMilestone(): Milestone {
date: new Date().toISOString().slice(0, 10), date: new Date().toISOString().slice(0, 10),
linkedStoryKeys: [], linkedStoryKeys: [],
critical: false, critical: false,
kind: 'generic',
expectedActions: undefined,
} }
} }
@ -199,6 +203,26 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
/> />
</div> </div>
</div> </div>
<div className="mt-3">
<label className="text-[10px] uppercase text-slate-500">
Champ Sprint Jira (ID customfield)
</label>
<input
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-xs outline-none"
value={draft.sprintFieldId ?? ''}
onChange={(e) =>
setDraft((d) => ({
...d,
sprintFieldId: e.target.value.trim() || undefined,
}))
}
placeholder="ex. customfield_10020 (vide = utiliser VITE_JIRA_SPRINT_FIELD ou désactiver)"
/>
<p className="mt-1 text-[10px] text-slate-600">
Laissez vide pour nutiliser que la variable denvironnement, ou saisissez lID exact du
champ Sprint de votre projet Scrum.
</p>
</div>
<p className="mt-2 text-[10px] text-slate-500"> <p className="mt-2 text-[10px] text-slate-500">
La date datterrissage utilise : vélocité × (effectif / baseline). WIP = créneaux La date datterrissage utilise : vélocité × (effectif / baseline). WIP = créneaux
nominaux pour la jauge « Ressources ». nominaux pour la jauge « Ressources ».
@ -323,6 +347,37 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
onChange={(e) => updateMilestone(m.id, { title: e.target.value })} onChange={(e) => updateMilestone(m.id, { title: e.target.value })}
placeholder="ex. Fin design" placeholder="ex. Fin design"
/> />
<div className="mb-2">
<label className="text-[10px] uppercase text-slate-500">Type de jalon</label>
<select
className="mt-1 w-full rounded border border-white/10 bg-black/30 px-2 py-1 text-xs text-slate-200 outline-none"
value={m.kind ?? 'generic'}
onChange={(e) =>
updateMilestone(m.id, { kind: e.target.value as MilestoneKind })
}
>
{MILESTONE_KIND_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} {opt.hint}
</option>
))}
</select>
</div>
<div className="mb-2">
<label className="text-[10px] uppercase text-slate-500">Actions attendues à cette date</label>
<textarea
rows={2}
spellCheck={false}
className="mt-1 w-full resize-y rounded border border-white/10 bg-black/30 px-2 py-1 text-xs text-slate-200 outline-none"
value={m.expectedActions ?? ''}
onChange={(e) =>
updateMilestone(m.id, {
expectedActions: e.target.value.trim() ? e.target.value : undefined,
})
}
placeholder="Ex. Recette signée, doc runbook, passage en prod…"
/>
</div>
<label className="mt-2 flex cursor-pointer items-center gap-2 text-[11px] text-amber-100/90"> <label className="mt-2 flex cursor-pointer items-center gap-2 text-[11px] text-amber-100/90">
<input <input
type="checkbox" type="checkbox"
@ -350,7 +405,7 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
.filter(Boolean), .filter(Boolean),
}) })
} }
placeholder="Stories liées (DCC-1, DCC-2) — vide = toutes" placeholder="Stories (DCC-1, DCC-2) — vide = toutes les stories chargées"
/> />
<button <button
type="button" type="button"

View File

@ -9,12 +9,16 @@ import {
milestoneLinkedGroups, milestoneLinkedGroups,
milestoneOpenRemainingUnits, milestoneOpenRemainingUnits,
} from '../lib/milestoneStatus' } from '../lib/milestoneStatus'
import { milestoneVelocityRisk } from '../lib/milestoneLoadRisk'
import { milestoneKindLabel, milestoneKindMarkerClass } from '../lib/milestoneKinds'
import { ProjectRoadmapBar } from './ProjectRoadmapBar' import { ProjectRoadmapBar } from './ProjectRoadmapBar'
type Props = { type Props = {
milestones: Milestone[] milestones: Milestone[]
groups: StoryGroup[] groups: StoryGroup[]
onOpenSettings: () => void onOpenSettings: () => void
/** Vélocité sous-tâches terminées / jour calendaire (burn-up), pour lindicateur charge. */
velocityPerCalendarDay: number
/** Alertes dimpact (ex. jalons critiques en retard). */ /** Alertes dimpact (ex. jalons critiques en retard). */
impactMessages?: string[] impactMessages?: string[]
} }
@ -50,10 +54,30 @@ function delaySummary(
return { text: '—', className: 'text-slate-500' } return { text: '—', className: 'text-slate-500' }
} }
function chargeLabel(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
v: number,
): { text: string; className: string } {
const r = milestoneVelocityRisk(m, groups, cfg, v)
if (r.level === 'tight' && r.daysNeeded != null) {
return {
text: `Serré (~${r.daysNeeded}j / ${r.calendarDaysLeft}j)`,
className: 'text-amber-200',
}
}
if (r.level === 'unknown') {
return { text: 'N/D', className: 'text-slate-500' }
}
return { text: 'OK', className: 'text-slate-500' }
}
export function MilestonesTimeline({ export function MilestonesTimeline({
milestones, milestones,
groups, groups,
onOpenSettings, onOpenSettings,
velocityPerCalendarDay,
impactMessages = [], impactMessages = [],
}: Props) { }: Props) {
const cfg = useStatusBuckets() const cfg = useStatusBuckets()
@ -90,10 +114,10 @@ export function MilestonesTimeline({
</div> </div>
<p className="mb-4 text-xs leading-relaxed text-slate-500"> <p className="mb-4 text-xs leading-relaxed text-slate-500">
Chaque jalon regarde un périmètre : les stories saisies dans « Stories liées », ou toutes les Périmètre : stories liées ou toutes si vide. Types (livrable, gouvernance, ) et actions
stories chargées si ce champ est vide. Lavancement est la moyenne des pourcentages de attendues se configurent dans les réglages voir aussi la vue Projet pour la frise. La
sous-tâches terminées (même règle que le retard). Le RAF est la somme du temps restant Jira colonne « Charge » compare les sous-tâches ouvertes du périmètre à la vélocité globale
(unités ÷ 27 000) sur les sous-tâches encore actives de ce périmètre. (burn-up, jours calendaires restants).
</p> </p>
{sorted.length === 0 ? ( {sorted.length === 0 ? (
@ -117,12 +141,12 @@ export function MilestonesTimeline({
className={`relative z-10 mb-2 flex h-4 w-4 rounded-full ring-4 ring-slate-950 ${ className={`relative z-10 mb-2 flex h-4 w-4 rounded-full ring-4 ring-slate-950 ${
late late
? 'bg-rose-500 shadow-[0_0_14px_rgba(244,63,94,0.7)]' ? 'bg-rose-500 shadow-[0_0_14px_rgba(244,63,94,0.7)]'
: 'bg-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.5)]' : milestoneKindMarkerClass(m.kind)
}`} }`}
title={ title={
late late
? 'Retard : date dépassée et périmètre non à 100 % (sous-tâches).' ? 'Retard : date dépassée et périmètre non à 100 % (sous-tâches).'
: jour ou échéance future.' : `${milestoneKindLabel(m.kind)} — à jour ou échéance future.`
} }
/> />
<span className="max-w-[140px] text-center text-xs font-medium text-white"> <span className="max-w-[140px] text-center text-xs font-medium text-white">
@ -151,15 +175,18 @@ export function MilestonesTimeline({
Synthèse par jalon Synthèse par jalon
</h3> </h3>
<div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-black/20"> <div className="overflow-x-auto rounded-xl border border-white/[0.06] bg-black/20">
<table className="w-full min-w-[720px] border-collapse text-left text-xs"> <table className="w-full min-w-[960px] border-collapse text-left text-xs">
<thead> <thead>
<tr className="border-b border-white/[0.08] bg-slate-900/80 text-[10px] uppercase tracking-wide text-slate-500"> <tr className="border-b border-white/[0.08] bg-slate-900/80 text-[10px] uppercase tracking-wide text-slate-500">
<th className="px-3 py-2 font-medium">Jalon</th> <th className="px-3 py-2 font-medium">Jalon</th>
<th className="px-3 py-2 font-medium">Type</th>
<th className="px-3 py-2 font-medium">Date</th> <th className="px-3 py-2 font-medium">Date</th>
<th className="px-3 py-2 font-medium">Périmètre</th> <th className="px-3 py-2 font-medium">Périmètre</th>
<th className="px-3 py-2 font-medium text-right">Avancement</th> <th className="px-3 py-2 font-medium text-right">Avancement</th>
<th className="px-3 py-2 font-medium text-right">RAF (u.)</th> <th className="px-3 py-2 font-medium text-right">RAF (u.)</th>
<th className="px-3 py-2 font-medium">Charge</th>
<th className="px-3 py-2 font-medium">Échéance</th> <th className="px-3 py-2 font-medium">Échéance</th>
<th className="px-3 py-2 font-medium">Actions attendues</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -169,10 +196,12 @@ export function MilestonesTimeline({
const pct = milestoneAverageCompletionPercent(m, groups, cfg) const pct = milestoneAverageCompletionPercent(m, groups, cfg)
const raf = milestoneOpenRemainingUnits(m, groups, cfg) const raf = milestoneOpenRemainingUnits(m, groups, cfg)
const del = delaySummary(m, groups, cfg) const del = delaySummary(m, groups, cfg)
const ch = chargeLabel(m, groups, cfg, velocityPerCalendarDay)
const scopeHint = const scopeHint =
m.linkedStoryKeys && m.linkedStoryKeys.length > 0 m.linkedStoryKeys && m.linkedStoryKeys.length > 0
? `${nStories} story(s) liée(s)` ? `${nStories} story(s) liée(s)`
: `Toutes (${nStories})` : `Toutes (${nStories})`
const actions = m.expectedActions?.trim() ?? ''
return ( return (
<tr <tr
key={m.id} key={m.id}
@ -186,10 +215,13 @@ export function MilestonesTimeline({
</span> </span>
)} )}
</td> </td>
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-400">
{milestoneKindLabel(m.kind)}
</td>
<td className="whitespace-nowrap px-3 py-2 align-top text-slate-400"> <td className="whitespace-nowrap px-3 py-2 align-top text-slate-400">
{formatFr(m.date)} {formatFr(m.date)}
</td> </td>
<td className="max-w-[200px] px-3 py-2 align-top text-slate-500" title={scopeHint}> <td className="max-w-[160px] px-3 py-2 align-top text-slate-500" title={scopeHint}>
{scopeHint} {scopeHint}
</td> </td>
<td className="px-3 py-2 align-top text-right font-mono text-slate-300"> <td className="px-3 py-2 align-top text-right font-mono text-slate-300">
@ -198,9 +230,21 @@ export function MilestonesTimeline({
<td className="px-3 py-2 align-top text-right font-mono text-slate-400"> <td className="px-3 py-2 align-top text-right font-mono text-slate-400">
{raf.toFixed(2)} {raf.toFixed(2)}
</td> </td>
<td className={`whitespace-nowrap px-3 py-2 align-top font-medium ${ch.className}`}>
{ch.text}
</td>
<td className={`whitespace-nowrap px-3 py-2 align-top font-medium ${del.className}`}> <td className={`whitespace-nowrap px-3 py-2 align-top font-medium ${del.className}`}>
{del.text} {del.text}
</td> </td>
<td className="max-w-[220px] px-3 py-2 align-top text-[11px] text-slate-500">
{actions ? (
<span className="line-clamp-3" title={actions}>
{actions}
</span>
) : (
<span className="text-slate-600"></span>
)}
</td>
</tr> </tr>
) )
})} })}

View File

@ -0,0 +1,302 @@
import { useMemo } from 'react'
import type { StoryGroup } from '../types/jira'
import type { Milestone } from '../lib/dashboardConfig'
import type { StatusBucketConfig } from '../lib/statusBuckets'
import { useStatusBuckets } from '../context/StatusBucketContext'
import {
isMilestoneLate,
milestoneAverageCompletionPercent,
milestoneCalendarDaysUntil,
milestoneLinkedGroups,
milestoneOpenRemainingUnits,
} from '../lib/milestoneStatus'
import { milestoneVelocityRisk } from '../lib/milestoneLoadRisk'
import {
milestoneKindChipClass,
milestoneKindLabel,
milestoneKindMarkerClass,
} from '../lib/milestoneKinds'
type Props = {
milestones: Milestone[]
groups: StoryGroup[]
/** Vélocité globale sous-tâches terminées / jour calendaire (burn-up). */
velocityPerCalendarDay: number
onOpenSettings: () => void
}
function toNoonMs(iso: string): number {
return new Date(iso + 'T12:00:00').getTime()
}
function formatShort(iso: string): string {
try {
return new Date(iso + 'T12:00:00').toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})
} catch {
return iso
}
}
function delaySummary(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
): { text: string; className: string } {
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
if (pct >= 100) return { text: 'Terminé', className: 'text-emerald-400' }
if (isMilestoneLate(m, groups, cfg)) return { text: 'Retard', className: 'text-rose-400' }
const d = milestoneCalendarDaysUntil(m)
if (d > 1) return { text: `Dans ${d} j`, className: d <= 7 ? 'text-amber-200' : 'text-slate-400' }
if (d === 1) return { text: 'Demain', className: 'text-amber-200' }
if (d === 0) return { text: "Aujourd'hui", className: 'text-amber-300' }
return { text: '—', className: 'text-slate-500' }
}
function loadHint(
m: Milestone,
groups: StoryGroup[],
cfg: StatusBucketConfig,
v: number,
): string {
const r = milestoneVelocityRisk(m, groups, cfg, v)
if (r.level === 'tight' && r.daysNeeded != null) {
return `~${r.daysNeeded} j à la vélocité actuelle pour fermer les sous-tâches ouvertes, ${r.calendarDaysLeft} j cal. avant le jalon.`
}
if (r.level === 'unknown') return 'Vélocité nulle ou historique insuffisant — impossible de comparer la charge.'
return 'Charge compatible avec la marge calendaire (ordre de grandeur).'
}
export function ProjectTimelineView({
milestones,
groups,
velocityPerCalendarDay,
onOpenSettings,
}: Props) {
const cfg = useStatusBuckets()
const sorted = useMemo(
() => [...milestones].sort((a, b) => a.date.localeCompare(b.date)),
[milestones],
)
const { startMs, endMs, todayPct } = useMemo(() => {
const today = Date.now()
const dates = sorted.map((m) => m.date).filter(Boolean)
if (dates.length === 0) {
const s = today - 7 * 86400000
const e = today + 30 * 86400000
return {
startMs: s,
endMs: e,
todayPct: 50,
}
}
let s = toNoonMs(dates[0]!)
let e = toNoonMs(dates[dates.length - 1]!)
s = Math.min(s, today - 7 * 86400000)
e = Math.max(e, today + 21 * 86400000)
const span = Math.max(1, e - s)
const tp = ((today - s) / span) * 100
return { startMs: s, endMs: e, todayPct: Math.max(0, Math.min(100, tp)) }
}, [sorted])
const span = Math.max(1, endMs - startMs)
const monthTicks = useMemo(() => {
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
}, [startMs, endMs])
if (sorted.length === 0) {
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-8 text-center backdrop-blur-xl sm:px-6">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Vue projet frise
</h2>
<p className="mt-2 text-sm text-slate-500">
Aucun jalon configuré. Ajoutez des dates, types et actions attendues dans les réglages.
</p>
<button
type="button"
onClick={onOpenSettings}
className="mt-4 rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-4 py-2 text-sm font-medium text-cyan-100"
>
Ouvrir la configuration
</button>
</section>
)
}
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">
Vue projet frise & jalons
</h2>
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-slate-500">
Frise calendaire : chaque point correspond à un jalon (couleur = type). La ligne
verticale blanche indique aujourdhui. En dessous, lagenda liste les actions attendues
et un indicateur de charge vs vélocité globale (sous-tâches / jour, comme le burn-up).
</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
</button>
</div>
<div className="relative mb-10 select-none">
<div className="mb-1 flex justify-between text-[10px] text-slate-600">
{monthTicks.map((t) => {
const pct = ((t.ms - startMs) / span) * 100
return (
<span
key={t.ms}
className="absolute -translate-x-1/2 whitespace-nowrap"
style={{ left: `${pct}%` }}
>
{t.label}
</span>
)
})}
</div>
<div className="relative mt-6 h-14 w-full rounded-lg bg-slate-900/80 ring-1 ring-inset ring-white/[0.06]">
{monthTicks.map((t) => {
const pct = ((t.ms - startMs) / span) * 100
return (
<div
key={`g-${t.ms}`}
className="pointer-events-none absolute bottom-0 top-0 w-px bg-slate-700/60"
style={{ left: `${pct}%` }}
/>
)
})}
<div
className="pointer-events-none absolute bottom-0 top-0 z-20 w-px bg-white shadow-[0_0_12px_rgba(255,255,255,0.5)]"
style={{ left: `${todayPct}%` }}
title="Aujourd'hui"
/>
{sorted.map((m) => {
const pct = ((toNoonMs(m.date) - startMs) / span) * 100
const clamped = Math.max(1.5, Math.min(98.5, pct))
return (
<button
key={m.id}
type="button"
title={`${m.title}${m.date}`}
className={`absolute top-1/2 z-10 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-slate-950 ${milestoneKindMarkerClass(m.kind)}`}
style={{ left: `${clamped}%` }}
onClick={() => {
const el = document.getElementById(`milestone-card-${m.id}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}}
/>
)
})}
</div>
<div className="relative mt-2 h-8 text-[10px] text-slate-500">
{sorted.map((m) => {
const pct = ((toNoonMs(m.date) - startMs) / span) * 100
const clamped = Math.max(2, Math.min(98, pct))
return (
<span
key={`l-${m.id}`}
className="absolute -translate-x-1/2 truncate text-center"
style={{ left: `${clamped}%`, maxWidth: '14%' }}
title={m.title}
>
{m.title || '(Sans titre)'}
</span>
)
})}
</div>
</div>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
Agenda (ordre chronologique)
</h3>
<ul className="space-y-3">
{sorted.map((m) => {
const linked = milestoneLinkedGroups(m, groups)
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
const raf = milestoneOpenRemainingUnits(m, groups, cfg)
const del = delaySummary(m, groups, cfg)
const risk = milestoneVelocityRisk(m, groups, cfg, velocityPerCalendarDay)
const scopeHint =
m.linkedStoryKeys && m.linkedStoryKeys.length > 0
? `${linked.length} story(s) liée(s)`
: `Toutes (${linked.length})`
return (
<li
id={`milestone-card-${m.id}`}
key={m.id}
className="scroll-mt-24 rounded-xl border border-white/[0.06] bg-black/25 px-4 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-slate-400">{formatShort(m.date)}</span>
<span className={milestoneKindChipClass(m.kind)}>{milestoneKindLabel(m.kind)}</span>
{m.critical && (
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase text-amber-200 ring-1 ring-amber-500/40">
Critique
</span>
)}
</div>
<p className="mt-1 text-sm font-semibold text-white">{m.title || 'Sans titre'}</p>
<p className="mt-0.5 text-[11px] text-slate-500">{scopeHint}</p>
</div>
<div className="shrink-0 text-right text-xs">
<p className={`font-medium ${del.className}`}>{del.text}</p>
<p className="mt-0.5 font-mono text-slate-500">{pct}% · RAF {raf.toFixed(2)} u.</p>
</div>
</div>
{m.expectedActions ? (
<div className="mt-2 rounded-lg border border-white/[0.04] bg-slate-950/50 px-3 py-2 text-xs leading-relaxed text-slate-300">
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Actions attendues {' '}
</span>
{m.expectedActions}
</div>
) : (
<p className="mt-2 text-[11px] italic text-slate-600">
Aucune action attendue renseignée complétez le champ dans les réglages pour le
suivi de réunion / livrable.
</p>
)}
<p
className={`mt-2 text-[11px] ${
risk.level === 'tight'
? 'text-amber-200/90'
: risk.level === 'unknown'
? 'text-slate-500'
: 'text-slate-600'
}`}
>
{loadHint(m, groups, cfg, velocityPerCalendarDay)}
</p>
</li>
)
})}
</ul>
</section>
)
}

View File

@ -0,0 +1,149 @@
import { useMemo, useState } from 'react'
import type { StoryGroup } from '../types/jira'
import { useStatusBuckets } from '../context/StatusBucketContext'
import { collectSprintOptions, filterGroupsBySprint } from '../lib/sprintExtract'
import { subtaskDoneRatioPercent } from '../lib/storyMetrics'
import { StoryCard } from './StoryCard'
type Props = {
groups: StoryGroup[]
sprintFieldId: string | null
onOpenSettings: () => void
}
export function SprintView({ groups, sprintFieldId, onOpenSettings }: Props) {
const cfg = useStatusBuckets()
const options = useMemo(
() => collectSprintOptions(groups, sprintFieldId),
[groups, sprintFieldId],
)
const [focusId, setFocusId] = useState<number | 'all'>('all')
const filtered = useMemo(() => {
if (!sprintFieldId || focusId === 'all') return groups
return filterGroupsBySprint(groups, focusId, sprintFieldId)
}, [groups, focusId, sprintFieldId])
const stats = useMemo(() => {
let subs = 0
let sumPct = 0
let nWithSubs = 0
for (const g of filtered) {
subs += g.subtasks.length
if (g.subtasks.length > 0) {
sumPct += subtaskDoneRatioPercent(g.subtasks, cfg)
nWithSubs += 1
}
}
const avgPct = nWithSubs > 0 ? Math.round(sumPct / nWithSubs) : 0
return { stories: filtered.length, subtasks: subs, avgPct }
}, [filtered, cfg])
if (!sprintFieldId) {
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{' '}
<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.
</p>
<button
type="button"
onClick={onOpenSettings}
className="mt-5 rounded-lg border border-cyan-500/50 bg-cyan-500/15 px-4 py-2 text-sm font-medium text-cyan-100"
>
Ouvrir les réglages
</button>
</section>
)
}
if (options.length === 0) {
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-5 py-8 backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
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.
</p>
</section>
)
}
return (
<section className="mb-10 space-y-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>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
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.
</p>
</div>
<div className="flex min-w-[200px] flex-1 flex-col gap-1 sm:max-w-md">
<label className="text-[10px] font-medium uppercase tracking-wide text-slate-500">
Sprint ciblé
</label>
<select
value={focusId === 'all' ? 'all' : String(focusId)}
onChange={(e) => {
const v = e.target.value
setFocusId(v === 'all' ? 'all' : Number(v))
}}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-slate-200 outline-none ring-cyan-500/20 focus:ring-2"
>
<option value="all">Tous les sprints ({groups.length} stories)</option>
{options.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
{s.state ? ` (${s.state})` : ''} {s.storyCount} stories
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-4 border-t border-white/[0.06] pt-4 text-sm text-slate-400">
<span>
Stories :{' '}
<span className="font-mono font-semibold text-slate-200">{stats.stories}</span>
</span>
<span>
Sous-tâches :{' '}
<span className="font-mono font-semibold text-slate-200">{stats.subtasks}</span>
</span>
<span title="Moyenne des % sous-tâches terminées sur les stories qui ont des sous-tâches">
Avancement moy. :{' '}
<span className="font-mono font-semibold text-emerald-300">{stats.avgPct}%</span>
</span>
</div>
</div>
{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 dans ce sprint pour le périmètre actuel (filtre « Ma vue » inclus).
</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} />
))}
</div>
)}
</section>
)
}

View File

@ -10,6 +10,7 @@ import { useStatusBuckets } from '../context/StatusBucketContext'
import { useLaneLabels } from '../context/LaneLabelsContext' import { useLaneLabels } from '../context/LaneLabelsContext'
import { getRemainingEstimateUnits, getStoryPoints } from '../lib/jiraFieldExtractors' import { getRemainingEstimateUnits, getStoryPoints } from '../lib/jiraFieldExtractors'
import { jiraBrowseIssueUrl } from '../lib/jiraLinks' import { jiraBrowseIssueUrl } from '../lib/jiraLinks'
import { mergeSprintsForGroup } from '../lib/sprintExtract'
function IssueKeyLink({ function IssueKeyLink({
issueKey, issueKey,
@ -42,6 +43,16 @@ type Props = {
group: StoryGroup group: StoryGroup
/** Board : icônes A/D/I par piste + carte un peu plus compacte. */ /** Board : icônes A/D/I par piste + carte un peu plus compacte. */
variant?: 'default' | 'board' variant?: 'default' | 'board'
/** Champ Sprint Jira : affiche les pastilles sur la carte (vue Sprint ou debug). */
sprintFieldId?: string | null
}
function sprintChipClass(state?: string): string {
const s = (state ?? '').toLowerCase()
if (s === 'active') return 'bg-emerald-500/20 text-emerald-100 ring-emerald-500/40'
if (s === 'future') return 'bg-sky-500/20 text-sky-100 ring-sky-500/40'
if (s === 'closed') return 'bg-slate-600/30 text-slate-300 ring-slate-500/30'
return 'bg-slate-600/25 text-slate-200 ring-slate-500/35'
} }
function phaseChipClass(phase: PhaseId): string { function phaseChipClass(phase: PhaseId): string {
@ -56,7 +67,7 @@ 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' }: Props) { export function StoryCard({ group, variant = 'default', sprintFieldId = null }: Props) {
const cfg = useStatusBuckets() const cfg = useStatusBuckets()
const laneCfg = useLaneLabels() const laneCfg = useLaneLabels()
const { story, subtasks } = group const { story, subtasks } = group
@ -71,6 +82,7 @@ export function StoryCard({ group, variant = 'default' }: Props) {
const spSubs = subtasks.reduce((a, s) => a + getStoryPoints(s), 0) const spSubs = subtasks.reduce((a, s) => a + getStoryPoints(s), 0)
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) : []
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)]">
@ -91,6 +103,19 @@ export function StoryCard({ group, variant = 'default' }: Props) {
<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>
{sprintChips.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{sprintChips.map((sp) => (
<span
key={sp.id}
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset ${sprintChipClass(sp.state)}`}
title={sp.goal ? `${sp.name}${sp.goal}` : sp.name}
>
{sp.name}
</span>
))}
</div>
)}
<p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500"> <p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span title="Story Points (champ configurable, défaut customfield_10028)"> <span title="Story Points (champ configurable, défaut customfield_10028)">
SP story : <span className="font-mono text-slate-300">{spStory}</span> SP story : <span className="font-mono text-slate-300">{spStory}</span>

View File

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

View File

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

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

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

View File

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

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

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