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:
48
README.md
48
README.md
@@ -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
|
||||
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
|
||||
« Jalon » dans le panneau, ce qui ramène sa fin sur son début. La bascule joue dans les deux sens
|
||||
à tout moment.
|
||||
Il n'y a pas de commande séparée pour créer un **jalon** ni pour marquer une **date imposée** : ce
|
||||
sont deux cases du panneau de la phase, indépendantes l'une de l'autre comme du statut. Cocher
|
||||
« 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 à
|
||||
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 |
|
||||
|---|---|
|
||||
| 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 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) |
|
||||
| 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 un nom de phase hors écran | Ramène la frise sur elle |
|
||||
| `←` `→` | Décale d'un jour la phase du curseur |
|
||||
| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour |
|
||||
| `←` `→` | Décale d'un jour la phase du curseur — ou tout le projet, sur sa ligne |
|
||||
| `Maj` + `←` `→` | Allonge ou raccourcit la phase d'un jour |
|
||||
| `Échap` | Annule le glisser en cours, ou ferme le panneau |
|
||||
| Clic dans une zone vide | Ferme le panneau ouvert |
|
||||
| `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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- **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
|
||||
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.
|
||||
@@ -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.
|
||||
- **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
|
||||
forme d'ensemble, toujours visible sur la ligne du projet et toujours rendue pareil. Elle ne se
|
||||
glisse pas.
|
||||
forme d'ensemble, toujours visible sur la ligne du projet et toujours rendue pareil. Elle se
|
||||
glisse pour repousser le projet entier.
|
||||
- **Couloirs pliables** : déplié, chaque phase occupe sa ligne sous la barre cumulative ; plié, il
|
||||
ne reste que celle-ci.
|
||||
- **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/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/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/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 |
|
||||
|
||||
@@ -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. */
|
||||
.poignee {
|
||||
position: absolute;
|
||||
@@ -884,6 +902,15 @@ button[aria-pressed="true"] {
|
||||
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 {
|
||||
outline: 2px solid var(--texte);
|
||||
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
|
||||
n'a pas à changer d'aspect quand on ouvre le couloir.
|
||||
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.
|
||||
Lecture seule : voir docs/decisions.md, section 8. */
|
||||
dès que le projet est déplié.
|
||||
Elle se glisse pour repousser le projet : voir docs/decisions.md, section 8. */
|
||||
.barre-cumulative {
|
||||
position: absolute;
|
||||
top: calc((var(--hauteur-ligne) - var(--hauteur-cumulative)) / 2);
|
||||
@@ -921,6 +948,25 @@ button[aria-pressed="true"] {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 4,
|
||||
"version": 6,
|
||||
"projects": [
|
||||
{
|
||||
"id": "site-web",
|
||||
@@ -99,6 +99,16 @@
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"name": "Bascule générale",
|
||||
|
||||
@@ -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
|
||||
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
|
||||
aurait pu la rendre glissable pour décaler tout le projet en bloc.
|
||||
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. Elle
|
||||
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
|
||||
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. Elle sert à regarder ; pour modifier, on agit
|
||||
sur les phases.
|
||||
**Ce n'était pas le cas au départ, et le motif du refus mérite d'être conservé** : 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 qu'on voie ce qui bouge. L'argument était
|
||||
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
|
||||
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
|
||||
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 —
|
||||
puisqu'elle voisine avec elles dès que le projet est déplié, et qu'elle ne se glisse pas. Le fantôme
|
||||
de référence se cale dessous, d'où sa variante `--cumulatif`.
|
||||
puisqu'elle voisine avec elles dès que le projet est déplié. Le fantôme de référence se cale dessous,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
@@ -9,7 +9,7 @@ dans un diff git et éditable à la main.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 5,
|
||||
"version": 6,
|
||||
"projects": [
|
||||
{
|
||||
"id": "site-web",
|
||||
@@ -29,6 +29,7 @@ dans un diff git et éditable à la main.
|
||||
"end": "2026-08-20",
|
||||
"status": "done",
|
||||
"milestone": false,
|
||||
"fixed": false,
|
||||
"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" }
|
||||
},
|
||||
@@ -39,7 +40,8 @@ dans un diff git et éditable à la main.
|
||||
"end": "2026-11-02",
|
||||
"status": "todo",
|
||||
"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 |
|
||||
|---|---|---|
|
||||
| `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. |
|
||||
|
||||
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
|
||||
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é*,
|
||||
un projet qui n'en a pas est *envisagé sans horizon*, et il se signalera comme réclamant une 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.
|
||||
Aucune de ces migrations ne demande ni ne devine quoi que ce soit. Un projet qui a des phases est
|
||||
*engagé*, un projet qui n'en a pas est *envisagé sans horizon*, et il se signalera comme réclamant une
|
||||
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. 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
|
||||
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`. |
|
||||
| `status` | chaîne | `todo`, `doing`, `done` ou `blocked`. |
|
||||
| `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. |
|
||||
| `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`).
|
||||
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`.
|
||||
- `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.
|
||||
- `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,
|
||||
@@ -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
|
||||
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.
|
||||
- **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
|
||||
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.
|
||||
|
||||
36
index.html
36
index.html
@@ -165,6 +165,18 @@
|
||||
<input type="text" name="name" required autocomplete="off" />
|
||||
</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">
|
||||
<label>
|
||||
Début
|
||||
@@ -176,6 +188,16 @@
|
||||
</label>
|
||||
</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>
|
||||
Statut
|
||||
<select name="status">
|
||||
@@ -186,11 +208,6 @@
|
||||
</select>
|
||||
</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
|
||||
et zone de saisie, le tout identique dans les deux panneaux. Un seul
|
||||
@@ -369,12 +386,15 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Modifier la phase du curseur</h3>
|
||||
<h3>Modifier la ligne du curseur</h3>
|
||||
<dl class="aide">
|
||||
<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>
|
||||
<dd>Allonge, raccourcit d'un jour</dd>
|
||||
<dd>Allonge, raccourcit la phase d'un jour</dd>
|
||||
<dt><kbd>Espace</kbd></dt>
|
||||
<dd>Statut suivant : à venir, en cours, terminé, bloqué</dd>
|
||||
<dt><kbd>Entrée</kbd> <kbd>e</kbd></dt>
|
||||
|
||||
32
js/app.js
32
js/app.js
@@ -22,12 +22,14 @@ import {
|
||||
STATUTS,
|
||||
ajouterJours,
|
||||
ajouterPhase,
|
||||
ancreDecalage,
|
||||
aujourdhui,
|
||||
bornesPlanning,
|
||||
cleTag,
|
||||
clotureEcraseDesStatuts,
|
||||
creerPhase,
|
||||
creerProjet,
|
||||
decalerProjet,
|
||||
ecarterProjet,
|
||||
etatProjet,
|
||||
figerReference,
|
||||
@@ -869,6 +871,17 @@ installerGlisser(refs.couloirs, {
|
||||
}
|
||||
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) {
|
||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||
if (projet) ouvrirProjet(projet);
|
||||
@@ -1315,16 +1328,29 @@ refs.frise.addEventListener(
|
||||
{ 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) => {
|
||||
// Une ligne de projet n'a pas de dates : les flèches n'y ont rien à décaler.
|
||||
if (!curseur?.phase) return;
|
||||
if (!curseur) 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;
|
||||
|
||||
// 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();
|
||||
const trouve = localiser(curseur.projet, curseur.phase);
|
||||
if (!trouve) return;
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
* 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
|
||||
* pas : les **flèches modifient** la phase du curseur (app.js, hérité), les
|
||||
* **lettres déplacent** le curseur et commandent. Aucun raccourci documenté n'a
|
||||
* changé de sens.
|
||||
* pas : les **flèches modifient** ce que porte le curseur — la phase, ou le
|
||||
* projet entier quand il est sur sa ligne (app.js, hérité) —, les **lettres
|
||||
* déplacent** le curseur et commandent. Aucun raccourci documenté n'a changé de
|
||||
* sens.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,7 @@ export function creerPanneau(refs, rappels) {
|
||||
formulaire.end.value = phase.end;
|
||||
formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo';
|
||||
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 :
|
||||
// on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour
|
||||
// y écrire quelque chose.
|
||||
@@ -77,6 +78,7 @@ export function creerPanneau(refs, rappels) {
|
||||
end: fin,
|
||||
status: formulaire.status.value,
|
||||
milestone: jalon,
|
||||
fixed: formulaire.fixed.checked,
|
||||
notes: formulaire.notes.value,
|
||||
});
|
||||
|
||||
@@ -170,6 +172,7 @@ export function creerPanneau(refs, rappels) {
|
||||
// serait le pire moment pour changer d'état.
|
||||
if (formulaire.notes !== actif) blocNotes.definir(phase.notes);
|
||||
if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone;
|
||||
if (formulaire.fixed !== actif) formulaire.fixed.checked = phase.fixed;
|
||||
champFin.hidden = phase.milestone;
|
||||
},
|
||||
|
||||
@@ -198,6 +201,7 @@ function sontEgales(a, b) {
|
||||
a.end === b.end &&
|
||||
a.status === b.status &&
|
||||
a.milestone === b.milestone &&
|
||||
a.fixed === b.fixed &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
|
||||
107
js/drag.js
107
js/drag.js
@@ -1,6 +1,12 @@
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
@@ -15,15 +21,20 @@
|
||||
import {
|
||||
ajouterJours,
|
||||
ajouterMois,
|
||||
ancreDecalage,
|
||||
decalerProjet,
|
||||
deplacerPhase,
|
||||
dernierJourDuMois,
|
||||
ecartJours,
|
||||
ecartMois,
|
||||
lundiDeLaSemaine,
|
||||
moisDe,
|
||||
phasesDecalables,
|
||||
premierJourDuMois,
|
||||
redimensionnerPhase,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
import { poserBarreCumulative } from './timeline.js';
|
||||
|
||||
/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */
|
||||
const SEUIL_GLISSER = 4;
|
||||
@@ -45,12 +56,43 @@ export function installerGlisser(conteneur, rappels) {
|
||||
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
||||
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;
|
||||
|
||||
const poignee = evenement.target.closest('[data-role="poignee"]');
|
||||
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
|
||||
// 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).
|
||||
@@ -122,6 +164,14 @@ export function installerGlisser(conteneur, rappels) {
|
||||
|
||||
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') {
|
||||
const avant = acheve.horizonInitial;
|
||||
const apres = acheve.horizonCourant;
|
||||
@@ -160,7 +210,8 @@ export function installerGlisser(conteneur, rappels) {
|
||||
geste = null;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -200,6 +251,7 @@ function relacher(geste, pointerId) {
|
||||
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
||||
*/
|
||||
function appliquerGeste(geste, decalage, echelle) {
|
||||
if (geste.sorte === 'cumul') return appliquerGesteCumul(geste, decalage, echelle);
|
||||
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
|
||||
|
||||
const { phaseInitiale: phase, mode } = geste;
|
||||
@@ -229,6 +281,57 @@ function appliquerGeste(geste, decalage, echelle) {
|
||||
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** :
|
||||
* l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire
|
||||
|
||||
100
js/model.js
100
js/model.js
@@ -15,16 +15,17 @@
|
||||
/**
|
||||
* Version du format de fichier.
|
||||
*
|
||||
* Elle a changé pour la dernière fois quand le projet a reçu son cycle de vie —
|
||||
* `horizon`, `completedDate`, `discardedDate` (v5). Un champ ajouté n'oblige
|
||||
* pourtant à rien : la v4 se relit sans encombre, et le validateur ne refuse
|
||||
* qu'un fichier *plus récent* que lui. C'est justement là qu'est la raison
|
||||
* d'incrémenter — le validateur reconstruit chaque projet champ par champ et
|
||||
* laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire antérieur
|
||||
* ouvrirait un fichier v5 sans broncher et en effacerait tous les horizons à la
|
||||
* première sauvegarde.
|
||||
* Elle a changé pour la dernière fois quand la phase a reçu sa date imposée —
|
||||
* `fixed` (v6) ; avant cela, quand le projet a reçu son cycle de vie (v5). Un
|
||||
* champ ajouté n'oblige pourtant à rien : la v5 se relit sans encombre, et le
|
||||
* validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement là
|
||||
* qu'est la raison d'incrémenter — le validateur reconstruit chaque projet champ
|
||||
* par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, 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 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'];
|
||||
|
||||
@@ -652,6 +653,10 @@ function validerPhase(phase, rang, repereProjet, idsPhases) {
|
||||
// chargement : la valeur est cosmétique, contrairement aux dates.
|
||||
status: STATUTS.includes(phase.status) ? phase.status : 'todo',
|
||||
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 : '',
|
||||
...(reference ? { baseline: reference } : {}),
|
||||
};
|
||||
@@ -996,6 +1001,7 @@ export function creerPhase(projet, nom, debut, fin, options = {}) {
|
||||
end: jalon ? debut : fin,
|
||||
status: STATUTS.includes(options.status) ? options.status : 'todo',
|
||||
milestone: jalon,
|
||||
fixed: Boolean(options.fixed),
|
||||
notes: options.notes || '',
|
||||
};
|
||||
}
|
||||
@@ -1006,6 +1012,82 @@ export function deplacerPhase(phase, nouveauDebut) {
|
||||
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.
|
||||
* La borne opposée fait butée : une phase ne peut pas se retourner.
|
||||
|
||||
113
js/timeline.js
113
js/timeline.js
@@ -29,6 +29,7 @@ import {
|
||||
formaterHorizon,
|
||||
formulerDerive,
|
||||
lundiDeLaSemaine,
|
||||
phasesDecalables,
|
||||
projetADater,
|
||||
projetFiltre,
|
||||
projetFiltreEtat,
|
||||
@@ -307,7 +308,7 @@ function construireLibelles(projets, tagsActifs, curseur) {
|
||||
const ligne = document.createElement('div');
|
||||
ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
|
||||
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.phase = phase.id;
|
||||
elements.push(ligne);
|
||||
@@ -506,9 +507,16 @@ function construirePhase(projet, phase, echelle, curseur) {
|
||||
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) {
|
||||
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.dataset.projet = projet.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.
|
||||
losange.title =
|
||||
`${phase.name} — ${formaterDateLongue(phase.start)}` +
|
||||
`\n${LIBELLES_STATUT[phase.status]}` +
|
||||
`\n${LIBELLES_STATUT[phase.status]}${mentionImposee(phase)}` +
|
||||
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
|
||||
elements.push(losange);
|
||||
|
||||
@@ -526,13 +534,19 @@ function construirePhase(projet, phase, echelle, curseur) {
|
||||
nom.className = 'jalon-nom';
|
||||
nom.style.left = `${echelle.x(phase.start)}px`;
|
||||
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);
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
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.width = `${echelle.largeurDe(phase.start, phase.end)}px`;
|
||||
barre.dataset.projet = projet.id;
|
||||
@@ -540,7 +554,7 @@ function construirePhase(projet, phase, echelle, curseur) {
|
||||
barre.dataset.role = 'barre';
|
||||
barre.title =
|
||||
`${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
|
||||
// source markdown rendrait la note moins lisible qu'avant qu'on ne
|
||||
// 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.
|
||||
*
|
||||
* Affichée qu'il soit plié ou déplié. Lecture seule dans les deux cas : aucun
|
||||
* `data-role`, donc drag.js l'ignore (docs/decisions.md, section 8).
|
||||
* Affichée qu'il soit plié ou déplié. Elle se saisit pour repousser le projet
|
||||
* 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) {
|
||||
const bornes = bornesProjet(projet);
|
||||
if (!bornes) return null;
|
||||
|
||||
const decalables = phasesDecalables(projet);
|
||||
|
||||
const conteneur = document.createElement('div');
|
||||
conteneur.className = 'barre-cumulative';
|
||||
conteneur.style.left = `${echelle.x(bornes.start)}px`;
|
||||
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
|
||||
conteneur.dataset.projet = projet.id;
|
||||
// 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));
|
||||
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}` : '') +
|
||||
// Déplié, les barres modifiables sont juste en dessous : le dire serait
|
||||
// enfoncer une porte ouverte.
|
||||
(projet.collapsed ? '\n\nDéplier le projet pour modifier les dates.' : '');
|
||||
`\n\n${modeDEmploiDecalage(projet, decalables.length)}`;
|
||||
|
||||
const origine = echelle.x(bornes.start);
|
||||
for (const phase of projet.phases) {
|
||||
const segment = document.createElement('div');
|
||||
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`;
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
2
main.go
2
main.go
@@ -67,7 +67,7 @@ const portsTentes = 10
|
||||
// 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
|
||||
// ancienne, et la réécrirait au format courant.
|
||||
var donneesInitiales = []byte(`{"version": 4, "projects": []}`)
|
||||
var donneesInitiales = []byte(`{"version": 6, "projects": []}`)
|
||||
|
||||
type serveur struct {
|
||||
fichierDonnees string
|
||||
|
||||
@@ -73,9 +73,9 @@ func TestGetSansFichierRenvoiePlanningVide(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
// Le format courant est la version 4 (js/model.js, VERSION_FORMAT).
|
||||
if planning.Version != 4 {
|
||||
t.Errorf("version = %d, attendu 4", planning.Version)
|
||||
// Le format courant est la version 6 (js/model.js, VERSION_FORMAT).
|
||||
if planning.Version != 6 {
|
||||
t.Errorf("version = %d, attendu 6", planning.Version)
|
||||
}
|
||||
if len(planning.Projects) != 0 {
|
||||
t.Errorf("projects = %v, attendu vide", planning.Projects)
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
bornesPlanning,
|
||||
bornesProjet,
|
||||
bornesReference,
|
||||
ancreDecalage,
|
||||
cleTag,
|
||||
clotureEcraseDesStatuts,
|
||||
creerPhase,
|
||||
creerProjet,
|
||||
dateValide,
|
||||
decalerHorizon,
|
||||
decalerProjet,
|
||||
definirHorizon,
|
||||
deplacerPhase,
|
||||
dernierJourDuMois,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
modifierPhase,
|
||||
moisValide,
|
||||
normaliserTags,
|
||||
phasesDecalables,
|
||||
planningVide,
|
||||
pourEcriture,
|
||||
premierJourDuMois,
|
||||
@@ -368,6 +371,24 @@ describe('validerPlanning', () => {
|
||||
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', () => {
|
||||
const resultat = validerPlanning({
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
describe('cycle de vie', () => {
|
||||
|
||||
Reference in New Issue
Block a user