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
|
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 |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
36
index.html
36
index.html
@@ -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>
|
||||||
|
|||||||
32
js/app.js
32
js/app.js
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
107
js/drag.js
107
js/drag.js
@@ -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
|
||||||
|
|||||||
100
js/model.js
100
js/model.js
@@ -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.
|
||||||
|
|||||||
113
js/timeline.js
113
js/timeline.js
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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
|
// 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user