This commit is contained in:
Bastien COIGNOUX
2026-04-26 10:38:53 +02:00
parent 020f5d11de
commit f32e74c713
2 changed files with 302 additions and 51 deletions

View File

@ -9,13 +9,17 @@ import {
epicScopeSprintProgress,
formatGanttSprintSubtitleLines,
formatSprintRangeFr,
ganttDayColumns,
ganttMonthBands,
ganttRangeFromSprintsAndMilestones,
ganttSubheaderTicks,
ganttVerticalGuideXs,
milestoneTooltipText,
msToX,
parseIsoMs,
pixelsPerDay,
timelineTicks,
timelineWidthPx,
type GanttDayColumn,
type GanttTimeScale,
sprintBarBounds,
sprintBarFillPercent,
@ -84,6 +88,57 @@ const SCALE_LABELS: Record<GanttTimeScale, string> = {
month: 'Mois',
}
function GanttTimelineBackdrop({
dayColumns,
guideXs,
todayX,
todayClamped,
compact,
}: {
dayColumns: GanttDayColumn[]
guideXs: number[]
todayX: number
todayClamped: 'before' | 'inside' | 'after'
compact?: boolean
}) {
const insetY = compact ? 'bottom-1 top-1' : 'inset-y-0'
return (
<>
{dayColumns
.filter((c) => c.isWeekend)
.map((c) => (
<div
key={c.dayStartMs}
className={`pointer-events-none absolute z-0 bg-slate-800/[0.38] ${insetY}`}
style={{ left: `${c.x0}px`, width: `${Math.max(0, c.x1 - c.x0)}px` }}
aria-hidden
/>
))}
{guideXs.map((gx, i) => (
<div
key={`vg-${i}-${Math.round(gx * 10)}`}
className={`pointer-events-none absolute z-[1] w-px bg-slate-600/50 ${insetY}`}
style={{ left: `${gx}px` }}
aria-hidden
/>
))}
<div
className={`pointer-events-none absolute z-[2] w-0 border-l-2 border-dashed border-emerald-400/95 shadow-[0_0_10px_rgba(52,211,153,0.35)] ${insetY} ${
todayClamped !== 'inside' ? 'opacity-45' : ''
}`}
style={{ left: `${todayX}px` }}
title={
todayClamped === 'inside'
? 'Aujourdhui'
: todayClamped === 'before'
? 'Aujourdhui (avant la période affichée)'
: 'Aujourdhui (après la période affichée)'
}
/>
</>
)
}
export function SprintGanttView({
sprints,
milestones,
@ -132,10 +187,21 @@ export function SprintGanttView({
[startMs, endMs, ppd],
)
const maxTicks = useMemo(() => Math.floor(widthPx / 72), [widthPx])
const ticks = useMemo(
() => timelineTicks(timeScale, startMs, endMs, maxTicks),
[timeScale, startMs, endMs, maxTicks],
const dayColumns = useMemo(
() => ganttDayColumns(startMs, endMs, widthPx),
[startMs, endMs, widthPx],
)
const monthBands = useMemo(
() => ganttMonthBands(startMs, endMs, widthPx),
[startMs, endMs, widthPx],
)
const subheaderTicks = useMemo(
() => ganttSubheaderTicks(timeScale, startMs, endMs, widthPx),
[timeScale, startMs, endMs, widthPx],
)
const guideXs = useMemo(
() => ganttVerticalGuideXs(timeScale, startMs, endMs, widthPx),
[timeScale, startMs, endMs, widthPx],
)
const todayLine = useMemo(() => {
@ -204,8 +270,9 @@ export function SprintGanttView({
</h2>
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-slate-500">
Échelle <span className="text-slate-400">jour / semaine / mois</span> et zoom (loupe) : la
timeline sétire en pixels par jour faites défiler horizontalement. Barre = charge
(champ Sprint) ou avancement calendaire. Losanges = jalons (survol pour le détail).
timeline sétire en pixels par jour faites défiler horizontalement. En-tête : mois puis
jours / repères ; week-ends en fond plus sombre. Barre = charge (champ Sprint) ou
avancement calendaire. Losanges = jalons (survol pour le détail).
</p>
</div>
<button
@ -310,49 +377,50 @@ export function SprintGanttView({
width: `${220 + widthPx}px`,
}}
>
<div className="sticky left-0 z-30 border-b border-r border-white/[0.08] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm">
<div className="sticky left-0 z-40 flex min-h-[4.25rem] flex-col justify-center border-b border-r border-white/[0.08] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm">
Piste
</div>
<div className="relative h-9 border-b border-white/[0.08] bg-slate-900/40">
{ticks.map((t) => {
const x = msToX(t.ms, startMs, endMs, widthPx)
return (
<div
className="relative min-h-[4.25rem] border-b border-white/[0.08] bg-slate-900/55"
style={{ width: `${widthPx}px` }}
>
<div className="relative h-8 border-b border-white/15">
{monthBands.map((b) => (
<div
key={b.monthStartMs}
className="absolute inset-y-0 flex items-center justify-center border-r border-white/20 bg-slate-900/80 text-[11px] font-semibold capitalize tracking-tight text-slate-200"
style={{ left: `${b.x0}px`, width: `${Math.max(0, b.x1 - b.x0)}px` }}
>
<span className="truncate px-1.5 text-center" title={b.label}>
{b.label}
</span>
</div>
))}
</div>
<div className="relative h-7">
{subheaderTicks.map((t, idx) => (
<span
key={t.ms}
className="absolute top-1 -translate-x-1/2 whitespace-nowrap text-[10px] text-slate-400"
style={{ left: `${x}px` }}
key={`${timeScale}-${t.ms}-${idx}`}
className={`absolute top-1 -translate-x-1/2 select-none whitespace-nowrap text-[10px] ${
t.major ? 'font-semibold text-slate-300' : 'text-slate-500'
}`}
style={{ left: `${t.x}px` }}
>
{t.label}
</span>
)
})}
))}
</div>
</div>
<div className="sticky left-0 z-30 flex items-center border-b border-r border-white/[0.06] bg-slate-950/95 px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-violet-300/90 backdrop-blur-sm">
Jalons
</div>
<div className="relative h-12 border-b border-white/[0.06] bg-slate-900/50">
{ticks.map((t) => (
<div
key={`jg-${t.ms}`}
className={`pointer-events-none absolute bottom-0 top-0 w-px ${
t.major ? 'bg-slate-500/50' : 'border-l border-dashed border-slate-600/40'
}`}
style={{ left: `${msToX(t.ms, startMs, endMs, widthPx)}px` }}
/>
))}
<div
className={`pointer-events-none absolute bottom-0 top-0 z-10 w-px bg-emerald-400 shadow-[0_0_10px_rgba(52,211,153,0.65)] ${
todayLine.clamped !== 'inside' ? 'opacity-45' : ''
}`}
style={{ left: `${todayLine.x}px` }}
title={
todayLine.clamped === 'inside'
? 'Aujourdhui'
: todayLine.clamped === 'before'
? 'Aujourdhui (avant la période affichée)'
: 'Aujourdhui (après la période affichée)'
}
<GanttTimelineBackdrop
dayColumns={dayColumns}
guideXs={guideXs}
todayX={todayLine.x}
todayClamped={todayLine.clamped}
/>
{sortedMilestones.map((m) => {
const ms = parseIsoMs(`${m.date}T12:00:00`)
@ -406,18 +474,12 @@ export function SprintGanttView({
</div>
<div className="relative min-h-[48px] border-b border-white/[0.06] bg-slate-900/30 py-2">
<div className="relative mx-0 h-10">
{ticks.map((t) => (
<div
key={`sg-${s.id}-${t.ms}`}
className="pointer-events-none absolute bottom-1 top-1 w-px bg-slate-700/35"
style={{ left: `${msToX(t.ms, startMs, endMs, widthPx)}px` }}
/>
))}
<div
className={`pointer-events-none absolute bottom-1 top-1 z-10 w-px bg-emerald-400/85 ${
todayLine.clamped !== 'inside' ? 'opacity-40' : ''
}`}
style={{ left: `${todayLine.x}px` }}
<GanttTimelineBackdrop
dayColumns={dayColumns}
guideXs={guideXs}
todayX={todayLine.x}
todayClamped={todayLine.clamped}
compact
/>
<div
className="absolute bottom-1 top-1 overflow-hidden rounded-full bg-gradient-to-r from-slate-700/90 to-sky-950/50 ring-1 ring-sky-500/25"

View File

@ -109,7 +109,8 @@ export function timelineTicks(
return ticks
}
function startOfWeekMondayLocal(ms: number): number {
/** Premier instant (midi local) du lundi de la semaine calendaire contenant `ms`. */
export function startOfWeekMondayLocal(ms: number): number {
const d = new Date(ms)
d.setHours(12, 0, 0, 0)
const day = d.getDay()
@ -199,6 +200,194 @@ export function monthTicksBetween(startMs: number, endMs: number): { ms: number;
return ticks
}
/** Colonne calendaire (jour) projetée sur la timeline — week-ends, grille jour. */
export type GanttDayColumn = {
dayStartMs: number
weekday: number
x0: number
x1: number
isWeekend: boolean
}
/**
* Une entrée par jour local qui intersecte [startMs, endMs] (bornes en pixels pour le fond).
*/
export function ganttDayColumns(startMs: number, endMs: number, widthPx: number): GanttDayColumn[] {
const cols: GanttDayColumn[] = []
const iter = new Date(startMs)
iter.setHours(0, 0, 0, 0)
if (iter.getTime() > startMs) iter.setDate(iter.getDate() - 1)
let guard = 0
while (iter.getTime() < endMs + MS_DAY && guard++ < 800) {
const dayStart = iter.getTime()
const wd = iter.getDay()
const next = new Date(iter)
next.setDate(next.getDate() + 1)
const dayEnd = next.getTime()
if (dayEnd > startMs && dayStart < endMs) {
const x0 = msToX(Math.max(dayStart, startMs), startMs, endMs, widthPx)
const x1 = msToX(Math.min(dayEnd, endMs), startMs, endMs, widthPx)
if (x1 > x0 + 0.02) {
cols.push({
dayStartMs: dayStart,
weekday: wd,
x0,
x1,
isWeekend: wd === 0 || wd === 6,
})
}
}
iter.setDate(iter.getDate() + 1)
}
return cols
}
/** Bandeau « mois + année » en tête de Gantt (aligné sur les mois calendaires). */
export type GanttMonthBand = {
label: string
x0: number
x1: number
monthStartMs: number
}
export function ganttMonthBands(startMs: number, endMs: number, widthPx: number): GanttMonthBand[] {
const bands: GanttMonthBand[] = []
const cur = new Date(startMs)
cur.setDate(1)
cur.setHours(0, 0, 0, 0)
if (cur.getTime() > startMs) cur.setMonth(cur.getMonth() - 1)
let guard = 0
while (cur.getTime() < endMs && guard++ < 48) {
const monthStart = cur.getTime()
const next = new Date(cur)
next.setMonth(next.getMonth() + 1)
const monthEnd = next.getTime()
const x0 = msToX(Math.max(monthStart, startMs), startMs, endMs, widthPx)
const x1 = msToX(Math.min(monthEnd, endMs), startMs, endMs, widthPx)
if (x1 > x0 + 2) {
const label = new Date(monthStart).toLocaleDateString('fr-FR', {
month: 'long',
year: 'numeric',
})
bands.push({ label, x0, x1, monthStartMs: monthStart })
}
cur.setMonth(cur.getMonth() + 1)
}
return bands
}
export type GanttSubheaderTick = { ms: number; label: string; x: number; major: boolean }
function thinSubheaderTicks(ticks: GanttSubheaderTick[], max: number): GanttSubheaderTick[] {
if (ticks.length <= max) return ticks
const step = Math.ceil(ticks.length / max)
return ticks.filter((_, i) => i % step === 0)
}
/**
* Libellés de la rangée inférieure (jours / semaines / repères mois) sous les bandeaux mensuels.
*/
export function ganttSubheaderTicks(
scale: GanttTimeScale,
startMs: number,
endMs: number,
widthPx: number,
): GanttSubheaderTick[] {
const spanDays = Math.max(1 / 24, (endMs - startMs) / MS_DAY)
const maxLabels = Math.max(10, Math.min(64, Math.floor(widthPx / 48)))
if (scale === 'day') {
const cols = ganttDayColumns(startMs, endMs, widthPx)
if (cols.length === 0) return []
const avgW = widthPx / spanDays
const step =
avgW >= 24 ? 1 : avgW >= 15 ? 2 : avgW >= 10 ? 3 : Math.max(1, Math.ceil(cols.length / maxLabels))
const out: GanttSubheaderTick[] = []
for (let i = 0; i < cols.length; i += step) {
const c = cols[i]!
const d = new Date(c.dayStartMs)
const label =
step === 1 && avgW >= 26
? d.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' })
: d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })
const x = (c.x0 + c.x1) / 2
const major = d.getDate() === 1 || d.getDay() === 1
out.push({ ms: c.dayStartMs, label, x, major })
}
return thinSubheaderTicks(out, maxLabels)
}
if (scale === 'week') {
const out: GanttSubheaderTick[] = []
let t = startOfWeekMondayLocal(startMs - 8 * MS_DAY)
let guard = 0
while (t <= endMs + MS_DAY && guard++ < 140) {
if (t + 6 * MS_DAY >= startMs) {
const d = new Date(t)
const label = d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
const x = msToX(t + 3.5 * MS_DAY, startMs, endMs, widthPx)
out.push({ ms: t, label, x, major: true })
}
t += 7 * MS_DAY
}
return thinSubheaderTicks(out, maxLabels)
}
/* month */
const out: GanttSubheaderTick[] = []
const d = new Date(startMs)
d.setDate(1)
d.setHours(12, 0, 0, 0)
if (d.getTime() > startMs) d.setMonth(d.getMonth() - 1)
let g = 0
while (d.getTime() < endMs && g++ < 40) {
const y = d.getFullYear()
const m = d.getMonth()
for (const dom of [1, 8, 15, 22]) {
const cur = new Date(y, m, dom, 12, 0, 0, 0)
const ms = cur.getTime()
if (ms < startMs || ms > endMs) continue
const x = msToX(ms, startMs, endMs, widthPx)
out.push({
ms,
label: String(dom),
x,
major: dom === 1,
})
}
d.setMonth(d.getMonth() + 1)
}
return thinSubheaderTicks(out, maxLabels)
}
/** Positions X des guides verticaux (limiter le nombre quand le zoom est faible). */
export function ganttVerticalGuideXs(
scale: GanttTimeScale,
startMs: number,
endMs: number,
widthPx: number,
): number[] {
const span = Math.max(1, endMs - startMs)
const ppdEff = widthPx / (span / MS_DAY)
if (ppdEff >= 2.4) {
return ganttDayColumns(startMs, endMs, widthPx).map((c) => c.x0)
}
if (ppdEff >= 0.95 || scale === 'week') {
const xs: number[] = []
let t = startOfWeekMondayLocal(startMs - 3 * MS_DAY)
let guard = 0
while (t <= endMs + MS_DAY && guard++ < 220) {
xs.push(msToX(t, startMs, endMs, widthPx))
t += 7 * MS_DAY
}
return xs
}
return ganttMonthBands(startMs, endMs, widthPx).map((b) => b.x0)
}
export function pctOnSpan(ms: number, startMs: number, endMs: number): number {
const span = Math.max(1, endMs - startMs)
return ((ms - startMs) / span) * 100