Compare commits
2 Commits
55128fd0d4
...
815e144d9a
| Author | SHA1 | Date | |
|---|---|---|---|
| 815e144d9a | |||
| 4a513f9a59 |
65
README.md
65
README.md
@@ -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 |
|
||||
|
||||
267
css/style.css
267
css/style.css
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 4,
|
||||
"version": 6,
|
||||
"projects": [
|
||||
{
|
||||
"id": "site-web",
|
||||
@@ -99,6 +99,16 @@
|
||||
"milestone": false,
|
||||
"notes": "**Bloquée** : en attente de la fenêtre de maintenance côté hébergeur.\n\n- [x] demande déposée le 3 août\n- [ ] créneau confirmé\n"
|
||||
},
|
||||
{
|
||||
"id": "fenetre-hebergeur",
|
||||
"name": "Fenêtre de maintenance",
|
||||
"start": "2026-09-12",
|
||||
"end": "2026-09-13",
|
||||
"status": "todo",
|
||||
"milestone": false,
|
||||
"fixed": true,
|
||||
"notes": "Créneau imposé par l'hébergeur : un seul week-end par trimestre.\n\nRepousser le projet ne la repousse pas — si le reste glisse, c'est ce créneau qu'on rate.\n"
|
||||
},
|
||||
{
|
||||
"id": "bascule-generale",
|
||||
"name": "Bascule générale",
|
||||
|
||||
@@ -102,15 +102,62 @@ Gérer les jours ouvrés impose de faire passer chaque calcul de date par un hel
|
||||
aussitôt la question des congés et des jours fériés — donc un calendrier à tenir à jour. Sur des
|
||||
phases qui se comptent en semaines et en mois, l'écart est dans le bruit de l'estimation.
|
||||
|
||||
## 8. La barre cumulative est en lecture seule
|
||||
## 8. La barre cumulative repousse le projet
|
||||
|
||||
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. On
|
||||
aurait pu la rendre glissable pour décaler tout le projet en bloc.
|
||||
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. Elle
|
||||
se glisse, et le projet entier se décale avec elle.
|
||||
|
||||
Écarté pour une raison de lisibilité du geste : la même barre représenterait tantôt une phase, tantôt
|
||||
un projet entier, et un glissement de quelques pixels déplacerait alors plusieurs mois de travail
|
||||
d'un coup, sans que l'utilisateur voie ce qui bouge. Elle sert à regarder ; pour modifier, on agit
|
||||
sur les phases.
|
||||
**Ce n'était pas le cas au départ, et le motif du refus mérite d'être conservé** : la même barre
|
||||
représenterait tantôt une phase, tantôt un projet entier, et un glissement de quelques pixels
|
||||
déplacerait alors plusieurs mois de travail d'un coup, sans qu'on voie ce qui bouge. L'argument était
|
||||
juste ; c'est le premier usage réel de l'outil qui l'a renversé. Un projet engagé glisse dans le
|
||||
temps — c'est même la chose qui arrive le plus souvent —, et le faire glisser phase par phase oblige
|
||||
à répéter le même geste cinq fois en priant pour que les écarts se conservent. Or ils ne se
|
||||
conservent pas : chaque phase s'accroche à son propre lundi, et un projet ainsi déplacé se
|
||||
déforme un peu à chaque passage.
|
||||
|
||||
L'objection d'origine n'a pas été balayée, elle a été traitée :
|
||||
|
||||
- **Les barres de phase suivent le résumé pendant le geste.** On voit exactement ce qu'on déplace
|
||||
pendant qu'on le déplace, y compris plié — où les segments colorés de la barre suffisent à le
|
||||
montrer. C'était le cœur du reproche, et il tombe.
|
||||
- **Le delta est calculé une fois, sur la première phase qui bouge, puis appliqué tel quel
|
||||
partout.** Les écarts entre phases sont conservés au jour près : le projet est repoussé, il n'est
|
||||
pas replanifié. C'est précisément ce que le geste phase par phase ne savait pas faire.
|
||||
- **La référence figée ne suit pas.** Repousser de trois semaines affiche aussitôt une dérive de
|
||||
trois semaines. Le geste ne cache pas ce qu'il coûte, il l'inscrit.
|
||||
|
||||
### Ce qui est fait est fait
|
||||
|
||||
Une phase `done` ne bouge pas. Décaler un projet, c'est reconnaître qu'il prendra plus de temps que
|
||||
prévu, et emporter les phases terminées réécrirait un passé qu'on a vécu — en effaçant du même coup
|
||||
la dérive qu'on cherche à lire.
|
||||
|
||||
Le geste ne « déplace » donc pas toujours : sur un projet à moitié fait, la barre s'**étire**, son
|
||||
bord gauche tient et le reste s'en va. C'est assumé, et c'est même ce qu'on veut voir. Le résumé se
|
||||
repose alors depuis le modèle à chaque mouvement de souris plutôt que d'être translaté, faute de quoi
|
||||
l'aperçu et le résultat divergeraient.
|
||||
|
||||
Le critère est le **statut**, jamais la position dans le calendrier : une phase terminée en avance
|
||||
reste où elle est, une phase `blocked` ou `doing` déjà commencée se décale avec le reste — c'est bien
|
||||
elle qui glisse. Un projet dont toutes les phases sont closes n'a rien à repousser : sa barre perd
|
||||
son `data-role`, garde le curseur par défaut, et le dit dans son infobulle plutôt que d'offrir une
|
||||
prise qui ne mènerait nulle part.
|
||||
|
||||
Rien n'empêche un décalage vers l'arrière de faire chevaucher une phase avec une phase terminée. Il
|
||||
n'y a pas de dépendances entre phases dans ce modèle (section 2) et poser ici une butée en
|
||||
inventerait une par la bande.
|
||||
|
||||
### Pas de poignées
|
||||
|
||||
La barre cumulative se déplace, elle ne se redimensionne pas. Étirer un projet supposerait de dilater
|
||||
chaque phase au prorata, avec des arrondis qui déforment les durées courtes et un résultat qu'on ne
|
||||
peut pas prévoir avant de l'avoir vu. C'est un autre besoin, et il attendra de se présenter.
|
||||
|
||||
Le même geste existe au clavier : sur la ligne d'un projet, `←` et `→` le repoussent d'un jour, là où
|
||||
elles ne faisaient rien. `Maj` n'y ajoute rien, pour la raison qui précède.
|
||||
|
||||
### Ce qui n'a pas changé
|
||||
|
||||
**Elle est affichée que le projet soit plié ou déplié.** Elle ne l'était d'abord qu'en mode plié, ce
|
||||
qui obligeait à replier pour savoir où en était le projet entier — donc à perdre la vue qu'on était
|
||||
@@ -120,8 +167,10 @@ en train d'éditer.
|
||||
plié, au motif qu'elle y était seule sur sa ligne. C'était une erreur : plier un couloir ne change
|
||||
rien à ce que cette barre représente, et la voir changer d'aspect au pli laissait croire à deux
|
||||
objets différents. Elle garde donc partout la même hauteur, plus mince qu'une barre de phase —
|
||||
puisqu'elle voisine avec elles dès que le projet est déplié, et qu'elle ne se glisse pas. Le fantôme
|
||||
de référence se cale dessous, d'où sa variante `--cumulatif`.
|
||||
puisqu'elle voisine avec elles dès que le projet est déplié. Le fantôme de référence se cale dessous,
|
||||
d'où sa variante `--cumulatif`. Étant mince, elle se repère mal une fois saisie : un anneau à sa
|
||||
couleur la décolle du couloir le temps du geste, sans toucher à sa géométrie, ce qui décalerait ce
|
||||
qu'on est en train de viser.
|
||||
|
||||
## 9. Accroche à la semaine
|
||||
|
||||
@@ -1052,3 +1101,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
89
index.html
89
index.html
@@ -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)
|
||||
<!--
|
||||
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>
|
||||
|
||||
33
js/app.js
33
js/app.js
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
113
js/detail.js
113
js/detail.js
@@ -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;
|
||||
}
|
||||
|
||||
107
js/drag.js
107
js/drag.js
@@ -1,6 +1,12 @@
|
||||
/**
|
||||
* Glisser et redimensionner les barres.
|
||||
*
|
||||
* Trois sortes de barres se saisissent, et le même appui les sert toutes :
|
||||
* celle d'une **phase**, accrochée au lundi ; celle de l'**horizon** d'un projet
|
||||
* envisagé, accrochée au mois ; et la barre **cumulative** d'un projet, qui
|
||||
* repousse d'un coup toutes ses phases non terminées. Chacune sait ce qu'elle
|
||||
* déplace par son `data-role`.
|
||||
*
|
||||
* Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un
|
||||
* glisser déplace la barre. On les départage au seuil : tant que le pointeur
|
||||
* n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme
|
||||
@@ -15,15 +21,20 @@
|
||||
import {
|
||||
ajouterJours,
|
||||
ajouterMois,
|
||||
ancreDecalage,
|
||||
decalerProjet,
|
||||
deplacerPhase,
|
||||
dernierJourDuMois,
|
||||
ecartJours,
|
||||
ecartMois,
|
||||
lundiDeLaSemaine,
|
||||
moisDe,
|
||||
phasesDecalables,
|
||||
premierJourDuMois,
|
||||
redimensionnerPhase,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
import { poserBarreCumulative } from './timeline.js';
|
||||
|
||||
/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */
|
||||
const SEUIL_GLISSER = 4;
|
||||
@@ -45,12 +56,43 @@ export function installerGlisser(conteneur, rappels) {
|
||||
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
||||
if (evenement.button !== 0) return;
|
||||
|
||||
const cible = evenement.target.closest('[data-role="barre"], [data-role="horizon"]');
|
||||
const cible = evenement.target.closest(
|
||||
'[data-role="barre"], [data-role="horizon"], [data-role="cumul"]'
|
||||
);
|
||||
if (!cible) return;
|
||||
|
||||
const poignee = evenement.target.closest('[data-role="poignee"]');
|
||||
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
|
||||
|
||||
// La barre cumulative repousse le projet entier. Elle n'a pas de poignées :
|
||||
// étirer un projet supposerait de dilater chaque phase au prorata, ce qui
|
||||
// n'est pas le même besoin (docs/decisions.md, section 8).
|
||||
if (cible.dataset.role === 'cumul') {
|
||||
const projet = rappels.trouverProjet(cible.dataset.projet);
|
||||
const ancre = projet && ancreDecalage(projet);
|
||||
if (!ancre) return;
|
||||
|
||||
geste = {
|
||||
element: cible,
|
||||
sorte: 'cumul',
|
||||
idProjet: projet.id,
|
||||
projetInitial: projet,
|
||||
ancre,
|
||||
jours: 0,
|
||||
mode: 'deplacer',
|
||||
xDepart: evenement.clientX,
|
||||
// Les barres de phase suivent le résumé pendant le geste : sans elles,
|
||||
// on repousserait plusieurs mois de travail sans voir ce qui bouge —
|
||||
// l'objection qui avait d'abord fait écarter ce geste.
|
||||
suiveurs: suiveursDuProjet(conteneur, projet),
|
||||
demarre: false,
|
||||
};
|
||||
|
||||
cible.setPointerCapture(evenement.pointerId);
|
||||
evenement.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// La barre d'un projet envisagé se manœuvre au mois, pas au jour : même
|
||||
// mécanique de geste, autre grain, et rien de commun avec une phase — il n'y
|
||||
// en a pas encore (docs/decisions.md, section 27).
|
||||
@@ -122,6 +164,14 @@ export function installerGlisser(conteneur, rappels) {
|
||||
|
||||
relacher(acheve, evenement.pointerId);
|
||||
|
||||
if (acheve.sorte === 'cumul') {
|
||||
// Un appui sans glisser ouvre les paramètres du projet, comme un clic sur
|
||||
// son nom ou sur sa barre d'horizon.
|
||||
if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet);
|
||||
else rappels.surDecalageProjet(acheve.idProjet, acheve.jours || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (acheve.sorte === 'horizon') {
|
||||
const avant = acheve.horizonInitial;
|
||||
const apres = acheve.horizonCourant;
|
||||
@@ -160,7 +210,8 @@ export function installerGlisser(conteneur, rappels) {
|
||||
geste = null;
|
||||
relacher(annule);
|
||||
|
||||
if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
|
||||
if (annule.sorte === 'cumul') rappels.surDecalageProjet(annule.idProjet, null);
|
||||
else if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
|
||||
else rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
}
|
||||
|
||||
@@ -200,6 +251,7 @@ function relacher(geste, pointerId) {
|
||||
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
||||
*/
|
||||
function appliquerGeste(geste, decalage, echelle) {
|
||||
if (geste.sorte === 'cumul') return appliquerGesteCumul(geste, decalage, echelle);
|
||||
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
|
||||
|
||||
const { phaseInitiale: phase, mode } = geste;
|
||||
@@ -229,6 +281,57 @@ function appliquerGeste(geste, decalage, echelle) {
|
||||
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Éléments de la frise qu'un décalage de projet emporte, avec leur position de
|
||||
* départ.
|
||||
*
|
||||
* La position est relue dans `style.left` plutôt que dans `offsetLeft` : le nom
|
||||
* d'un jalon porte une marge, que le second compte et le premier non. Repartir
|
||||
* de la valeur qu'on a soi-même posée est le seul moyen d'y ajouter un décalage
|
||||
* sans en décaler deux fois.
|
||||
*
|
||||
* Le fantôme de référence n'en est délibérément pas : il ne porte aucune phase,
|
||||
* il tient la place du plan figé, et le voir rester en arrière pendant qu'on
|
||||
* repousse le projet *est* la dérive qui s'affiche.
|
||||
*/
|
||||
function suiveursDuProjet(conteneur, projet) {
|
||||
const decalables = new Set(phasesDecalables(projet).map((phase) => phase.id));
|
||||
const suiveurs = [];
|
||||
|
||||
for (const element of conteneur.querySelectorAll('.barre, .jalon, .jalon-nom')) {
|
||||
if (element.dataset.projet !== projet.id) continue;
|
||||
if (!decalables.has(element.dataset.phase)) continue;
|
||||
suiveurs.push({ element, gaucheInitiale: parseFloat(element.style.left) });
|
||||
}
|
||||
|
||||
return suiveurs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repousse un projet entier : le même nombre de jours sur toutes ses phases non
|
||||
* terminées, accroché au lundi comme une phase seule (décision 9).
|
||||
*
|
||||
* L'accroche porte sur la première phase qui bouge, et non sur le début du
|
||||
* projet — sur un projet dont les premières phases sont closes, ce début reste
|
||||
* en place et ne dirait rien du geste en cours.
|
||||
*
|
||||
* La barre cumulative est reposée depuis le modèle plutôt que translatée : elle
|
||||
* se déforme quand une partie du projet ne suit pas, et son bord gauche tient
|
||||
* pendant que le reste s'en va.
|
||||
*/
|
||||
function appliquerGesteCumul(geste, decalage, echelle) {
|
||||
const jours = ecartJours(geste.ancre, accrocher(geste.ancre, decalage, echelle));
|
||||
if (jours === geste.jours) return;
|
||||
geste.jours = jours;
|
||||
|
||||
poserBarreCumulative(geste.element, decalerProjet(geste.projetInitial, jours), echelle);
|
||||
|
||||
const pixels = jours * echelle.pixelsParJour;
|
||||
for (const suiveur of geste.suiveurs) {
|
||||
suiveur.element.style.left = `${suiveur.gaucheInitiale + pixels}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** :
|
||||
* l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire
|
||||
|
||||
118
js/model.js
118
js/model.js
@@ -15,16 +15,17 @@
|
||||
/**
|
||||
* Version du format de fichier.
|
||||
*
|
||||
* Elle a changé pour la dernière fois quand le projet a reçu son cycle de vie —
|
||||
* `horizon`, `completedDate`, `discardedDate` (v5). Un champ ajouté n'oblige
|
||||
* pourtant à rien : la v4 se relit sans encombre, et le validateur ne refuse
|
||||
* qu'un fichier *plus récent* que lui. C'est justement là qu'est la raison
|
||||
* d'incrémenter — le validateur reconstruit chaque projet champ par champ et
|
||||
* laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire antérieur
|
||||
* ouvrirait un fichier v5 sans broncher et en effacerait tous les horizons à la
|
||||
* première sauvegarde.
|
||||
* Elle a changé pour la dernière fois quand la phase a reçu sa date imposée —
|
||||
* `fixed` (v6) ; avant cela, quand le projet a reçu son cycle de vie (v5). Un
|
||||
* champ ajouté n'oblige pourtant à rien : la v5 se relit sans encombre, et le
|
||||
* validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement là
|
||||
* qu'est la raison d'incrémenter — le validateur reconstruit chaque projet champ
|
||||
* par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire
|
||||
* antérieur ouvrirait un fichier v6 sans broncher et en effacerait toutes les
|
||||
* dates imposées à la première sauvegarde : les phases concernées se remettraient
|
||||
* alors à suivre le décalage de leur projet, en silence.
|
||||
*/
|
||||
export const VERSION_FORMAT = 5;
|
||||
export const VERSION_FORMAT = 6;
|
||||
|
||||
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
|
||||
|
||||
@@ -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.
|
||||
|
||||
11
js/mois.js
11
js/mois.js
@@ -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.
|
||||
|
||||
105
js/timeline.js
105
js/timeline.js
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
2
main.go
2
main.go
@@ -67,7 +67,7 @@ const portsTentes = 10
|
||||
// lancement est immédiatement relu par le front, qui refuse une version plus
|
||||
// récente que la sienne — mais accepterait sans rien dire une version plus
|
||||
// ancienne, et la réécrirait au format courant.
|
||||
var donneesInitiales = []byte(`{"version": 4, "projects": []}`)
|
||||
var donneesInitiales = []byte(`{"version": 6, "projects": []}`)
|
||||
|
||||
type serveur struct {
|
||||
fichierDonnees string
|
||||
|
||||
@@ -73,9 +73,9 @@ func TestGetSansFichierRenvoiePlanningVide(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
// Le format courant est la version 4 (js/model.js, VERSION_FORMAT).
|
||||
if planning.Version != 4 {
|
||||
t.Errorf("version = %d, attendu 4", planning.Version)
|
||||
// Le format courant est la version 6 (js/model.js, VERSION_FORMAT).
|
||||
if planning.Version != 6 {
|
||||
t.Errorf("version = %d, attendu 6", planning.Version)
|
||||
}
|
||||
if len(planning.Projects) != 0 {
|
||||
t.Errorf("projects = %v, attendu vide", planning.Projects)
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
bornesPlanning,
|
||||
bornesProjet,
|
||||
bornesReference,
|
||||
ancreDecalage,
|
||||
cleTag,
|
||||
clotureEcraseDesStatuts,
|
||||
creerPhase,
|
||||
creerProjet,
|
||||
dateValide,
|
||||
decalerHorizon,
|
||||
decalerProjet,
|
||||
definirHorizon,
|
||||
deplacerPhase,
|
||||
dernierJourDuMois,
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user