Repousser un projet en bloc, sauf ce dont on ne décide pas

Glisser la barre résumé décale toutes les phases du même nombre de jours,
écarts conservés : un projet engagé glisse souvent, et le faire phase par
phase les déformait. Restent en place ce qui est terminé — on ne réécrit
pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour
ce qui est délégué ou tenu du dehors.

D'où le format en version 6 : sans l'incrément, un binaire antérieur
effacerait ces dates imposées à la première sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 18:07:31 +02:00
parent 55128fd0d4
commit 4a513f9a59
15 changed files with 788 additions and 74 deletions

View File

@@ -93,9 +93,9 @@ survol de la ligne, sur son bord droit :
déclarer terminé, écarter, figer la référence, supprimer. Un clic droit sur le libellé ouvre le déclarer terminé, écarter, figer la référence, supprimer. Un clic droit sur le libellé ouvre le
même menu. même menu.
Il n'y a pas de commande séparée pour créer un **jalon** : c'est une phase dont on coche la case Il n'y a pas de commande séparée pour créer un **jalon** ni pour marquer une **date imposée** : ce
« Jalon » dans le panneau, ce qui ramène sa fin sur son début. La bascule joue dans les deux sens sont deux cases du panneau de la phase, indépendantes l'une de l'autre comme du statut. Cocher
à tout moment. « Jalon » ramène la fin sur le début. Les deux bascules jouent dans les deux sens à tout moment.
La colonne se **redimensionne** en glissant la ligne qui la sépare de la frise, en haut à La colonne se **redimensionne** en glissant la ligne qui la sépare de la frise, en haut à
gauche : un double-clic revient à la largeur d'origine. Cette largeur est propre au navigateur, gauche : un double-clic revient à la largeur d'origine. Cette largeur est propre au navigateur,
@@ -106,14 +106,15 @@ Une fois un projet en place :
| Geste | Effet | | Geste | Effet |
|---|---| |---|---|
| Glisser le corps d'une barre | Décale la phase, durée conservée | | Glisser le corps d'une barre | Décale la phase, durée conservée |
| Glisser la barre résumé d'un projet | Repousse le projet entier, écarts conservés |
| Glisser la barre pâle d'un envisagé | Décale son horizon, au mois | | Glisser la barre pâle d'un envisagé | Décale son horizon, au mois |
| Glisser le bord d'une barre | Change la date de début ou de fin | | Glisser le bord d'une barre | Change la date de début ou de fin |
| Glisser le bord gauche du panneau | Change sa largeur (double-clic : d'origine) | | Glisser le bord gauche du panneau | Change sa largeur (double-clic : d'origine) |
| Clic sur une barre ou son nom | Ouvre le panneau de détail | | Clic sur une barre ou son nom | Ouvre le panneau de détail |
| Clic sur une autre barre, panneau ouvert | Bascule le panneau sur cette phase | | Clic sur une autre barre, panneau ouvert | Bascule le panneau sur cette phase |
| Clic sur un nom de phase hors écran | Ramène la frise sur elle | | Clic sur un nom de phase hors écran | Ramène la frise sur elle |
| `←` `→` | Décale d'un jour la phase du curseur | | `←` `→` | Décale d'un jour la phase du curseur — ou tout le projet, sur sa ligne |
| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour | | `Maj` + `←` `→` | Allonge ou raccourcit la phase d'un jour |
| `Échap` | Annule le glisser en cours, ou ferme le panneau | | `Échap` | Annule le glisser en cours, ou ferme le panneau |
| Clic dans une zone vide | Ferme le panneau ouvert | | Clic dans une zone vide | Ferme le panneau ouvert |
| `Maj` + molette | Fait défiler la frise horizontalement | | `Maj` + molette | Fait défiler la frise horizontalement |
@@ -122,6 +123,33 @@ Une fois un projet en place :
Faire défiler jusqu'au bord droit élargit la frise vers le futur, et jusqu'au bord gauche 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. vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
### Repousser un projet en bloc
Un projet engagé glisse dans le temps — c'est même ce qui arrive le plus souvent. Plutôt que de
reprendre ses phases une à une, on **glisse sa barre résumé** : le même nombre de jours s'applique à
toutes, jalons compris, et les écarts entre elles sont conservés au jour près. Le projet est
repoussé, il n'est pas replanifié. Les flèches `←` `→` font la même chose au jour près, curseur posé
sur la ligne du projet.
**Ce qui est terminé ne bouge pas.** Les phases finies restent où elles sont : décaler un projet,
c'est reconnaître qu'il prendra plus de temps, pas réécrire ce qu'on a déjà vécu. Sur un projet à
moitié fait, la barre s'étire donc au lieu de se déplacer — son bord gauche tient et le reste s'en
va. Un projet dont tout est terminé n'offre aucune prise, et son infobulle le dit.
**Ce dont on ne décide pas non plus.** Une phase peut porter une **date imposée** : elle est
déléguée à quelqu'un d'autre, ou tenue par un contrat, un créneau, une échéance réglementaire. La
case se coche dans son panneau, sous « Jalon », et la barre reçoit alors deux petits taquets à ses
extrémités — un cercle pour un jalon. Repousser le projet ne la repousse pas : si tout le reste
glisse, c'est le créneau qu'on rate, et le chevauchement qui apparaît est justement ce qu'on voulait
voir. Elle reste glissable seule, elle : « imposée » veut dire *indépendante du projet*, pas
*immuable*.
À ne pas confondre avec le statut **bloqué**, son voisin dans le panneau : bloqué dit que ça
n'avance pas, date imposée que l'échéance ne vous appartient pas. Une phase peut être les deux.
Le **planning de référence ne suit pas** : repousser de trois semaines affiche aussitôt une dérive
de trois semaines. C'est voulu — le geste inscrit ce qu'il coûte.
### Le cycle de vie d'un projet ### Le cycle de vie d'un projet
On a presque toujours plus de projets imaginables que de temps pour les mener, et l'enjeu n'est pas On a presque toujours plus de projets imaginables que de temps pour les mener, et l'enjeu n'est pas
@@ -315,7 +343,9 @@ pour les deux vues et n'est pas enregistré.
## Ce que fait l'outil ## Ce que fait l'outil
- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin, - **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin,
un statut, des notes. Une phase peut être un **jalon** (une date unique, rendue en losange). un statut, des notes. Une phase peut être un **jalon** (une date unique, rendue en losange), et
porter une **date imposée** quand elle est déléguée ou tenue du dehors — le décalage du projet ne
l'emporte alors pas.
- **Un cycle de vie** — envisagé, engagé, terminé, écarté — dont deux états se déduisent des - **Un cycle de vie** — envisagé, engagé, terminé, écarté — dont deux états se déduisent des
données. Ce qu'on envisage sans pouvoir encore le mener vit sur la même frise que le reste, et données. Ce qu'on envisage sans pouvoir encore le mener vit sur la même frise que le reste, et
ne s'y perd pas. ne s'y perd pas.
@@ -325,8 +355,8 @@ pour les deux vues et n'est pas enregistré.
liens. Elles occupent toute la hauteur restante du panneau, et se cochent d'un clic dans l'aperçu. liens. Elles occupent toute la hauteur restante du panneau, et se cochent d'un clic dans l'aperçu.
- **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil. - **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil.
- **Une barre cumulative par projet**, segmentée en teintes selon le statut de chaque phase : la - **Une barre cumulative par projet**, segmentée en teintes selon le statut de chaque phase : la
forme d'ensemble, toujours visible sur la ligne du projet et toujours rendue pareil. Elle ne se forme d'ensemble, toujours visible sur la ligne du projet et toujours rendue pareil. Elle se
glisse pas. glisse pour repousser le projet entier.
- **Couloirs pliables** : déplié, chaque phase occupe sa ligne sous la barre cumulative ; plié, il - **Couloirs pliables** : déplié, chaque phase occupe sa ligne sous la barre cumulative ; plié, il
ne reste que celle-ci. ne reste que celle-ci.
- **Manipulation directe** : glisser une barre pour la décaler, tirer ses bords pour changer sa - **Manipulation directe** : glisser une barre pour la décaler, tirer ses bords pour changer sa
@@ -424,7 +454,7 @@ données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node
| `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli, curseur | | `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli, curseur |
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes de phase et de projet envisagé, ancrage sur le mois courant | | `js/mois.js` | Rendu de la vue par mois : groupes, lignes de phase et de projet envisagé, ancrage sur le mois courant |
| `js/tags.js` | Pastilles de tags, partagées par les deux vues | | `js/tags.js` | Pastilles de tags, partagées par les deux vues |
| `js/drag.js` | Glisser et redimensionner les barres — au lundi pour une phase, au mois pour un horizon | | `js/drag.js` | Glisser et redimensionner les barres — au lundi pour une phase ou un projet, au mois pour un horizon |
| `js/clavier.js` | Table des raccourcis à la vim, préfixes `g` `z` `d`, garde-fou de saisie | | `js/clavier.js` | Table des raccourcis à la vim, préfixes `g` `z` `d`, garde-fou de saisie |
| `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node | | `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node |
| `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux | | `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux |

View File

@@ -829,6 +829,24 @@ button[aria-pressed="true"] {
); );
} }
/* Une phase dont la date ne nous appartient pas — déléguée, contractuelle,
réglementaire (docs/decisions.md, section 28).
Deux **taquets** verticaux à ses extrémités, comme les butées d'une pièce
qu'on ne peut plus faire coulisser. La marque se cumule avec les quatre
textures de statut au lieu de leur disputer la place : c'est bien une phase à
venir, en cours ou bloquée, qui se trouve en plus tenue par le dehors.
En `inset`, donc sans toucher à la géométrie : la barre garde exactement la
largeur de ses dates, et l'ombre portée reste déclarée à côté — un
`box-shadow` ne se cumule pas d'une règle à l'autre, il se remplace. */
.barre--imposee {
box-shadow:
var(--ombre),
inset 3px 0 0 var(--texte),
inset -3px 0 0 var(--texte);
}
/* Poignées de redimensionnement, révélées au survol de la barre. */ /* Poignées de redimensionnement, révélées au survol de la barre. */
.poignee { .poignee {
position: absolute; position: absolute;
@@ -884,6 +902,15 @@ button[aria-pressed="true"] {
background: var(--danger); background: var(--danger);
} }
/* Un losange n'a pas d'extrémités où poser les taquets d'une barre imposée : il
se cercle. Déclarée après `.jalon`, dont elle partage la spécificité — c'est
l'ordre qui la fait gagner, un `box-shadow` se remplaçant en bloc. */
.jalon--impose {
box-shadow:
var(--ombre),
inset 0 0 0 2px var(--texte);
}
.jalon--selectionne { .jalon--selectionne {
outline: 2px solid var(--texte); outline: 2px solid var(--texte);
outline-offset: 1px; outline-offset: 1px;
@@ -907,8 +934,8 @@ button[aria-pressed="true"] {
déplié, et rendue à l'identique dans les deux cas — c'est le même objet, il déplié, et rendue à l'identique dans les deux cas — c'est le même objet, il
n'a pas à changer d'aspect quand on ouvre le couloir. n'a pas à changer d'aspect quand on ouvre le couloir.
Volontairement plus mince qu'une barre de phase : elle voisine avec elles Volontairement plus mince qu'une barre de phase : elle voisine avec elles
dès que le projet est déplié, et ne se glisse pas. dès que le projet est déplié.
Lecture seule : voir docs/decisions.md, section 8. */ Elle se glisse pour repousser le projet : voir docs/decisions.md, section 8. */
.barre-cumulative { .barre-cumulative {
position: absolute; position: absolute;
top: calc((var(--hauteur-ligne) - var(--hauteur-cumulative)) / 2); top: calc((var(--hauteur-ligne) - var(--hauteur-cumulative)) / 2);
@@ -921,6 +948,25 @@ button[aria-pressed="true"] {
z-index: 2; z-index: 2;
} }
/* La prise ne s'annonce que là où elle mène quelque part : sur un projet dont
tout est terminé, la barre n'a pas de rôle et garde le curseur par défaut —
rien ne bougerait. */
.barre-cumulative[data-role='cumul'] {
cursor: grab;
}
.barre-cumulative[data-role='cumul']:hover {
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
}
/* Mince comme elle est, une barre cumulative saisie se repère mal. L'ombre la
décolle du couloir le temps du geste, sans changer sa géométrie — ce qui
décalerait ce qu'on est en train de viser. */
.barre-cumulative.barre--glissee {
cursor: grabbing;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--couleur-projet) 60%, transparent);
}
.segment { .segment {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@@ -1,5 +1,5 @@
{ {
"version": 4, "version": 6,
"projects": [ "projects": [
{ {
"id": "site-web", "id": "site-web",
@@ -99,6 +99,16 @@
"milestone": false, "milestone": false,
"notes": "**Bloquée** : en attente de la fenêtre de maintenance côté hébergeur.\n\n- [x] demande déposée le 3 août\n- [ ] créneau confirmé\n" "notes": "**Bloquée** : en attente de la fenêtre de maintenance côté hébergeur.\n\n- [x] demande déposée le 3 août\n- [ ] créneau confirmé\n"
}, },
{
"id": "fenetre-hebergeur",
"name": "Fenêtre de maintenance",
"start": "2026-09-12",
"end": "2026-09-13",
"status": "todo",
"milestone": false,
"fixed": true,
"notes": "Créneau imposé par l'hébergeur : un seul week-end par trimestre.\n\nRepousser le projet ne la repousse pas — si le reste glisse, c'est ce créneau qu'on rate.\n"
},
{ {
"id": "bascule-generale", "id": "bascule-generale",
"name": "Bascule générale", "name": "Bascule générale",

View File

@@ -102,15 +102,62 @@ Gérer les jours ouvrés impose de faire passer chaque calcul de date par un hel
aussitôt la question des congés et des jours fériés — donc un calendrier à tenir à jour. Sur des 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. phases qui se comptent en semaines et en mois, l'écart est dans le bruit de l'estimation.
## 8. La barre cumulative est en lecture seule ## 8. La barre cumulative repousse le projet
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. On La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. Elle
aurait pu la rendre glissable pour décaler tout le projet en bloc. se glisse, et le projet entier se décale avec elle.
Écarté pour une raison de lisibilité du geste : la même barre représenterait tantôt une phase, tantôt **Ce n'était pas le cas au départ, et le motif du refus mérite d'être conservé** : la même barre
un projet entier, et un glissement de quelques pixels déplacerait alors plusieurs mois de travail représenterait tantôt une phase, tantôt un projet entier, et un glissement de quelques pixels
d'un coup, sans que l'utilisateur voie ce qui bouge. Elle sert à regarder ; pour modifier, on agit déplacerait alors plusieurs mois de travail d'un coup, sans qu'on voie ce qui bouge. L'argument était
sur les phases. juste ; c'est le premier usage réel de l'outil qui l'a renversé. Un projet engagé glisse dans le
temps — c'est même la chose qui arrive le plus souvent —, et le faire glisser phase par phase oblige
à répéter le même geste cinq fois en priant pour que les écarts se conservent. Or ils ne se
conservent pas : chaque phase s'accroche à son propre lundi, et un projet ainsi déplacé se
déforme un peu à chaque passage.
L'objection d'origine n'a pas été balayée, elle a été traitée :
- **Les barres de phase suivent le résumé pendant le geste.** On voit exactement ce qu'on déplace
pendant qu'on le déplace, y compris plié — où les segments colorés de la barre suffisent à le
montrer. C'était le cœur du reproche, et il tombe.
- **Le delta est calculé une fois, sur la première phase qui bouge, puis appliqué tel quel
partout.** Les écarts entre phases sont conservés au jour près : le projet est repoussé, il n'est
pas replanifié. C'est précisément ce que le geste phase par phase ne savait pas faire.
- **La référence figée ne suit pas.** Repousser de trois semaines affiche aussitôt une dérive de
trois semaines. Le geste ne cache pas ce qu'il coûte, il l'inscrit.
### Ce qui est fait est fait
Une phase `done` ne bouge pas. Décaler un projet, c'est reconnaître qu'il prendra plus de temps que
prévu, et emporter les phases terminées réécrirait un passé qu'on a vécu — en effaçant du même coup
la dérive qu'on cherche à lire.
Le geste ne « déplace » donc pas toujours : sur un projet à moitié fait, la barre s'**étire**, son
bord gauche tient et le reste s'en va. C'est assumé, et c'est même ce qu'on veut voir. Le résumé se
repose alors depuis le modèle à chaque mouvement de souris plutôt que d'être translaté, faute de quoi
l'aperçu et le résultat divergeraient.
Le critère est le **statut**, jamais la position dans le calendrier : une phase terminée en avance
reste où elle est, une phase `blocked` ou `doing` déjà commencée se décale avec le reste — c'est bien
elle qui glisse. Un projet dont toutes les phases sont closes n'a rien à repousser : sa barre perd
son `data-role`, garde le curseur par défaut, et le dit dans son infobulle plutôt que d'offrir une
prise qui ne mènerait nulle part.
Rien n'empêche un décalage vers l'arrière de faire chevaucher une phase avec une phase terminée. Il
n'y a pas de dépendances entre phases dans ce modèle (section 2) et poser ici une butée en
inventerait une par la bande.
### Pas de poignées
La barre cumulative se déplace, elle ne se redimensionne pas. Étirer un projet supposerait de dilater
chaque phase au prorata, avec des arrondis qui déforment les durées courtes et un résultat qu'on ne
peut pas prévoir avant de l'avoir vu. C'est un autre besoin, et il attendra de se présenter.
Le même geste existe au clavier : sur la ligne d'un projet, `←` et `→` le repoussent d'un jour, là où
elles ne faisaient rien. `Maj` n'y ajoute rien, pour la raison qui précède.
### Ce qui n'a pas changé
**Elle est affichée que le projet soit plié ou déplié.** Elle ne l'était d'abord qu'en mode plié, ce **Elle est affichée que le projet soit plié ou déplié.** Elle ne l'était d'abord qu'en mode plié, ce
qui obligeait à replier pour savoir où en était le projet entier — donc à perdre la vue qu'on était qui obligeait à replier pour savoir où en était le projet entier — donc à perdre la vue qu'on était
@@ -120,8 +167,10 @@ en train d'éditer.
plié, au motif qu'elle y était seule sur sa ligne. C'était une erreur : plier un couloir ne change plié, au motif qu'elle y était seule sur sa ligne. C'était une erreur : plier un couloir ne change
rien à ce que cette barre représente, et la voir changer d'aspect au pli laissait croire à deux rien à ce que cette barre représente, et la voir changer d'aspect au pli laissait croire à deux
objets différents. Elle garde donc partout la même hauteur, plus mince qu'une barre de phase — objets différents. Elle garde donc partout la même hauteur, plus mince qu'une barre de phase —
puisqu'elle voisine avec elles dès que le projet est déplié, et qu'elle ne se glisse pas. Le fantôme puisqu'elle voisine avec elles dès que le projet est déplié. Le fantôme de référence se cale dessous,
de référence se cale dessous, d'où sa variante `--cumulatif`. d'où sa variante `--cumulatif`. Étant mince, elle se repère mal une fois saisie : un anneau à sa
couleur la décolle du couloir le temps du geste, sans toucher à sa géométrie, ce qui décalerait ce
qu'on est en train de viser.
## 9. Accroche à la semaine ## 9. Accroche à la semaine
@@ -1052,3 +1101,78 @@ urgence en début de mandat.
**Un retour arrière déclaré**, d'engagé vers envisagé. On déplace les dates, cela suffit. Le seul **Un retour arrière déclaré**, d'engagé vers envisagé. On déplace les dates, cela suffit. Le seul
cas où il survient — retirer la dernière phase — se résout tout seul par la règle des bornes. cas où il survient — retirer la dernière phase — se résout tout seul par la règle des bornes.
## 28. Une date qu'on ne décide pas
Toutes les phases d'un planning ne sont pas nôtres. Certaines sont **déléguées** — un prestataire les
tient, on n'a que le résultat —, d'autres portent une **contrainte extérieure** : une fenêtre de
maintenance chez l'hébergeur, une échéance réglementaire, une date de salon. Une phase reçoit donc un
booléen `fixed`, et le décalage d'un projet (section 8) ne l'emporte pas.
Sans elle, le geste ajouté en section 8 était activement nuisible : repousser un projet de trois
semaines repoussait aussi l'audit du commissaire aux comptes, ce qui est faux et ce que rien ne
signalait.
### Pourquoi ce n'est pas un cinquième statut
C'était la formulation spontanée — « ajouter un état ». Elle a été écartée, parce que les quatre
statuts existants disent tous **l'avancement** (section 6), et qu'une date imposée n'est pas une
étape du travail : une phase déléguée est tour à tour à venir, en cours, terminée, et elle reste
déléguée tout du long. En faire une cinquième valeur aurait créé une exclusion mutuelle fausse — on
ne pourrait plus dire d'une phase déléguée qu'elle est bloquée, ce qui est précisément la situation
qu'on veut voir venir de loin.
Le modèle avait déjà le patron : **`milestone` est un booléen à côté de `status`**, pas un statut. Un
jalon peut être `todo` ou `done` sans que cela pose la moindre question. `fixed` suit exactement le
même chemin, et les deux se combinent librement — un jalon à date imposée est le cas le plus courant
des deux.
**À ne pas confondre avec `blocked`**, dont il est le voisin immédiat dans un formulaire :
`blocked` dit *ça n'avance pas*, `fixed` dit *cette date ne m'appartient pas*. Une phase peut être
les deux, l'une ou l'autre, ou aucune. Le libellé retenu — « date imposée » plutôt que « fixe » —
sert d'abord à cela : il nomme la cause, là où « fixe » n'aurait nommé que l'effet, et six mois plus
tard c'est la cause qu'on cherche en relisant.
### Le verrou est souple
Une phase à date imposée **se glisse toujours seule**, à la souris comme aux flèches. Seul le
décalage de son projet l'épargne. « Imposée » veut donc dire *indépendante du projet*, et non
*immuable* : quand l'hébergeur déplace son créneau, on déplace la barre, sans rien avoir à décocher.
Le verrou dur avait été envisagé — barre non glissable, dates saisies au panneau — au motif qu'on
marque une phase pour la protéger d'un geste accidentel. Il ajoutait une friction permanente pour un
risque occasionnel, et il aurait fait diverger cette case de son modèle, `milestone`, qui n'empêche
rien non plus.
Corollaire : un projet dont toutes les phases sont terminées **ou** imposées n'offre aucune prise, et
sa barre cumulative le dit dans son infobulle. C'est le comportement déjà écrit pour les projets
entièrement clos ; il n'a pas eu à changer, seule la liste des phases emportées s'est resserrée.
### Ce que ça donne à l'œil
Deux **taquets** verticaux aux extrémités de la barre, comme les butées d'une pièce qui ne coulisse
plus ; un **cercle** pour un jalon, qui n'a pas d'extrémités où en poser. La marque se cumule avec
les quatre textures de statut au lieu de leur disputer la place — c'est bien une phase à venir, en
cours ou bloquée, qui se trouve en plus tenue par le dehors.
Elle se pose en `box-shadow` interne, donc sans toucher à la géométrie : la barre garde exactement la
largeur de ses dates. Sur une phase de deux jours, les taquets occupent presque toute la barre et la
texture de statut disparaît ; c'est accepté, une fenêtre de maintenance de deux jours se lit de toute
façon à son infobulle.
### Le format passe en version 6
Un champ ajouté n'y obligerait pas — la v5 se relit sans encombre. Ce qui y oblige, c'est le sens du
numéro : le validateur reconstruit chaque phase champ par champ et laisse tomber ce qu'il ne connaît
pas. Sans incrément, un binaire antérieur ouvrirait un fichier v6 sans broncher et en effacerait
toutes les dates imposées à la première sauvegarde — les phases concernées se remettant alors à
suivre le décalage de leur projet, en silence. C'est exactement le genre de perte que ce numéro
existe pour empêcher.
La migration ne devine rien : toute phase héritée reçoit `false`. Rien dans les données ne permettrait
d'inférer laquelle était déléguée, et un faux positif ici immobiliserait une phase sans qu'on
comprenne pourquoi.
Au passage, `donneesInitiales` dans `main.go` a été remis d'aplomb : le serveur écrivait encore
`version 4` pour un planning vide, deux versions en retard. Sans conséquence — le validateur accepte
un fichier plus ancien et le remonte — mais un fichier neuf se déclarait d'avant le cycle de vie.

View File

@@ -9,7 +9,7 @@ dans un diff git et éditable à la main.
```json ```json
{ {
"version": 5, "version": 6,
"projects": [ "projects": [
{ {
"id": "site-web", "id": "site-web",
@@ -29,6 +29,7 @@ dans un diff git et éditable à la main.
"end": "2026-08-20", "end": "2026-08-20",
"status": "done", "status": "done",
"milestone": false, "milestone": false,
"fixed": false,
"notes": "Ateliers avec les trois pôles.\n\nPérimètre arrêté le **8 juillet**.", "notes": "Ateliers avec les trois pôles.\n\nPérimètre arrêté le **8 juillet**.",
"baseline": { "start": "2026-07-25", "end": "2026-08-10" } "baseline": { "start": "2026-07-25", "end": "2026-08-10" }
}, },
@@ -39,7 +40,8 @@ dans un diff git et éditable à la main.
"end": "2026-11-02", "end": "2026-11-02",
"status": "todo", "status": "todo",
"milestone": true, "milestone": true,
"notes": "" "fixed": true,
"notes": "Date annoncée au client."
} }
] ]
} }
@@ -51,18 +53,21 @@ dans un diff git et éditable à la main.
| Champ | Type | Description | | Champ | Type | Description |
|---|---|---| |---|---|---|
| `version` | entier | Version du format. Vaut `5`. Sert à détecter un fichier trop ancien au chargement. | | `version` | entier | Version du format. Vaut `6`. Sert à détecter un fichier trop ancien au chargement. |
| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. | | `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. |
Un fichier plus ancien se charge sans rien demander et est réécrit au format courant à la première Un fichier plus ancien se charge sans rien demander et est réécrit au format courant à la première
sauvegarde : une version `2` — celle d'avant les tags — voit ses projets recevoir une liste de tags sauvegarde : une version `2` — celle d'avant les tags — voit ses projets recevoir une liste de tags
vide, une version `3` — celle d'avant les notes de projet — des notes vides, une version `4` — celle vide, une version `3` — celle d'avant les notes de projet — des notes vides, une version `4` — celle
d'avant le cycle de vie — des projets sans horizon ni date d'acte. d'avant le cycle de vie — des projets sans horizon ni date d'acte, une version `5` — celle d'avant
les dates imposées — des phases à `fixed: false`.
Cette dernière migration ne demande rien et ne devine rien : un projet qui a des phases est *engagé*, Aucune de ces migrations ne demande ni ne devine quoi que ce soit. Un projet qui a des phases est
un projet qui n'en a pas est *envisagé sans horizon*, et il se signalera comme réclamant une date. *engagé*, un projet qui n'en a pas est *envisagé sans horizon*, et il se signalera comme réclamant une
Aucun projet n'est déclaré terminé ni écarté au chargement — ces deux états s'actent, ils ne se date. Aucun projet n'est déclaré terminé ni écarté au chargement — ces deux états s'actent, ils ne se
devinent pas, fût-ce d'un planning dont toutes les phases sont finies. devinent pas, fût-ce d'un planning dont toutes les phases sont finies. Et aucune phase n'est déclarée
à date imposée : rien dans les données ne dirait laquelle est déléguée, et un faux positif
immobiliserait une phase sans qu'on comprenne pourquoi.
Un fichier écrit par une version **plus récente** est en revanche refusé. C'est la raison d'être du Un fichier écrit par une version **plus récente** est en revanche refusé. C'est la raison d'être du
numéro : le validateur reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît numéro : le validateur reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît
@@ -96,6 +101,7 @@ effacerait les champs inconnus à la première sauvegarde.
| `end` | chaîne | Date de fin **incluse**, `AAAA-MM-JJ`. | | `end` | chaîne | Date de fin **incluse**, `AAAA-MM-JJ`. |
| `status` | chaîne | `todo`, `doing`, `done` ou `blocked`. | | `status` | chaîne | `todo`, `doing`, `done` ou `blocked`. |
| `milestone` | booléen | `true` pour un jalon. | | `milestone` | booléen | `true` pour un jalon. |
| `fixed` | booléen | `true` si la date est imposée du dehors — phase déléguée, contrainte contractuelle ou réglementaire. Un décalage du projet ne l'emporte pas. |
| `notes` | chaîne | Texte libre en markdown, éventuellement vide. | | `notes` | chaîne | Texte libre en markdown, é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. | | `baseline` | objet ou absent | Dates de référence : `{ "start": …, "end": … }`. Absent tant que la référence n'a pas été figée. |
@@ -113,6 +119,10 @@ Appliquées par `js/model.js` et couvertes par `tests/model.test.js`.
chiffre remplacé par un tiret. En cas de collision, un suffixe numérique est ajouté (`cadrage-2`). 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. 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`. - `status` fait partie des quatre valeurs autorisées ; toute autre valeur est ramenée à `todo`.
- `fixed` est orthogonal au reste : aucune combinaison n'est interdite. Une phase peut être à la fois
jalon et à date imposée — c'est même le cas le plus courant des deux —, ou `blocked` et imposée, ce
qui n'est pas une contradiction : `blocked` dit que ça n'avance pas, `fixed` que la date ne nous
appartient pas.
- `color` est un hexadécimal `#rrggbb` valide. - `color` est un hexadécimal `#rrggbb` valide.
- `tags` est un tableau de chaînes. Le tableau lui-même et le type de ses éléments sont vérifiés — - `tags` est un tableau de chaînes. Le tableau lui-même et le type de ses éléments sont vérifiés —
un tag mal typé serait un tag qu'on croit poser et qui ne filtre rien. Leur *contenu*, en revanche, un tag mal typé serait un tag qu'on croit poser et qui ne filtre rien. Leur *contenu*, en revanche,
@@ -270,6 +280,12 @@ l'année correspondante, qui peut différer de celle de la date.
fenêtre du planning au premier affichage — sans quoi un planning fait de projets encore tous fenêtre du planning au premier affichage — sans quoi un planning fait de projets encore tous
envisagés s'ouvrirait sur rien. Les projets écartés en sont exclus : ils ne s'affichent pas envisagés s'ouvrirait sur rien. Les projets écartés en sont exclus : ils ne s'affichent pas
d'ordinaire, et un projet abandonné il y a trois ans n'a pas à tirer la vue en arrière. d'ordinaire, et un projet abandonné il y a trois ans n'a pas à tirer la vue en arrière.
- **Repousser un projet** (`decalerProjet()`) ajoute le même nombre de jours aux `start` et `end` des
phases qu'il emporte — `phasesDecalables()` les désigne, `ancreDecalage()` donne la date sur
laquelle le geste s'accroche. Deux sortes en sont exclues, pour des raisons sans rapport : les
phases **terminées**, parce qu'on ne réécrit pas le passé, et les phases à **date imposée**, parce
qu'on n'en décide pas. Les `baseline` ne bougent pas non plus : le décalage se lit alors
intégralement dans la dérive.
- **Figer la référence** copie, pour chaque phase du projet, ses `start` et `end` courantes dans son - **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 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. refiger après un arbitrage assumé repart d'une base propre.

View File

@@ -165,6 +165,18 @@
<input type="text" name="name" required autocomplete="off" /> <input type="text" name="name" required autocomplete="off" />
</label> </label>
<!--
Les deux cases encadrent les dates, chacune du côté où elle agit :
« Jalon » décide s'il y en a une ou deux, et se lit donc avant ;
« Date imposée » qualifie celles qu'on vient de saisir, et se lit
après. Le statut ferme la marche : il parle du travail, pas du
calendrier.
-->
<label class="case">
<input type="checkbox" name="milestone" />
Jalon (une seule date, affichée en losange)
</label>
<div class="champ-double"> <div class="champ-double">
<label> <label>
Début Début
@@ -176,6 +188,16 @@
</label> </label>
</div> </div>
<!--
Orthogonale au statut, et c'est le point : une phase déléguée avance,
se termine ou se bloque comme une autre. Seul son ancrage dans le temps
nous échappe (docs/decisions.md, section 28).
-->
<label class="case">
<input type="checkbox" name="fixed" />
Date imposée (déléguée, contractuelle ou réglementaire)
</label>
<label> <label>
Statut Statut
<select name="status"> <select name="status">
@@ -186,11 +208,6 @@
</select> </select>
</label> </label>
<label class="case">
<input type="checkbox" name="milestone" />
Jalon (une seule date, affichée en losange)
</label>
<!-- <!--
Les notes sont bâties par `notes.js` : aperçu markdown, barre d'outils Les notes sont bâties par `notes.js` : aperçu markdown, barre d'outils
et zone de saisie, le tout identique dans les deux panneaux. Un seul et zone de saisie, le tout identique dans les deux panneaux. Un seul
@@ -369,12 +386,15 @@
</section> </section>
<section> <section>
<h3>Modifier la phase du curseur</h3> <h3>Modifier la ligne du curseur</h3>
<dl class="aide"> <dl class="aide">
<dt><kbd></kbd> <kbd></kbd></dt> <dt><kbd></kbd> <kbd></kbd></dt>
<dd>Décale d'un jour</dd> <dd>
Décale d'un jour — la phase du curseur, ou, sur la ligne d'un
projet, toutes ses phases non terminées
</dd>
<dt><kbd>Maj</kbd>+<kbd></kbd> <kbd></kbd></dt> <dt><kbd>Maj</kbd>+<kbd></kbd> <kbd></kbd></dt>
<dd>Allonge, raccourcit d'un jour</dd> <dd>Allonge, raccourcit la phase d'un jour</dd>
<dt><kbd>Espace</kbd></dt> <dt><kbd>Espace</kbd></dt>
<dd>Statut suivant : à venir, en cours, terminé, bloqué</dd> <dd>Statut suivant : à venir, en cours, terminé, bloqué</dd>
<dt><kbd>Entrée</kbd> <kbd>e</kbd></dt> <dt><kbd>Entrée</kbd> <kbd>e</kbd></dt>

View File

@@ -22,12 +22,14 @@ import {
STATUTS, STATUTS,
ajouterJours, ajouterJours,
ajouterPhase, ajouterPhase,
ancreDecalage,
aujourdhui, aujourdhui,
bornesPlanning, bornesPlanning,
cleTag, cleTag,
clotureEcraseDesStatuts, clotureEcraseDesStatuts,
creerPhase, creerPhase,
creerProjet, creerProjet,
decalerProjet,
ecarterProjet, ecarterProjet,
etatProjet, etatProjet,
figerReference, figerReference,
@@ -869,6 +871,17 @@ installerGlisser(refs.couloirs, {
} }
majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon }))); majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon })));
}, },
surDecalageProjet(idProjet, jours) {
// Même convention que pour une phase : null veut dire « geste annulé ou sans
// effet », on redessine sans rien sauvegarder.
if (!jours) {
dessiner();
return;
}
majPlanning(
remplacerProjet(planning, idProjet, (projet) => decalerProjet(projet, jours))
);
},
surClicProjet(idProjet) { surClicProjet(idProjet) {
const projet = planning.projects.find((p) => p.id === idProjet); const projet = planning.projects.find((p) => p.id === idProjet);
if (projet) ouvrirProjet(projet); if (projet) ouvrirProjet(projet);
@@ -1315,16 +1328,29 @@ refs.frise.addEventListener(
{ passive: false } { passive: false }
); );
// Flèches : ajustement fin de la phase du curseur, au jour près. // Flèches : ajustement fin, au jour près, de ce que porte le curseur.
document.addEventListener('keydown', (evenement) => { document.addEventListener('keydown', (evenement) => {
// Une ligne de projet n'a pas de dates : les flèches n'y ont rien à décaler. if (!curseur) return;
if (!curseur?.phase) return;
// Pas d'interception pendant une saisie dans le panneau ou un dialogue. // Pas d'interception pendant une saisie dans le panneau ou un dialogue.
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return; if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0; const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0;
if (pas === 0) return; if (pas === 0) return;
// Sur la ligne d'un projet, les flèches repoussent le projet entier — le même
// geste que le glisser de sa barre cumulative, au jour près. Maj n'y ajoute
// rien : un projet ne s'étire pas (docs/decisions.md, section 8).
if (!curseur.phase) {
const projet = planning.projects.find((p) => p.id === curseur.projet);
if (!projet || !ancreDecalage(projet)) return;
evenement.preventDefault();
majPlanning(
remplacerProjet(planning, curseur.projet, (aDecaler) => decalerProjet(aDecaler, pas))
);
return;
}
evenement.preventDefault(); evenement.preventDefault();
const trouve = localiser(curseur.projet, curseur.phase); const trouve = localiser(curseur.projet, curseur.phase);
if (!trouve) return; if (!trouve) return;

View File

@@ -7,9 +7,10 @@
* des séquences et le préfixe en attente. * des séquences et le préfixe en attente.
* *
* Le clavier de la frise est ainsi séparé en deux couches qui ne se recouvrent * Le clavier de la frise est ainsi séparé en deux couches qui ne se recouvrent
* pas : les **flèches modifient** la phase du curseur (app.js, hérité), les * pas : les **flèches modifient** ce que porte le curseur — la phase, ou le
* **lettres déplacent** le curseur et commandent. Aucun raccourci documenté n'a * projet entier quand il est sur sa ligne (app.js, hérité) —, les **lettres
* changé de sens. * déplacent** le curseur et commandent. Aucun raccourci documenté n'a changé de
* sens.
*/ */
/** /**

View File

@@ -40,6 +40,7 @@ export function creerPanneau(refs, rappels) {
formulaire.end.value = phase.end; formulaire.end.value = phase.end;
formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo'; formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo';
formulaire.milestone.checked = phase.milestone; formulaire.milestone.checked = phase.milestone;
formulaire.fixed.checked = phase.fixed;
// Ouverture sur l'aperçu si la phase est commentée, sur la saisie sinon : // Ouverture sur l'aperçu si la phase est commentée, sur la saisie sinon :
// on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour // on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour
// y écrire quelque chose. // y écrire quelque chose.
@@ -77,6 +78,7 @@ export function creerPanneau(refs, rappels) {
end: fin, end: fin,
status: formulaire.status.value, status: formulaire.status.value,
milestone: jalon, milestone: jalon,
fixed: formulaire.fixed.checked,
notes: formulaire.notes.value, notes: formulaire.notes.value,
}); });
@@ -170,6 +172,7 @@ export function creerPanneau(refs, rappels) {
// serait le pire moment pour changer d'état. // serait le pire moment pour changer d'état.
if (formulaire.notes !== actif) blocNotes.definir(phase.notes); if (formulaire.notes !== actif) blocNotes.definir(phase.notes);
if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone; if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone;
if (formulaire.fixed !== actif) formulaire.fixed.checked = phase.fixed;
champFin.hidden = phase.milestone; champFin.hidden = phase.milestone;
}, },
@@ -198,6 +201,7 @@ function sontEgales(a, b) {
a.end === b.end && a.end === b.end &&
a.status === b.status && a.status === b.status &&
a.milestone === b.milestone && a.milestone === b.milestone &&
a.fixed === b.fixed &&
a.notes === b.notes a.notes === b.notes
); );
} }

View File

@@ -1,6 +1,12 @@
/** /**
* Glisser et redimensionner les barres. * Glisser et redimensionner les barres.
* *
* Trois sortes de barres se saisissent, et le même appui les sert toutes :
* celle d'une **phase**, accrochée au lundi ; celle de l'**horizon** d'un projet
* envisagé, accrochée au mois ; et la barre **cumulative** d'un projet, qui
* repousse d'un coup toutes ses phases non terminées. Chacune sait ce qu'elle
* déplace par son `data-role`.
*
* Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un * 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 * 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 * n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme
@@ -15,15 +21,20 @@
import { import {
ajouterJours, ajouterJours,
ajouterMois, ajouterMois,
ancreDecalage,
decalerProjet,
deplacerPhase, deplacerPhase,
dernierJourDuMois, dernierJourDuMois,
ecartJours,
ecartMois, ecartMois,
lundiDeLaSemaine, lundiDeLaSemaine,
moisDe, moisDe,
phasesDecalables,
premierJourDuMois, premierJourDuMois,
redimensionnerPhase, redimensionnerPhase,
versUTC, versUTC,
} from './model.js'; } from './model.js';
import { poserBarreCumulative } from './timeline.js';
/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */ /** Déplacement en pixels au-delà duquel l'appui devient un glisser. */
const SEUIL_GLISSER = 4; const SEUIL_GLISSER = 4;
@@ -45,12 +56,43 @@ export function installerGlisser(conteneur, rappels) {
// Bouton principal uniquement : le clic droit ouvre le menu contextuel. // Bouton principal uniquement : le clic droit ouvre le menu contextuel.
if (evenement.button !== 0) return; if (evenement.button !== 0) return;
const cible = evenement.target.closest('[data-role="barre"], [data-role="horizon"]'); const cible = evenement.target.closest(
'[data-role="barre"], [data-role="horizon"], [data-role="cumul"]'
);
if (!cible) return; if (!cible) return;
const poignee = evenement.target.closest('[data-role="poignee"]'); const poignee = evenement.target.closest('[data-role="poignee"]');
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer'; const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
// La barre cumulative repousse le projet entier. Elle n'a pas de poignées :
// étirer un projet supposerait de dilater chaque phase au prorata, ce qui
// n'est pas le même besoin (docs/decisions.md, section 8).
if (cible.dataset.role === 'cumul') {
const projet = rappels.trouverProjet(cible.dataset.projet);
const ancre = projet && ancreDecalage(projet);
if (!ancre) return;
geste = {
element: cible,
sorte: 'cumul',
idProjet: projet.id,
projetInitial: projet,
ancre,
jours: 0,
mode: 'deplacer',
xDepart: evenement.clientX,
// Les barres de phase suivent le résumé pendant le geste : sans elles,
// on repousserait plusieurs mois de travail sans voir ce qui bouge —
// l'objection qui avait d'abord fait écarter ce geste.
suiveurs: suiveursDuProjet(conteneur, projet),
demarre: false,
};
cible.setPointerCapture(evenement.pointerId);
evenement.preventDefault();
return;
}
// La barre d'un projet envisagé se manœuvre au mois, pas au jour : même // La barre d'un projet envisagé se manœuvre au mois, pas au jour : même
// mécanique de geste, autre grain, et rien de commun avec une phase — il n'y // mécanique de geste, autre grain, et rien de commun avec une phase — il n'y
// en a pas encore (docs/decisions.md, section 27). // en a pas encore (docs/decisions.md, section 27).
@@ -122,6 +164,14 @@ export function installerGlisser(conteneur, rappels) {
relacher(acheve, evenement.pointerId); relacher(acheve, evenement.pointerId);
if (acheve.sorte === 'cumul') {
// Un appui sans glisser ouvre les paramètres du projet, comme un clic sur
// son nom ou sur sa barre d'horizon.
if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet);
else rappels.surDecalageProjet(acheve.idProjet, acheve.jours || null);
return;
}
if (acheve.sorte === 'horizon') { if (acheve.sorte === 'horizon') {
const avant = acheve.horizonInitial; const avant = acheve.horizonInitial;
const apres = acheve.horizonCourant; const apres = acheve.horizonCourant;
@@ -160,7 +210,8 @@ export function installerGlisser(conteneur, rappels) {
geste = null; geste = null;
relacher(annule); relacher(annule);
if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null); if (annule.sorte === 'cumul') rappels.surDecalageProjet(annule.idProjet, null);
else if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
else rappels.surDeplacement(annule.idProjet, annule.idPhase, null); else rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
} }
@@ -200,6 +251,7 @@ function relacher(geste, pointerId) {
* Les dates s'accrochent au lundi (docs/decisions.md, section 9). * Les dates s'accrochent au lundi (docs/decisions.md, section 9).
*/ */
function appliquerGeste(geste, decalage, echelle) { function appliquerGeste(geste, decalage, echelle) {
if (geste.sorte === 'cumul') return appliquerGesteCumul(geste, decalage, echelle);
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle); if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
const { phaseInitiale: phase, mode } = geste; const { phaseInitiale: phase, mode } = geste;
@@ -229,6 +281,57 @@ function appliquerGeste(geste, decalage, echelle) {
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`; geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
} }
/**
* Éléments de la frise qu'un décalage de projet emporte, avec leur position de
* départ.
*
* La position est relue dans `style.left` plutôt que dans `offsetLeft` : le nom
* d'un jalon porte une marge, que le second compte et le premier non. Repartir
* de la valeur qu'on a soi-même posée est le seul moyen d'y ajouter un décalage
* sans en décaler deux fois.
*
* Le fantôme de référence n'en est délibérément pas : il ne porte aucune phase,
* il tient la place du plan figé, et le voir rester en arrière pendant qu'on
* repousse le projet *est* la dérive qui s'affiche.
*/
function suiveursDuProjet(conteneur, projet) {
const decalables = new Set(phasesDecalables(projet).map((phase) => phase.id));
const suiveurs = [];
for (const element of conteneur.querySelectorAll('.barre, .jalon, .jalon-nom')) {
if (element.dataset.projet !== projet.id) continue;
if (!decalables.has(element.dataset.phase)) continue;
suiveurs.push({ element, gaucheInitiale: parseFloat(element.style.left) });
}
return suiveurs;
}
/**
* Repousse un projet entier : le même nombre de jours sur toutes ses phases non
* terminées, accroché au lundi comme une phase seule (décision 9).
*
* L'accroche porte sur la première phase qui bouge, et non sur le début du
* projet — sur un projet dont les premières phases sont closes, ce début reste
* en place et ne dirait rien du geste en cours.
*
* La barre cumulative est reposée depuis le modèle plutôt que translatée : elle
* se déforme quand une partie du projet ne suit pas, et son bord gauche tient
* pendant que le reste s'en va.
*/
function appliquerGesteCumul(geste, decalage, echelle) {
const jours = ecartJours(geste.ancre, accrocher(geste.ancre, decalage, echelle));
if (jours === geste.jours) return;
geste.jours = jours;
poserBarreCumulative(geste.element, decalerProjet(geste.projetInitial, jours), echelle);
const pixels = jours * echelle.pixelsParJour;
for (const suiveur of geste.suiveurs) {
suiveur.element.style.left = `${suiveur.gaucheInitiale + pixels}px`;
}
}
/** /**
* Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** : * Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** :
* l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire * l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire

View File

@@ -15,16 +15,17 @@
/** /**
* Version du format de fichier. * Version du format de fichier.
* *
* Elle a changé pour la dernière fois quand le projet a reçu son cycle de vie — * Elle a changé pour la dernière fois quand la phase a reçu sa date imposée —
* `horizon`, `completedDate`, `discardedDate` (v5). Un champ ajouté n'oblige * `fixed` (v6) ; avant cela, quand le projet a reçu son cycle de vie (v5). Un
* pourtant à rien : la v4 se relit sans encombre, et le validateur ne refuse * champ ajouté n'oblige pourtant à rien : la v5 se relit sans encombre, et le
* qu'un fichier *plus récent* que lui. C'est justement là qu'est la raison * validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement là
* d'incrémenter — le validateur reconstruit chaque projet champ par champ et * qu'est la raison d'incrémenter — le validateur reconstruit chaque projet champ
* laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire antérieur * par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire
* ouvrirait un fichier v5 sans broncher et en effacerait tous les horizons à la * antérieur ouvrirait un fichier v6 sans broncher et en effacerait toutes les
* première sauvegarde. * dates imposées à la première sauvegarde : les phases concernées se remettraient
* alors à suivre le décalage de leur projet, en silence.
*/ */
export const VERSION_FORMAT = 5; export const VERSION_FORMAT = 6;
export const STATUTS = ['todo', 'doing', 'done', 'blocked']; export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
@@ -652,6 +653,10 @@ function validerPhase(phase, rang, repereProjet, idsPhases) {
// chargement : la valeur est cosmétique, contrairement aux dates. // chargement : la valeur est cosmétique, contrairement aux dates.
status: STATUTS.includes(phase.status) ? phase.status : 'todo', status: STATUTS.includes(phase.status) ? phase.status : 'todo',
milestone: jalon, milestone: jalon,
// Absente d'un fichier en version 5, où elle vaut donc `false` : aucune date
// n'y était déclarée imposée, et rien dans les données ne permettrait de
// deviner laquelle l'était.
fixed: Boolean(phase.fixed),
notes: typeof phase.notes === 'string' ? phase.notes : '', notes: typeof phase.notes === 'string' ? phase.notes : '',
...(reference ? { baseline: reference } : {}), ...(reference ? { baseline: reference } : {}),
}; };
@@ -996,6 +1001,7 @@ export function creerPhase(projet, nom, debut, fin, options = {}) {
end: jalon ? debut : fin, end: jalon ? debut : fin,
status: STATUTS.includes(options.status) ? options.status : 'todo', status: STATUTS.includes(options.status) ? options.status : 'todo',
milestone: jalon, milestone: jalon,
fixed: Boolean(options.fixed),
notes: options.notes || '', notes: options.notes || '',
}; };
} }
@@ -1006,6 +1012,82 @@ export function deplacerPhase(phase, nouveauDebut) {
return { ...phase, start: nouveauDebut, end: ajouterJours(nouveauDebut, jours) }; return { ...phase, start: nouveauDebut, end: ajouterJours(nouveauDebut, jours) };
} }
/**
* Les phases qu'un décalage de projet emporte. Deux sortes en sont exclues, pour
* deux raisons qui n'ont rien à voir.
*
* **Ce qui est terminé.** Décaler un projet, c'est reconnaître qu'il prendra plus
* de temps que prévu — et ce qui est fait est fait. Emporter les phases `done`
* réécrirait un passé qu'on a vécu, et effacerait du même coup la dérive qu'on
* cherche justement à lire : la référence figée, elle, ne bouge pas. C'est le
* statut qui décide, jamais la position dans le calendrier : une phase terminée
* en avance reste où elle est, une phase `blocked` ou `doing` déjà commencée se
* décale avec le reste — c'est bien elle qui glisse.
*
* **Ce dont on ne décide pas.** Une phase à `fixed` porte une date qui ne nous
* appartient pas : elle est déléguée, contractuelle ou réglementaire. Repousser
* le projet ne repousse pas l'audit du commissaire aux comptes ; le décalage lui
* passe à travers, et le chevauchement qui en résulte est précisément
* l'information qu'on veut voir.
*/
export function phasesDecalables(projet) {
return projet.phases.filter((phase) => phase.status !== 'done' && !phase.fixed);
}
/**
* Date à laquelle s'accroche le geste de décalage : le début de la première
* phase qui bougera. Null si tout est terminé — il n'y a alors rien à décaler.
*
* C'est cette date, et non le début du projet, qui sert d'ancre : sur un projet
* dont les premières phases sont closes, le bord gauche de ce qui bouge doit
* suivre le pointeur, pas un bord qui reste sur place.
*/
export function ancreDecalage(projet) {
const decalables = phasesDecalables(projet);
if (!decalables.length) return null;
return decalables.reduce(
(debut, phase) => (versUTC(phase.start) < versUTC(debut) ? phase.start : debut),
decalables[0].start
);
}
/**
* Décale d'un même nombre de jours les phases que le projet emporte —
* `phasesDecalables` dit lesquelles.
*
* Le même delta partout, jalons compris : les écarts entre phases emportées sont
* conservés au jour près, ce qui est tout l'intérêt du geste — on repousse un
* plan sans le replanifier. Celles qui restent font que le projet ne se déplace
* pas tant qu'il s'étire.
*
* L'appartenance se décide **une fois**, avant de rien décaler, et se retient par
* identifiant : la parcourir au fil de la transformation reviendrait à interroger
* des phases déjà déplacées.
*
* Rien n'empêche le décalage de faire chevaucher deux phases : il n'y a pas de
* dépendances entre phases dans ce modèle (docs/decisions.md, section 2), et
* inventer une butée ici reviendrait à en poser une par la bande.
*/
export function decalerProjet(projet, jours) {
if (!jours) return projet;
const emportees = new Set(phasesDecalables(projet).map((phase) => phase.id));
return {
...projet,
phases: trierPhases(
projet.phases.map((phase) =>
emportees.has(phase.id)
? {
...phase,
start: ajouterJours(phase.start, jours),
end: ajouterJours(phase.end, jours),
}
: phase
)
),
};
}
/** /**
* Change une seule borne — le glisser d'un bord de barre. * 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. * La borne opposée fait butée : une phase ne peut pas se retourner.

View File

@@ -29,6 +29,7 @@ import {
formaterHorizon, formaterHorizon,
formulerDerive, formulerDerive,
lundiDeLaSemaine, lundiDeLaSemaine,
phasesDecalables,
projetADater, projetADater,
projetFiltre, projetFiltre,
projetFiltreEtat, projetFiltreEtat,
@@ -307,7 +308,7 @@ function construireLibelles(projets, tagsActifs, curseur) {
const ligne = document.createElement('div'); const ligne = document.createElement('div');
ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`; ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
ligne.textContent = phase.name; ligne.textContent = phase.name;
ligne.title = `${phase.name}${LIBELLES_STATUT[phase.status]}`; ligne.title = `${phase.name}${LIBELLES_STATUT[phase.status]}${mentionImposee(phase)}`;
ligne.dataset.projet = projet.id; ligne.dataset.projet = projet.id;
ligne.dataset.phase = phase.id; ligne.dataset.phase = phase.id;
elements.push(ligne); elements.push(ligne);
@@ -506,9 +507,16 @@ function construirePhase(projet, phase, echelle, curseur) {
elements.push(fantome); elements.push(fantome);
} }
// Une date imposée se lit sur la barre, et pas seulement dans le panneau : un
// planning se parcourt des yeux, et savoir ce qui suivra un décalage doit se
// voir avant de faire le geste, pas après.
const impose = phase.fixed ? ' barre--imposee' : '';
if (phase.milestone) { if (phase.milestone) {
const losange = document.createElement('div'); const losange = document.createElement('div');
losange.className = `jalon jalon--${phase.status}${selectionnee ? ' jalon--selectionne' : ''}`; losange.className =
`jalon jalon--${phase.status}${phase.fixed ? ' jalon--impose' : ''}` +
(selectionnee ? ' jalon--selectionne' : '');
losange.style.left = `${echelle.x(phase.start)}px`; losange.style.left = `${echelle.x(phase.start)}px`;
losange.dataset.projet = projet.id; losange.dataset.projet = projet.id;
losange.dataset.phase = phase.id; losange.dataset.phase = phase.id;
@@ -518,7 +526,7 @@ function construirePhase(projet, phase, echelle, curseur) {
// infobulle les taisait, alors que celle d'une barre les montrait. // infobulle les taisait, alors que celle d'une barre les montrait.
losange.title = losange.title =
`${phase.name}${formaterDateLongue(phase.start)}` + `${phase.name}${formaterDateLongue(phase.start)}` +
`\n${LIBELLES_STATUT[phase.status]}` + `\n${LIBELLES_STATUT[phase.status]}${mentionImposee(phase)}` +
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : ''); (phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
elements.push(losange); elements.push(losange);
@@ -526,13 +534,19 @@ function construirePhase(projet, phase, echelle, curseur) {
nom.className = 'jalon-nom'; nom.className = 'jalon-nom';
nom.style.left = `${echelle.x(phase.start)}px`; nom.style.left = `${echelle.x(phase.start)}px`;
nom.textContent = phase.name; nom.textContent = phase.name;
// Le nom vit hors du losange — il déborderait de ses 12 pixels. Il porte donc
// sa propre identité, faute de quoi le décalage d'un projet emporterait le
// jalon en laissant son nom sur place.
nom.dataset.projet = projet.id;
nom.dataset.phase = phase.id;
elements.push(nom); elements.push(nom);
return elements; return elements;
} }
const barre = document.createElement('div'); const barre = document.createElement('div');
barre.className = `barre barre--${phase.status}${selectionnee ? ' barre--selectionnee' : ''}`; barre.className =
`barre barre--${phase.status}${impose}` + (selectionnee ? ' barre--selectionnee' : '');
barre.style.left = `${echelle.x(phase.start)}px`; barre.style.left = `${echelle.x(phase.start)}px`;
barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`; barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`;
barre.dataset.projet = projet.id; barre.dataset.projet = projet.id;
@@ -540,7 +554,7 @@ function construirePhase(projet, phase, echelle, curseur) {
barre.dataset.role = 'barre'; barre.dataset.role = 'barre';
barre.title = barre.title =
`${phase.name}\n${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` + `${phase.name}\n${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
`\n${LIBELLES_STATUT[phase.status]}` + `\n${LIBELLES_STATUT[phase.status]}${mentionImposee(phase)}` +
// Aplati : l'infobulle native ne connaît que le texte, et y déverser la // Aplati : l'infobulle native ne connaît que le texte, et y déverser la
// source markdown rendrait la note moins lisible qu'avant qu'on ne // source markdown rendrait la note moins lisible qu'avant qu'on ne
// l'enrichisse. `aplatirMarkdown` retire les marques et remplace celles qui // l'enrichisse. `aplatirMarkdown` retire les marques et remplace celles qui
@@ -571,40 +585,107 @@ function construirePhase(projet, phase, echelle, curseur) {
/** /**
* Barre unique du projet, segmentée par phase — sa forme d'ensemble. * Barre unique du projet, segmentée par phase — sa forme d'ensemble.
* *
* Affichée qu'il soit plié ou déplié. Lecture seule dans les deux cas : aucun * Affichée qu'il soit plié ou déplié. Elle se saisit pour repousser le projet
* `data-role`, donc drag.js l'ignore (docs/decisions.md, section 8). * entier, à condition qu'il reste quelque chose à repousser : un projet dont
* toutes les phases sont terminées n'a rien qui bouge, et sa barre reste inerte
* plutôt que d'offrir une prise qui ne mènerait à rien (docs/decisions.md,
* section 8).
*/ */
function construireBarreCumulative(projet, echelle) { function construireBarreCumulative(projet, echelle) {
const bornes = bornesProjet(projet); const bornes = bornesProjet(projet);
if (!bornes) return null; if (!bornes) return null;
const decalables = phasesDecalables(projet);
const conteneur = document.createElement('div'); const conteneur = document.createElement('div');
conteneur.className = 'barre-cumulative'; conteneur.className = 'barre-cumulative';
conteneur.style.left = `${echelle.x(bornes.start)}px`; conteneur.dataset.projet = projet.id;
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`; // Rôle distinct de `barre` et de `horizon` : drag.js y reconnaît qu'il décale
// un projet entier, et non l'objet unique qu'il a sous le pointeur.
if (decalables.length) conteneur.dataset.role = 'cumul';
const derive = formulerDerive(deriveProjet(projet)); const derive = formulerDerive(deriveProjet(projet));
conteneur.title = conteneur.title =
`${projet.name}\n${formaterDateLongue(bornes.start)}${formaterDateLongue(bornes.end)}` + `${projet.name}\n${formaterDateLongue(bornes.start)}${formaterDateLongue(bornes.end)}` +
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` + `\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
(derive ? `\nÉcart à la référence : ${derive}` : '') + (derive ? `\nÉcart à la référence : ${derive}` : '') +
// Déplié, les barres modifiables sont juste en dessous : le dire serait `\n\n${modeDEmploiDecalage(projet, decalables.length)}`;
// enfoncer une porte ouverte.
(projet.collapsed ? '\n\nDéplier le projet pour modifier les dates.' : '');
const origine = echelle.x(bornes.start);
for (const phase of projet.phases) { for (const phase of projet.phases) {
const segment = document.createElement('div'); const segment = document.createElement('div');
segment.className = `segment segment--${phase.status}`; segment.className = `segment segment--${phase.status}`;
// Le geste rejoue la géométrie sur ces mêmes nœuds à chaque mouvement de
// souris, et le décalage retrie les phases : c'est l'identifiant qui les
// apparie, jamais leur rang.
segment.dataset.phase = phase.id;
conteneur.append(segment);
}
poserBarreCumulative(conteneur, projet, echelle);
return conteneur;
}
/**
* Ce qui suit le statut dans l'infobulle d'une phase à date imposée.
*
* Accolé au statut, et non sur sa propre ligne : les deux se lisent ensemble —
* « en cours, date imposée » — et occupent le même registre, celui de ce que la
* phase est aujourd'hui.
*/
function mentionImposee(phase) {
return phase.fixed ? ' — date imposée, non emportée par un décalage du projet' : '';
}
/**
* Ce que dit l'infobulle du résumé sur le geste qu'il offre.
*
* La restriction aux phases non terminées ne s'annonce que lorsqu'elle joue :
* sur un projet dont rien n'est clos — le cas courant — le geste emporte tout,
* et il n'y a aucune nuance à apprendre.
*/
function modeDEmploiDecalage(projet, nombreDecalables) {
if (!nombreDecalables) {
return 'Rien à repousser : ses phases sont toutes terminées ou à date imposée.';
}
if (nombreDecalables === projet.phases.length) return 'Glisser pour repousser le projet.';
const retenues = projet.phases.length - nombreDecalables;
const compte =
retenues > 1 ? `${retenues} de ses phases ne suivront pas` : 'Une de ses phases ne suivra pas';
return `Glisser pour repousser le projet. ${compte} — terminée${retenues > 1 ? 's' : ''} ou à date imposée.`;
}
/**
* Pose la géométrie de la barre cumulative et de ses segments d'après l'état du
* projet.
*
* Séparée de sa construction parce que `drag.js` la rejoue à chaque mouvement de
* souris : décaler un projet dont une partie est terminée ne translate pas cette
* barre, il la déforme — le bord gauche tient, le reste s'en va. Elle ne peut
* donc pas se contenter d'un `left` mis à jour, et la formule doit vivre au même
* endroit que celle du rendu, sous peine de voir l'aperçu et le résultat
* diverger.
*/
export function poserBarreCumulative(conteneur, projet, echelle) {
const bornes = bornesProjet(projet);
if (!bornes) return;
const origine = echelle.x(bornes.start);
conteneur.style.left = `${origine}px`;
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
const segments = new Map();
for (const segment of conteneur.children) segments.set(segment.dataset.phase, segment);
for (const phase of projet.phases) {
const segment = segments.get(phase.id);
if (!segment) continue;
segment.style.left = `${echelle.x(phase.start) - origine}px`; 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. // Un jalon n'a pas de largeur propre : on lui donne 3 px pour rester visible.
segment.style.width = phase.milestone segment.style.width = phase.milestone
? '3px' ? '3px'
: `${echelle.largeurDe(phase.start, phase.end)}px`; : `${echelle.largeurDe(phase.start, phase.end)}px`;
conteneur.append(segment);
} }
return conteneur;
} }
/** /**

View File

@@ -67,7 +67,7 @@ const portsTentes = 10
// lancement est immédiatement relu par le front, qui refuse une version plus // lancement est immédiatement relu par le front, qui refuse une version plus
// récente que la sienne — mais accepterait sans rien dire une version plus // récente que la sienne — mais accepterait sans rien dire une version plus
// ancienne, et la réécrirait au format courant. // ancienne, et la réécrirait au format courant.
var donneesInitiales = []byte(`{"version": 4, "projects": []}`) var donneesInitiales = []byte(`{"version": 6, "projects": []}`)
type serveur struct { type serveur struct {
fichierDonnees string fichierDonnees string

View File

@@ -73,9 +73,9 @@ func TestGetSansFichierRenvoiePlanningVide(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil {
t.Fatalf("réponse illisible : %v", err) t.Fatalf("réponse illisible : %v", err)
} }
// Le format courant est la version 4 (js/model.js, VERSION_FORMAT). // Le format courant est la version 6 (js/model.js, VERSION_FORMAT).
if planning.Version != 4 { if planning.Version != 6 {
t.Errorf("version = %d, attendu 4", planning.Version) t.Errorf("version = %d, attendu 6", planning.Version)
} }
if len(planning.Projects) != 0 { if len(planning.Projects) != 0 {
t.Errorf("projects = %v, attendu vide", planning.Projects) t.Errorf("projects = %v, attendu vide", planning.Projects)

View File

@@ -12,12 +12,14 @@ import {
bornesPlanning, bornesPlanning,
bornesProjet, bornesProjet,
bornesReference, bornesReference,
ancreDecalage,
cleTag, cleTag,
clotureEcraseDesStatuts, clotureEcraseDesStatuts,
creerPhase, creerPhase,
creerProjet, creerProjet,
dateValide, dateValide,
decalerHorizon, decalerHorizon,
decalerProjet,
definirHorizon, definirHorizon,
deplacerPhase, deplacerPhase,
dernierJourDuMois, dernierJourDuMois,
@@ -42,6 +44,7 @@ import {
modifierPhase, modifierPhase,
moisValide, moisValide,
normaliserTags, normaliserTags,
phasesDecalables,
planningVide, planningVide,
pourEcriture, pourEcriture,
premierJourDuMois, premierJourDuMois,
@@ -368,6 +371,24 @@ describe('validerPlanning', () => {
assert.equal(resultat.projects[0].notes, ''); assert.equal(resultat.projects[0].notes, '');
}); });
test('une phase d un fichier en version 5 n a pas de date imposée', () => {
// Rien dans les données ne permettrait de deviner laquelle l'était : la
// migration ne devine pas, elle met tout à `false`.
const resultat = validerPlanning({ version: 5, projects: [projet({ phases: [phase()] })] });
assert.equal(resultat.projects[0].phases[0].fixed, false);
});
test('conserve une date imposée, et l écrit dans le fichier', () => {
const resultat = validerPlanning({
projects: [projet({ phases: [phase({ fixed: true })] })],
});
assert.equal(resultat.projects[0].phases[0].fixed, true);
const relu = JSON.parse(JSON.stringify(pourEcriture(resultat)));
assert.equal(relu.version, VERSION_FORMAT);
assert.equal(relu.projects[0].phases[0].fixed, true);
});
test('conserve les notes d un projet', () => { test('conserve les notes d un projet', () => {
const resultat = validerPlanning({ const resultat = validerPlanning({
projects: [projet({ notes: '## Contexte\n\n- [ ] relancer' })], projects: [projet({ notes: '## Contexte\n\n- [ ] relancer' })],
@@ -901,6 +922,156 @@ describe('modifications', () => {
}); });
}); });
// --- décalage d'un projet ---------------------------------------------------
describe('décaler un projet', () => {
const enCours = projet({
phases: [
phase({ id: 'cadrage', start: '2026-08-03', end: '2026-08-21', status: 'done' }),
phase({ id: 'chantier', start: '2026-08-24', end: '2026-10-02', status: 'doing' }),
phase({
id: 'livraison',
start: '2026-10-12',
end: '2026-10-12',
status: 'todo',
milestone: true,
}),
],
});
const avecImposee = projet({
phases: [
phase({ id: 'chantier', start: '2026-08-24', end: '2026-10-02', status: 'doing' }),
phase({
id: 'audit-externe',
start: '2026-09-07',
end: '2026-09-18',
status: 'todo',
fixed: true,
}),
],
});
test('emporte tout ce qui n est pas terminé, et rien d autre', () => {
assert.deepEqual(
phasesDecalables(enCours).map((p) => p.id),
['chantier', 'livraison']
);
});
test('n emporte pas une phase à date imposée, quel que soit son statut', () => {
assert.deepEqual(
phasesDecalables(avecImposee).map((p) => p.id),
['chantier']
);
// Le statut ne rachète rien : c'est bien la date qui est tenue, pas le
// travail qui serait fini.
const enCoursEtImposee = projet({
phases: [phase({ status: 'doing', fixed: true })],
});
assert.deepEqual(phasesDecalables(enCoursEtImposee), []);
});
test('une phase à date imposée reste en place quand le projet est repoussé', () => {
const repousse = decalerProjet(avecImposee, 30);
const audit = repousse.phases.find((p) => p.id === 'audit-externe');
const chantier = repousse.phases.find((p) => p.id === 'chantier');
assert.deepEqual(
{ start: audit.start, end: audit.end },
{ start: '2026-09-07', end: '2026-09-18' }
);
assert.equal(chantier.start, '2026-09-23');
// Le chantier passe désormais par-dessus l'audit : c'est l'information que
// le geste doit faire apparaître, pas un cas à empêcher.
assert.ok(ecartJours(chantier.start, audit.end) < 0);
});
test('un projet dont tout est imposé ou terminé n offre aucune prise', () => {
const tenu = projet({
phases: [
phase({ id: 'a', status: 'done' }),
phase({ id: 'b', start: '2026-09-01', end: '2026-09-10', fixed: true }),
],
});
assert.equal(ancreDecalage(tenu), null);
assert.deepEqual(decalerProjet(tenu, 10).phases, tenu.phases);
});
test('s ancre sur la première phase qui bouge, pas sur le début du projet', () => {
// Le projet commence le 3 août, mais ce début est clos : c'est le 24 qui
// suit le pointeur.
assert.equal(ancreDecalage(enCours), '2026-08-24');
assert.equal(ancreDecalage(projet({ phases: [phase()] })), '2026-08-01');
});
test('un projet dont tout est terminé n a pas d ancre', () => {
const clos = projet({ phases: [phase({ status: 'done' })] });
assert.equal(ancreDecalage(clos), null);
assert.equal(ancreDecalage(projet()), null, 'ni un projet sans phase');
});
test('applique le même delta partout : les écarts sont conservés au jour près', () => {
const repousse = decalerProjet(enCours, 14);
const [cadrage, chantier, livraison] = repousse.phases;
assert.deepEqual(
{ start: cadrage.start, end: cadrage.end },
{ start: '2026-08-03', end: '2026-08-21' },
'la phase terminée reste où elle est'
);
assert.deepEqual(
{ start: chantier.start, end: chantier.end },
{ start: '2026-09-07', end: '2026-10-16' }
);
assert.deepEqual(
{ start: livraison.start, end: livraison.end },
{ start: '2026-10-26', end: '2026-10-26' },
'le jalon suit, et tient toujours sur un jour'
);
assert.equal(
ecartJours(chantier.end, livraison.start),
ecartJours(enCours.phases[1].end, enCours.phases[2].start)
);
});
test('recule aussi bien qu il avance', () => {
const recule = decalerProjet(enCours, -7);
assert.equal(recule.phases[1].start, '2026-08-17');
// Rien n'interdit de chevaucher une phase terminée : il n'y a pas de
// dépendances entre phases, et une butée en inventerait une.
assert.ok(ecartJours(recule.phases[1].start, recule.phases[0].end) > 0);
});
test('un décalage nul rend le projet inchangé', () => {
assert.equal(decalerProjet(enCours, 0), enCours);
});
test('la référence figée ne suit pas : c est elle qui mesure la dérive', () => {
const fige = figerReference(enCours, '2026-08-01');
const repousse = decalerProjet(fige, 21);
assert.deepEqual(repousse.phases[1].baseline, { start: '2026-08-24', end: '2026-10-02' });
assert.equal(deriveProjet(repousse), 21);
});
test('retrie les phases quand le décalage change leur ordre', () => {
const desordonne = projet({
phases: [
phase({ id: 'a', start: '2026-08-01', end: '2026-08-05', status: 'todo' }),
phase({ id: 'b', start: '2026-08-10', end: '2026-08-15', status: 'done' }),
],
});
assert.deepEqual(
decalerProjet(desordonne, 30).phases.map((p) => p.id),
['b', 'a']
);
});
});
// --- cycle de vie ----------------------------------------------------------- // --- cycle de vie -----------------------------------------------------------
describe('cycle de vie', () => { describe('cycle de vie', () => {