Compare commits

...

2 Commits

Author SHA1 Message Date
815e144d9a Rendre lisibles le statut et la date imposée
All checks were successful
Build / build (push) Successful in 1m2s
Statut : quatre boutons peints de leur texture, si bien que le panneau
sert enfin de légende. « En cours » se raye et « terminé » redevient vif
— la trame les distingue, la teinte n'a plus à le faire.

Date imposée : un cadre tireté cerne la forme entière. Les taquets
tombaient sous les poignées de redimensionnement et disparaissaient sur
une barre courte. Son libellé passe dans le modèle, ce qui la fait
apparaître dans la vue par mois, qui la taisait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:17:15 +02:00
4a513f9a59 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>
2026-08-10 18:07:31 +02:00
16 changed files with 1302 additions and 112 deletions

View File

@@ -93,9 +93,20 @@ 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, cocher « Date imposée » encadre les deux champs de date d'un
trait tireté — le même qu'on verra sur la barre. Les deux bascules jouent dans les deux sens à tout
moment.
Le **statut** se choisit sur quatre boutons, chacun peint de la texture qu'il donne à la barre. Deux
traits suffisent à les lire : c'est **plein** quand rien ne bouge et **rayé** quand quelque chose se
passe ; c'est la **couleur pleine** du projet dès qu'une phase a commencé, et une teinte **diluée**
tant qu'elle n'a pas commencé. D'où : à venir pâle et bordé, en cours plein et rayé, terminé plein et
nu, bloqué rayé de rouge — seule teinte étrangère, parce que c'est la seule chose qui ne parle pas
d'avancement. La couleur reste celle du projet : elle distingue les couloirs, jamais l'avancement. Le
panneau sert ainsi de légende — il n'y en a pas d'autre, et il n'y a rien à apprendre avant de s'en
servir.
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 +117,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 +134,35 @@ 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 sa forme se cerne alors d'un **trait tireté**
une barre comme un losange, et jusqu'aux deux champs de date du panneau. À ne pas confondre avec le
contour plein de la phase sélectionnée : celui-ci est dedans et discontinu, celui-là dehors et
franc. 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
@@ -269,6 +310,10 @@ mois —, chez qui — le projet et ses tags —, puis quoi — l'intitulé de l
enfin son statut. Une phase longue n'est **pas** répétée dans les mois qu'elle traverse : elle
n'apparaît qu'à son mois de départ, avec sa date de fin écrite en clair.
La petite marque en tête de ligne reprend les formes de la frise — barre pour une tâche, losange pour
un jalon, texture du statut, cadre tireté pour une **date imposée**. C'est ici qu'elle compte le
plus : une liste d'échéances doit dire lesquelles ne vous appartiennent pas.
Les phases qui portent des **notes** le signalent par un triangle en tête de ligne, le même que
celui qui plie un projet sur la frise. Un clic **n'importe où sur la ligne** déplie la note en
dessous, un second la referme. Elle s'y affiche rendue, comme dans le panneau. Rien n'est affiché
@@ -315,7 +360,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 +372,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 +471,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 |

View File

@@ -804,19 +804,52 @@ button[aria-pressed="true"] {
}
/* Le statut se lit à la texture de la barre, pas à sa couleur : la couleur
appartient au projet, elle sert à distinguer les couloirs entre eux. */
appartient au projet, elle sert à distinguer les couloirs entre eux.
Une seule grammaire pour les quatre, et elle tient en deux traits :
- **plein** pour un état stable, **rayé** pour ce qui bouge ou coince ;
- la couleur du projet **pleine** dès qu'une phase a commencé, **diluée** tant
qu'elle n'a pas commencé.
D'où : à venir dilué et bordé, en cours plein et rayé, terminé plein et nu,
bloqué rayé de rouge — seule teinte étrangère, parce que c'est la seule chose
qui ne parle pas d'avancement.
« Terminé » a longtemps été assourdi, d'abord d'un gris fixe puis d'une
dilution. Les deux disaient « c'est derrière nous » en donnant une frise
morte, où la majorité des barres d'un projet avancé sont éteintes. C'est la
rayure d'« en cours » qui a permis de le rendre vif : la trame distingue
désormais ces deux-là, la teinte n'a plus à s'en charger. */
.barre--todo {
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
color: var(--texte);
border: 1px solid var(--couleur-projet);
}
/* Rayée, et non pleine : posée à côté d'une barre terminée — un aplat de la même
teinte —, une couleur pleine ne se distinguait pas assez pour qu'on lise
l'avancement d'un couloir en diagonale. La rayure lui donne ce que les trois
autres textures ont déjà : une trame, et pas seulement une nuance. Elle mêle
la couleur du projet à elle-même diluée, là où celle d'une phase bloquée y
mêle du rouge : le motif est le même, la teinte dit lequel des deux on
regarde. */
.barre--doing {
background: var(--couleur-projet);
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 6px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 6px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 12px
);
}
/* La couleur du projet, pleine et nue — c'est la seule des quatre à n'avoir
aucune marque, et c'est bien ce qu'on veut dire : il n'y a plus rien à en
suivre. Déclarée quand même, et pas laissée au fond de `.barre` : ce qui vaut
pour un statut doit se lire à l'endroit où on lit les statuts. */
.barre--done {
background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b);
background: var(--couleur-projet);
}
.barre--blocked {
@@ -829,6 +862,42 @@ 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).
La forme est **cerclée**, tout entière : c'est tenu de partout, et le trait
suit le contour quel qu'il soit — une barre, un losange. Deux taquets aux
extrémités disaient la même chose et le disaient mal : sur une barre de deux
jours ils l'avalaient, sur une barre à venir ils se lisaient comme une bordure
épaissie, et ils occupaient la place où le survol révèle les poignées de
redimensionnement — le signe « cette date ne vous appartient pas » tombait
exactement là où l'outil dit « tirez ici pour la changer ».
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.
Le trait est **tireté**, et c'est ce qui le sépare du contour de la sélection,
plein. Un cerclage plein avait été essayé d'abord : à la taille réelle des
barres, on ne le distinguait pas d'une phase simplement sélectionnée. Le tiret
dit d'ailleurs ce qu'il faut — ce n'est pas nous qui traçons cette ligne.
Il vit sur un `::after` plutôt que sur la barre : un élément n'a qu'un
`outline`, et c'est la sélection qui le prend. Les deux marques se superposent
donc sans se gêner sur une phase imposée qu'on vient de sélectionner — un
cadre tireté dedans, un contour plein dehors. */
.barre--imposee::after,
.jalon--imposee::after {
content: '';
position: absolute;
inset: 0;
border: 2px dashed var(--texte);
border-radius: inherit;
/* Le losange tourne, son cadre avec lui : le pseudo-élément hérite de la
rotation du jalon, et n'a donc rien à en savoir. */
pointer-events: none;
}
/* Poignées de redimensionnement, révélées au survol de la barre. */
.poignee {
position: absolute;
@@ -876,14 +945,35 @@ button[aria-pressed="true"] {
box-shadow: var(--ombre);
}
/* Mêmes textures que les barres, hachures resserrées : le losange fait 14px de
côté, une rayure de 6px n'y tiendrait pas deux fois. */
.jalon--doing {
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 6px
);
}
.jalon--done {
background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b);
background: var(--couleur-projet);
}
.jalon--blocked {
background: var(--danger);
}
/* Rien ici pour `.jalon--imposee` : son cadre tireté est déclaré avec celui des
barres, plus haut, à la même épaisseur. C'est la même marque, et un losange
n'est qu'une autre forme à cerner — son `::after` s'appuie sur le
`position: absolute` que `.jalon` porte déjà, et tourne avec lui.
Le trait avait été affiné à 1px, au motif que la longueur des tirets suit
l'épaisseur et qu'un losange de 14px n'en porte que quelques-uns. Mais c'est
la lisibilité qui tranche : à 1px sur une forme de cette taille, la marque
s'efface. Mieux vaut peu de tirets qu'on voit que beaucoup qu'on devine. */
.jalon--selectionne {
outline: 2px solid var(--texte);
outline-offset: 1px;
@@ -907,8 +997,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 +1011,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;
@@ -1121,7 +1230,14 @@ button[aria-pressed="true"] {
et le second garde le droit de rétrécir sous la taille de son contenu — sans
lui, un flex item refuse de descendre sous sa hauteur intrinsèque et c'est la
page entière qui déborderait au lieu du corps qui défile. */
/* `--couleur-projet` se déclare ici, sur l'élément même que detail.js peint en
style inline à l'ouverture : le style inline l'emporte sur cette règle, et
l'accent ne sert que de repli tant qu'aucune phase n'est ouverte — sans quoi
les `color-mix` des textures de statut se peindraient dans le vide. La
déclarer plus bas, sur le groupe de boutons, ferait l'inverse : une valeur
posée sur un descendant gagne toujours contre celle dont il hérite. */
.panneau__corps {
--couleur-projet: var(--accent);
flex: 1;
min-height: 0;
padding: 16px;
@@ -1142,7 +1258,6 @@ button[aria-pressed="true"] {
.panneau__corps input[type="text"],
.panneau__corps input[type="date"],
.panneau__corps select,
.panneau__corps textarea {
font: inherit;
padding: 6px 8px;
@@ -1163,6 +1278,122 @@ button[aria-pressed="true"] {
gap: 10px;
}
/* Le cerclage de la barre imposée, reporté sur les deux champs que la case
qualifie : cocher « date imposée » n'avait jusque-là aucun effet visible dans
le panneau, alors que la conséquence porte précisément sur ces dates-là.
Le padding est repris par une marge négative de même valeur : les champs ne
bougent pas d'un pixel quand la case change d'état, seul le contour apparaît.
Il déborde de 8px dans les 16px de padding du corps, donc sans jamais sortir
du panneau. */
.champ-double--imposee {
padding: 6px 8px;
margin: -6px -8px;
border-radius: var(--rayon);
outline: 2px dashed var(--texte);
outline-offset: -2px;
}
/* --- statut, en quatre boutons ------------------------------------------- */
/* Chacun peint de la texture qu'il donne à la barre sur la frise : voir
index.html pour le pourquoi. La couleur, elle, vient du formulaire — cf. le
repli déclaré sur `.panneau__corps`. */
.statuts {
margin: 0;
padding: 0;
border: 0;
}
.statuts legend {
padding: 0 0 4px;
font-size: 12px;
font-weight: 500;
color: var(--texte-doux);
}
.statuts__choix {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
/* Le label *est* le bouton. Le radio natif s'efface sans quitter le DOM : c'est
lui qui porte la valeur, réunit le groupe et répond aux flèches. */
.panneau__corps .statut {
flex-direction: row;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon);
font-weight: 400;
color: var(--texte);
cursor: pointer;
}
.statut input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.statut:hover {
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
.statut:has(input:checked) {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
font-weight: 600;
}
/* Le focus se pose sur un radio devenu invisible : c'est au bouton de le
montrer, sans quoi la navigation aux flèches se ferait à l'aveugle. */
.statut:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* Les quatre textures, reprises telles quelles des barres — hachures resserrées
à l'échelle de la marque, comme celles de la vue par mois. */
.statut__marque {
flex: none;
width: 18px;
height: 9px;
border-radius: 2px;
background: var(--couleur-projet);
}
.statut--todo .statut__marque {
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
border: 1px solid var(--couleur-projet);
}
.statut--doing .statut__marque {
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 6px
);
}
.statut--done .statut__marque {
background: var(--couleur-projet);
}
.statut--blocked .statut__marque {
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 3px,
color-mix(in srgb, var(--danger) 75%, var(--couleur-projet)) 3px,
color-mix(in srgb, var(--danger) 75%, var(--couleur-projet)) 6px
);
}
.case {
flex-direction: row !important;
align-items: center;
@@ -1918,8 +2149,18 @@ kbd {
border: 1px solid var(--couleur-projet);
}
.mois-entree--doing .mois-entree__marque {
background: repeating-linear-gradient(
45deg,
var(--couleur-projet),
var(--couleur-projet) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 3px,
color-mix(in srgb, var(--couleur-projet) 50%, var(--surface)) 6px
);
}
.mois-entree--done .mois-entree__marque {
background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b);
background: var(--couleur-projet);
}
.mois-entree--blocked .mois-entree__marque {
@@ -1944,6 +2185,16 @@ kbd {
background: var(--danger);
}
/* Le même cadre tireté que sur la frise, à l'échelle de la marque : 1px sur huit
de haut plutôt que 2 sur vingt-six. En `border` et non sur un `::after` — la
marque n'a pas de sélection à qui laisser la place, et `border-box` lui garde
sa taille. Déclaré après les quatre textures de statut, dont il ne prend pas
la place : c'est une phase à venir, en cours ou bloquée qui se trouve en plus
tenue par le dehors. */
.mois-entree--imposee .mois-entree__marque {
border: 1px dashed var(--texte);
}
/* Chiffres tabulaires : les quantièmes s'alignent d'une ligne à l'autre, ce qui
fait de la colonne une échelle lisible plutôt qu'une pile de textes. */
.mois-entree__dates {

View File

@@ -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",

View File

@@ -102,15 +102,62 @@ Gérer les jours ouvrés impose de faire passer chaque calcul de date par un hel
aussitôt la question des congés et des jours fériés — donc un calendrier à tenir à jour. Sur des
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,189 @@ 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.
C'est ce qui avait été retenu ici, et c'est ce que la section 29 a défait : la marque était trop
faible et mal placée. Elle est aujourd'hui un **cadre tireté** qui cerne la forme entière, barre ou
losange. Le reste du raisonnement tient toujours — elle se cumule avec les quatre textures de statut
au lieu de leur disputer la place, et ne touche pas à la géométrie : la barre garde exactement la
largeur de ses dates.
### 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.
## 29. Rendre lisibles le statut et la date imposée
La section 28 a posé la date imposée ; l'usage a montré que ni elle ni le statut ne se lisaient bien
là où on les cherche. Quatre corrections, de la plus factuelle à la plus discutable.
### Le libellé d'une phase vit dans le modèle
« En cours — date imposée, non emportée par un décalage du projet » se recomposait sur quatre sites :
les trois infobulles de la frise, et celle de la vue par mois. La quatrième avait oublié la mention,
si bien que la vue des échéances — celle où une date déléguée compte le plus — taisait ce que la
frise annonçait sur chaque barre.
D'où `libelleStatut(phase)` dans `model.js`, à côté de la table dont il se sert. Le mettre dans une
vue aurait reposé la même question à la suivante ; le modèle est le seul endroit que les deux vues
connaissent déjà. La vue par mois a reçu du même coup la marque d'une date imposée sur sa puce de
ligne, qui reprend déjà les formes de la frise : le vocabulaire visuel est le même des deux côtés, on
ne l'apprend qu'une fois.
### Le statut passe de la liste déroulante à quatre boutons
Une liste déroulante montre une valeur sur quatre, n'en dessine aucune, et demande deux clics. Les
quatre boutons montrent les quatre, chacun peint de la **texture qu'il donne à la barre**, et le
panneau devient ainsi la légende que l'outil n'avait nulle part — au seul moment où on en a besoin,
celui du choix, et sans coûter d'espace permanent au planning.
Une légende posée sur la frise avait été envisagée d'abord. Elle occupe de la place en permanence
pour une chose qui s'apprend une fois, et elle se lit loin de l'objet qu'elle explique.
Les textures se peignent dans la **couleur du projet qu'on édite** : `detail.js` pose
`--couleur-projet` sur le formulaire à l'ouverture. Le repli se déclare sur `.panneau__corps`, donc
sur l'élément même que le style inline surcharge — le déclarer sur le groupe de boutons faisait
l'inverse, une valeur posée sur un descendant gagnant toujours contre celle dont il hérite. C'est le
genre de détail qui se paie en minutes de recherche : la teinte du repli est une couleur plausible,
et rien ne signale qu'elle n'est pas celle du projet.
### Les quatre textures sur une seule échelle
`doing` était un aplat de la couleur du projet, `done` le même aplat assourdi d'un gris fixe. Deux
nuances de la même teinte, qu'on ne distinguait pas en parcourant un couloir en diagonale — alors que
*ce qui avance / ce qui est fait* est la distinction qu'on lit le plus souvent.
Deux corrections, et la seconde n'a été possible que grâce à la première.
**`doing` reçoit une rayure**, comme `blocked` en a une : le motif est le même, la teinte dit lequel
des deux on regarde — du rouge pour une phase bloquée, la couleur du projet mêlée à elle-même diluée
pour une phase qui avance.
**`done` redevient vif.** Le gris qui l'assourdissait n'était là que pour le séparer de `doing` ; la
rayure s'en charge désormais, et la teinte n'a plus à s'en mêler. C'est un gain net : sur un projet
avancé, la majorité des barres sont terminées, et les éteindre toutes donnait une frise morte où le
travail accompli avait l'air d'un brouillon. Une dilution à mi-chemin avait été essayée d'abord —
même famille de couleur, mais toujours délavée, donc toujours le même reproche.
La grammaire tient maintenant en deux traits, et c'est cette économie qui la rend lisible :
- **plein** pour un état stable, **rayé** pour ce qui bouge ou coince ;
- la couleur du projet **pleine** dès qu'une phase a commencé, **diluée** tant qu'elle n'a pas
commencé.
D'où : à venir dilué et bordé, en cours plein et rayé, terminé plein et nu, bloqué rayé de rouge —
seule teinte étrangère, parce que c'est la seule chose qui ne parle pas d'avancement. Aucune des
quatre ne se lit à une nuance qu'il faudrait comparer à une autre pour la reconnaître.
Le contraste de la rayure a été resserré au passage — la bande claire descend à 50% de dilution
plutôt que 65% : face à un `done` désormais plein, une rayure trop douce se serait lue comme un aplat
légèrement texturé.
La barre cumulative garde ses opacités : elle fait neuf pixels de haut, une rayure y serait du bruit,
et elle dit la forme d'ensemble d'un projet, pas le détail d'une phase.
### La date imposée se marque d'un cadre tireté
Les deux taquets de la section 28 ne tenaient pas leurs promesses, pour trois raisons qui se
cumulaient :
- sur une barre de deux jours, ils l'avalaient — la section 28 l'avait prévu et accepté ; à l'usage
ce n'est pas acceptable, une fenêtre de maintenance est *typiquement* courte, c'est le cas central
et non le cas limite ;
- sur une barre à venir, qui porte déjà une bordure, ils se lisaient comme une bordure épaissie aux
bouts, c'est-à-dire comme rien ;
- surtout, ils occupaient exactement la place où le survol révèle les **poignées de
redimensionnement**. Le signe « cette date ne vous appartient pas » tombait là où l'outil dit
« tirez ici pour la changer ».
La forme se cerne donc maintenant tout entière, d'un trait **tireté** — une barre, un losange, et
jusqu'au bloc des deux champs de date dans le panneau, qui ne montrait rien quand on cochait la case
alors que la conséquence porte précisément sur ces dates-là. Un seul signe partout, là où le losange
se cerclait et la barre se taquetait : c'était deux signes pour une même chose.
Le tiret n'est pas décoratif. Un cerclage **plein** a été essayé d'abord, et à la taille réelle des
barres on ne le distinguait pas d'une phase simplement sélectionnée. Discontinu, il ne se confond
plus avec rien — et il dit ce qu'il faut : ce n'est pas nous qui traçons cette ligne.
Sur la frise, le cadre vit sur un `::after` : un élément n'a qu'un `outline`, et c'est la sélection
qui le prend. Les deux marques se superposent donc sans se gêner sur une phase imposée qu'on vient de
sélectionner — un cadre tireté dedans, un contour plein dehors. Le losange l'affine à 1px : la
longueur des tirets suit l'épaisseur du trait, et 2px en posait de trop longs pour 14px de côté.
Dans le panneau, le padding qui porte le cadre est repris par une marge négative de même valeur : les
champs ne bougent pas d'un pixel quand la case change d'état, seul le trait apparaît.
### Ce que `detail.js` a appris de la section 28
Ajouter `fixed` y avait demandé quatre retouches dispersées — remplissage, application, rafraîchissement,
comparaison — qu'on ne savait liées qu'en les cherchant. Les champs sont maintenant une liste unique
que ces fonctions parcourent. `notes` en reste dehors : son contrôle est bâti par `notes.js` et se
remplit par lui, jamais en écrivant dans le `textarea`, sans quoi l'aperçu markdown ne suivrait pas.
Le groupe de radios a demandé un détour : `formulaire.status` n'est plus un contrôle mais la liste des
quatre, et n'est donc jamais l'élément focalisé. Sans `aLeFocus()`, le panneau réécrirait le statut
sous les flèches de qui est en train de le choisir.

View File

@@ -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.

View File

@@ -165,7 +165,25 @@
<input type="text" name="name" required autocomplete="off" />
</label>
<div class="champ-double">
<!--
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>
<!--
Le bloc se cerne du cadre tireté d'une date imposée quand la case
ci-dessous est cochée : elle agit sur ces deux champs-là, sa
conséquence doit se voir là où elle joue. Même dessin que sur la barre
de la frise — ce qui s'apprend d'un côté se relit de l'autre.
-->
<div class="champ-double" id="champ-dates">
<label>
Début
<input type="date" name="start" required />
@@ -176,20 +194,58 @@
</label>
</div>
<label>
Statut
<select name="status">
<option value="todo">À venir</option>
<option value="doing">En cours</option>
<option value="done">Terminé</option>
<option value="blocked">Bloqué</option>
</select>
<!--
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 class="case">
<input type="checkbox" name="milestone" />
Jalon (une seule date, affichée en losange)
</label>
<!--
Quatre boutons plutôt qu'une liste déroulante, chacun peint de la
texture qu'il donne à la barre sur la frise. Le panneau devient ainsi
sa propre légende, au seul moment où l'on en a besoin — celui du choix
—, et sans coûter d'espace permanent au planning. Une liste déroulante
cachait trois valeurs sur quatre, n'en montrait aucune texture, et
demandait deux clics là où il en faut un.
Les quatre `input` partagent le nom `status` : `formulaire.status`
reste ce qu'il était pour detail.js, une valeur qui se lit et s'écrit.
-->
<fieldset class="statuts">
<legend>Statut</legend>
<!--
Les quatre choix vivent dans un `div` et non directement sous le
`fieldset` : un `legend` n'est pas un enfant comme les autres, et les
navigateurs le sortent du flux de son parent — le mettre en grille
aurait fait dépendre la mise en page d'un cas particulier.
-->
<div class="statuts__choix">
<label class="statut statut--todo">
<input type="radio" name="status" value="todo" />
<span class="statut__marque"></span>
À venir
</label>
<label class="statut statut--doing">
<input type="radio" name="status" value="doing" />
<span class="statut__marque"></span>
En cours
</label>
<label class="statut statut--done">
<input type="radio" name="status" value="done" />
<span class="statut__marque"></span>
Terminé
</label>
<label class="statut statut--blocked">
<input type="radio" name="status" value="blocked" />
<span class="statut__marque"></span>
Bloqué
</label>
</div>
</fieldset>
<!--
Les notes sont bâties par `notes.js` : aperçu markdown, barre d'outils
@@ -369,12 +425,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>

View File

@@ -22,12 +22,14 @@ import {
STATUTS,
ajouterJours,
ajouterPhase,
ancreDecalage,
aujourdhui,
bornesPlanning,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
decalerProjet,
ecarterProjet,
etatProjet,
figerReference,
@@ -178,6 +180,7 @@ const panneau = creerPanneau(
formulaire: $('formulaire-phase'),
titre: $('panneau-titre'),
erreur: $('erreur-phase'),
champDates: $('champ-dates'),
champFin: $('champ-fin'),
notes: $('notes-phase'),
supprimer: $('supprimer-phase'),
@@ -869,6 +872,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 +1329,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;

View File

@@ -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.
*/
/**

View File

@@ -16,8 +16,31 @@
import { STATUTS, dateValide, modifierPhase } from './model.js';
import { creerBlocNotes } from './notes.js';
/**
* Les champs de la phase, et la forme de leur contrôle.
*
* Une seule liste, parcourue par tout ce qui traite les champs en bloc :
* l'affichage, le rafraîchissement et la comparaison. Ajouter `fixed` avait
* demandé quatre retouches dispersées, dont on ne savait qu'en les cherchant
* qu'elles allaient ensemble ; il n'y a plus qu'une ligne à ajouter ici, et le
* cas particulier à écrire s'il y en a un.
*
* `notes` n'y est pas : son contrôle est bâti par `notes.js` et se remplit par
* lui, jamais en écrivant directement dans le `textarea` — l'aperçu markdown ne
* suivrait pas. Il reste comparé à part, pour la même raison.
*/
const CHAMPS = [
{ nom: 'name' },
{ nom: 'start' },
{ nom: 'end' },
{ nom: 'status' },
{ nom: 'milestone', case: true },
{ nom: 'fixed', case: true },
];
export function creerPanneau(refs, rappels) {
const { panneau, formulaire, titre, erreur, champFin, notes, supprimer, fermer } = refs;
const { panneau, formulaire, titre, erreur, champDates, champFin, notes, supprimer, fermer } =
refs;
// Le bloc pose lui-même un `<textarea name="notes">` dans le formulaire, et
// émet un `input` à chaque changement — bouton de la barre d'outils ou case
@@ -35,19 +58,51 @@ export function creerPanneau(refs, rappels) {
function remplir(projet, phase) {
titre.textContent = `${projet.name}${phase.name}`;
formulaire.name.value = phase.name;
formulaire.start.value = phase.start;
formulaire.end.value = phase.end;
formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo';
formulaire.milestone.checked = phase.milestone;
for (const champ of CHAMPS) ecrire(champ, phase);
// 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.
blocNotes.definir(phase.notes, { reinitialiserMode: true });
peindre(projet);
accorderLesChamps(phase);
afficherErreur(null);
}
/**
* Donne au formulaire la couleur du projet qu'il édite : les textures des
* quatre boutons de statut s'y peignent, comme la barre qu'on regarde
* derrière le panneau. Rejoué au rafraîchissement, faute de quoi recolorer un
* projet laisserait le panneau à sa teinte d'ouverture.
*/
function peindre(projet) {
formulaire.style.setProperty('--couleur-projet', projet.color);
}
/** Écrit un champ du modèle dans son contrôle, quelle que soit sa forme. */
function ecrire(champ, phase) {
const controle = formulaire[champ.nom];
if (champ.case) controle.checked = phase[champ.nom];
// Les quatre boutons de statut portent le même nom : leur affecter une
// valeur inconnue les décocherait tous, et le panneau n'afficherait plus
// rien là où le modèle, lui, retombe sur « à venir ».
else if (champ.nom === 'status') controle.value = statutAffiche(phase);
else controle.value = phase[champ.nom];
}
/**
* Ce que le formulaire montre en plus de ses valeurs : les champs qui n'ont
* pas lieu d'être, et la marque de ceux qu'on ne décide pas.
*
* Rejoué à chaque frappe et pas seulement à l'ouverture : les deux cases
* changent ce que le reste du panneau donne à voir, et l'effet doit suivre le
* clic qui le déclenche.
*/
function accorderLesChamps(phase) {
// Un jalon tient sur une seule date : le champ « fin » n'a rien à dire.
champFin.hidden = phase.milestone;
afficherErreur(null);
// Le cadre tireté de la frise, reporté sur les dates que la case qualifie.
champDates.classList.toggle('champ-double--imposee', phase.fixed);
}
/** Lit le formulaire, applique les invariants et remonte le changement. */
@@ -77,6 +132,7 @@ export function creerPanneau(refs, rappels) {
end: fin,
status: formulaire.status.value,
milestone: jalon,
fixed: formulaire.fixed.checked,
notes: formulaire.notes.value,
});
@@ -92,7 +148,7 @@ export function creerPanneau(refs, rappels) {
afficherErreur(null);
}
champFin.hidden = jalon;
accorderLesChamps(apres);
phaseCourante = apres;
if (!sontEgales(avant, apres)) {
@@ -162,15 +218,15 @@ export function creerPanneau(refs, rappels) {
const actif = document.activeElement;
phaseCourante = phase;
titre.textContent = `${projet.name}${phase.name}`;
for (const champ of ['name', 'start', 'end', 'status']) {
if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ];
for (const champ of CHAMPS) {
if (!aLeFocus(formulaire[champ.nom], actif)) ecrire(champ, phase);
}
// Le mode n'est pas réinitialisé : basculer en aperçu sous les doigts de
// qui écrit, parce qu'un glisser sur la frise a rafraîchi le panneau,
// 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;
champFin.hidden = phase.milestone;
peindre(projet);
accorderLesChamps(phase);
},
fermer() {
@@ -192,12 +248,29 @@ export function creerPanneau(refs, rappels) {
}
function sontEgales(a, b) {
return (
a.name === b.name &&
a.start === b.start &&
a.end === b.end &&
a.status === b.status &&
a.milestone === b.milestone &&
a.notes === b.notes
);
return CHAMPS.every((champ) => a[champ.nom] === b[champ.nom]) && a.notes === b.notes;
}
/**
* Le statut que le formulaire doit montrer.
*
* Le modèle ramène un statut inconnu à « à venir » sans bloquer le chargement
* (model.js) ; le panneau fait le même choix, faute de quoi il n'afficherait
* rien là où la frise, elle, dessine une barre.
*/
function statutAffiche(phase) {
return STATUTS.includes(phase.status) ? phase.status : 'todo';
}
/**
* Ce contrôle a-t-il le focus ?
*
* Les quatre boutons de statut partagent un nom, si bien que `formulaire.status`
* est la liste des quatre et non l'un d'eux : elle n'est jamais l'élément
* focalisé, et sans ce détour le panneau réécrirait le statut sous les flèches
* de qui est en train de le choisir.
*/
function aLeFocus(controle, actif) {
if (controle instanceof RadioNodeList) return [...controle].includes(actif);
return controle === actif;
}

View File

@@ -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

View File

@@ -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'];
@@ -50,6 +51,24 @@ export const LIBELLES_STATUT = {
blocked: 'Bloqué',
};
/**
* Ce qu'une phase est aujourd'hui, en une ligne : son statut, et la mention de
* sa date imposée quand elle en porte une.
*
* Les deux se lisent ensemble — « en cours, date imposée » — et occupent le même
* registre, d'où une seule phrase plutôt qu'une ligne chacun.
*
* Ce libellé vit ici, et non dans la vue qui l'affiche, parce que les deux vues
* l'écrivent : la frise sur ses barres, ses jalons et ses libellés, la vue par
* mois sur ses lignes. Recomposé sur place, il avait fini par diverger — la vue
* par mois taisait la date imposée que la frise annonçait.
*/
export function libelleStatut(phase) {
const statut = LIBELLES_STATUT[phase.status] ?? LIBELLES_STATUT.todo;
if (!phase.fixed) return statut;
return `${statut} — date imposée, non emportée par un décalage du projet`;
}
/** Palette par défaut, parcourue à la création de chaque nouveau projet. */
export const COULEURS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
@@ -652,6 +671,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 +1019,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 +1030,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.

View File

@@ -29,6 +29,7 @@ import {
formaterHorizon,
formaterMois,
formaterPeriode,
libelleStatut,
moisDe,
projetFiltre,
projetFiltreEtat,
@@ -213,6 +214,10 @@ function construireEntreePhase({ projet, phase }, tagsActifs) {
ligne.className = `mois-entree mois-entree--${phase.status}`;
ligne.style.setProperty('--couleur-projet', projet.color);
if (phase.milestone) ligne.classList.add('mois-entree--jalon');
// La vue par mois est celle où l'on parcourt les échéances : c'est
// exactement là qu'une date qu'on ne décide pas doit se voir. Elle ne le
// faisait pas, alors que la frise l'annonçait sur chaque barre.
if (phase.fixed) ligne.classList.add('mois-entree--imposee');
// C'est cette classe qui rend la ligne entière cliquable (app.js) : sans
// notes, il n'y a rien à déplier et le clic ne doit rien promettre.
if (phase.notes) ligne.classList.add('mois-entree--commentee');
@@ -221,8 +226,8 @@ function construireEntreePhase({ projet, phase }, tagsActifs) {
ligne.append(triangle(phase.notes, phase.name));
// La marque reprend les formes de la frise — losange pour un jalon, barre
// pour une tâche — pour qu'on reconnaisse au même dessin ce qu'on a appris à
// lire là-bas.
// pour une tâche, cadre tireté pour une date imposée — pour qu'on reconnaisse
// au même dessin ce qu'on a appris à lire là-bas.
const marque = document.createElement('span');
marque.className = 'mois-entree__marque';
ligne.append(marque);
@@ -327,7 +332,7 @@ function infobulle(projet, phase) {
? `Jalon du ${formaterDateLongue(phase.start)}`
: `${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
` (${jours} jour${jours > 1 ? 's' : ''})`) +
`\n${LIBELLES_STATUT[phase.status]}` +
`\n${libelleStatut(phase)}` +
// Aplati : un `title` ne connaît que le texte. Sans cela, survoler afficherait
// la source markdown, astérisques comprises — une note *moins* lisible
// qu'avant qu'on ne l'enrichisse.

View File

@@ -13,7 +13,6 @@
import {
LIBELLES_ETAT,
LIBELLES_STATUT,
MOIS_COURTS,
MOIS_LONGS,
ajouterJours,
@@ -28,7 +27,9 @@ import {
formaterDateLongue,
formaterHorizon,
formulerDerive,
libelleStatut,
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}${libelleStatut(phase)}`;
ligne.dataset.projet = projet.id;
ligne.dataset.phase = phase.id;
elements.push(ligne);
@@ -508,7 +509,12 @@ function construirePhase(projet, phase, echelle, curseur) {
if (phase.milestone) {
const losange = document.createElement('div');
losange.className = `jalon jalon--${phase.status}${selectionnee ? ' jalon--selectionne' : ''}`;
losange.classList.add('jalon', `jalon--${phase.status}`);
// Une date imposée se lit sur le losange, 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.
if (phase.fixed) losange.classList.add('jalon--imposee');
if (selectionnee) losange.classList.add('jalon--selectionne');
losange.style.left = `${echelle.x(phase.start)}px`;
losange.dataset.projet = projet.id;
losange.dataset.phase = phase.id;
@@ -518,7 +524,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${libelleStatut(phase)}` +
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
elements.push(losange);
@@ -526,13 +532,22 @@ 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.classList.add('barre', `barre--${phase.status}`);
// Voir le losange plus haut : c'est la même marque, un cadre tireté qui cerne
// la forme — laquelle, le CSS s'en arrange.
if (phase.fixed) barre.classList.add('barre--imposee');
if (selectionnee) barre.classList.add('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 +555,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${libelleStatut(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 +586,96 @@ 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 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;
}
/**

View File

@@ -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

View File

@@ -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)

View File

@@ -12,12 +12,14 @@ import {
bornesPlanning,
bornesProjet,
bornesReference,
ancreDecalage,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
dateValide,
decalerHorizon,
decalerProjet,
definirHorizon,
deplacerPhase,
dernierJourDuMois,
@@ -37,11 +39,13 @@ import {
formaterMois,
formaterPeriode,
formulerDerive,
libelleStatut,
libererReference,
lundiDeLaSemaine,
modifierPhase,
moisValide,
normaliserTags,
phasesDecalables,
planningVide,
pourEcriture,
premierJourDuMois,
@@ -368,6 +372,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 +923,176 @@ describe('modifications', () => {
});
});
// --- décalage d'un projet ---------------------------------------------------
// --- libellé d'une phase ----------------------------------------------------
describe('libelleStatut', () => {
test('rend le statut seul quand la date nous appartient', () => {
assert.equal(libelleStatut(phase({ status: 'doing' })), 'En cours');
});
test('accole la date imposée au statut, sans le remplacer', () => {
const libelle = libelleStatut(phase({ status: 'blocked', fixed: true }));
assert.match(libelle, /^Bloqué — date imposée/);
});
// Le validateur ramène un statut inconnu à « à venir » plutôt que de refuser
// le fichier : le libellé fait le même choix, faute de quoi une vue afficherait
// « undefined » là où la frise dessine une barre.
test('retombe sur « à venir » pour un statut inconnu', () => {
assert.equal(libelleStatut(phase({ status: 'zzz' })), 'À venir');
});
});
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', () => {