sprint
This commit is contained in:
@ -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
|
||||||
|
|
||||||
|
|||||||
67
src/App.tsx
67
src/App.tsx
@ -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,9 +328,25 @@ export default function App() {
|
|||||||
|
|
||||||
{!loading && !error && groups.length > 0 && (
|
{!loading && !error && groups.length > 0 && (
|
||||||
<div ref={dashboardRef} className="space-y-10">
|
<div ref={dashboardRef} className="space-y-10">
|
||||||
|
{view === 'project' ? (
|
||||||
|
<ProjectTimelineView
|
||||||
|
milestones={dashboardCfg.milestones}
|
||||||
|
groups={groups}
|
||||||
|
velocityPerCalendarDay={landing.effectiveVelocityPerDay}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
) : view === 'sprint' ? (
|
||||||
|
<SprintView
|
||||||
|
groups={displayGroups}
|
||||||
|
sprintFieldId={sprintFieldResolved}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<MilestonesTimeline
|
<MilestonesTimeline
|
||||||
milestones={dashboardCfg.milestones}
|
milestones={dashboardCfg.milestones}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
|
velocityPerCalendarDay={landing.effectiveVelocityPerDay}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
impactMessages={impactMessages}
|
impactMessages={impactMessages}
|
||||||
/>
|
/>
|
||||||
@ -344,13 +395,19 @@ export default function App() {
|
|||||||
) : 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}
|
||||||
|
group={g}
|
||||||
|
sprintFieldId={sprintFieldResolved}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<BoardView groups={displayGroups} />
|
<BoardView groups={displayGroups} sprintFieldId={sprintFieldResolved} />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -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 } : {}),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 n’utiliser que la variable d’environnement, ou saisissez l’ID 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 d’atterrissage utilise : vélocité × (effectif / baseline). WIP = créneaux
|
La date d’atterrissage 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"
|
||||||
|
|||||||
@ -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 l’indicateur charge. */
|
||||||
|
velocityPerCalendarDay: number
|
||||||
/** Alertes d’impact (ex. jalons critiques en retard). */
|
/** Alertes d’impact (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. L’avancement 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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
302
src/components/ProjectTimelineView.tsx
Normal file
302
src/components/ProjectTimelineView.tsx
Normal 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 aujourd’hui. En dessous, l’agenda 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
src/components/SprintView.tsx
Normal file
149
src/components/SprintView.tsx
Normal 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 l’identifiant 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. L’ID 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 d’impact si retard après la date. */
|
/** Jalon critique : alerte d’impact 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/lib/jiraSprintField.ts
Normal file
12
src/lib/jiraSprintField.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import type { DashboardConfig } from './dashboardConfig'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifiant du champ personnalisé « Sprint » dans Jira (ex. customfield_10020).
|
||||||
|
* Priorité : réglages dashboard → variable d’environnement.
|
||||||
|
*/
|
||||||
|
export function resolveSprintFieldId(cfg: Pick<DashboardConfig, 'sprintFieldId'> | null | undefined): string | null {
|
||||||
|
const fromCfg = cfg?.sprintFieldId?.trim()
|
||||||
|
if (fromCfg) return fromCfg
|
||||||
|
const fromEnv = import.meta.env.VITE_JIRA_SPRINT_FIELD?.trim()
|
||||||
|
return fromEnv || null
|
||||||
|
}
|
||||||
58
src/lib/milestoneKinds.ts
Normal file
58
src/lib/milestoneKinds.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export type MilestoneKind = 'deliverable' | 'governance' | 'dependency' | 'generic'
|
||||||
|
|
||||||
|
export const MILESTONE_KIND_OPTIONS: { value: MilestoneKind; label: string; hint: string }[] = [
|
||||||
|
{
|
||||||
|
value: 'deliverable',
|
||||||
|
label: 'Livrable',
|
||||||
|
hint: 'Recette, MEP, lot fonctionnel — suivi fin de réalisation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'governance',
|
||||||
|
label: 'Gouvernance',
|
||||||
|
hint: 'Comité, GO/NO-GO, cadrage — date de décision ou de pilotage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dependency',
|
||||||
|
label: 'Dépendance',
|
||||||
|
hint: 'Autre équipe, infra, lot externe.',
|
||||||
|
},
|
||||||
|
{ value: 'generic', label: 'Générique', hint: 'Repère calendaire simple.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function normalizeMilestoneKind(k: unknown): MilestoneKind {
|
||||||
|
if (k === 'deliverable' || k === 'governance' || k === 'dependency' || k === 'generic') return k
|
||||||
|
return 'generic'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function milestoneKindLabel(kind: MilestoneKind | undefined): string {
|
||||||
|
const k = kind ?? 'generic'
|
||||||
|
return MILESTONE_KIND_OPTIONS.find((o) => o.value === k)?.label ?? 'Générique'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classes pour pastilles / marqueurs sur la frise. */
|
||||||
|
export function milestoneKindChipClass(kind: MilestoneKind | undefined): string {
|
||||||
|
const base = 'rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset'
|
||||||
|
switch (kind ?? 'generic') {
|
||||||
|
case 'deliverable':
|
||||||
|
return `${base} bg-emerald-500/15 text-emerald-200 ring-emerald-500/35`
|
||||||
|
case 'governance':
|
||||||
|
return `${base} bg-violet-500/15 text-violet-200 ring-violet-500/35`
|
||||||
|
case 'dependency':
|
||||||
|
return `${base} bg-amber-500/15 text-amber-100 ring-amber-500/35`
|
||||||
|
default:
|
||||||
|
return `${base} bg-slate-600/40 text-slate-200 ring-slate-500/30`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function milestoneKindMarkerClass(kind: MilestoneKind | undefined): string {
|
||||||
|
switch (kind ?? 'generic') {
|
||||||
|
case 'deliverable':
|
||||||
|
return 'bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.55)]'
|
||||||
|
case 'governance':
|
||||||
|
return 'bg-violet-400 shadow-[0_0_12px_rgba(167,139,250,0.45)]'
|
||||||
|
case 'dependency':
|
||||||
|
return 'bg-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.45)]'
|
||||||
|
default:
|
||||||
|
return 'bg-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.45)]'
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/lib/milestoneLoadRisk.ts
Normal file
74
src/lib/milestoneLoadRisk.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import type { StoryGroup } from '../types/jira'
|
||||||
|
import type { Milestone } from './dashboardConfig'
|
||||||
|
import type { StatusBucketConfig } from './statusBuckets'
|
||||||
|
import { resolveWorkBucketFromIssue } from './statusBuckets'
|
||||||
|
import {
|
||||||
|
milestoneAverageCompletionPercent,
|
||||||
|
milestoneLinkedGroups,
|
||||||
|
} from './milestoneStatus'
|
||||||
|
|
||||||
|
/** Jours calendaires restants jusqu’à la fin du jour du jalon (0 si déjà passé). */
|
||||||
|
export function calendarDaysInclusiveUntil(isoDate: string): number {
|
||||||
|
const start = new Date()
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
const end = new Date(isoDate + 'T23:59:59')
|
||||||
|
const diff = (end.getTime() - start.getTime()) / 86400000
|
||||||
|
return Math.max(0, Math.ceil(diff))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sous-tâches encore actives (hors terminé / annulé) dans le périmètre du jalon. */
|
||||||
|
export function milestoneOpenSubtaskCount(
|
||||||
|
m: Milestone,
|
||||||
|
groups: StoryGroup[],
|
||||||
|
cfg: StatusBucketConfig,
|
||||||
|
): number {
|
||||||
|
let n = 0
|
||||||
|
for (const g of milestoneLinkedGroups(m, groups)) {
|
||||||
|
for (const s of g.subtasks) {
|
||||||
|
const b = resolveWorkBucketFromIssue(s, cfg)
|
||||||
|
if (b !== 'done' && b !== 'cancel') n += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MilestoneVelocityRisk = 'ok' | 'tight' | 'unknown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare le volume de sous-tâches ouvertes du périmètre à la vélocité globale (sous-tâches / jour
|
||||||
|
* calendaire, comme le burn-up). Si les jours « nécessaires » dépassent les jours calendaires
|
||||||
|
* restants avant le jalon → charge serrée (heuristique).
|
||||||
|
*/
|
||||||
|
export function milestoneVelocityRisk(
|
||||||
|
m: Milestone,
|
||||||
|
groups: StoryGroup[],
|
||||||
|
cfg: StatusBucketConfig,
|
||||||
|
velocitySubtasksPerCalendarDay: number,
|
||||||
|
): {
|
||||||
|
level: MilestoneVelocityRisk
|
||||||
|
openSubtasks: number
|
||||||
|
daysNeeded: number | null
|
||||||
|
calendarDaysLeft: number
|
||||||
|
} {
|
||||||
|
const pct = milestoneAverageCompletionPercent(m, groups, cfg)
|
||||||
|
const open = milestoneOpenSubtaskCount(m, groups, cfg)
|
||||||
|
const calendarDaysLeft = calendarDaysInclusiveUntil(m.date)
|
||||||
|
|
||||||
|
if (pct >= 100) {
|
||||||
|
return { level: 'ok', openSubtasks: open, daysNeeded: 0, calendarDaysLeft }
|
||||||
|
}
|
||||||
|
if (open === 0) {
|
||||||
|
return { level: 'ok', openSubtasks: 0, daysNeeded: 0, calendarDaysLeft }
|
||||||
|
}
|
||||||
|
if (velocitySubtasksPerCalendarDay <= 0.001) {
|
||||||
|
return { level: 'unknown', openSubtasks: open, daysNeeded: null, calendarDaysLeft }
|
||||||
|
}
|
||||||
|
const daysNeeded = Math.ceil(open / velocitySubtasksPerCalendarDay)
|
||||||
|
const tight = calendarDaysLeft > 0 && daysNeeded > calendarDaysLeft
|
||||||
|
return {
|
||||||
|
level: tight ? 'tight' : 'ok',
|
||||||
|
openSubtasks: open,
|
||||||
|
daysNeeded,
|
||||||
|
calendarDaysLeft,
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/lib/sprintExtract.ts
Normal file
117
src/lib/sprintExtract.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import type { JiraIssue, StoryGroup } from '../types/jira'
|
||||||
|
|
||||||
|
/** Snapshot sprint tel que renvoyé par le champ Sprint Jira (souvent `customfield_10020`). */
|
||||||
|
export type JiraSprintSnapshot = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
state?: string
|
||||||
|
boardId?: number
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
goal?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceSprintObject(o: Record<string, unknown>): JiraSprintSnapshot | null {
|
||||||
|
const id = Number(o.id)
|
||||||
|
const name = typeof o.name === 'string' ? o.name.trim() : ''
|
||||||
|
if (!Number.isFinite(id) || !name) return null
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
state: typeof o.state === 'string' ? o.state : undefined,
|
||||||
|
boardId: typeof o.boardId === 'number' ? o.boardId : undefined,
|
||||||
|
startDate: typeof o.startDate === 'string' ? o.startDate : undefined,
|
||||||
|
endDate: typeof o.endDate === 'string' ? o.endDate : undefined,
|
||||||
|
goal: typeof o.goal === 'string' ? o.goal : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse la valeur brute du champ Sprint (tableau d’objets ou de chaînes JSON historiques). */
|
||||||
|
export function parseSprintFieldRaw(raw: unknown): JiraSprintSnapshot[] {
|
||||||
|
if (raw == null) return []
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
const out: JiraSprintSnapshot[] = []
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(item) as Record<string, unknown>
|
||||||
|
const s = coerceSprintObject(o)
|
||||||
|
if (s) out.push(s)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
} else if (typeof item === 'object' && item !== null) {
|
||||||
|
const s = coerceSprintObject(item as Record<string, unknown>)
|
||||||
|
if (s) out.push(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSprintsOnIssue(issue: JiraIssue, fieldId: string | null): JiraSprintSnapshot[] {
|
||||||
|
if (!fieldId) return []
|
||||||
|
const raw = (issue.fields as Record<string, unknown>)[fieldId]
|
||||||
|
return parseSprintFieldRaw(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sprints distincts sur la story et ses sous-tâches (par id). */
|
||||||
|
export function mergeSprintsForGroup(group: StoryGroup, fieldId: string | null): JiraSprintSnapshot[] {
|
||||||
|
if (!fieldId) return []
|
||||||
|
const map = new Map<number, JiraSprintSnapshot>()
|
||||||
|
for (const issue of [group.story, ...group.subtasks]) {
|
||||||
|
for (const sp of getSprintsOnIssue(issue, fieldId)) {
|
||||||
|
map.set(sp.id, sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].sort((a, b) => {
|
||||||
|
const ae = a.endDate ?? ''
|
||||||
|
const be = b.endDate ?? ''
|
||||||
|
if (ae && be) return be.localeCompare(ae)
|
||||||
|
return a.name.localeCompare(b.name, 'fr')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La story ou une sous-tâche est dans le sprint `sprintId`. */
|
||||||
|
export function groupInSprint(group: StoryGroup, sprintId: number, fieldId: string | null): boolean {
|
||||||
|
if (!fieldId) return false
|
||||||
|
return mergeSprintsForGroup(group, fieldId).some((s) => s.id === sprintId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SprintOption = JiraSprintSnapshot & { storyCount: number }
|
||||||
|
|
||||||
|
/** Sprints distincts sur tout le périmètre, avec nombre de stories touchées. */
|
||||||
|
export function collectSprintOptions(groups: StoryGroup[], fieldId: string | null): SprintOption[] {
|
||||||
|
if (!fieldId) return []
|
||||||
|
const acc = new Map<number, { sprint: JiraSprintSnapshot; keys: Set<string> }>()
|
||||||
|
for (const g of groups) {
|
||||||
|
const seen = new Set<number>()
|
||||||
|
for (const issue of [g.story, ...g.subtasks]) {
|
||||||
|
for (const sp of getSprintsOnIssue(issue, fieldId)) {
|
||||||
|
seen.add(sp.id)
|
||||||
|
const cur = acc.get(sp.id)
|
||||||
|
if (cur) {
|
||||||
|
cur.keys.add(g.story.key)
|
||||||
|
} else {
|
||||||
|
acc.set(sp.id, { sprint: sp, keys: new Set([g.story.key]) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...acc.values()]
|
||||||
|
.map(({ sprint, keys }) => ({ ...sprint, storyCount: keys.size }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ae = a.endDate ?? ''
|
||||||
|
const be = b.endDate ?? ''
|
||||||
|
if (ae && be) return be.localeCompare(ae)
|
||||||
|
return b.name.localeCompare(a.name, 'fr')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterGroupsBySprint(
|
||||||
|
groups: StoryGroup[],
|
||||||
|
sprintId: number | null,
|
||||||
|
fieldId: string | null,
|
||||||
|
): StoryGroup[] {
|
||||||
|
if (!fieldId || sprintId == null) return groups
|
||||||
|
return groups.filter((g) => groupInSprint(g, sprintId, fieldId))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user