From 98ae311545d3bd41fe7ab76ca3c25e6c60ddaa1f Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Fri, 31 Jul 2026 06:53:57 +0200 Subject: [PATCH] Frise multi-projets : outil de planification macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une frise chronologique unique où plusieurs projets s'empilent en couloirs pliables, pour voir d'un coup d'œil où en est chacun et comment ils se situent les uns par rapport aux autres. Ce n'est pas un outil de suivi quotidien : une phase se compte en semaines, et il n'y a ni sous-tâches, ni tickets, ni dépendances entre phases. - Manipulation directe des barres (glisser, redimensionner), accrochées au lundi - Couloirs pliables : plié, un projet devient une barre segmentée par phase - Planning de référence figeable, avec barre fantôme et calcul de dérive - En-tête à trois bandes : année, mois, numéro de semaine ISO 8601 - Frise qui s'élargit au défilement, pour planifier dans un futur encore vide - Micro-serveur Python (bibliothèque standard) exposant GET/PUT sur /api/data, avec sauvegarde horodatée avant chaque écriture - Zéro build : modules ES natifs, aucune dépendance à installer 50 tests unitaires sur la logique métier (node --test, sans dépendance). Vérifié dans Chromium et Firefox. Les arbitrages de conception et surtout leurs raisons sont consignés dans docs/decisions.md — notamment l'abandon des dépendances entre phases, de la File System Access API, du SVG et des niveaux de zoom. Co-Authored-By: Claude Opus 5 --- .gitignore | 21 + README.md | 130 ++++++ css/style.css | 877 +++++++++++++++++++++++++++++++++++++++++ data/exemple.json | 155 ++++++++ docs/decisions.md | 220 +++++++++++ docs/modele-donnees.md | 123 ++++++ index.html | 158 ++++++++ js/app.js | 596 ++++++++++++++++++++++++++++ js/detail.js | 170 ++++++++ js/drag.js | 191 +++++++++ js/menu.js | 215 ++++++++++ js/model.js | 515 ++++++++++++++++++++++++ js/storage.js | 142 +++++++ js/timeline.js | 502 +++++++++++++++++++++++ serve.py | 230 +++++++++++ tests/model.test.js | 521 ++++++++++++++++++++++++ 16 files changed, 4766 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 css/style.css create mode 100644 data/exemple.json create mode 100644 docs/decisions.md create mode 100644 docs/modele-donnees.md create mode 100644 index.html create mode 100644 js/app.js create mode 100644 js/detail.js create mode 100644 js/drag.js create mode 100644 js/menu.js create mode 100644 js/model.js create mode 100644 js/storage.js create mode 100644 js/timeline.js create mode 100644 serve.py create mode 100644 tests/model.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dd4bbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Sauvegardes horodatées écrites par serve.py à chaque enregistrement. +# Régénérables, et jusqu'à 50 fichiers en rotation : uniquement du bruit ici. +data/backups/ + +# Fichier temporaire de l'écriture atomique du serveur. Normalement renommé +# aussitôt, il ne subsiste qu'en cas d'arrêt brutal en cours d'écriture. +data/*.tmp + +# Réglages locaux de Claude Code, propres à la machine. +.claude/settings.local.json + +__pycache__/ +*.py[cod] +.DS_Store + +# Note : data/projets.json n'est *pas* ignoré. Versionner son planning est le +# moyen de synchronisation recommandé dans le README — un conflit git est +# visible, là où un dossier synchronisé écrase silencieusement. Pour garder le +# dépôt limité au code, ajouter cette ligne : +# +# data/projets.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..76e089a --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Frise multi-projets + +Un outil de **planification macro** : voir sur une seule frise chronologique où en est chaque +projet, quelles sont ses grandes phases, et comment les projets se situent les uns par rapport aux +autres. + +Ce n'est **pas** un outil de suivi quotidien : pas de sous-tâches, pas de tickets, pas de +checklists. Une phase représente plusieurs semaines ou plusieurs mois de travail. + +## Démarrer + +```sh +python3 serve.py +``` + +Puis ouvrir . Aucune installation, aucune dépendance, aucune étape de +compilation — ni côté Python (bibliothèque standard uniquement) ni côté navigateur (JavaScript +vanilla en modules ES natifs). + +Options : + +```sh +python3 serve.py --port 9000 # changer le port +python3 serve.py --data data/exemple.json # ouvrir un autre fichier de données +``` + +Au premier lancement, le planning est vide. Pour découvrir l'outil sur des données +réalistes : + +```sh +cp data/exemple.json data/projets.json +``` + +## Prise en main + +Chaque projet porte trois commandes dans la colonne de gauche : + +- **le triangle** plie et déplie son couloir ; +- **l'œil** le masque de la frise sans le supprimer ; +- **`⋯`** ouvre son menu : ajouter une phase ou un jalon, renommer, changer la couleur, + figer la référence, supprimer le projet. Un clic droit sur le libellé ouvre le même menu. + +Une fois un projet en place : + +| Geste | Effet | +|---|---| +| Glisser le corps d'une barre | Décale la phase, durée conservée | +| Glisser le bord d'une barre | Change la date de début ou de fin | +| Clic sur une barre ou son nom | Ouvre le panneau de détail | +| `←` `→` | Décale d'un jour la phase sélectionnée | +| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour | +| `Échap` | Annule le glisser en cours, ou ferme le panneau | +| `Maj` + molette | Fait défiler la frise horizontalement | +| `↑` `↓` dans un menu | Parcourt les entrées | + +Faire défiler jusqu'au bord droit élargit la frise vers le futur, et jusqu'au bord gauche +vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour. + +Les barres s'accrochent au lundi pendant le glisser. Pour une date au jour près, passer +par les champs du panneau de détail. + +## Ce que fait l'outil + +- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin, + un statut, des notes libres. Une phase peut être un **jalon** (une date unique, rendue en losange). +- **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil. +- **Couloirs pliables** : déplié, on voit chaque phase sur sa ligne ; plié, le projet devient une + barre unique segmentée en teintes, qui donne la forme d'ensemble sans le détail. +- **Manipulation directe** : glisser une barre pour la décaler, tirer ses bords pour changer sa + durée. Les barres s'accrochent au lundi ; pour une date exacte, on passe par le panneau de détail. +- **Planning de référence** : une fois le planning validé, on le fige. La frise affiche alors une + barre fantôme sous la barre réelle et la dérive en semaines — de quoi répondre à « ce projet a-t-il + glissé, et de combien ? ». +- **Un en-tête à trois niveaux** — année, mois, numéro de semaine ISO — qui donne le + repère temporel sans jamais descendre au jour : à cette échelle, la semaine suffit. + Il n'y a qu'une seule échelle, donc aucun réglage de zoom à comprendre. +- **Une frise sans fin** : elle s'élargit d'elle-même quand le défilement approche d'un + bord, ce qui permet d'aller planifier dans un futur qu'aucune phase n'occupe encore. +- **Repère « aujourd'hui »** et masquage d'un projet. + +## Ce que l'outil ne fait pas + +Volontairement : pas de dépendances entre phases, pas de sous-tâches, pas de collaboration temps +réel, pas d'authentification, pas de gestion de charge par personne, pas de notifications. + +Les durées se comptent en **jours calendaires** : ni week-ends ni jours fériés ne sont gérés. À +l'échelle de phases de plusieurs semaines, la distinction n'apporte rien. + +Les raisons derrière ces choix sont détaillées dans [docs/decisions.md](docs/decisions.md). + +## Où vivent les données + +Tout tient dans un fichier JSON, `data/projets.json`, décrit dans +[docs/modele-donnees.md](docs/modele-donnees.md). Il est indenté et utilise des identifiants +parlants (`site-web`, `cadrage`) plutôt que des UUID : il reste éditable à la main et lisible dans un +diff git. + +À chaque sauvegarde, le serveur copie la version précédente dans `data/backups/`, horodatée. + +**Pour synchroniser plusieurs machines**, placer `data/` dans un dépôt git ou un dossier synchronisé +(Syncthing, Drive). L'outil ne gère pas les conflits : si deux machines modifient le planning en même +temps, c'est à la couche de synchronisation de les signaler. Git est recommandé pour cette raison — +un conflit y est visible, là qu'un dossier synchronisé écrase silencieusement. + +## Tests + +```sh +node --test +``` + +Couvre la logique métier : validation des dates, bornes d'un projet, calcul de dérive, génération des +identifiants. Aucune dépendance, le lanceur de tests intégré à Node suffit. + +## Organisation du code + +| Fichier | Rôle | +|---|---| +| `serve.py` | Sert les fichiers statiques, expose `GET`/`PUT` sur `/api/data`, écrit les sauvegardes | +| `js/model.js` | Données et règles métier : CRUD, validation, bornes, référence, dérive | +| `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état | +| `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli | +| `js/drag.js` | Glisser et redimensionner les barres | +| `js/detail.js` | Panneau de détail | +| `js/menu.js` | Menu contextuel et boîtes de dialogue | +| `js/app.js` | Amorçage, état en mémoire, câblage des événements | + +Le rendu utilise des éléments DOM positionnés en absolu, pas de SVG ni de bibliothèque Gantt. + +Les identifiants de code et les clés JSON sont en anglais ; l'interface, les commentaires et la +documentation sont en français. diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..c6f1b0f --- /dev/null +++ b/css/style.css @@ -0,0 +1,877 @@ +/* Frise multi-projets — feuille de style unique. + * + * Deux principes structurent ce fichier : + * + * 1. Les barres, jalons et cellules d'en-tête sont positionnés en absolu : + * timeline.js leur calcule un `left` et une `width` en pixels depuis + * l'échelle temporelle. Les couloirs, eux, restent en flux normal. + * + * 2. La frise entière est une grille dans un unique conteneur défilant, dont + * l'en-tête et la colonne des libellés sont figés par `position: sticky`. + * Le défilement des deux axes est donc entièrement natif. + */ + +:root { + --fond: #f8fafc; + --surface: #ffffff; + --bordure: #e2e8f0; + --bordure-forte: #cbd5e1; + --texte: #0f172a; + --texte-doux: #64748b; + --accent: #3b82f6; + --danger: #dc2626; + --aujourdhui: #ef4444; + + --hauteur-barre: 26px; + --hauteur-ligne: 38px; + --hauteur-entete: 66px; /* trois bandes de 22 px : année, mois, semaine */ + --largeur-libelles: 250px; + + --rayon: 6px; + --ombre: 0 1px 2px rgb(15 23 42 / 0.08); +} + +@media (prefers-color-scheme: dark) { + :root { + --fond: #0f172a; + --surface: #1e293b; + --bordure: #334155; + --bordure-forte: #475569; + --texte: #f1f5f9; + --texte-doux: #94a3b8; + --ombre: 0 1px 2px rgb(0 0 0 / 0.3); + } +} + +* { + box-sizing: border-box; +} + +/* Indispensable : plus bas, `.panneau` et `.alerte` déclarent `display: flex`, + ce qui écrase le `display: none` que le navigateur associe à l'attribut + `hidden`. Sans cette règle, ces éléments restent des calques `position: fixed` + étendus sur toute la page, invisibles mais interceptant tous les clics — la + frise devient inerte à la souris. */ +[hidden] { + display: none !important; +} + +body { + margin: 0; + font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; + color: var(--texte); + background: var(--fond); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* --- barre d'outils (préfixe `outils` : `barre` est réservé aux barres de phase) ------------------------------------------------------ */ + +.outils { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: var(--surface); + border-bottom: 1px solid var(--bordure); + flex-shrink: 0; +} + +.outils h1 { + font-size: 15px; + font-weight: 600; + margin: 0; + white-space: nowrap; +} + +.outils__groupe { + display: flex; + gap: 4px; +} + +.outils__etat { + margin: 0 0 0 auto; + font-size: 12px; + color: var(--texte-doux); + min-width: 130px; + text-align: right; +} + +.outils__etat[data-etat="erreur"] { + color: var(--danger); + font-weight: 500; +} + +button { + font: inherit; + padding: 5px 11px; + border: 1px solid var(--bordure-forte); + border-radius: var(--rayon); + background: var(--surface); + color: var(--texte); + cursor: pointer; +} + +button:hover { + border-color: var(--accent); +} + +button[aria-pressed="true"] { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.bouton--principal { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.bouton--danger { + color: var(--danger); + border-color: var(--danger); +} + +.bouton--danger:hover { + background: var(--danger); + color: #fff; +} + +/* --- structure de la frise ------------------------------------------------ */ + +/* Un seul conteneur défilant, sur les deux axes. L'en-tête et la colonne des + libellés restent en place grâce à `position: sticky`, ce qui évite toute + synchronisation de défilement en JavaScript. */ +.frise { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--fond); +} + +.grille { + display: grid; + grid-template-columns: var(--largeur-libelles) max-content; + grid-template-rows: var(--hauteur-entete) max-content; + width: max-content; + /* Sans cela, la dernière ligne colle au bas de la fenêtre et la barre de + défilement horizontale recouvre le dernier couloir. */ + padding-bottom: 24px; +} + +/* Le coin cumule les deux ancrages, et doit donc passer au-dessus des deux. */ +.grille__coin { + position: sticky; + top: 0; + left: 0; + z-index: 6; + background: var(--surface); + border-right: 1px solid var(--bordure); + border-bottom: 1px solid var(--bordure); +} + +.grille__entete { + position: sticky; + top: 0; + z-index: 5; + background: var(--surface); + border-bottom: 1px solid var(--bordure); +} + +.grille__libelles { + position: sticky; + left: 0; + z-index: 4; + background: var(--surface); + border-right: 1px solid var(--bordure); +} + +.grille__couloirs { + position: relative; + z-index: 1; +} + +/* --- en-tête de dates : trois bandes empilées ----------------------------- */ + +.bande { + position: relative; + height: calc(var(--hauteur-entete) / 3); + border-bottom: 1px solid var(--bordure); +} + +.bande--semaine { + border-bottom: 0; +} + +.bande__cellule { + position: absolute; + top: 0; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + border-left: 1px solid var(--bordure); + font-size: 11px; + color: var(--texte-doux); + white-space: nowrap; + overflow: hidden; + pointer-events: none; +} + +.bande--annee .bande__cellule { + font-size: 12px; + font-weight: 600; + color: var(--texte); + border-left-color: var(--bordure-forte); + background: color-mix(in srgb, var(--texte) 4%, transparent); +} + +.bande--mois .bande__cellule { + font-weight: 500; + color: var(--texte); + border-left-color: var(--bordure-forte); +} + +/* Les numéros de semaine sont le grain le plus fin : discrets par défaut, + ils ne doivent pas concurrencer le mois et l'année. */ +.bande--semaine .bande__cellule { + font-size: 10px; + letter-spacing: -0.2px; +} + +/* --- traits verticaux ----------------------------------------------------- */ + +.trait { + position: absolute; + top: 0; + width: 1px; + background: var(--bordure); + pointer-events: none; +} + +.trait--majeur { + background: var(--bordure-forte); +} + +.trait--aujourdhui { + background: var(--aujourdhui); + width: 2px; + z-index: 4; +} + +.trait--aujourdhui::after { + content: ""; + position: absolute; + top: 0; + left: -3px; + border: 4px solid transparent; + border-top-color: var(--aujourdhui); +} + +/* --- libellés ------------------------------------------------------------ */ + +.libelle-projet { + display: flex; + align-items: center; + gap: 6px; + height: var(--hauteur-ligne); + padding: 0 8px 0 6px; + font-weight: 600; + border-bottom: 1px solid var(--bordure); + background: color-mix(in srgb, var(--couleur-projet) 8%, var(--surface)); +} + +.libelle-projet__nom { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.libelle-projet__derive { + font-size: 11px; + font-weight: 500; + padding: 1px 5px; + border-radius: 10px; + white-space: nowrap; +} + +.libelle-projet__derive[data-sens="retard"] { + background: color-mix(in srgb, var(--danger) 15%, transparent); + color: var(--danger); +} + +.libelle-projet__derive[data-sens="avance"] { + background: color-mix(in srgb, #16a34a 15%, transparent); + color: #16a34a; +} + +.plier { + border: 0; + background: none; + padding: 2px 4px; + font-size: 10px; + color: var(--texte-doux); + cursor: pointer; + transition: transform 0.15s; +} + +.plier[aria-expanded="true"] { + transform: rotate(90deg); +} + +.icone-oeil { + border: 0; + background: none; + padding: 2px 4px; + cursor: pointer; + opacity: 0.45; + font-size: 12px; +} + +.icone-oeil:hover { + opacity: 1; +} + +/* `display: block` et non `flex` : `text-overflow: ellipsis` ne s'applique pas + au texte d'un conteneur flex, et le nom serait coupé net. Le centrage + vertical passe donc par `line-height`. */ +.libelle-phase { + display: block; + line-height: var(--hauteur-ligne); + height: var(--hauteur-ligne); + padding: 0 8px 0 26px; + color: var(--texte-doux); + border-bottom: 1px solid var(--bordure); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +.libelle-phase:hover { + color: var(--texte); +} + +.libelle-projet--masque { + opacity: 0.45; +} + +/* --- couloirs et barres -------------------------------------------------- */ + +.couloir { + position: relative; + height: var(--hauteur-ligne); + border-bottom: 1px solid var(--bordure); +} + +.couloir--projet { + background: color-mix(in srgb, var(--couleur-projet) 5%, transparent); +} + +.barre { + position: absolute; + top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2); + height: var(--hauteur-barre); + border-radius: 4px; + background: var(--couleur-projet); + color: #fff; + font-size: 12px; + line-height: var(--hauteur-barre); + padding: 0 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: grab; + box-shadow: var(--ombre); + user-select: none; + z-index: 2; +} + +.barre:hover { + filter: brightness(1.08); +} + +.barre--glissee { + cursor: grabbing; + z-index: 5; + filter: brightness(1.12); + box-shadow: 0 4px 12px rgb(15 23 42 / 0.25); +} + +.barre--selectionnee { + outline: 2px solid var(--texte); + outline-offset: 1px; +} + +/* Le statut se lit à la texture de la barre, pas à sa couleur : la couleur + appartient au projet, elle sert à distinguer les couloirs entre eux. */ +.barre--todo { + background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface)); + color: var(--texte); + border: 1px solid var(--couleur-projet); +} + +.barre--doing { + background: var(--couleur-projet); +} + +.barre--done { + background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b); +} + +.barre--blocked { + background: repeating-linear-gradient( + 45deg, + var(--couleur-projet), + var(--couleur-projet) 6px, + color-mix(in srgb, var(--danger) 75%, var(--couleur-projet)) 6px, + color-mix(in srgb, var(--danger) 75%, var(--couleur-projet)) 12px + ); +} + +/* Poignées de redimensionnement, révélées au survol de la barre. */ +.poignee { + position: absolute; + top: 0; + width: 7px; + height: 100%; + cursor: ew-resize; + opacity: 0; + background: rgb(255 255 255 / 0.5); +} + +.barre:hover .poignee { + opacity: 1; +} + +.poignee--debut { + left: 0; + border-radius: 4px 0 0 4px; +} + +.poignee--fin { + right: 0; + border-radius: 0 4px 4px 0; +} + +/* --- jalons -------------------------------------------------------------- */ + +.jalon { + position: absolute; + top: calc((var(--hauteur-ligne) - 14px) / 2); + width: 14px; + height: 14px; + margin-left: -7px; + background: var(--couleur-projet); + transform: rotate(45deg); + cursor: grab; + z-index: 3; + box-shadow: var(--ombre); +} + +.jalon--done { + background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b); +} + +.jalon--blocked { + background: var(--danger); +} + +.jalon--selectionne { + outline: 2px solid var(--texte); + outline-offset: 1px; +} + +/* Le nom du jalon vit hors du losange, pour ne pas subir la rotation. */ +.jalon-nom { + position: absolute; + top: calc((var(--hauteur-ligne) - 16px) / 2); + margin-left: 12px; + font-size: 11px; + color: var(--texte-doux); + white-space: nowrap; + pointer-events: none; + z-index: 3; +} + +/* --- projet plié --------------------------------------------------------- */ + +/* Barre unique segmentée par phase. Lecture seule : voir docs/decisions.md, + section 8. */ +.barre-pliee { + position: absolute; + top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2); + height: var(--hauteur-barre); + border-radius: 4px; + overflow: hidden; + display: flex; + box-shadow: var(--ombre); + background: color-mix(in srgb, var(--couleur-projet) 15%, var(--surface)); + cursor: default; + z-index: 2; +} + +.segment { + position: absolute; + top: 0; + height: 100%; + background: var(--couleur-projet); +} + +.segment--todo { + opacity: 0.3; +} + +.segment--doing { + opacity: 1; +} + +.segment--done { + opacity: 0.6; +} + +.segment--blocked { + background: var(--danger); + opacity: 0.85; +} + +/* --- barre fantôme de référence ------------------------------------------ */ + +.fantome { + position: absolute; + top: calc( + (var(--hauteur-ligne) - var(--hauteur-barre)) / 2 + var(--hauteur-barre) - 3px + ); + height: 5px; + border-radius: 3px; + background: repeating-linear-gradient( + 90deg, + var(--bordure-forte), + var(--bordure-forte) 4px, + transparent 4px, + transparent 7px + ); + pointer-events: none; + z-index: 1; +} + +/* --- panneau de détail --------------------------------------------------- */ + +.panneau { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 340px; + max-width: 90vw; + background: var(--surface); + border-left: 1px solid var(--bordure); + box-shadow: -4px 0 16px rgb(15 23 42 / 0.12); + z-index: 20; + display: flex; + flex-direction: column; +} + +.panneau__entete { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--bordure); +} + +.panneau__entete h2 { + margin: 0; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panneau__entete button { + border: 0; + font-size: 20px; + line-height: 1; + padding: 0 6px; + background: none; +} + +.panneau__corps { + padding: 16px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 14px; +} + +.panneau__corps label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: var(--texte-doux); +} + +.panneau__corps input[type="text"], +.panneau__corps input[type="date"], +.panneau__corps select, +.panneau__corps textarea { + font: inherit; + padding: 6px 8px; + border: 1px solid var(--bordure-forte); + border-radius: var(--rayon); + background: var(--fond); + color: var(--texte); + width: 100%; +} + +.panneau__corps textarea { + resize: vertical; +} + +.champ-double { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.case { + flex-direction: row !important; + align-items: center; + gap: 8px !important; + font-weight: 400 !important; + color: var(--texte) !important; +} + +.case input { + width: auto; +} + +.panneau__erreur { + margin: 0; + padding: 8px 10px; + border-radius: var(--rayon); + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger); + font-size: 12px; +} + +.panneau__actions { + margin-top: 4px; +} + +.voile { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 0.15); + z-index: 15; +} + +/* --- menu contextuel ------------------------------------------------------ */ + +.menu { + position: fixed; + z-index: 30; + min-width: 210px; + padding: 4px; + background: var(--surface); + border: 1px solid var(--bordure-forte); + border-radius: var(--rayon); + box-shadow: 0 8px 24px rgb(15 23 42 / 0.18); +} + +.menu__entree { + display: block; + width: 100%; + text-align: left; + padding: 6px 10px; + border: 0; + border-radius: 4px; + background: none; + white-space: nowrap; +} + +.menu__entree:hover:not(:disabled), +.menu__entree:focus-visible { + background: color-mix(in srgb, var(--accent) 14%, transparent); + border-color: transparent; + outline: none; +} + +.menu__entree:disabled { + color: var(--texte-doux); + opacity: 0.5; + cursor: default; +} + +.menu__entree--danger { + color: var(--danger); +} + +.menu__entree--danger:hover:not(:disabled), +.menu__entree--danger:focus-visible { + background: color-mix(in srgb, var(--danger) 14%, transparent); +} + +.menu__separateur { + margin: 4px 6px; + border: 0; + border-top: 1px solid var(--bordure); +} + +.menu-projet { + border: 0; + background: none; + padding: 2px 5px; + cursor: pointer; + color: var(--texte-doux); + font-size: 14px; + line-height: 1; + border-radius: 4px; +} + +.menu-projet:hover { + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--texte); +} + +/* --- dialogues ------------------------------------------------------------ */ + +.dialogue { + padding: 0; + border: 1px solid var(--bordure-forte); + border-radius: 10px; + background: var(--surface); + color: var(--texte); + max-width: 400px; + box-shadow: 0 12px 40px rgb(15 23 42 / 0.28); +} + +.dialogue::backdrop { + background: rgb(15 23 42 / 0.45); +} + +.dialogue form { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px 20px 16px; +} + +.dialogue h2 { + margin: 0; + font-size: 15px; +} + +.dialogue p { + margin: 0; + color: var(--texte-doux); + line-height: 1.5; +} + +.dialogue label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: var(--texte-doux); +} + +.dialogue input[type="text"] { + font: inherit; + padding: 7px 9px; + border: 1px solid var(--bordure-forte); + border-radius: var(--rayon); + background: var(--fond); + color: var(--texte); +} + +.couleurs { + margin: 0; + padding: 0; + border: 0; +} + +.couleurs legend { + padding: 0 0 6px; + font-size: 12px; + font-weight: 500; + color: var(--texte-doux); +} + +.couleurs div { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.pastille { + width: 24px; + height: 24px; + padding: 0; + border: 2px solid transparent; + border-radius: 50%; + cursor: pointer; +} + +.pastille[aria-pressed="true"] { + border-color: var(--texte); + box-shadow: inset 0 0 0 2px var(--surface); +} + +.dialogue__erreur { + color: var(--danger) !important; + font-size: 12px; +} + +.dialogue__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin: 0; + padding: 0; +} + +/* --- états divers -------------------------------------------------------- */ + +.vide { + margin: auto; + text-align: center; + color: var(--texte-doux); + line-height: 2.4; +} + +.alerte { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--fond); + z-index: 50; + padding: 24px; +} + +.alerte p { + max-width: 560px; + padding: 16px 20px; + border-radius: var(--rayon); + border: 1px solid var(--danger); + background: color-mix(in srgb, var(--danger) 8%, var(--surface)); + color: var(--texte); + white-space: pre-wrap; +} + +/* Pendant un glisser, on neutralise la sélection de texte sur toute la page : + sinon le navigateur surligne les libellés au passage du curseur. */ +body.glisse-en-cours { + user-select: none; + cursor: grabbing; +} diff --git a/data/exemple.json b/data/exemple.json new file mode 100644 index 0000000..f373294 --- /dev/null +++ b/data/exemple.json @@ -0,0 +1,155 @@ +{ + "version": 2, + "projects": [ + { + "id": "site-web", + "name": "Refonte du site web", + "color": "#3b82f6", + "collapsed": false, + "hidden": false, + "baselineDate": "2026-06-12", + "phases": [ + { + "id": "cadrage", + "name": "Cadrage", + "start": "2026-06-15", + "end": "2026-07-10", + "status": "done", + "milestone": false, + "notes": "Ateliers avec les trois pôles. Périmètre arrêté le 8 juillet.", + "baseline": { "start": "2026-06-15", "end": "2026-07-03" } + }, + { + "id": "maquettes", + "name": "Maquettes", + "start": "2026-07-13", + "end": "2026-08-28", + "status": "doing", + "milestone": false, + "notes": "Deux itérations prévues. La seconde dépend du retour de la direction.", + "baseline": { "start": "2026-07-06", "end": "2026-08-14" } + }, + { + "id": "developpement", + "name": "Développement", + "start": "2026-08-31", + "end": "2026-11-13", + "status": "todo", + "milestone": false, + "notes": "", + "baseline": { "start": "2026-08-17", "end": "2026-10-30" } + }, + { + "id": "recette", + "name": "Recette", + "start": "2026-11-16", + "end": "2026-12-11", + "status": "todo", + "milestone": false, + "notes": "", + "baseline": { "start": "2026-11-02", "end": "2026-11-27" } + }, + { + "id": "mise-en-ligne", + "name": "Mise en ligne", + "start": "2026-12-15", + "end": "2026-12-15", + "status": "todo", + "milestone": true, + "notes": "Date communiquée aux partenaires.", + "baseline": { "start": "2026-12-01", "end": "2026-12-01" } + } + ] + }, + { + "id": "migration-infra", + "name": "Migration infrastructure", + "color": "#10b981", + "collapsed": false, + "hidden": false, + "phases": [ + { + "id": "audit", + "name": "Audit de l'existant", + "start": "2026-05-04", + "end": "2026-06-12", + "status": "done", + "milestone": false, + "notes": "Inventaire complet des services et de leurs dépendances." + }, + { + "id": "preparation", + "name": "Préparation des environnements", + "start": "2026-06-15", + "end": "2026-08-07", + "status": "done", + "milestone": false, + "notes": "" + }, + { + "id": "bascule-pilote", + "name": "Bascule pilote", + "start": "2026-08-10", + "end": "2026-09-11", + "status": "blocked", + "milestone": false, + "notes": "En attente de la fenêtre de maintenance côté hébergeur." + }, + { + "id": "bascule-generale", + "name": "Bascule générale", + "start": "2026-09-14", + "end": "2026-10-23", + "status": "todo", + "milestone": false, + "notes": "" + }, + { + "id": "decommissionnement", + "name": "Décommissionnement", + "start": "2026-11-02", + "end": "2026-11-02", + "status": "todo", + "milestone": true, + "notes": "Arrêt définitif des anciens serveurs." + } + ] + }, + { + "id": "formation-equipe", + "name": "Formation de l'équipe", + "color": "#f59e0b", + "collapsed": true, + "hidden": false, + "phases": [ + { + "id": "recensement", + "name": "Recensement des besoins", + "start": "2026-09-01", + "end": "2026-09-25", + "status": "todo", + "milestone": false, + "notes": "" + }, + { + "id": "sessions", + "name": "Sessions de formation", + "start": "2026-10-05", + "end": "2026-12-18", + "status": "todo", + "milestone": false, + "notes": "Trois sessions de deux jours, étalées sur le trimestre." + }, + { + "id": "bilan", + "name": "Bilan", + "start": "2027-01-15", + "end": "2027-01-15", + "status": "todo", + "milestone": true, + "notes": "" + } + ] + } + ] +} diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..309e4bb --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,220 @@ +# Décisions de conception + +Ce document garde la trace des arbitrages et surtout de leurs raisons, pour éviter de refaire les +mêmes débats dans six mois. + +## 1. Pourquoi un outil sur-mesure + +Les suites complètes (Redmine, Taiga, OpenProject) exigent un serveur applicatif et une base de +données à administrer — hors de proportion avec le besoin. Les mini-outils « un seul fichier HTML » +sont des Kanban sans dates. + +[Markwhen](https://markwhen.com) est le concurrent sérieux : timeline en couloirs engendrée depuis du +texte markdown-ish, open source, self-hostable, avec extension VS Code et vue Gantt. Il couvre une +bonne partie du besoin. + +Deux choses manquent, et ce sont elles qui justifient ce projet : + +- la **manipulation directe** des barres (glisser, redimensionner) ; +- le **suivi de dérive** face à un planning de référence figé. + +Si ces deux besoins disparaissaient, il faudrait sérieusement envisager d'abandonner cet outil au +profit de Markwhen. + +## 2. Pas de dépendances entre phases + +Une première version du cahier des charges prévoyait des dépendances fin→début avec propagation en +cascade. Abandonné. + +Le coût est très supérieur à ce qu'il paraît : graphe orienté, détection de cycles, et surtout une +sémantique de propagation à trancher — la poussée est-elle une contrainte dure ou souple, la marge +existante est-elle conservée, avancer un prédécesseur tire-t-il ses successeurs en arrière, que +faire d'une date imposée de l'extérieur. Chacune de ces questions a plusieurs réponses défendables, +et se tromper produit un outil qui déplace des barres dans le dos de l'utilisateur. + +En regard, le bénéfice réel en planification macro est faible. Le besoin concret — « ce projet glisse +d'un mois » — se traite en décalant les phases concernées à la main, ce qui prend quelques secondes +sur un projet de cinq ou six phases. + +**Conséquence heureuse** : sans flèches à tracer entre les barres, le SVG perd sa raison d'être (voir +décision 4). + +## 3. Micro-serveur Python plutôt que File System Access API + +La première approche envisagée était d'écrire directement dans le fichier depuis le navigateur, via +`showOpenFilePicker` / `createWritable`, pour se passer de tout back-end. + +Sauf que cette API exige un **contexte sécurisé** : elle ne fonctionne pas depuis `file://`. Il +fallait donc de toute façon lancer un serveur statique. Le « zéro back-end » était perdu d'avance, +tout en payant trois prix : limitation aux navigateurs Chromium, re-autorisation du sélecteur de +fichier à chaque session, et maintien d'un chemin de repli export/import pour Firefox et Safari, +doublé d'un miroir `localStorage`. + +Puisqu'une commande doit être lancée dans tous les cas, autant qu'elle serve à quelque chose. Un +serveur de la bibliothèque standard, `GET` et `PUT` sur `/api/data`, tient en une centaine de lignes +— soit **moins** de code que l'API navigateur plus son repli plus son miroir. Et il fonctionne +partout, sans sélecteur à réautoriser. + +Bénéfice supplémentaire : le serveur peut écrire une sauvegarde horodatée à chaque écriture, ce que +la File System Access API ne permettait pas simplement. + +## 4. DOM et CSS plutôt que SVG + +Le SVG s'imposait tant qu'il fallait tracer des flèches courbes entre des barres. Les dépendances +supprimées, il ne reste que des rectangles, du texte et des losanges. + +Des `div` positionnées en absolu font tout cela plus simplement : troncature du texte par +`text-overflow`, curseurs de redimensionnement, styles de survol, transitions, tout est natif en CSS. +En SVG il faudrait mesurer le texte à la main et réimplémenter des comportements que le navigateur +offre gratuitement. Les losanges de jalon se font par une rotation CSS de 45°. + +## 5. Identifiants parlants plutôt qu'UUID + +Le fichier de données est censé rester ouvrable dans un éditeur de texte et versionné dans git. Des +UUID rendent les deux pénibles : impossible de savoir quelle phase on lit, et un diff illisible. + +`site-web` et `cadrage` coûtent la même chose à manipuler et gardent le fichier compréhensible. La +contrepartie — gérer les collisions à la création — est traitée par un suffixe numérique. + +Un identifiant ne change **jamais** quand le nom est modifié : il identifie, il ne décrit pas. +Renommer une phase ne doit pas invalider les références qui pointent vers elle. + +## 6. Statut plutôt que pourcentage d'avancement + +Un pourcentage d'avancement sur une phase macro de plusieurs semaines est presque toujours un chiffre +inventé, et il donne une fausse impression de précision. Quatre statuts — à venir, en cours, terminé, +bloqué — se saisissent en un clic et disent l'essentiel. `blocked` en particulier porte une +information qu'un pourcentage ne peut pas exprimer. + +## 7. Jours calendaires + +Gérer les jours ouvrés impose de faire passer chaque calcul de date par un helper, et soulève +aussitôt la question des congés et des jours fériés — donc un calendrier à tenir à jour. Sur des +phases qui se comptent en semaines et en mois, l'écart est dans le bruit de l'estimation. + +## 8. Le mode plié est en lecture seule + +Un couloir plié affiche une barre unique segmentée par phase. On aurait pu rendre cette barre +glissable pour décaler tout le projet en bloc. + +Écarté pour une raison de lisibilité du geste : la même barre représenterait tantôt une phase, tantôt +un projet entier, et un glissement de quelques pixels déplacerait alors plusieurs mois de travail +d'un coup, sans que l'utilisateur voie ce qui bouge. Le mode plié sert à regarder ; pour modifier, on +déplie. + +## 9. Accroche à la semaine + +Au zoom trimestre, un jour représente quelques pixels : viser une date précise à la souris devient +un exercice de patience. L'accroche au lundi rend le geste franc et correspond à la granularité +réelle d'une planification macro. + +Les dates exactes restent saisissables au clavier dans le panneau de détail, ce qui couvre les cas +où une phase doit démarrer un jeudi précis. + +## 10. Zéro build + +Modules ES natifs, chargés directement par le navigateur. Pas de `package.json`, pas de bundler, pas +de `node_modules`. Le fichier qu'on lit dans l'éditeur est exactement celui qu'exécute le navigateur, +ce qui rend le débogage direct et l'outil reprenable dans plusieurs années sans exhumer une chaîne de +compilation obsolète. + +Node n'est utilisé que pour lancer les tests, via son lanceur intégré (`node --test`), sans aucune +dépendance. + +## 11. Un en-tête à trois bandes, la semaine pour grain le plus fin + +L'en-tête affichait une seule rangée de graduations dont le contenu changeait selon le +zoom : des quantièmes en vue semaine, des noms de mois en vue trimestre. Il fallait +deviner l'unité affichée, et l'année n'apparaissait qu'au zoom le plus large. + +Trois bandes empilées — **année**, **mois**, **numéro de semaine ISO** — lèvent +l'ambiguïté : les trois repères sont toujours là, quel que soit le zoom. Chaque cellule +est un bloc couvrant exactement son intervalle, ce qui centre le libellé sur la période +qu'il désigne au lieu de le laisser flotter après un trait. + +Le jour a disparu : sur des phases qui se comptent en semaines, il n'apportait rien et +encombrait l'affichage. Le zoom ne change donc plus l'unité, seulement la largeur des +mêmes semaines. + +Les numéros suivent l'**ISO 8601** : la semaine appartient à l'année où tombe son jeudi. +C'est pourquoi `semaineISO()` renvoie l'année en même temps que le numéro — afficher +« S53 » sous un bandeau « 2027 » serait faux, alors que le 1er janvier 2027 est bien en +semaine 53 de 2026. + +## 12. Défilement natif sur les deux axes plutôt que synchronisé en JavaScript + +La colonne des libellés était un conteneur séparé, recalé sur le défilement vertical de +la frise par un `translateY` appliqué à chaque événement `scroll`. Deux défauts : un +décalage visible d'une image sur les défilements rapides, et deux systèmes de coordonnées +à tenir cohérents. + +Tout tient désormais dans un unique conteneur défilant, découpé en grille 2×2 : l'en-tête +est `sticky top`, la colonne des libellés `sticky left`, et le coin les deux à la fois. Le +navigateur gère seul le défilement des deux axes. Le code de synchronisation a disparu. + +## 13. Menu contextuel plutôt que `prompt()` et `confirm()` + +Les actions d'un projet passaient par un `prompt()` listant des numéros d'action à saisir +au clavier, et les suppressions par un `confirm()`. C'était rapide à écrire et pénible à +utiliser : rien n'indiquait qu'il fallait cliquer sur le nom du projet, et ces boîtes +natives bloquent le fil d'exécution. + +Un bouton `⋯` explicite ouvre maintenant un vrai menu, navigable au clavier, où les +actions indisponibles sont grisées plutôt qu'absentes — « Retirer la référence » reste +visible quand aucune référence n'est figée, ce qui apprend que la fonction existe. + +Les dialogues s'appuient sur l'élément `` natif, qui fournit le voile, le piège à +focus, la fermeture par Échap et la restauration du focus sans code supplémentaire. Sur +une action destructrice, le focus initial se pose sur « Annuler » : un appui réflexe sur +Entrée ne doit rien détruire. + +Une exception assumée : ajouter une phase ne demande pas son nom dans un dialogue. La +phase est créée immédiatement à la suite de la précédente, et le panneau de détail +s'ouvre avec le nom présélectionné — l'utilisateur tape simplement par-dessus. Un +dialogue de plus n'aurait rien apporté. + +## 14. Une seule échelle, pas de zoom + +Trois niveaux de zoom — semaine, mois, trimestre — étaient proposés. Retirés. + +Depuis que l'en-tête empile l'année, le mois et le numéro de semaine (décision 11), les +trois repères utiles sont lisibles en permanence. Le zoom ne résolvait plus qu'un problème +qu'il avait lui-même créé : savoir quelle unité on regardait. Il restait un réglage de +plus à comprendre, un état de plus à gérer, et un chemin de code de plus à tester. + +L'échelle unique est de **6 pixels par jour**, soit 42 pixels par semaine : assez pour que +« S32 » tienne dans sa cellule, assez serré pour qu'une année tienne dans une fenêtre de +portable. + +## 15. Une frise qui s'élargit au défilement + +La fenêtre affichée se déduisait des bornes des phases existantes, avec une marge fixe. +Conséquence gênante : impossible d'aller regarder un futur vide pour y planifier. La frise +s'arrêtait là où s'arrêtaient les données. + +La fenêtre est désormais un **état de vue** distinct des données. Elle démarre autour des +phases existantes, puis s'élargit de six mois dès que le défilement passe à moins de +400 pixels d'un bord. On peut ainsi partir vers 2031, y poser une phase, et revenir. + +Deux subtilités : + +- **Élargir vers la gauche déplace l'origine de l'échelle**, donc tout le contenu se + décale vers la droite. On compense `scrollLeft` de la même quantité, sans quoi la vue + sauterait en arrière à chaque élargissement. Un drapeau empêche l'ajustement de + relancer le gestionnaire de défilement. +- **La fenêtre ne rétrécit jamais**, même si les phases se resserrent : le terrain déjà + exploré doit rester atteignable. + +L'élargissement s'arrête à **dix ans de part et d'autre des données**. Ce n'est donc pas +infini au sens strict — c'est un garde-fou : rien n'est virtualisé, et un défilement +prolongé ferait autrement enfler le DOM sans limite. À l'échelle d'une planification +macro, dix ans dépassent largement l'horizon utile. + +Corollaire : le recentrage est instantané, jamais animé. Sur une frise qui peut atteindre +plusieurs dizaines de milliers de pixels, une animation serait lente, et le gestionnaire +de défilement l'interromprait en élargissant la fenêtre au passage près d'un bord. + +Attention en modifiant `centrerSur` : `scrollLeft` porte sur la grille entière, dont la +première colonne est celle des libellés. Comme elle est `sticky`, elle masque en +permanence les premiers pixels de la vue, et le centre utile des couloirs est décalé +d'une demi-largeur de colonne par rapport au centre de la frise. diff --git a/docs/modele-donnees.md b/docs/modele-donnees.md new file mode 100644 index 0000000..14a096f --- /dev/null +++ b/docs/modele-donnees.md @@ -0,0 +1,123 @@ +# Modèle de données + +Tout le planning tient dans un seul fichier JSON, par défaut `data/projets.json`. + +Le fichier est écrit indenté sur 2 espaces, avec les clés dans un ordre stable, pour rester lisible +dans un diff git et éditable à la main. + +## Structure + +```json +{ + "version": 2, + "projects": [ + { + "id": "site-web", + "name": "Refonte du site web", + "color": "#3b82f6", + "collapsed": false, + "hidden": false, + "baselineDate": "2026-06-12", + "phases": [ + { + "id": "cadrage", + "name": "Cadrage", + "start": "2026-08-01", + "end": "2026-08-20", + "status": "done", + "milestone": false, + "notes": "Ateliers avec les trois pôles.", + "baseline": { "start": "2026-07-25", "end": "2026-08-10" } + }, + { + "id": "livraison", + "name": "Mise en ligne", + "start": "2026-11-02", + "end": "2026-11-02", + "status": "todo", + "milestone": true, + "notes": "" + } + ] + } + ] +} +``` + +## Racine + +| Champ | Type | Description | +|---|---|---| +| `version` | entier | Version du format. Vaut `2`. Sert à détecter un fichier trop ancien au chargement. | +| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. | + +## Projet + +| Champ | Type | Description | +|---|---|---| +| `id` | chaîne | Identifiant parlant, dérivé du nom (`Refonte du site web` → `site-web`). Unique dans le fichier. | +| `name` | chaîne | Nom affiché. Non vide. | +| `color` | chaîne | Couleur du couloir, en hexadécimal `#rrggbb`. | +| `collapsed` | booléen | `true` si le couloir est plié. | +| `hidden` | booléen | `true` si le projet est masqué de la frise. Ses données restent intactes. | +| `baselineDate` | chaîne ou absent | Date à laquelle la référence a été figée. Absent si elle ne l'a jamais été. | +| `phases` | tableau | Les phases, triées par date de début croissante. | + +## Phase + +| Champ | Type | Description | +|---|---|---| +| `id` | chaîne | Identifiant parlant, unique **au sein de son projet**. | +| `name` | chaîne | Nom affiché. Non vide. | +| `start` | chaîne | Date de début, `AAAA-MM-JJ`. | +| `end` | chaîne | Date de fin **incluse**, `AAAA-MM-JJ`. | +| `status` | chaîne | `todo`, `doing`, `done` ou `blocked`. | +| `milestone` | booléen | `true` pour un jalon. | +| `notes` | chaîne | Texte libre, éventuellement vide. | +| `baseline` | objet ou absent | Dates de référence : `{ "start": …, "end": … }`. Absent tant que la référence n'a pas été figée. | + +## Règles de validation + +Appliquées par `js/model.js` et couvertes par `tests/model.test.js`. + +- `end` est postérieure ou égale à `start`. +- Un jalon (`milestone: true`) a nécessairement `start === end`. Cocher « jalon » sur une phase de + plusieurs jours ramène `end` sur `start`. +- Les dates suivent strictement `AAAA-MM-JJ` et doivent exister réellement dans le calendrier + (`2026-02-30` est rejetée). +- `id` de projet unique dans le fichier ; `id` de phase unique dans son projet. +- Un `id` est engendré à partir du nom : minuscules, accents retirés, tout ce qui n'est ni lettre ni + chiffre remplacé par un tiret. En cas de collision, un suffixe numérique est ajouté (`cadrage-2`). + Un `id` ne change jamais si le nom est modifié ensuite — il identifie, il ne décrit pas. +- `status` fait partie des quatre valeurs autorisées ; toute autre valeur est ramenée à `todo`. +- `color` est un hexadécimal `#rrggbb` valide. + +Un fichier invalide n'est jamais réparé en silence : le chargement échoue avec un message qui pointe +le projet et la phase fautifs, pour que le fichier puisse être corrigé à la main. + +## Dates : conventions + +Les dates sont manipulées comme des **chaînes `AAAA-MM-JJ`**, pas comme des objets `Date`. Cela évite +tous les pièges de fuseau horaire : `new Date("2026-08-01")` est interprétée en UTC alors que +`new Date(2026, 7, 1)` l'est en heure locale, ce qui décale d'un jour selon le fuseau. Les +conversions n'ont lieu que dans les helpers de calcul de `model.js`. + +`end` est **incluse** : une phase du `2026-08-01` au `2026-08-01` dure un jour. + +Tout se compte en **jours calendaires**, week-ends et jours fériés compris. + +Les numéros de semaine affichés dans l'en-tête suivent l'**ISO 8601** : la semaine +appartient à l'année où tombe son jeudi. `semaineISO()` renvoie donc le numéro *et* +l'année correspondante, qui peut différer de celle de la date. + +## Bornes et dérive + +- Les **bornes d'un projet** vont de la plus petite `start` à la plus grande `end` de ses phases. Un + projet sans phase n'a pas de bornes et s'affiche comme un couloir vide. +- **Figer la référence** copie, pour chaque phase du projet, ses `start` et `end` courantes dans son + champ `baseline`, et inscrit la date du jour dans `baselineDate`. L'opération est rejouable : la + refiger après un arbitrage assumé repart d'une base propre. +- La **dérive** d'un projet est l'écart, en jours, entre la plus grande `end` actuelle et la plus + grande `end` de référence. Elle est affichée arrondie en semaines. Une phase créée après le figeage + n'a pas de `baseline` et ne compte pas dans la référence, mais compte dans les dates actuelles : + ajouter une phase en fin de projet crée donc bien une dérive, ce qui est le comportement voulu. diff --git a/index.html b/index.html new file mode 100644 index 0000000..89f5779 --- /dev/null +++ b/index.html @@ -0,0 +1,158 @@ + + + + + + Frise multi-projets + + + + +
+

Frise multi-projets

+ +
+ + +
+ +

+
+ + +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+

Nouveau projet

+ + + +
+ Couleur du couloir +
+
+ + + + + + + + +
+
+ + + +
+

Confirmer

+

+ + + + +
+
+ + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..0babe30 --- /dev/null +++ b/js/app.js @@ -0,0 +1,596 @@ +/** + * Amorçage et câblage. + * + * L'état vit ici, dans une seule variable `planning`. Toute modification passe + * par `majPlanning`, qui rend la frise et déclenche la sauvegarde : il n'y a + * qu'un seul chemin pour changer quelque chose, ce qui rend le flux facile à + * suivre. + */ + +import { creerPanneau } from './detail.js'; +import { creerDialogueConfirmation, creerDialogueProjet, creerMenu } from './menu.js'; +import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js'; +import { PIXELS_PAR_JOUR, formaterDateLongue, rendre } from './timeline.js'; +import { creerStockage, viderAvantFermeture } from './storage.js'; +import { + COULEURS, + ajouterJours, + ajouterPhase, + aujourdhui, + bornesPlanning, + creerPhase, + creerProjet, + figerReference, + libererReference, + planningVide, + pourEcriture, + remplacerPhase, + remplacerProjet, + supprimerPhase, + supprimerProjet, + validerPlanning, + versUTC, +} from './model.js'; + +const $ = (id) => document.getElementById(id); + +const refs = { + libelles: $('libelles'), + entete: $('entete'), + couloirs: $('couloirs'), + frise: $('frise'), + etat: $('etat-sauvegarde'), + messageVide: $('message-vide'), + alerte: $('alerte'), + alerteMessage: $('alerte-message'), +}; + +let planning = planningVide(); +let echelle = null; +let selection = null; // { projet, phase } + +/** + * Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré. + * + * Elle démarre autour des phases existantes puis s'élargit toute seule quand le + * défilement approche d'un bord, ce qui permet d'aller poser une phase dans un + * futur qu'aucune donnée n'occupe encore. + */ +let fenetre = null; + +/** Marge conservée autour des phases : on peut toujours glisser au-delà. */ +const MARGE_JOURS = 120; + +/** Longueur ajoutée à chaque élargissement, environ six mois. */ +const PAS_ELARGISSEMENT = 182; + +/** Distance au bord, en pixels, à partir de laquelle on élargit. */ +const SEUIL_ELARGISSEMENT = 400; + +/** + * Garde-fou : l'élargissement s'arrête à dix ans de part et d'autre des + * données. « Infini » au sens de l'usage — on ne planifie pas au-delà — sans + * laisser un défilement prolongé faire enfler le DOM sans limite. + */ +const PORTEE_MAX_ANNEES = 10; + +const stockage = creerStockage(afficherEtat); +const menu = creerMenu($('menu')); + +const demanderProjet = creerDialogueProjet( + { + dialogue: $('dialogue-projet'), + titre: $('dialogue-projet-titre'), + champNom: $('dialogue-projet-nom'), + conteneurCouleurs: $('dialogue-projet-couleurs'), + erreur: $('dialogue-projet-erreur'), + valider: $('dialogue-projet-ok'), + }, + COULEURS +); + +const demanderConfirmation = creerDialogueConfirmation({ + dialogue: $('dialogue-confirmation'), + titre: $('dialogue-confirmation-titre'), + message: $('dialogue-confirmation-message'), + valider: $('dialogue-confirmation-ok'), +}); + +const panneau = creerPanneau( + { + panneau: $('panneau'), + voile: $('voile'), + formulaire: $('formulaire-phase'), + titre: $('panneau-titre'), + erreur: $('erreur-phase'), + champFin: $('champ-fin'), + supprimer: $('supprimer-phase'), + fermer: $('fermer-panneau'), + }, + { + surModification(idProjet, idPhase, phase) { + majPlanning( + remplacerProjet(planning, idProjet, (projet) => + remplacerPhase(projet, idPhase, () => phase) + ) + ); + }, + demanderSuppression(_idProjet, _idPhase, nomPhase) { + return demanderConfirmation({ + titreTexte: 'Supprimer la phase', + messageTexte: `« ${nomPhase} » sera définitivement supprimée du planning.`, + libelleValider: 'Supprimer', + danger: true, + }); + }, + surSuppression(idProjet, idPhase) { + majPlanning( + remplacerProjet(planning, idProjet, (projet) => supprimerPhase(projet, idPhase)) + ); + }, + surFermeture() { + selection = null; + dessiner(); + }, + } +); + +// --------------------------------------------------------------------------- +// État +// --------------------------------------------------------------------------- + +/** Unique point d'entrée des modifications : rend puis sauvegarde. */ +function majPlanning(suivant) { + planning = suivant; + dessiner(); + stockage.sauvegarder(pourEcriture(planning)); +} + +function dessiner() { + const aDesProjets = planning.projects.length > 0; + refs.frise.hidden = !aDesProjets; + refs.messageVide.hidden = aDesProjets; + + if (!aDesProjets) { + echelle = null; + return; + } + + elargirPourContenir(); + echelle = rendre(refs, planning, { fenetre, selection }); + + // Le panneau reflète la phase telle qu'elle est après le rendu : un glisser + // sur la frise doit se voir dans les champs. + if (selection) { + const trouve = localiser(selection.projet, selection.phase); + if (trouve) panneau.rafraichir(trouve.projet, trouve.phase); + } +} + +function localiser(idProjet, idPhase) { + const projet = planning.projects.find((p) => p.id === idProjet); + if (!projet) return null; + const phase = projet.phases.find((p) => p.id === idPhase); + return phase ? { projet, phase } : null; +} + +function afficherEtat(etat, detail) { + const messages = { + inactif: '', + 'en-cours': 'Enregistrement…', + enregistre: 'Enregistré', + erreur: detail || 'Erreur d’enregistrement', + }; + refs.etat.textContent = messages[etat] ?? ''; + refs.etat.dataset.etat = etat; + refs.etat.title = etat === 'erreur' ? detail || '' : ''; +} + +function afficherAlerte(message) { + refs.alerteMessage.textContent = message; + refs.alerte.hidden = false; + // L'alerte recouvre déjà la page, mais laisser une frise vide « visible » + // derrière elle brouillerait le diagnostic si le style venait à changer. + refs.frise.hidden = true; + refs.messageVide.hidden = true; +} + +// --------------------------------------------------------------------------- +// Fenêtre temporelle et défilement sans fin +// --------------------------------------------------------------------------- + +/** Fenêtre de départ : les phases existantes, avec une marge de chaque côté. */ +function fenetreInitiale() { + const bornes = bornesPlanning(planning); + if (!bornes) { + const jour = aujourdhui(); + return { debut: ajouterJours(jour, -MARGE_JOURS), fin: ajouterJours(jour, MARGE_JOURS * 2) }; + } + return { + debut: ajouterJours(bornes.start, -MARGE_JOURS), + fin: ajouterJours(bornes.end, MARGE_JOURS), + }; +} + +/** + * Élargit la fenêtre si une phase est sortie de son cadre, sans jamais la + * rétrécir : le terrain déjà exploré au défilement doit rester accessible. + */ +function elargirPourContenir() { + if (!fenetre) fenetre = fenetreInitiale(); + + const bornes = bornesPlanning(planning); + if (!bornes) return; + + const voulu = { + debut: ajouterJours(bornes.start, -MARGE_JOURS), + fin: ajouterJours(bornes.end, MARGE_JOURS), + }; + if (versUTC(voulu.debut) < versUTC(fenetre.debut)) fenetre.debut = voulu.debut; + if (versUTC(voulu.fin) > versUTC(fenetre.fin)) fenetre.fin = voulu.fin; +} + +/** Bornes au-delà desquelles on cesse d'élargir. */ +function porteeMaximale() { + const bornes = bornesPlanning(planning) ?? { start: aujourdhui(), end: aujourdhui() }; + const jours = 365 * PORTEE_MAX_ANNEES; + return { + min: ajouterJours(bornes.start, -jours), + max: ajouterJours(bornes.end, jours), + }; +} + +// Vrai pendant qu'on élargit, pour que l'ajustement de `scrollLeft` ne relance +// pas immédiatement ce même gestionnaire. +let elargissementEnCours = false; + +/** + * Élargit la fenêtre quand le défilement approche d'un bord, ce qui donne + * l'impression d'une frise sans fin. + * + * Élargir vers la gauche déplace l'origine de l'échelle : tout le contenu se + * décale vers la droite. On compense `scrollLeft` de la même quantité, sinon la + * vue sauterait en arrière à chaque élargissement. + */ +refs.frise.addEventListener('scroll', () => { + if (elargissementEnCours || !echelle || refs.frise.hidden) return; + + const zone = refs.frise; + const portee = porteeMaximale(); + const resteADroite = zone.scrollWidth - zone.scrollLeft - zone.clientWidth; + + if (zone.scrollLeft < SEUIL_ELARGISSEMENT && versUTC(fenetre.debut) > versUTC(portee.min)) { + elargissementEnCours = true; + fenetre.debut = ajouterJours(fenetre.debut, -PAS_ELARGISSEMENT); + dessiner(); + zone.scrollLeft += PAS_ELARGISSEMENT * PIXELS_PAR_JOUR; + elargissementEnCours = false; + return; + } + + if (resteADroite < SEUIL_ELARGISSEMENT && versUTC(fenetre.fin) < versUTC(portee.max)) { + elargissementEnCours = true; + fenetre.fin = ajouterJours(fenetre.fin, PAS_ELARGISSEMENT); + dessiner(); + elargissementEnCours = false; + } +}); + +// --------------------------------------------------------------------------- +// Interactions de la frise +// --------------------------------------------------------------------------- + +installerGlisser(refs.couloirs, { + echelle: () => echelle, + trouverPhase(idProjet, idPhase) { + return localiser(idProjet, idPhase)?.phase ?? null; + }, + surDeplacement(idProjet, idPhase, phase) { + // `phase` vaut null quand le geste a été annulé ou n'a rien changé : on + // redessine pour remettre la barre à sa place, sans rien sauvegarder. + if (!phase) { + dessiner(); + return; + } + majPlanning( + remplacerProjet(planning, idProjet, (projet) => + remplacerPhase(projet, idPhase, () => phase) + ) + ); + }, + surClic(idProjet, idPhase) { + ouvrirPhase(idProjet, idPhase); + }, +}); + +function ouvrirPhase(idProjet, idPhase) { + const trouve = localiser(idProjet, idPhase); + if (!trouve) return; + selection = { projet: idProjet, phase: idPhase }; + panneau.ouvrir(trouve.projet, trouve.phase); + dessiner(); +} + +// Clics sur la colonne des libellés : plier, masquer, ouvrir le menu ou la phase. +refs.libelles.addEventListener('click', (evenement) => { + const cible = evenement.target.closest('[data-action]'); + + if (!cible) { + const ligne = evenement.target.closest('.libelle-phase'); + if (ligne) ouvrirPhase(ligne.dataset.projet, ligne.dataset.phase); + return; + } + + const idProjet = cible.dataset.projet; + + if (cible.dataset.action === 'plier') { + majPlanning( + remplacerProjet(planning, idProjet, (projet) => ({ + ...projet, + collapsed: !projet.collapsed, + })) + ); + } else if (cible.dataset.action === 'masquer') { + majPlanning( + remplacerProjet(planning, idProjet, (projet) => ({ ...projet, hidden: !projet.hidden })) + ); + } else if (cible.dataset.action === 'menu') { + ouvrirMenuProjet(idProjet, cible); + } +}); + +// Clic droit sur un libellé de projet : même menu, ancré sur le nom. +refs.libelles.addEventListener('contextmenu', (evenement) => { + const ligne = evenement.target.closest('.libelle-projet'); + if (!ligne) return; + evenement.preventDefault(); + const bouton = ligne.querySelector('[data-action="menu"]'); + ouvrirMenuProjet(bouton.dataset.projet, bouton); +}); + +// --------------------------------------------------------------------------- +// Menu d'un projet +// --------------------------------------------------------------------------- + +function ouvrirMenuProjet(idProjet, ancre) { + const projet = planning.projects.find((p) => p.id === idProjet); + if (!projet) return; + + const aUneReference = projet.phases.some((phase) => phase.baseline); + + menu.ouvrir(ancre, [ + { libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet, false) }, + { libelle: 'Ajouter un jalon', action: () => ajouterUnePhase(projet, true) }, + { + libelle: projet.collapsed ? 'Déplier le projet' : 'Plier le projet', + separateurAvant: true, + action: () => + majPlanning( + remplacerProjet(planning, idProjet, (p) => ({ ...p, collapsed: !p.collapsed })) + ), + }, + { + libelle: projet.hidden ? 'Afficher sur la frise' : 'Masquer de la frise', + action: () => + majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))), + }, + { libelle: 'Renommer, changer la couleur…', action: () => renommerProjet(projet) }, + { + libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence', + separateurAvant: true, + desactive: projet.phases.length === 0, + action: () => figerLaReference(projet, aUneReference), + }, + { + libelle: 'Retirer la référence', + desactive: !aUneReference, + action: () => retirerLaReference(projet), + }, + { + libelle: 'Supprimer le projet', + separateurAvant: true, + danger: true, + action: () => supprimerLeProjet(projet), + }, + ]); +} + +function ajouterUnePhase(projet, jalon) { + // On enchaîne à la suite de la dernière phase, ce qui est presque toujours ce + // qu'on veut. Plutôt que de demander le nom dans un dialogue, on crée la + // phase tout de suite et on ouvre le panneau de détail avec le nom + // présélectionné : l'utilisateur tape directement par-dessus. + const derniere = projet.phases[projet.phases.length - 1]; + const debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui(); + const fin = jalon ? debut : ajouterJours(debut, 20); + + const phase = creerPhase(projet, jalon ? 'Nouveau jalon' : 'Nouvelle phase', debut, fin, { + milestone: jalon, + }); + + majPlanning( + remplacerProjet(planning, projet.id, (p) => ({ + ...ajouterPhase(p, phase), + collapsed: false, // inutile d'ajouter une phase dans un couloir replié + })) + ); + ouvrirPhase(projet.id, phase.id); + + // La frise défile librement bien au-delà des phases existantes : sans ce + // recentrage, une phase créée alors qu'on regardait 2029 apparaîtrait hors + // écran, et l'ajout semblerait n'avoir rien fait. + centrerSur(phase.start); +} + +async function renommerProjet(projet) { + const reponse = await demanderProjet({ + titreTexte: 'Modifier le projet', + libelleValider: 'Enregistrer', + nom: projet.name, + couleur: projet.color, + }); + if (!reponse) return; + + // L'identifiant ne change pas : il identifie, il ne décrit pas. + majPlanning( + remplacerProjet(planning, projet.id, (p) => ({ + ...p, + name: reponse.nom, + color: reponse.couleur, + })) + ); +} + +async function figerLaReference(projet, dejaFigee) { + const ok = await demanderConfirmation({ + titreTexte: dejaFigee ? 'Refiger la référence' : 'Figer la référence', + messageTexte: dejaFigee + ? `Le planning actuel de « ${projet.name} » deviendra la nouvelle référence. ` + + 'La dérive affichée repartira de zéro.' + : `Le planning actuel de « ${projet.name} » sera mémorisé comme référence. ` + + 'La frise affichera ensuite son écart avec cette référence.', + libelleValider: dejaFigee ? 'Refiger' : 'Figer', + }); + if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => figerReference(p))); +} + +async function retirerLaReference(projet) { + const ok = await demanderConfirmation({ + titreTexte: 'Retirer la référence', + messageTexte: + `Le planning de référence de « ${projet.name} » sera oublié, ` + + 'et la frise cessera d’afficher sa dérive.', + libelleValider: 'Retirer', + danger: true, + }); + if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => libererReference(p))); +} + +async function supprimerLeProjet(projet) { + const nombre = projet.phases.length; + const ok = await demanderConfirmation({ + titreTexte: 'Supprimer le projet', + messageTexte: + `« ${projet.name} »` + + (nombre ? ` et ses ${nombre} phase${nombre > 1 ? 's' : ''}` : '') + + ' seront définitivement supprimés du planning.', + libelleValider: 'Supprimer', + danger: true, + }); + if (!ok) return; + + if (selection?.projet === projet.id) panneau.fermer(); + majPlanning(supprimerProjet(planning, projet.id)); +} + +async function nouveauProjet() { + const reponse = await demanderProjet({ + titreTexte: 'Nouveau projet', + libelleValider: 'Créer', + couleur: COULEURS[planning.projects.length % COULEURS.length], + }); + if (!reponse) return; + + const projet = { ...creerProjet(planning, reponse.nom), color: reponse.couleur }; + majPlanning({ ...planning, projects: [...planning.projects, projet] }); +} + +// --------------------------------------------------------------------------- +// Barre d'outils +// --------------------------------------------------------------------------- + +$('aller-aujourdhui').addEventListener('click', () => centrerSur(aujourdhui())); +$('nouveau-projet').addEventListener('click', nouveauProjet); +$('premier-projet').addEventListener('click', nouveauProjet); + +/** + * Amène une date au centre de la vue, instantanément. + * + * Volontairement pas de défilement fluide : la frise s'élargit au défilement et + * peut atteindre plusieurs dizaines de milliers de pixels. Une animation sur une + * telle distance est lente, et le gestionnaire de défilement l'interromprait en + * élargissant la fenêtre au passage près d'un bord. + */ +function centrerSur(date) { + if (!echelle) return; + + // `scrollLeft` porte sur toute la grille, dont la première colonne est celle + // des libellés. Comme elle est `sticky`, elle recouvre en permanence les + // premiers pixels de la vue : le centre utile des couloirs est donc décalé + // d'une demi-largeur de colonne par rapport au centre de la frise. + const largeurLibelles = refs.libelles.offsetWidth; + const centreUtile = (largeurLibelles + refs.frise.clientWidth) / 2; + + refs.frise.scrollLeft = Math.max(0, largeurLibelles + echelle.x(date) - centreUtile); +} + +// Molette + Maj : défilement horizontal, geste habituel sur une frise. +refs.frise.addEventListener( + 'wheel', + (evenement) => { + if (!evenement.shiftKey || evenement.ctrlKey) return; + evenement.preventDefault(); + refs.frise.scrollLeft += evenement.deltaY; + }, + { passive: false } +); + +// Flèches : ajustement fin de la phase sélectionnée, au jour près. +document.addEventListener('keydown', (evenement) => { + if (!selection) return; + // Pas d'interception pendant une saisie dans le panneau ou un dialogue. + if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return; + + const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0; + if (pas === 0) return; + + evenement.preventDefault(); + const trouve = localiser(selection.projet, selection.phase); + if (!trouve) return; + + // Maj + flèche étire la phase au lieu de la déplacer. + const suivante = evenement.shiftKey + ? etirerAuClavier(trouve.phase, pas) + : decalerAuClavier(trouve.phase, pas); + + majPlanning( + remplacerProjet(planning, selection.projet, (projet) => + remplacerPhase(projet, selection.phase, () => suivante) + ) + ); +}); + +// --------------------------------------------------------------------------- +// Démarrage +// --------------------------------------------------------------------------- + +async function demarrer() { + try { + const brut = await stockage.charger(); + planning = validerPlanning(brut); + } catch (err) { + afficherAlerte( + `${err.message}\n\n` + + 'Le planning n’a pas été chargé, et rien ne sera enregistré tant que le ' + + 'problème persiste — les données existantes sont donc intactes.' + ); + return; + } + + dessiner(); + viderAvantFermeture(stockage); + + if (planning.projects.length) { + // Sans ce report, la largeur de la zone n'est pas encore connue et le + // centrage tombe à côté. + requestAnimationFrame(() => centrerSur(aujourdhui())); + } + + console.info( + `Frise chargée : ${planning.projects.length} projet(s), ` + + `${planning.projects.reduce((n, p) => n + p.phases.length, 0)} phase(s). ` + + `Aujourd'hui : ${formaterDateLongue(aujourdhui())}.` + ); +} + +demarrer(); diff --git a/js/detail.js b/js/detail.js new file mode 100644 index 0000000..0511ad9 --- /dev/null +++ b/js/detail.js @@ -0,0 +1,170 @@ +/** + * Panneau de détail : édition précise d'une phase. + * + * Le panneau applique les changements au fil de la saisie plutôt qu'à la + * validation d'un formulaire. La frise se met à jour en direct, et la + * sauvegarde debouncée de storage.js absorbe la fréquence des modifications. + * + * C'est aussi ici qu'on saisit une date exacte : le glisser sur la frise + * s'accroche au lundi, ce panneau est le recours quand une phase doit démarrer + * un jeudi précis. + */ + +import { STATUTS, dateValide, modifierPhase } from './model.js'; + +export function creerPanneau(refs, rappels) { + const { panneau, voile, formulaire, titre, erreur, champFin, supprimer, fermer } = refs; + + let selection = null; // { projet, phase } + let phaseCourante = null; + + function afficherErreur(message) { + erreur.textContent = message || ''; + erreur.hidden = !message; + } + + function remplir(projet, phase) { + titre.textContent = `${projet.name} — ${phase.name}`; + formulaire.name.value = phase.name; + formulaire.start.value = phase.start; + formulaire.end.value = phase.end; + formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo'; + formulaire.milestone.checked = phase.milestone; + formulaire.notes.value = phase.notes; + + // Un jalon tient sur une seule date : le champ « fin » n'a rien à dire. + champFin.hidden = phase.milestone; + afficherErreur(null); + } + + /** Lit le formulaire, applique les invariants et remonte le changement. */ + function appliquer() { + if (!selection || !phaseCourante) return; + + const nom = formulaire.name.value.trim(); + if (!nom) { + afficherErreur('Le nom ne peut pas être vide.'); + return; + } + + const debut = formulaire.start.value; + if (!dateValide(debut)) { + // Saisie en cours dans le champ date : on attend sans rien changer. + return; + } + + const jalon = formulaire.milestone.checked; + const fin = jalon ? debut : formulaire.end.value; + if (!jalon && !dateValide(fin)) return; + + const avant = phaseCourante; + const apres = modifierPhase(phaseCourante, { + name: nom, + start: debut, + end: fin, + status: formulaire.status.value, + milestone: jalon, + notes: formulaire.notes.value, + }); + + // modifierPhase a pu corriger la fin (jalon, ou fin antérieure au début). + // On reflète cette correction dans le formulaire pour que l'utilisateur + // voie ce qui a réellement été enregistré. + if (apres.end !== fin) { + formulaire.end.value = apres.end; + if (!jalon) { + afficherErreur('La fin précédait le début : la durée précédente a été conservée.'); + } + } else { + afficherErreur(null); + } + + champFin.hidden = jalon; + phaseCourante = apres; + + if (!sontEgales(avant, apres)) { + rappels.surModification(selection.projet, selection.phase, apres); + } + } + + formulaire.addEventListener('input', appliquer); + formulaire.addEventListener('change', appliquer); + formulaire.addEventListener('submit', (evenement) => evenement.preventDefault()); + + supprimer.addEventListener('click', async () => { + if (!selection) return; + const cible = selection; + const nom = phaseCourante.name; + + if (!(await rappels.demanderSuppression(cible.projet, cible.phase, nom))) return; + + // Le panneau a pu être fermé ou changer de phase pendant la confirmation. + if (selection?.phase !== cible.phase || selection?.projet !== cible.projet) return; + + api.fermer(); + rappels.surSuppression(cible.projet, cible.phase); + }); + + fermer.addEventListener('click', () => api.fermer()); + voile.addEventListener('click', () => api.fermer()); + + document.addEventListener('keydown', (evenement) => { + // Échap ferme le panneau, sauf pendant une saisie où il annule d'abord la + // complétion native du champ. + if (evenement.key === 'Escape' && !panneau.hidden) { + api.fermer(); + } + }); + + const api = { + ouvrir(projet, phase) { + selection = { projet: projet.id, phase: phase.id }; + phaseCourante = phase; + remplir(projet, phase); + panneau.hidden = false; + voile.hidden = false; + formulaire.name.focus(); + formulaire.name.select(); + }, + + /** Recharge le contenu après une modification venue de la frise. */ + rafraichir(projet, phase) { + if (!selection || selection.phase !== phase.id || selection.projet !== projet.id) return; + // On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur + // au milieu d'une frappe. + const actif = document.activeElement; + phaseCourante = phase; + titre.textContent = `${projet.name} — ${phase.name}`; + for (const champ of ['name', 'start', 'end', 'status', 'notes']) { + if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ]; + } + if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone; + champFin.hidden = phase.milestone; + }, + + fermer() { + selection = null; + phaseCourante = null; + panneau.hidden = true; + voile.hidden = true; + rappels.surFermeture(); + }, + + selection() { + return selection; + }, + }; + + return api; +} + +function sontEgales(a, b) { + return ( + a.name === b.name && + a.start === b.start && + a.end === b.end && + a.status === b.status && + a.milestone === b.milestone && + a.notes === b.notes + ); +} diff --git a/js/drag.js b/js/drag.js new file mode 100644 index 0000000..2410e1a --- /dev/null +++ b/js/drag.js @@ -0,0 +1,191 @@ +/** + * Glisser et redimensionner les barres. + * + * Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un + * glisser déplace la barre. On les départage au seuil : tant que le pointeur + * n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme + * un clic. Sans ce seuil, ouvrir le panneau décalerait la phase d'un jour à + * chaque fois que la souris frémit. + * + * Pendant le geste, seule la géométrie de l'élément est modifiée — l'état n'est + * touché qu'au relâchement. Cela évite de reconstruire toute la frise à chaque + * mouvement de souris, et laisse la possibilité d'annuler avec Échap. + */ + +import { + ajouterJours, + deplacerPhase, + lundiDeLaSemaine, + redimensionnerPhase, + versUTC, +} from './model.js'; + +/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */ +const SEUIL_GLISSER = 4; + +/** + * Installe la gestion du glisser sur le conteneur des couloirs. + * + * @param {HTMLElement} conteneur + * @param {object} rappels + * - echelle() : l'échelle courante + * - trouverPhase(idProjet, idPhase) + * - surDeplacement(idProjet, idPhase, phase) : appelé au relâchement + * - surClic(idProjet, idPhase) + */ +export function installerGlisser(conteneur, rappels) { + let geste = null; + + conteneur.addEventListener('pointerdown', (evenement) => { + // Bouton principal uniquement : le clic droit ouvre le menu contextuel. + if (evenement.button !== 0) return; + + const cible = evenement.target.closest('[data-role="barre"]'); + if (!cible) return; + + const { projet: idProjet, phase: idPhase } = cible.dataset; + const phase = rappels.trouverPhase(idProjet, idPhase); + if (!phase) return; + + const poignee = evenement.target.closest('[data-role="poignee"]'); + // Un jalon n'a pas de durée : ses poignées n'ont pas de sens. + const mode = poignee && !phase.milestone ? `redimensionner-${poignee.dataset.bord}` : 'deplacer'; + + geste = { + element: cible, + idProjet, + idPhase, + phaseInitiale: phase, + phaseCourante: phase, + mode, + xDepart: evenement.clientX, + gaucheInitiale: cible.offsetLeft, + largeurInitiale: cible.offsetWidth, + demarre: false, + }; + + cible.setPointerCapture(evenement.pointerId); + // Empêche la sélection de texte et le glisser natif de l'élément. + evenement.preventDefault(); + }); + + conteneur.addEventListener('pointermove', (evenement) => { + if (!geste) return; + + const decalage = evenement.clientX - geste.xDepart; + + if (!geste.demarre) { + if (Math.abs(decalage) < SEUIL_GLISSER) return; + geste.demarre = true; + geste.element.classList.add('barre--glissee'); + document.body.classList.add('glisse-en-cours'); + } + + appliquerGeste(geste, decalage, rappels.echelle()); + }); + + conteneur.addEventListener('pointerup', (evenement) => { + if (!geste) return; + const acheve = geste; + geste = null; + + acheve.element.releasePointerCapture?.(evenement.pointerId); + acheve.element.classList.remove('barre--glissee'); + document.body.classList.remove('glisse-en-cours'); + + if (!acheve.demarre) { + rappels.surClic(acheve.idProjet, acheve.idPhase); + return; + } + + // Le geste n'a rien changé (revenu à son point de départ) : inutile de + // déclencher une sauvegarde. + const avant = acheve.phaseInitiale; + const apres = acheve.phaseCourante; + if (apres.start === avant.start && apres.end === avant.end) { + rappels.surDeplacement(acheve.idProjet, acheve.idPhase, null); + return; + } + + rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres); + }); + + // Échap pendant le geste : on annule et on laisse le rendu se refaire. + document.addEventListener('keydown', (evenement) => { + if (evenement.key !== 'Escape' || !geste) return; + const annule = geste; + geste = null; + annule.element.classList.remove('barre--glissee'); + document.body.classList.remove('glisse-en-cours'); + rappels.surDeplacement(annule.idProjet, annule.idPhase, null); + }); + + // Un pointeur perdu (fenêtre qui perd le focus, périphérique débranché) doit + // laisser l'interface dans un état propre plutôt qu'en glisser perpétuel. + conteneur.addEventListener('pointercancel', () => { + if (!geste) return; + const annule = geste; + geste = null; + annule.element.classList.remove('barre--glissee'); + document.body.classList.remove('glisse-en-cours'); + rappels.surDeplacement(annule.idProjet, annule.idPhase, null); + }); +} + +/** + * Met à jour la géométrie de l'élément et mémorise les dates correspondantes. + * Les dates s'accrochent au lundi (docs/decisions.md, section 9). + */ +function appliquerGeste(geste, decalage, echelle) { + const { phaseInitiale: phase, mode } = geste; + + if (mode === 'deplacer') { + const debut = accrocher(phase.start, decalage, echelle); + geste.phaseCourante = deplacerPhase(phase, debut); + geste.element.style.left = `${echelle.x(debut)}px`; + return; + } + + if (mode === 'redimensionner-debut') { + let debut = accrocher(phase.start, decalage, echelle); + // La borne opposée fait butée : une phase ne peut pas se retourner. + if (versUTC(debut) > versUTC(phase.end)) debut = phase.end; + + geste.phaseCourante = redimensionnerPhase(phase, 'debut', debut); + geste.element.style.left = `${echelle.x(debut)}px`; + geste.element.style.width = `${echelle.largeurDe(debut, phase.end)}px`; + return; + } + + let fin = accrocher(phase.end, decalage, echelle); + if (versUTC(fin) < versUTC(phase.start)) fin = phase.start; + + geste.phaseCourante = redimensionnerPhase(phase, 'fin', fin); + geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`; +} + +/** + * Décale une date du nombre de jours correspondant au déplacement, puis + * l'accroche au lundi de sa semaine. + */ +function accrocher(date, decalagePixels, echelle) { + const jours = Math.round(decalagePixels / echelle.pixelsParJour); + return lundiDeLaSemaine(ajouterJours(date, jours)); +} + +/** + * Décale une phase au clavier, pour un ajustement fin sans souris. + * Utilisé par app.js sur les flèches gauche/droite quand une phase est + * sélectionnée. + */ +export function decalerAuClavier(phase, jours) { + return deplacerPhase(phase, ajouterJours(phase.start, jours)); +} + +/** Allonge ou raccourcit une phase au clavier, sans jamais la retourner. */ +export function etirerAuClavier(phase, jours) { + if (phase.milestone) return phase; + const fin = ajouterJours(phase.end, jours); + if (versUTC(fin) < versUTC(phase.start)) return phase; + return { ...phase, end: fin }; +} diff --git a/js/menu.js b/js/menu.js new file mode 100644 index 0000000..fe1d501 --- /dev/null +++ b/js/menu.js @@ -0,0 +1,215 @@ +/** + * Menu contextuel et boîtes de dialogue. + * + * Remplace les `prompt()` et `confirm()` natifs, qui bloquaient le fil + * d'exécution, ne se stylaient pas, et — pour le menu d'un projet — obligeaient + * à saisir un numéro d'action au clavier. + * + * Les dialogues s'appuient sur l'élément `` natif, qui apporte + * gratuitement le voile, le piège à focus, la fermeture par Échap et la + * restauration du focus à la fermeture. + */ + +const MARGE_ECRAN = 8; + +// --------------------------------------------------------------------------- +// Menu contextuel +// --------------------------------------------------------------------------- + +/** + * @param {HTMLElement} racine l'élément `.menu` du document + */ +export function creerMenu(racine) { + let fermerCourant = null; + + function fermer() { + if (!fermerCourant) return; + const rendreLeFocus = fermerCourant; + fermerCourant = null; + racine.hidden = true; + racine.replaceChildren(); + rendreLeFocus(); + } + + // En capture : un clic ailleurs referme le menu avant que la cible ne le + // traite, sans quoi on pourrait activer un bouton et laisser le menu ouvert. + document.addEventListener( + 'pointerdown', + (evenement) => { + if (fermerCourant && !racine.contains(evenement.target)) fermer(); + }, + true + ); + + document.addEventListener('keydown', (evenement) => { + if (!fermerCourant) return; + + if (evenement.key === 'Escape') { + evenement.preventDefault(); + fermer(); + return; + } + + const entrees = [...racine.querySelectorAll('button:not(:disabled)')]; + if (!entrees.length) return; + const index = entrees.indexOf(document.activeElement); + + if (evenement.key === 'ArrowDown') { + evenement.preventDefault(); + entrees[(index + 1) % entrees.length].focus(); + } else if (evenement.key === 'ArrowUp') { + evenement.preventDefault(); + entrees[(index - 1 + entrees.length) % entrees.length].focus(); + } + }); + + window.addEventListener('resize', fermer); + + return { + /** + * @param {HTMLElement} ancre élément près duquel afficher le menu + * @param {Array} entrees { libelle, action, danger?, separateurAvant?, desactive? } + */ + ouvrir(ancre, entrees) { + fermer(); + + const aRendre = document.activeElement; + fermerCourant = () => aRendre?.focus?.(); + + for (const entree of entrees) { + if (entree.separateurAvant) { + const separateur = document.createElement('hr'); + separateur.className = 'menu__separateur'; + racine.append(separateur); + } + + const bouton = document.createElement('button'); + bouton.type = 'button'; + bouton.className = `menu__entree${entree.danger ? ' menu__entree--danger' : ''}`; + bouton.setAttribute('role', 'menuitem'); + bouton.textContent = entree.libelle; + bouton.disabled = Boolean(entree.desactive); + bouton.addEventListener('click', () => { + fermer(); + entree.action(); + }); + racine.append(bouton); + } + + racine.hidden = false; + positionner(racine, ancre); + racine.querySelector('button:not(:disabled)')?.focus(); + }, + + fermer, + }; +} + +/** Place le menu sous son ancre, en le rabattant s'il déborde de la fenêtre. */ +function positionner(menu, ancre) { + const cadre = ancre.getBoundingClientRect(); + + // Mesure d'abord au coin supérieur gauche : la taille du menu dépend de son + // contenu, et la lire avant de le placer évite un saut visible. + menu.style.left = '0px'; + menu.style.top = '0px'; + const taille = menu.getBoundingClientRect(); + + let gauche = cadre.left; + let haut = cadre.bottom + 2; + + if (gauche + taille.width > window.innerWidth - MARGE_ECRAN) { + gauche = Math.max(MARGE_ECRAN, window.innerWidth - taille.width - MARGE_ECRAN); + } + if (haut + taille.height > window.innerHeight - MARGE_ECRAN) { + // Pas la place en dessous : on bascule au-dessus de l'ancre. + haut = Math.max(MARGE_ECRAN, cadre.top - taille.height - 2); + } + + menu.style.left = `${Math.round(gauche)}px`; + menu.style.top = `${Math.round(haut)}px`; +} + +// --------------------------------------------------------------------------- +// Dialogues +// --------------------------------------------------------------------------- + +/** + * Demande le nom et la couleur d'un projet. + * @returns {Promise<{nom: string, couleur: string} | null>} null si annulé. + */ +export function creerDialogueProjet(refs, couleurs) { + const { dialogue, titre, champNom, conteneurCouleurs, erreur, valider } = refs; + let choisie = couleurs[0]; + + function peindrePalette() { + conteneurCouleurs.replaceChildren(); + for (const couleur of couleurs) { + const bouton = document.createElement('button'); + bouton.type = 'button'; + bouton.className = 'pastille'; + bouton.style.background = couleur; + bouton.setAttribute('aria-label', `Couleur ${couleur}`); + bouton.setAttribute('aria-pressed', String(couleur === choisie)); + bouton.addEventListener('click', () => { + choisie = couleur; + peindrePalette(); + }); + conteneurCouleurs.append(bouton); + } + } + + return function demander({ titreTexte, libelleValider, nom = '', couleur = couleurs[0] }) { + titre.textContent = titreTexte; + valider.textContent = libelleValider; + champNom.value = nom; + choisie = couleur; + erreur.hidden = true; + peindrePalette(); + + dialogue.showModal(); + champNom.focus(); + champNom.select(); + + return new Promise((resoudre) => { + dialogue.addEventListener( + 'close', + () => { + // `returnValue` vaut '' quand le dialogue est fermé par Échap. + if (dialogue.returnValue !== 'ok') return resoudre(null); + const saisi = champNom.value.trim(); + if (!saisi) return resoudre(null); + resoudre({ nom: saisi, couleur: choisie }); + }, + { once: true } + ); + }); + }; +} + +/** + * Demande confirmation avant une action irréversible. + * @returns {Promise} + */ +export function creerDialogueConfirmation(refs) { + const { dialogue, titre, message, valider } = refs; + + return function demander({ titreTexte, messageTexte, libelleValider = 'Confirmer', danger = false }) { + titre.textContent = titreTexte; + message.textContent = messageTexte; + valider.textContent = libelleValider; + valider.classList.toggle('bouton--danger', danger); + valider.classList.toggle('bouton--principal', !danger); + + dialogue.showModal(); + // Le focus part sur « Annuler » : sur une action destructrice, un appui + // réflexe sur Entrée ne doit pas valider. + dialogue.querySelector('button[value="annuler"]').focus(); + + return new Promise((resoudre) => { + dialogue.addEventListener('close', () => resoudre(dialogue.returnValue === 'ok'), { + once: true, + }); + }); + }; +} diff --git a/js/model.js b/js/model.js new file mode 100644 index 0000000..54f0b6a --- /dev/null +++ b/js/model.js @@ -0,0 +1,515 @@ +/** + * Données et règles métier du planning. + * + * Ce module ne touche ni au DOM ni au réseau : il transforme des objets et + * lève des erreurs. C'est ce qui le rend testable sous Node (tests/model.test.js). + * + * Convention de dates : partout des chaînes « AAAA-MM-JJ », jamais d'objets + * Date. `new Date("2026-08-01")` est interprétée en UTC alors que + * `new Date(2026, 7, 1)` l'est en heure locale — mélanger les deux décale d'un + * jour selon le fuseau. Les conversions restent confinées aux helpers ci-dessous. + * + * La date de fin est *incluse* : du 01/08 au 01/08 dure un jour. + */ + +export const VERSION_FORMAT = 2; + +export const STATUTS = ['todo', 'doing', 'done', 'blocked']; + +export const LIBELLES_STATUT = { + todo: 'À venir', + doing: 'En cours', + done: 'Terminé', + blocked: 'Bloqué', +}; + +/** Palette par défaut, parcourue à la création de chaque nouveau projet. */ +export const COULEURS = [ + '#3b82f6', '#10b981', '#f59e0b', '#ef4444', + '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', +]; + +const MOTIF_DATE = /^\d{4}-\d{2}-\d{2}$/; +const MOTIF_COULEUR = /^#[0-9a-f]{6}$/i; +const MS_PAR_JOUR = 86400000; + +// --------------------------------------------------------------------------- +// Dates +// --------------------------------------------------------------------------- + +/** Vrai si la chaîne est une date « AAAA-MM-JJ » qui existe au calendrier. */ +export function dateValide(valeur) { + if (typeof valeur !== 'string' || !MOTIF_DATE.test(valeur)) return false; + // Date.UTC normalise silencieusement le 30 février en 2 mars : on compare + // donc le résultat à l'entrée pour débusquer ces dates inexistantes. + const [a, m, j] = valeur.split('-').map(Number); + const date = new Date(Date.UTC(a, m - 1, j)); + return ( + date.getUTCFullYear() === a && date.getUTCMonth() === m - 1 && date.getUTCDate() === j + ); +} + +/** « AAAA-MM-JJ » vers un instant UTC, pour l'arithmétique uniquement. */ +export function versUTC(texte) { + const [a, m, j] = texte.split('-').map(Number); + return Date.UTC(a, m - 1, j); +} + +/** Instant UTC vers « AAAA-MM-JJ ». */ +export function depuisUTC(instant) { + return new Date(instant).toISOString().slice(0, 10); +} + +/** Décale une date d'un nombre de jours, éventuellement négatif. */ +export function ajouterJours(texte, jours) { + return depuisUTC(versUTC(texte) + jours * MS_PAR_JOUR); +} + +/** Nombre de jours de `debut` à `fin`, signé. Deux dates égales donnent 0. */ +export function ecartJours(debut, fin) { + return Math.round((versUTC(fin) - versUTC(debut)) / MS_PAR_JOUR); +} + +/** Durée d'une phase, fin incluse. Une phase d'un seul jour dure 1. */ +export function duree(phase) { + return ecartJours(phase.start, phase.end) + 1; +} + +/** Date du jour dans le fuseau local, au format « AAAA-MM-JJ ». */ +export function aujourdhui() { + const maintenant = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${maintenant.getFullYear()}-${pad(maintenant.getMonth() + 1)}-${pad(maintenant.getDate())}`; +} + +/** Recule une date jusqu'au lundi de sa semaine — l'accroche du glisser. */ +export function lundiDeLaSemaine(texte) { + const instant = versUTC(texte); + const jour = new Date(instant).getUTCDay(); // 0 = dimanche + const recul = jour === 0 ? 6 : jour - 1; + return depuisUTC(instant - recul * MS_PAR_JOUR); +} + +/** Avance une date jusqu'au dimanche qui clôt sa semaine. */ +export function dimancheDeLaSemaine(texte) { + return ajouterJours(lundiDeLaSemaine(texte), 6); +} + +/** + * Numéro de semaine ISO 8601 et année à laquelle il se rattache. + * + * La semaine ISO est celle qui contient son jeudi, ce qui fait qu'une semaine à + * cheval sur deux années appartient à celle où tombe ce jeudi. Le 1er janvier + * 2027 est ainsi en semaine 53 de 2026 — d'où l'année renvoyée en même temps + * que le numéro : afficher « S53 » sous le bandeau « 2027 » serait faux. + */ +export function semaineISO(texte) { + const date = new Date(versUTC(texte)); + + // On se place sur le jeudi de la semaine ISO courante. + const jourLundiZero = (date.getUTCDay() + 6) % 7; + date.setUTCDate(date.getUTCDate() - jourLundiZero + 3); + const jeudi = date.getTime(); + const annee = date.getUTCFullYear(); + + // Puis sur le premier jeudi de cette année-là, qui définit la semaine 1. + const premierJanvier = new Date(Date.UTC(annee, 0, 1)); + const decalage = (4 - premierJanvier.getUTCDay() + 7) % 7; + const premierJeudi = Date.UTC(annee, 0, 1 + decalage); + + return { + annee, + numero: 1 + Math.round((jeudi - premierJeudi) / (7 * MS_PAR_JOUR)), + }; +} + +// --------------------------------------------------------------------------- +// Identifiants +// --------------------------------------------------------------------------- + +/** + * Fabrique un identifiant lisible à partir d'un nom : minuscules, accents + * retirés, tout le reste en tirets. + * + * Un identifiant ne change jamais quand le nom est modifié : il identifie, il + * ne décrit pas. + */ +export function fabriquerId(nom, dejaPris = []) { + const base = + nom + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // marques diacritiques combinantes + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40) || 'sans-nom'; + + if (!dejaPris.includes(base)) return base; + + let compteur = 2; + while (dejaPris.includes(`${base}-${compteur}`)) compteur += 1; + return `${base}-${compteur}`; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +export class ErreurValidation extends Error {} + +/** + * Vérifie un planning entier et le renvoie normalisé. + * + * Un fichier invalide n'est jamais réparé en silence : on lève une erreur qui + * nomme le projet et la phase fautifs, pour que le fichier puisse être corrigé + * à la main. + */ +export function validerPlanning(donnees) { + if (!donnees || typeof donnees !== 'object') { + throw new ErreurValidation('Le fichier ne contient pas un objet JSON.'); + } + if (!Array.isArray(donnees.projects)) { + throw new ErreurValidation("Le fichier ne contient pas de tableau « projects »."); + } + if (donnees.version !== undefined && donnees.version > VERSION_FORMAT) { + throw new ErreurValidation( + `Fichier en version ${donnees.version}, alors que cet outil lit la version ${VERSION_FORMAT}. ` + + 'Il a probablement été écrit par une version plus récente.' + ); + } + + const idsProjets = new Set(); + const projets = donnees.projects.map((projet, index) => { + const repere = projet && projet.name ? `« ${projet.name} »` : `n°${index + 1}`; + + if (!projet || typeof projet !== 'object') { + throw new ErreurValidation(`Projet ${repere} : ce n'est pas un objet.`); + } + if (typeof projet.name !== 'string' || !projet.name.trim()) { + throw new ErreurValidation(`Projet ${repere} : le nom est vide.`); + } + if (typeof projet.id !== 'string' || !projet.id) { + throw new ErreurValidation(`Projet ${repere} : identifiant manquant.`); + } + if (idsProjets.has(projet.id)) { + throw new ErreurValidation(`Deux projets portent l'identifiant « ${projet.id} ».`); + } + idsProjets.add(projet.id); + + if (projet.baselineDate !== undefined && !dateValide(projet.baselineDate)) { + throw new ErreurValidation( + `Projet ${repere} : « baselineDate » n'est pas une date valide (${projet.baselineDate}).` + ); + } + if (!Array.isArray(projet.phases)) { + throw new ErreurValidation(`Projet ${repere} : « phases » doit être un tableau.`); + } + + const idsPhases = new Set(); + const phases = projet.phases.map((phase, rang) => + validerPhase(phase, rang, repere, idsPhases) + ); + + return { + id: projet.id, + name: projet.name.trim(), + color: MOTIF_COULEUR.test(projet.color || '') ? projet.color : COULEURS[0], + collapsed: Boolean(projet.collapsed), + hidden: Boolean(projet.hidden), + ...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}), + phases: trierPhases(phases), + }; + }); + + return { version: VERSION_FORMAT, projects: projets }; +} + +function validerPhase(phase, rang, repereProjet, idsPhases) { + const repere = phase && phase.name ? `« ${phase.name} »` : `n°${rang + 1}`; + const ou = `Projet ${repereProjet}, phase ${repere}`; + + if (!phase || typeof phase !== 'object') { + throw new ErreurValidation(`${ou} : ce n'est pas un objet.`); + } + if (typeof phase.name !== 'string' || !phase.name.trim()) { + throw new ErreurValidation(`${ou} : le nom est vide.`); + } + if (typeof phase.id !== 'string' || !phase.id) { + throw new ErreurValidation(`${ou} : identifiant manquant.`); + } + if (idsPhases.has(phase.id)) { + throw new ErreurValidation( + `Projet ${repereProjet} : deux phases portent l'identifiant « ${phase.id} ».` + ); + } + idsPhases.add(phase.id); + + if (!dateValide(phase.start)) { + throw new ErreurValidation(`${ou} : date de début invalide (${phase.start}).`); + } + if (!dateValide(phase.end)) { + throw new ErreurValidation(`${ou} : date de fin invalide (${phase.end}).`); + } + if (versUTC(phase.end) < versUTC(phase.start)) { + throw new ErreurValidation( + `${ou} : la fin (${phase.end}) précède le début (${phase.start}).` + ); + } + + const jalon = Boolean(phase.milestone); + if (jalon && phase.start !== phase.end) { + throw new ErreurValidation( + `${ou} : un jalon doit tenir sur un seul jour (${phase.start} → ${phase.end}).` + ); + } + + let reference; + if (phase.baseline !== undefined) { + const b = phase.baseline; + if (!b || !dateValide(b.start) || !dateValide(b.end)) { + throw new ErreurValidation(`${ou} : la référence contient une date invalide.`); + } + if (versUTC(b.end) < versUTC(b.start)) { + throw new ErreurValidation(`${ou} : la fin de référence précède son début.`); + } + reference = { start: b.start, end: b.end }; + } + + return { + id: phase.id, + name: phase.name.trim(), + start: phase.start, + end: phase.end, + // Un statut inconnu est ramené à « à venir » plutôt que de bloquer le + // chargement : la valeur est cosmétique, contrairement aux dates. + status: STATUTS.includes(phase.status) ? phase.status : 'todo', + milestone: jalon, + notes: typeof phase.notes === 'string' ? phase.notes : '', + ...(reference ? { baseline: reference } : {}), + }; +} + +function trierPhases(phases) { + return [...phases].sort( + (a, b) => versUTC(a.start) - versUTC(b.start) || a.name.localeCompare(b.name, 'fr') + ); +} + +// --------------------------------------------------------------------------- +// Bornes et dérive +// --------------------------------------------------------------------------- + +/** + * Étendue d'un projet, de sa première phase à sa dernière. + * Renvoie null pour un projet sans phase. + */ +export function bornesProjet(projet) { + if (!projet.phases.length) return null; + let debut = projet.phases[0].start; + let fin = projet.phases[0].end; + for (const phase of projet.phases) { + if (versUTC(phase.start) < versUTC(debut)) debut = phase.start; + if (versUTC(phase.end) > versUTC(fin)) fin = phase.end; + } + return { start: debut, end: fin }; +} + +/** Étendue de tous les projets visibles réunis. Null si rien à afficher. */ +export function bornesPlanning(planning) { + let debut = null; + let fin = null; + for (const projet of planning.projects) { + if (projet.hidden) continue; + const bornes = bornesProjet(projet); + if (!bornes) continue; + if (debut === null || versUTC(bornes.start) < versUTC(debut)) debut = bornes.start; + if (fin === null || versUTC(bornes.end) > versUTC(fin)) fin = bornes.end; + } + return debut === null ? null : { start: debut, end: fin }; +} + +/** + * Fige le planning courant comme référence : chaque phase mémorise ses dates + * actuelles. L'opération est rejouable — la refiger après un arbitrage assumé + * repart d'une base propre. + */ +export function figerReference(projet, date = aujourdhui()) { + return { + ...projet, + baselineDate: date, + phases: projet.phases.map((phase) => ({ + ...phase, + baseline: { start: phase.start, end: phase.end }, + })), + }; +} + +/** Retire la référence d'un projet. */ +export function libererReference(projet) { + return { + ...projet, + baselineDate: undefined, + phases: projet.phases.map(({ baseline, ...reste }) => reste), + }; +} + +/** + * Dérive d'un projet, en jours, entre sa fin actuelle et sa fin de référence. + * Positive si le projet a glissé. Null si aucune référence n'a été figée. + * + * Une phase ajoutée après le figeage n'a pas de référence : elle ne compte pas + * dans la fin de référence mais compte dans la fin actuelle. Ajouter une phase + * en fin de projet crée donc bien une dérive, ce qui est voulu. + */ +export function deriveProjet(projet) { + const avecReference = projet.phases.filter((phase) => phase.baseline); + if (!avecReference.length) return null; + + const finReference = avecReference.reduce( + (max, phase) => (versUTC(phase.baseline.end) > versUTC(max) ? phase.baseline.end : max), + avecReference[0].baseline.end + ); + + const bornes = bornesProjet(projet); + if (!bornes) return null; + + return ecartJours(finReference, bornes.end); +} + +/** Étendue de référence d'un projet, pour la barre fantôme. Null si non figée. */ +export function bornesReference(projet) { + const avecReference = projet.phases.filter((phase) => phase.baseline); + if (!avecReference.length) return null; + + let debut = avecReference[0].baseline.start; + let fin = avecReference[0].baseline.end; + for (const { baseline } of avecReference) { + if (versUTC(baseline.start) < versUTC(debut)) debut = baseline.start; + if (versUTC(baseline.end) > versUTC(fin)) fin = baseline.end; + } + return { start: debut, end: fin }; +} + +/** Formule une dérive en semaines, prête à afficher. Null si négligeable. */ +export function formulerDerive(jours) { + if (jours === null || jours === 0) return null; + const semaines = Math.round(Math.abs(jours) / 7); + // Sous la demi-semaine, on annonce les jours plutôt qu'un « 0 semaine ». + const quantite = + semaines === 0 + ? `${Math.abs(jours)} j` + : `${semaines} sem`; + return jours > 0 ? `+${quantite}` : `−${quantite}`; +} + +// --------------------------------------------------------------------------- +// Modifications +// --------------------------------------------------------------------------- + +export function creerProjet(planning, nom) { + const id = fabriquerId(nom, planning.projects.map((p) => p.id)); + return { + id, + name: nom.trim(), + color: COULEURS[planning.projects.length % COULEURS.length], + collapsed: false, + hidden: false, + phases: [], + }; +} + +export function creerPhase(projet, nom, debut, fin, options = {}) { + const id = fabriquerId(nom, projet.phases.map((p) => p.id)); + const jalon = Boolean(options.milestone); + return { + id, + name: nom.trim(), + start: debut, + // Un jalon tient sur un jour : cocher la case ramène la fin sur le début. + end: jalon ? debut : fin, + status: STATUTS.includes(options.status) ? options.status : 'todo', + milestone: jalon, + notes: options.notes || '', + }; +} + +/** Décale une phase en conservant sa durée — le glisser du corps de la barre. */ +export function deplacerPhase(phase, nouveauDebut) { + const jours = duree(phase) - 1; + return { ...phase, start: nouveauDebut, end: ajouterJours(nouveauDebut, jours) }; +} + +/** + * Change une seule borne — le glisser d'un bord de barre. + * La borne opposée fait butée : une phase ne peut pas se retourner. + */ +export function redimensionnerPhase(phase, bord, date) { + if (phase.milestone) return phase; + if (bord === 'debut') { + return { ...phase, start: versUTC(date) > versUTC(phase.end) ? phase.end : date }; + } + return { ...phase, end: versUTC(date) < versUTC(phase.start) ? phase.start : date }; +} + +/** + * Applique des changements à une phase, en maintenant les invariants : + * fin postérieure au début, et jalon tenant sur un seul jour. + */ +export function modifierPhase(phase, changements) { + const suivante = { ...phase, ...changements }; + + if (suivante.milestone) { + suivante.end = suivante.start; + } else if (versUTC(suivante.end) < versUTC(suivante.start)) { + // L'utilisateur vient de saisir une fin antérieure au début : on suppose + // qu'il voulait déplacer la phase, et on conserve la durée précédente. + suivante.end = ajouterJours(suivante.start, duree(phase) - 1); + } + + return suivante; +} + +/** Renvoie un planning où le projet d'identifiant `id` a été transformé. */ +export function remplacerProjet(planning, id, transformation) { + return { + ...planning, + projects: planning.projects.map((projet) => + projet.id === id ? transformation(projet) : projet + ), + }; +} + +/** Renvoie un projet où la phase d'identifiant `id` a été transformée. */ +export function remplacerPhase(projet, id, transformation) { + return { + ...projet, + phases: trierPhases( + projet.phases.map((phase) => (phase.id === id ? transformation(phase) : phase)) + ), + }; +} + +export function supprimerPhase(projet, id) { + return { ...projet, phases: projet.phases.filter((phase) => phase.id !== id) }; +} + +export function supprimerProjet(planning, id) { + return { ...planning, projects: planning.projects.filter((projet) => projet.id !== id) }; +} + +export function ajouterPhase(projet, phase) { + return { ...projet, phases: trierPhases([...projet.phases, phase]) }; +} + +/** + * Prépare le planning pour l'écriture : on retire les clés `undefined`, que + * `JSON.stringify` omettrait de toute façon, pour que le fichier écrit soit + * exactement ce que le modèle décrit. + */ +export function pourEcriture(planning) { + return JSON.parse(JSON.stringify({ version: VERSION_FORMAT, projects: planning.projects })); +} + +export function planningVide() { + return { version: VERSION_FORMAT, projects: [] }; +} diff --git a/js/storage.js b/js/storage.js new file mode 100644 index 0000000..77062fd --- /dev/null +++ b/js/storage.js @@ -0,0 +1,142 @@ +/** + * Dialogue avec le serveur local : chargement du planning au démarrage, et + * sauvegarde automatique debouncée à chaque modification. + * + * Le module ne connaît rien au contenu du planning : il transporte un objet + * JSON. Les règles métier sont dans model.js. + */ + +/** Délai d'inactivité avant d'écrire sur le disque. */ +const DELAI_DEBOUNCE = 1000; + +/** Le rendu « enregistré » disparaît après ce délai, pour ne pas rester affiché. */ +const DUREE_CONFIRMATION = 2000; + +const URL_DONNEES = '/api/data'; + +/** + * @param {(etat: 'inactif'|'en-cours'|'enregistre'|'erreur', detail?: string) => void} surEtat + * Appelé à chaque changement d'état, pour l'indicateur de l'interface. + */ +export function creerStockage(surEtat = () => {}) { + let minuteurDebounce = null; + let minuteurConfirmation = null; + + // Dernier instantané en attente d'écriture. On ne garde que le plus récent : + // si trois modifications s'enchaînent, seule la dernière part sur le réseau. + let enAttente = null; + + // Une écriture est en vol. Toute modification survenue entre-temps sera + // envoyée à la fin de celle-ci, pour éviter deux PUT concurrents. + let ecritureEnCours = false; + + function annoncer(etat, detail) { + clearTimeout(minuteurConfirmation); + surEtat(etat, detail); + if (etat === 'enregistre') { + minuteurConfirmation = setTimeout(() => surEtat('inactif'), DUREE_CONFIRMATION); + } + } + + async function ecrire() { + if (ecritureEnCours || enAttente === null) return; + + const charge = enAttente; + enAttente = null; + ecritureEnCours = true; + annoncer('en-cours'); + + try { + const reponse = await fetch(URL_DONNEES, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(charge), + }); + + if (!reponse.ok) { + const corps = await reponse.json().catch(() => ({})); + throw new Error(corps.error || `Le serveur a répondu ${reponse.status}.`); + } + + annoncer('enregistre'); + } catch (err) { + // On remet la charge en attente : la prochaine modification, ou la + // prochaine tentative, la renverra. Rien n'est perdu tant que l'onglet + // reste ouvert. + if (enAttente === null) enAttente = charge; + annoncer('erreur', decrireErreur(err)); + } finally { + ecritureEnCours = false; + // Une modification est arrivée pendant l'écriture : on enchaîne. + if (enAttente !== null) planifier(); + } + } + + function planifier() { + clearTimeout(minuteurDebounce); + minuteurDebounce = setTimeout(ecrire, DELAI_DEBOUNCE); + } + + return { + /** + * Charge le planning. Lève une erreur explicite si le serveur est + * injoignable ou si le fichier est corrompu — l'application ne doit + * jamais démarrer sur des données douteuses. + */ + async charger() { + let reponse; + try { + reponse = await fetch(URL_DONNEES, { headers: { Accept: 'application/json' } }); + } catch { + throw new Error( + "Serveur injoignable. L'application doit être ouverte via « python3 serve.py », " + + 'pas en ouvrant index.html directement.' + ); + } + + if (!reponse.ok) { + const corps = await reponse.json().catch(() => ({})); + throw new Error(corps.error || `Le serveur a répondu ${reponse.status}.`); + } + + return reponse.json(); + }, + + /** Enregistre après un délai d'inactivité. Appelable à chaque frappe. */ + sauvegarder(donnees) { + enAttente = donnees; + planifier(); + }, + + /** Écrit immédiatement, sans attendre le debounce. */ + async vider() { + clearTimeout(minuteurDebounce); + await ecrire(); + }, + + /** Vrai s'il reste des modifications non écrites sur le disque. */ + aDesModificationsEnAttente() { + return enAttente !== null || ecritureEnCours; + }, + }; +} + +function decrireErreur(err) { + if (err instanceof TypeError) return 'Serveur injoignable. Est-il toujours lancé ?'; + return err.message; +} + +/** + * Écrit les modifications en attente quand l'onglet passe en arrière-plan. + * + * C'est plus fiable que « beforeunload » : sur mobile et lors d'une fermeture + * d'onglet, « visibilitychange » est le dernier événement dont la livraison + * soit garantie. + */ +export function viderAvantFermeture(stockage) { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden' && stockage.aDesModificationsEnAttente()) { + stockage.vider(); + } + }); +} diff --git a/js/timeline.js b/js/timeline.js new file mode 100644 index 0000000..241c182 --- /dev/null +++ b/js/timeline.js @@ -0,0 +1,502 @@ +/** + * Rendu de la frise : échelle temporelle, graduations, couloirs, barres, + * jalons, barre segmentée du mode plié et barre fantôme de référence. + * + * Le rendu est complet à chaque appel : on reconstruit le DOM depuis l'état + * plutôt que d'appliquer des différences. À l'échelle visée — quelques dizaines + * de phases — c'est instantané, et ça supprime toute une classe de bugs de + * synchronisation entre l'état et l'affichage. + * + * Aucun SVG : des éléments positionnés en absolu suffisent depuis que les + * dépendances ont été retirées (docs/decisions.md, section 4). + */ + +import { + LIBELLES_STATUT, + ajouterJours, + aujourdhui, + bornesProjet, + bornesReference, + deriveProjet, + dimancheDeLaSemaine, + ecartJours, + formulerDerive, + lundiDeLaSemaine, + semaineISO, + versUTC, +} from './model.js'; + +/** + * Échelle unique : 6 pixels par jour, soit 42 pixels par semaine. + * + * Il n'y a délibérément plus de niveaux de zoom. L'en-tête empile déjà l'année, + * le mois et le numéro de semaine : les trois repères utiles sont visibles en + * permanence, et changer d'échelle n'apportait qu'un réglage de plus à + * comprendre. Voir docs/decisions.md, section 14. + */ +export const PIXELS_PAR_JOUR = 6; + +/** + * Largeur minimale, en pixels, qu'une cellule doit occuper pour porter son + * libellé : en dessous, elle reste vide plutôt que d'afficher un texte tronqué. + */ +const LIBELLE_MINI = { annee: 40, mois: 34, semaine: 30 }; + +const MOIS_COURTS = [ + 'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', + 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.', +]; + +const MOIS_LONGS = [ + 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', + 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre', +]; + +/** + * Calcule l'échelle : origine, largeur totale et conversions date <-> pixel. + * Toute la géométrie de la frise passe par cet objet. + * + * La fenêtre affichée est décidée par app.js, qui l'élargit au fil du + * défilement — l'échelle ne la déduit plus des bornes du planning. + * + * @param {{debut: string, fin: string}} fenetre + */ +export function creerEchelle(fenetre) { + const { debut, fin } = fenetre; + const jours = ecartJours(debut, fin) + 1; + const pixelsParJour = PIXELS_PAR_JOUR; + + return { + debut, + fin, + pixelsParJour, + largeur: jours * pixelsParJour, + /** Date « AAAA-MM-JJ » vers l'abscisse de son bord gauche. */ + x(date) { + return ecartJours(this.debut, date) * pixelsParJour; + }, + /** Largeur d'une phase, date de fin incluse. */ + largeurDe(debutPhase, finPhase) { + return (ecartJours(debutPhase, finPhase) + 1) * pixelsParJour; + }, + /** Abscisse vers la date correspondante — l'inverse de `x`. */ + dateEn(abscisse) { + return ajouterJours(this.debut, Math.round(abscisse / pixelsParJour)); + }, + }; +} + +/** + * Reconstruit toute la frise. + * + * @param {object} refs éléments DOM ciblés (libelles, entete, couloirs) + * @param {object} planning l'état courant + * @param {object} options { fenetre, selection } + */ +export function rendre(refs, planning, { fenetre, selection }) { + const echelle = creerEchelle(fenetre); + const visibles = planning.projects.filter((projet) => !projet.hidden); + + refs.entete.style.width = `${echelle.largeur}px`; + refs.couloirs.style.width = `${echelle.largeur}px`; + + refs.entete.replaceChildren(...construireEntete(echelle)); + refs.libelles.replaceChildren(...construireLibelles(planning)); + refs.couloirs.replaceChildren( + ...construireTraits(echelle, hauteurTotale(visibles)), + ...construireCouloirs(visibles, echelle, selection) + ); + + return echelle; +} + +function hauteurTotale(projets) { + const lignes = projets.reduce( + (total, projet) => total + 1 + (projet.collapsed ? 0 : projet.phases.length), + 0 + ); + return lignes * 38; // --hauteur-ligne +} + +// En-tête de dates : trois bandes empilées +// --------------------------------------------------------------------------- + +/** + * L'en-tête empile trois bandes — année, mois, semaine ISO — plutôt qu'une + * seule rangée de graduations. Chaque cellule est un bloc couvrant exactement + * son intervalle, ce qui place le libellé au centre de la période qu'il désigne + * au lieu de le laisser flotter après un trait. + * + * La semaine est le grain le plus fin : on ne descend jamais au jour. + */ +function construireEntete(echelle) { + return [ + bande(echelle, [...segmentsAnnees(echelle)], 'annee', LIBELLE_MINI.annee), + bande(echelle, [...segmentsMois(echelle)], 'mois', LIBELLE_MINI.mois), + bande(echelle, [...segmentsSemaines(echelle)], 'semaine', LIBELLE_MINI.semaine), + ]; +} + +/** + * @param {number} largeurMiniLibelle largeur en pixels sous laquelle la cellule + * reste vide : mieux vaut pas de texte qu'un texte tronqué illisible. + */ +function bande(echelle, segments, nom, largeurMiniLibelle) { + const conteneur = document.createElement('div'); + conteneur.className = `bande bande--${nom}`; + + for (const segment of segments) { + const largeur = echelle.largeurDe(segment.debut, segment.fin); + const cellule = document.createElement('div'); + cellule.className = 'bande__cellule'; + cellule.style.left = `${echelle.x(segment.debut)}px`; + cellule.style.width = `${largeur}px`; + if (largeur >= largeurMiniLibelle) cellule.textContent = segment.libelle; + cellule.title = segment.titre ?? segment.libelle; + conteneur.append(cellule); + } + + return conteneur; +} + +function plusTard(a, b) { + return versUTC(a) >= versUTC(b) ? a : b; +} + +function plusTot(a, b) { + return versUTC(a) <= versUTC(b) ? a : b; +} + +function* segmentsAnnees(echelle) { + const derniere = Number(echelle.fin.slice(0, 4)); + for (let annee = Number(echelle.debut.slice(0, 4)); annee <= derniere; annee += 1) { + yield { + debut: plusTard(`${annee}-01-01`, echelle.debut), + fin: plusTot(`${annee}-12-31`, echelle.fin), + libelle: String(annee), + }; + } +} + +function* segmentsMois(echelle) { + let annee = Number(echelle.debut.slice(0, 4)); + let mois = Number(echelle.debut.slice(5, 7)); + const finUTC = versUTC(echelle.fin); + + while (true) { + const premier = `${annee}-${String(mois).padStart(2, '0')}-01`; + if (versUTC(premier) > finUTC) return; + + // Le dernier jour du mois s'obtient en reculant d'un jour depuis le + // premier du mois suivant, ce qui gère les années bissextiles sans table. + const suivant = moisSuivant(annee, mois); + const premierSuivant = `${suivant.annee}-${String(suivant.mois).padStart(2, '0')}-01`; + + yield { + debut: plusTard(premier, echelle.debut), + fin: plusTot(ajouterJours(premierSuivant, -1), echelle.fin), + libelle: MOIS_COURTS[mois - 1], + titre: `${MOIS_LONGS[mois - 1]} ${annee}`, + }; + + ({ annee, mois } = suivant); + } +} + +function* segmentsSemaines(echelle) { + const finUTC = versUTC(echelle.fin); + let lundi = lundiDeLaSemaine(echelle.debut); + + while (versUTC(lundi) <= finUTC) { + const { annee, numero } = semaineISO(lundi); + const dimanche = dimancheDeLaSemaine(lundi); + yield { + debut: plusTard(lundi, echelle.debut), + fin: plusTot(dimanche, echelle.fin), + libelle: `S${numero}`, + titre: `Semaine ${numero} de ${annee} — du ${formaterDateLongue(lundi)} au ${formaterDateLongue(dimanche)}`, + }; + lundi = ajouterJours(lundi, 7); + } +} + +// --------------------------------------------------------------------------- +// Traits verticaux des couloirs +// --------------------------------------------------------------------------- + +/** + * Les traits reprennent le découpage de l'en-tête : un trait fort à chaque + * début de mois, un trait léger à chaque lundi. + */ +function construireTraits(echelle, hauteur) { + const elements = []; + + for (const semaine of segmentsSemaines(echelle)) { + elements.push(trait(echelle, semaine.debut, hauteur, '')); + } + + for (const mois of segmentsMois(echelle)) { + elements.push(trait(echelle, mois.debut, hauteur, ' trait--majeur')); + } + + // Repère « aujourd'hui », seulement s'il tombe dans la fenêtre affichée. + const jour = aujourdhui(); + if (versUTC(jour) >= versUTC(echelle.debut) && versUTC(jour) <= versUTC(echelle.fin)) { + const repere = trait(echelle, jour, hauteur, ' trait--aujourdhui'); + repere.title = `Aujourd'hui, ${formaterDateLongue(jour)}`; + elements.push(repere); + } + + return elements; +} + +function trait(echelle, date, hauteur, suffixeClasse) { + const element = document.createElement('div'); + element.className = `trait${suffixeClasse}`; + element.style.left = `${echelle.x(date)}px`; + element.style.height = `${hauteur}px`; + return element; +} + +function moisSuivant(annee, mois) { + return mois === 12 ? { annee: annee + 1, mois: 1 } : { annee, mois: mois + 1 }; +} + +export function formaterDateLongue(date) { + const [a, m, j] = date.split('-'); + return `${j} ${MOIS_COURTS[Number(m) - 1]} ${a}`; +} + +// --------------------------------------------------------------------------- +// Colonne des libellés +// --------------------------------------------------------------------------- + +function construireLibelles(planning) { + const elements = []; + + for (const projet of planning.projects) { + if (projet.hidden) { + elements.push(libelleProjet(projet)); + continue; + } + + elements.push(libelleProjet(projet)); + if (projet.collapsed) continue; + + for (const phase of projet.phases) { + const ligne = document.createElement('div'); + ligne.className = 'libelle-phase'; + ligne.textContent = phase.name; + ligne.title = `${phase.name} — ${LIBELLES_STATUT[phase.status]}`; + ligne.dataset.projet = projet.id; + ligne.dataset.phase = phase.id; + elements.push(ligne); + } + } + + return elements; +} + +function libelleProjet(projet) { + const ligne = document.createElement('div'); + ligne.className = `libelle-projet${projet.hidden ? ' libelle-projet--masque' : ''}`; + ligne.style.setProperty('--couleur-projet', projet.color); + + const plier = document.createElement('button'); + plier.className = 'plier'; + plier.type = 'button'; + plier.dataset.action = 'plier'; + plier.dataset.projet = projet.id; + plier.setAttribute('aria-expanded', String(!projet.collapsed)); + plier.setAttribute( + 'aria-label', + projet.collapsed ? `Déplier ${projet.name}` : `Plier ${projet.name}` + ); + plier.textContent = '▶'; + ligne.append(plier); + + const nom = document.createElement('span'); + nom.className = 'libelle-projet__nom'; + nom.textContent = projet.name; + nom.title = projet.name; + ligne.append(nom); + + const derive = formulerDerive(deriveProjet(projet)); + if (derive) { + const etiquette = document.createElement('span'); + etiquette.className = 'libelle-projet__derive'; + etiquette.dataset.sens = derive.startsWith('+') ? 'retard' : 'avance'; + etiquette.textContent = derive; + etiquette.title = projet.baselineDate + ? `Écart au planning figé le ${formaterDateLongue(projet.baselineDate)}` + : 'Écart au planning de référence'; + ligne.append(etiquette); + } + + const oeil = document.createElement('button'); + oeil.className = 'icone-oeil'; + oeil.type = 'button'; + oeil.dataset.action = 'masquer'; + oeil.dataset.projet = projet.id; + oeil.textContent = projet.hidden ? '🚫' : '👁'; + oeil.setAttribute('aria-label', projet.hidden ? `Afficher ${projet.name}` : `Masquer ${projet.name}`); + ligne.append(oeil); + + // Bouton de menu explicite : les actions du projet passaient auparavant par + // un clic sur son nom, ce que rien n'indiquait. + const menu = document.createElement('button'); + menu.className = 'menu-projet'; + menu.type = 'button'; + menu.dataset.action = 'menu'; + menu.dataset.projet = projet.id; + menu.textContent = '⋯'; + menu.title = `Actions sur ${projet.name}`; + menu.setAttribute('aria-label', `Actions sur ${projet.name}`); + menu.setAttribute('aria-haspopup', 'menu'); + ligne.append(menu); + + return ligne; +} + +// --------------------------------------------------------------------------- +// Couloirs +// --------------------------------------------------------------------------- + +function construireCouloirs(projets, echelle, selection) { + const elements = []; + + for (const projet of projets) { + const couloirProjet = document.createElement('div'); + couloirProjet.className = 'couloir couloir--projet'; + couloirProjet.style.setProperty('--couleur-projet', projet.color); + + if (projet.collapsed) { + const barre = construireBarrePliee(projet, echelle); + if (barre) couloirProjet.append(barre); + const fantome = construireFantome(projet, echelle); + if (fantome) couloirProjet.append(fantome); + } + + elements.push(couloirProjet); + + if (projet.collapsed) continue; + + for (const phase of projet.phases) { + const couloir = document.createElement('div'); + couloir.className = 'couloir'; + couloir.style.setProperty('--couleur-projet', projet.color); + couloir.append(...construirePhase(projet, phase, echelle, selection)); + elements.push(couloir); + } + } + + return elements; +} + +function construirePhase(projet, phase, echelle, selection) { + const selectionnee = + selection && selection.projet === projet.id && selection.phase === phase.id; + + const elements = []; + + // La référence se dessine sous la barre, donc en premier. + if (phase.baseline) { + const fantome = document.createElement('div'); + fantome.className = 'fantome'; + fantome.style.left = `${echelle.x(phase.baseline.start)}px`; + fantome.style.width = `${echelle.largeurDe(phase.baseline.start, phase.baseline.end)}px`; + fantome.title = `Référence : ${formaterDateLongue(phase.baseline.start)} → ${formaterDateLongue(phase.baseline.end)}`; + elements.push(fantome); + } + + if (phase.milestone) { + const losange = document.createElement('div'); + losange.className = `jalon jalon--${phase.status}${selectionnee ? ' jalon--selectionne' : ''}`; + losange.style.left = `${echelle.x(phase.start)}px`; + losange.dataset.projet = projet.id; + losange.dataset.phase = phase.id; + losange.dataset.role = 'barre'; + losange.title = `${phase.name} — ${formaterDateLongue(phase.start)}`; + elements.push(losange); + + const nom = document.createElement('span'); + nom.className = 'jalon-nom'; + nom.style.left = `${echelle.x(phase.start)}px`; + nom.textContent = phase.name; + elements.push(nom); + + return elements; + } + + const barre = document.createElement('div'); + barre.className = `barre barre--${phase.status}${selectionnee ? ' barre--selectionnee' : ''}`; + barre.style.left = `${echelle.x(phase.start)}px`; + barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`; + barre.dataset.projet = projet.id; + barre.dataset.phase = phase.id; + barre.dataset.role = 'barre'; + barre.textContent = phase.name; + barre.title = + `${phase.name}\n${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` + + `\n${LIBELLES_STATUT[phase.status]}` + + (phase.notes ? `\n\n${phase.notes}` : ''); + + for (const bord of ['debut', 'fin']) { + const poignee = document.createElement('div'); + poignee.className = `poignee poignee--${bord}`; + poignee.dataset.role = 'poignee'; + poignee.dataset.bord = bord; + barre.append(poignee); + } + + elements.push(barre); + return elements; +} + +/** + * Barre unique du mode plié, segmentée par phase. + * Lecture seule : aucun `data-role`, donc drag.js l'ignore. + */ +function construireBarrePliee(projet, echelle) { + const bornes = bornesProjet(projet); + if (!bornes) return null; + + const conteneur = document.createElement('div'); + conteneur.className = 'barre-pliee'; + conteneur.style.left = `${echelle.x(bornes.start)}px`; + conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`; + + const derive = formulerDerive(deriveProjet(projet)); + conteneur.title = + `${projet.name}\n${formaterDateLongue(bornes.start)} → ${formaterDateLongue(bornes.end)}` + + `\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` + + (derive ? `\nÉcart à la référence : ${derive}` : '') + + '\n\nDéplier le projet pour modifier les dates.'; + + const origine = echelle.x(bornes.start); + for (const phase of projet.phases) { + const segment = document.createElement('div'); + segment.className = `segment segment--${phase.status}`; + segment.style.left = `${echelle.x(phase.start) - origine}px`; + // Un jalon n'a pas de largeur propre : on lui donne 3 px pour rester visible. + segment.style.width = phase.milestone + ? '3px' + : `${echelle.largeurDe(phase.start, phase.end)}px`; + conteneur.append(segment); + } + + return conteneur; +} + +/** Barre fantôme du projet plié, reflétant l'étendue de référence. */ +function construireFantome(projet, echelle) { + const reference = bornesReference(projet); + if (!reference) return null; + + const fantome = document.createElement('div'); + fantome.className = 'fantome'; + fantome.style.left = `${echelle.x(reference.start)}px`; + fantome.style.width = `${echelle.largeurDe(reference.start, reference.end)}px`; + fantome.title = `Référence : ${formaterDateLongue(reference.start)} → ${formaterDateLongue(reference.end)}`; + return fantome; +} diff --git a/serve.py b/serve.py new file mode 100644 index 0000000..d9d57c7 --- /dev/null +++ b/serve.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Micro-serveur pour la frise multi-projets. + +Sert les fichiers statiques du dossier courant et expose le fichier de données : + + GET /api/data -> renvoie le JSON du planning + PUT /api/data -> le remplace, apres copie horodatee dans data/backups/ + +Bibliotheque standard uniquement, aucune dependance a installer. + +Ce n'est volontairement pas un back-end : il n'y a ici aucune logique metier, le +serveur ne fait que lire et ecrire un fichier. Voir docs/decisions.md, section 3. +""" + +import argparse +import json +import shutil +import sys +from datetime import datetime, timezone +from functools import partial +from http import HTTPStatus +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +RACINE = Path(__file__).resolve().parent + +# Au-dela, on refuse la requete : un planning macro pese quelques kilo-octets, +# un corps de cette taille signale forcement une erreur. +TAILLE_MAX = 5 * 1024 * 1024 + +# Nombre de sauvegardes conservees par fichier de donnees. Les plus anciennes +# sont supprimees pour que data/backups/ ne grossisse pas indefiniment. +BACKUPS_CONSERVES = 50 + +DONNEES_INITIALES = {"version": 2, "projects": []} + + +class Handler(SimpleHTTPRequestHandler): + """Fichiers statiques, plus deux routes pour le fichier de donnees.""" + + def __init__(self, *args, fichier_donnees: Path, **kwargs): + self.fichier_donnees = fichier_donnees + super().__init__(*args, directory=str(RACINE), **kwargs) + + # --- routes ----------------------------------------------------------- + + def do_GET(self): + if self.path.split("?")[0] == "/api/data": + self._lire_donnees() + return + super().do_GET() + + def do_PUT(self): + if self.path.split("?")[0] == "/api/data": + self._ecrire_donnees() + return + self._erreur(HTTPStatus.NOT_FOUND, "Route inconnue.") + + # --- implementation des routes ---------------------------------------- + + def _lire_donnees(self): + if not self.fichier_donnees.exists(): + # Premier lancement : on repond un planning vide sans rien ecrire + # sur le disque. Le fichier naitra a la premiere sauvegarde. + self._json(HTTPStatus.OK, DONNEES_INITIALES) + return + + try: + contenu = self.fichier_donnees.read_text(encoding="utf-8") + json.loads(contenu) # on refuse de servir un fichier corrompu + except json.JSONDecodeError as err: + self._erreur( + HTTPStatus.INTERNAL_SERVER_ERROR, + f"{self.fichier_donnees.name} n'est pas un JSON valide : {err}. " + "Le corriger a la main ou repartir d'une sauvegarde de data/backups/.", + ) + return + except OSError as err: + self._erreur(HTTPStatus.INTERNAL_SERVER_ERROR, f"Lecture impossible : {err}") + return + + self._envoyer(HTTPStatus.OK, contenu.encode("utf-8"), "application/json") + + def _ecrire_donnees(self): + try: + taille = int(self.headers.get("Content-Length", 0)) + except ValueError: + self._erreur(HTTPStatus.BAD_REQUEST, "En-tete Content-Length invalide.") + return + + if taille <= 0: + self._erreur(HTTPStatus.BAD_REQUEST, "Corps de requete vide.") + return + if taille > TAILLE_MAX: + self._erreur(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Corps de requete trop volumineux.") + return + + corps = self.rfile.read(taille) + + # On valide avant d'ecrire : mieux vaut refuser une sauvegarde que + # remplacer un planning correct par du JSON casse. + try: + donnees = json.loads(corps.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as err: + self._erreur(HTTPStatus.BAD_REQUEST, f"JSON invalide : {err}") + return + + if not isinstance(donnees, dict) or "projects" not in donnees: + self._erreur(HTTPStatus.BAD_REQUEST, "Le JSON doit etre un objet contenant 'projects'.") + return + + try: + self._sauvegarder_version_precedente() + self._ecrire_atomiquement(donnees) + except OSError as err: + self._erreur(HTTPStatus.INTERNAL_SERVER_ERROR, f"Ecriture impossible : {err}") + return + + self._json(HTTPStatus.OK, {"ok": True, "savedAt": _horodatage_iso()}) + + # --- ecriture sur disque ---------------------------------------------- + + def _sauvegarder_version_precedente(self): + """Copie le fichier actuel dans data/backups/ avant de l'ecraser.""" + if not self.fichier_donnees.exists(): + return + + dossier = self.fichier_donnees.parent / "backups" + dossier.mkdir(parents=True, exist_ok=True) + + marqueur = datetime.now().strftime("%Y%m%d-%H%M%S") + cible = dossier / f"{self.fichier_donnees.stem}-{marqueur}.json" + + # Deux sauvegardes dans la meme seconde : on suffixe pour ne pas + # ecraser la precedente. + compteur = 1 + while cible.exists(): + cible = dossier / f"{self.fichier_donnees.stem}-{marqueur}-{compteur}.json" + compteur += 1 + + shutil.copy2(self.fichier_donnees, cible) + _elaguer_backups(dossier, self.fichier_donnees.stem) + + def _ecrire_atomiquement(self, donnees): + """Ecrit dans un fichier temporaire puis remplace, pour qu'une coupure + en cours d'ecriture ne laisse jamais un planning tronque.""" + self.fichier_donnees.parent.mkdir(parents=True, exist_ok=True) + temporaire = self.fichier_donnees.with_suffix(".json.tmp") + texte = json.dumps(donnees, indent=2, ensure_ascii=False) + "\n" + temporaire.write_text(texte, encoding="utf-8") + temporaire.replace(self.fichier_donnees) + + # --- helpers de reponse ----------------------------------------------- + + def _json(self, statut, charge): + self._envoyer(statut, json.dumps(charge, ensure_ascii=False).encode("utf-8"), + "application/json") + + def _erreur(self, statut, message): + self._json(statut, {"error": message}) + + def _envoyer(self, statut, corps, type_contenu): + self.send_response(statut) + self.send_header("Content-Type", f"{type_contenu}; charset=utf-8") + self.send_header("Content-Length", str(len(corps))) + self.end_headers() # ajoute Cache-Control: no-store + self.wfile.write(corps) + + def end_headers(self): + # Aucun cache, sur aucune route, et emis ici seulement pour n'avoir + # qu'un seul endroit responsable de cet en-tete. + # + # Filtrer sur l'extension ne suffirait pas : la page d'accueil est + # servie sur « / », donc elle passerait au travers et Chromium + # resservirait un index.html perime apres chaque modification. + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def log_message(self, format, *args): + # Une ligne par requete suffit ; on retire l'adresse, toujours locale. + sys.stderr.write(f" {format % args}\n") + + +def _elaguer_backups(dossier: Path, prefixe: str): + fichiers = sorted(dossier.glob(f"{prefixe}-*.json")) + for vieux in fichiers[:-BACKUPS_CONSERVES]: + vieux.unlink(missing_ok=True) + + +def _horodatage_iso(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def main(): + analyseur = argparse.ArgumentParser(description="Serveur local de la frise multi-projets.") + analyseur.add_argument("--port", type=int, default=8000, help="port d'ecoute (defaut : 8000)") + analyseur.add_argument("--data", default="data/projets.json", + help="fichier de donnees (defaut : data/projets.json)") + arguments = analyseur.parse_args() + + fichier = Path(arguments.data) + if not fichier.is_absolute(): + fichier = RACINE / fichier + fichier = fichier.resolve() + + if RACINE not in fichier.parents: + analyseur.error("--data doit designer un fichier situe dans le dossier du projet.") + + handler = partial(Handler, fichier_donnees=fichier) + + try: + serveur = ThreadingHTTPServer(("127.0.0.1", arguments.port), handler) + except OSError as err: + print(f"Impossible d'ecouter sur le port {arguments.port} : {err}", file=sys.stderr) + print("Un autre serveur tourne peut-etre deja. Essayer --port 8001.", file=sys.stderr) + raise SystemExit(1) + + print(f"Frise multi-projets -> http://localhost:{arguments.port}") + print(f"Donnees : {fichier.relative_to(RACINE)}") + print("Ctrl+C pour arreter.\n") + + try: + serveur.serve_forever() + except KeyboardInterrupt: + print("\nArret.") + serveur.shutdown() + + +if __name__ == "__main__": + main() diff --git a/tests/model.test.js b/tests/model.test.js new file mode 100644 index 0000000..08ec322 --- /dev/null +++ b/tests/model.test.js @@ -0,0 +1,521 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + ErreurValidation, + ajouterJours, + ajouterPhase, + bornesPlanning, + bornesProjet, + bornesReference, + creerPhase, + creerProjet, + dateValide, + deplacerPhase, + dimancheDeLaSemaine, + deriveProjet, + duree, + ecartJours, + fabriquerId, + figerReference, + formulerDerive, + libererReference, + lundiDeLaSemaine, + modifierPhase, + planningVide, + pourEcriture, + redimensionnerPhase, + remplacerPhase, + semaineISO, + validerPlanning, +} from '../js/model.js'; + +// --- helpers de fabrication ------------------------------------------------- + +function phase(champs = {}) { + return { + id: 'cadrage', + name: 'Cadrage', + start: '2026-08-01', + end: '2026-08-20', + status: 'todo', + milestone: false, + notes: '', + ...champs, + }; +} + +function projet(champs = {}) { + return { + id: 'site-web', + name: 'Site web', + color: '#3b82f6', + collapsed: false, + hidden: false, + phases: [], + ...champs, + }; +} + +// --- dates ------------------------------------------------------------------ + +describe('dates', () => { + test('accepte une date réelle et rejette les formes invalides', () => { + assert.ok(dateValide('2026-08-01')); + assert.ok(dateValide('2024-02-29'), 'année bissextile'); + + assert.ok(!dateValide('2026-02-30'), 'jour inexistant'); + assert.ok(!dateValide('2025-02-29'), 'année non bissextile'); + assert.ok(!dateValide('2026-13-01'), 'mois inexistant'); + assert.ok(!dateValide('2026-8-1'), 'sans zéro de remplissage'); + assert.ok(!dateValide('01/08/2026')); + assert.ok(!dateValide('')); + assert.ok(!dateValide(null)); + assert.ok(!dateValide(20260801)); + }); + + test('ajouterJours traverse les mois, les années et les bissextiles', () => { + assert.equal(ajouterJours('2026-08-01', 30), '2026-08-31'); + assert.equal(ajouterJours('2026-12-31', 1), '2027-01-01'); + assert.equal(ajouterJours('2026-01-01', -1), '2025-12-31'); + assert.equal(ajouterJours('2024-02-28', 1), '2024-02-29'); + assert.equal(ajouterJours('2025-02-28', 1), '2025-03-01'); + assert.equal(ajouterJours('2026-08-01', 0), '2026-08-01'); + }); + + test("ecartJours est signé et vaut zéro entre deux dates égales", () => { + assert.equal(ecartJours('2026-08-01', '2026-08-20'), 19); + assert.equal(ecartJours('2026-08-20', '2026-08-01'), -19); + assert.equal(ecartJours('2026-08-01', '2026-08-01'), 0); + }); + + test('la durée inclut la date de fin', () => { + assert.equal(duree(phase({ start: '2026-08-01', end: '2026-08-01' })), 1); + assert.equal(duree(phase({ start: '2026-08-01', end: '2026-08-20' })), 20); + }); + + test('semaineISO suit la règle du jeudi, y compris aux changements d année', () => { + // 2026-01-01 est un jeudi : il ouvre donc la semaine 1 de 2026. + assert.deepEqual(semaineISO('2026-01-01'), { annee: 2026, numero: 1 }); + // Le lundi qui précède appartient à la même semaine ISO, en 2026. + assert.deepEqual(semaineISO('2025-12-29'), { annee: 2026, numero: 1 }); + // 2027-01-01 est un vendredi : sa semaine contient le jeudi 31/12/2026, + // elle appartient donc encore à 2026. + assert.deepEqual(semaineISO('2027-01-01'), { annee: 2026, numero: 53 }); + + assert.deepEqual(semaineISO('2026-08-03'), { annee: 2026, numero: 32 }); + assert.deepEqual(semaineISO('2026-08-09'), { annee: 2026, numero: 32 }, 'le dimanche clôt la semaine'); + assert.deepEqual(semaineISO('2026-08-10'), { annee: 2026, numero: 33 }); + }); + + test('dimancheDeLaSemaine clôt la semaine du lundi correspondant', () => { + assert.equal(dimancheDeLaSemaine('2026-08-05'), '2026-08-09'); + assert.equal(dimancheDeLaSemaine('2026-08-03'), '2026-08-09', 'depuis le lundi'); + assert.equal(dimancheDeLaSemaine('2026-08-09'), '2026-08-09', 'depuis le dimanche'); + }); + + test('lundiDeLaSemaine recule jusqu au lundi, dimanche compris', () => { + // 2026-08-05 est un mercredi, 2026-08-03 le lundi de sa semaine. + assert.equal(lundiDeLaSemaine('2026-08-05'), '2026-08-03'); + assert.equal(lundiDeLaSemaine('2026-08-03'), '2026-08-03', 'un lundi ne bouge pas'); + // 2026-08-09 est un dimanche : il appartient à la semaine du 3, pas du 10. + assert.equal(lundiDeLaSemaine('2026-08-09'), '2026-08-03'); + }); + + test('les calculs de dates ne dérivent pas selon le fuseau horaire', () => { + // Ce test attrape la confusion classique entre new Date("...") en UTC et + // new Date(a, m, j) en heure locale, qui décale d'un jour. + for (const tz of ['UTC', 'Pacific/Kiritimati', 'Pacific/Niue']) { + process.env.TZ = tz; + assert.equal(ajouterJours('2026-08-01', 1), '2026-08-02', tz); + assert.equal(lundiDeLaSemaine('2026-08-05'), '2026-08-03', tz); + assert.equal(ecartJours('2026-08-01', '2026-08-02'), 1, tz); + } + delete process.env.TZ; + }); +}); + +// --- identifiants ----------------------------------------------------------- + +describe('fabriquerId', () => { + test('translittère accents, majuscules et ponctuation', () => { + assert.equal(fabriquerId('Refonte du site web'), 'refonte-du-site-web'); + assert.equal(fabriquerId('Étude préalable'), 'etude-prealable'); + assert.equal(fabriquerId('Phase 1 : cadrage !'), 'phase-1-cadrage'); + assert.equal(fabriquerId(' espaces '), 'espaces'); + }); + + test('se rabat sur un identifiant lisible quand rien ne subsiste', () => { + assert.equal(fabriquerId('!!!'), 'sans-nom'); + assert.equal(fabriquerId(''), 'sans-nom'); + }); + + test('suffixe en cas de collision, sans jamais réutiliser un identifiant pris', () => { + assert.equal(fabriquerId('Cadrage', []), 'cadrage'); + assert.equal(fabriquerId('Cadrage', ['cadrage']), 'cadrage-2'); + assert.equal(fabriquerId('Cadrage', ['cadrage', 'cadrage-2']), 'cadrage-3'); + // Le trou dans la suite ne doit pas produire un doublon. + assert.equal(fabriquerId('Cadrage', ['cadrage', 'cadrage-3']), 'cadrage-2'); + }); +}); + +// --- validation ------------------------------------------------------------- + +describe('validerPlanning', () => { + test('accepte un planning correct et le normalise', () => { + const resultat = validerPlanning({ + version: 2, + projects: [projet({ phases: [phase()] })], + }); + + assert.equal(resultat.version, 2); + assert.equal(resultat.projects.length, 1); + assert.equal(resultat.projects[0].phases[0].id, 'cadrage'); + }); + + test('accepte un planning vide', () => { + assert.deepEqual(validerPlanning({ version: 2, projects: [] }), planningVide()); + }); + + test('trie les phases par date de début', () => { + const resultat = validerPlanning({ + version: 2, + projects: [ + projet({ + phases: [ + phase({ id: 'dev', name: 'Dev', start: '2026-10-01', end: '2026-11-30' }), + phase({ id: 'cadrage', name: 'Cadrage', start: '2026-08-01', end: '2026-08-20' }), + ], + }), + ], + }); + + assert.deepEqual( + resultat.projects[0].phases.map((p) => p.id), + ['cadrage', 'dev'] + ); + }); + + test('rejette une fin antérieure au début, en nommant le fautif', () => { + assert.throws( + () => + validerPlanning({ + projects: [projet({ phases: [phase({ start: '2026-08-20', end: '2026-08-01' })] })], + }), + (err) => err instanceof ErreurValidation && /Cadrage/.test(err.message) + ); + }); + + test('rejette un jalon qui ne tient pas sur un seul jour', () => { + assert.throws( + () => + validerPlanning({ + projects: [ + projet({ + phases: [phase({ milestone: true, start: '2026-08-01', end: '2026-08-20' })], + }), + ], + }), + ErreurValidation + ); + }); + + test('accepte un jalon correct', () => { + const resultat = validerPlanning({ + projects: [ + projet({ phases: [phase({ milestone: true, start: '2026-11-02', end: '2026-11-02' })] }), + ], + }); + assert.equal(resultat.projects[0].phases[0].milestone, true); + }); + + test('rejette les identifiants en double', () => { + assert.throws( + () => validerPlanning({ projects: [projet(), projet()] }), + (err) => /identifiant/.test(err.message) + ); + + assert.throws( + () => + validerPlanning({ + projects: [projet({ phases: [phase(), phase({ name: 'Autre' })] })], + }), + (err) => /identifiant/.test(err.message) + ); + }); + + test('rejette une structure de fichier absente ou aberrante', () => { + assert.throws(() => validerPlanning(null), ErreurValidation); + assert.throws(() => validerPlanning({}), ErreurValidation); + assert.throws(() => validerPlanning({ projects: 'oui' }), ErreurValidation); + assert.throws(() => validerPlanning({ projects: [projet({ phases: null })] }), ErreurValidation); + }); + + test('refuse un fichier écrit par une version plus récente', () => { + assert.throws( + () => validerPlanning({ version: 99, projects: [] }), + (err) => /version 99/.test(err.message) + ); + }); + + test('ramène un statut inconnu à « à venir » sans bloquer le chargement', () => { + const resultat = validerPlanning({ + projects: [projet({ phases: [phase({ status: 'n-importe-quoi' })] })], + }); + assert.equal(resultat.projects[0].phases[0].status, 'todo'); + }); + + test('remplace une couleur invalide par la couleur par défaut', () => { + const resultat = validerPlanning({ projects: [projet({ color: 'bleu' })] }); + assert.equal(resultat.projects[0].color, '#3b82f6'); + }); + + test('rejette une référence dont les dates sont invalides', () => { + assert.throws( + () => + validerPlanning({ + projects: [ + projet({ phases: [phase({ baseline: { start: '2026-13-01', end: '2026-08-10' } })] }), + ], + }), + ErreurValidation + ); + }); +}); + +// --- bornes ----------------------------------------------------------------- + +describe('bornes', () => { + const troisPhases = projet({ + phases: [ + phase({ id: 'a', name: 'A', start: '2026-08-01', end: '2026-08-20' }), + phase({ id: 'b', name: 'B', start: '2026-09-01', end: '2026-12-15' }), + phase({ id: 'c', name: 'C', start: '2026-08-15', end: '2026-09-30' }), + ], + }); + + test('couvrent de la première à la dernière phase, quel que soit leur ordre', () => { + assert.deepEqual(bornesProjet(troisPhases), { start: '2026-08-01', end: '2026-12-15' }); + }); + + test('valent null pour un projet sans phase', () => { + assert.equal(bornesProjet(projet()), null); + }); + + test('le planning agrège les projets visibles et ignore les masqués', () => { + const planning = { + version: 2, + projects: [ + projet({ id: 'a', phases: [phase({ start: '2026-08-01', end: '2026-08-20' })] }), + projet({ id: 'b', phases: [phase({ start: '2026-01-01', end: '2026-02-01' })] }), + ], + }; + assert.deepEqual(bornesPlanning(planning), { start: '2026-01-01', end: '2026-08-20' }); + + planning.projects[1].hidden = true; + assert.deepEqual(bornesPlanning(planning), { start: '2026-08-01', end: '2026-08-20' }); + }); + + test('le planning vaut null quand rien n est visible', () => { + assert.equal(bornesPlanning(planningVide()), null); + assert.equal(bornesPlanning({ projects: [projet({ hidden: true })] }), null); + }); +}); + +// --- référence et dérive ---------------------------------------------------- + +describe('référence et dérive', () => { + const base = projet({ + phases: [ + phase({ id: 'cadrage', name: 'Cadrage', start: '2026-08-01', end: '2026-08-20' }), + phase({ id: 'dev', name: 'Dev', start: '2026-09-01', end: '2026-10-30' }), + ], + }); + + test('figer copie les dates courantes et horodate', () => { + const fige = figerReference(base, '2026-06-12'); + assert.equal(fige.baselineDate, '2026-06-12'); + assert.deepEqual(fige.phases[0].baseline, { start: '2026-08-01', end: '2026-08-20' }); + assert.deepEqual(fige.phases[1].baseline, { start: '2026-09-01', end: '2026-10-30' }); + }); + + test('la dérive est nulle juste après le figeage', () => { + assert.equal(deriveProjet(figerReference(base, '2026-06-12')), 0); + }); + + test('la dérive vaut null tant que rien n est figé', () => { + assert.equal(deriveProjet(base), null); + }); + + test('décaler la dernière phase produit une dérive positive', () => { + const fige = figerReference(base, '2026-06-12'); + const glisse = remplacerPhase(fige, 'dev', (p) => deplacerPhase(p, '2026-09-22')); + assert.equal(deriveProjet(glisse), 21); + }); + + test('avancer la dernière phase produit une dérive négative', () => { + const fige = figerReference(base, '2026-06-12'); + const avance = remplacerPhase(fige, 'dev', (p) => deplacerPhase(p, '2026-08-25')); + assert.equal(deriveProjet(avance), -7); + }); + + test('décaler une phase qui n est pas la dernière ne dérive pas', () => { + const fige = figerReference(base, '2026-06-12'); + const glisse = remplacerPhase(fige, 'cadrage', (p) => deplacerPhase(p, '2026-08-08')); + assert.equal(deriveProjet(glisse), 0); + }); + + test('une phase ajoutée après le figeage compte dans la dérive', () => { + // Elle n'a pas de référence, donc pas d'effet sur la fin de référence, + // mais elle repousse la fin actuelle : c'est bien un glissement. + const fige = figerReference(base, '2026-06-12'); + const augmente = ajouterPhase( + fige, + creerPhase(fige, 'Recette', '2026-11-01', '2026-11-30') + ); + assert.equal(deriveProjet(augmente), 31); + }); + + test('les bornes de référence ignorent les phases sans référence', () => { + const fige = figerReference(base, '2026-06-12'); + const augmente = ajouterPhase(fige, creerPhase(fige, 'Recette', '2026-11-01', '2026-11-30')); + assert.deepEqual(bornesReference(augmente), { start: '2026-08-01', end: '2026-10-30' }); + assert.equal(bornesReference(base), null); + }); + + test('libérer retire la référence partout', () => { + const libere = libererReference(figerReference(base, '2026-06-12')); + assert.equal(deriveProjet(libere), null); + assert.ok(libere.phases.every((p) => p.baseline === undefined)); + // pourEcriture doit faire disparaître baselineDate du fichier. + const ecrit = pourEcriture({ projects: [libere] }); + assert.ok(!('baselineDate' in ecrit.projects[0])); + }); + + test('refiger après un arbitrage remet la dérive à zéro', () => { + const fige = figerReference(base, '2026-06-12'); + const glisse = remplacerPhase(fige, 'dev', (p) => deplacerPhase(p, '2026-09-22')); + assert.equal(deriveProjet(figerReference(glisse, '2026-07-01')), 0); + }); + + test('formulerDerive arrondit en semaines et signe le résultat', () => { + assert.equal(formulerDerive(null), null); + assert.equal(formulerDerive(0), null); + assert.equal(formulerDerive(21), '+3 sem'); + assert.equal(formulerDerive(-14), '−2 sem'); + // Sous la demi-semaine on annonce les jours, pour éviter un « 0 semaine ». + assert.equal(formulerDerive(2), '+2 j'); + assert.equal(formulerDerive(-3), '−3 j'); + }); +}); + +// --- modifications ---------------------------------------------------------- + +describe('modifications', () => { + test('déplacer conserve la durée', () => { + const depart = phase({ start: '2026-08-01', end: '2026-08-20' }); + const arrivee = deplacerPhase(depart, '2026-09-01'); + assert.equal(arrivee.start, '2026-09-01'); + assert.equal(arrivee.end, '2026-09-20'); + assert.equal(duree(arrivee), duree(depart)); + }); + + test('déplacer un jalon le laisse sur un seul jour', () => { + const jalon = phase({ milestone: true, start: '2026-11-02', end: '2026-11-02' }); + const deplace = deplacerPhase(jalon, '2026-12-01'); + assert.equal(deplace.start, '2026-12-01'); + assert.equal(deplace.end, '2026-12-01'); + }); + + test('redimensionner change une seule borne', () => { + const depart = phase({ start: '2026-08-01', end: '2026-08-20' }); + assert.equal(redimensionnerPhase(depart, 'debut', '2026-07-15').start, '2026-07-15'); + assert.equal(redimensionnerPhase(depart, 'debut', '2026-07-15').end, '2026-08-20'); + assert.equal(redimensionnerPhase(depart, 'fin', '2026-09-30').end, '2026-09-30'); + assert.equal(redimensionnerPhase(depart, 'fin', '2026-09-30').start, '2026-08-01'); + }); + + test('une phase ne peut pas se retourner en la redimensionnant', () => { + const depart = phase({ start: '2026-08-01', end: '2026-08-20' }); + assert.equal(redimensionnerPhase(depart, 'debut', '2026-09-30').start, '2026-08-20'); + assert.equal(redimensionnerPhase(depart, 'fin', '2026-07-01').end, '2026-08-01'); + }); + + test('un jalon ne se redimensionne pas', () => { + const jalon = phase({ milestone: true, start: '2026-11-02', end: '2026-11-02' }); + assert.deepEqual(redimensionnerPhase(jalon, 'fin', '2026-12-01'), jalon); + }); + + test('cocher « jalon » ramène la fin sur le début', () => { + const modifiee = modifierPhase(phase({ start: '2026-08-01', end: '2026-08-20' }), { + milestone: true, + }); + assert.equal(modifiee.end, '2026-08-01'); + }); + + test('saisir une fin antérieure au début conserve la durée précédente', () => { + const depart = phase({ start: '2026-08-01', end: '2026-08-20' }); // 20 jours + const modifiee = modifierPhase(depart, { start: '2026-09-01', end: '2026-08-05' }); + assert.equal(modifiee.start, '2026-09-01'); + assert.equal(modifiee.end, '2026-09-20'); + }); + + test('creerPhase engendre un identifiant unique dans le projet', () => { + let p = projet(); + p = ajouterPhase(p, creerPhase(p, 'Cadrage', '2026-08-01', '2026-08-20')); + p = ajouterPhase(p, creerPhase(p, 'Cadrage', '2026-09-01', '2026-09-20')); + assert.deepEqual(p.phases.map((x) => x.id), ['cadrage', 'cadrage-2']); + }); + + test('creerPhase force un jalon sur un seul jour', () => { + const jalon = creerPhase(projet(), 'Livraison', '2026-11-02', '2026-12-31', { + milestone: true, + }); + assert.equal(jalon.end, '2026-11-02'); + }); + + test('creerProjet engendre un identifiant unique et parcourt la palette', () => { + let planning = planningVide(); + const premier = creerProjet(planning, 'Site web'); + planning = { ...planning, projects: [premier] }; + const second = creerProjet(planning, 'Site web'); + + assert.equal(premier.id, 'site-web'); + assert.equal(second.id, 'site-web-2'); + assert.notEqual(premier.color, second.color); + }); + + test('remplacerPhase retrie quand un déplacement change l ordre', () => { + const p = projet({ + phases: [ + phase({ id: 'a', name: 'A', start: '2026-08-01', end: '2026-08-20' }), + phase({ id: 'b', name: 'B', start: '2026-09-01', end: '2026-09-20' }), + ], + }); + const apres = remplacerPhase(p, 'a', (x) => deplacerPhase(x, '2026-10-01')); + assert.deepEqual(apres.phases.map((x) => x.id), ['b', 'a']); + }); + + test('un planning modifié survit à un aller-retour par la validation', () => { + // Garantit que ce qu'on écrit sur le disque est relisible sans perte. + let planning = planningVide(); + const p = creerProjet(planning, 'Site web'); + planning = { ...planning, projects: [p] }; + planning = { + ...planning, + projects: [ + figerReference( + ajouterPhase(p, creerPhase(p, 'Cadrage', '2026-08-01', '2026-08-20')), + '2026-06-12' + ), + ], + }; + + const relu = validerPlanning(pourEcriture(planning)); + assert.deepEqual(relu, validerPlanning(pourEcriture(relu))); + assert.deepEqual(relu.projects[0].phases[0].baseline, { + start: '2026-08-01', + end: '2026-08-20', + }); + }); +});