Compare commits

..

5 Commits

Author SHA1 Message Date
89c37cf28d docker 2026-04-26 22:50:35 +02:00
1813603bb3 gantt 2026-04-26 10:57:13 +02:00
f32e74c713 gantt 2026-04-26 10:38:53 +02:00
020f5d11de gantt 2026-04-24 21:08:34 +02:00
19af51160a sprint 2026-04-24 15:23:23 +02:00
34 changed files with 3710 additions and 105 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
node_modules
dist
.git
.github
*.md
.env
.env.*
!.env.example
!.env.deploy.example
coverage
.vscode
.cursor
terminals
**/*.log

View File

@ -22,6 +22,13 @@ 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
# Board logiciel DCC (URL …/boards/1445/) : sprints actifs/futurs via API Agile (sans élargir le JQL).
# 0 ou false = désactiver (liste déduite uniquement des tickets chargés).
# VITE_JIRA_BOARD_ID=1445
# Clé de lépopée : utilisée dans le JQL (parentEpic + exclusion) et pour le groupement UI # 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

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ dist-ssr
.env .env
.env.* .env.*
!.env.example !.env.example
.env.deploy
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

40
Dockerfile Normal file
View File

@ -0,0 +1,40 @@
# Build front Vite (variables VITE_* et JIRA_DOMAIN figées au build)
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Origine Jira pour les liens « ouvrir le ticket » (__JIRA_ORIGIN__ dans vite.config.js)
ARG JIRA_DOMAIN=
ENV JIRA_DOMAIN=$JIRA_DOMAIN
# Obligatoire en prod : URL de ton proxy HTTPS vers Jira (même origine ou sous-chemin)
ARG VITE_JIRA_BASE_URL=
ENV VITE_JIRA_BASE_URL=$VITE_JIRA_BASE_URL
ARG VITE_JIRA_BROWSE_BASE_URL=
ENV VITE_JIRA_BROWSE_BASE_URL=$VITE_JIRA_BROWSE_BASE_URL
ARG VITE_JIRA_EPIC_KEY=
ENV VITE_JIRA_EPIC_KEY=$VITE_JIRA_EPIC_KEY
ARG VITE_JIRA_PAGE_SIZE=
ENV VITE_JIRA_PAGE_SIZE=$VITE_JIRA_PAGE_SIZE
ARG VITE_JIRA_BOARD_ID=
ENV VITE_JIRA_BOARD_ID=$VITE_JIRA_BOARD_ID
ARG VITE_JIRA_SPRINT_FIELD=
ENV VITE_JIRA_SPRINT_FIELD=$VITE_JIRA_SPRINT_FIELD
ARG VITE_JIRA_STORY_POINTS_FIELD=
ENV VITE_JIRA_STORY_POINTS_FIELD=$VITE_JIRA_STORY_POINTS_FIELD
ARG VITE_MY_JIRA_ACCOUNT_ID=
ENV VITE_MY_JIRA_ACCOUNT_ID=$VITE_MY_JIRA_ACCOUNT_ID
ARG VITE_MY_JIRA_EMAIL=
ENV VITE_MY_JIRA_EMAIL=$VITE_MY_JIRA_EMAIL
RUN npm run build
FROM nginx:alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

31
deploy/hooks.json.example Normal file
View File

@ -0,0 +1,31 @@
[
{
"id": "jira-descours-deploy",
"execute-command": "/config/deploy.sh",
"command-working-directory": "/",
"trigger-rule": {
"and": [
{
"match": {
"type": "value",
"value": "refs/heads/main",
"parameter": {
"source": "payload",
"name": "ref"
}
}
},
{
"match": {
"type": "value",
"value": "CHANGEME_SECRET_TOKEN",
"parameter": {
"source": "url",
"name": "token"
}
}
}
]
}
}
]

View File

@ -0,0 +1,8 @@
#!/bin/sh
# Copier vers le NAS (ex. /volume1/docker/jira-descours/deploy.sh), chmod +x,
# adapter APP_DIR et redémarrer le conteneur « webhook » qui linvoque.
set -e
APP_DIR="/volume1/docker/jira-descours/src"
cd "$APP_DIR"
git pull --ff-only
docker compose --env-file .env.deploy -f docker-compose.yml up -d --build

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
# Déploiement NAS : variables dans un fichier `.env.deploy` à côté de ce fichier (non versionné).
# Voir docs/DEPLOY_SYNOLOGY_GITEA.md
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
JIRA_DOMAIN: ${JIRA_DOMAIN:-}
VITE_JIRA_BASE_URL: ${VITE_JIRA_BASE_URL:-}
VITE_JIRA_BROWSE_BASE_URL: ${VITE_JIRA_BROWSE_BASE_URL:-}
VITE_JIRA_EPIC_KEY: ${VITE_JIRA_EPIC_KEY:-}
VITE_JIRA_PAGE_SIZE: ${VITE_JIRA_PAGE_SIZE:-}
VITE_JIRA_BOARD_ID: ${VITE_JIRA_BOARD_ID:-}
VITE_JIRA_SPRINT_FIELD: ${VITE_JIRA_SPRINT_FIELD:-}
VITE_JIRA_STORY_POINTS_FIELD: ${VITE_JIRA_STORY_POINTS_FIELD:-}
VITE_MY_JIRA_ACCOUNT_ID: ${VITE_MY_JIRA_ACCOUNT_ID:-}
VITE_MY_JIRA_EMAIL: ${VITE_MY_JIRA_EMAIL:-}
image: jira-descours:local
ports:
- "${HOST_PORT:-8080}:80"
restart: unless-stopped

18
docker/nginx.conf Normal file
View File

@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
}

View File

@ -0,0 +1,227 @@
# Déploiement automatique : Gitea + Docker sur Synology NAS
Ce dépôt contient un **`Dockerfile`** et un **`docker-compose.yml`** pour servir le build Vite avec **Nginx**.
Lobjectif : à chaque **push sur `main`**, le NAS **tire** le code et **reconstruit** le conteneur.
---
## Vue densemble
| Étape | Rôle |
|--------|------|
| 13 | Cloner le dépôt sur le NAS, créer `.env.deploy` |
| 4 | Premier déploiement manuel (`docker compose up`) |
| 57 | Conteneur **webhook** qui exécute un script au push Gitea |
| 8 | Configurer le **webhook** dans Gitea (URL + secret en query) |
| 910 | **Proxy inverse** Synology + HTTPS pour laccès extérieur |
| 1112 | Vérifications et dépannage |
Chemins dexemple : `/volume1/docker/jira-descours/` — à adapter à ton volume DSM.
---
## 1. Préparer un dossier sur le NAS
En **SSH** (utilisateur admin ou compte avec droits `docker`) :
```sh
mkdir -p /volume1/docker/jira-descours
cd /volume1/docker/jira-descours
```
---
## 2. Cloner le dépôt Gitea (une fois)
Utilise lURL **SSH** ou **HTTPS** de ton Gitea.
**Dépôt privé (recommandé)** : créer une **clé SSH** sur le NAS, enregistrer la clé **publique** dans Gitea
(*Paramètres du dépôt → Clés de déploiement* ou compte utilisateur → Clés SSH).
```sh
cd /volume1/docker/jira-descours
git clone git@GITEA_HOST:UTILISATEUR/jira-descours.git src
cd src
```
Le dossier `src` contiendra le `docker-compose.yml` à la racine du dépôt après clone.
---
## 3. Fichier denvironnement de build `.env.deploy`
Sur le NAS, dans **`src/`** (racine du clone) :
```sh
cp .env.deploy.example .env.deploy
nano .env.deploy
```
Renseigne au minimum :
- **`JIRA_DOMAIN`** — sous-domaine Atlassian (comme en dev).
- **`VITE_JIRA_BASE_URL`** — URL **HTTPS** de ton **proxy Jira** (obligatoire en prod : le navigateur nutilise pas le proxy Vite du `npm run dev`).
Optionnel : `VITE_JIRA_*` comme dans `.env.example`.
**`HOST_PORT`** : port sur lequel le NAS écoute (ex. `8080`). Tu le brancheras ensuite sur le **proxy inverse** DSM.
Ne **commit pas** `.env.deploy` (déjà ignoré par `.gitignore`).
---
## 4. Premier build manuel
Toujours dans `src/` :
```sh
docker compose --env-file .env.deploy up -d --build
```
Teste en local (LAN) : `http://IP_DU_NAS:8080` (ou le port choisi).
---
## 5. Script de déploiement appelé par le webhook
Toujours sur le NAS, crée un script **hors** du dépôt (pour ne pas lécraser au `git pull`), par ex. :
```sh
sudo tee /volume1/docker/jira-descours/deploy.sh <<'EOF'
#!/bin/sh
set -e
APP_DIR="/volume1/docker/jira-descours/src"
cd "$APP_DIR"
git pull --ff-only
docker compose --env-file .env.deploy -f docker-compose.yml up -d --build
EOF
sudo chmod +x /volume1/docker/jira-descours/deploy.sh
```
Adapte **`APP_DIR`** si ton chemin diffère.
---
## 6. Fichier `hooks.json` pour [adnanh/webhook](https://github.com/adnanh/webhook)
Copie `deploy/hooks.json.example` vers le NAS, par ex. :
`/volume1/docker/jira-descours/hooks.json`
1. Remplace **`CHANGEME_SECRET_TOKEN`** par un **secret long** (génère-en un et garde-le pour Gitea).
2. Vérifie que **`refs/heads/main`** correspond à ta branche de prod (sinon change la valeur).
---
## 7. Lancer le récepteur webhook (Docker)
Le conteneur webhook doit pouvoir exécuter **`docker`** : montage du socket Docker.
**Choisir une IP joignable depuis le conteneur Gitea** :
- Gitea **hors Docker** sur le NAS → souvent `http://127.0.0.1:PORT` depuis le NAS… mais le webhook doit écouter sur une interface que Gitea peut appeler.
- Gitea **dans Docker** sur le même hôte → souvent `http://172.17.0.1:9888` (bridge Docker vers lhôte) ou **lIP LAN du NAS** (`http://192.168.x.x:9888`).
Exemple (adapter chemins et tag dimage) :
```sh
docker run -d --name gitea-deploy-hook --restart unless-stopped \
-p 127.0.0.1:9888:9000 \
-v /volume1/docker/jira-descours/hooks.json:/etc/webhook/hooks.json:ro \
-v /volume1/docker/jira-descours/deploy.sh:/config/deploy.sh:ro \
-v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/adnanh/webhook:2.8.2 \
-verbose -hooks=/etc/webhook/hooks.json
```
- **`-p 127.0.0.1:9888:9000`** : le hook nest pas exposé sur toute la carte réseau (plus sûr). Gitea sur le **même hôte** doit joindre `127.0.0.1:9888` **depuis lhôte** ; si Gitea est **dans un autre conteneur**, utilise plutôt lIP LAN du NAS et `-p 9888:9000` **avec pare-feu** ou réseau Docker partagé.
Vérifie la doc de ton image `webhook` pour le chemin des hooks (`-hooks=...`).
Test manuel (depuis le NAS) :
```sh
curl "http://127.0.0.1:9888/hooks/jira-descours-deploy?token=CHANGEME_SECRET_TOKEN" \
-X POST -H "Content-Type: application/json" \
-d '{"ref":"refs/heads/main"}'
```
Le hook doit sexécuter (voir logs du conteneur `gitea-deploy-hook`).
---
## 8. Webhook dans Gitea
1. Ouvre le dépôt → **Paramètres****Webhooks****Ajouter un webhook****Gitea**.
2. **URL cible** (exemple si hook sur le NAS, port 9888, secret en query) :
`http://IP_LAN_DU_NAS:9888/hooks/jira-descours-deploy?token=TON_SECRET_LONG`
Si Gitea et le hook sont sur le **même** OS Docker, teste dabord avec lIP LAN ; ajuste selon ce qui fonctionne chez toi.
3. **Déclencher sur** : « Push » (événements de push).
4. Branche : si lUI le permet, limite à **`main`** ; sinon le filtre est déjà dans `hooks.json` (`ref`).
Enregistre, puis **« Test de livraison »** ou un **push** sur `main` : le site doit se reconstruire après quelques minutes.
---
## 9. Accès depuis lextérieur (HTTPS)
1. **Nom de domaine** ou **DDNS Synology** pointant vers lIP publique de ta box.
2. **Box internet** : redirection **TCP 443** (et éventuellement **80**) vers l**IP du NAS** (même ports ou ceux de ton reverse proxy).
3. Sur DSM : **Panneau de configuration****Portail de connexion****Proxy inverse** :
- **Nom dhôte** : ex. `jira-descours.mondomaine.fr`
- **Destination** : `http://127.0.0.1:8080` (ou le `HOST_PORT` défini dans `.env.deploy`)
- **HTTPS** activé, certificat Lets Encrypt pour ce nom dhôte.
4. Accès : `https://jira-descours.mondomaine.fr`
**Alternative sans ouvrir les ports** : [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) sur le NAS (très courant en homelab).
---
## 10. Sécurité (rappel court)
- Secret **long** dans lURL du webhook ; ne le commite pas.
- Préférer le hook en **127.0.0.1** si Gitea appelle depuis la même machine ; sinon **pare-feu** DSM limitant le port du hook aux IP de confiance.
- Mettre à jour DSM, Gitea et les images Docker régulièrement.
---
## 11. Check-list après un push
- Gitea → **Paramètres du dépôt****Webhooks** : dernière livraison en vert.
- Sur le NAS : `docker logs gitea-deploy-hook` puis `docker ps` : conteneur `web` à jour.
- Site public : hard refresh (Ctrl+F5) pour éviter le cache du navigateur.
---
## 12. Dépannage
| Problème | Piste |
|----------|--------|
| `git pull` échoue | Clé SSH / droits dépôt ; URL du `origin`. |
| Build Docker OOM | NAS peu de RAM : build sur une machine CI puis `docker pull` (évolution). |
| Page blanche / erreur Jira | `VITE_JIRA_BASE_URL` absent ou incorrect au **build** ; refaire `up --build` après correction de `.env.deploy`. |
| Webhook jamais reçu | URL depuis le conteneur Gitea (test `curl` depuis **dedans** le conteneur Gitea vers lURL du hook). |
| 403 Lets Encrypt | Ports 80/443 bien redirigés ; nom DNS correct. |
---
## Fichiers utiles dans ce dépôt
| Fichier | Rôle |
|---------|------|
| `Dockerfile` | Build Node + image Nginx |
| `docker-compose.yml` | Service `web` + args de build |
| `docker/nginx.conf` | SPA `try_files``index.html` |
| `.env.deploy.example` | Modèle pour le NAS |
| `deploy/hooks.json.example` | Modèle webhook |
| `deploy/nas-deploy.sh.example` | Ancienne variante (script dans le dépôt) ; le guide privilégie `deploy.sh` **sur le NAS** |
---
## Variante : Gitea Actions + runner sur le NAS
Si tu préfères tout voir dans lUI Gitea : active **Actions**, installe **act_runner** sur le NAS, enregistre-le sur ton instance, puis ajoute un workflow `.gitea/workflows/deploy.yml` qui exécute les mêmes commandes que `deploy.sh`. Cest plus lourd à configurer (dont laccès sécurisé à `docker.sock`) mais très propre à long terme.

View File

@ -1,6 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { StoryGroup } from './types/jira' import type { StoryGroup } from './types/jira'
import { fetchAllIssuesByJql, MIGRATION_EPIC_KEY, MIGRATION_JQL, jiraClient } from './api/jiraClient' import {
fetchAllIssuesByJql,
fetchBoardSprints,
MIGRATION_EPIC_KEY,
MIGRATION_JQL,
jiraClient,
} from './api/jiraClient'
import { groupSubtasksUnderStories } from './lib/groupIssues' import { groupSubtasksUnderStories } from './lib/groupIssues'
import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory' import { appendBurnupSnapshot, loadBurnupHistory, type BurnupPoint } from './lib/burnupHistory'
import { isIssueDone } from './lib/statusBuckets' import { isIssueDone } from './lib/statusBuckets'
@ -15,9 +21,12 @@ import {
} from './lib/executiveHealth' } from './lib/executiveHealth'
import { countSubtasksByPhase } from './lib/phaseAggregate' import { countSubtasksByPhase } from './lib/phaseAggregate'
import { import {
exportSynologyBackupJson,
loadDashboardConfig, loadDashboardConfig,
sanitizeGanttSprintRowMetric,
saveDashboardConfig, saveDashboardConfig,
type DashboardConfig, type DashboardConfig,
type GanttSprintRowMetric,
} from './lib/dashboardConfig' } from './lib/dashboardConfig'
import { assigneeMatchesMyView } from './lib/assigneeMatch' import { assigneeMatchesMyView } from './lib/assigneeMatch'
import { isAxiosError } from 'axios' import { isAxiosError } from 'axios'
@ -31,12 +40,18 @@ import { DashboardSettingsModal } from './components/DashboardSettingsModal'
import { ManagementOverview } from './components/ManagementOverview' import { ManagementOverview } from './components/ManagementOverview'
import { PhaseDistributionChart } from './components/PhaseDistributionChart' import { PhaseDistributionChart } from './components/PhaseDistributionChart'
import { ExportDashboardButton } from './components/ExportDashboardButton' import { ExportDashboardButton } from './components/ExportDashboardButton'
import { MacroCockpitStrip } from './components/MacroCockpitStrip'
import { StatusBucketProvider } from './context/StatusBucketContext' 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 { SprintGanttView } from './components/SprintGanttView'
import { SprintView } from './components/SprintView'
import { resolveJiraBoardId, resolveSprintFieldId } from './lib/jiraSprintField'
import type { JiraSprintSnapshot } from './lib/sprintExtract'
type ViewMode = 'list' | 'board' type ViewMode = 'list' | 'board' | 'project' | 'gantt' | 'sprint'
export default function App() { export default function App() {
const dashboardRef = useRef<HTMLDivElement>(null) const dashboardRef = useRef<HTMLDivElement>(null)
@ -48,12 +63,18 @@ export default function App() {
const [burnupData, setBurnupData] = useState<BurnupPoint[]>(() => loadBurnupHistory()) const [burnupData, setBurnupData] = useState<BurnupPoint[]>(() => loadBurnupHistory())
const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig()) const [dashboardCfg, setDashboardCfg] = useState<DashboardConfig>(() => loadDashboardConfig())
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [boardSprints, setBoardSprints] = useState<JiraSprintSnapshot[]>([])
const statusBucketsRef = useRef(dashboardCfg.statusBuckets) const statusBucketsRef = useRef(dashboardCfg.statusBuckets)
statusBucketsRef.current = dashboardCfg.statusBuckets statusBucketsRef.current = dashboardCfg.statusBuckets
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) =>
@ -120,7 +141,24 @@ export default function App() {
], ],
) )
const phaseCounts = useMemo(() => countSubtasksByPhase(groups), [groups]) const phaseCounts = useMemo(
() => countSubtasksByPhase(groups, dashboardCfg.statusBuckets, dashboardCfg.laneLabels),
[groups, dashboardCfg.statusBuckets, dashboardCfg.laneLabels],
)
const boardSprintsVisible = useMemo(() => {
const ex = new Set(dashboardCfg.excludedSprintIds)
return boardSprints.filter((s) => !ex.has(s.id))
}, [boardSprints, dashboardCfg.excludedSprintIds])
const setGanttSprintRowMetric = useCallback((metric: GanttSprintRowMetric) => {
const m = sanitizeGanttSprintRowMetric(metric)
setDashboardCfg((prev) => {
const next: DashboardConfig = { ...prev, ganttSprintRowMetric: m }
saveDashboardConfig(next)
return next
})
}, [])
const toggleMyView = () => { const toggleMyView = () => {
const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive } const next: DashboardConfig = { ...dashboardCfg, myViewActive: !myViewActive }
@ -137,7 +175,23 @@ 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 boardId = resolveJiraBoardId()
const sprintListPromise =
boardId != null
? fetchBoardSprints(boardId, signal, ['active', 'future']).catch((err) => {
console.warn('[jira] Sprints board Agile (active/future) :', err)
return [] as JiraSprintSnapshot[]
})
: Promise.resolve([] as JiraSprintSnapshot[])
const [issues, sprintsFromBoard] = await Promise.all([
fetchAllIssuesByJql(MIGRATION_JQL, signal, {
additionalFields: sprintField ? [sprintField] : [],
}),
sprintListPromise,
])
setBoardSprints(sprintsFromBoard)
const grouped = groupSubtasksUnderStories(issues) const grouped = groupSubtasksUnderStories(issues)
setGroups(grouped) setGroups(grouped)
setUpdatedAt(new Date()) setUpdatedAt(new Date())
@ -166,10 +220,11 @@ export default function App() {
setError(e instanceof Error ? e.message : 'Erreur inconnue') setError(e instanceof Error ? e.message : 'Erreur inconnue')
} }
setGroups([]) setGroups([])
setBoardSprints([])
} 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,10 +298,56 @@ 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('gantt')}
className={`rounded-lg px-4 py-2 text-sm font-medium transition ${
view === 'gantt'
? 'bg-cyan-500/20 text-cyan-100 shadow-[0_0_12px_rgba(34,211,238,0.2)]'
: 'text-slate-400 hover:text-white'
}`}
title="Gantt sprints (dates Jira) + jalons, avancement"
>
Gantt
</button>
<button
type="button"
onClick={() => setView('sprint')}
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 && (
<>
<ExportDashboardButton targetRef={dashboardRef} /> <ExportDashboardButton targetRef={dashboardRef} />
<button
type="button"
onClick={() => exportSynologyBackupJson(dashboardCfg)}
className="rounded-xl border border-emerald-500/35 bg-emerald-500/10 px-3 py-2 text-xs font-semibold text-emerald-100 transition hover:border-emerald-400/50 hover:bg-emerald-500/15"
title="JSON avec horodatage pour sauvegarde sur NAS Synology (volume Docker)."
>
Backup JSON NAS
</button>
</>
)} )}
{updatedAt && !loading && ( {updatedAt && !loading && (
<span className="text-xs text-slate-500"> <span className="text-xs text-slate-500">
@ -293,9 +394,69 @@ 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' ? (
<div className="space-y-10">
<MacroCockpitStrip
groups={groups}
dashboardCfg={dashboardCfg}
landing={landing}
finalMilestoneIso={finalMilestoneIso}
/>
<ProjectTimelineView
milestones={dashboardCfg.milestones}
groups={groups}
velocityPerCalendarDay={landing.effectiveVelocityPerDay}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div>
) : view === 'gantt' ? (
<div className="space-y-10">
<MacroCockpitStrip
groups={groups}
dashboardCfg={dashboardCfg}
landing={landing}
finalMilestoneIso={finalMilestoneIso}
/>
<SprintGanttView
sprints={boardSprintsVisible}
milestones={dashboardCfg.milestones}
groups={groups}
sprintFieldId={sprintFieldResolved}
ganttSprintRowMetric={dashboardCfg.ganttSprintRowMetric}
ganttNonWorkingDates={dashboardCfg.ganttNonWorkingDates}
onGanttSprintRowMetricChange={setGanttSprintRowMetric}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div>
) : view === 'sprint' ? (
<div className="space-y-10">
<MacroCockpitStrip
groups={groups}
dashboardCfg={dashboardCfg}
landing={landing}
finalMilestoneIso={finalMilestoneIso}
/>
<SprintView
groups={displayGroups}
sprintFieldId={sprintFieldResolved}
boardSprintsFromApi={boardSprintsVisible}
onOpenSettings={() => setSettingsOpen(true)}
gapBadges={dashboardCfg.functionalGaps}
/>
</div>
) : (
<>
<MacroCockpitStrip
groups={groups}
dashboardCfg={dashboardCfg}
landing={landing}
finalMilestoneIso={finalMilestoneIso}
/>
<MilestonesTimeline <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 +505,24 @@ 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}
gapBadges={dashboardCfg.functionalGaps}
/>
))} ))}
</div> </div>
) : ( ) : (
<BoardView groups={displayGroups} /> <BoardView
groups={displayGroups}
sprintFieldId={sprintFieldResolved}
gapBadges={dashboardCfg.functionalGaps}
/>
)} )}
</section> </section>
</>
)}
</div> </div>
)} )}
</main> </main>
@ -360,6 +532,7 @@ export default function App() {
config={dashboardCfg} config={dashboardCfg}
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
onSave={saveSettings} onSave={saveSettings}
boardSprints={boardSprints}
/> />
</div> </div>
</LaneLabelsProvider> </LaneLabelsProvider>

View File

@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import type { JiraIssue, JiraSearchJqlResponse } from '../types/jira' import type { JiraIssue, JiraSearchJqlResponse } from '../types/jira'
import { coerceSprintObject, type JiraSprintSnapshot } from '../lib/sprintExtract'
/** /**
* Même périmètre quun filtre Jira type filter=25111 : tout ce qui est sous lépopée * Même périmètre quun filtre Jira type filter=25111 : tout ce qui est sous lépopée
@ -63,6 +64,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 +76,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 +88,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 +100,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 } : {}),
} }
@ -152,3 +156,88 @@ export async function fetchAllIssuesByJql(
return collected return collected
} }
type AgileSprintPage = {
values?: Record<string, unknown>[]
isLast?: boolean
}
/**
* Sprints dun board logiciel (API Agile Jira), avec pagination.
* @see https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/#api-rest-agile-1-0-board-boardid-sprint-get
*/
export async function fetchBoardSprints(
boardId: number,
signal: AbortSignal | undefined,
states: ('active' | 'future' | 'closed')[],
): Promise<JiraSprintSnapshot[]> {
const base = clientBaseUrl()
if (!base || !Number.isFinite(boardId) || boardId <= 0) return []
const stateParam = states.join(',')
const all: JiraSprintSnapshot[] = []
let startAt = 0
const maxResults = 50
for (let page = 0; page < 40; page += 1) {
const { data } = await jiraClient.get<AgileSprintPage>(
`/rest/agile/1.0/board/${boardId}/sprint`,
{
params: { state: stateParam, startAt, maxResults },
signal,
},
)
const values = data.values ?? []
for (const row of values) {
if (row && typeof row === 'object') {
const s = coerceSprintObject(row as Record<string, unknown>)
if (s) all.push(s)
}
}
if (values.length === 0) break
if (data.isLast === true) break
if (values.length < maxResults) break
startAt += values.length
}
return all
}
type AgileSprintIssuesPage = {
issues?: { key?: string }[]
isLast?: boolean
maxResults?: number
startAt?: number
}
/**
* Toutes les clés dissues dun sprint (API Agile), pour filtrer sans `customfield` Sprint.
* @see https://developer.atlassian.com/cloud/jira/software/rest/api-group-sprint/#api-rest-agile-1-0-sprint-sprintid-issue-get
*/
export async function fetchAllIssueKeysInSprint(
sprintId: number,
signal: AbortSignal | undefined,
): Promise<Set<string>> {
const base = clientBaseUrl()
if (!base || !Number.isFinite(sprintId) || sprintId <= 0) return new Set()
const keys = new Set<string>()
let startAt = 0
const maxResults = 100
for (let page = 0; page < 100; page += 1) {
const { data } = await jiraClient.get<AgileSprintIssuesPage>(
`/rest/agile/1.0/sprint/${sprintId}/issue`,
{
params: { startAt, maxResults, fields: 'key' },
signal,
},
)
const issues = data.issues ?? []
for (const row of issues) {
if (row?.key && typeof row.key === 'string') keys.add(row.key)
}
if (issues.length === 0) break
if (data.isLast === true) break
if (issues.length < maxResults) break
startAt += issues.length
}
return keys
}

View File

@ -1,12 +1,15 @@
import type { StoryGroup } from '../types/jira' import type { StoryGroup } from '../types/jira'
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
import { groupStoriesByComponent } from '../lib/boardGrouping' import { groupStoriesByComponent } from '../lib/boardGrouping'
import { StoryCard } from './StoryCard' import { StoryCard } from './StoryCard'
type Props = { type Props = {
groups: StoryGroup[] groups: StoryGroup[]
sprintFieldId?: string | null
gapBadges?: FunctionalGapBadge[]
} }
export function BoardView({ groups }: Props) { export function BoardView({ groups, sprintFieldId = null, gapBadges }: Props) {
const columns = groupStoriesByComponent(groups) const columns = groupStoriesByComponent(groups)
return ( return (
@ -24,7 +27,13 @@ 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}
gapBadges={gapBadges}
/>
))} ))}
</div> </div>
</div> </div>

View File

@ -1,12 +1,22 @@
import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react' import { useEffect, useId, useRef, useState, type ChangeEvent } from 'react'
import { import {
exportConfigJson, exportConfigJson,
exportSynologyBackupJson,
GANTT_SPRINT_METRIC_OPTIONS,
mergeImportedConfig, mergeImportedConfig,
normalizeFunctionalGapsForSave,
parseGanttNonWorkingDatesFromText,
sanitizeExcludedSprintIds,
type DashboardConfig, type DashboardConfig,
type FunctionalGapBadge,
type GanttSprintRowMetric,
type LaneLabelsConfig, type LaneLabelsConfig,
type Milestone, type Milestone,
type MilestoneKind,
type StatusBucketConfig, type StatusBucketConfig,
} from '../lib/dashboardConfig' } from '../lib/dashboardConfig'
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
import { MILESTONE_KIND_OPTIONS } from '../lib/milestoneKinds'
function parseBucketLines(raw: string): string[] { function parseBucketLines(raw: string): string[] {
return raw return raw
@ -59,6 +69,8 @@ type Props = {
config: DashboardConfig config: DashboardConfig
onClose: () => void onClose: () => void
onSave: (next: DashboardConfig) => void onSave: (next: DashboardConfig) => void
/** Sprints board (API) pour masquage sélectif ; optionnel si pas encore chargés. */
boardSprints?: JiraSprintSnapshot[]
} }
function newMilestone(): Milestone { function newMilestone(): Milestone {
@ -68,19 +80,47 @@ 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,
} }
} }
export function DashboardSettingsModal({ open, config, onClose, onSave }: Props) { function newGapBadge(): FunctionalGapBadge {
return {
id:
typeof crypto !== 'undefined' && crypto.randomUUID
? `gap-${crypto.randomUUID().slice(0, 10)}`
: `gap-${Date.now()}`,
label: '',
terms: [''],
criticalFlow: false,
}
}
function parseGapTerms(raw: string): string[] {
return raw
.split(/[,;]+/)
.map((s) => s.trim())
.filter(Boolean)
}
export function DashboardSettingsModal({ open, config, onClose, onSave, boardSprints }: Props) {
const dialogRef = useRef<HTMLDialogElement>(null) const dialogRef = useRef<HTMLDialogElement>(null)
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const titleId = useId() const titleId = useId()
const [draft, setDraft] = useState<DashboardConfig>(config) const [draft, setDraft] = useState<DashboardConfig>(config)
const [ganttNonWorkInput, setGanttNonWorkInput] = useState('')
useEffect(() => { useEffect(() => {
if (open) setDraft(config) if (open) setDraft(config)
}, [open, config]) }, [open, config])
const configNonWorkKey = config.ganttNonWorkingDates.join('|')
useEffect(() => {
if (!open) return
setGanttNonWorkInput(config.ganttNonWorkingDates.join('\n'))
}, [open, configNonWorkKey])
useEffect(() => { useEffect(() => {
const el = dialogRef.current const el = dialogRef.current
if (!el) return if (!el) return
@ -114,8 +154,10 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
try { try {
const parsed = JSON.parse(String(reader.result)) as unknown const parsed = JSON.parse(String(reader.result)) as unknown
const merged = mergeImportedConfig(draft, parsed) const merged = mergeImportedConfig(draft, parsed)
if (merged) setDraft(merged) if (merged) {
else alert('Fichier JSON invalide (version 1 attendue).') setDraft(merged)
setGanttNonWorkInput(merged.ganttNonWorkingDates.join('\n'))
} else alert('Fichier JSON invalide (configuration v1 ou bundle Synology v1).')
} catch { } catch {
alert('Impossible de lire ce fichier JSON.') alert('Impossible de lire ce fichier JSON.')
} }
@ -199,6 +241,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 ».
@ -298,6 +360,174 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
</div> </div>
</div> </div>
<div className="rounded-xl border border-indigo-500/25 bg-indigo-500/5 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-indigo-200/90">
Gantt & vue Sprint
</p>
<label className="mt-2 block text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Infos sous les barres (Gantt)
</label>
<select
className="mt-1 w-full rounded-lg border border-white/10 bg-black/30 px-2 py-2 text-sm text-slate-200 outline-none"
value={draft.ganttSprintRowMetric}
onChange={(e) =>
setDraft((d) => ({
...d,
ganttSprintRowMetric: e.target.value as GanttSprintRowMetric,
}))
}
>
{GANTT_SPRINT_METRIC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<label className="mt-3 block text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
Jours non travaillés (Gantt)
</label>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
Une date par ligne au format <span className="font-mono text-slate-400">AAAA-MM-JJ</span> (fuseau
local du navigateur). Même style que sam. / dim. : fériés, ponts, fermeture.
</p>
<textarea
rows={4}
spellCheck={false}
placeholder={'2026-05-01\n2026-05-08'}
className="mt-1.5 w-full resize-y rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 font-mono text-[11px] leading-relaxed text-slate-200 outline-none ring-indigo-500/20 focus:ring-1"
value={ganttNonWorkInput}
onChange={(e) => setGanttNonWorkInput(e.target.value)}
/>
<p className="mt-3 text-[10px] font-semibold uppercase tracking-wide text-indigo-200/80">
Sprints à masquer
</p>
<p className="mt-1 text-[10px] leading-relaxed text-slate-500">
Sprints cochés : retirés du Gantt et du menu de la vue Sprint. Rechargez les données si la
liste est vide.
</p>
{boardSprints && boardSprints.length > 0 ? (
<ul className="mt-2 max-h-44 space-y-2 overflow-y-auto pr-1">
{boardSprints.map((sp) => (
<li key={sp.id} className="flex items-start gap-2 text-xs text-slate-200">
<input
type="checkbox"
id={`ex-sp-${sp.id}`}
checked={draft.excludedSprintIds.includes(sp.id)}
onChange={(e) =>
setDraft((d) => {
const next = new Set(d.excludedSprintIds)
if (e.target.checked) next.add(sp.id)
else next.delete(sp.id)
return { ...d, excludedSprintIds: [...next] }
})
}
className="mt-0.5 rounded border-indigo-400/50"
/>
<label htmlFor={`ex-sp-${sp.id}`} className="cursor-pointer leading-snug">
<span className="font-medium">{sp.name}</span>
<span className="ml-2 font-mono text-[10px] text-slate-500">#{sp.id}</span>
{sp.state ? (
<span className="ml-2 text-[10px] uppercase text-slate-500">{sp.state}</span>
) : null}
</label>
</li>
))}
</ul>
) : (
<p className="mt-2 text-[11px] text-slate-500">
Aucun sprint en mémoire : actualisez le cockpit puis rouvrez les réglages.
</p>
)}
</div>
<div className="rounded-xl border border-rose-500/20 bg-rose-500/[0.06] p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-rose-100/90">
Badges « gaps » (PO)
</span>
<button
type="button"
onClick={() =>
setDraft((d) => ({ ...d, functionalGaps: [...d.functionalGaps, newGapBadge()] }))
}
className="text-xs font-medium text-rose-200/90 hover:text-rose-100"
>
+ Ajouter
</button>
</div>
<p className="text-[10px] leading-relaxed text-slate-500">
Termes cherchés dans clés, résumés et étiquettes (insensible casse / accents). Cochez
« flux critique » pour renforcer le feu rouge macro (Panier, Checkout).
</p>
<ul className="mt-3 max-h-48 space-y-3 overflow-y-auto pr-1">
{draft.functionalGaps.map((g) => (
<li
key={g.id}
className="rounded-lg border border-white/10 bg-black/25 p-2 text-xs text-slate-200"
>
<div className="flex flex-wrap gap-2">
<input
className="min-w-[100px] flex-1 rounded border border-white/10 bg-transparent px-2 py-1"
value={g.label}
onChange={(e) =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.map((x) =>
x.id === g.id ? { ...x, label: e.target.value } : x,
),
}))
}
placeholder="Libellé (ex. Panier)"
/>
<label className="flex cursor-pointer items-center gap-1.5 text-[11px] text-rose-100/90">
<input
type="checkbox"
checked={Boolean(g.criticalFlow)}
onChange={(e) =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.map((x) =>
x.id === g.id ? { ...x, criticalFlow: e.target.checked } : x,
),
}))
}
className="rounded border-rose-400/50"
/>
Flux critique
</label>
<button
type="button"
onClick={() =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.filter((x) => x.id !== g.id),
}))
}
className="text-[11px] text-rose-400 hover:text-rose-300"
>
Supprimer
</button>
</div>
<textarea
rows={2}
spellCheck={false}
className="mt-2 w-full resize-y rounded border border-white/10 bg-black/30 px-2 py-1 font-mono text-[11px] outline-none"
value={g.terms.join(', ')}
onChange={(e) =>
setDraft((d) => ({
...d,
functionalGaps: d.functionalGaps.map((x) =>
x.id === g.id ? { ...x, terms: parseGapTerms(e.target.value) } : x,
),
}))
}
placeholder="panier, cart, basket…"
/>
</li>
))}
</ul>
</div>
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500"> <span className="text-xs font-medium uppercase tracking-wide text-slate-500">
@ -323,6 +553,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 +611,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"
@ -373,6 +634,14 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
> >
Exporter configuration (JSON) Exporter configuration (JSON)
</button> </button>
<button
type="button"
onClick={() => exportSynologyBackupJson(draft)}
className="rounded-lg border border-emerald-400/35 bg-emerald-950/40 px-3 py-2 text-xs font-semibold text-emerald-50"
title="Enveloppe bundleVersion + exportedAt pour sauvegarde NAS / Docker Synology."
>
Bundle Synology (JSON)
</button>
<button <button
type="button" type="button"
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
@ -401,7 +670,12 @@ export function DashboardSettingsModal({ open, config, onClose, onSave }: Props)
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
onSave(draft) onSave({
...draft,
functionalGaps: normalizeFunctionalGapsForSave(draft.functionalGaps),
excludedSprintIds: sanitizeExcludedSprintIds(draft.excludedSprintIds),
ganttNonWorkingDates: parseGanttNonWorkingDatesFromText(ganttNonWorkInput),
})
onClose() onClose()
}} }}
className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400" className="rounded-lg bg-cyan-500 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-400"

View File

@ -0,0 +1,198 @@
import type { StoryGroup } from '../types/jira'
import type { DashboardConfig } from '../lib/dashboardConfig'
import type { LandingEstimate } from '../lib/executiveLanding'
import { computeMacroPipelineHealth } from '../lib/macroTrafficLight'
import { assigneeOpenLoadRadar } from '../lib/assigneeRadar'
import { countOpenGapsByBadge } from '../lib/functionalGaps'
import { calendarDelayVsLastMilestone } from '../lib/scheduleDelay'
import { resolveWorkBucketFromIssue } from '../lib/statusBuckets'
type Props = {
groups: StoryGroup[]
dashboardCfg: DashboardConfig
landing: LandingEstimate
finalMilestoneIso: string | null
}
function trafficLightClasses(light: 'green' | 'amber' | 'red'): { ring: string; bg: string; dot: string } {
switch (light) {
case 'green':
return {
ring: 'ring-emerald-400/50',
bg: 'bg-emerald-500/20',
dot: 'bg-emerald-400 shadow-[0_0_14px_rgba(52,211,153,0.7)]',
}
case 'amber':
return {
ring: 'ring-amber-400/55',
bg: 'bg-amber-500/20',
dot: 'bg-amber-400 shadow-[0_0_14px_rgba(251,191,36,0.65)]',
}
case 'red':
return {
ring: 'ring-rose-500/60',
bg: 'bg-rose-600/25',
dot: 'bg-rose-500 shadow-[0_0_16px_rgba(244,63,94,0.75)]',
}
}
}
export function MacroCockpitStrip({ groups, dashboardCfg, landing, finalMilestoneIso }: Props) {
const macro = computeMacroPipelineHealth(
groups,
dashboardCfg.statusBuckets,
dashboardCfg.laneLabels,
dashboardCfg.functionalGaps,
)
const delay = calendarDelayVsLastMilestone(landing, finalMilestoneIso)
const radar = assigneeOpenLoadRadar(
groups,
dashboardCfg.statusBuckets,
dashboardCfg.wipSlotsPerDev,
).slice(0, 6)
const gaps = countOpenGapsByBadge(groups, dashboardCfg.functionalGaps, dashboardCfg.statusBuckets)
const openSamples = groups
.flatMap((g) =>
g.subtasks
.filter((st) => {
const b = resolveWorkBucketFromIssue(st, dashboardCfg.statusBuckets)
return b === 'in_progress' || b === 'blocked'
})
.map((st) => ({
key: st.key,
who: st.fields.assignee?.displayName ?? '—',
summary: st.fields.summary,
})),
)
.slice(0, 5)
const cls = trafficLightClasses(macro.light)
return (
<section className="rounded-2xl border border-white/[0.08] bg-gradient-to-br from-slate-950/90 to-slate-900/40 p-4 shadow-[0_12px_48px_rgba(0,0,0,0.35)] backdrop-blur-xl sm:p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
Cockpit macro DSI · Projet · PO · Exécution
</h2>
<span className="text-[10px] text-slate-500">
Données = instantané Jira au dernier chargement (Actualiser).
</span>
</div>
<div className="grid gap-4 lg:grid-cols-12">
<div
className={`lg:col-span-4 rounded-xl border border-white/10 p-4 ring-2 ring-inset ${cls.ring} ${cls.bg}`}
>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Feux phases (DSI)
</p>
<div className="mt-2 flex items-start gap-3">
<span
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full ${cls.dot}`}
title={macro.title}
aria-hidden
/>
<div className="min-w-0">
<p className="text-sm font-semibold text-white">{macro.title}</p>
<p className="mt-1 text-xs leading-relaxed text-slate-300">{macro.detail}</p>
{macro.violatingStoryKeys.length > 0 && (
<p className="mt-2 font-mono text-[11px] text-rose-200/90">
{macro.violatingStoryKeys.join(', ')}
</p>
)}
</div>
</div>
</div>
<div className="lg:col-span-4 rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Jalons &amp; vélocité (Directeur de projet)
</p>
{delay ? (
<p className="mt-2 text-sm font-medium text-amber-100">{delay.message}</p>
) : (
<p className="mt-2 text-sm text-slate-300">
Aucun retard calendaire détecté par rapport au dernier jalon (ou date / vélocité
indisponible).
</p>
)}
<p className="mt-2 text-[11px] text-slate-500">
{landing.businessDaysToFinish != null
? `~${landing.businessDaysToFinish} j. ouvrés restants (sous-tâches), vélocité ajustée effectif / baseline.`
: 'Vélocité nulle ou données insuffisantes pour estimer la fin.'}
</p>
</div>
<div className="lg:col-span-4 rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Écarts fonctionnels (PO)
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{gaps.map((g) => (
<li
key={g.id}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium ring-1 ring-inset ${
g.criticalFlow
? 'bg-rose-500/15 text-rose-100 ring-rose-400/40'
: 'bg-slate-600/30 text-slate-200 ring-slate-500/35'
}`}
title="Sous-tâches encore ouvertes sur les stories correspondant aux termes configurés."
>
{g.label}
<span className="ml-1 tabular-nums opacity-90">({g.openCount})</span>
</li>
))}
</ul>
</div>
<div className="lg:col-span-6 rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
Radar de charge (Chef de projet)
</p>
<p className="mt-1 text-[10px] text-slate-500">
Plafond WIP : {dashboardCfg.wipSlotsPerDev} sous-tâches ouvertes / personne.
</p>
<ul className="mt-2 space-y-1.5 text-xs">
{radar.length === 0 ? (
<li className="text-slate-500">Aucune sous-tâche ouverte.</li>
) : (
radar.map((r) => (
<li
key={r.name}
className={`flex justify-between gap-2 rounded-lg px-2 py-1 ${
r.overload ? 'bg-rose-500/15 text-rose-100' : 'bg-white/[0.04] text-slate-200'
}`}
>
<span className="truncate">{r.name}</span>
<span className="shrink-0 tabular-nums font-semibold">
{r.openCount}
{r.overload ? ' ⚠' : ''}
</span>
</li>
))
)}
</ul>
</div>
<div className="lg:col-span-6 rounded-xl border border-white/10 bg-black/20 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
En cours (aperçu)
</p>
<ul className="mt-2 space-y-1.5 text-[11px] text-slate-300">
{openSamples.length === 0 ? (
<li className="text-slate-500">Aucun ticket « en cours » ou « bloqué ».</li>
) : (
openSamples.map((x) => (
<li key={x.key} className="truncate" title={x.summary}>
<span className="font-mono text-cyan-300/90">{x.key}</span>{' '}
<span className="text-slate-500">·</span> {x.who}
</li>
))
)}
</ul>
</div>
</div>
</section>
)
}

View File

@ -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,517 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type { StoryGroup } from '../types/jira'
import { GANTT_SPRINT_METRIC_OPTIONS, type GanttSprintRowMetric, type Milestone } from '../lib/dashboardConfig'
import { useStatusBuckets } from '../context/StatusBucketContext'
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
import { milestoneKindMarkerClass } from '../lib/milestoneKinds'
import {
GANTT_ZOOM_FACTORS,
epicScopeSprintProgress,
formatGanttSprintSubtitleLines,
formatSprintRangeFr,
ganttDayColumns,
ganttMonthBands,
ganttRangeFromSprintsAndMilestones,
ganttSubheaderTicks,
ganttVerticalGuideXs,
milestoneTooltipText,
msToX,
parseIsoMs,
pixelsPerDay,
timelineWidthPx,
type GanttDayColumn,
type GanttTimeScale,
sprintBarBounds,
sprintBarFillPercent,
} from '../lib/sprintGantt'
type Props = {
sprints: JiraSprintSnapshot[]
milestones: Milestone[]
groups: StoryGroup[]
sprintFieldId: string | null
ganttSprintRowMetric: GanttSprintRowMetric
/** Dates yyyy-mm-dd (local) en plus des week-ends pour le fond du Gantt. */
ganttNonWorkingDates: string[]
onGanttSprintRowMetricChange: (m: GanttSprintRowMetric) => void
onOpenSettings: () => void
}
function sprintOrderRank(state?: string): number {
const s = (state ?? '').toLowerCase()
if (s === 'active') return 0
if (s === 'future') return 1
return 2
}
function IconLoupeMinus({ className }: { className?: string }) {
return (
<svg
className={className}
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
>
<circle cx="10" cy="10" r="6" />
<path d="M15 15l5 5" />
<path d="M7.5 10h5" />
</svg>
)
}
function IconLoupePlus({ className }: { className?: string }) {
return (
<svg
className={className}
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
>
<circle cx="10" cy="10" r="6" />
<path d="M15 15l5 5" />
<path d="M10 7.5v5M7.5 10h5" />
</svg>
)
}
const SCALE_LABELS: Record<GanttTimeScale, string> = {
day: 'Jour',
week: 'Semaine',
month: 'Mois',
}
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.isGanttNonWork)
.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,
groups,
sprintFieldId,
ganttSprintRowMetric,
ganttNonWorkingDates,
onGanttSprintRowMetricChange,
onOpenSettings,
}: Props) {
const cfg = useStatusBuckets()
const scrollRef = useRef<HTMLDivElement>(null)
const [timeScale, setTimeScale] = useState<GanttTimeScale>('week')
const [zoomIndex, setZoomIndex] = useState(3)
const { datedSprints, undatedSprints } = useMemo(() => {
const dated: JiraSprintSnapshot[] = []
const undated: JiraSprintSnapshot[] = []
for (const s of sprints) {
if (sprintBarBounds(s)) dated.push(s)
else undated.push(s)
}
dated.sort((a, b) => {
const ra = sprintOrderRank(a.state)
const rb = sprintOrderRank(b.state)
if (ra !== rb) return ra - rb
const ba = sprintBarBounds(a)!
const bb = sprintBarBounds(b)!
return ba.startMs - bb.startMs
})
return { datedSprints: dated, undatedSprints: undated }
}, [sprints])
const sortedMilestones = useMemo(
() => [...milestones].sort((a, b) => a.date.localeCompare(b.date)),
[milestones],
)
const { startMs, endMs } = useMemo(
() => ganttRangeFromSprintsAndMilestones(datedSprints, sortedMilestones),
[datedSprints, sortedMilestones],
)
const ppd = useMemo(() => pixelsPerDay(timeScale, zoomIndex), [timeScale, zoomIndex])
const widthPx = useMemo(
() => timelineWidthPx(startMs, endMs, ppd),
[startMs, endMs, ppd],
)
const nonWorkingSet = useMemo(() => new Set(ganttNonWorkingDates), [ganttNonWorkingDates])
const dayColumns = useMemo(
() => ganttDayColumns(startMs, endMs, widthPx, nonWorkingSet),
[startMs, endMs, widthPx, nonWorkingSet],
)
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(() => {
const now = Date.now()
if (now < startMs) return { x: 0, clamped: 'before' as const }
if (now > endMs) return { x: widthPx, clamped: 'after' as const }
return { x: msToX(now, startMs, endMs, widthPx), clamped: 'inside' as const }
}, [startMs, endMs, widthPx])
const scrollToToday = useCallback(() => {
const el = scrollRef.current
if (!el) return
const now = Date.now()
const clamped = Math.min(Math.max(now, startMs), endMs)
const x = msToX(clamped, startMs, endMs, widthPx)
el.scrollLeft = Math.max(0, x - el.clientWidth / 2 + 110)
}, [startMs, endMs, widthPx])
if (sprints.length === 0) {
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-5 py-8 text-center backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Gantt sprints
</h2>
<p className="mt-3 text-sm text-slate-400">
Aucun sprint actif ou futur renvoyé par le board Jira. Vérifiez{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_BOARD_ID</code> et
actualisez.
</p>
</section>
)
}
if (datedSprints.length === 0) {
return (
<section className="mb-10 rounded-2xl border border-amber-500/25 bg-amber-500/5 px-5 py-8 backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-amber-200/90">
Gantt sprints dates manquantes
</h2>
<p className="mx-auto mt-3 max-w-xl text-sm text-amber-100/90">
Jira na pas renvoyé de <code className="rounded bg-black/30 px-1 font-mono text-xs">startDate</code> /{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">endDate</code> pour ces sprints. Le
Gantt nécessite ces champs (sprints Scrum classiques).
</p>
{undatedSprints.length > 0 && (
<ul className="mx-auto mt-4 max-w-lg list-inside list-disc text-left text-xs text-amber-200/80">
{undatedSprints.map((s) => (
<li key={s.id}>
{s.name} ({s.state ?? '?'})
</li>
))}
</ul>
)}
</section>
)
}
const zoomFactor = GANTT_ZOOM_FACTORS[Math.max(0, Math.min(GANTT_ZOOM_FACTORS.length - 1, zoomIndex))]!
return (
<section className="mb-10 rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-5 backdrop-blur-xl sm:px-6">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Gantt sprints & jalons
</h2>
<p className="mt-1 max-w-3xl text-xs leading-relaxed text-slate-500">
Échelle <span className="text-slate-400">jour / semaine / mois</span> et zoom (loupe) : la
timeline sétire en pixels par jour faites défiler horizontalement. En-tête : mois puis
jours (sans répéter le mois) ; week-ends et jours configurés en fond plus sombre. Remplissage
des barres = % de sous-tâches terminées (champ Sprint). Losanges = jalons (survol pour le
détail).
</p>
</div>
<button
type="button"
onClick={onOpenSettings}
className="shrink-0 rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-500/20"
>
Réglages jalons / Sprint
</button>
</div>
{!sprintFieldId && (
<p className="mb-3 rounded-lg border border-sky-500/20 bg-sky-500/10 px-3 py-2 text-xs text-sky-100/90">
Sans champ Sprint, le <span className="font-medium text-sky-50">remplissage des barres reste à 0 %</span>{' '}
(aucune sous-tâche rattachée au sprint). Ajoutez{' '}
<code className="rounded bg-black/30 px-1 font-mono">customfield_</code> pour agréger les sous-tâches
par sprint.
</p>
)}
<div className="mb-3 flex flex-wrap items-center gap-2 border-b border-white/[0.08] pb-3">
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">
Échelle
</span>
{(['day', 'week', 'month'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setTimeScale(s)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
timeScale === s
? 'bg-cyan-500/25 text-cyan-100 ring-1 ring-cyan-400/50'
: 'bg-slate-900/80 text-slate-400 ring-1 ring-white/10 hover:text-white'
}`}
>
{SCALE_LABELS[s]}
</button>
))}
<span className="mx-1 hidden h-6 w-px bg-white/15 sm:inline-block" aria-hidden />
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">Zoom</span>
<button
type="button"
aria-label="Zoom arrière — voir plus de période"
title="Zoom arrière"
disabled={zoomIndex <= 0}
onClick={() => setZoomIndex((z) => Math.max(0, z - 1))}
className="inline-flex items-center justify-center rounded-lg border border-white/15 bg-slate-900/80 p-2 text-slate-300 transition hover:border-cyan-500/40 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
>
<IconLoupeMinus />
</button>
<span
className="min-w-[3rem] text-center font-mono text-[11px] text-slate-400"
title="Facteur de zoom sur la densité horizontale"
>
×{zoomFactor.toFixed(2)}
</span>
<button
type="button"
aria-label="Zoom avant — agrandir le détail"
title="Zoom avant"
disabled={zoomIndex >= GANTT_ZOOM_FACTORS.length - 1}
onClick={() => setZoomIndex((z) => Math.min(GANTT_ZOOM_FACTORS.length - 1, z + 1))}
className="inline-flex items-center justify-center rounded-lg border border-white/15 bg-slate-900/80 p-2 text-slate-300 transition hover:border-cyan-500/40 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-35"
>
<IconLoupePlus />
</button>
<span className="ml-auto hidden text-[10px] text-slate-600 sm:inline">
{ppd.toFixed(1)} px/j · {Math.round(widthPx)} px
</span>
<button
type="button"
onClick={scrollToToday}
className="rounded-lg border border-emerald-500/35 bg-emerald-500/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wide text-emerald-100/95 hover:bg-emerald-500/20"
title="Fait défiler la timeline pour centrer la date du jour"
>
Centrer sur aujourdhui
</button>
<label className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
<span className="hidden sm:inline">Sous les barres</span>
<select
className="max-w-[11rem] rounded-lg border border-white/10 bg-slate-900/90 px-2 py-1 text-[11px] font-medium normal-case text-slate-200 outline-none"
value={ganttSprintRowMetric}
onChange={(e) => onGanttSprintRowMetricChange(e.target.value as GanttSprintRowMetric)}
title="Identique à loption dans Réglages"
>
{GANTT_SPRINT_METRIC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
</div>
<div
ref={scrollRef}
className="max-w-full overflow-x-auto overflow-y-visible rounded-xl border border-white/[0.08] bg-slate-950/50"
>
<div
className="grid"
style={{
gridTemplateColumns: `220px ${widthPx}px`,
width: `${220 + widthPx}px`,
}}
>
<div className="sticky left-0 z-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 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={`${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">
<GanttTimelineBackdrop
dayColumns={dayColumns}
guideXs={guideXs}
todayX={todayLine.x}
todayClamped={todayLine.clamped}
/>
{sortedMilestones.map((m) => {
const ms = parseIsoMs(`${m.date}T12:00:00`)
if (ms == null || ms < startMs || ms > endMs) return null
const x = msToX(ms, startMs, endMs, widthPx)
const left = Math.max(6, Math.min(widthPx - 6, x))
return (
<span
key={m.id}
role="img"
tabIndex={0}
title={milestoneTooltipText(m)}
className={`absolute top-1/2 z-20 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 cursor-help rotate-45 ring-2 ring-slate-950 ${milestoneKindMarkerClass(m.kind)}`}
style={{ left: `${left}px` }}
aria-label={m.title}
/>
)
})}
</div>
{datedSprints.map((s) => {
const b = sprintBarBounds(s)!
const x0 = msToX(b.startMs, startMs, endMs, widthPx)
const x1 = msToX(b.endMs, startMs, endMs, widthPx)
const barW = Math.max(10, x1 - x0)
const fill = sprintBarFillPercent(s, groups, sprintFieldId, cfg)
const delivery = epicScopeSprintProgress(groups, s.id, sprintFieldId, cfg)
const subtitleLines = formatGanttSprintSubtitleLines(
ganttSprintRowMetric,
s,
groups,
sprintFieldId,
cfg,
)
const barTitle =
sprintFieldId && delivery.total > 0
? `${s.name}\n${formatSprintRangeFr(s)}\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 (
<div key={s.id} className="contents">
<div className="sticky left-0 z-30 flex flex-col justify-center border-b border-r border-white/[0.06] bg-slate-950/95 px-2 py-2 text-xs backdrop-blur-sm">
<span className="font-medium text-slate-200">{s.name}</span>
<span className="mt-0.5 text-[10px] uppercase text-slate-500">{s.state ?? '—'}</span>
<span className="mt-0.5 font-mono text-[10px] text-slate-500">{formatSprintRangeFr(s)}</span>
{subtitleLines.map((line, i) => (
<span key={i} className="mt-0.5 font-mono text-[10px] leading-snug text-sky-200/85">
{line}
</span>
))}
</div>
<div className="relative min-h-[48px] border-b border-white/[0.06] bg-slate-900/30 py-2">
<div className="relative mx-0 h-10">
<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"
style={{ left: `${x0}px`, width: `${barW}px` }}
title={barTitle}
>
<div
className="h-full rounded-l-full bg-gradient-to-r from-sky-600 via-sky-500 to-sky-400/90 shadow-[inset_0_0_12px_rgba(255,255,255,0.12)]"
style={{ width: `${fill}%` }}
/>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
{undatedSprints.length > 0 && (
<p className="mt-4 text-[11px] text-slate-500">
Sprints sans dates affichables : {undatedSprints.map((u) => u.name).join(', ')}.
</p>
)}
</section>
)
}

View File

@ -0,0 +1,239 @@
import { useEffect, useMemo, useState } from 'react'
import type { StoryGroup } from '../types/jira'
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
import { fetchAllIssueKeysInSprint } from '../api/jiraClient'
import { useStatusBuckets } from '../context/StatusBucketContext'
import type { JiraSprintSnapshot } from '../lib/sprintExtract'
import {
collectSprintOptions,
filterGroupsBySprint,
filterGroupsBySprintIssueKeys,
sprintOptionsFromBoardAndGroups,
sprintOptionsFromBoardOnly,
} from '../lib/sprintExtract'
import { subtaskDoneRatioPercent } from '../lib/storyMetrics'
import { StoryCard } from './StoryCard'
type Props = {
groups: StoryGroup[]
sprintFieldId: string | null
/** Sprints actifs/futurs depuis lAPI Agile du board (sans élargir le périmètre JQL). */
boardSprintsFromApi?: JiraSprintSnapshot[]
onOpenSettings: () => void
gapBadges?: FunctionalGapBadge[]
}
export function SprintView({
groups,
sprintFieldId,
boardSprintsFromApi = [],
onOpenSettings,
gapBadges,
}: Props) {
const cfg = useStatusBuckets()
const [focusId, setFocusId] = useState<number | 'all'>('all')
const [sprintIssueKeys, setSprintIssueKeys] = useState<Set<string>>(new Set())
const [sprintIssuesLoading, setSprintIssuesLoading] = useState(false)
const options = useMemo(() => {
if (boardSprintsFromApi.length > 0) {
if (sprintFieldId) {
const fromBoard = sprintOptionsFromBoardAndGroups(
boardSprintsFromApi,
groups,
sprintFieldId,
)
if (fromBoard.length > 0) return fromBoard
} else {
return sprintOptionsFromBoardOnly(boardSprintsFromApi)
}
}
if (sprintFieldId) return collectSprintOptions(groups, sprintFieldId)
return []
}, [boardSprintsFromApi, groups, sprintFieldId])
useEffect(() => {
if (sprintFieldId || focusId === 'all') {
setSprintIssueKeys(new Set())
setSprintIssuesLoading(false)
return
}
const sprintId = focusId as number
const ac = new AbortController()
setSprintIssuesLoading(true)
setSprintIssueKeys(new Set())
void fetchAllIssueKeysInSprint(sprintId, ac.signal)
.then((set) => {
if (!ac.signal.aborted) setSprintIssueKeys(set)
})
.catch(() => {
if (!ac.signal.aborted) setSprintIssueKeys(new Set())
})
.finally(() => {
if (!ac.signal.aborted) setSprintIssuesLoading(false)
})
return () => ac.abort()
}, [focusId, sprintFieldId])
const filtered = useMemo(() => {
if (focusId === 'all') return groups
if (sprintFieldId) return filterGroupsBySprint(groups, focusId, sprintFieldId)
if (sprintIssuesLoading) return []
return filterGroupsBySprintIssueKeys(groups, sprintIssueKeys)
}, [groups, focusId, sprintFieldId, sprintIssueKeys, sprintIssuesLoading])
const stats = useMemo(() => {
let subs = 0
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])
/** Aucune liste possible : ni board Agile, ni champ Sprint sur les tickets. */
const mustConfigureSprintField = options.length === 0 && !sprintFieldId && boardSprintsFromApi.length === 0
if (mustConfigureSprintField) {
return (
<section className="mb-10 rounded-2xl border border-amber-500/25 bg-amber-500/5 px-5 py-8 text-center backdrop-blur-xl sm:px-8">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-amber-200/90">
Vue Sprint configuration requise
</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-amber-100/90">
Aucun sprint actif/futur na é reçu du board Agile, et le champ Sprint nest pas
renseigné. Indiquez lidentifiant du champ personnalisé Sprint (souvent{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">customfield_10020</code>) dans
les réglages ou via{' '}
<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_SPRINT_FIELD</code>,
ou vérifiez le board (<code className="rounded bg-black/30 px-1 font-mono text-xs">VITE_JIRA_BOARD_ID</code>
) puis actualisez.
</p>
<button
type="button"
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 actif ou futur sur le board, et aucun sprint détecté sur les tickets chargés.
</p>
</section>
)
}
const filterModeAgile = !sprintFieldId && boardSprintsFromApi.length > 0
return (
<section className="mb-10 space-y-6">
{filterModeAgile && (
<div className="rounded-xl border border-sky-500/25 bg-sky-500/10 px-4 py-3 text-xs leading-relaxed text-sky-100/95">
Filtre sprint via lAPI Agile Jira (issues du sprint). Pour afficher les pastilles sprint
sur chaque carte, renseignez aussi le champ Sprint (ID{' '}
<code className="rounded bg-black/30 px-1 font-mono text-[11px]">customfield_</code>) dans
les réglages.
</div>
)}
<div className="rounded-2xl border border-white/[0.08] bg-slate-950/40 px-4 py-4 backdrop-blur-xl sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<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">
{sprintFieldId ? (
<>
Sprints actifs/futurs du board Jira Agile ; le nombre de stories compte le
périmètre chargé (épopée) avec le champ Sprint sur les tickets.
</>
) : (
<>
Sprints actifs/futurs du board (API Agile). Le filtre par sprint interroge les
issues du sprint côté Jira ; le périmètre affiché reste celui de lépopée Golden
Carbon.
</>
)}
</p>
</div>
<div className="flex min-w-[200px] flex-1 flex-col gap-1 sm:max-w-md">
<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})` : ''}
{sprintFieldId ? `${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>
{sprintIssuesLoading && focusId !== 'all' && !sprintFieldId ? (
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
Chargement des tickets du sprint
</p>
) : filtered.length === 0 ? (
<p className="rounded-2xl border border-white/10 bg-slate-950/35 px-6 py-8 text-center text-sm text-slate-400">
Aucune story du périmètre dans ce sprint (filtre « Ma vue » inclus), ou erreur lors du
chargement des issues du sprint.
</p>
) : (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{filtered.map((g) => (
<StoryCard
key={g.story.key}
group={g}
sprintFieldId={sprintFieldId}
gapBadges={gapBadges}
/>
))}
</div>
)}
</section>
)
}

View File

@ -1,6 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import type { PhaseId, StoryGroup } from '../types/jira' import type { PhaseId, StoryGroup } from '../types/jira'
import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase' import { PHASE_LABELS, statusToPhase } from '../lib/statusPhase'
import type { FunctionalGapBadge } from '../lib/dashboardConfig'
import { storyMatchesGapBadge } from '../lib/functionalGaps'
import { effectivePipelinePhase } from '../lib/pipelinePhase'
import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel' import { priorityBand, priorityBadgeClass } from '../lib/priorityLabel'
import { stepperStates, subtaskDoneRatioPercent } from '../lib/storyMetrics' import { stepperStates, subtaskDoneRatioPercent } from '../lib/storyMetrics'
import { PhaseStepper } from './PhaseStepper' import { PhaseStepper } from './PhaseStepper'
@ -10,6 +13,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 +46,18 @@ 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
/** Badges décarts fonctionnels (Panier, Checkout…) selon les réglages. */
gapBadges?: FunctionalGapBadge[]
}
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,13 +72,18 @@ 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,
gapBadges,
}: Props) {
const cfg = useStatusBuckets() const cfg = useStatusBuckets()
const laneCfg = useLaneLabels() const laneCfg = useLaneLabels()
const { story, subtasks } = group const { story, subtasks } = group
const [subsOpen, setSubsOpen] = useState(true) const [subsOpen, setSubsOpen] = useState(true)
const progress = subtaskDoneRatioPercent(subtasks, cfg) const progress = subtaskDoneRatioPercent(subtasks, cfg)
const steps = stepperStates(subtasks, cfg) const steps = stepperStates(subtasks, cfg, laneCfg)
const band = priorityBand(story) const band = priorityBand(story)
const assignee = story.fields.assignee?.displayName const assignee = story.fields.assignee?.displayName
const blockerHint = blockingSummaryForTooltip(group, cfg, laneCfg) const blockerHint = blockingSummaryForTooltip(group, cfg, laneCfg)
@ -71,6 +92,8 @@ 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) : []
const matchedGaps = (gapBadges ?? []).filter((b) => storyMatchesGapBadge(group, b))
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 +114,36 @@ 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>
{matchedGaps.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{matchedGaps.map((b) => (
<span
key={b.id}
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ring-inset ${
b.criticalFlow
? 'bg-rose-500/20 text-rose-100 ring-rose-500/45'
: 'bg-amber-500/15 text-amber-100 ring-amber-500/40'
}`}
title={`Écart fonctionnel (termes : ${b.terms.join(', ')})`}
>
{b.label}
</span>
))}
</div>
)}
{sprintChips.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{sprintChips.map((sp) => (
<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>
@ -165,7 +218,7 @@ export function StoryCard({ group, variant = 'default' }: Props) {
}`} }`}
> >
{subtasks.map((st) => { {subtasks.map((st) => {
const ph = statusToPhase(st.fields.status.name) const ph = effectivePipelinePhase(st, cfg, laneCfg)
return ( return (
<li <li
key={st.key} key={st.key}

33
src/lib/assigneeRadar.ts Normal file
View File

@ -0,0 +1,33 @@
import type { StoryGroup } from '../types/jira'
import type { StatusBucketConfig } from './statusBuckets'
import { resolveWorkBucketFromIssue } from './statusBuckets'
export type AssigneeLoadRow = {
name: string
openCount: number
overload: boolean
}
/** Radar de charge : sous-tâches ouvertes par assigné vs plafond WIP (réglages). */
export function assigneeOpenLoadRadar(
groups: StoryGroup[],
cfg: StatusBucketConfig,
wipCap: number,
): AssigneeLoadRow[] {
const map = new Map<string, number>()
for (const g of groups) {
for (const st of g.subtasks) {
const b = resolveWorkBucketFromIssue(st, cfg)
if (b === 'done' || b === 'cancel') continue
const name = st.fields.assignee?.displayName?.trim() || 'Non assigné'
map.set(name, (map.get(name) ?? 0) + 1)
}
}
return [...map.entries()]
.map(([name, openCount]) => ({
name,
openCount,
overload: openCount > wipCap,
}))
.sort((a, b) => b.openCount - a.openCount)
}

View File

@ -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,158 @@ 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)
}
/** Badges « Golden Carbon » : écarts fonctionnels (Panier, Checkout…) par mots-clés configurables. */
export type FunctionalGapBadge = {
id: string
label: string
/** Termes cherchés dans clés, résumés et étiquettes (insensible à la casse / accents). */
terms: string[]
/** Flux critique : renforce le feu rouge macro si conflit de phases. */
criticalFlow?: boolean
}
export function sanitizeFunctionalGapBadge(raw: unknown): FunctionalGapBadge | null {
if (!raw || typeof raw !== 'object') return null
const o = raw as Partial<FunctionalGapBadge>
if (!o.id || typeof o.id !== 'string' || !o.label || typeof o.label !== 'string') return null
const terms = Array.isArray(o.terms)
? o.terms
.filter((t): t is string => typeof t === 'string' && t.trim().length > 0)
.map((t) => t.trim())
: []
if (terms.length === 0) return null
return {
id: o.id.trim(),
label: o.label.trim(),
terms,
criticalFlow: Boolean(o.criticalFlow),
}
}
export function defaultFunctionalGaps(): FunctionalGapBadge[] {
return [
{ id: 'cart', label: 'Panier', terms: ['panier', 'cart', 'basket', 'caddie'], criticalFlow: true },
{ id: 'checkout', label: 'Checkout', terms: ['checkout', 'caisse', 'commande'], criticalFlow: true },
{ id: 'payment', label: 'Paiement', terms: ['paiement', 'payment', 'stripe', 'psp'], criticalFlow: false },
]
}
function sanitizeFunctionalGapsArray(
arr: unknown,
fallback: FunctionalGapBadge[],
): FunctionalGapBadge[] {
if (!Array.isArray(arr)) return fallback
const out = arr.map(sanitizeFunctionalGapBadge).filter((b): b is FunctionalGapBadge => b != null)
return out.length > 0 ? out : fallback
}
/** À lenregistrement des réglages : retire les badges invalides, garde au moins les défauts. */
export function normalizeFunctionalGapsForSave(arr: FunctionalGapBadge[]): FunctionalGapBadge[] {
if (!Array.isArray(arr) || arr.length === 0) return defaultFunctionalGaps()
const out = arr.map(sanitizeFunctionalGapBadge).filter((b): b is FunctionalGapBadge => b != null)
return out.length > 0 ? out : defaultFunctionalGaps()
}
/** Infos affichées sous chaque sprint sur le Gantt. */
export type GanttSprintRowMetric =
| 'none'
| 'time_remaining'
| 'subtasks_done_count'
| 'subtasks_done_percent'
| 'combined'
export function defaultGanttSprintRowMetric(): GanttSprintRowMetric {
return 'combined'
}
export const GANTT_SPRINT_METRIC_OPTIONS: { value: GanttSprintRowMetric; label: string }[] = [
{ value: 'combined', label: 'Temps restant + sous-tâches' },
{ value: 'time_remaining', label: 'Temps restant (dates sprint)' },
{ value: 'subtasks_done_count', label: 'Sous-tâches terminées (nombre)' },
{ value: 'subtasks_done_percent', label: 'Sous-tâches terminées (%)' },
{ value: 'none', label: 'Masquer' },
]
export function sanitizeGanttSprintRowMetric(raw: unknown): GanttSprintRowMetric {
const v = typeof raw === 'string' ? raw : ''
if (
v === 'none' ||
v === 'time_remaining' ||
v === 'subtasks_done_count' ||
v === 'subtasks_done_percent' ||
v === 'combined'
) {
return v
}
return defaultGanttSprintRowMetric()
}
export function sanitizeExcludedSprintIds(raw: unknown): number[] {
if (!Array.isArray(raw)) return []
const out: number[] = []
for (const x of raw) {
const n = typeof x === 'number' ? x : Number(x)
if (Number.isFinite(n) && n > 0) out.push(Math.floor(n))
}
return [...new Set(out)]
}
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 = { export type DashboardConfig = {
version: 1 version: 1
@ -34,6 +188,16 @@ 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
/** Badges décarts fonctionnels (vue produit / pages critiques). */
functionalGaps: FunctionalGapBadge[]
/** IDs sprint Jira (API Agile) masqués dans le Gantt et la vue Sprint. */
excludedSprintIds: number[]
/** Lignes dinfo sous les barres de sprint (Gantt). */
ganttSprintRowMetric: GanttSprintRowMetric
/** Dates non travaillées (yyyy-mm-dd, fuseau local) — fond Gantt comme week-end. */
ganttNonWorkingDates: string[]
} }
const STORAGE_KEY = 'dcc-dashboard-config-v1' const STORAGE_KEY = 'dcc-dashboard-config-v1'
@ -46,8 +210,26 @@ export const defaultDashboardConfig = (): DashboardConfig => ({
teamCapacity: 3, teamCapacity: 3,
baselineCapacity: 3, baselineCapacity: 3,
wipSlotsPerDev: 5, wipSlotsPerDev: 5,
functionalGaps: defaultFunctionalGaps(),
excludedSprintIds: [],
ganttSprintRowMetric: defaultGanttSprintRowMetric(),
ganttNonWorkingDates: [],
}) })
/** Import : configuration seule, ou bundle Synology `{ bundleVersion, dashboard }`. */
export function extractDashboardPayload(imported: unknown): Partial<DashboardConfig> | null {
if (!imported || typeof imported !== 'object') return null
const o = imported as Record<string, unknown>
if (o.bundleVersion === 1 && o.dashboard && typeof o.dashboard === 'object') {
const inner = o.dashboard as Partial<DashboardConfig>
if (inner.version !== 1) return null
return inner
}
const flat = imported as Partial<DashboardConfig>
if (flat.version !== 1) return null
return flat
}
export function loadDashboardConfig(): DashboardConfig { export function loadDashboardConfig(): DashboardConfig {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
@ -56,7 +238,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 +264,14 @@ 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,
functionalGaps: sanitizeFunctionalGapsArray(parsed.functionalGaps, defaultFunctionalGaps()),
excludedSprintIds: sanitizeExcludedSprintIds(parsed.excludedSprintIds),
ganttSprintRowMetric: sanitizeGanttSprintRowMetric(parsed.ganttSprintRowMetric),
ganttNonWorkingDates: sanitizeGanttNonWorkingDates(parsed.ganttNonWorkingDates),
} }
} catch { } catch {
return defaultDashboardConfig() return defaultDashboardConfig()
@ -102,16 +292,33 @@ export function exportConfigJson(cfg: DashboardConfig): void {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
/** Sauvegarde JSON enrichie pour NAS Synology (Docker / volume monté) : horodatage + enveloppe versionnée. */
export function exportSynologyBackupJson(cfg: DashboardConfig): void {
const bundle = {
bundleVersion: 1 as const,
exportedAt: new Date().toISOString(),
app: 'dcc-migration-cockpit' as const,
dashboard: cfg,
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
a.download = `dcc-synology-backup-${stamp}.json`
a.click()
URL.revokeObjectURL(url)
}
export function mergeImportedConfig( export function mergeImportedConfig(
current: DashboardConfig, current: DashboardConfig,
imported: unknown, imported: unknown,
): DashboardConfig | null { ): DashboardConfig | null {
if (!imported || typeof imported !== 'object') return null const o = extractDashboardPayload(imported)
const o = imported as Partial<DashboardConfig> if (!o) 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 +342,22 @@ 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,
functionalGaps: sanitizeFunctionalGapsArray(o.functionalGaps, current.functionalGaps),
excludedSprintIds:
o.excludedSprintIds !== undefined ? sanitizeExcludedSprintIds(o.excludedSprintIds) : current.excludedSprintIds,
ganttSprintRowMetric:
o.ganttSprintRowMetric !== undefined
? sanitizeGanttSprintRowMetric(o.ganttSprintRowMetric)
: current.ganttSprintRowMetric,
ganttNonWorkingDates:
o.ganttNonWorkingDates !== undefined
? sanitizeGanttNonWorkingDates(o.ganttNonWorkingDates)
: current.ganttNonWorkingDates,
} }
} }

60
src/lib/functionalGaps.ts Normal file
View File

@ -0,0 +1,60 @@
import type { StoryGroup } from '../types/jira'
import type { FunctionalGapBadge } from './dashboardConfig'
import type { StatusBucketConfig } from './statusBuckets'
import { resolveWorkBucketFromIssue } from './statusBuckets'
function norm(s: string): string {
return s
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/\p{M}/gu, '')
}
function groupHaystack(group: StoryGroup): string {
const { story, subtasks } = group
const parts: string[] = [
story.key,
story.fields.summary,
...(story.fields.labels ?? []),
]
for (const st of subtasks) {
parts.push(st.key, st.fields.summary, ...(st.fields.labels ?? []))
}
return norm(parts.join(' '))
}
function haystackMatchesTerms(hay: string, terms: string[]): boolean {
return terms.some((t) => {
const n = norm(t)
return n.length > 0 && hay.includes(n)
})
}
export function storyMatchesGapBadge(group: StoryGroup, badge: FunctionalGapBadge): boolean {
return haystackMatchesTerms(groupHaystack(group), badge.terms)
}
/** Sous-tâches encore ouvertes sur les stories qui matchent chaque badge (vue écarts). */
export function countOpenGapsByBadge(
groups: StoryGroup[],
badges: FunctionalGapBadge[],
cfg: StatusBucketConfig,
): { id: string; label: string; openCount: number; criticalFlow: boolean }[] {
return badges.map((badge) => {
let openCount = 0
for (const g of groups) {
if (!storyMatchesGapBadge(g, badge)) continue
for (const st of g.subtasks) {
const b = resolveWorkBucketFromIssue(st, cfg)
if (b !== 'done' && b !== 'cancel') openCount += 1
}
}
return {
id: badge.id,
label: badge.label,
openCount,
criticalFlow: Boolean(badge.criticalFlow),
}
})
}

View File

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

View File

@ -0,0 +1,28 @@
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
}
/**
* Board logiciel Jira (ex. URL `.../boards/1445/`) pour lAPI Agile `.../board/{id}/sprint`.
* Sert à lister les sprints **actifs** et **futurs** sans élargir le JQL du cockpit.
* `0` ou `false` = désactiver lappel (repli sur les sprints déduits des tickets chargés).
* Variable absente ou vide → **1445** (board DCC).
*/
export function resolveJiraBoardId(): number | null {
const rawOpt = import.meta.env.VITE_JIRA_BOARD_ID
if (rawOpt === undefined || rawOpt === null) return 1445
const raw = String(rawOpt).trim().toLowerCase()
if (raw === '' || raw === '0' || raw === 'false') return null
const n = Number(String(rawOpt).trim())
if (Number.isFinite(n) && n > 0) return Math.floor(n)
return 1445
}

View File

@ -0,0 +1,68 @@
import type { StoryGroup } from '../types/jira'
import type { FunctionalGapBadge } from './dashboardConfig'
import { detectWorkLane, type LaneLabelsConfig } from './laneDetection'
import type { StatusBucketConfig } from './statusBuckets'
import { isIssueCanceled, isIssueDone } from './statusBuckets'
import { storyMatchesGapBadge } from './functionalGaps'
import { stepperStates } from './storyMetrics'
export type MacroTrafficLight = 'green' | 'amber' | 'red'
export type MacroPipelineHealth = {
light: MacroTrafficLight
title: string
detail: string
violatingStoryKeys: string[]
hasCriticalViolation: boolean
}
/**
* Feux macro (DSI) : rouge / orange si lintégration (piste I) est encore ouverte
* alors que létape Design du stepper nest pas validée à 100 %.
*/
export function computeMacroPipelineHealth(
groups: StoryGroup[],
bucketCfg: StatusBucketConfig,
laneCfg: LaneLabelsConfig,
gapBadges: FunctionalGapBadge[] | undefined,
): MacroPipelineHealth {
const violating: { key: string; critical: boolean }[] = []
const badges = gapBadges ?? []
for (const g of groups) {
const { story, subtasks } = g
if (subtasks.length === 0) continue
const steps = stepperStates(subtasks, bucketCfg, laneCfg)
const designValidated = steps.design
const integrationStillOpen = subtasks.some((st) => {
if (isIssueDone(st, bucketCfg) || isIssueCanceled(st, bucketCfg)) return false
return detectWorkLane(st, laneCfg) === 'integration'
})
if (integrationStillOpen && !designValidated) {
const critical = badges.some((b) => b.criticalFlow && storyMatchesGapBadge(g, b))
violating.push({ key: story.key, critical })
}
}
if (violating.length === 0) {
return {
light: 'green',
title: 'Séquence A → D → I',
detail:
'Aucun conflit détecté : pas dintégration active tant que le design nest pas validé sur une même story.',
violatingStoryKeys: [],
hasCriticalViolation: false,
}
}
const hasCriticalViolation = violating.some((v) => v.critical)
const light: MacroTrafficLight = hasCriticalViolation ? 'red' : 'amber'
return {
light,
title: hasCriticalViolation ? 'Alerte — flux critique' : 'Attention — séquence',
detail: `${violating.length} story(s) avec intégration ouverte sans validation design complète.`,
violatingStoryKeys: violating.map((v) => v.key),
hasCriticalViolation,
}
}

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,
}
}

View File

@ -1,11 +1,17 @@
import type { PhaseId, StoryGroup } from '../types/jira' import type { PhaseId, StoryGroup } from '../types/jira'
import { statusToPhase } from './statusPhase' import type { LaneLabelsConfig } from './laneDetection'
import type { StatusBucketConfig } from './statusBuckets'
import { effectivePipelinePhase } from './pipelinePhase'
export function countSubtasksByPhase(groups: StoryGroup[]): Record<PhaseId, number> { export function countSubtasksByPhase(
groups: StoryGroup[],
bucketCfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): Record<PhaseId, number> {
const acc: Record<PhaseId, number> = { analyse: 0, design: 0, integration: 0, done: 0 } const acc: Record<PhaseId, number> = { analyse: 0, design: 0, integration: 0, done: 0 }
for (const g of groups) { for (const g of groups) {
for (const s of g.subtasks) { for (const s of g.subtasks) {
const p = statusToPhase(s.fields.status.name) const p = effectivePipelinePhase(s, bucketCfg, laneCfg)
acc[p] += 1 acc[p] += 1
} }
} }

19
src/lib/pipelinePhase.ts Normal file
View File

@ -0,0 +1,19 @@
import type { JiraIssue, PhaseId } from '../types/jira'
import { detectWorkLane, type LaneLabelsConfig } from './laneDetection'
import type { StatusBucketConfig } from './statusBuckets'
import { isIssueCanceled, isIssueDone } from './statusBuckets'
import { statusToPhase } from './statusPhase'
/**
* Phase « pipeline » pour une sous-tâche : cartographie statuts, ou piste A/D/I
* (étiquettes + heuristique) lorsque `laneCfg` est fourni — aligné sur `PhaseLaneIcons`.
*/
export function effectivePipelinePhase(
issue: JiraIssue,
bucketCfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): PhaseId {
if (isIssueCanceled(issue, bucketCfg) || isIssueDone(issue, bucketCfg)) return 'done'
if (laneCfg) return detectWorkLane(issue, laneCfg) as PhaseId
return statusToPhase(issue.fields.status.name)
}

19
src/lib/scheduleDelay.ts Normal file
View File

@ -0,0 +1,19 @@
import type { LandingEstimate } from './executiveLanding'
/** Compare latterrissage projeté au dernier jalon (calendrier civil). */
export function calendarDelayVsLastMilestone(
landing: LandingEstimate,
milestoneIso: string | null,
): { delayDays: number; message: string } | null {
if (!landing.estimatedLanding || !milestoneIso || !/^\d{4}-\d{2}-\d{2}/.test(milestoneIso)) {
return null
}
const end = new Date(milestoneIso.slice(0, 10) + 'T12:00:00').getTime()
const land = landing.estimatedLanding.getTime()
const delayDays = Math.ceil((land - end) / 86400000)
if (delayDays <= 0) return null
return {
delayDays,
message: `Retard estimé : +${delayDays} jour(s) calendaire(s) vs dernier jalon (vélocité actuelle).`,
}
}

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

@ -0,0 +1,196 @@
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
}
export function coerceSprintObject(o: Record<string, unknown>): JiraSprintSnapshot | null {
const id = Number(o.id)
const name = typeof o.name === 'string' ? o.name.trim() : ''
if (!Number.isFinite(id) || !name) return null
const boardIdRaw = o.boardId ?? o.originBoardId
const boardId =
typeof boardIdRaw === 'number' && Number.isFinite(boardIdRaw)
? boardIdRaw
: typeof boardIdRaw === 'string' && Number.isFinite(Number(boardIdRaw))
? Number(boardIdRaw)
: undefined
return {
id,
name,
state: typeof o.state === 'string' ? o.state : undefined,
boardId,
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))
}
/** Filtre par clés issues (ex. issues renvoyées par `GET .../sprint/{id}/issue`). */
export function filterGroupsBySprintIssueKeys(groups: StoryGroup[], issueKeys: Set<string>): StoryGroup[] {
if (issueKeys.size === 0) return []
return groups.filter(
(g) =>
issueKeys.has(g.story.key) || g.subtasks.some((st) => issueKeys.has(st.key)),
)
}
function sprintOrderRank(state?: string): number {
const s = (state ?? '').toLowerCase()
if (s === 'active') return 0
if (s === 'future') return 1
if (s === 'closed') return 2
return 3
}
/** Sprints board uniquement (`storyCount` = 0 tant que le champ Sprint nest pas configuré). */
export function sprintOptionsFromBoardOnly(boardSprints: JiraSprintSnapshot[]): SprintOption[] {
if (boardSprints.length === 0) return []
return boardSprints
.map((s) => ({ ...s, storyCount: 0 }))
.sort((a, b) => {
const ra = sprintOrderRank(a.state)
const rb = sprintOrderRank(b.state)
if (ra !== rb) return ra - rb
const as = a.startDate ?? ''
const bs = b.startDate ?? ''
if (as && bs) return as.localeCompare(bs)
return a.name.localeCompare(b.name, 'fr')
})
}
/**
* Sprints renvoyés par lAPI Agile du board (actifs / futurs), enrichis du nombre de **stories**
* du périmètre chargé présentes dans chaque sprint (via le champ Sprint sur les issues).
*/
export function sprintOptionsFromBoardAndGroups(
boardSprints: JiraSprintSnapshot[],
groups: StoryGroup[],
fieldId: string | null,
): SprintOption[] {
if (!fieldId || boardSprints.length === 0) return []
const boardIds = new Set(boardSprints.map((s) => s.id))
const keysBySprintId = new Map<number, Set<string>>()
for (const g of groups) {
const seen = new Set<number>()
for (const issue of [g.story, ...g.subtasks]) {
for (const sp of getSprintsOnIssue(issue, fieldId)) {
if (!boardIds.has(sp.id) || seen.has(sp.id)) continue
seen.add(sp.id)
if (!keysBySprintId.has(sp.id)) keysBySprintId.set(sp.id, new Set())
keysBySprintId.get(sp.id)!.add(g.story.key)
}
}
}
return boardSprints
.map((sprint) => ({
...sprint,
storyCount: keysBySprintId.get(sprint.id)?.size ?? 0,
}))
.sort((a, b) => {
const ra = sprintOrderRank(a.state)
const rb = sprintOrderRank(b.state)
if (ra !== rb) return ra - rb
const as = a.startDate ?? ''
const bs = b.startDate ?? ''
if (as && bs) return as.localeCompare(bs)
return a.name.localeCompare(b.name, 'fr')
})
}

527
src/lib/sprintGantt.ts Normal file
View File

@ -0,0 +1,527 @@
import type { GanttSprintRowMetric, Milestone } from './dashboardConfig'
import { milestoneKindLabel } from './milestoneKinds'
import type { StoryGroup } from '../types/jira'
import type { StatusBucketConfig } from './statusBuckets'
import { resolveWorkBucketFromIssue } from './statusBuckets'
import type { JiraSprintSnapshot } from './sprintExtract'
import { groupInSprint } from './sprintExtract'
export const MS_DAY = 86400000
/** Échelle daffichage du Gantt (densité des graduations). */
export type GanttTimeScale = 'day' | 'week' | 'month'
export type GanttTick = { ms: number; label: string; major?: boolean }
/** Facteurs de zoom (indice ↑ = plus de pixels par jour = loupe « + »). */
export const GANTT_ZOOM_FACTORS = [0.35, 0.5, 0.68, 0.9, 1.2, 1.6, 2.1, 2.8] as const
const BASE_PPD: Record<GanttTimeScale, number> = {
/** ~2,5 semaines lisibles à zoom neutre sur un écran large */
day: 22,
week: 8,
month: 2.4,
}
export function pixelsPerDay(scale: GanttTimeScale, zoomIndex: number): number {
const i = Math.max(0, Math.min(GANTT_ZOOM_FACTORS.length - 1, zoomIndex))
return BASE_PPD[scale] * GANTT_ZOOM_FACTORS[i]!
}
export function timelineWidthPx(startMs: number, endMs: number, ppd: number): number {
const days = Math.max(1 / 24, (endMs - startMs) / MS_DAY)
return Math.max(720, Math.ceil(days * ppd))
}
export function msToX(ms: number, startMs: number, endMs: number, widthPx: number): number {
const span = Math.max(1, endMs - startMs)
return ((ms - startMs) / span) * widthPx
}
/**
* Graduations selon léchelle ; `maxTicks` limite le nombre de lignes / libellés.
*/
export function timelineTicks(
scale: GanttTimeScale,
startMs: number,
endMs: number,
maxTicks: number,
): GanttTick[] {
const cap = Math.max(8, Math.min(72, maxTicks))
const ticks: GanttTick[] = []
if (scale === 'month') {
const d = new Date(startMs)
d.setDate(1)
d.setHours(12, 0, 0, 0)
while (d.getTime() <= endMs) {
if (d.getTime() >= startMs - MS_DAY / 2) {
ticks.push({
ms: d.getTime(),
label: d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }),
major: true,
})
}
d.setMonth(d.getMonth() + 1)
if (ticks.length > 120) break
}
return thinTicks(ticks, cap)
}
if (scale === 'week') {
const stepWeeks = Math.max(1, Math.ceil(((endMs - startMs) / MS_DAY / 7) / cap))
let t = startOfWeekMondayLocal(startMs)
let back = 0
while (t + 6 * MS_DAY < startMs && back++ < 104) {
t += stepWeeks * 7 * MS_DAY
}
let guard = 0
while (t <= endMs && guard++ < 200) {
if (t + 6 * MS_DAY >= startMs) {
ticks.push({
ms: t,
label: formatWeekRangeFr(t),
major: true,
})
}
t += stepWeeks * 7 * MS_DAY
}
return ticks
}
/* day */
const totalDays = (endMs - startMs) / MS_DAY
const stepDays = totalDays > cap ? Math.max(1, Math.ceil(totalDays / cap)) : 1
let cur = startMs
let g = 0
while (cur <= endMs && g++ < 500) {
const dd = new Date(cur)
ticks.push({
ms: cur,
label:
stepDays === 1
? dd.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })
: dd.toLocaleDateString('fr-FR', { weekday: 'short', day: 'numeric', month: 'short' }),
major: dd.getDay() === 1,
})
cur += stepDays * MS_DAY
}
return ticks
}
/** 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()
const diff = day === 0 ? -6 : 1 - day
d.setDate(d.getDate() + diff)
return d.getTime()
}
function formatWeekRangeFr(weekStartMs: number): string {
const a = new Date(weekStartMs)
const b = new Date(weekStartMs + 6 * MS_DAY)
const sameMonth = a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear()
if (sameMonth) {
return `${a.getDate()}${b.getDate()} ${a.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' })}`
}
return `${a.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} ${b.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: '2-digit' })}`
}
function thinTicks(ticks: GanttTick[], max: number): GanttTick[] {
if (ticks.length <= max) return ticks
const step = Math.ceil(ticks.length / max)
return ticks.filter((_, i) => i % step === 0)
}
export function parseIsoMs(iso?: string | null): number | null {
if (!iso || typeof iso !== 'string') return null
const t = Date.parse(iso)
return Number.isFinite(t) ? t : null
}
/** Fenêtre calendaire du sprint à partir des dates Jira Agile (`startDate` / `endDate`). */
export function sprintBarBounds(s: JiraSprintSnapshot): { startMs: number; endMs: number } | null {
const startRaw = parseIsoMs(s.startDate)
const endRaw = parseIsoMs(s.endDate)
if (startRaw == null && endRaw == null) return null
let startMs = startRaw ?? endRaw! - 14 * MS_DAY
let endMs = endRaw ?? startRaw! + 14 * MS_DAY
if (endMs <= startMs) endMs = startMs + MS_DAY
return { startMs, endMs }
}
export function ganttRangeFromSprintsAndMilestones(
sprints: JiraSprintSnapshot[],
milestones: Milestone[],
): { startMs: number; endMs: number } {
const now = Date.now()
let sMin = now
let sMax = now + 30 * MS_DAY
let has = false
for (const sp of sprints) {
const b = sprintBarBounds(sp)
if (!b) continue
has = true
sMin = Math.min(sMin, b.startMs)
sMax = Math.max(sMax, b.endMs)
}
for (const m of milestones) {
const t = parseIsoMs(`${m.date}T12:00:00`)
if (t != null) {
has = true
sMin = Math.min(sMin, t)
sMax = Math.max(sMax, t)
}
}
if (!has) {
return { startMs: now - 14 * MS_DAY, endMs: now + 90 * MS_DAY }
}
sMin = Math.min(sMin, now - 7 * MS_DAY)
sMax = Math.max(sMax, now + 28 * MS_DAY)
return { startMs: sMin, endMs: sMax }
}
export function monthTicksBetween(startMs: number, endMs: number): { ms: number; label: string }[] {
const ticks: { ms: number; label: string }[] = []
const d = new Date(startMs)
d.setDate(1)
d.setHours(12, 0, 0, 0)
while (d.getTime() <= endMs) {
if (d.getTime() >= startMs) {
ticks.push({
ms: d.getTime(),
label: d.toLocaleDateString('fr-FR', { month: 'short', year: '2-digit' }),
})
}
d.setMonth(d.getMonth() + 1)
}
return ticks
}
/** 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
/** 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,
nonWorkingKeys?: ReadonlySet<string>,
): 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) {
const key = localIsoDateKey(dayStart)
const isWeekend = wd === 0 || wd === 6
const isConfigured = nonWorkingKeys?.has(key) ?? false
cols.push({
dayStartMs: dayStart,
weekday: wd,
x0,
x1,
isGanttNonWork: isWeekend || isConfigured,
})
}
}
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 wdShort = d.toLocaleDateString('fr-FR', { weekday: 'short' }).replace(/\.$/, '')
const dom = d.getDate()
const label =
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 })
}
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 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 })
}
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
}
/** Avancement livraison : sous-tâches Done / (non annulées) pour le périmètre épique dans ce sprint. */
export function epicScopeSprintProgress(
groups: StoryGroup[],
sprintId: number,
fieldId: string | null,
cfg: StatusBucketConfig,
): { done: number; total: number; percent: number } {
if (!fieldId) return { done: 0, total: 0, percent: 0 }
let done = 0
let total = 0
for (const g of groups) {
if (!groupInSprint(g, sprintId, fieldId)) continue
for (const st of g.subtasks) {
const b = resolveWorkBucketFromIssue(st, cfg)
if (b === 'cancel') continue
total += 1
if (b === 'done') done += 1
}
}
const percent = total > 0 ? Math.round((done / total) * 100) : 0
return { done, total, percent }
}
/** Avancement temporel du sprint (début → fin vs aujourdhui). */
export function sprintTimeElapsedPercent(s: JiraSprintSnapshot, nowMs = Date.now()): number {
const b = sprintBarBounds(s)
if (!b) return 0
const { startMs, endMs } = b
if (nowMs <= startMs) return 0
if (nowMs >= endMs) return 100
return Math.round(((nowMs - startMs) / (endMs - startMs)) * 100)
}
/**
* Remplissage de la barre : uniquement le % de sous-tâches terminées (périmètre + champ Sprint).
* Pas davancement calendaire : sans données, la barre reste à 0 %.
*/
export function sprintBarFillPercent(
s: JiraSprintSnapshot,
groups: StoryGroup[],
fieldId: string | null,
cfg: StatusBucketConfig,
): number {
const delivery = epicScopeSprintProgress(groups, s.id, fieldId, cfg)
if (!fieldId || delivery.total === 0) return 0
return Math.min(100, delivery.percent)
}
export function milestoneTooltipText(m: Milestone): string {
const lines: string[] = [m.title || 'Jalon', `Date : ${m.date}`]
if (m.expectedActions?.trim()) lines.push(`Actions attendues :\n${m.expectedActions.trim()}`)
if (m.critical) lines.push('Jalon critique')
lines.push(`Type : ${milestoneKindLabel(m.kind)}`)
return lines.join('\n\n')
}
export function formatSprintRangeFr(s: JiraSprintSnapshot): string {
const fmt = (iso?: string) => {
if (!iso) return '—'
try {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
} catch {
return '—'
}
}
return `${fmt(s.startDate)}${fmt(s.endDate)}`
}
export function formatSprintTimeRemaining(s: JiraSprintSnapshot): string | null {
const b = sprintBarBounds(s)
if (!b) return null
const now = Date.now()
if (now >= b.endMs) return 'Sprint terminé (dates)'
if (now < b.startMs) {
const j = Math.ceil((b.startMs - now) / MS_DAY)
if (j <= 1) return 'Démarre demain ou aujourdhui'
return `Début dans ${j} j`
}
const j = Math.ceil((b.endMs - now) / MS_DAY)
if (j <= 1) return '< 1 j restant'
return j === 1 ? '1 jour restant' : `${j} jours restants`
}
export function formatGanttSprintSubtitleLines(
metric: GanttSprintRowMetric,
s: JiraSprintSnapshot,
groups: StoryGroup[],
fieldId: string | null,
cfg: StatusBucketConfig,
): string[] {
const time = formatSprintTimeRemaining(s)
const prog = epicScopeSprintProgress(groups, s.id, fieldId, cfg)
switch (metric) {
case 'none':
return []
case 'time_remaining':
return time ? [time] : []
case 'subtasks_done_count':
if (!fieldId) return ['Champ Sprint requis pour compter les sous-tâches']
if (prog.total === 0) return ['Aucune sous-tâche dans ce sprint (périmètre chargé)']
return [`${prog.done} / ${prog.total} sous-tâches terminées`]
case 'subtasks_done_percent':
if (!fieldId) return ['Champ Sprint requis pour le %']
if (prog.total === 0) return ['Aucune sous-tâche dans ce sprint']
return [`${prog.percent} % sous-tâches terminées`]
case 'combined':
default: {
const lines: string[] = []
if (time) lines.push(time)
if (fieldId && prog.total > 0) lines.push(`${prog.done} / ${prog.total} st · ${prog.percent} %`)
return lines.length > 0 ? lines : [time ?? '—']
}
}
}

View File

@ -1,7 +1,9 @@
import type { JiraIssue, PhaseId } from '../types/jira' import type { JiraIssue, PhaseId } from '../types/jira'
import { PHASE_ORDER, statusToPhase } from './statusPhase' import type { LaneLabelsConfig } from './laneDetection'
import { PHASE_ORDER } from './statusPhase'
import type { StatusBucketConfig } from './statusBuckets' import type { StatusBucketConfig } from './statusBuckets'
import { isIssueCanceled, isIssueDone } from './statusBuckets' import { isIssueCanceled, isIssueDone } from './statusBuckets'
import { effectivePipelinePhase } from './pipelinePhase'
function phaseRank(p: PhaseId): number { function phaseRank(p: PhaseId): number {
const i = PHASE_ORDER.indexOf(p) const i = PHASE_ORDER.indexOf(p)
@ -14,10 +16,14 @@ const MAX_PHASE_RANK = PHASE_ORDER.length - 1
* % davancement moyen des sous-tâches sur le pipeline Analyse → Design → Intégration → Terminé * % davancement moyen des sous-tâches sur le pipeline Analyse → Design → Intégration → Terminé
* (0 % = tout en analyse, 100 % = tout terminé). * (0 % = tout en analyse, 100 % = tout terminé).
*/ */
export function storyProgressPercent(subtasks: JiraIssue[]): number { export function storyProgressPercent(
subtasks: JiraIssue[],
bucketCfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): number {
if (subtasks.length === 0) return 0 if (subtasks.length === 0) return 0
const sum = subtasks.reduce( const sum = subtasks.reduce(
(acc, st) => acc + phaseRank(statusToPhase(st.fields.status.name)), (acc, st) => acc + phaseRank(effectivePipelinePhase(st, bucketCfg, laneCfg)),
0, 0,
) )
return Math.round((sum / (subtasks.length * MAX_PHASE_RANK)) * 100) return Math.round((sum / (subtasks.length * MAX_PHASE_RANK)) * 100)
@ -27,10 +33,17 @@ export function storyProgressPercent(subtasks: JiraIssue[]): number {
* Étape A/D/I « passée » : toutes les sous-tâches sont **strictement** au-delà de cette phase * Étape A/D/I « passée » : toutes les sous-tâches sont **strictement** au-delà de cette phase
* (évite les barres vertes alors que tout est encore en analyse / ouvert). * (évite les barres vertes alors que tout est encore en analyse / ouvert).
*/ */
export function isStepComplete(subtasks: JiraIssue[], stepPhase: PhaseId): boolean { export function isStepComplete(
subtasks: JiraIssue[],
stepPhase: PhaseId,
bucketCfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): boolean {
if (subtasks.length === 0) return false if (subtasks.length === 0) return false
const stepIdx = phaseRank(stepPhase) const stepIdx = phaseRank(stepPhase)
return subtasks.every((st) => phaseRank(statusToPhase(st.fields.status.name)) > stepIdx) return subtasks.every(
(st) => phaseRank(effectivePipelinePhase(st, bucketCfg, laneCfg)) > stepIdx,
)
} }
/** /**
@ -50,11 +63,12 @@ export function subtaskDoneRatioPercent(
export function stepperStates( export function stepperStates(
subtasks: JiraIssue[], subtasks: JiraIssue[],
cfg: StatusBucketConfig, cfg: StatusBucketConfig,
laneCfg?: LaneLabelsConfig | null,
): Record<PhaseId, boolean> { ): Record<PhaseId, boolean> {
return { return {
analyse: isStepComplete(subtasks, 'analyse'), analyse: isStepComplete(subtasks, 'analyse', cfg, laneCfg),
design: isStepComplete(subtasks, 'design'), design: isStepComplete(subtasks, 'design', cfg, laneCfg),
integration: isStepComplete(subtasks, 'integration'), integration: isStepComplete(subtasks, 'integration', cfg, laneCfg),
done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)), done: subtasks.length > 0 && subtasks.every((st) => isIssueDone(st, cfg)),
} }
} }

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

@ -14,6 +14,10 @@ interface ImportMetaEnv {
readonly VITE_JIRA_EPIC_KEY?: string readonly VITE_JIRA_EPIC_KEY?: string
/** Taille de page `/search/jql` (1100, défaut 100). Sans maxResults, Jira renvoie souvent 20 issues. */ /** Taille de page `/search/jql` (1100, défaut 100). Sans maxResults, Jira renvoie souvent 20 issues. */
readonly VITE_JIRA_PAGE_SIZE?: string readonly VITE_JIRA_PAGE_SIZE?: string
/** ID du board logiciel Jira pour lister les sprints actifs/futurs (API Agile). 0 = désactiver. */
readonly VITE_JIRA_BOARD_ID?: string
/** Champ Sprint Scrum (ex. customfield_10020). */
readonly VITE_JIRA_SPRINT_FIELD?: string
} }
interface ImportMeta { interface ImportMeta {