From 1813603bb3bcc71b3712a873bb1116e448768824 Mon Sep 17 00:00:00 2001
From: Bastien COIGNOUX
Date: Sun, 26 Apr 2026 10:57:13 +0200
Subject: [PATCH] gantt
---
src/App.tsx | 1 +
src/components/DashboardSettingsModal.tsx | 30 ++++++++++++++--
src/components/SprintGanttView.tsx | 28 +++++++++------
src/lib/dashboardConfig.ts | 30 ++++++++++++++++
src/lib/sprintGantt.ts | 42 ++++++++++++++++-------
5 files changed, 107 insertions(+), 24 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index 65e4b37..f5d1550 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -423,6 +423,7 @@ export default function App() {
groups={groups}
sprintFieldId={sprintFieldResolved}
ganttSprintRowMetric={dashboardCfg.ganttSprintRowMetric}
+ ganttNonWorkingDates={dashboardCfg.ganttNonWorkingDates}
onGanttSprintRowMetricChange={setGanttSprintRowMetric}
onOpenSettings={() => setSettingsOpen(true)}
/>
diff --git a/src/components/DashboardSettingsModal.tsx b/src/components/DashboardSettingsModal.tsx
index 8162325..551daa1 100644
--- a/src/components/DashboardSettingsModal.tsx
+++ b/src/components/DashboardSettingsModal.tsx
@@ -5,6 +5,7 @@ import {
GANTT_SPRINT_METRIC_OPTIONS,
mergeImportedConfig,
normalizeFunctionalGapsForSave,
+ parseGanttNonWorkingDatesFromText,
sanitizeExcludedSprintIds,
type DashboardConfig,
type FunctionalGapBadge,
@@ -108,11 +109,18 @@ export function DashboardSettingsModal({ open, config, onClose, onSave, boardSpr
const fileRef = useRef(null)
const titleId = useId()
const [draft, setDraft] = useState(config)
+ const [ganttNonWorkInput, setGanttNonWorkInput] = useState('')
useEffect(() => {
if (open) setDraft(config)
}, [open, config])
+ const configNonWorkKey = config.ganttNonWorkingDates.join('|')
+ useEffect(() => {
+ if (!open) return
+ setGanttNonWorkInput(config.ganttNonWorkingDates.join('\n'))
+ }, [open, configNonWorkKey])
+
useEffect(() => {
const el = dialogRef.current
if (!el) return
@@ -146,8 +154,10 @@ export function DashboardSettingsModal({ open, config, onClose, onSave, boardSpr
try {
const parsed = JSON.parse(String(reader.result)) as unknown
const merged = mergeImportedConfig(draft, parsed)
- if (merged) setDraft(merged)
- else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).')
+ if (merged) {
+ setDraft(merged)
+ setGanttNonWorkInput(merged.ganttNonWorkingDates.join('\n'))
+ } else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).')
} catch {
alert('Impossible de lire ce fichier JSON.')
}
@@ -373,6 +383,21 @@ export function DashboardSettingsModal({ open, config, onClose, onSave, boardSpr
))}
+
+
+ Une date par ligne au format AAAA-MM-JJ (fuseau
+ local du navigateur). Même style que sam. / dim. : fériés, ponts, fermeture.
+
+
)}
@@ -457,8 +463,10 @@ export function SprintGanttView({
)
const barTitle =
sprintFieldId && delivery.total > 0
- ? `${s.name}\n${formatSprintRangeFr(s)}\nSous-tâches : ${delivery.done} / ${delivery.total} (${delivery.percent} %)\n${subtitleLines.join('\n')}`
- : `${s.name}\n${formatSprintRangeFr(s)}\nAvancée calendaire : ${fill} %\n${subtitleLines.join('\n')}`
+ ? `${s.name}\n${formatSprintRangeFr(s)}\nBarre : ${delivery.percent} % des sous-tâches terminées (${delivery.done} / ${delivery.total})\n${subtitleLines.join('\n')}`
+ : sprintFieldId
+ ? `${s.name}\n${formatSprintRangeFr(s)}\nBarre : 0 % — aucune sous-tâche (non annulée) dans ce sprint avec le champ Sprint.\n${subtitleLines.join('\n')}`
+ : `${s.name}\n${formatSprintRangeFr(s)}\nBarre : 0 % — configurez le champ Sprint dans les réglages.\n${subtitleLines.join('\n')}`
return (
diff --git a/src/lib/dashboardConfig.ts b/src/lib/dashboardConfig.ts
index 48455bd..e08a3b2 100644
--- a/src/lib/dashboardConfig.ts
+++ b/src/lib/dashboardConfig.ts
@@ -149,6 +149,28 @@ export function sanitizeExcludedSprintIds(raw: unknown): number[] {
return [...new Set(out)]
}
+const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
+
+/** Jours fériés / ponts (clé locale yyyy-mm-dd) — même fond atténué que les week-ends sur le Gantt. */
+export function sanitizeGanttNonWorkingDates(raw: unknown): string[] {
+ if (!Array.isArray(raw)) return []
+ const out: string[] = []
+ for (const x of raw) {
+ const s = typeof x === 'string' ? x.trim().slice(0, 10) : ''
+ if (ISO_DATE_RE.test(s)) out.push(s)
+ }
+ return [...new Set(out)].sort()
+}
+
+/** Parse une zone de texte (lignes ou séparateurs virgule / point-virgule). */
+export function parseGanttNonWorkingDatesFromText(raw: string): string[] {
+ const parts = raw
+ .split(/[\n,;\t]+/)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ return sanitizeGanttNonWorkingDates(parts)
+}
+
export type DashboardConfig = {
version: 1
milestones: Milestone[]
@@ -174,6 +196,8 @@ export type DashboardConfig = {
excludedSprintIds: number[]
/** Lignes d’info sous les barres de sprint (Gantt). */
ganttSprintRowMetric: GanttSprintRowMetric
+ /** Dates non travaillées (yyyy-mm-dd, fuseau local) — fond Gantt comme week-end. */
+ ganttNonWorkingDates: string[]
}
const STORAGE_KEY = 'dcc-dashboard-config-v1'
@@ -189,6 +213,7 @@ export const defaultDashboardConfig = (): DashboardConfig => ({
functionalGaps: defaultFunctionalGaps(),
excludedSprintIds: [],
ganttSprintRowMetric: defaultGanttSprintRowMetric(),
+ ganttNonWorkingDates: [],
})
/** Import : configuration seule, ou bundle Synology `{ bundleVersion, dashboard }`. */
@@ -246,6 +271,7 @@ export function loadDashboardConfig(): DashboardConfig {
functionalGaps: sanitizeFunctionalGapsArray(parsed.functionalGaps, defaultFunctionalGaps()),
excludedSprintIds: sanitizeExcludedSprintIds(parsed.excludedSprintIds),
ganttSprintRowMetric: sanitizeGanttSprintRowMetric(parsed.ganttSprintRowMetric),
+ ganttNonWorkingDates: sanitizeGanttNonWorkingDates(parsed.ganttNonWorkingDates),
}
} catch {
return defaultDashboardConfig()
@@ -329,5 +355,9 @@ export function mergeImportedConfig(
o.ganttSprintRowMetric !== undefined
? sanitizeGanttSprintRowMetric(o.ganttSprintRowMetric)
: current.ganttSprintRowMetric,
+ ganttNonWorkingDates:
+ o.ganttNonWorkingDates !== undefined
+ ? sanitizeGanttNonWorkingDates(o.ganttNonWorkingDates)
+ : current.ganttNonWorkingDates,
}
}
diff --git a/src/lib/sprintGantt.ts b/src/lib/sprintGantt.ts
index 55433c2..78622dd 100644
--- a/src/lib/sprintGantt.ts
+++ b/src/lib/sprintGantt.ts
@@ -200,19 +200,33 @@ export function monthTicksBetween(startMs: number, endMs: number): { ms: number;
return ticks
}
-/** Colonne calendaire (jour) projetée sur la timeline — week-ends, grille jour. */
+/** Colonne calendaire (jour) projetée sur la timeline — week-ends, jours configurés, grille. */
export type GanttDayColumn = {
dayStartMs: number
weekday: number
x0: number
x1: number
- isWeekend: boolean
+ /** Fond atténué : samedi / dimanche ou date listée dans les réglages Gantt. */
+ isGanttNonWork: boolean
+}
+
+function localIsoDateKey(dayStartMs: number): string {
+ const d = new Date(dayStartMs)
+ const y = d.getFullYear()
+ const m = String(d.getMonth() + 1).padStart(2, '0')
+ const day = String(d.getDate()).padStart(2, '0')
+ return `${y}-${m}-${day}`
}
/**
* 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[] {
+export function ganttDayColumns(
+ startMs: number,
+ endMs: number,
+ widthPx: number,
+ nonWorkingKeys?: ReadonlySet,
+): GanttDayColumn[] {
const cols: GanttDayColumn[] = []
const iter = new Date(startMs)
iter.setHours(0, 0, 0, 0)
@@ -229,12 +243,15 @@ export function ganttDayColumns(startMs: number, endMs: number, widthPx: number)
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) {
+ const key = localIsoDateKey(dayStart)
+ const isWeekend = wd === 0 || wd === 6
+ const isConfigured = nonWorkingKeys?.has(key) ?? false
cols.push({
dayStartMs: dayStart,
weekday: wd,
x0,
x1,
- isWeekend: wd === 0 || wd === 6,
+ isGanttNonWork: isWeekend || isConfigured,
})
}
}
@@ -308,10 +325,10 @@ export function ganttSubheaderTicks(
for (let i = 0; i < cols.length; i += step) {
const c = cols[i]!
const d = new Date(c.dayStartMs)
+ const wdShort = d.toLocaleDateString('fr-FR', { weekday: 'short' }).replace(/\.$/, '')
+ const dom = d.getDate()
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' })
+ step === 1 && avgW >= 26 ? `${wdShort} ${dom}` : `${dom}`
const x = (c.x0 + c.x1) / 2
const major = d.getDate() === 1 || d.getDay() === 1
out.push({ ms: c.dayStartMs, label, x, major })
@@ -326,7 +343,8 @@ export function ganttSubheaderTicks(
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 wdShort = d.toLocaleDateString('fr-FR', { weekday: 'short' }).replace(/\.$/, '')
+ const label = `${wdShort} ${d.getDate()}`
const x = msToX(t + 3.5 * MS_DAY, startMs, endMs, widthPx)
out.push({ ms: t, label, x, major: true })
}
@@ -427,8 +445,8 @@ export function sprintTimeElapsedPercent(s: JiraSprintSnapshot, nowMs = Date.now
}
/**
- * Remplissage de la barre : priorité au % sous-tâches terminées (périmètre + champ Sprint),
- * sinon avancement calendaire du sprint.
+ * Remplissage de la barre : uniquement le % de sous-tâches terminées (périmètre + champ Sprint).
+ * Pas d’avancement calendaire : sans données, la barre reste à 0 %.
*/
export function sprintBarFillPercent(
s: JiraSprintSnapshot,
@@ -437,8 +455,8 @@ export function sprintBarFillPercent(
cfg: StatusBucketConfig,
): number {
const delivery = epicScopeSprintProgress(groups, s.id, fieldId, cfg)
- if (fieldId && delivery.total > 0) return Math.min(100, delivery.percent)
- return sprintTimeElapsedPercent(s)
+ if (!fieldId || delivery.total === 0) return 0
+ return Math.min(100, delivery.percent)
}
export function milestoneTooltipText(m: Milestone): string {