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
|
déclarer terminé, écarter, figer la référence, supprimer. Un clic droit sur le libellé ouvre le
|
||||||
même menu.
|
même menu.
|
||||||
|
|
||||||
Il n'y a pas de commande séparée pour créer un **jalon** : c'est une phase dont on coche la case
|
Il n'y a pas de commande séparée pour créer un **jalon** ni pour marquer une **date imposée** : ce
|
||||||
« Jalon » dans le panneau, ce qui ramène sa fin sur son début. La bascule joue dans les deux sens
|
sont deux cases du panneau de la phase, indépendantes l'une de l'autre comme du statut. Cocher
|
||||||
à tout moment.
|
« Jalon » ramène la fin sur le début, 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 à
|
La colonne se **redimensionne** en glissant la ligne qui la sépare de la frise, en haut à
|
||||||
gauche : un double-clic revient à la largeur d'origine. Cette largeur est propre au navigateur,
|
gauche : un double-clic revient à la largeur d'origine. Cette largeur est propre au navigateur,
|
||||||
@@ -106,14 +117,15 @@ Une fois un projet en place :
|
|||||||
| Geste | Effet |
|
| Geste | Effet |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Glisser le corps d'une barre | Décale la phase, durée conservée |
|
| Glisser le corps d'une barre | Décale la phase, durée conservée |
|
||||||
|
| Glisser la barre résumé d'un projet | Repousse le projet entier, écarts conservés |
|
||||||
| Glisser la barre pâle d'un envisagé | Décale son horizon, au mois |
|
| Glisser la barre pâle d'un envisagé | Décale son horizon, au mois |
|
||||||
| Glisser le bord d'une barre | Change la date de début ou de fin |
|
| Glisser le bord d'une barre | Change la date de début ou de fin |
|
||||||
| Glisser le bord gauche du panneau | Change sa largeur (double-clic : d'origine) |
|
| Glisser le bord gauche du panneau | Change sa largeur (double-clic : d'origine) |
|
||||||
| Clic sur une barre ou son nom | Ouvre le panneau de détail |
|
| Clic sur une barre ou son nom | Ouvre le panneau de détail |
|
||||||
| Clic sur une autre barre, panneau ouvert | Bascule le panneau sur cette phase |
|
| Clic sur une autre barre, panneau ouvert | Bascule le panneau sur cette phase |
|
||||||
| Clic sur un nom de phase hors écran | Ramène la frise sur elle |
|
| Clic sur un nom de phase hors écran | Ramène la frise sur elle |
|
||||||
| `←` `→` | Décale d'un jour la phase du curseur |
|
| `←` `→` | Décale d'un jour la phase du curseur — ou tout le projet, sur sa ligne |
|
||||||
| `Maj` + `←` `→` | Allonge ou raccourcit d'un jour |
|
| `Maj` + `←` `→` | Allonge ou raccourcit la phase d'un jour |
|
||||||
| `Échap` | Annule le glisser en cours, ou ferme le panneau |
|
| `Échap` | Annule le glisser en cours, ou ferme le panneau |
|
||||||
| Clic dans une zone vide | Ferme le panneau ouvert |
|
| Clic dans une zone vide | Ferme le panneau ouvert |
|
||||||
| `Maj` + molette | Fait défiler la frise horizontalement |
|
| `Maj` + molette | Fait défiler la frise horizontalement |
|
||||||
@@ -122,6 +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
|
Faire défiler jusqu'au bord droit élargit la frise vers le futur, et jusqu'au bord gauche
|
||||||
vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
|
vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
|
||||||
|
|
||||||
|
### Repousser un projet en bloc
|
||||||
|
|
||||||
|
Un projet engagé glisse dans le temps — c'est même ce qui arrive le plus souvent. Plutôt que de
|
||||||
|
reprendre ses phases une à une, on **glisse sa barre résumé** : le même nombre de jours s'applique à
|
||||||
|
toutes, jalons compris, et les écarts entre elles sont conservés au jour près. Le projet est
|
||||||
|
repoussé, il n'est pas replanifié. Les flèches `←` `→` font la même chose au jour près, curseur posé
|
||||||
|
sur la ligne du projet.
|
||||||
|
|
||||||
|
**Ce qui est terminé ne bouge pas.** Les phases finies restent où elles sont : décaler un projet,
|
||||||
|
c'est reconnaître qu'il prendra plus de temps, pas réécrire ce qu'on a déjà vécu. Sur un projet à
|
||||||
|
moitié fait, la barre s'étire donc au lieu de se déplacer — son bord gauche tient et le reste s'en
|
||||||
|
va. Un projet dont tout est terminé n'offre aucune prise, et son infobulle le dit.
|
||||||
|
|
||||||
|
**Ce dont on ne décide pas non plus.** Une phase peut porter une **date imposée** : elle est
|
||||||
|
déléguée à quelqu'un d'autre, ou tenue par un contrat, un créneau, une échéance réglementaire. La
|
||||||
|
case se coche dans son panneau, sous « Jalon », et 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
|
### Le cycle de vie d'un projet
|
||||||
|
|
||||||
On a presque toujours plus de projets imaginables que de temps pour les mener, et l'enjeu n'est pas
|
On a presque toujours plus de projets imaginables que de temps pour les mener, et l'enjeu n'est pas
|
||||||
@@ -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
|
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.
|
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
|
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
|
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é
|
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
|
## Ce que fait l'outil
|
||||||
|
|
||||||
- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin,
|
- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin,
|
||||||
un statut, des notes. Une phase peut être un **jalon** (une date unique, rendue en losange).
|
un statut, des notes. Une phase peut être un **jalon** (une date unique, rendue en losange), et
|
||||||
|
porter une **date imposée** quand elle est déléguée ou tenue du dehors — le décalage du projet ne
|
||||||
|
l'emporte alors pas.
|
||||||
- **Un cycle de vie** — envisagé, engagé, terminé, écarté — dont deux états se déduisent des
|
- **Un cycle de vie** — envisagé, engagé, terminé, écarté — dont deux états se déduisent des
|
||||||
données. Ce qu'on envisage sans pouvoir encore le mener vit sur la même frise que le reste, et
|
données. Ce qu'on envisage sans pouvoir encore le mener vit sur la même frise que le reste, et
|
||||||
ne s'y perd pas.
|
ne s'y perd pas.
|
||||||
@@ -325,8 +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.
|
liens. Elles occupent toute la hauteur restante du panneau, et se cochent d'un clic dans l'aperçu.
|
||||||
- **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil.
|
- **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil.
|
||||||
- **Une barre cumulative par projet**, segmentée en teintes selon le statut de chaque phase : la
|
- **Une barre cumulative par projet**, segmentée en teintes selon le statut de chaque phase : la
|
||||||
forme d'ensemble, toujours visible sur la ligne du projet et toujours rendue pareil. Elle ne se
|
forme d'ensemble, toujours visible sur la ligne du projet et toujours rendue pareil. Elle se
|
||||||
glisse pas.
|
glisse pour repousser le projet entier.
|
||||||
- **Couloirs pliables** : déplié, chaque phase occupe sa ligne sous la barre cumulative ; plié, il
|
- **Couloirs pliables** : déplié, chaque phase occupe sa ligne sous la barre cumulative ; plié, il
|
||||||
ne reste que celle-ci.
|
ne reste que celle-ci.
|
||||||
- **Manipulation directe** : glisser une barre pour la décaler, tirer ses bords pour changer sa
|
- **Manipulation directe** : glisser une barre pour la décaler, tirer ses bords pour changer sa
|
||||||
@@ -424,7 +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/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli, curseur |
|
||||||
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes de phase et de projet envisagé, ancrage sur le mois courant |
|
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes de phase et de projet envisagé, ancrage sur le mois courant |
|
||||||
| `js/tags.js` | Pastilles de tags, partagées par les deux vues |
|
| `js/tags.js` | Pastilles de tags, partagées par les deux vues |
|
||||||
| `js/drag.js` | Glisser et redimensionner les barres — au lundi pour une phase, au mois pour un horizon |
|
| `js/drag.js` | Glisser et redimensionner les barres — au lundi pour une phase ou un projet, au mois pour un horizon |
|
||||||
| `js/clavier.js` | Table des raccourcis à la vim, préfixes `g` `z` `d`, garde-fou de saisie |
|
| `js/clavier.js` | Table des raccourcis à la vim, préfixes `g` `z` `d`, garde-fou de saisie |
|
||||||
| `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node |
|
| `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node |
|
||||||
| `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux |
|
| `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux |
|
||||||
|
|||||||
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
|
/* 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 {
|
.barre--todo {
|
||||||
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
|
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
|
||||||
color: var(--texte);
|
color: var(--texte);
|
||||||
border: 1px solid var(--couleur-projet);
|
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 {
|
.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 {
|
.barre--done {
|
||||||
background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b);
|
background: var(--couleur-projet);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barre--blocked {
|
.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. */
|
/* Poignées de redimensionnement, révélées au survol de la barre. */
|
||||||
.poignee {
|
.poignee {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -876,14 +945,35 @@ button[aria-pressed="true"] {
|
|||||||
box-shadow: var(--ombre);
|
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 {
|
.jalon--done {
|
||||||
background: color-mix(in srgb, var(--couleur-projet) 55%, #64748b);
|
background: var(--couleur-projet);
|
||||||
}
|
}
|
||||||
|
|
||||||
.jalon--blocked {
|
.jalon--blocked {
|
||||||
background: var(--danger);
|
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 {
|
.jalon--selectionne {
|
||||||
outline: 2px solid var(--texte);
|
outline: 2px solid var(--texte);
|
||||||
outline-offset: 1px;
|
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
|
déplié, et rendue à l'identique dans les deux cas — c'est le même objet, il
|
||||||
n'a pas à changer d'aspect quand on ouvre le couloir.
|
n'a pas à changer d'aspect quand on ouvre le couloir.
|
||||||
Volontairement plus mince qu'une barre de phase : elle voisine avec elles
|
Volontairement plus mince qu'une barre de phase : elle voisine avec elles
|
||||||
dès que le projet est déplié, et ne se glisse pas.
|
dès que le projet est déplié.
|
||||||
Lecture seule : voir docs/decisions.md, section 8. */
|
Elle se glisse pour repousser le projet : voir docs/decisions.md, section 8. */
|
||||||
.barre-cumulative {
|
.barre-cumulative {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc((var(--hauteur-ligne) - var(--hauteur-cumulative)) / 2);
|
top: calc((var(--hauteur-ligne) - var(--hauteur-cumulative)) / 2);
|
||||||
@@ -921,6 +1011,25 @@ button[aria-pressed="true"] {
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* La prise ne s'annonce que là où elle mène quelque part : sur un projet dont
|
||||||
|
tout est terminé, la barre n'a pas de rôle et garde le curseur par défaut —
|
||||||
|
rien ne bougerait. */
|
||||||
|
.barre-cumulative[data-role='cumul'] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barre-cumulative[data-role='cumul']:hover {
|
||||||
|
background: color-mix(in srgb, var(--couleur-projet) 28%, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mince comme elle est, une barre cumulative saisie se repère mal. L'ombre la
|
||||||
|
décolle du couloir le temps du geste, sans changer sa géométrie — ce qui
|
||||||
|
décalerait ce qu'on est en train de viser. */
|
||||||
|
.barre-cumulative.barre--glissee {
|
||||||
|
cursor: grabbing;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--couleur-projet) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.segment {
|
.segment {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -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
|
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
|
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. */
|
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 {
|
.panneau__corps {
|
||||||
|
--couleur-projet: var(--accent);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -1142,7 +1258,6 @@ button[aria-pressed="true"] {
|
|||||||
|
|
||||||
.panneau__corps input[type="text"],
|
.panneau__corps input[type="text"],
|
||||||
.panneau__corps input[type="date"],
|
.panneau__corps input[type="date"],
|
||||||
.panneau__corps select,
|
|
||||||
.panneau__corps textarea {
|
.panneau__corps textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
@@ -1163,6 +1278,122 @@ button[aria-pressed="true"] {
|
|||||||
gap: 10px;
|
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 {
|
.case {
|
||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1918,8 +2149,18 @@ kbd {
|
|||||||
border: 1px solid var(--couleur-projet);
|
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 {
|
.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 {
|
.mois-entree--blocked .mois-entree__marque {
|
||||||
@@ -1944,6 +2185,16 @@ kbd {
|
|||||||
background: var(--danger);
|
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
|
/* 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. */
|
fait de la colonne une échelle lisible plutôt qu'une pile de textes. */
|
||||||
.mois-entree__dates {
|
.mois-entree__dates {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 4,
|
"version": 6,
|
||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"id": "site-web",
|
"id": "site-web",
|
||||||
@@ -99,6 +99,16 @@
|
|||||||
"milestone": false,
|
"milestone": false,
|
||||||
"notes": "**Bloquée** : en attente de la fenêtre de maintenance côté hébergeur.\n\n- [x] demande déposée le 3 août\n- [ ] créneau confirmé\n"
|
"notes": "**Bloquée** : en attente de la fenêtre de maintenance côté hébergeur.\n\n- [x] demande déposée le 3 août\n- [ ] créneau confirmé\n"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "fenetre-hebergeur",
|
||||||
|
"name": "Fenêtre de maintenance",
|
||||||
|
"start": "2026-09-12",
|
||||||
|
"end": "2026-09-13",
|
||||||
|
"status": "todo",
|
||||||
|
"milestone": false,
|
||||||
|
"fixed": true,
|
||||||
|
"notes": "Créneau imposé par l'hébergeur : un seul week-end par trimestre.\n\nRepousser le projet ne la repousse pas — si le reste glisse, c'est ce créneau qu'on rate.\n"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "bascule-generale",
|
"id": "bascule-generale",
|
||||||
"name": "Bascule générale",
|
"name": "Bascule générale",
|
||||||
|
|||||||
@@ -102,15 +102,62 @@ Gérer les jours ouvrés impose de faire passer chaque calcul de date par un hel
|
|||||||
aussitôt la question des congés et des jours fériés — donc un calendrier à tenir à jour. Sur des
|
aussitôt la question des congés et des jours fériés — donc un calendrier à tenir à jour. Sur des
|
||||||
phases qui se comptent en semaines et en mois, l'écart est dans le bruit de l'estimation.
|
phases qui se comptent en semaines et en mois, l'écart est dans le bruit de l'estimation.
|
||||||
|
|
||||||
## 8. La barre cumulative est en lecture seule
|
## 8. La barre cumulative repousse le projet
|
||||||
|
|
||||||
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. On
|
La ligne d'un projet porte une barre unique segmentée par phase, qui donne sa forme d'ensemble. Elle
|
||||||
aurait pu la rendre glissable pour décaler tout le projet en bloc.
|
se glisse, et le projet entier se décale avec elle.
|
||||||
|
|
||||||
Écarté pour une raison de lisibilité du geste : la même barre représenterait tantôt une phase, tantôt
|
**Ce n'était pas le cas au départ, et le motif du refus mérite d'être conservé** : la même barre
|
||||||
un projet entier, et un glissement de quelques pixels déplacerait alors plusieurs mois de travail
|
représenterait tantôt une phase, tantôt un projet entier, et un glissement de quelques pixels
|
||||||
d'un coup, sans que l'utilisateur voie ce qui bouge. Elle sert à regarder ; pour modifier, on agit
|
déplacerait alors plusieurs mois de travail d'un coup, sans qu'on voie ce qui bouge. L'argument était
|
||||||
sur les phases.
|
juste ; c'est le premier usage réel de l'outil qui l'a renversé. Un projet engagé glisse dans le
|
||||||
|
temps — c'est même la chose qui arrive le plus souvent —, et le faire glisser phase par phase oblige
|
||||||
|
à répéter le même geste cinq fois en priant pour que les écarts se conservent. Or ils ne se
|
||||||
|
conservent pas : chaque phase s'accroche à son propre lundi, et un projet ainsi déplacé se
|
||||||
|
déforme un peu à chaque passage.
|
||||||
|
|
||||||
|
L'objection d'origine n'a pas été balayée, elle a été traitée :
|
||||||
|
|
||||||
|
- **Les barres de phase suivent le résumé pendant le geste.** On voit exactement ce qu'on déplace
|
||||||
|
pendant qu'on le déplace, y compris plié — où les segments colorés de la barre suffisent à le
|
||||||
|
montrer. C'était le cœur du reproche, et il tombe.
|
||||||
|
- **Le delta est calculé une fois, sur la première phase qui bouge, puis appliqué tel quel
|
||||||
|
partout.** Les écarts entre phases sont conservés au jour près : le projet est repoussé, il n'est
|
||||||
|
pas replanifié. C'est précisément ce que le geste phase par phase ne savait pas faire.
|
||||||
|
- **La référence figée ne suit pas.** Repousser de trois semaines affiche aussitôt une dérive de
|
||||||
|
trois semaines. Le geste ne cache pas ce qu'il coûte, il l'inscrit.
|
||||||
|
|
||||||
|
### Ce qui est fait est fait
|
||||||
|
|
||||||
|
Une phase `done` ne bouge pas. Décaler un projet, c'est reconnaître qu'il prendra plus de temps que
|
||||||
|
prévu, et emporter les phases terminées réécrirait un passé qu'on a vécu — en effaçant du même coup
|
||||||
|
la dérive qu'on cherche à lire.
|
||||||
|
|
||||||
|
Le geste ne « déplace » donc pas toujours : sur un projet à moitié fait, la barre s'**étire**, son
|
||||||
|
bord gauche tient et le reste s'en va. C'est assumé, et c'est même ce qu'on veut voir. Le résumé se
|
||||||
|
repose alors depuis le modèle à chaque mouvement de souris plutôt que d'être translaté, faute de quoi
|
||||||
|
l'aperçu et le résultat divergeraient.
|
||||||
|
|
||||||
|
Le critère est le **statut**, jamais la position dans le calendrier : une phase terminée en avance
|
||||||
|
reste où elle est, une phase `blocked` ou `doing` déjà commencée se décale avec le reste — c'est bien
|
||||||
|
elle qui glisse. Un projet dont toutes les phases sont closes n'a rien à repousser : sa barre perd
|
||||||
|
son `data-role`, garde le curseur par défaut, et le dit dans son infobulle plutôt que d'offrir une
|
||||||
|
prise qui ne mènerait nulle part.
|
||||||
|
|
||||||
|
Rien n'empêche un décalage vers l'arrière de faire chevaucher une phase avec une phase terminée. Il
|
||||||
|
n'y a pas de dépendances entre phases dans ce modèle (section 2) et poser ici une butée en
|
||||||
|
inventerait une par la bande.
|
||||||
|
|
||||||
|
### Pas de poignées
|
||||||
|
|
||||||
|
La barre cumulative se déplace, elle ne se redimensionne pas. Étirer un projet supposerait de dilater
|
||||||
|
chaque phase au prorata, avec des arrondis qui déforment les durées courtes et un résultat qu'on ne
|
||||||
|
peut pas prévoir avant de l'avoir vu. C'est un autre besoin, et il attendra de se présenter.
|
||||||
|
|
||||||
|
Le même geste existe au clavier : sur la ligne d'un projet, `←` et `→` le repoussent d'un jour, là où
|
||||||
|
elles ne faisaient rien. `Maj` n'y ajoute rien, pour la raison qui précède.
|
||||||
|
|
||||||
|
### Ce qui n'a pas changé
|
||||||
|
|
||||||
**Elle est affichée que le projet soit plié ou déplié.** Elle ne l'était d'abord qu'en mode plié, ce
|
**Elle est affichée que le projet soit plié ou déplié.** Elle ne l'était d'abord qu'en mode plié, ce
|
||||||
qui obligeait à replier pour savoir où en était le projet entier — donc à perdre la vue qu'on était
|
qui obligeait à replier pour savoir où en était le projet entier — donc à perdre la vue qu'on était
|
||||||
@@ -120,8 +167,10 @@ en train d'éditer.
|
|||||||
plié, au motif qu'elle y était seule sur sa ligne. C'était une erreur : plier un couloir ne change
|
plié, au motif qu'elle y était seule sur sa ligne. C'était une erreur : plier un couloir ne change
|
||||||
rien à ce que cette barre représente, et la voir changer d'aspect au pli laissait croire à deux
|
rien à ce que cette barre représente, et la voir changer d'aspect au pli laissait croire à deux
|
||||||
objets différents. Elle garde donc partout la même hauteur, plus mince qu'une barre de phase —
|
objets différents. Elle garde donc partout la même hauteur, plus mince qu'une barre de phase —
|
||||||
puisqu'elle voisine avec elles dès que le projet est déplié, et qu'elle ne se glisse pas. Le fantôme
|
puisqu'elle voisine avec elles dès que le projet est déplié. Le fantôme de référence se cale dessous,
|
||||||
de référence se cale dessous, d'où sa variante `--cumulatif`.
|
d'où sa variante `--cumulatif`. Étant mince, elle se repère mal une fois saisie : un anneau à sa
|
||||||
|
couleur la décolle du couloir le temps du geste, sans toucher à sa géométrie, ce qui décalerait ce
|
||||||
|
qu'on est en train de viser.
|
||||||
|
|
||||||
## 9. Accroche à la semaine
|
## 9. Accroche à la semaine
|
||||||
|
|
||||||
@@ -1052,3 +1101,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
|
**Un retour arrière déclaré**, d'engagé vers envisagé. On déplace les dates, cela suffit. Le seul
|
||||||
cas où il survient — retirer la dernière phase — se résout tout seul par la règle des bornes.
|
cas où il survient — retirer la dernière phase — se résout tout seul par la règle des bornes.
|
||||||
|
|
||||||
|
## 28. Une date qu'on ne décide pas
|
||||||
|
|
||||||
|
Toutes les phases d'un planning ne sont pas nôtres. Certaines sont **déléguées** — un prestataire les
|
||||||
|
tient, on n'a que le résultat —, d'autres portent une **contrainte extérieure** : une fenêtre de
|
||||||
|
maintenance chez l'hébergeur, une échéance réglementaire, une date de salon. Une phase reçoit donc un
|
||||||
|
booléen `fixed`, et le décalage d'un projet (section 8) ne l'emporte pas.
|
||||||
|
|
||||||
|
Sans elle, le geste ajouté en section 8 était activement nuisible : repousser un projet de trois
|
||||||
|
semaines repoussait aussi l'audit du commissaire aux comptes, ce qui est faux et ce que rien ne
|
||||||
|
signalait.
|
||||||
|
|
||||||
|
### Pourquoi ce n'est pas un cinquième statut
|
||||||
|
|
||||||
|
C'était la formulation spontanée — « ajouter un état ». Elle a été écartée, parce que les quatre
|
||||||
|
statuts existants disent tous **l'avancement** (section 6), et qu'une date imposée n'est pas une
|
||||||
|
étape du travail : une phase déléguée est tour à tour à venir, en cours, terminée, et elle reste
|
||||||
|
déléguée tout du long. En faire une cinquième valeur aurait créé une exclusion mutuelle fausse — on
|
||||||
|
ne pourrait plus dire d'une phase déléguée qu'elle est bloquée, ce qui est précisément la situation
|
||||||
|
qu'on veut voir venir de loin.
|
||||||
|
|
||||||
|
Le modèle avait déjà le patron : **`milestone` est un booléen à côté de `status`**, pas un statut. Un
|
||||||
|
jalon peut être `todo` ou `done` sans que cela pose la moindre question. `fixed` suit exactement le
|
||||||
|
même chemin, et les deux se combinent librement — un jalon à date imposée est le cas le plus courant
|
||||||
|
des deux.
|
||||||
|
|
||||||
|
**À ne pas confondre avec `blocked`**, dont il est le voisin immédiat dans un formulaire :
|
||||||
|
`blocked` dit *ça n'avance pas*, `fixed` dit *cette date ne m'appartient pas*. Une phase peut être
|
||||||
|
les deux, l'une ou l'autre, ou aucune. Le libellé retenu — « date imposée » plutôt que « fixe » —
|
||||||
|
sert d'abord à cela : il nomme la cause, là où « fixe » n'aurait nommé que l'effet, et six mois plus
|
||||||
|
tard c'est la cause qu'on cherche en relisant.
|
||||||
|
|
||||||
|
### Le verrou est souple
|
||||||
|
|
||||||
|
Une phase à date imposée **se glisse toujours seule**, à la souris comme aux flèches. Seul le
|
||||||
|
décalage de son projet l'épargne. « Imposée » veut donc dire *indépendante du projet*, et non
|
||||||
|
*immuable* : quand l'hébergeur déplace son créneau, on déplace la barre, sans rien avoir à décocher.
|
||||||
|
|
||||||
|
Le verrou dur avait été envisagé — barre non glissable, dates saisies au panneau — au motif qu'on
|
||||||
|
marque une phase pour la protéger d'un geste accidentel. Il ajoutait une friction permanente pour un
|
||||||
|
risque occasionnel, et il aurait fait diverger cette case de son modèle, `milestone`, qui n'empêche
|
||||||
|
rien non plus.
|
||||||
|
|
||||||
|
Corollaire : un projet dont toutes les phases sont terminées **ou** imposées n'offre aucune prise, et
|
||||||
|
sa barre cumulative le dit dans son infobulle. C'est le comportement déjà écrit pour les projets
|
||||||
|
entièrement clos ; il n'a pas eu à changer, seule la liste des phases emportées s'est resserrée.
|
||||||
|
|
||||||
|
### Ce que ça donne à l'œil
|
||||||
|
|
||||||
|
> Deux **taquets** verticaux aux extrémités de la barre, comme les butées d'une pièce qui ne coulisse
|
||||||
|
> plus ; un **cercle** pour un jalon, qui n'a pas d'extrémités où en poser.
|
||||||
|
|
||||||
|
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
|
```json
|
||||||
{
|
{
|
||||||
"version": 5,
|
"version": 6,
|
||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"id": "site-web",
|
"id": "site-web",
|
||||||
@@ -29,6 +29,7 @@ dans un diff git et éditable à la main.
|
|||||||
"end": "2026-08-20",
|
"end": "2026-08-20",
|
||||||
"status": "done",
|
"status": "done",
|
||||||
"milestone": false,
|
"milestone": false,
|
||||||
|
"fixed": false,
|
||||||
"notes": "Ateliers avec les trois pôles.\n\nPérimètre arrêté le **8 juillet**.",
|
"notes": "Ateliers avec les trois pôles.\n\nPérimètre arrêté le **8 juillet**.",
|
||||||
"baseline": { "start": "2026-07-25", "end": "2026-08-10" }
|
"baseline": { "start": "2026-07-25", "end": "2026-08-10" }
|
||||||
},
|
},
|
||||||
@@ -39,7 +40,8 @@ dans un diff git et éditable à la main.
|
|||||||
"end": "2026-11-02",
|
"end": "2026-11-02",
|
||||||
"status": "todo",
|
"status": "todo",
|
||||||
"milestone": true,
|
"milestone": true,
|
||||||
"notes": ""
|
"fixed": true,
|
||||||
|
"notes": "Date annoncée au client."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -51,18 +53,21 @@ dans un diff git et éditable à la main.
|
|||||||
|
|
||||||
| Champ | Type | Description |
|
| Champ | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `version` | entier | Version du format. Vaut `5`. Sert à détecter un fichier trop ancien au chargement. |
|
| `version` | entier | Version du format. Vaut `6`. Sert à détecter un fichier trop ancien au chargement. |
|
||||||
| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. |
|
| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. |
|
||||||
|
|
||||||
Un fichier plus ancien se charge sans rien demander et est réécrit au format courant à la première
|
Un fichier plus ancien se charge sans rien demander et est réécrit au format courant à la première
|
||||||
sauvegarde : une version `2` — celle d'avant les tags — voit ses projets recevoir une liste de tags
|
sauvegarde : une version `2` — celle d'avant les tags — voit ses projets recevoir une liste de tags
|
||||||
vide, une version `3` — celle d'avant les notes de projet — des notes vides, une version `4` — celle
|
vide, une version `3` — celle d'avant les notes de projet — des notes vides, une version `4` — celle
|
||||||
d'avant le cycle de vie — des projets sans horizon ni date d'acte.
|
d'avant le cycle de vie — des projets sans horizon ni date d'acte, une version `5` — celle d'avant
|
||||||
|
les dates imposées — des phases à `fixed: false`.
|
||||||
|
|
||||||
Cette dernière migration ne demande rien et ne devine rien : un projet qui a des phases est *engagé*,
|
Aucune de ces migrations ne demande ni ne devine quoi que ce soit. Un projet qui a des phases est
|
||||||
un projet qui n'en a pas est *envisagé sans horizon*, et il se signalera comme réclamant une date.
|
*engagé*, un projet qui n'en a pas est *envisagé sans horizon*, et il se signalera comme réclamant une
|
||||||
Aucun projet n'est déclaré terminé ni écarté au chargement — ces deux états s'actent, ils ne se
|
date. Aucun projet n'est déclaré terminé ni écarté au chargement — ces deux états s'actent, ils ne se
|
||||||
devinent pas, fût-ce d'un planning dont toutes les phases sont finies.
|
devinent pas, fût-ce d'un planning dont toutes les phases sont finies. Et aucune phase n'est déclarée
|
||||||
|
à date imposée : rien dans les données ne dirait laquelle est déléguée, et un faux positif
|
||||||
|
immobiliserait une phase sans qu'on comprenne pourquoi.
|
||||||
|
|
||||||
Un fichier écrit par une version **plus récente** est en revanche refusé. C'est la raison d'être du
|
Un fichier écrit par une version **plus récente** est en revanche refusé. C'est la raison d'être du
|
||||||
numéro : le validateur reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît
|
numéro : le validateur reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît
|
||||||
@@ -96,6 +101,7 @@ effacerait les champs inconnus à la première sauvegarde.
|
|||||||
| `end` | chaîne | Date de fin **incluse**, `AAAA-MM-JJ`. |
|
| `end` | chaîne | Date de fin **incluse**, `AAAA-MM-JJ`. |
|
||||||
| `status` | chaîne | `todo`, `doing`, `done` ou `blocked`. |
|
| `status` | chaîne | `todo`, `doing`, `done` ou `blocked`. |
|
||||||
| `milestone` | booléen | `true` pour un jalon. |
|
| `milestone` | booléen | `true` pour un jalon. |
|
||||||
|
| `fixed` | booléen | `true` si la date est imposée du dehors — phase déléguée, contrainte contractuelle ou réglementaire. Un décalage du projet ne l'emporte pas. |
|
||||||
| `notes` | chaîne | Texte libre en markdown, éventuellement vide. |
|
| `notes` | chaîne | Texte libre en markdown, éventuellement vide. |
|
||||||
| `baseline` | objet ou absent | Dates de référence : `{ "start": …, "end": … }`. Absent tant que la référence n'a pas été figée. |
|
| `baseline` | objet ou absent | Dates de référence : `{ "start": …, "end": … }`. Absent tant que la référence n'a pas été figée. |
|
||||||
|
|
||||||
@@ -113,6 +119,10 @@ Appliquées par `js/model.js` et couvertes par `tests/model.test.js`.
|
|||||||
chiffre remplacé par un tiret. En cas de collision, un suffixe numérique est ajouté (`cadrage-2`).
|
chiffre remplacé par un tiret. En cas de collision, un suffixe numérique est ajouté (`cadrage-2`).
|
||||||
Un `id` ne change jamais si le nom est modifié ensuite — il identifie, il ne décrit pas.
|
Un `id` ne change jamais si le nom est modifié ensuite — il identifie, il ne décrit pas.
|
||||||
- `status` fait partie des quatre valeurs autorisées ; toute autre valeur est ramenée à `todo`.
|
- `status` fait partie des quatre valeurs autorisées ; toute autre valeur est ramenée à `todo`.
|
||||||
|
- `fixed` est orthogonal au reste : aucune combinaison n'est interdite. Une phase peut être à la fois
|
||||||
|
jalon et à date imposée — c'est même le cas le plus courant des deux —, ou `blocked` et imposée, ce
|
||||||
|
qui n'est pas une contradiction : `blocked` dit que ça n'avance pas, `fixed` que la date ne nous
|
||||||
|
appartient pas.
|
||||||
- `color` est un hexadécimal `#rrggbb` valide.
|
- `color` est un hexadécimal `#rrggbb` valide.
|
||||||
- `tags` est un tableau de chaînes. Le tableau lui-même et le type de ses éléments sont vérifiés —
|
- `tags` est un tableau de chaînes. Le tableau lui-même et le type de ses éléments sont vérifiés —
|
||||||
un tag mal typé serait un tag qu'on croit poser et qui ne filtre rien. Leur *contenu*, en revanche,
|
un tag mal typé serait un tag qu'on croit poser et qui ne filtre rien. Leur *contenu*, en revanche,
|
||||||
@@ -270,6 +280,12 @@ l'année correspondante, qui peut différer de celle de la date.
|
|||||||
fenêtre du planning au premier affichage — sans quoi un planning fait de projets encore tous
|
fenêtre du planning au premier affichage — sans quoi un planning fait de projets encore tous
|
||||||
envisagés s'ouvrirait sur rien. Les projets écartés en sont exclus : ils ne s'affichent pas
|
envisagés s'ouvrirait sur rien. Les projets écartés en sont exclus : ils ne s'affichent pas
|
||||||
d'ordinaire, et un projet abandonné il y a trois ans n'a pas à tirer la vue en arrière.
|
d'ordinaire, et un projet abandonné il y a trois ans n'a pas à tirer la vue en arrière.
|
||||||
|
- **Repousser un projet** (`decalerProjet()`) ajoute le même nombre de jours aux `start` et `end` des
|
||||||
|
phases qu'il emporte — `phasesDecalables()` les désigne, `ancreDecalage()` donne la date sur
|
||||||
|
laquelle le geste s'accroche. Deux sortes en sont exclues, pour des raisons sans rapport : les
|
||||||
|
phases **terminées**, parce qu'on ne réécrit pas le passé, et les phases à **date imposée**, parce
|
||||||
|
qu'on n'en décide pas. Les `baseline` ne bougent pas non plus : le décalage se lit alors
|
||||||
|
intégralement dans la dérive.
|
||||||
- **Figer la référence** copie, pour chaque phase du projet, ses `start` et `end` courantes dans son
|
- **Figer la référence** copie, pour chaque phase du projet, ses `start` et `end` courantes dans son
|
||||||
champ `baseline`, et inscrit la date du jour dans `baselineDate`. L'opération est rejouable : la
|
champ `baseline`, et inscrit la date du jour dans `baselineDate`. L'opération est rejouable : la
|
||||||
refiger après un arbitrage assumé repart d'une base propre.
|
refiger après un arbitrage assumé repart d'une base propre.
|
||||||
|
|||||||
89
index.html
89
index.html
@@ -165,7 +165,25 @@
|
|||||||
<input type="text" name="name" required autocomplete="off" />
|
<input type="text" name="name" required autocomplete="off" />
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Début
|
Début
|
||||||
<input type="date" name="start" required />
|
<input type="date" name="start" required />
|
||||||
@@ -176,20 +194,58 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>
|
<!--
|
||||||
Statut
|
Orthogonale au statut, et c'est le point : une phase déléguée avance,
|
||||||
<select name="status">
|
se termine ou se bloque comme une autre. Seul son ancrage dans le temps
|
||||||
<option value="todo">À venir</option>
|
nous échappe (docs/decisions.md, section 28).
|
||||||
<option value="doing">En cours</option>
|
-->
|
||||||
<option value="done">Terminé</option>
|
<label class="case">
|
||||||
<option value="blocked">Bloqué</option>
|
<input type="checkbox" name="fixed" />
|
||||||
</select>
|
Date imposée (déléguée, contractuelle ou réglementaire)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="case">
|
<!--
|
||||||
<input type="checkbox" name="milestone" />
|
Quatre boutons plutôt qu'une liste déroulante, chacun peint de la
|
||||||
Jalon (une seule date, affichée en losange)
|
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>
|
||||||
|
<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
|
Les notes sont bâties par `notes.js` : aperçu markdown, barre d'outils
|
||||||
@@ -369,12 +425,15 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>Modifier la phase du curseur</h3>
|
<h3>Modifier la ligne du curseur</h3>
|
||||||
<dl class="aide">
|
<dl class="aide">
|
||||||
<dt><kbd>←</kbd> <kbd>→</kbd></dt>
|
<dt><kbd>←</kbd> <kbd>→</kbd></dt>
|
||||||
<dd>Décale d'un jour</dd>
|
<dd>
|
||||||
|
Décale d'un jour — la phase du curseur, ou, sur la ligne d'un
|
||||||
|
projet, toutes ses phases non terminées
|
||||||
|
</dd>
|
||||||
<dt><kbd>Maj</kbd>+<kbd>←</kbd> <kbd>→</kbd></dt>
|
<dt><kbd>Maj</kbd>+<kbd>←</kbd> <kbd>→</kbd></dt>
|
||||||
<dd>Allonge, raccourcit d'un jour</dd>
|
<dd>Allonge, raccourcit la phase d'un jour</dd>
|
||||||
<dt><kbd>Espace</kbd></dt>
|
<dt><kbd>Espace</kbd></dt>
|
||||||
<dd>Statut suivant : à venir, en cours, terminé, bloqué</dd>
|
<dd>Statut suivant : à venir, en cours, terminé, bloqué</dd>
|
||||||
<dt><kbd>Entrée</kbd> <kbd>e</kbd></dt>
|
<dt><kbd>Entrée</kbd> <kbd>e</kbd></dt>
|
||||||
|
|||||||
33
js/app.js
33
js/app.js
@@ -22,12 +22,14 @@ import {
|
|||||||
STATUTS,
|
STATUTS,
|
||||||
ajouterJours,
|
ajouterJours,
|
||||||
ajouterPhase,
|
ajouterPhase,
|
||||||
|
ancreDecalage,
|
||||||
aujourdhui,
|
aujourdhui,
|
||||||
bornesPlanning,
|
bornesPlanning,
|
||||||
cleTag,
|
cleTag,
|
||||||
clotureEcraseDesStatuts,
|
clotureEcraseDesStatuts,
|
||||||
creerPhase,
|
creerPhase,
|
||||||
creerProjet,
|
creerProjet,
|
||||||
|
decalerProjet,
|
||||||
ecarterProjet,
|
ecarterProjet,
|
||||||
etatProjet,
|
etatProjet,
|
||||||
figerReference,
|
figerReference,
|
||||||
@@ -178,6 +180,7 @@ const panneau = creerPanneau(
|
|||||||
formulaire: $('formulaire-phase'),
|
formulaire: $('formulaire-phase'),
|
||||||
titre: $('panneau-titre'),
|
titre: $('panneau-titre'),
|
||||||
erreur: $('erreur-phase'),
|
erreur: $('erreur-phase'),
|
||||||
|
champDates: $('champ-dates'),
|
||||||
champFin: $('champ-fin'),
|
champFin: $('champ-fin'),
|
||||||
notes: $('notes-phase'),
|
notes: $('notes-phase'),
|
||||||
supprimer: $('supprimer-phase'),
|
supprimer: $('supprimer-phase'),
|
||||||
@@ -869,6 +872,17 @@ installerGlisser(refs.couloirs, {
|
|||||||
}
|
}
|
||||||
majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon })));
|
majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon })));
|
||||||
},
|
},
|
||||||
|
surDecalageProjet(idProjet, jours) {
|
||||||
|
// Même convention que pour une phase : null veut dire « geste annulé ou sans
|
||||||
|
// effet », on redessine sans rien sauvegarder.
|
||||||
|
if (!jours) {
|
||||||
|
dessiner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
majPlanning(
|
||||||
|
remplacerProjet(planning, idProjet, (projet) => decalerProjet(projet, jours))
|
||||||
|
);
|
||||||
|
},
|
||||||
surClicProjet(idProjet) {
|
surClicProjet(idProjet) {
|
||||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||||
if (projet) ouvrirProjet(projet);
|
if (projet) ouvrirProjet(projet);
|
||||||
@@ -1315,16 +1329,29 @@ refs.frise.addEventListener(
|
|||||||
{ passive: false }
|
{ passive: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Flèches : ajustement fin de la phase du curseur, au jour près.
|
// Flèches : ajustement fin, au jour près, de ce que porte le curseur.
|
||||||
document.addEventListener('keydown', (evenement) => {
|
document.addEventListener('keydown', (evenement) => {
|
||||||
// Une ligne de projet n'a pas de dates : les flèches n'y ont rien à décaler.
|
if (!curseur) return;
|
||||||
if (!curseur?.phase) return;
|
|
||||||
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
|
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
|
||||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
||||||
|
|
||||||
const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0;
|
const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0;
|
||||||
if (pas === 0) return;
|
if (pas === 0) return;
|
||||||
|
|
||||||
|
// Sur la ligne d'un projet, les flèches repoussent le projet entier — le même
|
||||||
|
// geste que le glisser de sa barre cumulative, au jour près. Maj n'y ajoute
|
||||||
|
// rien : un projet ne s'étire pas (docs/decisions.md, section 8).
|
||||||
|
if (!curseur.phase) {
|
||||||
|
const projet = planning.projects.find((p) => p.id === curseur.projet);
|
||||||
|
if (!projet || !ancreDecalage(projet)) return;
|
||||||
|
|
||||||
|
evenement.preventDefault();
|
||||||
|
majPlanning(
|
||||||
|
remplacerProjet(planning, curseur.projet, (aDecaler) => decalerProjet(aDecaler, pas))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
evenement.preventDefault();
|
evenement.preventDefault();
|
||||||
const trouve = localiser(curseur.projet, curseur.phase);
|
const trouve = localiser(curseur.projet, curseur.phase);
|
||||||
if (!trouve) return;
|
if (!trouve) return;
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
* des séquences et le préfixe en attente.
|
* des séquences et le préfixe en attente.
|
||||||
*
|
*
|
||||||
* Le clavier de la frise est ainsi séparé en deux couches qui ne se recouvrent
|
* Le clavier de la frise est ainsi séparé en deux couches qui ne se recouvrent
|
||||||
* pas : les **flèches modifient** la phase du curseur (app.js, hérité), les
|
* pas : les **flèches modifient** ce que porte le curseur — la phase, ou le
|
||||||
* **lettres déplacent** le curseur et commandent. Aucun raccourci documenté n'a
|
* projet entier quand il est sur sa ligne (app.js, hérité) —, les **lettres
|
||||||
* changé de sens.
|
* déplacent** le curseur et commandent. Aucun raccourci documenté n'a changé de
|
||||||
|
* sens.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
113
js/detail.js
113
js/detail.js
@@ -16,8 +16,31 @@
|
|||||||
import { STATUTS, dateValide, modifierPhase } from './model.js';
|
import { STATUTS, dateValide, modifierPhase } from './model.js';
|
||||||
import { creerBlocNotes } from './notes.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) {
|
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
|
// 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
|
// é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) {
|
function remplir(projet, phase) {
|
||||||
titre.textContent = `${projet.name} — ${phase.name}`;
|
titre.textContent = `${projet.name} — ${phase.name}`;
|
||||||
formulaire.name.value = phase.name;
|
for (const champ of CHAMPS) ecrire(champ, phase);
|
||||||
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;
|
|
||||||
// Ouverture sur l'aperçu si la phase est commentée, sur la saisie sinon :
|
// Ouverture sur l'aperçu si la phase est commentée, sur la saisie sinon :
|
||||||
// on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour
|
// on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour
|
||||||
// y écrire quelque chose.
|
// y écrire quelque chose.
|
||||||
blocNotes.definir(phase.notes, { reinitialiserMode: true });
|
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.
|
// Un jalon tient sur une seule date : le champ « fin » n'a rien à dire.
|
||||||
champFin.hidden = phase.milestone;
|
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. */
|
/** Lit le formulaire, applique les invariants et remonte le changement. */
|
||||||
@@ -77,6 +132,7 @@ export function creerPanneau(refs, rappels) {
|
|||||||
end: fin,
|
end: fin,
|
||||||
status: formulaire.status.value,
|
status: formulaire.status.value,
|
||||||
milestone: jalon,
|
milestone: jalon,
|
||||||
|
fixed: formulaire.fixed.checked,
|
||||||
notes: formulaire.notes.value,
|
notes: formulaire.notes.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +148,7 @@ export function creerPanneau(refs, rappels) {
|
|||||||
afficherErreur(null);
|
afficherErreur(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
champFin.hidden = jalon;
|
accorderLesChamps(apres);
|
||||||
phaseCourante = apres;
|
phaseCourante = apres;
|
||||||
|
|
||||||
if (!sontEgales(avant, apres)) {
|
if (!sontEgales(avant, apres)) {
|
||||||
@@ -162,15 +218,15 @@ export function creerPanneau(refs, rappels) {
|
|||||||
const actif = document.activeElement;
|
const actif = document.activeElement;
|
||||||
phaseCourante = phase;
|
phaseCourante = phase;
|
||||||
titre.textContent = `${projet.name} — ${phase.name}`;
|
titre.textContent = `${projet.name} — ${phase.name}`;
|
||||||
for (const champ of ['name', 'start', 'end', 'status']) {
|
for (const champ of CHAMPS) {
|
||||||
if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ];
|
if (!aLeFocus(formulaire[champ.nom], actif)) ecrire(champ, phase);
|
||||||
}
|
}
|
||||||
// Le mode n'est pas réinitialisé : basculer en aperçu sous les doigts de
|
// 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,
|
// qui écrit, parce qu'un glisser sur la frise a rafraîchi le panneau,
|
||||||
// serait le pire moment pour changer d'état.
|
// serait le pire moment pour changer d'état.
|
||||||
if (formulaire.notes !== actif) blocNotes.definir(phase.notes);
|
if (formulaire.notes !== actif) blocNotes.definir(phase.notes);
|
||||||
if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone;
|
peindre(projet);
|
||||||
champFin.hidden = phase.milestone;
|
accorderLesChamps(phase);
|
||||||
},
|
},
|
||||||
|
|
||||||
fermer() {
|
fermer() {
|
||||||
@@ -192,12 +248,29 @@ export function creerPanneau(refs, rappels) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sontEgales(a, b) {
|
function sontEgales(a, b) {
|
||||||
return (
|
return CHAMPS.every((champ) => a[champ.nom] === b[champ.nom]) && a.notes === b.notes;
|
||||||
a.name === b.name &&
|
}
|
||||||
a.start === b.start &&
|
|
||||||
a.end === b.end &&
|
/**
|
||||||
a.status === b.status &&
|
* Le statut que le formulaire doit montrer.
|
||||||
a.milestone === b.milestone &&
|
*
|
||||||
a.notes === b.notes
|
* 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.
|
* Glisser et redimensionner les barres.
|
||||||
*
|
*
|
||||||
|
* Trois sortes de barres se saisissent, et le même appui les sert toutes :
|
||||||
|
* celle d'une **phase**, accrochée au lundi ; celle de l'**horizon** d'un projet
|
||||||
|
* envisagé, accrochée au mois ; et la barre **cumulative** d'un projet, qui
|
||||||
|
* repousse d'un coup toutes ses phases non terminées. Chacune sait ce qu'elle
|
||||||
|
* déplace par son `data-role`.
|
||||||
|
*
|
||||||
* Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un
|
* Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un
|
||||||
* glisser déplace la barre. On les départage au seuil : tant que le pointeur
|
* glisser déplace la barre. On les départage au seuil : tant que le pointeur
|
||||||
* n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme
|
* n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme
|
||||||
@@ -15,15 +21,20 @@
|
|||||||
import {
|
import {
|
||||||
ajouterJours,
|
ajouterJours,
|
||||||
ajouterMois,
|
ajouterMois,
|
||||||
|
ancreDecalage,
|
||||||
|
decalerProjet,
|
||||||
deplacerPhase,
|
deplacerPhase,
|
||||||
dernierJourDuMois,
|
dernierJourDuMois,
|
||||||
|
ecartJours,
|
||||||
ecartMois,
|
ecartMois,
|
||||||
lundiDeLaSemaine,
|
lundiDeLaSemaine,
|
||||||
moisDe,
|
moisDe,
|
||||||
|
phasesDecalables,
|
||||||
premierJourDuMois,
|
premierJourDuMois,
|
||||||
redimensionnerPhase,
|
redimensionnerPhase,
|
||||||
versUTC,
|
versUTC,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
|
import { poserBarreCumulative } from './timeline.js';
|
||||||
|
|
||||||
/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */
|
/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */
|
||||||
const SEUIL_GLISSER = 4;
|
const SEUIL_GLISSER = 4;
|
||||||
@@ -45,12 +56,43 @@ export function installerGlisser(conteneur, rappels) {
|
|||||||
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
||||||
if (evenement.button !== 0) return;
|
if (evenement.button !== 0) return;
|
||||||
|
|
||||||
const cible = evenement.target.closest('[data-role="barre"], [data-role="horizon"]');
|
const cible = evenement.target.closest(
|
||||||
|
'[data-role="barre"], [data-role="horizon"], [data-role="cumul"]'
|
||||||
|
);
|
||||||
if (!cible) return;
|
if (!cible) return;
|
||||||
|
|
||||||
const poignee = evenement.target.closest('[data-role="poignee"]');
|
const poignee = evenement.target.closest('[data-role="poignee"]');
|
||||||
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
|
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
|
||||||
|
|
||||||
|
// La barre cumulative repousse le projet entier. Elle n'a pas de poignées :
|
||||||
|
// étirer un projet supposerait de dilater chaque phase au prorata, ce qui
|
||||||
|
// n'est pas le même besoin (docs/decisions.md, section 8).
|
||||||
|
if (cible.dataset.role === 'cumul') {
|
||||||
|
const projet = rappels.trouverProjet(cible.dataset.projet);
|
||||||
|
const ancre = projet && ancreDecalage(projet);
|
||||||
|
if (!ancre) return;
|
||||||
|
|
||||||
|
geste = {
|
||||||
|
element: cible,
|
||||||
|
sorte: 'cumul',
|
||||||
|
idProjet: projet.id,
|
||||||
|
projetInitial: projet,
|
||||||
|
ancre,
|
||||||
|
jours: 0,
|
||||||
|
mode: 'deplacer',
|
||||||
|
xDepart: evenement.clientX,
|
||||||
|
// Les barres de phase suivent le résumé pendant le geste : sans elles,
|
||||||
|
// on repousserait plusieurs mois de travail sans voir ce qui bouge —
|
||||||
|
// l'objection qui avait d'abord fait écarter ce geste.
|
||||||
|
suiveurs: suiveursDuProjet(conteneur, projet),
|
||||||
|
demarre: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
cible.setPointerCapture(evenement.pointerId);
|
||||||
|
evenement.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// La barre d'un projet envisagé se manœuvre au mois, pas au jour : même
|
// La barre d'un projet envisagé se manœuvre au mois, pas au jour : même
|
||||||
// mécanique de geste, autre grain, et rien de commun avec une phase — il n'y
|
// mécanique de geste, autre grain, et rien de commun avec une phase — il n'y
|
||||||
// en a pas encore (docs/decisions.md, section 27).
|
// en a pas encore (docs/decisions.md, section 27).
|
||||||
@@ -122,6 +164,14 @@ export function installerGlisser(conteneur, rappels) {
|
|||||||
|
|
||||||
relacher(acheve, evenement.pointerId);
|
relacher(acheve, evenement.pointerId);
|
||||||
|
|
||||||
|
if (acheve.sorte === 'cumul') {
|
||||||
|
// Un appui sans glisser ouvre les paramètres du projet, comme un clic sur
|
||||||
|
// son nom ou sur sa barre d'horizon.
|
||||||
|
if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet);
|
||||||
|
else rappels.surDecalageProjet(acheve.idProjet, acheve.jours || null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (acheve.sorte === 'horizon') {
|
if (acheve.sorte === 'horizon') {
|
||||||
const avant = acheve.horizonInitial;
|
const avant = acheve.horizonInitial;
|
||||||
const apres = acheve.horizonCourant;
|
const apres = acheve.horizonCourant;
|
||||||
@@ -160,7 +210,8 @@ export function installerGlisser(conteneur, rappels) {
|
|||||||
geste = null;
|
geste = null;
|
||||||
relacher(annule);
|
relacher(annule);
|
||||||
|
|
||||||
if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
|
if (annule.sorte === 'cumul') rappels.surDecalageProjet(annule.idProjet, null);
|
||||||
|
else if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
|
||||||
else rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
else rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +251,7 @@ function relacher(geste, pointerId) {
|
|||||||
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
||||||
*/
|
*/
|
||||||
function appliquerGeste(geste, decalage, echelle) {
|
function appliquerGeste(geste, decalage, echelle) {
|
||||||
|
if (geste.sorte === 'cumul') return appliquerGesteCumul(geste, decalage, echelle);
|
||||||
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
|
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
|
||||||
|
|
||||||
const { phaseInitiale: phase, mode } = geste;
|
const { phaseInitiale: phase, mode } = geste;
|
||||||
@@ -229,6 +281,57 @@ function appliquerGeste(geste, decalage, echelle) {
|
|||||||
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
|
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Éléments de la frise qu'un décalage de projet emporte, avec leur position de
|
||||||
|
* départ.
|
||||||
|
*
|
||||||
|
* La position est relue dans `style.left` plutôt que dans `offsetLeft` : le nom
|
||||||
|
* d'un jalon porte une marge, que le second compte et le premier non. Repartir
|
||||||
|
* de la valeur qu'on a soi-même posée est le seul moyen d'y ajouter un décalage
|
||||||
|
* sans en décaler deux fois.
|
||||||
|
*
|
||||||
|
* Le fantôme de référence n'en est délibérément pas : il ne porte aucune phase,
|
||||||
|
* il tient la place du plan figé, et le voir rester en arrière pendant qu'on
|
||||||
|
* repousse le projet *est* la dérive qui s'affiche.
|
||||||
|
*/
|
||||||
|
function suiveursDuProjet(conteneur, projet) {
|
||||||
|
const decalables = new Set(phasesDecalables(projet).map((phase) => phase.id));
|
||||||
|
const suiveurs = [];
|
||||||
|
|
||||||
|
for (const element of conteneur.querySelectorAll('.barre, .jalon, .jalon-nom')) {
|
||||||
|
if (element.dataset.projet !== projet.id) continue;
|
||||||
|
if (!decalables.has(element.dataset.phase)) continue;
|
||||||
|
suiveurs.push({ element, gaucheInitiale: parseFloat(element.style.left) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return suiveurs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repousse un projet entier : le même nombre de jours sur toutes ses phases non
|
||||||
|
* terminées, accroché au lundi comme une phase seule (décision 9).
|
||||||
|
*
|
||||||
|
* L'accroche porte sur la première phase qui bouge, et non sur le début du
|
||||||
|
* projet — sur un projet dont les premières phases sont closes, ce début reste
|
||||||
|
* en place et ne dirait rien du geste en cours.
|
||||||
|
*
|
||||||
|
* La barre cumulative est reposée depuis le modèle plutôt que translatée : elle
|
||||||
|
* se déforme quand une partie du projet ne suit pas, et son bord gauche tient
|
||||||
|
* pendant que le reste s'en va.
|
||||||
|
*/
|
||||||
|
function appliquerGesteCumul(geste, decalage, echelle) {
|
||||||
|
const jours = ecartJours(geste.ancre, accrocher(geste.ancre, decalage, echelle));
|
||||||
|
if (jours === geste.jours) return;
|
||||||
|
geste.jours = jours;
|
||||||
|
|
||||||
|
poserBarreCumulative(geste.element, decalerProjet(geste.projetInitial, jours), echelle);
|
||||||
|
|
||||||
|
const pixels = jours * echelle.pixelsParJour;
|
||||||
|
for (const suiveur of geste.suiveurs) {
|
||||||
|
suiveur.element.style.left = `${suiveur.gaucheInitiale + pixels}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** :
|
* Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** :
|
||||||
* l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire
|
* l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire
|
||||||
|
|||||||
118
js/model.js
118
js/model.js
@@ -15,16 +15,17 @@
|
|||||||
/**
|
/**
|
||||||
* Version du format de fichier.
|
* Version du format de fichier.
|
||||||
*
|
*
|
||||||
* Elle a changé pour la dernière fois quand le projet a reçu son cycle de vie —
|
* Elle a changé pour la dernière fois quand la phase a reçu sa date imposée —
|
||||||
* `horizon`, `completedDate`, `discardedDate` (v5). Un champ ajouté n'oblige
|
* `fixed` (v6) ; avant cela, quand le projet a reçu son cycle de vie (v5). Un
|
||||||
* pourtant à rien : la v4 se relit sans encombre, et le validateur ne refuse
|
* champ ajouté n'oblige pourtant à rien : la v5 se relit sans encombre, et le
|
||||||
* qu'un fichier *plus récent* que lui. C'est justement là qu'est la raison
|
* validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement là
|
||||||
* d'incrémenter — le validateur reconstruit chaque projet champ par champ et
|
* qu'est la raison d'incrémenter — le validateur reconstruit chaque projet champ
|
||||||
* laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire antérieur
|
* par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire
|
||||||
* ouvrirait un fichier v5 sans broncher et en effacerait tous les horizons à la
|
* antérieur ouvrirait un fichier v6 sans broncher et en effacerait toutes les
|
||||||
* première sauvegarde.
|
* dates imposées à la première sauvegarde : les phases concernées se remettraient
|
||||||
|
* alors à suivre le décalage de leur projet, en silence.
|
||||||
*/
|
*/
|
||||||
export const VERSION_FORMAT = 5;
|
export const VERSION_FORMAT = 6;
|
||||||
|
|
||||||
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
|
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
|
||||||
|
|
||||||
@@ -50,6 +51,24 @@ export const LIBELLES_STATUT = {
|
|||||||
blocked: 'Bloqué',
|
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. */
|
/** Palette par défaut, parcourue à la création de chaque nouveau projet. */
|
||||||
export const COULEURS = [
|
export const COULEURS = [
|
||||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
|
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
|
||||||
@@ -652,6 +671,10 @@ function validerPhase(phase, rang, repereProjet, idsPhases) {
|
|||||||
// chargement : la valeur est cosmétique, contrairement aux dates.
|
// chargement : la valeur est cosmétique, contrairement aux dates.
|
||||||
status: STATUTS.includes(phase.status) ? phase.status : 'todo',
|
status: STATUTS.includes(phase.status) ? phase.status : 'todo',
|
||||||
milestone: jalon,
|
milestone: jalon,
|
||||||
|
// Absente d'un fichier en version 5, où elle vaut donc `false` : aucune date
|
||||||
|
// n'y était déclarée imposée, et rien dans les données ne permettrait de
|
||||||
|
// deviner laquelle l'était.
|
||||||
|
fixed: Boolean(phase.fixed),
|
||||||
notes: typeof phase.notes === 'string' ? phase.notes : '',
|
notes: typeof phase.notes === 'string' ? phase.notes : '',
|
||||||
...(reference ? { baseline: reference } : {}),
|
...(reference ? { baseline: reference } : {}),
|
||||||
};
|
};
|
||||||
@@ -996,6 +1019,7 @@ export function creerPhase(projet, nom, debut, fin, options = {}) {
|
|||||||
end: jalon ? debut : fin,
|
end: jalon ? debut : fin,
|
||||||
status: STATUTS.includes(options.status) ? options.status : 'todo',
|
status: STATUTS.includes(options.status) ? options.status : 'todo',
|
||||||
milestone: jalon,
|
milestone: jalon,
|
||||||
|
fixed: Boolean(options.fixed),
|
||||||
notes: options.notes || '',
|
notes: options.notes || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1030,82 @@ export function deplacerPhase(phase, nouveauDebut) {
|
|||||||
return { ...phase, start: nouveauDebut, end: ajouterJours(nouveauDebut, jours) };
|
return { ...phase, start: nouveauDebut, end: ajouterJours(nouveauDebut, jours) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les phases qu'un décalage de projet emporte. Deux sortes en sont exclues, pour
|
||||||
|
* deux raisons qui n'ont rien à voir.
|
||||||
|
*
|
||||||
|
* **Ce qui est terminé.** Décaler un projet, c'est reconnaître qu'il prendra plus
|
||||||
|
* de temps que prévu — et ce qui est fait est fait. Emporter les phases `done`
|
||||||
|
* réécrirait un passé qu'on a vécu, et effacerait du même coup la dérive qu'on
|
||||||
|
* cherche justement à lire : la référence figée, elle, ne bouge pas. C'est le
|
||||||
|
* statut qui décide, jamais la position dans le calendrier : une phase terminée
|
||||||
|
* en avance reste où elle est, une phase `blocked` ou `doing` déjà commencée se
|
||||||
|
* décale avec le reste — c'est bien elle qui glisse.
|
||||||
|
*
|
||||||
|
* **Ce dont on ne décide pas.** Une phase à `fixed` porte une date qui ne nous
|
||||||
|
* appartient pas : elle est déléguée, contractuelle ou réglementaire. Repousser
|
||||||
|
* le projet ne repousse pas l'audit du commissaire aux comptes ; le décalage lui
|
||||||
|
* passe à travers, et le chevauchement qui en résulte est précisément
|
||||||
|
* l'information qu'on veut voir.
|
||||||
|
*/
|
||||||
|
export function phasesDecalables(projet) {
|
||||||
|
return projet.phases.filter((phase) => phase.status !== 'done' && !phase.fixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date à laquelle s'accroche le geste de décalage : le début de la première
|
||||||
|
* phase qui bougera. Null si tout est terminé — il n'y a alors rien à décaler.
|
||||||
|
*
|
||||||
|
* C'est cette date, et non le début du projet, qui sert d'ancre : sur un projet
|
||||||
|
* dont les premières phases sont closes, le bord gauche de ce qui bouge doit
|
||||||
|
* suivre le pointeur, pas un bord qui reste sur place.
|
||||||
|
*/
|
||||||
|
export function ancreDecalage(projet) {
|
||||||
|
const decalables = phasesDecalables(projet);
|
||||||
|
if (!decalables.length) return null;
|
||||||
|
return decalables.reduce(
|
||||||
|
(debut, phase) => (versUTC(phase.start) < versUTC(debut) ? phase.start : debut),
|
||||||
|
decalables[0].start
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Décale d'un même nombre de jours les phases que le projet emporte —
|
||||||
|
* `phasesDecalables` dit lesquelles.
|
||||||
|
*
|
||||||
|
* Le même delta partout, jalons compris : les écarts entre phases emportées sont
|
||||||
|
* conservés au jour près, ce qui est tout l'intérêt du geste — on repousse un
|
||||||
|
* plan sans le replanifier. Celles qui restent font que le projet ne se déplace
|
||||||
|
* pas tant qu'il s'étire.
|
||||||
|
*
|
||||||
|
* L'appartenance se décide **une fois**, avant de rien décaler, et se retient par
|
||||||
|
* identifiant : la parcourir au fil de la transformation reviendrait à interroger
|
||||||
|
* des phases déjà déplacées.
|
||||||
|
*
|
||||||
|
* Rien n'empêche le décalage de faire chevaucher deux phases : il n'y a pas de
|
||||||
|
* dépendances entre phases dans ce modèle (docs/decisions.md, section 2), et
|
||||||
|
* inventer une butée ici reviendrait à en poser une par la bande.
|
||||||
|
*/
|
||||||
|
export function decalerProjet(projet, jours) {
|
||||||
|
if (!jours) return projet;
|
||||||
|
const emportees = new Set(phasesDecalables(projet).map((phase) => phase.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...projet,
|
||||||
|
phases: trierPhases(
|
||||||
|
projet.phases.map((phase) =>
|
||||||
|
emportees.has(phase.id)
|
||||||
|
? {
|
||||||
|
...phase,
|
||||||
|
start: ajouterJours(phase.start, jours),
|
||||||
|
end: ajouterJours(phase.end, jours),
|
||||||
|
}
|
||||||
|
: phase
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change une seule borne — le glisser d'un bord de barre.
|
* Change une seule borne — le glisser d'un bord de barre.
|
||||||
* La borne opposée fait butée : une phase ne peut pas se retourner.
|
* La borne opposée fait butée : une phase ne peut pas se retourner.
|
||||||
|
|||||||
11
js/mois.js
11
js/mois.js
@@ -29,6 +29,7 @@ import {
|
|||||||
formaterHorizon,
|
formaterHorizon,
|
||||||
formaterMois,
|
formaterMois,
|
||||||
formaterPeriode,
|
formaterPeriode,
|
||||||
|
libelleStatut,
|
||||||
moisDe,
|
moisDe,
|
||||||
projetFiltre,
|
projetFiltre,
|
||||||
projetFiltreEtat,
|
projetFiltreEtat,
|
||||||
@@ -213,6 +214,10 @@ function construireEntreePhase({ projet, phase }, tagsActifs) {
|
|||||||
ligne.className = `mois-entree mois-entree--${phase.status}`;
|
ligne.className = `mois-entree mois-entree--${phase.status}`;
|
||||||
ligne.style.setProperty('--couleur-projet', projet.color);
|
ligne.style.setProperty('--couleur-projet', projet.color);
|
||||||
if (phase.milestone) ligne.classList.add('mois-entree--jalon');
|
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
|
// 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.
|
// notes, il n'y a rien à déplier et le clic ne doit rien promettre.
|
||||||
if (phase.notes) ligne.classList.add('mois-entree--commentee');
|
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));
|
ligne.append(triangle(phase.notes, phase.name));
|
||||||
|
|
||||||
// La marque reprend les formes de la frise — losange pour un jalon, barre
|
// 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 à
|
// pour une tâche, cadre tireté pour une date imposée — pour qu'on reconnaisse
|
||||||
// lire là-bas.
|
// au même dessin ce qu'on a appris à lire là-bas.
|
||||||
const marque = document.createElement('span');
|
const marque = document.createElement('span');
|
||||||
marque.className = 'mois-entree__marque';
|
marque.className = 'mois-entree__marque';
|
||||||
ligne.append(marque);
|
ligne.append(marque);
|
||||||
@@ -327,7 +332,7 @@ function infobulle(projet, phase) {
|
|||||||
? `Jalon du ${formaterDateLongue(phase.start)}`
|
? `Jalon du ${formaterDateLongue(phase.start)}`
|
||||||
: `${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
: `${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
||||||
` (${jours} jour${jours > 1 ? 's' : ''})`) +
|
` (${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
|
// Aplati : un `title` ne connaît que le texte. Sans cela, survoler afficherait
|
||||||
// la source markdown, astérisques comprises — une note *moins* lisible
|
// la source markdown, astérisques comprises — une note *moins* lisible
|
||||||
// qu'avant qu'on ne l'enrichisse.
|
// qu'avant qu'on ne l'enrichisse.
|
||||||
|
|||||||
105
js/timeline.js
105
js/timeline.js
@@ -13,7 +13,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
LIBELLES_ETAT,
|
LIBELLES_ETAT,
|
||||||
LIBELLES_STATUT,
|
|
||||||
MOIS_COURTS,
|
MOIS_COURTS,
|
||||||
MOIS_LONGS,
|
MOIS_LONGS,
|
||||||
ajouterJours,
|
ajouterJours,
|
||||||
@@ -28,7 +27,9 @@ import {
|
|||||||
formaterDateLongue,
|
formaterDateLongue,
|
||||||
formaterHorizon,
|
formaterHorizon,
|
||||||
formulerDerive,
|
formulerDerive,
|
||||||
|
libelleStatut,
|
||||||
lundiDeLaSemaine,
|
lundiDeLaSemaine,
|
||||||
|
phasesDecalables,
|
||||||
projetADater,
|
projetADater,
|
||||||
projetFiltre,
|
projetFiltre,
|
||||||
projetFiltreEtat,
|
projetFiltreEtat,
|
||||||
@@ -307,7 +308,7 @@ function construireLibelles(projets, tagsActifs, curseur) {
|
|||||||
const ligne = document.createElement('div');
|
const ligne = document.createElement('div');
|
||||||
ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
|
ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
|
||||||
ligne.textContent = phase.name;
|
ligne.textContent = phase.name;
|
||||||
ligne.title = `${phase.name} — ${LIBELLES_STATUT[phase.status]}`;
|
ligne.title = `${phase.name} — ${libelleStatut(phase)}`;
|
||||||
ligne.dataset.projet = projet.id;
|
ligne.dataset.projet = projet.id;
|
||||||
ligne.dataset.phase = phase.id;
|
ligne.dataset.phase = phase.id;
|
||||||
elements.push(ligne);
|
elements.push(ligne);
|
||||||
@@ -508,7 +509,12 @@ function construirePhase(projet, phase, echelle, curseur) {
|
|||||||
|
|
||||||
if (phase.milestone) {
|
if (phase.milestone) {
|
||||||
const losange = document.createElement('div');
|
const losange = document.createElement('div');
|
||||||
losange.className = `jalon jalon--${phase.status}${selectionnee ? ' jalon--selectionne' : ''}`;
|
losange.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.style.left = `${echelle.x(phase.start)}px`;
|
||||||
losange.dataset.projet = projet.id;
|
losange.dataset.projet = projet.id;
|
||||||
losange.dataset.phase = phase.id;
|
losange.dataset.phase = phase.id;
|
||||||
@@ -518,7 +524,7 @@ function construirePhase(projet, phase, echelle, curseur) {
|
|||||||
// infobulle les taisait, alors que celle d'une barre les montrait.
|
// infobulle les taisait, alors que celle d'une barre les montrait.
|
||||||
losange.title =
|
losange.title =
|
||||||
`${phase.name} — ${formaterDateLongue(phase.start)}` +
|
`${phase.name} — ${formaterDateLongue(phase.start)}` +
|
||||||
`\n${LIBELLES_STATUT[phase.status]}` +
|
`\n${libelleStatut(phase)}` +
|
||||||
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
|
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
|
||||||
elements.push(losange);
|
elements.push(losange);
|
||||||
|
|
||||||
@@ -526,13 +532,22 @@ function construirePhase(projet, phase, echelle, curseur) {
|
|||||||
nom.className = 'jalon-nom';
|
nom.className = 'jalon-nom';
|
||||||
nom.style.left = `${echelle.x(phase.start)}px`;
|
nom.style.left = `${echelle.x(phase.start)}px`;
|
||||||
nom.textContent = phase.name;
|
nom.textContent = phase.name;
|
||||||
|
// Le nom vit hors du losange — il déborderait de ses 12 pixels. Il porte donc
|
||||||
|
// sa propre identité, faute de quoi le décalage d'un projet emporterait le
|
||||||
|
// jalon en laissant son nom sur place.
|
||||||
|
nom.dataset.projet = projet.id;
|
||||||
|
nom.dataset.phase = phase.id;
|
||||||
elements.push(nom);
|
elements.push(nom);
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
const barre = document.createElement('div');
|
const barre = document.createElement('div');
|
||||||
barre.className = `barre barre--${phase.status}${selectionnee ? ' barre--selectionnee' : ''}`;
|
barre.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.left = `${echelle.x(phase.start)}px`;
|
||||||
barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`;
|
barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`;
|
||||||
barre.dataset.projet = projet.id;
|
barre.dataset.projet = projet.id;
|
||||||
@@ -540,7 +555,7 @@ function construirePhase(projet, phase, echelle, curseur) {
|
|||||||
barre.dataset.role = 'barre';
|
barre.dataset.role = 'barre';
|
||||||
barre.title =
|
barre.title =
|
||||||
`${phase.name}\n${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
`${phase.name}\n${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
||||||
`\n${LIBELLES_STATUT[phase.status]}` +
|
`\n${libelleStatut(phase)}` +
|
||||||
// Aplati : l'infobulle native ne connaît que le texte, et y déverser la
|
// Aplati : l'infobulle native ne connaît que le texte, et y déverser la
|
||||||
// source markdown rendrait la note moins lisible qu'avant qu'on ne
|
// source markdown rendrait la note moins lisible qu'avant qu'on ne
|
||||||
// l'enrichisse. `aplatirMarkdown` retire les marques et remplace celles qui
|
// l'enrichisse. `aplatirMarkdown` retire les marques et remplace celles qui
|
||||||
@@ -571,40 +586,96 @@ function construirePhase(projet, phase, echelle, curseur) {
|
|||||||
/**
|
/**
|
||||||
* Barre unique du projet, segmentée par phase — sa forme d'ensemble.
|
* Barre unique du projet, segmentée par phase — sa forme d'ensemble.
|
||||||
*
|
*
|
||||||
* Affichée qu'il soit plié ou déplié. Lecture seule dans les deux cas : aucun
|
* Affichée qu'il soit plié ou déplié. Elle se saisit pour repousser le projet
|
||||||
* `data-role`, donc drag.js l'ignore (docs/decisions.md, section 8).
|
* entier, à condition qu'il reste quelque chose à repousser : un projet dont
|
||||||
|
* toutes les phases sont terminées n'a rien qui bouge, et sa barre reste inerte
|
||||||
|
* plutôt que d'offrir une prise qui ne mènerait à rien (docs/decisions.md,
|
||||||
|
* section 8).
|
||||||
*/
|
*/
|
||||||
function construireBarreCumulative(projet, echelle) {
|
function construireBarreCumulative(projet, echelle) {
|
||||||
const bornes = bornesProjet(projet);
|
const bornes = bornesProjet(projet);
|
||||||
if (!bornes) return null;
|
if (!bornes) return null;
|
||||||
|
|
||||||
|
const decalables = phasesDecalables(projet);
|
||||||
|
|
||||||
const conteneur = document.createElement('div');
|
const conteneur = document.createElement('div');
|
||||||
conteneur.className = 'barre-cumulative';
|
conteneur.className = 'barre-cumulative';
|
||||||
conteneur.style.left = `${echelle.x(bornes.start)}px`;
|
conteneur.dataset.projet = projet.id;
|
||||||
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
|
// Rôle distinct de `barre` et de `horizon` : drag.js y reconnaît qu'il décale
|
||||||
|
// un projet entier, et non l'objet unique qu'il a sous le pointeur.
|
||||||
|
if (decalables.length) conteneur.dataset.role = 'cumul';
|
||||||
|
|
||||||
const derive = formulerDerive(deriveProjet(projet));
|
const derive = formulerDerive(deriveProjet(projet));
|
||||||
conteneur.title =
|
conteneur.title =
|
||||||
`${projet.name}\n${formaterDateLongue(bornes.start)} → ${formaterDateLongue(bornes.end)}` +
|
`${projet.name}\n${formaterDateLongue(bornes.start)} → ${formaterDateLongue(bornes.end)}` +
|
||||||
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
|
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
|
||||||
(derive ? `\nÉcart à la référence : ${derive}` : '') +
|
(derive ? `\nÉcart à la référence : ${derive}` : '') +
|
||||||
// Déplié, les barres modifiables sont juste en dessous : le dire serait
|
`\n\n${modeDEmploiDecalage(projet, decalables.length)}`;
|
||||||
// enfoncer une porte ouverte.
|
|
||||||
(projet.collapsed ? '\n\nDéplier le projet pour modifier les dates.' : '');
|
|
||||||
|
|
||||||
const origine = echelle.x(bornes.start);
|
|
||||||
for (const phase of projet.phases) {
|
for (const phase of projet.phases) {
|
||||||
const segment = document.createElement('div');
|
const segment = document.createElement('div');
|
||||||
segment.className = `segment segment--${phase.status}`;
|
segment.className = `segment segment--${phase.status}`;
|
||||||
|
// Le geste rejoue la géométrie sur ces mêmes nœuds à chaque mouvement de
|
||||||
|
// souris, et le décalage retrie les phases : c'est l'identifiant qui les
|
||||||
|
// apparie, jamais leur rang.
|
||||||
|
segment.dataset.phase = phase.id;
|
||||||
|
conteneur.append(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
poserBarreCumulative(conteneur, projet, echelle);
|
||||||
|
return conteneur;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ce que dit l'infobulle du résumé sur le geste qu'il offre.
|
||||||
|
*
|
||||||
|
* La restriction aux phases non terminées ne s'annonce que lorsqu'elle joue :
|
||||||
|
* sur un projet dont rien n'est clos — le cas courant — le geste emporte tout,
|
||||||
|
* et il n'y a aucune nuance à apprendre.
|
||||||
|
*/
|
||||||
|
function modeDEmploiDecalage(projet, nombreDecalables) {
|
||||||
|
if (!nombreDecalables) {
|
||||||
|
return 'Rien à repousser : ses phases sont toutes terminées ou à date imposée.';
|
||||||
|
}
|
||||||
|
if (nombreDecalables === projet.phases.length) return 'Glisser pour repousser le projet.';
|
||||||
|
|
||||||
|
const retenues = projet.phases.length - nombreDecalables;
|
||||||
|
const compte =
|
||||||
|
retenues > 1 ? `${retenues} de ses phases ne suivront pas` : 'Une de ses phases ne suivra pas';
|
||||||
|
return `Glisser pour repousser le projet. ${compte} — terminée${retenues > 1 ? 's' : ''} ou à date imposée.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose la géométrie de la barre cumulative et de ses segments d'après l'état du
|
||||||
|
* projet.
|
||||||
|
*
|
||||||
|
* Séparée de sa construction parce que `drag.js` la rejoue à chaque mouvement de
|
||||||
|
* souris : décaler un projet dont une partie est terminée ne translate pas cette
|
||||||
|
* barre, il la déforme — le bord gauche tient, le reste s'en va. Elle ne peut
|
||||||
|
* donc pas se contenter d'un `left` mis à jour, et la formule doit vivre au même
|
||||||
|
* endroit que celle du rendu, sous peine de voir l'aperçu et le résultat
|
||||||
|
* diverger.
|
||||||
|
*/
|
||||||
|
export function poserBarreCumulative(conteneur, projet, echelle) {
|
||||||
|
const bornes = bornesProjet(projet);
|
||||||
|
if (!bornes) return;
|
||||||
|
|
||||||
|
const origine = echelle.x(bornes.start);
|
||||||
|
conteneur.style.left = `${origine}px`;
|
||||||
|
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
|
||||||
|
|
||||||
|
const segments = new Map();
|
||||||
|
for (const segment of conteneur.children) segments.set(segment.dataset.phase, segment);
|
||||||
|
|
||||||
|
for (const phase of projet.phases) {
|
||||||
|
const segment = segments.get(phase.id);
|
||||||
|
if (!segment) continue;
|
||||||
segment.style.left = `${echelle.x(phase.start) - origine}px`;
|
segment.style.left = `${echelle.x(phase.start) - origine}px`;
|
||||||
// Un jalon n'a pas de largeur propre : on lui donne 3 px pour rester visible.
|
// Un jalon n'a pas de largeur propre : on lui donne 3 px pour rester visible.
|
||||||
segment.style.width = phase.milestone
|
segment.style.width = phase.milestone
|
||||||
? '3px'
|
? '3px'
|
||||||
: `${echelle.largeurDe(phase.start, phase.end)}px`;
|
: `${echelle.largeurDe(phase.start, phase.end)}px`;
|
||||||
conteneur.append(segment);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return conteneur;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -67,7 +67,7 @@ const portsTentes = 10
|
|||||||
// lancement est immédiatement relu par le front, qui refuse une version plus
|
// lancement est immédiatement relu par le front, qui refuse une version plus
|
||||||
// récente que la sienne — mais accepterait sans rien dire une version plus
|
// récente que la sienne — mais accepterait sans rien dire une version plus
|
||||||
// ancienne, et la réécrirait au format courant.
|
// ancienne, et la réécrirait au format courant.
|
||||||
var donneesInitiales = []byte(`{"version": 4, "projects": []}`)
|
var donneesInitiales = []byte(`{"version": 6, "projects": []}`)
|
||||||
|
|
||||||
type serveur struct {
|
type serveur struct {
|
||||||
fichierDonnees string
|
fichierDonnees string
|
||||||
|
|||||||
@@ -73,9 +73,9 @@ func TestGetSansFichierRenvoiePlanningVide(t *testing.T) {
|
|||||||
if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &planning); err != nil {
|
||||||
t.Fatalf("réponse illisible : %v", err)
|
t.Fatalf("réponse illisible : %v", err)
|
||||||
}
|
}
|
||||||
// Le format courant est la version 4 (js/model.js, VERSION_FORMAT).
|
// Le format courant est la version 6 (js/model.js, VERSION_FORMAT).
|
||||||
if planning.Version != 4 {
|
if planning.Version != 6 {
|
||||||
t.Errorf("version = %d, attendu 4", planning.Version)
|
t.Errorf("version = %d, attendu 6", planning.Version)
|
||||||
}
|
}
|
||||||
if len(planning.Projects) != 0 {
|
if len(planning.Projects) != 0 {
|
||||||
t.Errorf("projects = %v, attendu vide", planning.Projects)
|
t.Errorf("projects = %v, attendu vide", planning.Projects)
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import {
|
|||||||
bornesPlanning,
|
bornesPlanning,
|
||||||
bornesProjet,
|
bornesProjet,
|
||||||
bornesReference,
|
bornesReference,
|
||||||
|
ancreDecalage,
|
||||||
cleTag,
|
cleTag,
|
||||||
clotureEcraseDesStatuts,
|
clotureEcraseDesStatuts,
|
||||||
creerPhase,
|
creerPhase,
|
||||||
creerProjet,
|
creerProjet,
|
||||||
dateValide,
|
dateValide,
|
||||||
decalerHorizon,
|
decalerHorizon,
|
||||||
|
decalerProjet,
|
||||||
definirHorizon,
|
definirHorizon,
|
||||||
deplacerPhase,
|
deplacerPhase,
|
||||||
dernierJourDuMois,
|
dernierJourDuMois,
|
||||||
@@ -37,11 +39,13 @@ import {
|
|||||||
formaterMois,
|
formaterMois,
|
||||||
formaterPeriode,
|
formaterPeriode,
|
||||||
formulerDerive,
|
formulerDerive,
|
||||||
|
libelleStatut,
|
||||||
libererReference,
|
libererReference,
|
||||||
lundiDeLaSemaine,
|
lundiDeLaSemaine,
|
||||||
modifierPhase,
|
modifierPhase,
|
||||||
moisValide,
|
moisValide,
|
||||||
normaliserTags,
|
normaliserTags,
|
||||||
|
phasesDecalables,
|
||||||
planningVide,
|
planningVide,
|
||||||
pourEcriture,
|
pourEcriture,
|
||||||
premierJourDuMois,
|
premierJourDuMois,
|
||||||
@@ -368,6 +372,24 @@ describe('validerPlanning', () => {
|
|||||||
assert.equal(resultat.projects[0].notes, '');
|
assert.equal(resultat.projects[0].notes, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('une phase d un fichier en version 5 n a pas de date imposée', () => {
|
||||||
|
// Rien dans les données ne permettrait de deviner laquelle l'était : la
|
||||||
|
// migration ne devine pas, elle met tout à `false`.
|
||||||
|
const resultat = validerPlanning({ version: 5, projects: [projet({ phases: [phase()] })] });
|
||||||
|
assert.equal(resultat.projects[0].phases[0].fixed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('conserve une date imposée, et l écrit dans le fichier', () => {
|
||||||
|
const resultat = validerPlanning({
|
||||||
|
projects: [projet({ phases: [phase({ fixed: true })] })],
|
||||||
|
});
|
||||||
|
assert.equal(resultat.projects[0].phases[0].fixed, true);
|
||||||
|
|
||||||
|
const relu = JSON.parse(JSON.stringify(pourEcriture(resultat)));
|
||||||
|
assert.equal(relu.version, VERSION_FORMAT);
|
||||||
|
assert.equal(relu.projects[0].phases[0].fixed, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('conserve les notes d un projet', () => {
|
test('conserve les notes d un projet', () => {
|
||||||
const resultat = validerPlanning({
|
const resultat = validerPlanning({
|
||||||
projects: [projet({ notes: '## Contexte\n\n- [ ] relancer' })],
|
projects: [projet({ notes: '## Contexte\n\n- [ ] relancer' })],
|
||||||
@@ -901,6 +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 -----------------------------------------------------------
|
// --- cycle de vie -----------------------------------------------------------
|
||||||
|
|
||||||
describe('cycle de vie', () => {
|
describe('cycle de vie', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user