Compare commits
4 Commits
3092be86ae
...
d6f3b2961b
| Author | SHA1 | Date | |
|---|---|---|---|
| d6f3b2961b | |||
| 60a91ba5b2 | |||
| 8404f12656 | |||
| 3c7cff3ac6 |
114
README.md
114
README.md
@@ -65,7 +65,7 @@ alors `data/projets.json`, comme avant.
|
||||
|
||||
```sh
|
||||
go test ./... # le serveur
|
||||
node --test tests/ # le modèle métier
|
||||
node --test # le modèle métier
|
||||
```
|
||||
|
||||
## Prise en main
|
||||
@@ -75,13 +75,18 @@ ce nom présélectionné : on tape simplement par-dessus. Il n'y a aucun formula
|
||||
ici, ni pour une phase. Tout s'applique au fil de la saisie, et un projet créé par erreur se
|
||||
supprime depuis son propre panneau.
|
||||
|
||||
Il naît **envisagé** : sans phase, avec pour seul ancrage un horizon d'un mois, celui où on le
|
||||
crée. On le repousse à sa place, et on l'engage le jour où on lui donne des dates fermes — voir
|
||||
[le cycle de vie](#le-cycle-de-vie-dun-projet) juste en dessous.
|
||||
|
||||
Dans la colonne de gauche, le **triangle** plie et déplie le couloir d'un projet, et un clic sur
|
||||
son **nom** ouvre ses paramètres : nom, couleur, tags. Deux commandes apparaissent au survol de
|
||||
la ligne, sur son bord droit :
|
||||
son **nom** ouvre ses paramètres : nom, couleur, tags, horizon. Deux commandes apparaissent au
|
||||
survol de la ligne, sur son bord droit :
|
||||
|
||||
- **`+`** ajoute une phase à la suite de la dernière, et ouvre son panneau de détail ;
|
||||
- **`⋯`** ouvre le menu du projet : ajouter une phase, plier, masquer de la frise, renommer,
|
||||
figer la référence, supprimer. Un clic droit sur le libellé ouvre le même menu.
|
||||
déclarer terminé, écarter, figer la référence, supprimer. Un clic droit sur le libellé ouvre le
|
||||
même menu.
|
||||
|
||||
Il n'y a pas de commande séparée pour créer un **jalon** : c'est une phase dont on coche la case
|
||||
« Jalon » dans le panneau, ce qui ramène sa fin sur son début. La bascule joue dans les deux sens
|
||||
@@ -96,6 +101,7 @@ Une fois un projet en place :
|
||||
| Geste | Effet |
|
||||
|---|---|
|
||||
| Glisser le corps d'une barre | Décale la phase, durée conservée |
|
||||
| Glisser la barre pâle d'un envisagé | Décale son horizon, au mois |
|
||||
| Glisser le bord d'une barre | Change la date de début ou de fin |
|
||||
| Glisser le bord gauche du panneau | Change sa largeur (double-clic : d'origine) |
|
||||
| Clic sur une barre ou son nom | Ouvre le panneau de détail |
|
||||
@@ -111,6 +117,65 @@ Une fois un projet en place :
|
||||
Faire défiler jusqu'au bord droit élargit la frise vers le futur, et jusqu'au bord gauche
|
||||
vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
|
||||
|
||||
### 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
|
||||
de les oublier en attendant. Un projet traverse donc quatre états, dont **deux se déduisent tout
|
||||
seuls** — il n'y a rien à tenir à jour :
|
||||
|
||||
| État | Comment on le sait |
|
||||
|---|---|
|
||||
| **Envisagé** | il n'a qu'un horizon, pas encore de phase |
|
||||
| **Engagé** | on lui a donné au moins une phase datée |
|
||||
| **Terminé** | toutes ses phases sont finies **et** on l'a acté |
|
||||
| **Écarté** | on a décidé de ne pas le faire |
|
||||
|
||||
Le flux tient en une phrase : **une idée entre avec un horizon, on la situe, on l'engage en lui
|
||||
donnant des dates, elle vit sur la frise, elle s'achève ou on l'écarte.**
|
||||
|
||||
Engager n'est donc pas un bouton, c'est un geste : ajouter la première phase. Terminer et écarter,
|
||||
eux, s'actent — livré n'est pas clos, et abandonner est une décision. Leurs deux commandes sont au
|
||||
bas du **panneau du projet**, sous le champ qui dit où il en est, et aussi dans son menu **`⋯`**.
|
||||
|
||||
**Déclarer un projet terminé termine aussi toutes ses phases**, jalons compris : sans quoi la frise
|
||||
se contredirait, avec des tâches « à venir » dans un projet fini. C'est la seule opération du cycle
|
||||
qui écrase quelque chose — le statut d'une phase bloquée est perdu, et rouvrir le projet ne le
|
||||
rendra pas. Elle demande donc confirmation, mais seulement s'il reste des phases à changer.
|
||||
|
||||
#### L'horizon
|
||||
|
||||
Un projet envisagé n'est jamais hors du temps : il porte un **horizon en mois** — « mars 2027 »,
|
||||
« de mars à septembre 2027 », « courant 2028 » — et il se pose **sur la frise**, à cet horizon.
|
||||
C'est là que l'arbitrage devient concret : quand le printemps 2027 est déjà chargé et que six
|
||||
barres pâles s'y superposent, on décide devant l'évidence.
|
||||
|
||||
Sa barre est hachurée et ses bords sont fondus, parce que ces dates ne sont pas fermes. Elle se
|
||||
glisse et se redimensionne comme une barre de phase, mais **accrochée au mois** là où celle d'une
|
||||
phase l'est au lundi : le geste est possible, il ne permet simplement pas de poser une date qu'on
|
||||
n'a pas. Le panneau du projet offre en plus trois crans — `−3 mois`, `+3 mois`, `+1 an` — et les
|
||||
deux mois au clavier.
|
||||
|
||||
La largeur de la barre *est* l'incertitude, et elle se resserre à mesure que le projet mûrit :
|
||||
d'abord une plage large, puis un mois, puis les dates fermes de l'engagement.
|
||||
|
||||
**Engager démarre à l'horizon** : la première phase d'un projet envisagé naît au premier jour de son
|
||||
horizon, là où la barre pâle commençait. On précise ensuite ses dates comme n'importe quelle phase.
|
||||
|
||||
Un projet dont l'**horizon est passé** — ou qui n'en a jamais eu — le dit sur sa ligne, par une
|
||||
pastille **« à dater »**. C'est ce qui empêche la liste des envisagés de devenir une décharge : on
|
||||
repousse, ou on écarte, mais on ne laisse rien dormir.
|
||||
|
||||
#### Écarter n'est pas supprimer
|
||||
|
||||
Un projet écarté quitte la frise, mais il garde tout — notes comprises, où l'on aura écrit
|
||||
pourquoi on a dit non. Les mêmes idées reviennent tous les deux ans, portées par d'autres, et
|
||||
savoir qu'on les a déjà examinées vaut mieux que de refaire le débat.
|
||||
|
||||
On le retrouve en cochant **Écartés** dans la barre *Afficher*, et **Remettre en jeu** l'en sort. Il
|
||||
n'y a alors aucune destination à choisir : le projet retrouve seul l'état que ses données
|
||||
commandent — engagé s'il a des phases, envisagé sinon. Son horizon étant resté celui d'avant, il
|
||||
réclamera aussitôt une date, et c'est voulu.
|
||||
|
||||
### Au clavier, à la vim
|
||||
|
||||
La frise a un **curseur** : la ligne sur laquelle portent les raccourcis. Il se pose en cliquant une
|
||||
@@ -188,6 +253,12 @@ liste, projets mêlés, groupés par mois — celui où chaque phase **commence*
|
||||
« qu'est-ce qui arrive le mois prochain », que la frise donne mal parce qu'elle sépare les projets
|
||||
en couloirs.
|
||||
|
||||
Les **projets sans phase** y figurent aussi, au premier mois de leur horizon : un projet visé pour
|
||||
mars 2027 arrive bel et bien en mars 2027, et la liste répond à « qu'est-ce qui arrive », pas à
|
||||
« qu'est-ce qui est déjà décidé ». Leur ligne occupe les mêmes colonnes, en italique, et porte
|
||||
« Envisagé » là où les autres portent leur statut. Un projet **écarté** avant d'avoir jamais été
|
||||
engagé s'y retrouve de la même façon — barré, et seulement si l'on a coché *Écartés*.
|
||||
|
||||
Chaque ligne se lit dans cet ordre : quand — la période, `31 → 13 nov.` quand la phase déborde du
|
||||
mois —, chez qui — le projet et ses tags —, puis quoi — l'intitulé de la tâche ou du jalon — et
|
||||
enfin son statut. Une phase longue n'est **pas** répétée dans les mois qu'elle traverse : elle
|
||||
@@ -224,10 +295,27 @@ portant les deux. Le filtre n'est pas enregistré, il ne vaut que pour la sessio
|
||||
Les barres s'accrochent au lundi pendant le glisser. Pour une date au jour près, passer
|
||||
par les champs du panneau de détail.
|
||||
|
||||
### Afficher par état
|
||||
|
||||
Une seconde barre, **Afficher**, paraît au-dessus dès que le planning compte plus d'un état. Elle
|
||||
montre chacun avec son compte — *Envisagés (4)*, *Écartés (7)* — et un clic l'affiche ou le masque.
|
||||
Les écartés en sont décochés au départ : le cimetière ne s'ouvre que si on le demande.
|
||||
|
||||
Elle ne joue **pas** dans le même sens que le filtre par tags, et c'est pourquoi elle ne lui
|
||||
ressemble pas : un projet porte plusieurs tags mais un seul état, si bien que cocher un tag de plus
|
||||
resserre la sélection quand cocher un état de plus l'élargit. D'où « Filtrer » d'un côté, « Afficher »
|
||||
de l'autre, et des boutons carrés plutôt que des pastilles. Comme le filtre par tags, celui-ci vaut
|
||||
pour les deux vues et n'est pas enregistré.
|
||||
|
||||
## Ce que fait l'outil
|
||||
|
||||
- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin,
|
||||
un statut, des notes. Une phase peut être un **jalon** (une date unique, rendue en losange).
|
||||
- **Un cycle de vie** — envisagé, engagé, terminé, écarté — dont deux états se déduisent des
|
||||
données. Ce qu'on envisage sans pouvoir encore le mener vit sur la même frise que le reste, et
|
||||
ne s'y perd pas.
|
||||
- **Un horizon en mois** pour les projets envisagés, rendu en barre hachurée aux bords fondus : la
|
||||
largeur dit l'incertitude, et un horizon dépassé réclame de lui-même qu'on tranche.
|
||||
- **Des notes en markdown**, sur un projet comme sur une phase : titres, listes, cases à cocher,
|
||||
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.
|
||||
@@ -249,7 +337,7 @@ par les champs du panneau de détail.
|
||||
- **Une vue par mois** : toutes les tâches et tous les jalons en une liste, projets mêlés, groupés
|
||||
par mois de début. Elle se lit, elle ne s'édite pas.
|
||||
- **Des tags sur les projets**, et un filtre qui restreint les deux vues à ceux qui les portent
|
||||
tous.
|
||||
tous — doublé d'un affichage par état, qui joue lui en sens inverse.
|
||||
- **Repère « aujourd'hui »** et masquage d'un projet.
|
||||
|
||||
## Ce que l'outil ne fait pas
|
||||
@@ -257,6 +345,11 @@ par les champs du panneau de détail.
|
||||
Volontairement : pas de dépendances entre phases, pas de sous-tâches, pas de collaboration temps
|
||||
réel, pas d'authentification, pas de gestion de charge par personne, pas de notifications.
|
||||
|
||||
**Pas de note de priorité non plus, ni de matrice.** L'arbitrage passe par l'horizon : dire « celui-ci
|
||||
au printemps, celui-là on verra en 2028 » *est* la décision, et un score posé à côté des dates se
|
||||
périmerait en silence. Ce qui manque encore, c'est le départage fin entre deux projets visant le même
|
||||
trimestre — il attend d'avoir un endroit où ranger, la frise sachant situer mais pas ordonner.
|
||||
|
||||
Les durées se comptent en **jours calendaires** : ni week-ends ni jours fériés ne sont gérés. À
|
||||
l'échelle de phases de plusieurs semaines, la distinction n'apporte rien.
|
||||
|
||||
@@ -308,8 +401,9 @@ node --test # la logique métier
|
||||
go test ./... # le serveur
|
||||
```
|
||||
|
||||
Côté navigateur : validation des dates, bornes d'un projet, calcul de dérive, génération des
|
||||
identifiants, normalisation et filtrage des tags, regroupement des phases par mois. Côté serveur : lecture et écriture du planning,
|
||||
Côté navigateur : validation des dates et des horizons, bornes et emprise d'un projet, calcul de
|
||||
dérive, génération des identifiants, normalisation et filtrage des tags, regroupement des phases par
|
||||
mois, dérivation de l'état d'un projet et transitions de son cycle de vie. Côté serveur : lecture et écriture du planning,
|
||||
refus des corps invalides, rotation des sauvegardes, écriture atomique, résolution du chemin de
|
||||
données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node et à Go suffisent.
|
||||
|
||||
@@ -319,12 +413,12 @@ données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node
|
||||
|---|---|
|
||||
| `main.go` | Sert l'interface embarquée, expose `GET`/`PUT` sur `/api/data`, écrit les sauvegardes |
|
||||
| `build.sh` | Compile les binaires des cinq plateformes dans `dist/` |
|
||||
| `js/model.js` | Données et règles métier : CRUD, validation, bornes, référence, dérive, tags, regroupement par mois |
|
||||
| `js/model.js` | Données et règles métier : CRUD, validation, bornes, cycle de vie et horizon, référence, dérive, tags, regroupement par mois |
|
||||
| `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état |
|
||||
| `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, 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/drag.js` | Glisser et redimensionner les barres |
|
||||
| `js/drag.js` | Glisser et redimensionner les barres — au lundi pour une phase, au mois pour un horizon |
|
||||
| `js/clavier.js` | Table des raccourcis à la vim, préfixes `g` `z` `d`, garde-fou de saisie |
|
||||
| `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node |
|
||||
| `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux |
|
||||
|
||||
321
css/style.css
321
css/style.css
@@ -21,6 +21,10 @@
|
||||
--accent: #3b82f6;
|
||||
--danger: #dc2626;
|
||||
--aujourdhui: #ef4444;
|
||||
/* Réservé à ce qui réclame un arbitrage sans être une erreur : un horizon
|
||||
dépassé n'est pas une faute, c'est une décision à reprendre. D'où une teinte
|
||||
distincte de `--danger`, qui signale ce qui ne va pas. */
|
||||
--attention: #b45309;
|
||||
|
||||
--hauteur-barre: 26px;
|
||||
/* Barre cumulative d'un projet, et fantôme qui la double. Ils sont frères,
|
||||
@@ -53,6 +57,7 @@
|
||||
--texte: #f1f5f9;
|
||||
--texte-doux: #94a3b8;
|
||||
--ombre: 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
--attention: #fbbf24;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +214,68 @@ button[aria-pressed="true"] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- filtre par état ------------------------------------------------------ */
|
||||
|
||||
/* Même bandeau que le filtre par tags, mais des boutons carrés et non des
|
||||
pastilles rondes : les deux filtres jouent en sens contraire — cocher un tag
|
||||
resserre, cocher un état élargit — et rien ne serait plus trompeur que deux
|
||||
commandes identiques aux effets opposés. */
|
||||
.etats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--bordure);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.etats__liste {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.etat-filtre {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--bordure-forte);
|
||||
border-radius: var(--rayon);
|
||||
background: var(--surface);
|
||||
color: var(--texte-doux);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Un état affiché est plein, un état masqué est creux. La différence se lit au
|
||||
remplissage et pas seulement à la teinte : la barre en porte quatre côte à
|
||||
côte, et la couleur seule n'y suffirait pas. */
|
||||
.etat-filtre[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.etat-filtre:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Le dernier état affiché ne se décoche pas : tout masquer reviendrait à vider
|
||||
la frise sans qu'aucun geste ne l'ait demandé. */
|
||||
.etat-filtre:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Les écartés se distinguent des trois états vivants, cochés ou non : c'est le
|
||||
seul dont l'ouverture change la nature de ce qu'on regarde. */
|
||||
.etat-filtre[data-etat="discarded"][aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--attention) 12%, var(--surface));
|
||||
border-color: var(--attention);
|
||||
color: var(--attention);
|
||||
}
|
||||
|
||||
/* --- tags ----------------------------------------------------------------- */
|
||||
|
||||
/* Chaque pastille porte sa teinte dans `--teinte-tag`, calculée par hachage du
|
||||
@@ -539,6 +606,48 @@ button[aria-pressed="true"] {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
/* Un projet envisagé dont l'horizon est passé — ou qui n'en a jamais eu. Même
|
||||
forme que la dérive, dont c'est le pendant : l'une dit qu'un projet engagé a
|
||||
glissé, l'autre qu'un projet envisagé attend qu'on tranche.
|
||||
Volontairement sobre : ce signal doit rester lisible sur une colonne qui en
|
||||
porte plusieurs, sans devenir l'alerte qu'on apprend à ignorer. */
|
||||
.libelle-projet__adater {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 1px 5px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--attention) 15%, transparent);
|
||||
color: var(--attention);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* --- états du cycle de vie ------------------------------------------------ */
|
||||
|
||||
/* Un projet terminé **reste** sur la frise : elle est notre mémoire, et l'en
|
||||
retirer perdrait le bilan (décision 27). Il s'éteint donc au lieu de
|
||||
disparaître — assez pour que l'œil aille d'abord à ce qui est vivant, pas
|
||||
assez pour qu'on ne puisse plus le lire.
|
||||
L'écarté va plus loin : il n'est visible que si on l'a demandé au filtre, et
|
||||
le gris dit qu'il ne se passera plus rien. */
|
||||
.libelle-projet[data-etat="completed"],
|
||||
.couloir--projet[data-etat="completed"] {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.libelle-projet[data-etat="discarded"],
|
||||
.couloir--projet[data-etat="discarded"] {
|
||||
opacity: 0.5;
|
||||
filter: grayscale(0.85);
|
||||
}
|
||||
|
||||
/* Le nom d'un projet écarté se barre : l'opacité seule dirait « en retrait »,
|
||||
là où il faut lire « on ne le fera pas ». */
|
||||
.libelle-projet[data-etat="discarded"] .libelle-projet__nom {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.plier {
|
||||
border: 0;
|
||||
background: none;
|
||||
@@ -731,10 +840,17 @@ button[aria-pressed="true"] {
|
||||
background: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
|
||||
.barre:hover .poignee {
|
||||
.barre:hover .poignee,
|
||||
.barre-horizon:hover .poignee {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Sur fond pâle et hachuré, un voile blanc à demi transparent ne se voit pas :
|
||||
les poignées d'un horizon prennent la teinte de son projet. */
|
||||
.barre-horizon .poignee {
|
||||
background: color-mix(in srgb, var(--couleur-projet) 55%, transparent);
|
||||
}
|
||||
|
||||
.poignee--debut {
|
||||
left: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
@@ -858,6 +974,67 @@ button[aria-pressed="true"] {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* --- horizon d'un projet envisagé ---------------------------------------- */
|
||||
|
||||
/* La barre d'un projet qui n'a pas encore de phase. Tout, ici, sert à dire que
|
||||
les dates ne sont pas fermes (décision 27) :
|
||||
|
||||
- les **bords fondus** (`mask-image`), qui suppriment le trait net qu'on
|
||||
pourrait prendre pour une date de début ou de fin ;
|
||||
- les **hachures obliques**, qui la distinguent au premier coup d'œil d'une
|
||||
barre de phase, y compris pour qui ne perçoit pas la nuance de teinte.
|
||||
|
||||
Elle se glisse et se redimensionne comme une barre de phase, mais accrochée au
|
||||
**mois** plutôt qu'au lundi : le geste est possible, il ne permet simplement
|
||||
pas de poser une date qu'on n'a pas.
|
||||
|
||||
Sa largeur porte à elle seule l'incertitude : un mois pour un projet mûr, une
|
||||
large plage pour un projet vague. */
|
||||
.barre-horizon {
|
||||
position: absolute;
|
||||
top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2);
|
||||
height: var(--hauteur-barre);
|
||||
border-radius: 4px;
|
||||
color: var(--texte-doux);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
line-height: var(--hauteur-barre);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
color-mix(in srgb, var(--couleur-projet) 26%, transparent),
|
||||
color-mix(in srgb, var(--couleur-projet) 26%, transparent) 5px,
|
||||
transparent 5px,
|
||||
transparent 10px
|
||||
),
|
||||
color-mix(in srgb, var(--couleur-projet) 12%, var(--surface));
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
#000 14px,
|
||||
#000 calc(100% - 14px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Un horizon dépassé se teinte, en écho à la pastille « à dater » de la colonne.
|
||||
La marque est portée deux fois — dans la colonne et sur la frise — parce que
|
||||
les deux se lisent séparément : on parcourt l'une, on balaie l'autre. */
|
||||
.barre-horizon--depasse {
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
color-mix(in srgb, var(--attention) 30%, transparent),
|
||||
color-mix(in srgb, var(--attention) 30%, transparent) 5px,
|
||||
transparent 5px,
|
||||
transparent 10px
|
||||
),
|
||||
color-mix(in srgb, var(--attention) 12%, var(--surface));
|
||||
}
|
||||
|
||||
/* --- panneau de détail --------------------------------------------------- */
|
||||
|
||||
/* Non modal : aucun voile derrière lui, la frise reste manipulable pendant
|
||||
@@ -1011,6 +1188,106 @@ button[aria-pressed="true"] {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Les deux actes du cycle de vie, côte à côte et à parts égales : aucun des deux
|
||||
n'est plus courant que l'autre, et les mettre l'un sous l'autre suggérerait
|
||||
une progression qui n'existe pas — on termine *ou* on écarte. */
|
||||
.panneau__actions--cycle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panneau__actions--cycle button {
|
||||
flex: 1 1 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- cycle de vie dans le panneau ---------------------------------------- */
|
||||
|
||||
/* Où en est le projet. Une ligne de texte et non un sélecteur : l'état se
|
||||
déduit des données (décision 27), il n'y a rien à y choisir. */
|
||||
.panneau__etat {
|
||||
margin: 0;
|
||||
font-size: var(--taille-annotation);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--texte-doux);
|
||||
}
|
||||
|
||||
.panneau__etat[data-etat="discarded"] {
|
||||
color: var(--attention);
|
||||
}
|
||||
|
||||
.horizon {
|
||||
border: 1px solid var(--bordure);
|
||||
border-radius: var(--rayon);
|
||||
padding: 8px 10px 10px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.horizon > legend {
|
||||
font-size: var(--taille-annotation);
|
||||
color: var(--texte-doux);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Les deux bornes côte à côte : c'est un intervalle, et le lire sur deux lignes
|
||||
ferait perdre ce que sa largeur veut dire. */
|
||||
.horizon__intervalle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.horizon__intervalle label {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: var(--taille-annotation);
|
||||
color: var(--texte-doux);
|
||||
}
|
||||
|
||||
.horizon__crans {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.horizon__crans button {
|
||||
flex: 1 1 0;
|
||||
padding: 4px 6px;
|
||||
font-size: var(--taille-annotation);
|
||||
border: 1px solid var(--bordure-forte);
|
||||
border-radius: var(--rayon);
|
||||
background: var(--surface);
|
||||
color: var(--texte);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.horizon__crans button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.horizon__crans button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.horizon__aide {
|
||||
margin: 0;
|
||||
font-size: var(--taille-annotation);
|
||||
line-height: 1.35;
|
||||
color: var(--texte-doux);
|
||||
}
|
||||
|
||||
/* Un horizon passé le dit ici comme il le dit sur la frise et dans la colonne :
|
||||
c'est le même signal, vu depuis les trois endroits où l'on travaille. */
|
||||
.horizon__aide[data-alerte="true"] {
|
||||
color: var(--attention);
|
||||
}
|
||||
|
||||
/* --- bloc de notes -------------------------------------------------------- */
|
||||
|
||||
/* Le bloc prend toute la hauteur que les champs au-dessus lui laissent. C'est
|
||||
@@ -1791,6 +2068,48 @@ button.mois-entree__plier {
|
||||
color: var(--texte-doux);
|
||||
}
|
||||
|
||||
/* --- ligne d'un projet envisagé ------------------------------------------ */
|
||||
|
||||
/* Mêmes colonnes qu'une ligne de phase — la liste reste une grille qu'on lit en
|
||||
diagonale —, mais tout y dit que rien n'est arrêté : l'italique de l'intitulé
|
||||
et les hachures de la marque, qui reprennent celles de la barre sur la frise.
|
||||
Ce qu'on a appris à lire d'un côté vaut de l'autre. */
|
||||
.mois-entree--envisage .mois-entree__nom {
|
||||
font-style: italic;
|
||||
color: var(--texte-doux);
|
||||
}
|
||||
|
||||
.mois-entree--envisage .mois-entree__marque {
|
||||
background: repeating-linear-gradient(
|
||||
-45deg,
|
||||
color-mix(in srgb, var(--couleur-projet) 60%, transparent),
|
||||
color-mix(in srgb, var(--couleur-projet) 60%, transparent) 3px,
|
||||
transparent 3px,
|
||||
transparent 6px
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--couleur-projet) 45%, transparent);
|
||||
}
|
||||
|
||||
.mois-entree__statut[data-statut="considered"],
|
||||
.mois-entree__statut[data-statut="discarded"] {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Un projet écarté qui n'a jamais été engagé passe aussi par cette ligne — il
|
||||
n'a que son horizon pour se situer. Il s'y lit comme sur la frise : éteint et
|
||||
barré, pour qu'on ne le confonde pas avec ce qui va arriver. */
|
||||
.mois-entree--envisage[data-etat="discarded"] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.mois-entree--envisage[data-etat="discarded"] .mois-entree__projet {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.mois-entree--envisage[data-etat="discarded"] .mois-entree__statut {
|
||||
color: var(--attention);
|
||||
}
|
||||
|
||||
.mois__vide {
|
||||
max-width: 1080px;
|
||||
margin: 48px auto;
|
||||
|
||||
@@ -156,6 +156,40 @@
|
||||
"notes": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "refonte-intranet",
|
||||
"name": "Refonte de l'intranet",
|
||||
"color": "#8b5cf6",
|
||||
"tags": ["interne", "web"],
|
||||
"collapsed": false,
|
||||
"hidden": false,
|
||||
"notes": "Envisagé, pas encore engagé : l'horizon dit qu'on y viendra, pas quand exactement.\n\n- [ ] recenser ce que l'ancien intranet porte encore\n- [ ] estimer le budget\n",
|
||||
"horizon": { "start": "2027-04", "end": "2027-09" },
|
||||
"phases": []
|
||||
},
|
||||
{
|
||||
"id": "archivage",
|
||||
"name": "Archivage des dossiers papier",
|
||||
"color": "#06b6d4",
|
||||
"tags": ["interne"],
|
||||
"collapsed": false,
|
||||
"hidden": false,
|
||||
"notes": "Horizon dépassé : à repousser ou à écarter.",
|
||||
"horizon": { "start": "2026-03", "end": "2026-05" },
|
||||
"phases": []
|
||||
},
|
||||
{
|
||||
"id": "application-mobile",
|
||||
"name": "Application mobile",
|
||||
"color": "#ec4899",
|
||||
"tags": ["client"],
|
||||
"collapsed": false,
|
||||
"hidden": false,
|
||||
"notes": "Écartée : le site refondu sera responsive, une application native ne se justifie pas.\n\nÀ reprendre si un besoin hors ligne apparaît.",
|
||||
"horizon": { "start": "2027-01", "end": "2027-06" },
|
||||
"discardedDate": "2026-06-22",
|
||||
"phases": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -827,3 +827,228 @@ vont par paires. La génération aurait produit une liste exacte et illisible.
|
||||
Elle se lit, elle ne s'édite pas (décision 24) : rien n'y aurait de sens à désigner. `j`, `k`, `gg`
|
||||
et `G` y font donc ce qu'ils font dans un document — ils déroulent la liste. `gm` et `ga` marchent
|
||||
dans les deux vues, le reste attend le retour à la frise.
|
||||
|
||||
## 27. Le cycle de vie d'un projet, et l'horizon
|
||||
|
||||
L'outil est employé pour piloter les chantiers d'une petite mairie : beaucoup plus de projets
|
||||
imaginables que de temps pour les mener, et une crainte première — **ne rien oublier**. Une idée
|
||||
qu'on ne peut pas traiter cette année ne doit pas disparaître pour autant.
|
||||
|
||||
Or l'outil ne connaissait que les projets déjà planifiés. Tout ce qui n'était pas encore engagé
|
||||
vivait ailleurs — un carnet, une tête, un compte rendu de réunion — de sorte que l'arbitrage se
|
||||
rendait **sans voir le planning**, alors que c'est précisément le planning qui dit s'il y a la
|
||||
place.
|
||||
|
||||
### La priorité est une décision d'avant l'engagement
|
||||
|
||||
Le point de départ était une matrice d'Eisenhower dans le panneau d'un projet. Deux objections
|
||||
l'ont déplacée.
|
||||
|
||||
L'urgence est **déjà dans les dates**. La resaisir crée un champ qui se périme en silence : un
|
||||
projet coché « urgent » en juin ne l'est plus en septembre, et rien ne viendra le décocher.
|
||||
|
||||
Surtout, une fois un projet planifié, **l'arbitrage est déjà rendu** — ce sont ses dates qui le
|
||||
portent. Une priorité posée sur un projet engagé ne déciderait plus rien, elle commenterait. Le
|
||||
lieu de la priorisation est donc en amont, sur ce qui n'est pas encore engagé.
|
||||
|
||||
### Quatre états, dont deux se déduisent
|
||||
|
||||
Un projet traverse quatre états, et deux d'entre eux se lisent dans les données sans qu'on ait
|
||||
rien à tenir à jour :
|
||||
|
||||
| État | Comment on le sait |
|
||||
|---|---|
|
||||
| **Envisagé** | il n'a qu'un horizon, pas encore de phase |
|
||||
| **Engagé** | on lui a donné au moins une phase datée |
|
||||
| **Terminé** | toutes ses phases sont finies **et** on l'a acté |
|
||||
| **Écarté** | on a décidé de ne pas le faire |
|
||||
|
||||
Une règle unique les résume : **les phases commandent l'état, sauf quand on a prononcé quelque
|
||||
chose.** Livré n'est pas clos, et abandonner est une décision — ces deux-là s'actent. Le reste se
|
||||
déduit.
|
||||
|
||||
**Clore un projet termine toutes ses phases avec lui.** Sans cela la frise se contredirait : une
|
||||
ligne éteinte au-dessus de segments pâles, et la vue par mois annonçant des tâches à venir dans un
|
||||
projet fini. C'est donc la seule opération du cycle qui **écrase des données** — le statut d'une
|
||||
phase bloquée est perdu, et rouvrir le projet ne peut pas le deviner. Elle se confirme à ce titre,
|
||||
comme une suppression (décision 21), mais **seulement quand il reste quelque chose à écraser** : un
|
||||
dialogue qui ne prévient de rien apprend à cliquer sans lire.
|
||||
|
||||
Engager n'est donc pas un bouton : c'est un geste, celui de donner ses premières dates fermes. Et
|
||||
écarter n'est pas supprimer — le projet quitte la frise mais reste retrouvable, avec dans ses notes
|
||||
la raison pour laquelle on a dit non. Les mêmes idées reviennent tous les deux ans, portées par
|
||||
d'autres : savoir qu'on les a déjà examinées, et sur quel motif, a de la valeur.
|
||||
|
||||
### L'horizon est le repli des bornes
|
||||
|
||||
Un projet envisagé n'est pas une ligne hors du temps : il porte un **horizon**, exprimé en mois —
|
||||
« mars 2027 », « de mars à septembre 2027 », « courant 2028 » — et il se pose **sur la frise**, à
|
||||
cet horizon. C'est là que l'arbitrage devient concret : quand le printemps 2027 est déjà chargé et
|
||||
que six projets pâles s'y superposent, on décide devant l'évidence plutôt que dans l'abstrait.
|
||||
C'est ce qui remplace le calcul de charge que l'outil ne fera jamais.
|
||||
|
||||
La règle tient en une phrase, et elle ne mélange pas le saisi et le calculé :
|
||||
|
||||
> L'emprise temporelle d'un projet se calcule depuis ses phases. Un projet qui n'en a pas encore la
|
||||
> déclare à la main : c'est son horizon.
|
||||
|
||||
Les phases gagnent toujours. `bornesProjet()` fait déjà ce calcul et retourne `null` quand il n'y a
|
||||
aucune phase — l'horizon bouche ce trou, il n'ajoute pas de règle. Corollaire : **on ne réécrit
|
||||
jamais une saisie par un calcul**. Un projet qui perdrait sa dernière phase retrouve l'horizon qu'il
|
||||
avait déclaré, pas l'enveloppe des phases disparues.
|
||||
|
||||
Le grain est le mois, jamais l'année : en début de mandat, tout est à faire, et un horizon annuel ne
|
||||
range plus rien. Un horizon s'exprime comme un intervalle de mois plutôt que par des crans nommés —
|
||||
ni trimestre ni semestre à apprendre, un seul mécanisme, et trois propriétés qui en découlent :
|
||||
|
||||
- **la largeur de la barre pâle *est* l'incertitude**, l'œil la lit sans qu'on l'écrive ;
|
||||
- **la précision de l'horizon suit la maturité du projet** : on resserre à mesure qu'on y voit
|
||||
clair. Il n'y a pas de frontière entre envisagé et engagé, il y a un dégradé ;
|
||||
- **engager, c'est préciser au jour près** ce qui était au mois près.
|
||||
|
||||
La barre d'un projet envisagé se dessine donc autrement : pâle, aux bords fondus, et **accrochée au
|
||||
mois** — jamais au jour. C'est l'accroche au lundi de la décision 9 poussée d'un cran : le geste
|
||||
existe, il ne permet simplement pas de poser une date qu'on n'a pas.
|
||||
|
||||
Elle fut d'abord rendue **immobile**, au motif qu'interdire physiquement valait mieux qu'une
|
||||
convention. L'usage a tranché autrement, et pour une bonne raison : on ajuste un horizon *en
|
||||
regardant ses voisins*, pour voir si le printemps 2027 est déjà chargé. Obliger à passer par un
|
||||
panneau pour ce réglage-là, c'est le faire à l'aveugle. L'accroche au mois suffisait à protéger ce
|
||||
qui devait l'être.
|
||||
|
||||
L'horizon **survit à l'engagement**. Non pour le comparer à ce qui a été planifié, mais parce que ne
|
||||
rien effacer est ici plus simple qu'effacer, et parce qu'il sert de filet si le projet se dépeuple.
|
||||
|
||||
### Un horizon échu remonte à la surface
|
||||
|
||||
Un backlog échoue toujours de la même façon : tout ce qu'on n'ose pas refuser y atterrit, il gonfle,
|
||||
plus personne ne le relit. Les deux garde-fous habituels — un plafond, une péremption qui efface —
|
||||
sont ici interdits par la crainte de départ.
|
||||
|
||||
L'horizon en fournit un meilleur : **un projet dont l'horizon est dépassé se signale**. On est en
|
||||
juin, quatre projets visaient le premier trimestre : il faut trancher, on repousse ou on écarte.
|
||||
Rien ne disparaît, mais rien ne dort — et la relecture tombe d'elle-même au rythme des arbitrages
|
||||
budgétaires.
|
||||
|
||||
### Ce qui ancre un projet écarté
|
||||
|
||||
La question n'a pas la même réponse selon d'où vient l'écartement.
|
||||
|
||||
Un projet écarté **depuis l'état engagé** garde ses phases datées, dont certaines ont réellement eu
|
||||
lieu. Il a un ancrage, et un vrai : le travail fait ne doit pas s'effacer, puisque la frise est notre
|
||||
mémoire.
|
||||
|
||||
Un projet écarté **depuis l'état envisagé** n'a rien. Son horizon a cessé d'être une prévision — on
|
||||
ne le fera pas à cette date, on ne le fera pas du tout — et l'y laisser serait un mensonge sur la
|
||||
frise.
|
||||
|
||||
D'où **la date d'écartement**, posée automatiquement, jamais saisie. C'est l'ancrage commun aux deux
|
||||
cas, celui sans lequel les écartés ne forment plus qu'un tas. Il ne s'agit pas de journaliser les
|
||||
décisions — les comptes rendus de réunion font ce travail, et mieux — mais d'horodater une sortie.
|
||||
|
||||
La clôture est horodatée de la même façon, et c'est ce qui donne au modèle sa forme définitive :
|
||||
**les deux actes prononcés laissent une date, et ces deux dates sont l'unique trace de l'état dans le
|
||||
fichier.** Acter, c'est dater. Il n'y a pas de champ « état » — il se lit dans ces deux dates et dans
|
||||
la présence de phases.
|
||||
|
||||
On les retrouve d'abord en les cochant dans le filtre par état, ce qui les ramène dans la colonne
|
||||
des libellés, groupés et éteints. Cela répond à « je sais que ça existe, je veux le retrouver ». Cela
|
||||
ne répond pas à « relire d'un coup deux ans d'idées écartées avec leurs motifs », qui demanderait une
|
||||
liste — geste assez rare pour attendre que le manque se fasse sentir.
|
||||
|
||||
### La réanimation ne demande aucun mécanisme
|
||||
|
||||
Puisque les états se déduisent, il suffit de **retirer l'écartement** : le projet retrouve tout seul
|
||||
l'état que ses données commandent. Des phases, il redevient engagé ; un horizon seul, envisagé. Pas
|
||||
de destination à choisir, une seule commande.
|
||||
|
||||
Le système se referme bien : un projet écarté en 2026 et réanimé aujourd'hui revient avec un horizon
|
||||
périmé, donc se signale aussitôt comme échu. On le redate dans la foulée.
|
||||
|
||||
```
|
||||
écarté ←──────┐
|
||||
↗ ↑ │ réanimer
|
||||
/ │ │ (l'état se
|
||||
envisagé ──────┴─ engagé ──→ terminé recalcule)
|
||||
(un horizon) (≥ 1 phase) (acté)
|
||||
↑ │
|
||||
└──────────────┘
|
||||
s'il perd sa dernière phase
|
||||
```
|
||||
|
||||
### L'état ne passe pas par les tags
|
||||
|
||||
Un tag est libre, disparaît quand plus personne ne le porte, et dérive orthographiquement
|
||||
(décision 16) : trois propriétés inacceptables pour un état. Ce sont deux mécanismes indépendants
|
||||
qui filtrent la même frise, et les tags restent disponibles pour ce à quoi ils servent.
|
||||
|
||||
Les deux filtres ne se comportent d'ailleurs pas pareil, et il faut que cela se voie : **celui des
|
||||
tags est conjonctif, celui des états est disjonctif**. Un projet porte plusieurs tags mais un seul
|
||||
état — cocher `client` + `urgent` resserre, cocher `envisagé` + `engagé` élargit. La même case à
|
||||
cocher voudrait sinon dire deux choses opposées selon la ligne où elle se trouve.
|
||||
|
||||
Comme celui des tags, le filtre par état **n'est pas enregistré** : c'est un état de vue, pas un
|
||||
réglage.
|
||||
|
||||
### Ce que le premier essai a corrigé
|
||||
|
||||
Trois choses ne se sont vues qu'en manipulant l'outil, et toutes disaient la même : **un projet
|
||||
envisagé ne doit pas être un citoyen de seconde zone**.
|
||||
|
||||
**Engager démarre à l'horizon.** La première phase d'un projet envisagé naissait à la date du jour,
|
||||
comme celle de n'importe quel projet vide. Elle apparaissait donc à des mois de la barre pâle qu'elle
|
||||
venait remplacer, et le lien entre les deux se perdait. Elle commence maintenant au premier jour de
|
||||
l'horizon : engager, c'est préciser au jour près ce qu'on avait déclaré au mois près, pas repartir
|
||||
d'ailleurs.
|
||||
|
||||
**Les deux actes sont dans le panneau, pas seulement dans le menu.** Ils avaient été rangés dans le
|
||||
menu ⋯ au nom de la décision 20 — un bouton permanent se mérite, et clore un projet est rare. Mais
|
||||
c'est dans le panneau qu'on **lit** l'état du projet, donc c'est là qu'on cherche à en changer : les
|
||||
y chercher et ne pas les trouver coûte plus cher que deux boutons de plus. Ils figurent aux deux
|
||||
endroits, comme « plier » ou « masquer ».
|
||||
|
||||
**Les projets sans phase paraissent dans la vue par mois.** Ils n'y figuraient pas, faute de phase à
|
||||
lister — alors qu'un projet visé pour mars 2027 *arrive* bel et bien en mars 2027. Les omettre
|
||||
revenait à dire que la liste ne montre que l'engagé, quand toute cette décision pose le contraire.
|
||||
Ils s'y rattachent au premier mois de leur horizon, dans les mêmes colonnes que les phases mais en
|
||||
italique. C'est ce qui a fait renommer `phasesParMois` en `entreesParMois` : la liste ne montre plus
|
||||
seulement des phases.
|
||||
|
||||
La règle porte sur **l'absence de phases et non sur l'état**, exactement comme `empriseProjet` : un
|
||||
projet écarté avant d'avoir jamais été engagé n'a lui non plus que son horizon pour se situer, et
|
||||
il doit se retrouver là quand on ouvre le cimetière. La distinguer par l'état aurait fait du
|
||||
cimetière de la liste par mois une collection des seuls projets qui avaient démarré — soit
|
||||
l'inverse de ce qu'on y cherche.
|
||||
|
||||
### Ce qui a été reporté, et pourquoi
|
||||
|
||||
Ces choix ne sont pas des refus définitifs. Ils attendent leur moment, et il vaut mieux savoir
|
||||
lequel.
|
||||
|
||||
**La matrice d'Eisenhower.** Elle n'a pas de sens sur des projets engagés, pour les raisons
|
||||
ci-dessus. Sur les seuls envisagés elle redevient cohérente, et se branche même bien : l'horizon
|
||||
fournit l'axe d'urgence, il ne resterait que l'importance à saisir. À reprendre quand une liste
|
||||
existera, et seulement si l'horizon seul ne suffit pas à départager.
|
||||
|
||||
**Une vue liste des envisagés.** La frise sait situer, elle ne sait pas ranger : on ne peut pas
|
||||
ordonner six projets par priorité dans un empilement de couloirs. Cette première étape ne livre donc
|
||||
**aucun mécanisme d'ordre explicite entre projets** — ce qu'elle livre, c'est l'horizon, et c'est
|
||||
déjà l'arbitrage le plus décisif. Reste le départage fin entre projets visant le même trimestre.
|
||||
|
||||
**Un dézoom de l'axe temporel**, pour embrasser plusieurs années d'un coup. Le jour où il arrivera,
|
||||
il faudra rouvrir la décision 14, « une seule échelle, pas de zoom ».
|
||||
|
||||
**Une vue de rétrospective.** La frise défile en arrière sans limite (décision 15) : le passé y est
|
||||
déjà lisible, et un écran de plus l'aurait redit. Corollaire à tenir : **un projet terminé reste sur
|
||||
la frise**, éteint mais présent, sinon on perd le bilan.
|
||||
|
||||
**Des dates réelles à côté des dates prévues.** Ce serait doubler chaque champ pour un gain nul :
|
||||
dans cet outil on recale les barres au fil de l'eau, donc la date de fin *devient* le réel. La
|
||||
contrepartie est assumée — la qualité du bilan dépend de l'entretien de la frise, et aucun champ ne
|
||||
répare un planning qu'on ne tient pas.
|
||||
|
||||
**Un repère de fin de mandat**, comme le repère « aujourd'hui ». Pertinent sur le principe, sans
|
||||
urgence en début de mandat.
|
||||
|
||||
**Un retour arrière déclaré**, d'engagé vers envisagé. On déplace les dates, cela suffit. Le seul
|
||||
cas où il survient — retirer la dernière phase — se résout tout seul par la règle des bornes.
|
||||
|
||||
@@ -9,7 +9,7 @@ dans un diff git et éditable à la main.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 4,
|
||||
"version": 5,
|
||||
"projects": [
|
||||
{
|
||||
"id": "site-web",
|
||||
@@ -19,6 +19,7 @@ dans un diff git et éditable à la main.
|
||||
"collapsed": false,
|
||||
"hidden": false,
|
||||
"notes": "## Contexte\n\nPiloté par **Marie D.**\n\n- [x] cadrage validé\n- [ ] recette",
|
||||
"horizon": { "start": "2026-08", "end": "2026-11" },
|
||||
"baselineDate": "2026-06-12",
|
||||
"phases": [
|
||||
{
|
||||
@@ -50,12 +51,18 @@ dans un diff git et éditable à la main.
|
||||
|
||||
| Champ | Type | Description |
|
||||
|---|---|---|
|
||||
| `version` | entier | Version du format. Vaut `4`. Sert à détecter un fichier trop ancien au chargement. |
|
||||
| `version` | entier | Version du format. Vaut `5`. Sert à détecter un fichier trop ancien au chargement. |
|
||||
| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. |
|
||||
|
||||
Un fichier plus ancien se charge sans rien demander et est réécrit au format courant à la première
|
||||
sauvegarde : une version `2` — celle d'avant les tags — voit ses projets recevoir une liste de tags
|
||||
vide, une version `3` — celle d'avant les notes de projet — des notes vides.
|
||||
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.
|
||||
|
||||
Cette dernière migration ne demande rien et ne devine rien : un projet qui a des phases est *engagé*,
|
||||
un projet qui n'en a pas est *envisagé sans horizon*, et il se signalera comme réclamant une date.
|
||||
Aucun projet n'est déclaré terminé ni écarté au chargement — ces deux états s'actent, ils ne se
|
||||
devinent pas, fût-ce d'un planning dont toutes les phases sont finies.
|
||||
|
||||
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
|
||||
@@ -73,6 +80,9 @@ effacerait les champs inconnus à la première sauvegarde.
|
||||
| `collapsed` | booléen | `true` si le couloir est plié. |
|
||||
| `hidden` | booléen | `true` si le projet est masqué de la frise. Ses données restent intactes. |
|
||||
| `notes` | chaîne | Texte libre en markdown, éventuellement vide. Ce que la frise ne sait pas dire : contexte, interlocuteurs, décisions. |
|
||||
| `horizon` | objet ou absent | Emprise déclarée, en mois : `{ "start": "2027-03", "end": "2027-06" }`. Fin **incluse**. C'est l'ancrage d'un projet qui n'a pas encore de phase. |
|
||||
| `completedDate` | chaîne ou absent | Date à laquelle le projet a été déclaré terminé. Absent tant qu'il ne l'a pas été. |
|
||||
| `discardedDate` | chaîne ou absent | Date à laquelle le projet a été écarté. Absent tant qu'il ne l'a pas été. |
|
||||
| `baselineDate` | chaîne ou absent | Date à laquelle la référence a été figée. Absent si elle ne l'a jamais été. |
|
||||
| `phases` | tableau | Les phases, triées par date de début croissante. |
|
||||
|
||||
@@ -110,6 +120,12 @@ Appliquées par `js/model.js` et couvertes par `tests/model.test.js`.
|
||||
- `notes`, de projet comme de phase, est ramenée à `""` si ce n'est pas une chaîne. Le champ n'engage
|
||||
aucun calcul, contrairement aux dates : bloquer le chargement d'un planning entier pour lui serait
|
||||
disproportionné.
|
||||
- `horizon` est facultatif, mais s'il est présent il porte deux mois `AAAA-MM` réels, et sa fin
|
||||
n'est pas antérieure à son début. Il est vérifié aussi strictement que les dates d'une phase, et
|
||||
non réparé en silence : il décide d'une position sur la frise, et un horizon avalé laisserait un
|
||||
projet envisagé invisible sans qu'on sache pourquoi.
|
||||
- `completedDate` et `discardedDate` suivent les mêmes règles que `baselineDate` : absentes, ou
|
||||
dates `AAAA-MM-JJ` réelles.
|
||||
|
||||
## Tags
|
||||
|
||||
@@ -178,6 +194,58 @@ celui qui la lit. Un lien refusé n'est pas effacé : son libellé redevient du
|
||||
|
||||
Voir [decisions.md](decisions.md), section 25.
|
||||
|
||||
## Cycle de vie et horizon
|
||||
|
||||
L'état d'un projet — envisagé, engagé, terminé, écarté — **n'est pas un champ**. Il se lit dans les
|
||||
données, et `etatProjet()` applique une règle unique : *les phases commandent l'état, sauf quand on a
|
||||
prononcé quelque chose.*
|
||||
|
||||
| État | Comment il se lit | Ce qu'il veut dire |
|
||||
|---|---|---|
|
||||
| `considered` | ni `discardedDate`, ni `completedDate`, ni phase | Envisagé : un horizon pour seul ancrage |
|
||||
| `engaged` | au moins une phase | Engagé : il a des dates fermes |
|
||||
| `completed` | `completedDate` présente | Terminé, et quelqu'un l'a prononcé |
|
||||
| `discarded` | `discardedDate` présente | Écarté : on a décidé de ne pas le faire |
|
||||
|
||||
Terminer et écarter s'actent, ils ne se devinent pas : un projet dont toutes les phases sont `done`
|
||||
reste *engagé* tant que sa clôture n'a pas été prononcée — livré n'est pas clos. Ces deux actes
|
||||
laissent une date, et ces deux dates sont l'unique trace de l'état dans le fichier. Un projet ne
|
||||
porte jamais les deux à la fois ; si un fichier retouché à la main le fait, l'écartement l'emporte.
|
||||
|
||||
**Clore un projet passe toutes ses phases à `done`**, jalons compris. C'est la seule opération du
|
||||
cycle qui écrase des données : le statut d'une phase `blocked` ou `todo` est perdu, et
|
||||
`rouvrirProjet()` ne le rétablit pas — il n'a aucun moyen de savoir ce qu'il valait.
|
||||
`clotureEcraseDesStatuts()` dit s'il reste quelque chose à écraser, ce qui permet à l'interface de
|
||||
ne demander confirmation que dans ce cas.
|
||||
|
||||
**Réanimer un projet écarté** ne demande donc rien d'autre que d'effacer `discardedDate` : il
|
||||
retrouve seul l'état que ses données commandent, engagé s'il a des phases, envisagé sinon. Son
|
||||
horizon est resté celui d'avant et se signalera aussitôt comme dépassé, ce qui est voulu.
|
||||
|
||||
### L'horizon
|
||||
|
||||
Un projet envisagé n'est jamais hors du temps : il déclare son emprise en **mois**, fin incluse. Un
|
||||
horizon d'un seul mois a `start === end`.
|
||||
|
||||
Le grain est le mois et jamais l'année, et l'horizon s'exprime comme un intervalle plutôt que par des
|
||||
crans nommés — ni trimestre ni semestre à apprendre. La largeur de l'intervalle *est* l'incertitude,
|
||||
et elle se resserre à mesure que le projet mûrit, jusqu'aux dates fermes de l'engagement.
|
||||
|
||||
L'horizon **survit à l'engagement** : il n'est ni effacé ni réécrit quand des phases arrivent. C'est
|
||||
ce qui permet à un projet ayant perdu sa dernière phase de retrouver l'horizon qu'il avait déclaré,
|
||||
plutôt que l'enveloppe de phases disparues — **on ne réécrit jamais une saisie par un calcul**.
|
||||
|
||||
Il se manœuvre au **mois** partout : les crans du panneau, le glisser sur la frise et le
|
||||
redimensionnement de ses bords passent tous par `ajouterMois()` et n'atteignent jamais le jour.
|
||||
`entreesParMois()` rattache d'ailleurs un projet envisagé au premier mois de son horizon, pour qu'il
|
||||
paraisse dans la vue par mois au même titre qu'une phase qui démarre.
|
||||
|
||||
Un projet envisagé **sans** horizon est légal : c'est l'état de tous les projets sans phase hérités
|
||||
d'un fichier en version 4. Il réclame une date, exactement comme un projet dont l'horizon est
|
||||
dépassé — `projetADater()` confond volontairement les deux cas en un seul signal.
|
||||
|
||||
Voir [decisions.md](decisions.md), section 27.
|
||||
|
||||
## Dates : conventions
|
||||
|
||||
Les dates sont manipulées comme des **chaînes `AAAA-MM-JJ`**, pas comme des objets `Date`. Cela évite
|
||||
@@ -195,8 +263,13 @@ l'année correspondante, qui peut différer de celle de la date.
|
||||
|
||||
## Bornes et dérive
|
||||
|
||||
- Les **bornes d'un projet** vont de la plus petite `start` à la plus grande `end` de ses phases. Un
|
||||
projet sans phase n'a pas de bornes et s'affiche comme un couloir vide.
|
||||
- Les **bornes d'un projet** (`bornesProjet()`) vont de la plus petite `start` à la plus grande `end`
|
||||
de ses phases. Un projet sans phase n'a pas de bornes.
|
||||
- Son **emprise** (`empriseProjet()`) est celle de ses phases, ou à défaut celle de son horizon,
|
||||
ramenée du premier jour de son premier mois au dernier jour de son dernier. C'est elle qui cadre la
|
||||
fenêtre du planning au premier affichage — sans quoi un planning fait de projets encore tous
|
||||
envisagés s'ouvrirait sur rien. Les projets écartés en sont exclus : ils ne s'affichent pas
|
||||
d'ordinaire, et un projet abandonné il y a trois ans n'a pas à tirer la vue en arrière.
|
||||
- **Figer la référence** copie, pour chaque phase du projet, ses `start` et `end` courantes dans son
|
||||
champ `baseline`, et inscrit la date du jour dans `baselineDate`. L'opération est rejouable : la
|
||||
refiger après un arbitrage assumé repart d'une base propre.
|
||||
|
||||
70
index.html
70
index.html
@@ -54,6 +54,22 @@
|
||||
<p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p>
|
||||
</header>
|
||||
|
||||
<!--
|
||||
Filtre par état, masqué tant que tous les projets sont dans le même : lui
|
||||
aussi n'apparaît que le jour où il sert.
|
||||
|
||||
Il ne se comporte pas comme celui des tags, et c'est pourquoi il ne lui
|
||||
ressemble pas. Un projet porte plusieurs tags mais un seul état : cocher un
|
||||
tag de plus resserre la sélection, cocher un état de plus l'élargit. D'où
|
||||
« Afficher » ici et « Filtrer » en dessous, et deux formes distinctes —
|
||||
sans quoi la même case voudrait dire deux choses opposées selon la ligne
|
||||
où elle se trouve.
|
||||
-->
|
||||
<div class="etats" id="etats" hidden role="group" aria-label="Afficher par état">
|
||||
<span class="filtres__titre">Afficher</span>
|
||||
<div class="etats__liste" id="etats-liste"></div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Barre de filtres, masquée tant qu'aucun projet ne porte de tag : elle
|
||||
n'apparaît que le jour où elle sert à quelque chose.
|
||||
@@ -107,8 +123,13 @@
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<!--
|
||||
Deux tamis peuvent vider la vue — les tags cochés, les états décochés — et
|
||||
celui qui les a resserrés ne sait pas toujours lequel a fini par tout
|
||||
emporter. Le bouton relâche donc les deux d'un coup.
|
||||
-->
|
||||
<p class="vide" id="message-filtre" hidden>
|
||||
Aucun projet ne porte tous les tags sélectionnés.<br />
|
||||
Aucun projet ne passe les filtres en cours.<br />
|
||||
<button type="button" id="effacer-filtres-vide">Tout afficher</button>
|
||||
</p>
|
||||
|
||||
@@ -215,11 +236,48 @@
|
||||
<button type="button" id="fermer-panneau-projet" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<form class="panneau__corps" id="formulaire-projet">
|
||||
<!--
|
||||
Où en est le projet dans son cycle de vie. En lecture seule : l'état
|
||||
se déduit des données, il ne se choisit pas dans une liste. Les deux
|
||||
qui s'actent — terminer, écarter — sont dans le menu ⋯ du projet, avec
|
||||
les autres décisions qu'on ne prend qu'une fois.
|
||||
-->
|
||||
<p class="panneau__etat" id="panneau-projet-etat"></p>
|
||||
|
||||
<label>
|
||||
Nom
|
||||
<input type="text" name="name" required autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<!--
|
||||
Horizon : l'ancrage temporel d'un projet envisagé, au mois. Le bloc
|
||||
disparaît dès que le projet a une phase — ce sont elles qui commandent
|
||||
alors son emprise (docs/decisions.md, section 27).
|
||||
-->
|
||||
<fieldset class="horizon" id="panneau-projet-horizon" hidden>
|
||||
<legend>Horizon</legend>
|
||||
<div class="horizon__intervalle">
|
||||
<label>
|
||||
De
|
||||
<input type="month" name="horizonStart" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
à
|
||||
<input type="month" name="horizonEnd" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<!--
|
||||
Un horizon se repousse par crans et jamais au jour : c'est ce qui
|
||||
garde l'imprécision visible. Les mêmes crans dans les deux sens.
|
||||
-->
|
||||
<div class="horizon__crans" id="panneau-projet-crans">
|
||||
<button type="button" data-cran="-3" title="Avancer de trois mois">−3 mois</button>
|
||||
<button type="button" data-cran="3" title="Repousser de trois mois">+3 mois</button>
|
||||
<button type="button" data-cran="12" title="Repousser d'un an">+1 an</button>
|
||||
</div>
|
||||
<p class="horizon__aide" id="panneau-projet-horizon-aide"></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="couleurs">
|
||||
<legend>Couleur du couloir</legend>
|
||||
<div id="panneau-projet-couleurs"></div>
|
||||
@@ -245,6 +303,16 @@
|
||||
|
||||
<p class="panneau__erreur" id="erreur-projet" role="alert" hidden></p>
|
||||
|
||||
<!--
|
||||
Les deux actes du cycle de vie. Ils sont aussi dans le menu ⋯, avec les
|
||||
autres décisions qu'on ne prend qu'une fois — mais c'est ici qu'on lit
|
||||
l'état du projet, donc ici qu'on cherche à en changer.
|
||||
-->
|
||||
<div class="panneau__actions panneau__actions--cycle">
|
||||
<button type="button" id="terminer-projet"></button>
|
||||
<button type="button" id="ecarter-projet"></button>
|
||||
</div>
|
||||
|
||||
<div class="panneau__actions">
|
||||
<button type="button" id="supprimer-projet" class="bouton--danger">
|
||||
Supprimer le projet
|
||||
|
||||
228
js/app.js
228
js/app.js
@@ -17,14 +17,19 @@ import { rendreMois, revelerMoisCourant } from './mois.js';
|
||||
import { creerStockage, viderAvantFermeture } from './storage.js';
|
||||
import {
|
||||
COULEURS,
|
||||
ETATS,
|
||||
LIBELLES_ETAT,
|
||||
STATUTS,
|
||||
ajouterJours,
|
||||
ajouterPhase,
|
||||
aujourdhui,
|
||||
bornesPlanning,
|
||||
cleTag,
|
||||
clotureEcraseDesStatuts,
|
||||
creerPhase,
|
||||
creerProjet,
|
||||
ecarterProjet,
|
||||
etatProjet,
|
||||
figerReference,
|
||||
formaterDateLongue,
|
||||
libererReference,
|
||||
@@ -32,11 +37,16 @@ import {
|
||||
normaliserTags,
|
||||
planningVide,
|
||||
pourEcriture,
|
||||
premierJourDuMois,
|
||||
projetFiltre,
|
||||
projetFiltreEtat,
|
||||
reanimerProjet,
|
||||
remplacerPhase,
|
||||
remplacerProjet,
|
||||
rouvrirProjet,
|
||||
supprimerPhase,
|
||||
supprimerProjet,
|
||||
terminerProjet,
|
||||
teinteTag,
|
||||
tousLesTags,
|
||||
validerPlanning,
|
||||
@@ -58,6 +68,8 @@ const refs = {
|
||||
filtres: $('filtres'),
|
||||
filtresListe: $('filtres-liste'),
|
||||
effacerFiltres: $('effacer-filtres'),
|
||||
etats: $('etats'),
|
||||
etatsListe: $('etats-liste'),
|
||||
alerte: $('alerte'),
|
||||
alerteMessage: $('alerte-message'),
|
||||
panneau: $('panneau'),
|
||||
@@ -113,6 +125,18 @@ let defilementFrise = 0;
|
||||
*/
|
||||
let tagsActifs = [];
|
||||
|
||||
/**
|
||||
* États affichés. État de vue lui aussi, jamais enregistré — un réglage qui
|
||||
* survivrait au rechargement rouvrirait le planning amputé sans qu'on se
|
||||
* souvienne pourquoi.
|
||||
*
|
||||
* La sélection est **disjonctive**, à l'inverse de celle des tags : un projet
|
||||
* n'a qu'un état, cocher en élargit donc toujours. Et le défaut n'est pas la
|
||||
* liste vide mais les trois états vivants : le cimetière ne s'ouvre que si on le
|
||||
* demande, sans quoi il encombrerait la colonne dès la première année.
|
||||
*/
|
||||
let etatsActifs = ['considered', 'engaged', 'completed'];
|
||||
|
||||
/**
|
||||
* Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré.
|
||||
*
|
||||
@@ -197,6 +221,12 @@ const panneauProjet = creerPanneauProjet(
|
||||
conteneurCouleurs: $('panneau-projet-couleurs'),
|
||||
suggestions: $('panneau-projet-suggestions'),
|
||||
blocSuggestions: $('panneau-projet-suggestions-bloc'),
|
||||
etat: $('panneau-projet-etat'),
|
||||
blocHorizon: $('panneau-projet-horizon'),
|
||||
crans: $('panneau-projet-crans'),
|
||||
aideHorizon: $('panneau-projet-horizon-aide'),
|
||||
terminer: $('terminer-projet'),
|
||||
ecarter: $('ecarter-projet'),
|
||||
notes: $('notes-projet'),
|
||||
supprimer: $('supprimer-projet'),
|
||||
fermer: $('fermer-panneau-projet'),
|
||||
@@ -211,6 +241,9 @@ const panneauProjet = creerPanneauProjet(
|
||||
demanderSuppression(_idProjet, projet) {
|
||||
return confirmerSuppressionProjet(projet);
|
||||
},
|
||||
demanderCloture(projet) {
|
||||
return confirmerCloture(projet);
|
||||
},
|
||||
surSuppression(idProjet) {
|
||||
if (curseur?.projet === idProjet) panneau.fermer();
|
||||
majPlanning(supprimerProjet(planning, idProjet));
|
||||
@@ -243,7 +276,7 @@ function dessiner() {
|
||||
normaliserCurseur();
|
||||
|
||||
const aDesProjets = planning.projects.length > 0;
|
||||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
||||
const retenus = projetsRetenus();
|
||||
|
||||
refs.messageVide.hidden = aDesProjets;
|
||||
// Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce
|
||||
@@ -257,6 +290,7 @@ function dessiner() {
|
||||
refs.vueMois.hidden = !aQuelqueChoseAMontrer || vue !== 'mois';
|
||||
|
||||
peindreFiltres();
|
||||
peindreEtats();
|
||||
peindreOnglets();
|
||||
|
||||
if (!aDesProjets) {
|
||||
@@ -265,12 +299,12 @@ function dessiner() {
|
||||
}
|
||||
|
||||
if (vue === 'mois') {
|
||||
rendreMois(refs.vueMois, planning, { tagsActifs });
|
||||
rendreMois(refs.vueMois, planning, { tagsActifs, etatsActifs });
|
||||
return;
|
||||
}
|
||||
|
||||
elargirPourContenir();
|
||||
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs });
|
||||
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs, etatsActifs });
|
||||
|
||||
// Le panneau reflète la phase telle qu'elle est après le rendu : un glisser
|
||||
// sur la frise doit se voir dans les champs.
|
||||
@@ -286,6 +320,21 @@ function dessiner() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Projets que la frise montre : ceux que les deux filtres laissent passer.
|
||||
*
|
||||
* Centralisé parce que quatre parcours doivent en dire autant — le rendu, mais
|
||||
* aussi les déplacements du curseur clavier, qui sauteraient sur des lignes
|
||||
* invisibles si leur énumération divergeait de celle de `construireLibelles`
|
||||
* (timeline.js). L'œil (`hidden`) n'entre pas ici : un projet masqué garde sa
|
||||
* ligne de libellé, donc sa place dans le curseur.
|
||||
*/
|
||||
function projetsRetenus() {
|
||||
return planning.projects.filter(
|
||||
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
|
||||
);
|
||||
}
|
||||
|
||||
function localiser(idProjet, idPhase) {
|
||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||
if (!projet) return null;
|
||||
@@ -365,8 +414,71 @@ function effacerFiltres() {
|
||||
dessiner();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtre par état
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reconstruit la barre des états.
|
||||
*
|
||||
* Elle ne paraît que si le planning en compte plus d'un : tant que tous les
|
||||
* projets sont engagés — le cas d'un fichier hérité de la version 4 —, elle
|
||||
* n'aurait rien à trier.
|
||||
*
|
||||
* Chaque état affiche son compte. C'est ce qui rend le cimetière consultable
|
||||
* sans lui construire d'écran : « écartés (7) » dit à la fois qu'ils existent et
|
||||
* combien, et un clic les ramène dans la colonne.
|
||||
*/
|
||||
function peindreEtats() {
|
||||
const comptes = new Map(ETATS.map((nom) => [nom, 0]));
|
||||
for (const projet of planning.projects) {
|
||||
comptes.set(etatProjet(projet), comptes.get(etatProjet(projet)) + 1);
|
||||
}
|
||||
|
||||
const presents = ETATS.filter((nom) => comptes.get(nom) > 0);
|
||||
refs.etats.hidden = presents.length < 2;
|
||||
|
||||
refs.etatsListe.replaceChildren(
|
||||
...presents.map((nom) => {
|
||||
const actif = etatsActifs.includes(nom);
|
||||
const bouton = document.createElement('button');
|
||||
bouton.type = 'button';
|
||||
bouton.className = 'etat-filtre';
|
||||
bouton.dataset.etat = nom;
|
||||
bouton.textContent = `${LIBELLES_ETAT[nom]}s (${comptes.get(nom)})`;
|
||||
bouton.setAttribute('aria-pressed', String(actif));
|
||||
// Tout décocher afficherait le planning entier — l'inverse de ce que
|
||||
// demande le geste. On bloque plutôt le dernier actif, ce qui se voit.
|
||||
bouton.disabled = actif && etatsActifs.length === 1;
|
||||
bouton.title = actif
|
||||
? bouton.disabled
|
||||
? 'Au moins un état doit rester affiché.'
|
||||
: `Masquer les projets ${LIBELLES_ETAT[nom].toLowerCase()}s`
|
||||
: `Afficher aussi les projets ${LIBELLES_ETAT[nom].toLowerCase()}s`;
|
||||
bouton.addEventListener('click', () => basculerEtat(nom));
|
||||
return bouton;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Ajoute l'état à l'affichage, ou l'en retire s'il y était déjà. */
|
||||
function basculerEtat(nom) {
|
||||
const restants = etatsActifs.filter((actif) => actif !== nom);
|
||||
if (!restants.length) return;
|
||||
etatsActifs = restants.length === etatsActifs.length ? [...etatsActifs, nom] : restants;
|
||||
dessiner();
|
||||
}
|
||||
|
||||
refs.effacerFiltres.addEventListener('click', effacerFiltres);
|
||||
$('effacer-filtres-vide').addEventListener('click', effacerFiltres);
|
||||
|
||||
// Depuis la barre des tags, « Tout afficher » ne touche qu'aux tags : ouvrir le
|
||||
// cimetière au passage serait une surprise. Depuis l'écran vide, en revanche, on
|
||||
// ne sait pas lequel des deux tamis a tout emporté — on relâche donc les deux.
|
||||
$('effacer-filtres-vide').addEventListener('click', () => {
|
||||
tagsActifs = [];
|
||||
etatsActifs = [...ETATS];
|
||||
dessiner();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bascule entre la frise et la vue par mois
|
||||
@@ -745,6 +857,22 @@ installerGlisser(refs.couloirs, {
|
||||
surClic(idProjet, idPhase) {
|
||||
ouvrirPhase(idProjet, idPhase);
|
||||
},
|
||||
trouverProjet(idProjet) {
|
||||
return planning.projects.find((p) => p.id === idProjet) ?? null;
|
||||
},
|
||||
surDeplacementHorizon(idProjet, horizon) {
|
||||
// Même convention que pour une phase : null veut dire « geste annulé ou sans
|
||||
// effet », on redessine sans rien sauvegarder.
|
||||
if (!horizon) {
|
||||
dessiner();
|
||||
return;
|
||||
}
|
||||
majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon })));
|
||||
},
|
||||
surClicProjet(idProjet) {
|
||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||
if (projet) ouvrirProjet(projet);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -854,6 +982,14 @@ function ouvrirMenuProjet(idProjet, ancre) {
|
||||
if (!projet) return;
|
||||
|
||||
const aUneReference = projet.phases.some((phase) => phase.baseline);
|
||||
const etat = etatProjet(projet);
|
||||
|
||||
// Deux actes, et deux seulement : les autres états se déduisent. Chacun se
|
||||
// présente dans un sens ou dans l'autre selon où en est le projet, comme le
|
||||
// font déjà « plier » et « masquer » — une entrée qui bascule plutôt que deux
|
||||
// dont une serait toujours grise.
|
||||
const acter = (transformation) =>
|
||||
majPlanning(remplacerProjet(planning, idProjet, transformation));
|
||||
|
||||
menu.ouvrir(ancre, [
|
||||
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet) },
|
||||
@@ -871,6 +1007,26 @@ function ouvrirMenuProjet(idProjet, ancre) {
|
||||
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
|
||||
},
|
||||
{ libelle: 'Renommer, changer la couleur…', action: () => ouvrirProjet(projet) },
|
||||
{
|
||||
libelle: etat === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé',
|
||||
separateurAvant: true,
|
||||
// Un projet envisagé n'a rien à clore : la clôture est le bout du chemin
|
||||
// qui commence avec la première phase. Un écarté non plus — il sort du
|
||||
// cimetière par « Remettre en jeu », et retrouve alors l'état que ses
|
||||
// données commandent ; le clore d'ici court-circuiterait le cycle.
|
||||
desactive: etat === 'considered' || etat === 'discarded',
|
||||
action: () =>
|
||||
etat === 'completed' ? acter((p) => rouvrirProjet(p)) : terminerLeProjet(projet),
|
||||
},
|
||||
{
|
||||
// Écarter n'est pas supprimer, et la manœuvre se défait : pas de dialogue
|
||||
// de confirmation, réservé à ce qui ne se rattrape pas (décision 21). Le
|
||||
// projet garde tout, notes comprises, et se retrouve en cochant
|
||||
// « écartés » dans le filtre par état.
|
||||
libelle: etat === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet',
|
||||
action: () =>
|
||||
acter(etat === 'discarded' ? (p) => reanimerProjet(p) : (p) => ecarterProjet(p)),
|
||||
},
|
||||
{
|
||||
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
|
||||
separateurAvant: true,
|
||||
@@ -909,8 +1065,20 @@ function ajouterUnePhase(projet, { debut = null } = {}) {
|
||||
// `debut` n'est fourni que par les raccourcis `a`, `o` et `O`, qui insèrent
|
||||
// ailleurs qu'au bout : les phases étant triées par date, « avant » et
|
||||
// « après » ne s'expriment que par une date, jamais par un rang.
|
||||
//
|
||||
// La toute première phase d'un projet envisagé démarre à son horizon, et non
|
||||
// aujourd'hui : c'est le moment où l'on engage, et engager veut dire préciser
|
||||
// ce qu'on avait déclaré au mois près. La faire naître ailleurs donnerait à
|
||||
// croire que l'horizon n'était pas pris au sérieux — et laisserait la barre
|
||||
// pâle et la barre ferme à deux endroits sans rapport.
|
||||
const derniere = projet.phases[projet.phases.length - 1];
|
||||
if (!debut) debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
|
||||
if (!debut) {
|
||||
debut = derniere
|
||||
? ajouterJours(derniere.end, 1)
|
||||
: projet.horizon
|
||||
? premierJourDuMois(projet.horizon.start)
|
||||
: aujourdhui();
|
||||
}
|
||||
|
||||
const phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20));
|
||||
|
||||
@@ -945,7 +1113,7 @@ function ouvrirProjet(projet, options = {}) {
|
||||
* gestionnaire — ou le glisser qui commence peut-être — n'ait fini son travail.
|
||||
*/
|
||||
const CIBLES_OUVRANTES =
|
||||
'[data-role="barre"], [data-role="poignee"], .libelle-phase, [data-action]';
|
||||
'[data-role="barre"], [data-role="horizon"], [data-role="poignee"], .libelle-phase, [data-action]';
|
||||
|
||||
/** Surfaces où un clic n'est pas un « ailleurs » : on y reste au travail. */
|
||||
const CIBLES_PROTEGEES = '.panneau, .menu, dialog, .poignee-colonne';
|
||||
@@ -1006,6 +1174,42 @@ function confirmerSuppressionProjet(projet) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clore un projet **termine toutes ses phases avec lui**, et rien ne rétablira
|
||||
* ensuite celle qui était bloquée ou celle qui n'avait pas commencé : rouvrir le
|
||||
* projet ne peut pas deviner ce qu'elles valaient. C'est donc une action
|
||||
* irréversible au sens de la décision 21, et elle s'annonce comme telle.
|
||||
*
|
||||
* On ne demande rien quand plus aucune phase n'est ouverte : il n'y a alors rien
|
||||
* à écraser, et un dialogue qui ne prévient de rien apprend à cliquer sans lire.
|
||||
*/
|
||||
function confirmerCloture(projet) {
|
||||
if (!clotureEcraseDesStatuts(projet)) return Promise.resolve(true);
|
||||
|
||||
const restantes = projet.phases.filter((phase) => phase.status !== 'done').length;
|
||||
// Deux phrases écrites en entier plutôt qu'une suite de ternaires accordés :
|
||||
// le texte d'un dialogue se relit, et une accumulation de « s » conditionnels
|
||||
// finit toujours par produire une conjugaison de travers.
|
||||
const message =
|
||||
restantes > 1
|
||||
? `${restantes} phases de « ${projet.name} » ne sont pas encore terminées. Elles passeront ` +
|
||||
'au statut « Terminé », jalons compris, et rouvrir le projet ensuite ne leur rendra pas ' +
|
||||
'leur statut précédent.'
|
||||
: `Une phase de « ${projet.name} » n'est pas encore terminée. Elle passera au statut ` +
|
||||
'« Terminé », et rouvrir le projet ensuite ne lui rendra pas son statut précédent.';
|
||||
|
||||
return demanderConfirmation({
|
||||
titreTexte: 'Déclarer le projet terminé',
|
||||
messageTexte: message,
|
||||
libelleValider: 'Déclarer terminé',
|
||||
});
|
||||
}
|
||||
|
||||
async function terminerLeProjet(projet) {
|
||||
if (!(await confirmerCloture(projet))) return;
|
||||
majPlanning(remplacerProjet(planning, projet.id, (p) => terminerProjet(p)));
|
||||
}
|
||||
|
||||
async function supprimerLeProjet(projet) {
|
||||
if (!(await confirmerSuppressionProjet(projet))) return;
|
||||
|
||||
@@ -1025,7 +1229,7 @@ async function supprimerLeProjet(projet) {
|
||||
|
||||
/** Ligne du projet qui suit celui-ci, ou du précédent s'il fermait la liste. */
|
||||
function projetVoisin(idProjet) {
|
||||
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
|
||||
const retenus = projetsRetenus();
|
||||
const index = retenus.findIndex((p) => p.id === idProjet);
|
||||
if (index === -1) return null;
|
||||
const voisin = retenus[index + 1] ?? retenus[index - 1];
|
||||
@@ -1052,6 +1256,12 @@ function nouveauProjet() {
|
||||
// Un filtre actif préremplit les tags : quand on crée un projet en ne
|
||||
// regardant que les projets « client », c'en est presque toujours un — et
|
||||
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
|
||||
//
|
||||
// Même raison pour l'état : un projet naît envisagé, et le masquer aussitôt
|
||||
// parce qu'on avait décoché « envisagés » ferait paraître le bouton sans
|
||||
// effet. On rouvre donc l'état plutôt que de créer dans l'ombre.
|
||||
if (!etatsActifs.includes('considered')) etatsActifs = [...etatsActifs, 'considered'];
|
||||
|
||||
const projet = creerProjet(planning, 'Nouveau projet', tagsActifs);
|
||||
majPlanning({ ...planning, projects: [...planning.projects, projet] });
|
||||
ouvrirProjet(projet, { nomAEcraser: true });
|
||||
@@ -1146,7 +1356,7 @@ document.addEventListener('keydown', (evenement) => {
|
||||
function lignesFrise() {
|
||||
const lignes = [];
|
||||
|
||||
for (const projet of planning.projects.filter((p) => projetFiltre(p, tagsActifs))) {
|
||||
for (const projet of projetsRetenus()) {
|
||||
lignes.push({ projet: projet.id, phase: null });
|
||||
if (projet.hidden || projet.collapsed) continue;
|
||||
for (const phase of projet.phases) lignes.push({ projet: projet.id, phase: phase.id });
|
||||
@@ -1312,7 +1522,7 @@ function deplacerDansLeProjet(pas) {
|
||||
|
||||
/** `{` et `}` : la ligne du projet précédent ou suivant. */
|
||||
function deplacerDeProjet(pas) {
|
||||
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
|
||||
const retenus = projetsRetenus();
|
||||
if (!retenus.length) return;
|
||||
|
||||
const depart = retenus.findIndex((p) => p.id === curseur?.projet);
|
||||
|
||||
151
js/drag.js
151
js/drag.js
@@ -14,8 +14,13 @@
|
||||
|
||||
import {
|
||||
ajouterJours,
|
||||
ajouterMois,
|
||||
deplacerPhase,
|
||||
dernierJourDuMois,
|
||||
ecartMois,
|
||||
lundiDeLaSemaine,
|
||||
moisDe,
|
||||
premierJourDuMois,
|
||||
redimensionnerPhase,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
@@ -40,19 +45,45 @@ export function installerGlisser(conteneur, rappels) {
|
||||
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
||||
if (evenement.button !== 0) return;
|
||||
|
||||
const cible = evenement.target.closest('[data-role="barre"]');
|
||||
const cible = evenement.target.closest('[data-role="barre"], [data-role="horizon"]');
|
||||
if (!cible) return;
|
||||
|
||||
const poignee = evenement.target.closest('[data-role="poignee"]');
|
||||
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
|
||||
|
||||
// La barre d'un projet envisagé se manœuvre au mois, pas au jour : même
|
||||
// mécanique de geste, autre grain, et rien de commun avec une phase — il n'y
|
||||
// en a pas encore (docs/decisions.md, section 27).
|
||||
if (cible.dataset.role === 'horizon') {
|
||||
const projet = rappels.trouverProjet(cible.dataset.projet);
|
||||
if (!projet?.horizon) return;
|
||||
|
||||
geste = {
|
||||
element: cible,
|
||||
sorte: 'horizon',
|
||||
idProjet: projet.id,
|
||||
horizonInitial: projet.horizon,
|
||||
horizonCourant: projet.horizon,
|
||||
mode: bord,
|
||||
xDepart: evenement.clientX,
|
||||
demarre: false,
|
||||
};
|
||||
|
||||
cible.setPointerCapture(evenement.pointerId);
|
||||
evenement.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const { projet: idProjet, phase: idPhase } = cible.dataset;
|
||||
const phase = rappels.trouverPhase(idProjet, idPhase);
|
||||
if (!phase) return;
|
||||
|
||||
const poignee = evenement.target.closest('[data-role="poignee"]');
|
||||
// Un jalon n'a pas de durée : ses poignées n'ont pas de sens.
|
||||
const mode = poignee && !phase.milestone ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
|
||||
const mode = poignee && !phase.milestone ? bord : 'deplacer';
|
||||
|
||||
geste = {
|
||||
element: cible,
|
||||
sorte: 'phase',
|
||||
idProjet,
|
||||
idPhase,
|
||||
phaseInitiale: phase,
|
||||
@@ -89,9 +120,21 @@ export function installerGlisser(conteneur, rappels) {
|
||||
const acheve = geste;
|
||||
geste = null;
|
||||
|
||||
acheve.element.releasePointerCapture?.(evenement.pointerId);
|
||||
acheve.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
relacher(acheve, evenement.pointerId);
|
||||
|
||||
if (acheve.sorte === 'horizon') {
|
||||
const avant = acheve.horizonInitial;
|
||||
const apres = acheve.horizonCourant;
|
||||
// Un appui sans glisser ouvre les paramètres du projet : c'est là que se
|
||||
// saisissent son horizon et le reste, comme un clic sur son nom.
|
||||
if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet);
|
||||
else if (apres.start !== avant.start || apres.end !== avant.end) {
|
||||
rappels.surDeplacementHorizon(acheve.idProjet, apres);
|
||||
} else {
|
||||
rappels.surDeplacementHorizon(acheve.idProjet, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!acheve.demarre) {
|
||||
rappels.surClic(acheve.idProjet, acheve.idPhase);
|
||||
@@ -110,26 +153,46 @@ export function installerGlisser(conteneur, rappels) {
|
||||
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres);
|
||||
});
|
||||
|
||||
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
if (evenement.key !== 'Escape' || !geste) return;
|
||||
/** Abandonne le geste en cours et laisse le rendu se refaire depuis l'état. */
|
||||
function annuler() {
|
||||
if (!geste) return;
|
||||
const annule = geste;
|
||||
geste = null;
|
||||
annule.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
relacher(annule);
|
||||
|
||||
if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
|
||||
else rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
}
|
||||
|
||||
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
if (evenement.key === 'Escape') annuler();
|
||||
});
|
||||
|
||||
// Un pointeur perdu (fenêtre qui perd le focus, périphérique débranché) doit
|
||||
// laisser l'interface dans un état propre plutôt qu'en glisser perpétuel.
|
||||
conteneur.addEventListener('pointercancel', () => {
|
||||
if (!geste) return;
|
||||
const annule = geste;
|
||||
geste = null;
|
||||
annule.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
});
|
||||
conteneur.addEventListener('pointercancel', annuler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend le pointeur et retire les marques du glisser.
|
||||
*
|
||||
* `releasePointerCapture` lève quand le pointeur a déjà disparu — périphérique
|
||||
* débranché, fenêtre qui perd le focus au mauvais moment. L'exception ne doit
|
||||
* pas empêcher ce qui suit : le geste de l'utilisateur est achevé et son
|
||||
* résultat mérite d'être appliqué, même si le nettoyage n'avait plus rien à
|
||||
* rendre.
|
||||
*/
|
||||
function relacher(geste, pointerId) {
|
||||
if (pointerId !== undefined) {
|
||||
try {
|
||||
geste.element.releasePointerCapture?.(pointerId);
|
||||
} catch {
|
||||
// Le pointeur n'était plus capturé : il n'y a rien à réparer.
|
||||
}
|
||||
}
|
||||
geste.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,6 +200,8 @@ export function installerGlisser(conteneur, rappels) {
|
||||
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
||||
*/
|
||||
function appliquerGeste(geste, decalage, echelle) {
|
||||
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
|
||||
|
||||
const { phaseInitiale: phase, mode } = geste;
|
||||
|
||||
if (mode === 'deplacer') {
|
||||
@@ -164,6 +229,37 @@ function appliquerGeste(geste, decalage, echelle) {
|
||||
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** :
|
||||
* l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire
|
||||
* qu'il en pose.
|
||||
*
|
||||
* Les bornes ne se retournent pas — la borne opposée fait butée, comme pour une
|
||||
* phase —, si bien qu'un horizon fait toujours au moins un mois plein.
|
||||
*/
|
||||
function appliquerGesteHorizon(geste, decalage, echelle) {
|
||||
const { horizonInitial: horizon, mode } = geste;
|
||||
const largeur = ecartMois(horizon.start, horizon.end);
|
||||
|
||||
let suivant;
|
||||
if (mode === 'deplacer') {
|
||||
const debut = accrocherAuMois(horizon.start, decalage, echelle);
|
||||
suivant = { start: debut, end: ajouterMois(debut, largeur) };
|
||||
} else if (mode === 'redimensionner-debut') {
|
||||
const debut = accrocherAuMois(horizon.start, decalage, echelle);
|
||||
suivant = { start: debut > horizon.end ? horizon.end : debut, end: horizon.end };
|
||||
} else {
|
||||
const fin = accrocherAuMois(horizon.end, decalage, echelle);
|
||||
suivant = { start: horizon.start, end: fin < horizon.start ? horizon.start : fin };
|
||||
}
|
||||
|
||||
geste.horizonCourant = suivant;
|
||||
const gauche = premierJourDuMois(suivant.start);
|
||||
const droite = dernierJourDuMois(suivant.end);
|
||||
geste.element.style.left = `${echelle.x(gauche)}px`;
|
||||
geste.element.style.width = `${echelle.largeurDe(gauche, droite)}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Décale une date du nombre de jours correspondant au déplacement, puis
|
||||
* l'accroche au lundi de sa semaine.
|
||||
@@ -173,6 +269,21 @@ function accrocher(date, decalagePixels, echelle) {
|
||||
return lundiDeLaSemaine(ajouterJours(date, jours));
|
||||
}
|
||||
|
||||
/**
|
||||
* Décale un mois du déplacement demandé, puis l'accroche au mois le plus proche.
|
||||
*
|
||||
* L'arrondi se fait à la moitié du mois atteint plutôt qu'à un nombre fixe de
|
||||
* jours : les mois n'ont pas tous la même longueur, et découper en tranches de
|
||||
* trente jours ferait dériver le geste d'un cran au fil de l'année.
|
||||
*/
|
||||
function accrocherAuMois(mois, decalagePixels, echelle) {
|
||||
const jours = Math.round(decalagePixels / echelle.pixelsParJour);
|
||||
const cible = ajouterJours(premierJourDuMois(mois), jours);
|
||||
const moisCible = moisDe(cible);
|
||||
const longueur = Number(dernierJourDuMois(moisCible).slice(8, 10));
|
||||
return Number(cible.slice(8, 10)) > longueur / 2 ? ajouterMois(moisCible, 1) : moisCible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Décale une phase au clavier, pour un ajustement fin sans souris.
|
||||
* Utilisé par app.js sur les flèches gauche/droite quand une phase est
|
||||
|
||||
418
js/model.js
418
js/model.js
@@ -15,18 +15,34 @@
|
||||
/**
|
||||
* Version du format de fichier.
|
||||
*
|
||||
* Elle a changé pour la dernière fois quand le projet a reçu des `notes` (v4).
|
||||
* Un champ ajouté n'oblige pourtant à rien : la v3 se relit sans encombre, et
|
||||
* le validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement
|
||||
* là qu'est la raison d'incrémenter — le validateur reconstruit chaque projet
|
||||
* champ par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un
|
||||
* binaire antérieur ouvrirait un fichier v4 sans broncher et en effacerait
|
||||
* toutes les notes de projet à la première sauvegarde.
|
||||
* Elle a changé pour la dernière fois quand le projet a reçu son cycle de vie —
|
||||
* `horizon`, `completedDate`, `discardedDate` (v5). Un champ ajouté n'oblige
|
||||
* pourtant à rien : la v4 se relit sans encombre, et le validateur ne refuse
|
||||
* qu'un fichier *plus récent* que lui. C'est justement là qu'est la raison
|
||||
* d'incrémenter — le validateur reconstruit chaque projet champ par champ et
|
||||
* laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire antérieur
|
||||
* ouvrirait un fichier v5 sans broncher et en effacerait tous les horizons à la
|
||||
* première sauvegarde.
|
||||
*/
|
||||
export const VERSION_FORMAT = 4;
|
||||
export const VERSION_FORMAT = 5;
|
||||
|
||||
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
|
||||
|
||||
/**
|
||||
* États d'un projet, dans l'ordre du cycle de vie.
|
||||
*
|
||||
* Contrairement au statut d'une phase, ce n'est **pas** un champ du fichier : il
|
||||
* se déduit de `etatProjet()`. Voir docs/decisions.md, section 27.
|
||||
*/
|
||||
export const ETATS = ['considered', 'engaged', 'completed', 'discarded'];
|
||||
|
||||
export const LIBELLES_ETAT = {
|
||||
considered: 'Envisagé',
|
||||
engaged: 'Engagé',
|
||||
completed: 'Terminé',
|
||||
discarded: 'Écarté',
|
||||
};
|
||||
|
||||
export const LIBELLES_STATUT = {
|
||||
todo: 'À venir',
|
||||
doing: 'En cours',
|
||||
@@ -56,6 +72,7 @@ export const MOIS_LONGS = [
|
||||
];
|
||||
|
||||
const MOTIF_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const MOTIF_MOIS = /^\d{4}-\d{2}$/;
|
||||
const MOTIF_COULEUR = /^#[0-9a-f]{6}$/i;
|
||||
const MS_PAR_JOUR = 86400000;
|
||||
|
||||
@@ -165,6 +182,58 @@ export function formaterMois(mois) {
|
||||
return `${MOIS_LONGS[Number(mois.slice(5, 7)) - 1]} ${mois.slice(0, 4)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mois
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Le mois est le grain de l'horizon d'un projet envisagé (décision 27). Comme
|
||||
// les dates, il se manipule en chaîne — « AAAA-MM » se compare et se trie comme
|
||||
// du texte, ce qui évite tout objet Date dans les comparaisons.
|
||||
|
||||
/** Vrai si la chaîne est un mois « AAAA-MM » plausible. */
|
||||
export function moisValide(valeur) {
|
||||
if (typeof valeur !== 'string' || !MOTIF_MOIS.test(valeur)) return false;
|
||||
const numero = Number(valeur.slice(5, 7));
|
||||
return numero >= 1 && numero <= 12;
|
||||
}
|
||||
|
||||
/** Mois en cours, au format « AAAA-MM ». */
|
||||
export function moisCourant() {
|
||||
return moisDe(aujourdhui());
|
||||
}
|
||||
|
||||
/** Premier jour d'un mois : « 2027-03 » → « 2027-03-01 ». */
|
||||
export function premierJourDuMois(mois) {
|
||||
return `${mois}-01`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dernier jour d'un mois : « 2027-03 » → « 2027-03-31 ».
|
||||
*
|
||||
* Le jour 0 du mois suivant est le dernier du mois demandé — `Date.UTC` fait le
|
||||
* report d'année tout seul, décembre compris.
|
||||
*/
|
||||
export function dernierJourDuMois(mois) {
|
||||
const annee = Number(mois.slice(0, 4));
|
||||
const numero = Number(mois.slice(5, 7));
|
||||
return depuisUTC(Date.UTC(annee, numero, 0));
|
||||
}
|
||||
|
||||
/** Décale un mois d'un nombre de mois, éventuellement négatif. */
|
||||
export function ajouterMois(mois, nombre) {
|
||||
const annee = Number(mois.slice(0, 4));
|
||||
const numero = Number(mois.slice(5, 7));
|
||||
return depuisUTC(Date.UTC(annee, numero - 1 + nombre, 1)).slice(0, 7);
|
||||
}
|
||||
|
||||
/** Nombre de mois de `debut` à `fin`, signé. Deux mois égaux donnent 0. */
|
||||
export function ecartMois(debut, fin) {
|
||||
return (
|
||||
(Number(fin.slice(0, 4)) - Number(debut.slice(0, 4))) * 12 +
|
||||
(Number(fin.slice(5, 7)) - Number(debut.slice(5, 7)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Période d'une phase, abrégée pour une liste où le mois est déjà écrit en
|
||||
* titre : « 12 » pour un jalon, « 12 → 30 » pour une tâche qui tient dans le
|
||||
@@ -292,6 +361,106 @@ export function teinteTag(tag) {
|
||||
return hachage;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cycle de vie et horizon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* État d'un projet, déduit de ses données. Voir docs/decisions.md, section 27.
|
||||
*
|
||||
* Une seule règle : **les phases commandent l'état, sauf quand on a prononcé
|
||||
* quelque chose**. Terminer et écarter sont des décisions — livré n'est pas
|
||||
* clos, et abandonner n'est pas une conséquence des dates —, elles laissent
|
||||
* donc une trace horodatée. Le reste se déduit : un projet qui a des phases est
|
||||
* engagé, un projet qui n'en a pas encore est envisagé.
|
||||
*
|
||||
* Un projet ne peut pas porter les deux dates à la fois — les transitions
|
||||
* ci-dessous s'en assurent —, mais un fichier retouché à la main le pourrait :
|
||||
* l'écartement l'emporte, pour que la lecture reste déterministe.
|
||||
*/
|
||||
export function etatProjet(projet) {
|
||||
if (projet.discardedDate) return 'discarded';
|
||||
if (projet.completedDate) return 'completed';
|
||||
return projet.phases.length ? 'engaged' : 'considered';
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le projet passe le filtre par état.
|
||||
*
|
||||
* Disjonctif, là où le filtre par tags est conjonctif (`projetFiltre`) : un
|
||||
* projet porte plusieurs tags mais un seul état, si bien que cocher un état de
|
||||
* plus ne peut qu'élargir la sélection. Une liste vide laisse tout passer, mais
|
||||
* l'interface n'en pose jamais : elle part des états qu'on veut voir d'ordinaire
|
||||
* — les écartés n'en font pas partie.
|
||||
*/
|
||||
export function projetFiltreEtat(projet, etatsActifs) {
|
||||
if (!etatsActifs.length) return true;
|
||||
return etatsActifs.includes(etatProjet(projet));
|
||||
}
|
||||
|
||||
/**
|
||||
* Emprise d'un horizon, ramenée en dates pour être dessinée : du premier jour
|
||||
* de son premier mois au dernier jour de son dernier. Null si le projet n'en a
|
||||
* pas.
|
||||
*/
|
||||
export function bornesHorizon(projet) {
|
||||
if (!projet.horizon) return null;
|
||||
return {
|
||||
start: premierJourDuMois(projet.horizon.start),
|
||||
end: dernierJourDuMois(projet.horizon.end),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emprise temporelle d'un projet, quel que soit son état.
|
||||
*
|
||||
* L'emprise se calcule depuis les phases ; un projet qui n'en a pas encore la
|
||||
* déclare à la main, c'est son horizon. Les phases gagnent donc toujours, et
|
||||
* l'horizon n'est qu'une valeur de repli — jamais réécrite par le calcul, ce
|
||||
* qui fait qu'un projet ayant perdu sa dernière phase retrouve l'horizon qu'il
|
||||
* avait déclaré.
|
||||
*/
|
||||
export function empriseProjet(projet) {
|
||||
return bornesProjet(projet) ?? bornesHorizon(projet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrai si le projet réclame une date : envisagé sans horizon, ou dont l'horizon
|
||||
* est déjà passé.
|
||||
*
|
||||
* C'est le garde-fou qui empêche la liste des envisagés de devenir une décharge
|
||||
* (décision 27). Plutôt qu'un plafond ou une péremption qui effacerait — deux
|
||||
* choses interdites par la crainte d'oublier —, un projet dont l'horizon est
|
||||
* dépassé se signale : on repousse, ou on écarte.
|
||||
*
|
||||
* Les deux cas se confondent volontairement en un seul signal. Un projet hérité
|
||||
* d'un fichier en version 4, sans phase et sans horizon, réclame une date au
|
||||
* même titre qu'un projet qu'on visait pour le trimestre dernier.
|
||||
*/
|
||||
export function projetADater(projet, mois = moisCourant()) {
|
||||
if (etatProjet(projet) !== 'considered') return false;
|
||||
return !projet.horizon || projet.horizon.end < mois;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizon rendu lisible : « mars 2027 », « mars → juin 2027 »,
|
||||
* « novembre 2026 → février 2027 ».
|
||||
*
|
||||
* L'année n'est écrite qu'une fois quand les deux bornes la partagent : comme
|
||||
* pour la période d'une phase (`formaterPeriode`), son apparition en tête
|
||||
* signale à elle seule le débordement.
|
||||
*/
|
||||
export function formaterHorizon(horizon) {
|
||||
if (!horizon) return '';
|
||||
if (horizon.start === horizon.end) return formaterMois(horizon.start);
|
||||
|
||||
const memeAnnee = horizon.start.slice(0, 4) === horizon.end.slice(0, 4);
|
||||
const debut = memeAnnee
|
||||
? MOIS_LONGS[Number(horizon.start.slice(5, 7)) - 1]
|
||||
: formaterMois(horizon.start);
|
||||
return `${debut} → ${formaterMois(horizon.end)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -337,11 +506,15 @@ export function validerPlanning(donnees) {
|
||||
}
|
||||
idsProjets.add(projet.id);
|
||||
|
||||
if (projet.baselineDate !== undefined && !dateValide(projet.baselineDate)) {
|
||||
throw new ErreurValidation(
|
||||
`Projet ${repere} : « baselineDate » n'est pas une date valide (${projet.baselineDate}).`
|
||||
);
|
||||
for (const cle of ['baselineDate', 'completedDate', 'discardedDate']) {
|
||||
if (projet[cle] !== undefined && !dateValide(projet[cle])) {
|
||||
throw new ErreurValidation(
|
||||
`Projet ${repere} : « ${cle} » n'est pas une date valide (${projet[cle]}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
const horizon = validerHorizon(projet.horizon, repere);
|
||||
|
||||
if (!Array.isArray(projet.phases)) {
|
||||
throw new ErreurValidation(`Projet ${repere} : « phases » doit être un tableau.`);
|
||||
}
|
||||
@@ -363,6 +536,9 @@ export function validerPlanning(donnees) {
|
||||
// bloquer le chargement d'un planning entier pour un champ mal typé serait
|
||||
// disproportionné là où le champ n'engage aucun calcul.
|
||||
notes: typeof projet.notes === 'string' ? projet.notes : '',
|
||||
...(horizon ? { horizon } : {}),
|
||||
...(projet.completedDate ? { completedDate: projet.completedDate } : {}),
|
||||
...(projet.discardedDate ? { discardedDate: projet.discardedDate } : {}),
|
||||
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
|
||||
phases: trierPhases(phases),
|
||||
};
|
||||
@@ -371,6 +547,33 @@ export function validerPlanning(donnees) {
|
||||
return { version: VERSION_FORMAT, projects: projets };
|
||||
}
|
||||
|
||||
/**
|
||||
* L'horizon est facultatif — aucun fichier en version 4 n'en porte — mais s'il
|
||||
* est là, il est vérifié aussi strictement que les dates d'une phase et non
|
||||
* réparé en silence comme le sont le statut ou les notes : il décide d'une
|
||||
* position sur la frise, et un horizon avalé laisserait un projet envisagé
|
||||
* invisible sans qu'on sache pourquoi.
|
||||
*/
|
||||
function validerHorizon(horizon, repere) {
|
||||
if (horizon === undefined) return undefined;
|
||||
if (!horizon || typeof horizon !== 'object') {
|
||||
throw new ErreurValidation(`Projet ${repere} : « horizon » doit être un objet.`);
|
||||
}
|
||||
if (!moisValide(horizon.start) || !moisValide(horizon.end)) {
|
||||
throw new ErreurValidation(
|
||||
`Projet ${repere} : l'horizon doit porter deux mois « AAAA-MM » ` +
|
||||
`(${horizon.start} → ${horizon.end}).`
|
||||
);
|
||||
}
|
||||
// « AAAA-MM » se compare comme du texte.
|
||||
if (horizon.end < horizon.start) {
|
||||
throw new ErreurValidation(
|
||||
`Projet ${repere} : la fin de l'horizon (${horizon.end}) précède son début (${horizon.start}).`
|
||||
);
|
||||
}
|
||||
return { start: horizon.start, end: horizon.end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les tags sont facultatifs — un fichier en version 2 n'en a aucun — mais s'ils
|
||||
* sont là, ils doivent être des chaînes : un tag avalé en silence parce qu'il
|
||||
@@ -479,13 +682,21 @@ export function bornesProjet(projet) {
|
||||
return { start: debut, end: fin };
|
||||
}
|
||||
|
||||
/** Étendue de tous les projets visibles réunis. Null si rien à afficher. */
|
||||
/**
|
||||
* Étendue de tous les projets visibles réunis. Null si rien à afficher.
|
||||
*
|
||||
* Elle sert à cadrer la fenêtre au premier affichage, et prend donc l'emprise
|
||||
* plutôt que les seules phases : sans cela, un planning fait de projets encore
|
||||
* tous envisagés s'ouvrirait sur rien. Les é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.
|
||||
*/
|
||||
export function bornesPlanning(planning) {
|
||||
let debut = null;
|
||||
let fin = null;
|
||||
for (const projet of planning.projects) {
|
||||
if (projet.hidden) continue;
|
||||
const bornes = bornesProjet(projet);
|
||||
if (projet.hidden || etatProjet(projet) === 'discarded') continue;
|
||||
const bornes = empriseProjet(projet);
|
||||
if (!bornes) continue;
|
||||
if (debut === null || versUTC(bornes.start) < versUTC(debut)) debut = bornes.start;
|
||||
if (fin === null || versUTC(bornes.end) > versUTC(fin)) fin = bornes.end;
|
||||
@@ -572,37 +783,59 @@ export function formulerDerive(jours) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Toutes les phases du planning regroupées par mois, projets mêlés.
|
||||
* Tout ce que le planning fait arriver, regroupé par mois, projets mêlés.
|
||||
*
|
||||
* Une phase est rattachée au **mois de son début**, et à lui seul. Une phase de
|
||||
* trois mois n'apparaît donc qu'une fois, au mois où elle démarre : la liste
|
||||
* répond à « qu'est-ce qui commence, et quand », question à laquelle la frise
|
||||
* répond mal parce qu'elle éparpille les départs sur autant de couloirs qu'il y
|
||||
* a de projets. La répéter dans chaque mois traversé en aurait fait un tableau
|
||||
* de charge — utile, mais c'est une autre vue, et le doublon coûte la lecture
|
||||
* en diagonale qui fait tout l'intérêt de celle-ci. Sa fin est écrite sur la
|
||||
* ligne, ce qui suffit à voir qu'elle déborde.
|
||||
* Deux sortes d'entrées y cohabitent, et la seconde est venue plus tard :
|
||||
*
|
||||
* Les deux tamis de la frise s'appliquent de la même façon (timeline.js) : le
|
||||
* filtre par tags retire le projet, l'œil (`hidden`) aussi — un projet dont on
|
||||
* a masqué les barres n'a pas à revenir par la liste.
|
||||
* - une **phase**, rattachée au mois de son début et à lui seul. Une phase de
|
||||
* trois mois n'apparaît donc qu'une fois, au mois où elle démarre : la liste
|
||||
* répond à « qu'est-ce qui commence, et quand », question à laquelle la frise
|
||||
* répond mal parce qu'elle éparpille les départs sur autant de couloirs qu'il
|
||||
* y a de projets. La répéter dans chaque mois traversé en aurait fait un
|
||||
* tableau de charge — utile, mais c'est une autre vue, et le doublon coûte la
|
||||
* lecture en diagonale qui fait tout l'intérêt de celle-ci. Sa fin est écrite
|
||||
* sur la ligne, ce qui suffit à voir qu'elle déborde.
|
||||
* - un **projet sans phase**, rattaché au premier mois de son horizon. Il n'a
|
||||
* rien à lister autrement, donc il ne paraissait nulle part ici — alors qu'un
|
||||
* projet visé pour mars 2027 *arrive* bel et bien en mars 2027. L'omettre
|
||||
* revenait à dire que la liste ne montre que l'engagé, quand toute la
|
||||
* conception pose qu'un projet envisagé n'est pas hors du temps (décision 27).
|
||||
* La règle porte sur l'absence de phases et non sur l'état, exactement comme
|
||||
* `empriseProjet` : un projet écarté avant d'avoir été engagé n'a lui non plus
|
||||
* que son horizon pour se situer, et il doit se retrouver là quand on ouvre le
|
||||
* cimetière.
|
||||
*
|
||||
* Les tamis de la frise s'appliquent tous de la même façon (timeline.js) : le
|
||||
* filtre par tags retire le projet, celui par état aussi, et l'œil (`hidden`)
|
||||
* de même — un projet dont on a masqué les barres n'a pas à revenir par la
|
||||
* liste, et un projet écarté n'a pas à peupler les mois à venir.
|
||||
*
|
||||
* Les mois sans rien ne sont pas représentés : intercaler « novembre 2026 —
|
||||
* rien » entre deux mois pleins allongerait la liste de tout le temps mort d'un
|
||||
* planning, alors que l'absence se lit déjà dans le saut d'un titre à l'autre.
|
||||
*
|
||||
* @returns {Array<{mois: string, entrees: Array<{projet: object, phase: object}>}>}
|
||||
* @returns {Array<{mois: string, entrees: Array<{projet: object, phase?: object, horizon?: object}>}>}
|
||||
* trié du plus ancien au plus récent, chaque mois trié par date puis par nom.
|
||||
*/
|
||||
export function phasesParMois(planning, tagsActifs = []) {
|
||||
export function entreesParMois(planning, tagsActifs = [], etatsActifs = []) {
|
||||
const groupes = new Map();
|
||||
const poser = (mois, entree) => {
|
||||
if (!groupes.has(mois)) groupes.set(mois, []);
|
||||
groupes.get(mois).push(entree);
|
||||
};
|
||||
|
||||
for (const projet of planning.projects) {
|
||||
if (projet.hidden || !projetFiltre(projet, tagsActifs)) continue;
|
||||
if (!projetFiltreEtat(projet, etatsActifs)) continue;
|
||||
|
||||
for (const phase of projet.phases) {
|
||||
const mois = moisDe(phase.start);
|
||||
if (!groupes.has(mois)) groupes.set(mois, []);
|
||||
groupes.get(mois).push({ projet, phase });
|
||||
poser(moisDe(phase.start), { projet, phase });
|
||||
}
|
||||
|
||||
// Un projet sans horizon n'a rien à quoi se rattacher : il se signale déjà
|
||||
// dans la colonne de la frise, où sa pastille « à dater » réclame une date.
|
||||
if (!projet.phases.length && projet.horizon) {
|
||||
poser(projet.horizon.start, { projet, horizon: projet.horizon });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,18 +845,40 @@ export function phasesParMois(planning, tagsActifs = []) {
|
||||
mois,
|
||||
entrees: groupes.get(mois).sort(
|
||||
(a, b) =>
|
||||
versUTC(a.phase.start) - versUTC(b.phase.start) ||
|
||||
versUTC(debutEntree(a)) - versUTC(debutEntree(b)) ||
|
||||
a.projet.name.localeCompare(b.projet.name, 'fr') ||
|
||||
a.phase.name.localeCompare(b.phase.name, 'fr')
|
||||
nomEntree(a).localeCompare(nomEntree(b), 'fr')
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Date à laquelle une entrée commence, pour le tri. Un horizon n'ayant pas de
|
||||
* jour, il prend le premier du mois — ce qui le place en tête des entrées de son
|
||||
* mois, à sa place : c'est ce qui est le moins arrêté qui ouvre la liste.
|
||||
*/
|
||||
function debutEntree(entree) {
|
||||
return entree.phase ? entree.phase.start : premierJourDuMois(entree.horizon.start);
|
||||
}
|
||||
|
||||
function nomEntree(entree) {
|
||||
return entree.phase ? entree.phase.name : entree.projet.name;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function creerProjet(planning, nom, tags = []) {
|
||||
/**
|
||||
* Un projet naît **envisagé** : sans phase, avec pour seul ancrage un horizon
|
||||
* d'un mois, celui où on le crée.
|
||||
*
|
||||
* Ce mois par défaut n'est pas une prévision, c'est une position de départ — la
|
||||
* barre paraît sous les yeux, là où on regarde, et se repousse aussitôt. Naître
|
||||
* sans horizon aurait été plus honnête mais invisible, et la décision 27 pose
|
||||
* qu'un projet envisagé n'est jamais hors du temps.
|
||||
*/
|
||||
export function creerProjet(planning, nom, tags = [], mois = moisCourant()) {
|
||||
const id = fabriquerId(nom, planning.projects.map((p) => p.id));
|
||||
return {
|
||||
id,
|
||||
@@ -633,10 +888,103 @@ export function creerProjet(planning, nom, tags = []) {
|
||||
collapsed: false,
|
||||
hidden: false,
|
||||
notes: '',
|
||||
horizon: { start: mois, end: mois },
|
||||
phases: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose l'horizon d'un projet. Les bornes se remettent d'aplomb plutôt que d'être
|
||||
* refusées : la saisie se fait au fil de la frappe, et un instant où la fin
|
||||
* précède le début est l'état normal de quelqu'un qui écrit son intervalle.
|
||||
*/
|
||||
export function definirHorizon(projet, debut, fin = debut) {
|
||||
return { ...projet, horizon: { start: debut, end: fin < debut ? debut : fin } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Repousse — ou avance — l'horizon d'un nombre de mois, largeur conservée.
|
||||
*
|
||||
* C'est le seul geste qui déplace un horizon. La barre d'un projet envisagé ne
|
||||
* se glisse pas au jour : l'imprécision doit rester visible, et l'interdire
|
||||
* physiquement vaut mieux que de compter sur une convention (décision 27).
|
||||
*/
|
||||
export function decalerHorizon(projet, mois) {
|
||||
if (!projet.horizon) return projet;
|
||||
return {
|
||||
...projet,
|
||||
horizon: {
|
||||
start: ajouterMois(projet.horizon.start, mois),
|
||||
end: ajouterMois(projet.horizon.end, mois),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Acte la clôture d'un projet, et **termine toutes ses phases avec lui**.
|
||||
*
|
||||
* Livré n'est pas clos : c'est quelqu'un qui le prononce, même quand une phase
|
||||
* traîne encore en « à venir ». Mais prononcer la clôture d'un projet dont une
|
||||
* phase resterait bloquée laisserait la frise se contredire — la barre
|
||||
* cumulative garderait ses segments pâles sous une ligne éteinte, et la vue par
|
||||
* mois annoncerait des tâches à venir dans un projet fini.
|
||||
*
|
||||
* L'opération est donc **destructive** et ne se défait pas : `rouvrirProjet` ne
|
||||
* peut pas savoir laquelle des phases était bloquée et laquelle était à venir.
|
||||
* D'où la confirmation demandée par l'interface dès qu'une phase resterait à
|
||||
* changer (docs/decisions.md, section 27).
|
||||
*/
|
||||
export function terminerProjet(projet, date = aujourdhui()) {
|
||||
return {
|
||||
...projet,
|
||||
completedDate: date,
|
||||
discardedDate: undefined,
|
||||
phases: projet.phases.map((phase) =>
|
||||
phase.status === 'done' ? phase : { ...phase, status: 'done' }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Vrai si clore le projet changerait le statut d'au moins une de ses phases. */
|
||||
export function clotureEcraseDesStatuts(projet) {
|
||||
return projet.phases.some((phase) => phase.status !== 'done');
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule la clôture : le projet retourne à l'état que ses phases commandent.
|
||||
*
|
||||
* Les statuts, eux, restent à « terminé » — ils ont été écrasés à la clôture, et
|
||||
* rien ne dit ce qu'ils valaient avant. C'est le prix de l'opération, et la
|
||||
* raison pour laquelle elle se confirme.
|
||||
*/
|
||||
export function rouvrirProjet(projet) {
|
||||
return { ...projet, completedDate: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Écarte un projet. Ce n'est pas le supprimer : il quitte la frise mais garde
|
||||
* tout, notes comprises — la raison pour laquelle on a dit non a de la valeur le
|
||||
* jour où la même idée revient.
|
||||
*
|
||||
* La date est le seul ancrage temporel qui reste à un projet jamais engagé, dont
|
||||
* l'horizon a cessé d'être une prévision. Elle est posée, jamais saisie.
|
||||
*/
|
||||
export function ecarterProjet(projet, date = aujourdhui()) {
|
||||
return { ...projet, discardedDate: date, completedDate: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort un projet du cimetière. Aucune destination à choisir : puisque les états
|
||||
* se déduisent, retirer l'écartement suffit — le projet retrouve seul l'état que
|
||||
* ses données commandent, engagé s'il a des phases, envisagé sinon.
|
||||
*
|
||||
* Son horizon, lui, est resté celui d'avant : il se signalera aussitôt comme
|
||||
* dépassé, et c'est voulu.
|
||||
*/
|
||||
export function reanimerProjet(projet) {
|
||||
return { ...projet, discardedDate: undefined };
|
||||
}
|
||||
|
||||
export function creerPhase(projet, nom, debut, fin, options = {}) {
|
||||
const id = fabriquerId(nom, projet.phases.map((p) => p.id));
|
||||
const jalon = Boolean(options.milestone);
|
||||
|
||||
121
js/mois.js
121
js/mois.js
@@ -19,15 +19,19 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
LIBELLES_ETAT,
|
||||
LIBELLES_STATUT,
|
||||
aujourdhui,
|
||||
duree,
|
||||
entreesParMois,
|
||||
etatProjet,
|
||||
formaterDateLongue,
|
||||
formaterHorizon,
|
||||
formaterMois,
|
||||
formaterPeriode,
|
||||
moisDe,
|
||||
phasesParMois,
|
||||
projetFiltre,
|
||||
projetFiltreEtat,
|
||||
} from './model.js';
|
||||
import { aplatirMarkdown } from './markdown.js';
|
||||
import { rendreMarkdown } from './notes.js';
|
||||
@@ -38,13 +42,13 @@ import { pastillesTags } from './tags.js';
|
||||
*
|
||||
* @param {HTMLElement} conteneur
|
||||
* @param {object} planning
|
||||
* @param {object} options { tagsActifs }
|
||||
* @param {object} options { tagsActifs, etatsActifs }
|
||||
*/
|
||||
export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) {
|
||||
const groupes = phasesParMois(planning, tagsActifs);
|
||||
export function rendreMois(conteneur, planning, { tagsActifs = [], etatsActifs = [] } = {}) {
|
||||
const groupes = entreesParMois(planning, tagsActifs, etatsActifs);
|
||||
|
||||
if (!groupes.length) {
|
||||
conteneur.replaceChildren(messageVide(planning, tagsActifs));
|
||||
conteneur.replaceChildren(messageVide(planning, tagsActifs, etatsActifs));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,8 +69,10 @@ export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) {
|
||||
* Le cas des projets masqués mérite son message : rien à l'écran ne rappelle
|
||||
* l'œil ici, puisque la liste n'a pas de colonne de libellés où le rouvrir.
|
||||
*/
|
||||
function messageVide(planning, tagsActifs) {
|
||||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
||||
function messageVide(planning, tagsActifs, etatsActifs = []) {
|
||||
const retenus = planning.projects.filter(
|
||||
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
|
||||
);
|
||||
|
||||
const paragraphe = document.createElement('p');
|
||||
paragraphe.className = 'mois__vide';
|
||||
@@ -120,18 +126,89 @@ function construireGroupe({ mois, entrees }, moisCourant, tagsActifs) {
|
||||
return section;
|
||||
}
|
||||
|
||||
/** « 4 tâches · 2 jalons », en taisant celui des deux qui vaut zéro. */
|
||||
/** « 4 tâches · 2 jalons · 1 envisagé », en taisant ce qui vaut zéro. */
|
||||
function resumerCompte(entrees) {
|
||||
const jalons = entrees.filter(({ phase }) => phase.milestone).length;
|
||||
const taches = entrees.length - jalons;
|
||||
const sansPhase = entrees.filter(({ horizon }) => horizon);
|
||||
// Les projets sans phase ne se comptent pas ensemble : « 3 envisagés » et
|
||||
// « 3 écartés » ne disent pas du tout la même chose du mois qui vient.
|
||||
const envisages = sansPhase.filter(({ projet }) => etatProjet(projet) === 'considered').length;
|
||||
const ecartes = sansPhase.length - envisages;
|
||||
const jalons = entrees.filter(({ phase }) => phase?.milestone).length;
|
||||
const taches = entrees.length - jalons - sansPhase.length;
|
||||
|
||||
const morceaux = [];
|
||||
if (taches) morceaux.push(`${taches} tâche${taches > 1 ? 's' : ''}`);
|
||||
if (jalons) morceaux.push(`${jalons} jalon${jalons > 1 ? 's' : ''}`);
|
||||
if (envisages) morceaux.push(`${envisages} envisagé${envisages > 1 ? 's' : ''}`);
|
||||
if (ecartes) morceaux.push(`${ecartes} écarté${ecartes > 1 ? 's' : ''}`);
|
||||
return morceaux.join(' · ');
|
||||
}
|
||||
|
||||
function construireEntree({ projet, phase }, tagsActifs) {
|
||||
function construireEntree(entree, tagsActifs) {
|
||||
return entree.horizon
|
||||
? construireEntreeHorizon(entree, tagsActifs)
|
||||
: construireEntreePhase(entree, tagsActifs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ligne d'un projet qui n'a pas de phase : mêmes colonnes que celle d'une phase,
|
||||
* pour que la liste reste une grille qu'on lit en diagonale, mais un contenu qui
|
||||
* dit partout que rien n'est arrêté.
|
||||
*
|
||||
* L'intitulé est en italique et l'emplacement du statut porte l'état : c'est
|
||||
* bien la même question — qu'est-ce qui arrive, et où ça en est — posée à un
|
||||
* objet qui n'a pas encore de dates. Un projet écarté avant d'avoir été engagé
|
||||
* passe par ici lui aussi, et se lit « Écarté » à la même place.
|
||||
*/
|
||||
function construireEntreeHorizon({ projet, horizon }, tagsActifs) {
|
||||
const etat = etatProjet(projet);
|
||||
|
||||
const ligne = document.createElement('li');
|
||||
ligne.className = 'mois-entree mois-entree--envisage';
|
||||
ligne.dataset.etat = etat;
|
||||
ligne.style.setProperty('--couleur-projet', projet.color);
|
||||
if (projet.notes) ligne.classList.add('mois-entree--commentee');
|
||||
ligne.title =
|
||||
`${projet.name}\n\n${LIBELLES_ETAT[etat]} — ${formaterHorizon(horizon)}` +
|
||||
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '');
|
||||
|
||||
ligne.append(triangle(projet.notes, projet.name));
|
||||
|
||||
const marque = document.createElement('span');
|
||||
marque.className = 'mois-entree__marque';
|
||||
ligne.append(marque);
|
||||
|
||||
const dates = document.createElement('span');
|
||||
dates.className = 'mois-entree__dates';
|
||||
// Le mois du groupe est déjà écrit en titre : on n'en répète que la fin quand
|
||||
// l'horizon déborde, comme `formaterPeriode` le fait pour une phase.
|
||||
dates.textContent = horizon.start === horizon.end ? '—' : `→ ${formaterMois(horizon.end)}`;
|
||||
ligne.append(dates);
|
||||
|
||||
const nomProjet = document.createElement('span');
|
||||
nomProjet.className = 'mois-entree__projet';
|
||||
nomProjet.textContent = projet.name;
|
||||
ligne.append(nomProjet);
|
||||
|
||||
ligne.append(pastillesTags(projet.tags, tagsActifs));
|
||||
|
||||
const nom = document.createElement('span');
|
||||
nom.className = 'mois-entree__nom';
|
||||
nom.textContent = formaterHorizon(horizon);
|
||||
ligne.append(nom);
|
||||
|
||||
const statut = document.createElement('span');
|
||||
statut.className = 'mois-entree__statut';
|
||||
statut.dataset.statut = etat;
|
||||
statut.textContent = LIBELLES_ETAT[etat];
|
||||
ligne.append(statut);
|
||||
|
||||
if (projet.notes) ligne.append(notes(projet.notes));
|
||||
|
||||
return ligne;
|
||||
}
|
||||
|
||||
function construireEntreePhase({ projet, phase }, tagsActifs) {
|
||||
const ligne = document.createElement('li');
|
||||
ligne.className = `mois-entree mois-entree--${phase.status}`;
|
||||
ligne.style.setProperty('--couleur-projet', projet.color);
|
||||
@@ -141,7 +218,7 @@ function construireEntree({ projet, phase }, tagsActifs) {
|
||||
if (phase.notes) ligne.classList.add('mois-entree--commentee');
|
||||
ligne.title = infobulle(projet, phase);
|
||||
|
||||
ligne.append(triangle(phase));
|
||||
ligne.append(triangle(phase.notes, phase.name));
|
||||
|
||||
// La marque reprend les formes de la frise — losange pour un jalon, barre
|
||||
// pour une tâche — pour qu'on reconnaisse au même dessin ce qu'on a appris à
|
||||
@@ -179,7 +256,7 @@ function construireEntree({ projet, phase }, tagsActifs) {
|
||||
statut.textContent = LIBELLES_STATUT[phase.status];
|
||||
ligne.append(statut);
|
||||
|
||||
if (phase.notes) ligne.append(notes(phase));
|
||||
if (phase.notes) ligne.append(notes(phase.notes));
|
||||
|
||||
return ligne;
|
||||
}
|
||||
@@ -196,9 +273,13 @@ function construireEntree({ projet, phase }, tagsActifs) {
|
||||
* Les lignes sans notes reçoivent tout de même une case vide : les colonnes de
|
||||
* la grille sont comptées, et il n'y a pas de raison qu'une phase commentée
|
||||
* décale toutes les autres.
|
||||
*
|
||||
* Il reçoit le texte et le nom plutôt que l'objet qui les porte : une ligne de
|
||||
* projet envisagé déplie les notes du projet là où une ligne de phase déplie les
|
||||
* siennes, et le triangle n'a pas à savoir laquelle des deux il sert.
|
||||
*/
|
||||
function triangle(phase) {
|
||||
if (!phase.notes) {
|
||||
function triangle(texte, nom) {
|
||||
if (!texte) {
|
||||
const vide = document.createElement('span');
|
||||
vide.className = 'mois-entree__plier';
|
||||
return vide;
|
||||
@@ -210,9 +291,9 @@ function triangle(phase) {
|
||||
bouton.dataset.action = 'notes';
|
||||
bouton.setAttribute('aria-expanded', 'false');
|
||||
// La ligne entière déplie, mais elle n'est pas focusable : ce bouton est le
|
||||
// seul chemin clavier vers les notes, il lui faut donc un nom qui dise de
|
||||
// quelle phase il parle.
|
||||
bouton.setAttribute('aria-label', `Notes de ${phase.name}`);
|
||||
// seul chemin clavier vers les notes, il lui faut donc un nom qui dise de quoi
|
||||
// il parle.
|
||||
bouton.setAttribute('aria-label', `Notes de ${nom}`);
|
||||
bouton.title = 'Afficher les notes';
|
||||
bouton.textContent = '▶';
|
||||
return bouton;
|
||||
@@ -230,10 +311,10 @@ function triangle(phase) {
|
||||
* qu'on n'y casse rien. On les voit cochées ou non, ce qui est justement ce
|
||||
* qu'on vient y chercher quand on fait le point.
|
||||
*/
|
||||
function notes(phase) {
|
||||
function notes(texte) {
|
||||
const bloc = document.createElement('div');
|
||||
bloc.className = 'mois-entree__notes';
|
||||
bloc.append(rendreMarkdown(phase.notes));
|
||||
bloc.append(rendreMarkdown(texte));
|
||||
bloc.hidden = true;
|
||||
return bloc;
|
||||
}
|
||||
|
||||
172
js/projet.js
172
js/projet.js
@@ -11,6 +11,20 @@
|
||||
*/
|
||||
|
||||
import { creerBlocNotes } from './notes.js';
|
||||
import {
|
||||
LIBELLES_ETAT,
|
||||
decalerHorizon,
|
||||
definirHorizon,
|
||||
ecarterProjet,
|
||||
etatProjet,
|
||||
formaterDateLongue,
|
||||
formaterHorizon,
|
||||
moisValide,
|
||||
projetADater,
|
||||
reanimerProjet,
|
||||
rouvrirProjet,
|
||||
terminerProjet,
|
||||
} from './model.js';
|
||||
|
||||
export function creerPanneauProjet(refs, outils, rappels) {
|
||||
const {
|
||||
@@ -21,6 +35,12 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
||||
conteneurCouleurs,
|
||||
suggestions,
|
||||
blocSuggestions,
|
||||
etat,
|
||||
blocHorizon,
|
||||
crans,
|
||||
aideHorizon,
|
||||
terminer,
|
||||
ecarter,
|
||||
notes,
|
||||
supprimer,
|
||||
fermer,
|
||||
@@ -106,6 +126,101 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
||||
blocSuggestions.hidden = tagsConnus.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizon lu dans les deux champs de mois.
|
||||
*
|
||||
* Une borne laissée vide prend la valeur de l'autre : on saisit souvent un
|
||||
* mois unique, et exiger de le taper deux fois serait une formalité. Les deux
|
||||
* vides valent « pas d'horizon » — un état légal, celui d'un projet hérité
|
||||
* d'un fichier en version 4, qui se signalera comme réclamant une date.
|
||||
*
|
||||
* Le champ `type="month"` n'est pas un sélecteur partout : sous Firefox c'est
|
||||
* un champ texte, d'où la vérification plutôt qu'une confiance faite au
|
||||
* navigateur.
|
||||
*/
|
||||
function horizonSaisi() {
|
||||
const debut = formulaire.horizonStart.value;
|
||||
const fin = formulaire.horizonEnd.value;
|
||||
|
||||
if (!moisValide(debut) && !moisValide(fin)) return undefined;
|
||||
if (!moisValide(debut)) return { start: fin, end: fin };
|
||||
if (!moisValide(fin)) return { start: debut, end: debut };
|
||||
return definirHorizon({}, debut, fin).horizon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le bloc « Horizon » ne paraît que pour un projet envisagé.
|
||||
*
|
||||
* Dès qu'une phase existe, ce sont les phases qui commandent l'emprise et
|
||||
* l'horizon n'est plus qu'une valeur de repli conservée en silence : la
|
||||
* proposer à la saisie laisserait croire qu'elle déplace encore quelque chose.
|
||||
*/
|
||||
function peindreCycleDeVie(projet) {
|
||||
const nom = etatProjet(projet);
|
||||
const envisage = nom === 'considered';
|
||||
|
||||
etat.textContent = LIBELLES_ETAT[nom] + legendeEtat(projet, nom);
|
||||
etat.dataset.etat = nom;
|
||||
|
||||
// Chaque acte se présente dans un sens ou dans l'autre selon où en est le
|
||||
// projet : une commande qui bascule plutôt que deux dont l'une serait
|
||||
// toujours grise.
|
||||
terminer.textContent = nom === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé';
|
||||
// Un projet envisagé n'a rien à clore, et un écarté sort du cimetière par
|
||||
// « Remettre en jeu » avant tout autre chose.
|
||||
terminer.disabled = nom === 'considered' || nom === 'discarded';
|
||||
terminer.title = terminer.disabled
|
||||
? nom === 'considered'
|
||||
? "Un projet envisagé n'a pas encore commencé."
|
||||
: "Remettre le projet en jeu d'abord."
|
||||
: nom === 'completed'
|
||||
? 'Les phases resteront terminées : leur statut d’avant est perdu.'
|
||||
: 'Toutes les phases et tous les jalons passeront à « Terminé ».';
|
||||
|
||||
ecarter.textContent = nom === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet';
|
||||
ecarter.title =
|
||||
nom === 'discarded'
|
||||
? "Le projet retrouvera l'état que ses données commandent."
|
||||
: 'Le projet quitte la frise mais garde tout, notes comprises.';
|
||||
|
||||
blocHorizon.hidden = !envisage;
|
||||
if (!envisage) return;
|
||||
|
||||
// On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur au
|
||||
// milieu d'une frappe, comme pour le nom et les tags.
|
||||
const actif = document.activeElement;
|
||||
if (formulaire.horizonStart !== actif) {
|
||||
formulaire.horizonStart.value = projet.horizon?.start ?? '';
|
||||
}
|
||||
if (formulaire.horizonEnd !== actif) {
|
||||
formulaire.horizonEnd.value = projet.horizon?.end ?? '';
|
||||
}
|
||||
|
||||
const aDater = projetADater(projet);
|
||||
aideHorizon.textContent = aDater
|
||||
? projet.horizon
|
||||
? 'Cet horizon est passé : le repousser, ou écarter le projet.'
|
||||
: "Sans horizon, ce projet n'est situé nulle part sur la frise."
|
||||
: 'Ajouter une phase engage le projet et lui donne des dates fermes.';
|
||||
aideHorizon.dataset.alerte = String(aDater);
|
||||
|
||||
// Rien à décaler tant qu'aucun horizon n'est posé : les crans conservent la
|
||||
// largeur d'un intervalle, ils n'en inventent pas.
|
||||
for (const bouton of crans.children) bouton.disabled = !projet.horizon;
|
||||
}
|
||||
|
||||
/** Ce qui suit le nom de l'état : l'horizon, ou la date de l'acte. */
|
||||
function legendeEtat(projet, nom) {
|
||||
if (nom === 'considered') {
|
||||
return projet.horizon ? ` — ${formaterHorizon(projet.horizon)}` : ' — sans horizon';
|
||||
}
|
||||
if (nom === 'engaged') {
|
||||
return ` — ${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}`;
|
||||
}
|
||||
if (nom === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
|
||||
return ` le ${formaterDateLongue(projet.discardedDate)}`;
|
||||
}
|
||||
|
||||
function remplir(projet) {
|
||||
titre.textContent = projet.name;
|
||||
formulaire.name.value = projet.name;
|
||||
@@ -113,6 +228,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
||||
blocNotes.definir(projet.notes, { reinitialiserMode: true });
|
||||
peindrePalette(projet.color);
|
||||
peindreSuggestions();
|
||||
peindreCycleDeVie(projet);
|
||||
afficherErreur(null);
|
||||
}
|
||||
|
||||
@@ -139,6 +255,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
||||
name: nom,
|
||||
tags: tagsSaisis(),
|
||||
notes: formulaire.notes.value,
|
||||
// L'horizon d'un projet engagé n'est plus modifiable, mais il n'est pas
|
||||
// effacé pour autant : c'est ce qui permet à un projet ayant perdu sa
|
||||
// dernière phase de le retrouver plutôt qu'une enveloppe reconstituée.
|
||||
horizon: etatProjet(projetCourant) === 'considered' ? horizonSaisi() : projetCourant.horizon,
|
||||
...forces,
|
||||
};
|
||||
|
||||
@@ -148,11 +268,21 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
||||
suivant.tags.length === projetCourant.tags.length &&
|
||||
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
|
||||
|
||||
const memeHorizon =
|
||||
suivant.horizon?.start === projetCourant.horizon?.start &&
|
||||
suivant.horizon?.end === projetCourant.horizon?.end;
|
||||
|
||||
if (
|
||||
suivant.name === projetCourant.name &&
|
||||
suivant.color === projetCourant.color &&
|
||||
suivant.notes === projetCourant.notes &&
|
||||
memesTags
|
||||
// Les deux dates d'acte comptent ici comme les autres champs : sans elles,
|
||||
// déclarer un projet terminé ne changerait aucune des valeurs comparées et
|
||||
// la modification serait tue.
|
||||
suivant.completedDate === projetCourant.completedDate &&
|
||||
suivant.discardedDate === projetCourant.discardedDate &&
|
||||
memesTags &&
|
||||
memeHorizon
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -161,6 +291,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
||||
titre.textContent = suivant.name;
|
||||
peindrePalette(suivant.color);
|
||||
peindreSuggestions();
|
||||
peindreCycleDeVie(suivant);
|
||||
rappels.surModification(idCourant, suivant);
|
||||
}
|
||||
|
||||
@@ -168,6 +299,41 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
||||
formulaire.addEventListener('change', () => appliquer());
|
||||
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
|
||||
|
||||
// Repousser par crans est le seul geste qui déplace un horizon : la barre de
|
||||
// la frise, elle, ne se glisse pas. Les trois boutons couvrent ce qu'on fait
|
||||
// réellement — reculer d'un trimestre, en gagner un, renvoyer à l'an prochain.
|
||||
crans.addEventListener('click', (evenement) => {
|
||||
const bouton = evenement.target.closest('[data-cran]');
|
||||
if (!bouton || !projetCourant?.horizon) return;
|
||||
appliquer({ horizon: decalerHorizon(projetCourant, Number(bouton.dataset.cran)).horizon });
|
||||
});
|
||||
|
||||
// Les deux actes passent par `appliquer`, comme le reste du panneau : c'est
|
||||
// lui qui compare, remonte la modification et repeint.
|
||||
terminer.addEventListener('click', async () => {
|
||||
if (!projetCourant) return;
|
||||
if (etatProjet(projetCourant) === 'completed') {
|
||||
appliquer(rouvrirProjet(projetCourant));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clore termine aussi les phases : la confirmation vit dans app.js, qui la
|
||||
// partage avec l'entrée du menu ⋯ — le même acte ne doit pas prévenir depuis
|
||||
// un endroit et se taire depuis l'autre.
|
||||
const cible = idCourant;
|
||||
if (!(await rappels.demanderCloture(projetCourant))) return;
|
||||
// Le panneau a pu être fermé ou changer de projet pendant la confirmation.
|
||||
if (idCourant !== cible || !projetCourant) return;
|
||||
|
||||
appliquer(terminerProjet(projetCourant));
|
||||
});
|
||||
|
||||
ecarter.addEventListener('click', () => {
|
||||
if (!projetCourant) return;
|
||||
const nom = etatProjet(projetCourant);
|
||||
appliquer(nom === 'discarded' ? reanimerProjet(projetCourant) : ecarterProjet(projetCourant));
|
||||
});
|
||||
|
||||
supprimer.addEventListener('click', async () => {
|
||||
if (!idCourant) return;
|
||||
const cible = idCourant;
|
||||
@@ -222,6 +388,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
||||
if (formulaire.notes !== actif) blocNotes.definir(projet.notes);
|
||||
peindrePalette(projet.color);
|
||||
peindreSuggestions();
|
||||
// L'état a pu changer sans passer par ce panneau : ajouter la première
|
||||
// phase engage le projet, et le bloc « Horizon » doit disparaître au même
|
||||
// instant que la barre pâle sur la frise.
|
||||
peindreCycleDeVie(projet);
|
||||
},
|
||||
|
||||
fermer() {
|
||||
|
||||
116
js/timeline.js
116
js/timeline.js
@@ -12,20 +12,26 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
LIBELLES_ETAT,
|
||||
LIBELLES_STATUT,
|
||||
MOIS_COURTS,
|
||||
MOIS_LONGS,
|
||||
ajouterJours,
|
||||
aujourdhui,
|
||||
bornesHorizon,
|
||||
bornesProjet,
|
||||
bornesReference,
|
||||
deriveProjet,
|
||||
dimancheDeLaSemaine,
|
||||
ecartJours,
|
||||
etatProjet,
|
||||
formaterDateLongue,
|
||||
formaterHorizon,
|
||||
formulerDerive,
|
||||
lundiDeLaSemaine,
|
||||
projetADater,
|
||||
projetFiltre,
|
||||
projetFiltreEtat,
|
||||
semaineISO,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
@@ -85,19 +91,28 @@ export function creerEchelle(fenetre) {
|
||||
/**
|
||||
* Reconstruit toute la frise.
|
||||
*
|
||||
* Deux tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
|
||||
* Trois tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
|
||||
* **filtre par tags** retire le projet de la vue entière, libellé compris : il
|
||||
* répond à « je ne regarde que les projets clients ». L'**œil** (`hidden`) garde
|
||||
* la ligne de libellé, grisée, et n'ôte que les barres : il répond à « je sais
|
||||
* que ce projet existe, mais il encombre ma frise ».
|
||||
* répond à « je ne regarde que les projets clients ». Le **filtre par état** en
|
||||
* fait autant, mais en élargissant à chaque case cochée plutôt qu'en resserrant,
|
||||
* un projet n'ayant qu'un état : il répond à « je ne veux pas voir le
|
||||
* cimetière ». L'**œil** (`hidden`) garde la ligne de libellé, grisée, et n'ôte
|
||||
* que les barres : il répond à « je sais que ce projet existe, mais il encombre
|
||||
* ma frise ».
|
||||
*
|
||||
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
|
||||
* @param {object} planning l'état courant
|
||||
* @param {object} options { fenetre, curseur, tagsActifs }
|
||||
* @param {object} options { fenetre, curseur, tagsActifs, etatsActifs }
|
||||
*/
|
||||
export function rendre(refs, planning, { fenetre, curseur, tagsActifs = [] }) {
|
||||
export function rendre(
|
||||
refs,
|
||||
planning,
|
||||
{ fenetre, curseur, tagsActifs = [], etatsActifs = [] }
|
||||
) {
|
||||
const echelle = creerEchelle(fenetre);
|
||||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
||||
const retenus = planning.projects.filter(
|
||||
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
|
||||
);
|
||||
const visibles = retenus.filter((projet) => !projet.hidden);
|
||||
|
||||
refs.entete.style.width = `${echelle.largeur}px`;
|
||||
@@ -303,6 +318,8 @@ function construireLibelles(projets, tagsActifs, curseur) {
|
||||
}
|
||||
|
||||
function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
||||
const etat = etatProjet(projet);
|
||||
|
||||
const ligne = document.createElement('div');
|
||||
ligne.className =
|
||||
'libelle-projet' +
|
||||
@@ -310,6 +327,10 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
||||
(sousCurseur ? ' libelle-projet--curseur' : '');
|
||||
ligne.style.setProperty('--couleur-projet', projet.color);
|
||||
ligne.dataset.projet = projet.id;
|
||||
// L'état se peint en CSS plutôt qu'en classes empilées : il est exclusif, et
|
||||
// un attribut qui ne peut porter qu'une valeur le dit mieux que quatre
|
||||
// modificateurs dont un seul doit être posé à la fois.
|
||||
ligne.dataset.etat = etat;
|
||||
|
||||
const plier = document.createElement('button');
|
||||
plier.className = 'plier';
|
||||
@@ -333,6 +354,7 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
||||
// reste le seul endroit où les deux se lisent toujours en entier.
|
||||
nom.title =
|
||||
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
|
||||
`\n${LIBELLES_ETAT[etat]}${legendeEtat(projet, etat)}` +
|
||||
// Les notes du projet passent avant l'invite : c'est le contenu, l'invite
|
||||
// n'est qu'un mode d'emploi. Le libellé est le seul endroit de la frise où
|
||||
// elles se lisent — un projet n'a pas de barre à lui.
|
||||
@@ -345,6 +367,20 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
||||
|
||||
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
|
||||
|
||||
// Un projet envisagé dont l'horizon est passé — ou qui n'en a jamais eu — le
|
||||
// dit sur sa ligne. C'est le garde-fou qui empêche les envisagés de devenir
|
||||
// une décharge : on ne peut pas ne pas le voir en parcourant la colonne, et
|
||||
// il ne s'éteint qu'en repoussant l'horizon ou en écartant le projet.
|
||||
if (projetADater(projet)) {
|
||||
const alerte = document.createElement('span');
|
||||
alerte.className = 'libelle-projet__adater';
|
||||
alerte.textContent = 'à dater';
|
||||
alerte.title = projet.horizon
|
||||
? `Horizon dépassé (${formaterHorizon(projet.horizon)}) — repousser ou écarter.`
|
||||
: "Aucun horizon : ce projet n'est situé nulle part sur la frise.";
|
||||
ligne.append(alerte);
|
||||
}
|
||||
|
||||
const derive = formulerDerive(deriveProjet(projet));
|
||||
if (derive) {
|
||||
const etiquette = document.createElement('span');
|
||||
@@ -395,6 +431,19 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
||||
return ligne;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce qui suit le nom de l'état dans une infobulle : l'horizon d'un projet
|
||||
* envisagé, la date de l'acte pour les deux qu'on prononce.
|
||||
*/
|
||||
function legendeEtat(projet, etat) {
|
||||
if (etat === 'considered') {
|
||||
return projet.horizon ? ` — ${formaterHorizon(projet.horizon)}` : ' — sans horizon';
|
||||
}
|
||||
if (etat === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
|
||||
if (etat === 'discarded') return ` le ${formaterDateLongue(projet.discardedDate)}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Couloirs
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -411,12 +460,16 @@ function construireCouloirs(projets, echelle, curseur) {
|
||||
const couloirProjet = document.createElement('div');
|
||||
couloirProjet.className = `couloir couloir--projet${surLeProjet ? ' couloir--curseur' : ''}`;
|
||||
couloirProjet.style.setProperty('--couleur-projet', projet.color);
|
||||
couloirProjet.dataset.etat = etatProjet(projet);
|
||||
|
||||
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
|
||||
// coiffe les phases du projet et garde sa forme d'ensemble sous les yeux
|
||||
// pendant qu'on travaille sur le détail. Sans elle, il fallait replier pour
|
||||
// savoir où en était le projet entier, donc perdre la vue qu'on éditait.
|
||||
const barre = construireBarreCumulative(projet, echelle);
|
||||
//
|
||||
// Un projet envisagé n'a pas de phase à cumuler : c'est son horizon qui
|
||||
// occupe la ligne, et rien d'autre ne s'affiche sous elle.
|
||||
const barre = construireBarreCumulative(projet, echelle) ?? construireHorizon(projet, echelle);
|
||||
if (barre) couloirProjet.append(barre);
|
||||
const fantome = construireFantome(projet, echelle);
|
||||
if (fantome) couloirProjet.append(fantome);
|
||||
@@ -554,6 +607,53 @@ function construireBarreCumulative(projet, echelle) {
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Barre d'horizon d'un projet envisagé : pâle, aux bords fondus.
|
||||
*
|
||||
* Elle se glisse et se redimensionne comme une barre de phase, mais **accrochée
|
||||
* au mois** là où celle d'une phase l'est au lundi. C'est le même parti pris
|
||||
* qu'à la décision 9, poussé d'un cran : on ne pose pas un horizon au jour près,
|
||||
* donc le geste ne le permet pas — tandis qu'interdire le glisser tout court, ce
|
||||
* qui fut d'abord essayé, obligeait à passer par le panneau pour un ajustement
|
||||
* qu'on veut faire en regardant les voisins.
|
||||
*
|
||||
* Sa largeur *est* l'incertitude : un projet vague occupe une large plage floue,
|
||||
* un projet mûr un seul mois. Rien à écrire pour le dire, l'œil le lit.
|
||||
*/
|
||||
function construireHorizon(projet, echelle) {
|
||||
const bornes = bornesHorizon(projet);
|
||||
if (!bornes) return null;
|
||||
|
||||
const barre = document.createElement('div');
|
||||
barre.className = 'barre-horizon';
|
||||
barre.style.left = `${echelle.x(bornes.start)}px`;
|
||||
barre.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
|
||||
barre.dataset.projet = projet.id;
|
||||
// Un rôle distinct de celui d'une phase : `drag.js` y reconnaît qu'il déplace
|
||||
// des mois et non des jours, et qu'il n'y a pas de phase à aller chercher.
|
||||
barre.dataset.role = 'horizon';
|
||||
if (projetADater(projet)) barre.classList.add('barre-horizon--depasse');
|
||||
barre.title =
|
||||
`${projet.name}\nEnvisagé — ${formaterHorizon(projet.horizon)}` +
|
||||
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '') +
|
||||
'\n\nSe glisse au mois. Ajouter une phase engage le projet et lui donne des dates fermes.';
|
||||
|
||||
const nom = document.createElement('span');
|
||||
nom.className = 'barre__nom';
|
||||
nom.textContent = projet.name;
|
||||
barre.append(nom);
|
||||
|
||||
for (const bord of ['debut', 'fin']) {
|
||||
const poignee = document.createElement('div');
|
||||
poignee.className = `poignee poignee--${bord}`;
|
||||
poignee.dataset.role = 'poignee';
|
||||
poignee.dataset.bord = bord;
|
||||
barre.append(poignee);
|
||||
}
|
||||
|
||||
return barre;
|
||||
}
|
||||
|
||||
/** Barre fantôme du projet, reflétant l'étendue de référence. */
|
||||
function construireFantome(projet, echelle) {
|
||||
const reference = bornesReference(projet);
|
||||
|
||||
@@ -6,37 +6,56 @@ import {
|
||||
MAX_LONGUEUR_TAG,
|
||||
VERSION_FORMAT,
|
||||
ajouterJours,
|
||||
ajouterMois,
|
||||
ajouterPhase,
|
||||
bornesHorizon,
|
||||
bornesPlanning,
|
||||
bornesProjet,
|
||||
bornesReference,
|
||||
cleTag,
|
||||
clotureEcraseDesStatuts,
|
||||
creerPhase,
|
||||
creerProjet,
|
||||
dateValide,
|
||||
decalerHorizon,
|
||||
definirHorizon,
|
||||
deplacerPhase,
|
||||
dernierJourDuMois,
|
||||
dimancheDeLaSemaine,
|
||||
deriveProjet,
|
||||
duree,
|
||||
ecarterProjet,
|
||||
ecartJours,
|
||||
ecartMois,
|
||||
empriseProjet,
|
||||
entreesParMois,
|
||||
etatProjet,
|
||||
fabriquerId,
|
||||
figerReference,
|
||||
formaterDateLongue,
|
||||
formaterHorizon,
|
||||
formaterMois,
|
||||
formaterPeriode,
|
||||
formulerDerive,
|
||||
libererReference,
|
||||
lundiDeLaSemaine,
|
||||
modifierPhase,
|
||||
moisValide,
|
||||
normaliserTags,
|
||||
phasesParMois,
|
||||
planningVide,
|
||||
pourEcriture,
|
||||
premierJourDuMois,
|
||||
projetADater,
|
||||
projetFiltre,
|
||||
projetFiltreEtat,
|
||||
reanimerProjet,
|
||||
redimensionnerPhase,
|
||||
remplacerPhase,
|
||||
rouvrirProjet,
|
||||
semaineISO,
|
||||
supprimerPhase,
|
||||
teinteTag,
|
||||
terminerProjet,
|
||||
tousLesTags,
|
||||
validerPlanning,
|
||||
} from '../js/model.js';
|
||||
@@ -164,6 +183,57 @@ describe('dates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- mois -------------------------------------------------------------------
|
||||
|
||||
describe('mois', () => {
|
||||
test('moisValide accepte « AAAA-MM » et rejette le reste', () => {
|
||||
assert.ok(moisValide('2027-03'));
|
||||
assert.ok(moisValide('2027-12'));
|
||||
|
||||
assert.ok(!moisValide('2027-13'), 'mois inexistant');
|
||||
assert.ok(!moisValide('2027-00'), 'mois zéro');
|
||||
assert.ok(!moisValide('2027-3'), 'sans zéro de remplissage');
|
||||
assert.ok(!moisValide('2027-03-01'), 'une date n est pas un mois');
|
||||
assert.ok(!moisValide(''));
|
||||
assert.ok(!moisValide(null));
|
||||
assert.ok(!moisValide(202703));
|
||||
});
|
||||
|
||||
test('les bornes d un mois encadrent ses jours, bissextiles comprises', () => {
|
||||
assert.equal(premierJourDuMois('2027-03'), '2027-03-01');
|
||||
assert.equal(dernierJourDuMois('2027-03'), '2027-03-31');
|
||||
assert.equal(dernierJourDuMois('2027-04'), '2027-04-30');
|
||||
assert.equal(dernierJourDuMois('2028-02'), '2028-02-29', 'année bissextile');
|
||||
assert.equal(dernierJourDuMois('2027-02'), '2027-02-28');
|
||||
assert.equal(dernierJourDuMois('2026-12'), '2026-12-31', 'report d année');
|
||||
});
|
||||
|
||||
test('ajouterMois traverse les années dans les deux sens', () => {
|
||||
assert.equal(ajouterMois('2026-11', 3), '2027-02');
|
||||
assert.equal(ajouterMois('2026-02', -3), '2025-11');
|
||||
assert.equal(ajouterMois('2026-12', 1), '2027-01');
|
||||
assert.equal(ajouterMois('2026-01', -1), '2025-12');
|
||||
assert.equal(ajouterMois('2026-08', 0), '2026-08');
|
||||
assert.equal(ajouterMois('2026-08', 24), '2028-08');
|
||||
});
|
||||
|
||||
test('ecartMois est signé et vaut zéro entre deux mois égaux', () => {
|
||||
assert.equal(ecartMois('2026-11', '2027-02'), 3);
|
||||
assert.equal(ecartMois('2027-02', '2026-11'), -3);
|
||||
assert.equal(ecartMois('2026-08', '2026-08'), 0);
|
||||
});
|
||||
|
||||
test('formaterHorizon n écrit l année qu une fois quand elle est partagée', () => {
|
||||
assert.equal(formaterHorizon({ start: '2027-03', end: '2027-03' }), 'mars 2027');
|
||||
assert.equal(formaterHorizon({ start: '2027-03', end: '2027-06' }), 'mars → juin 2027');
|
||||
assert.equal(
|
||||
formaterHorizon({ start: '2026-11', end: '2027-02' }),
|
||||
'novembre 2026 → février 2027'
|
||||
);
|
||||
assert.equal(formaterHorizon(undefined), '');
|
||||
});
|
||||
});
|
||||
|
||||
// --- identifiants -----------------------------------------------------------
|
||||
|
||||
describe('fabriquerId', () => {
|
||||
@@ -458,7 +528,7 @@ describe('bornes', () => {
|
||||
|
||||
// --- regroupement par mois --------------------------------------------------
|
||||
|
||||
describe('phasesParMois', () => {
|
||||
describe('entreesParMois', () => {
|
||||
const planning = {
|
||||
version: VERSION_FORMAT,
|
||||
projects: [
|
||||
@@ -493,7 +563,7 @@ describe('phasesParMois', () => {
|
||||
const noms = (groupe) => groupe.entrees.map(({ phase: p }) => p.name);
|
||||
|
||||
test('rattache chaque phase au seul mois de son début', () => {
|
||||
const groupes = phasesParMois(planning);
|
||||
const groupes = entreesParMois(planning);
|
||||
assert.deepEqual(cles(groupes), ['2026-09', '2026-11']);
|
||||
assert.deepEqual(noms(groupes[0]), ['Cadrage', 'Rédaction']);
|
||||
// « Rédaction » traverse octobre et novembre sans y réapparaître.
|
||||
@@ -501,8 +571,8 @@ describe('phasesParMois', () => {
|
||||
});
|
||||
|
||||
test('ne fabrique pas de mois vide entre deux mois pleins', () => {
|
||||
assert.equal(phasesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
|
||||
assert.equal(cles(phasesParMois(planning)).includes('2026-10'), false);
|
||||
assert.equal(entreesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
|
||||
assert.equal(cles(entreesParMois(planning)).includes('2026-10'), false);
|
||||
});
|
||||
|
||||
test('trie les mois du plus ancien au plus récent, à cheval sur les années', () => {
|
||||
@@ -516,7 +586,7 @@ describe('phasesParMois', () => {
|
||||
}),
|
||||
],
|
||||
};
|
||||
assert.deepEqual(cles(phasesParMois(surDeuxAns)), ['2026-12', '2027-01']);
|
||||
assert.deepEqual(cles(entreesParMois(surDeuxAns)), ['2026-12', '2027-01']);
|
||||
});
|
||||
|
||||
test('trie un mois par date, puis par projet, puis par nom de phase', () => {
|
||||
@@ -537,24 +607,98 @@ describe('phasesParMois', () => {
|
||||
}),
|
||||
],
|
||||
};
|
||||
assert.deepEqual(noms(phasesParMois(memeJour)[0]), ['Amorçage', 'Bilan', 'Atelier']);
|
||||
assert.deepEqual(noms(entreesParMois(memeJour)[0]), ['Amorçage', 'Bilan', 'Atelier']);
|
||||
});
|
||||
|
||||
test('applique le filtre par tags, et écarte les projets masqués', () => {
|
||||
assert.deepEqual(cles(phasesParMois(planning, ['interne'])), ['2026-09']);
|
||||
assert.deepEqual(noms(phasesParMois(planning, ['interne'])[0]), ['Rédaction']);
|
||||
assert.deepEqual(cles(entreesParMois(planning, ['interne'])), ['2026-09']);
|
||||
assert.deepEqual(noms(entreesParMois(planning, ['interne'])[0]), ['Rédaction']);
|
||||
|
||||
const masque = {
|
||||
projects: planning.projects.map((p) =>
|
||||
p.id === 'portail' ? { ...p, hidden: true } : p
|
||||
),
|
||||
};
|
||||
assert.deepEqual(noms(phasesParMois(masque)[0]), ['Cadrage']);
|
||||
assert.deepEqual(noms(entreesParMois(masque)[0]), ['Cadrage']);
|
||||
});
|
||||
|
||||
test('rend une liste vide plutôt que null quand rien ne reste', () => {
|
||||
assert.deepEqual(phasesParMois(planningVide()), []);
|
||||
assert.deepEqual(phasesParMois(planning, ['inconnu']), []);
|
||||
assert.deepEqual(entreesParMois(planningVide()), []);
|
||||
assert.deepEqual(entreesParMois(planning, ['inconnu']), []);
|
||||
});
|
||||
|
||||
test('un projet envisagé paraît au premier mois de son horizon', () => {
|
||||
// Il n'a aucune phase : sans cette entrée, un projet visé pour mars 2027
|
||||
// n'apparaîtrait nulle part dans une liste qui répond pourtant à « qu'est-ce
|
||||
// qui arrive, et quand ».
|
||||
const avecEnvisage = {
|
||||
projects: [projet({ id: 'idee', name: 'Idée', horizon: { start: '2027-03', end: '2027-06' } })],
|
||||
};
|
||||
const groupes = entreesParMois(avecEnvisage);
|
||||
assert.deepEqual(cles(groupes), ['2027-03'], "au début de l'horizon, pas à sa fin");
|
||||
assert.deepEqual(groupes[0].entrees[0].horizon, { start: '2027-03', end: '2027-06' });
|
||||
assert.equal(groupes[0].entrees[0].phase, undefined);
|
||||
});
|
||||
|
||||
test('un projet envisagé sans horizon ne paraît nulle part', () => {
|
||||
// Il n'a rien à quoi se rattacher, et sa pastille « à dater » le signale
|
||||
// déjà dans la colonne de la frise.
|
||||
assert.deepEqual(entreesParMois({ projects: [projet({ id: 'flottant' })] }), []);
|
||||
});
|
||||
|
||||
test('un projet écarté avant d avoir été engagé paraît à son horizon', () => {
|
||||
// C'est l'absence de phases qui compte, pas l'état : sans cela, ouvrir le
|
||||
// cimetière dans la liste ne montrerait que les projets qui avaient démarré.
|
||||
const cimetiere = {
|
||||
projects: [
|
||||
projet({
|
||||
id: 'abandonne',
|
||||
name: 'Abandonné',
|
||||
horizon: { start: '2027-03', end: '2027-03' },
|
||||
discardedDate: '2026-09-14',
|
||||
}),
|
||||
],
|
||||
};
|
||||
const groupes = entreesParMois(cimetiere, [], ['discarded']);
|
||||
assert.deepEqual(cles(groupes), ['2027-03']);
|
||||
assert.equal(etatProjet(groupes[0].entrees[0].projet), 'discarded');
|
||||
|
||||
// Et il reste hors de vue tant qu'on n'a pas coché son état.
|
||||
assert.deepEqual(entreesParMois(cimetiere, [], ['considered', 'engaged']), []);
|
||||
});
|
||||
|
||||
test('un projet engagé ne paraît pas deux fois malgré son horizon conservé', () => {
|
||||
// L'horizon survit à l'engagement, mais ce sont les phases qui parlent
|
||||
// désormais : le lister en plus ferait un doublon.
|
||||
const engage = {
|
||||
projects: [
|
||||
projet({
|
||||
id: 'engage',
|
||||
horizon: { start: '2027-03', end: '2027-06' },
|
||||
phases: [phase({ start: '2026-08-01', end: '2026-08-20' })],
|
||||
}),
|
||||
],
|
||||
};
|
||||
const groupes = entreesParMois(engage);
|
||||
assert.deepEqual(cles(groupes), ['2026-08']);
|
||||
assert.equal(groupes[0].entrees.length, 1);
|
||||
});
|
||||
|
||||
test('un envisagé ouvre son mois, devant les phases qui y démarrent plus tard', () => {
|
||||
const melange = {
|
||||
projects: [
|
||||
projet({ id: 'idee', name: 'Idée', horizon: { start: '2026-09', end: '2026-09' } }),
|
||||
projet({
|
||||
id: 'autre',
|
||||
name: 'Autre',
|
||||
phases: [phase({ id: 'p', name: 'Tâche', start: '2026-09-15', end: '2026-09-20' })],
|
||||
}),
|
||||
],
|
||||
};
|
||||
assert.deepEqual(
|
||||
entreesParMois(melange)[0].entrees.map((e) => e.projet.id),
|
||||
['idee', 'autre']
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -756,3 +900,288 @@ describe('modifications', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- cycle de vie -----------------------------------------------------------
|
||||
|
||||
describe('cycle de vie', () => {
|
||||
const envisage = projet({ horizon: { start: '2027-03', end: '2027-06' } });
|
||||
const engage = projet({ horizon: { start: '2027-03', end: '2027-06' }, phases: [phase()] });
|
||||
|
||||
test('l état se déduit des phases tant que rien n a été prononcé', () => {
|
||||
assert.equal(etatProjet(envisage), 'considered');
|
||||
assert.equal(etatProjet(projet()), 'considered', 'même sans horizon');
|
||||
assert.equal(etatProjet(engage), 'engaged');
|
||||
});
|
||||
|
||||
test('terminer et écarter sont des actes, et ils horodatent', () => {
|
||||
const clos = terminerProjet(engage, '2027-11-08');
|
||||
assert.equal(etatProjet(clos), 'completed');
|
||||
assert.equal(clos.completedDate, '2027-11-08');
|
||||
|
||||
const ecarte = ecarterProjet(envisage, '2026-09-14');
|
||||
assert.equal(etatProjet(ecarte), 'discarded');
|
||||
assert.equal(ecarte.discardedDate, '2026-09-14');
|
||||
});
|
||||
|
||||
test('clore le projet termine toutes ses phases, jalons compris', () => {
|
||||
// Sans cela la frise se contredirait : une ligne éteinte au-dessus de
|
||||
// segments pâles, et des tâches « à venir » listées dans un projet fini.
|
||||
const enCours = projet({
|
||||
phases: [
|
||||
phase({ id: 'a', name: 'A', status: 'done' }),
|
||||
phase({ id: 'b', name: 'B', status: 'blocked' }),
|
||||
phase({ id: 'c', name: 'C', status: 'todo', milestone: true, end: '2026-08-01' }),
|
||||
],
|
||||
});
|
||||
const clos = terminerProjet(enCours, '2027-11-08');
|
||||
assert.deepEqual(
|
||||
clos.phases.map((p) => p.status),
|
||||
['done', 'done', 'done']
|
||||
);
|
||||
assert.equal(clos.phases[2].milestone, true, 'un jalon reste un jalon');
|
||||
});
|
||||
|
||||
test('rouvrir ne rend pas aux phases le statut qu elles avaient', () => {
|
||||
// L'écrasement est le prix assumé de la clôture, et la raison pour laquelle
|
||||
// l'interface la fait confirmer.
|
||||
const enCours = projet({ phases: [phase({ status: 'blocked' })] });
|
||||
const rouvert = rouvrirProjet(terminerProjet(enCours));
|
||||
assert.equal(etatProjet(rouvert), 'engaged');
|
||||
assert.equal(rouvert.phases[0].status, 'done');
|
||||
});
|
||||
|
||||
test('clotureEcraseDesStatuts ne prévient que s il reste quelque chose à écraser', () => {
|
||||
assert.ok(clotureEcraseDesStatuts(projet({ phases: [phase({ status: 'todo' })] })));
|
||||
assert.ok(!clotureEcraseDesStatuts(projet({ phases: [phase({ status: 'done' })] })));
|
||||
assert.ok(!clotureEcraseDesStatuts(projet()), 'aucune phase, rien à écraser');
|
||||
});
|
||||
|
||||
test('des phases toutes finies ne closent pas le projet à elles seules', () => {
|
||||
// Livré n'est pas clos : c'est quelqu'un qui le prononce.
|
||||
const livre = projet({ phases: [phase({ status: 'done' })] });
|
||||
assert.equal(etatProjet(livre), 'engaged');
|
||||
});
|
||||
|
||||
test('un projet ne porte jamais les deux dates à la fois', () => {
|
||||
const clos = terminerProjet(engage, '2027-11-08');
|
||||
const puisEcarte = ecarterProjet(clos, '2027-12-01');
|
||||
assert.equal(puisEcarte.completedDate, undefined);
|
||||
assert.equal(etatProjet(puisEcarte), 'discarded');
|
||||
});
|
||||
|
||||
test("l'écartement l'emporte sur la clôture dans un fichier retouché à la main", () => {
|
||||
const incoherent = projet({ completedDate: '2027-01-01', discardedDate: '2027-02-01' });
|
||||
assert.equal(etatProjet(incoherent), 'discarded');
|
||||
});
|
||||
|
||||
test('réanimer rend au projet l état que ses données commandent', () => {
|
||||
// Aucune destination à choisir : c'est tout l'intérêt d'un état déduit.
|
||||
assert.equal(etatProjet(reanimerProjet(ecarterProjet(engage))), 'engaged');
|
||||
assert.equal(etatProjet(reanimerProjet(ecarterProjet(envisage))), 'considered');
|
||||
});
|
||||
|
||||
test('rouvrir annule la seule clôture, sans retirer ni ajouter de phase', () => {
|
||||
const rouvert = rouvrirProjet(terminerProjet(engage, '2027-11-08'));
|
||||
assert.equal(etatProjet(rouvert), 'engaged');
|
||||
assert.deepEqual(
|
||||
rouvert.phases.map((p) => p.id),
|
||||
engage.phases.map((p) => p.id)
|
||||
);
|
||||
// Leur statut, lui, a été écrasé par la clôture — voir le test dédié.
|
||||
});
|
||||
|
||||
test('un projet qui perd sa dernière phase redevient envisagé', () => {
|
||||
// Le seul retour arrière du cycle, et il n'a rien à déclarer.
|
||||
const vide = supprimerPhase(engage, 'cadrage');
|
||||
assert.equal(etatProjet(vide), 'considered');
|
||||
assert.deepEqual(empriseProjet(vide), { start: '2027-03-01', end: '2027-06-30' });
|
||||
});
|
||||
|
||||
test('creerProjet fait naître un projet envisagé, ancré sur le mois courant', () => {
|
||||
const p = creerProjet(planningVide(), 'Salle des fêtes', [], '2026-08');
|
||||
assert.equal(etatProjet(p), 'considered');
|
||||
assert.deepEqual(p.horizon, { start: '2026-08', end: '2026-08' });
|
||||
});
|
||||
|
||||
test('projetFiltreEtat élargit à chaque état coché, contrairement aux tags', () => {
|
||||
assert.ok(projetFiltreEtat(envisage, ['considered']));
|
||||
assert.ok(!projetFiltreEtat(envisage, ['engaged']));
|
||||
assert.ok(projetFiltreEtat(envisage, ['considered', 'engaged']));
|
||||
assert.ok(projetFiltreEtat(engage, ['considered', 'engaged']));
|
||||
assert.ok(projetFiltreEtat(envisage, []), 'une liste vide laisse tout passer');
|
||||
});
|
||||
|
||||
test('la vue par mois écarte les projets dont l état n est pas coché', () => {
|
||||
const planning = {
|
||||
projects: [
|
||||
projet({ id: 'vivant', name: 'Vivant', phases: [phase({ start: '2026-08-01' })] }),
|
||||
projet({
|
||||
id: 'mort',
|
||||
name: 'Mort',
|
||||
discardedDate: '2026-07-01',
|
||||
phases: [phase({ id: 'p', start: '2026-08-05' })],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const tout = entreesParMois(planning, [], []);
|
||||
assert.equal(tout[0].entrees.length, 2);
|
||||
|
||||
const sansEcartes = entreesParMois(planning, [], ['considered', 'engaged', 'completed']);
|
||||
assert.deepEqual(
|
||||
sansEcartes[0].entrees.map((e) => e.projet.id),
|
||||
['vivant']
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- horizon ----------------------------------------------------------------
|
||||
|
||||
describe('horizon', () => {
|
||||
const horizon = { start: '2027-03', end: '2027-06' };
|
||||
|
||||
test('les bornes d un horizon couvrent ses mois entiers', () => {
|
||||
assert.deepEqual(bornesHorizon(projet({ horizon })), {
|
||||
start: '2027-03-01',
|
||||
end: '2027-06-30',
|
||||
});
|
||||
assert.equal(bornesHorizon(projet()), null);
|
||||
});
|
||||
|
||||
test('les phases commandent l emprise, l horizon n est qu un repli', () => {
|
||||
const avecPhases = projet({ horizon, phases: [phase()] });
|
||||
assert.deepEqual(empriseProjet(avecPhases), { start: '2026-08-01', end: '2026-08-20' });
|
||||
assert.deepEqual(empriseProjet(projet({ horizon })), {
|
||||
start: '2027-03-01',
|
||||
end: '2027-06-30',
|
||||
});
|
||||
assert.equal(empriseProjet(projet()), null, 'ni phase ni horizon');
|
||||
});
|
||||
|
||||
test('definirHorizon remet une fin antérieure au début d aplomb', () => {
|
||||
// La saisie se fait au fil de la frappe : un intervalle à l'envers est un
|
||||
// état de passage, pas une erreur à signaler.
|
||||
assert.deepEqual(definirHorizon(projet(), '2027-06', '2027-03').horizon, {
|
||||
start: '2027-06',
|
||||
end: '2027-06',
|
||||
});
|
||||
assert.deepEqual(definirHorizon(projet(), '2027-03').horizon, {
|
||||
start: '2027-03',
|
||||
end: '2027-03',
|
||||
});
|
||||
});
|
||||
|
||||
test('decalerHorizon conserve la largeur de l intervalle', () => {
|
||||
const decale = decalerHorizon(projet({ horizon }), 6);
|
||||
assert.deepEqual(decale.horizon, { start: '2027-09', end: '2027-12' });
|
||||
assert.deepEqual(decalerHorizon(projet({ horizon }), -3).horizon, {
|
||||
start: '2026-12',
|
||||
end: '2027-03',
|
||||
});
|
||||
assert.deepEqual(decalerHorizon(projet(), 6), projet(), 'sans horizon, rien à décaler');
|
||||
});
|
||||
|
||||
test('un horizon dépassé réclame une date, un horizon à venir non', () => {
|
||||
assert.ok(!projetADater(projet({ horizon }), '2027-04'), 'on est dedans');
|
||||
assert.ok(!projetADater(projet({ horizon }), '2027-06'), 'dernier mois inclus');
|
||||
assert.ok(projetADater(projet({ horizon }), '2027-07'), 'dépassé');
|
||||
});
|
||||
|
||||
test('un projet envisagé sans horizon réclame une date lui aussi', () => {
|
||||
// C'est le cas d'un projet hérité d'un fichier en version 4 : les deux
|
||||
// manques se signalent pareil.
|
||||
assert.ok(projetADater(projet(), '2026-08'));
|
||||
});
|
||||
|
||||
test('un projet engagé, clos ou écarté ne réclame jamais de date', () => {
|
||||
assert.ok(!projetADater(projet({ horizon, phases: [phase()] }), '2030-01'));
|
||||
assert.ok(!projetADater(projet({ horizon, completedDate: '2027-01-01' }), '2030-01'));
|
||||
assert.ok(!projetADater(projet({ horizon, discardedDate: '2027-01-01' }), '2030-01'));
|
||||
});
|
||||
|
||||
test('la fenêtre du planning tient compte des horizons', () => {
|
||||
// Sans cela, un planning fait de projets encore tous envisagés s'ouvrirait
|
||||
// sur rien.
|
||||
const planning = { projects: [projet({ horizon })] };
|
||||
assert.deepEqual(bornesPlanning(planning), { start: '2027-03-01', end: '2027-06-30' });
|
||||
});
|
||||
|
||||
test('la fenêtre du planning ignore les projets écartés', () => {
|
||||
const planning = {
|
||||
projects: [
|
||||
projet({ id: 'vivant', phases: [phase()] }),
|
||||
projet({
|
||||
id: 'mort',
|
||||
discardedDate: '2026-07-01',
|
||||
phases: [phase({ id: 'p', start: '2019-01-01', end: '2019-06-01' })],
|
||||
}),
|
||||
],
|
||||
};
|
||||
assert.deepEqual(bornesPlanning(planning), { start: '2026-08-01', end: '2026-08-20' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- validation du cycle de vie ---------------------------------------------
|
||||
|
||||
describe('validerPlanning : horizon et états', () => {
|
||||
const avec = (champs) => ({ version: 5, projects: [projet(champs)] });
|
||||
|
||||
test('accepte un horizon correct et le conserve tel quel', () => {
|
||||
const relu = validerPlanning(avec({ horizon: { start: '2027-03', end: '2027-06' } }));
|
||||
assert.deepEqual(relu.projects[0].horizon, { start: '2027-03', end: '2027-06' });
|
||||
});
|
||||
|
||||
test('laisse sans horizon un projet qui n en a pas', () => {
|
||||
// C'est le cas de tous les projets d'un fichier en version 4.
|
||||
const relu = validerPlanning(avec({}));
|
||||
assert.ok(!('horizon' in relu.projects[0]));
|
||||
assert.equal(etatProjet(relu.projects[0]), 'considered');
|
||||
});
|
||||
|
||||
test('rejette un horizon mal formé plutôt que de l avaler', () => {
|
||||
// Contrairement au statut ou aux notes : l'horizon décide d'une position sur
|
||||
// la frise, et l'avaler laisserait un projet invisible sans raison visible.
|
||||
assert.throws(
|
||||
() => validerPlanning(avec({ horizon: { start: '2027-13', end: '2027-06' } })),
|
||||
(err) => err instanceof ErreurValidation && /horizon/.test(err.message)
|
||||
);
|
||||
assert.throws(
|
||||
() => validerPlanning(avec({ horizon: { start: '2027-03-01', end: '2027-06' } })),
|
||||
ErreurValidation
|
||||
);
|
||||
assert.throws(() => validerPlanning(avec({ horizon: 'mars 2027' })), ErreurValidation);
|
||||
});
|
||||
|
||||
test('rejette un horizon dont la fin précède le début, en nommant le fautif', () => {
|
||||
assert.throws(
|
||||
() => validerPlanning(avec({ horizon: { start: '2027-06', end: '2027-03' } })),
|
||||
(err) => err instanceof ErreurValidation && /« Site web »/.test(err.message)
|
||||
);
|
||||
});
|
||||
|
||||
test('rejette une date d acte invalide', () => {
|
||||
assert.throws(
|
||||
() => validerPlanning(avec({ completedDate: '2027-02-30' })),
|
||||
(err) => err instanceof ErreurValidation && /completedDate/.test(err.message)
|
||||
);
|
||||
assert.throws(
|
||||
() => validerPlanning(avec({ discardedDate: 'hier' })),
|
||||
(err) => err instanceof ErreurValidation && /discardedDate/.test(err.message)
|
||||
);
|
||||
});
|
||||
|
||||
test('un projet du cycle de vie survit à un aller-retour par la validation', () => {
|
||||
let planning = planningVide();
|
||||
const p = ecarterProjet(
|
||||
definirHorizon(creerProjet(planning, 'Salle des fêtes', [], '2026-08'), '2027-03', '2027-06'),
|
||||
'2026-09-14'
|
||||
);
|
||||
planning = { ...planning, projects: [p] };
|
||||
|
||||
const relu = validerPlanning(pourEcriture(planning));
|
||||
assert.deepEqual(relu, validerPlanning(pourEcriture(relu)));
|
||||
assert.deepEqual(relu.projects[0].horizon, { start: '2027-03', end: '2027-06' });
|
||||
assert.equal(relu.projects[0].discardedDate, '2026-09-14');
|
||||
assert.ok(!('completedDate' in relu.projects[0]), 'les clés undefined ne sont pas écrites');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user