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
|
```sh
|
||||||
go test ./... # le serveur
|
go test ./... # le serveur
|
||||||
node --test tests/ # le modèle métier
|
node --test # le modèle métier
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prise en main
|
## 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
|
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.
|
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
|
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
|
son **nom** ouvre ses paramètres : nom, couleur, tags, horizon. Deux commandes apparaissent au
|
||||||
la ligne, sur son bord droit :
|
survol de la ligne, sur son bord droit :
|
||||||
|
|
||||||
- **`+`** ajoute une phase à la suite de la dernière, et ouvre son panneau de détail ;
|
- **`+`** 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,
|
- **`⋯`** 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
|
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
|
« 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 |
|
| Geste | Effet |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Glisser le corps d'une barre | Décale la phase, durée conservée |
|
| Glisser le corps d'une barre | Décale la phase, durée conservée |
|
||||||
|
| Glisser la barre pâle d'un envisagé | Décale son horizon, au mois |
|
||||||
| Glisser le bord d'une barre | Change la date de début ou de fin |
|
| Glisser le bord d'une barre | Change la date de début ou de fin |
|
||||||
| Glisser le bord gauche du panneau | Change sa largeur (double-clic : d'origine) |
|
| Glisser le bord gauche du panneau | Change sa largeur (double-clic : d'origine) |
|
||||||
| Clic sur une barre ou son nom | Ouvre le panneau de détail |
|
| Clic sur une barre ou son nom | Ouvre le panneau de détail |
|
||||||
@@ -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
|
Faire défiler jusqu'au bord droit élargit la frise vers le futur, et jusqu'au bord gauche
|
||||||
vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
|
vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
|
||||||
|
|
||||||
|
### 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
|
### Au clavier, à la vim
|
||||||
|
|
||||||
La frise a un **curseur** : la ligne sur laquelle portent les raccourcis. Il se pose en cliquant une
|
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
|
« qu'est-ce qui arrive le mois prochain », que la frise donne mal parce qu'elle sépare les projets
|
||||||
en couloirs.
|
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
|
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
|
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
|
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
|
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.
|
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
|
## Ce que fait l'outil
|
||||||
|
|
||||||
- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin,
|
- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin,
|
||||||
un statut, des notes. Une phase peut être un **jalon** (une date unique, rendue en losange).
|
un statut, des notes. Une phase peut être un **jalon** (une date unique, rendue en losange).
|
||||||
|
- **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,
|
- **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.
|
liens. Elles occupent toute la hauteur restante du panneau, et se cochent d'un clic dans l'aperçu.
|
||||||
- **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil.
|
- **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil.
|
||||||
@@ -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
|
- **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.
|
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
|
- **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.
|
- **Repère « aujourd'hui »** et masquage d'un projet.
|
||||||
|
|
||||||
## Ce que l'outil ne fait pas
|
## 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
|
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.
|
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. À
|
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.
|
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
|
go test ./... # le serveur
|
||||||
```
|
```
|
||||||
|
|
||||||
Côté navigateur : validation des dates, bornes d'un projet, calcul de dérive, génération des
|
Côté navigateur : validation des dates et des horizons, bornes et emprise d'un projet, calcul de
|
||||||
identifiants, normalisation et filtrage des tags, regroupement des phases par mois. Côté serveur : lecture et écriture du planning,
|
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
|
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.
|
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 |
|
| `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/` |
|
| `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/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/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/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/clavier.js` | Table des raccourcis à la vim, préfixes `g` `z` `d`, garde-fou de saisie |
|
||||||
| `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node |
|
| `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node |
|
||||||
| `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux |
|
| `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux |
|
||||||
|
|||||||
321
css/style.css
321
css/style.css
@@ -21,6 +21,10 @@
|
|||||||
--accent: #3b82f6;
|
--accent: #3b82f6;
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--aujourdhui: #ef4444;
|
--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;
|
--hauteur-barre: 26px;
|
||||||
/* Barre cumulative d'un projet, et fantôme qui la double. Ils sont frères,
|
/* Barre cumulative d'un projet, et fantôme qui la double. Ils sont frères,
|
||||||
@@ -53,6 +57,7 @@
|
|||||||
--texte: #f1f5f9;
|
--texte: #f1f5f9;
|
||||||
--texte-doux: #94a3b8;
|
--texte-doux: #94a3b8;
|
||||||
--ombre: 0 1px 2px rgb(0 0 0 / 0.3);
|
--ombre: 0 1px 2px rgb(0 0 0 / 0.3);
|
||||||
|
--attention: #fbbf24;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +214,68 @@ button[aria-pressed="true"] {
|
|||||||
white-space: nowrap;
|
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 ----------------------------------------------------------------- */
|
/* --- tags ----------------------------------------------------------------- */
|
||||||
|
|
||||||
/* Chaque pastille porte sa teinte dans `--teinte-tag`, calculée par hachage du
|
/* Chaque pastille porte sa teinte dans `--teinte-tag`, calculée par hachage du
|
||||||
@@ -539,6 +606,48 @@ button[aria-pressed="true"] {
|
|||||||
color: #16a34a;
|
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 {
|
.plier {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -731,10 +840,17 @@ button[aria-pressed="true"] {
|
|||||||
background: rgb(255 255 255 / 0.5);
|
background: rgb(255 255 255 / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barre:hover .poignee {
|
.barre:hover .poignee,
|
||||||
|
.barre-horizon:hover .poignee {
|
||||||
opacity: 1;
|
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 {
|
.poignee--debut {
|
||||||
left: 0;
|
left: 0;
|
||||||
border-radius: 4px 0 0 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
@@ -858,6 +974,67 @@ button[aria-pressed="true"] {
|
|||||||
height: 4px;
|
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 --------------------------------------------------- */
|
/* --- panneau de détail --------------------------------------------------- */
|
||||||
|
|
||||||
/* Non modal : aucun voile derrière lui, la frise reste manipulable pendant
|
/* Non modal : aucun voile derrière lui, la frise reste manipulable pendant
|
||||||
@@ -1011,6 +1188,106 @@ button[aria-pressed="true"] {
|
|||||||
margin-top: 4px;
|
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 -------------------------------------------------------- */
|
/* --- bloc de notes -------------------------------------------------------- */
|
||||||
|
|
||||||
/* Le bloc prend toute la hauteur que les champs au-dessus lui laissent. C'est
|
/* 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);
|
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 {
|
.mois__vide {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 48px auto;
|
margin: 48px auto;
|
||||||
|
|||||||
@@ -156,6 +156,40 @@
|
|||||||
"notes": ""
|
"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`
|
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
|
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.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"version": 4,
|
"version": 5,
|
||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"id": "site-web",
|
"id": "site-web",
|
||||||
@@ -19,6 +19,7 @@ dans un diff git et éditable à la main.
|
|||||||
"collapsed": false,
|
"collapsed": false,
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"notes": "## Contexte\n\nPiloté par **Marie D.**\n\n- [x] cadrage validé\n- [ ] recette",
|
"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",
|
"baselineDate": "2026-06-12",
|
||||||
"phases": [
|
"phases": [
|
||||||
{
|
{
|
||||||
@@ -50,12 +51,18 @@ dans un diff git et éditable à la main.
|
|||||||
|
|
||||||
| Champ | Type | Description |
|
| 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. |
|
| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. |
|
||||||
|
|
||||||
Un fichier plus ancien se charge sans rien demander et est réécrit au format courant à la première
|
Un fichier plus ancien se charge sans rien demander et est réécrit au format courant à la première
|
||||||
sauvegarde : une version `2` — celle d'avant les tags — voit ses projets recevoir une liste de tags
|
sauvegarde : une version `2` — celle d'avant les tags — voit ses projets recevoir une liste de tags
|
||||||
vide, une version `3` — celle d'avant les notes de projet — des notes vides.
|
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
|
Un fichier écrit par une version **plus récente** est en revanche refusé. C'est la raison d'être du
|
||||||
numéro : le validateur reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît
|
numéro : le validateur reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît
|
||||||
@@ -73,6 +80,9 @@ effacerait les champs inconnus à la première sauvegarde.
|
|||||||
| `collapsed` | booléen | `true` si le couloir est plié. |
|
| `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. |
|
| `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. |
|
| `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é. |
|
| `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. |
|
| `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
|
- `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
|
aucun calcul, contrairement aux dates : bloquer le chargement d'un planning entier pour lui serait
|
||||||
disproportionné.
|
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
|
## 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.
|
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
|
## Dates : conventions
|
||||||
|
|
||||||
Les dates sont manipulées comme des **chaînes `AAAA-MM-JJ`**, pas comme des objets `Date`. Cela évite
|
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
|
## Bornes et dérive
|
||||||
|
|
||||||
- Les **bornes d'un projet** vont de la plus petite `start` à la plus grande `end` de ses phases. Un
|
- Les **bornes d'un projet** (`bornesProjet()`) vont de la plus petite `start` à la plus grande `end`
|
||||||
projet sans phase n'a pas de bornes et s'affiche comme un couloir vide.
|
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
|
- **Figer la référence** copie, pour chaque phase du projet, ses `start` et `end` courantes dans son
|
||||||
champ `baseline`, et inscrit la date du jour dans `baselineDate`. L'opération est rejouable : la
|
champ `baseline`, et inscrit la date du jour dans `baselineDate`. L'opération est rejouable : la
|
||||||
refiger après un arbitrage assumé repart d'une base propre.
|
refiger après un arbitrage assumé repart d'une base propre.
|
||||||
|
|||||||
70
index.html
70
index.html
@@ -54,6 +54,22 @@
|
|||||||
<p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p>
|
<p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p>
|
||||||
</header>
|
</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
|
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.
|
n'apparaît que le jour où elle sert à quelque chose.
|
||||||
@@ -107,8 +123,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</p>
|
</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>
|
<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>
|
<button type="button" id="effacer-filtres-vide">Tout afficher</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -215,11 +236,48 @@
|
|||||||
<button type="button" id="fermer-panneau-projet" aria-label="Fermer">×</button>
|
<button type="button" id="fermer-panneau-projet" aria-label="Fermer">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="panneau__corps" id="formulaire-projet">
|
<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>
|
<label>
|
||||||
Nom
|
Nom
|
||||||
<input type="text" name="name" required autocomplete="off" />
|
<input type="text" name="name" required autocomplete="off" />
|
||||||
</label>
|
</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">
|
<fieldset class="couleurs">
|
||||||
<legend>Couleur du couloir</legend>
|
<legend>Couleur du couloir</legend>
|
||||||
<div id="panneau-projet-couleurs"></div>
|
<div id="panneau-projet-couleurs"></div>
|
||||||
@@ -245,6 +303,16 @@
|
|||||||
|
|
||||||
<p class="panneau__erreur" id="erreur-projet" role="alert" hidden></p>
|
<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">
|
<div class="panneau__actions">
|
||||||
<button type="button" id="supprimer-projet" class="bouton--danger">
|
<button type="button" id="supprimer-projet" class="bouton--danger">
|
||||||
Supprimer le projet
|
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 { creerStockage, viderAvantFermeture } from './storage.js';
|
||||||
import {
|
import {
|
||||||
COULEURS,
|
COULEURS,
|
||||||
|
ETATS,
|
||||||
|
LIBELLES_ETAT,
|
||||||
STATUTS,
|
STATUTS,
|
||||||
ajouterJours,
|
ajouterJours,
|
||||||
ajouterPhase,
|
ajouterPhase,
|
||||||
aujourdhui,
|
aujourdhui,
|
||||||
bornesPlanning,
|
bornesPlanning,
|
||||||
cleTag,
|
cleTag,
|
||||||
|
clotureEcraseDesStatuts,
|
||||||
creerPhase,
|
creerPhase,
|
||||||
creerProjet,
|
creerProjet,
|
||||||
|
ecarterProjet,
|
||||||
|
etatProjet,
|
||||||
figerReference,
|
figerReference,
|
||||||
formaterDateLongue,
|
formaterDateLongue,
|
||||||
libererReference,
|
libererReference,
|
||||||
@@ -32,11 +37,16 @@ import {
|
|||||||
normaliserTags,
|
normaliserTags,
|
||||||
planningVide,
|
planningVide,
|
||||||
pourEcriture,
|
pourEcriture,
|
||||||
|
premierJourDuMois,
|
||||||
projetFiltre,
|
projetFiltre,
|
||||||
|
projetFiltreEtat,
|
||||||
|
reanimerProjet,
|
||||||
remplacerPhase,
|
remplacerPhase,
|
||||||
remplacerProjet,
|
remplacerProjet,
|
||||||
|
rouvrirProjet,
|
||||||
supprimerPhase,
|
supprimerPhase,
|
||||||
supprimerProjet,
|
supprimerProjet,
|
||||||
|
terminerProjet,
|
||||||
teinteTag,
|
teinteTag,
|
||||||
tousLesTags,
|
tousLesTags,
|
||||||
validerPlanning,
|
validerPlanning,
|
||||||
@@ -58,6 +68,8 @@ const refs = {
|
|||||||
filtres: $('filtres'),
|
filtres: $('filtres'),
|
||||||
filtresListe: $('filtres-liste'),
|
filtresListe: $('filtres-liste'),
|
||||||
effacerFiltres: $('effacer-filtres'),
|
effacerFiltres: $('effacer-filtres'),
|
||||||
|
etats: $('etats'),
|
||||||
|
etatsListe: $('etats-liste'),
|
||||||
alerte: $('alerte'),
|
alerte: $('alerte'),
|
||||||
alerteMessage: $('alerte-message'),
|
alerteMessage: $('alerte-message'),
|
||||||
panneau: $('panneau'),
|
panneau: $('panneau'),
|
||||||
@@ -113,6 +125,18 @@ let defilementFrise = 0;
|
|||||||
*/
|
*/
|
||||||
let tagsActifs = [];
|
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é.
|
* Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré.
|
||||||
*
|
*
|
||||||
@@ -197,6 +221,12 @@ const panneauProjet = creerPanneauProjet(
|
|||||||
conteneurCouleurs: $('panneau-projet-couleurs'),
|
conteneurCouleurs: $('panneau-projet-couleurs'),
|
||||||
suggestions: $('panneau-projet-suggestions'),
|
suggestions: $('panneau-projet-suggestions'),
|
||||||
blocSuggestions: $('panneau-projet-suggestions-bloc'),
|
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'),
|
notes: $('notes-projet'),
|
||||||
supprimer: $('supprimer-projet'),
|
supprimer: $('supprimer-projet'),
|
||||||
fermer: $('fermer-panneau-projet'),
|
fermer: $('fermer-panneau-projet'),
|
||||||
@@ -211,6 +241,9 @@ const panneauProjet = creerPanneauProjet(
|
|||||||
demanderSuppression(_idProjet, projet) {
|
demanderSuppression(_idProjet, projet) {
|
||||||
return confirmerSuppressionProjet(projet);
|
return confirmerSuppressionProjet(projet);
|
||||||
},
|
},
|
||||||
|
demanderCloture(projet) {
|
||||||
|
return confirmerCloture(projet);
|
||||||
|
},
|
||||||
surSuppression(idProjet) {
|
surSuppression(idProjet) {
|
||||||
if (curseur?.projet === idProjet) panneau.fermer();
|
if (curseur?.projet === idProjet) panneau.fermer();
|
||||||
majPlanning(supprimerProjet(planning, idProjet));
|
majPlanning(supprimerProjet(planning, idProjet));
|
||||||
@@ -243,7 +276,7 @@ function dessiner() {
|
|||||||
normaliserCurseur();
|
normaliserCurseur();
|
||||||
|
|
||||||
const aDesProjets = planning.projects.length > 0;
|
const aDesProjets = planning.projects.length > 0;
|
||||||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
const retenus = projetsRetenus();
|
||||||
|
|
||||||
refs.messageVide.hidden = aDesProjets;
|
refs.messageVide.hidden = aDesProjets;
|
||||||
// Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce
|
// 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';
|
refs.vueMois.hidden = !aQuelqueChoseAMontrer || vue !== 'mois';
|
||||||
|
|
||||||
peindreFiltres();
|
peindreFiltres();
|
||||||
|
peindreEtats();
|
||||||
peindreOnglets();
|
peindreOnglets();
|
||||||
|
|
||||||
if (!aDesProjets) {
|
if (!aDesProjets) {
|
||||||
@@ -265,12 +299,12 @@ function dessiner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (vue === 'mois') {
|
if (vue === 'mois') {
|
||||||
rendreMois(refs.vueMois, planning, { tagsActifs });
|
rendreMois(refs.vueMois, planning, { tagsActifs, etatsActifs });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
elargirPourContenir();
|
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
|
// 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.
|
// 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) {
|
function localiser(idProjet, idPhase) {
|
||||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||||
if (!projet) return null;
|
if (!projet) return null;
|
||||||
@@ -365,8 +414,71 @@ function effacerFiltres() {
|
|||||||
dessiner();
|
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);
|
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
|
// Bascule entre la frise et la vue par mois
|
||||||
@@ -745,6 +857,22 @@ installerGlisser(refs.couloirs, {
|
|||||||
surClic(idProjet, idPhase) {
|
surClic(idProjet, idPhase) {
|
||||||
ouvrirPhase(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;
|
if (!projet) return;
|
||||||
|
|
||||||
const aUneReference = projet.phases.some((phase) => phase.baseline);
|
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, [
|
menu.ouvrir(ancre, [
|
||||||
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet) },
|
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet) },
|
||||||
@@ -871,6 +1007,26 @@ function ouvrirMenuProjet(idProjet, ancre) {
|
|||||||
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
|
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
|
||||||
},
|
},
|
||||||
{ libelle: 'Renommer, changer la couleur…', action: () => ouvrirProjet(projet) },
|
{ 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',
|
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
|
||||||
separateurAvant: true,
|
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
|
// `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
|
// 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.
|
// « 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];
|
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));
|
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.
|
* gestionnaire — ou le glisser qui commence peut-être — n'ait fini son travail.
|
||||||
*/
|
*/
|
||||||
const CIBLES_OUVRANTES =
|
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. */
|
/** Surfaces où un clic n'est pas un « ailleurs » : on y reste au travail. */
|
||||||
const CIBLES_PROTEGEES = '.panneau, .menu, dialog, .poignee-colonne';
|
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) {
|
async function supprimerLeProjet(projet) {
|
||||||
if (!(await confirmerSuppressionProjet(projet))) return;
|
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. */
|
/** Ligne du projet qui suit celui-ci, ou du précédent s'il fermait la liste. */
|
||||||
function projetVoisin(idProjet) {
|
function projetVoisin(idProjet) {
|
||||||
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
|
const retenus = projetsRetenus();
|
||||||
const index = retenus.findIndex((p) => p.id === idProjet);
|
const index = retenus.findIndex((p) => p.id === idProjet);
|
||||||
if (index === -1) return null;
|
if (index === -1) return null;
|
||||||
const voisin = retenus[index + 1] ?? retenus[index - 1];
|
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
|
// 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
|
// regardant que les projets « client », c'en est presque toujours un — et
|
||||||
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
|
// 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);
|
const projet = creerProjet(planning, 'Nouveau projet', tagsActifs);
|
||||||
majPlanning({ ...planning, projects: [...planning.projects, projet] });
|
majPlanning({ ...planning, projects: [...planning.projects, projet] });
|
||||||
ouvrirProjet(projet, { nomAEcraser: true });
|
ouvrirProjet(projet, { nomAEcraser: true });
|
||||||
@@ -1146,7 +1356,7 @@ document.addEventListener('keydown', (evenement) => {
|
|||||||
function lignesFrise() {
|
function lignesFrise() {
|
||||||
const lignes = [];
|
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 });
|
lignes.push({ projet: projet.id, phase: null });
|
||||||
if (projet.hidden || projet.collapsed) continue;
|
if (projet.hidden || projet.collapsed) continue;
|
||||||
for (const phase of projet.phases) lignes.push({ projet: projet.id, phase: phase.id });
|
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. */
|
/** `{` et `}` : la ligne du projet précédent ou suivant. */
|
||||||
function deplacerDeProjet(pas) {
|
function deplacerDeProjet(pas) {
|
||||||
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
|
const retenus = projetsRetenus();
|
||||||
if (!retenus.length) return;
|
if (!retenus.length) return;
|
||||||
|
|
||||||
const depart = retenus.findIndex((p) => p.id === curseur?.projet);
|
const depart = retenus.findIndex((p) => p.id === curseur?.projet);
|
||||||
|
|||||||
149
js/drag.js
149
js/drag.js
@@ -14,8 +14,13 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ajouterJours,
|
ajouterJours,
|
||||||
|
ajouterMois,
|
||||||
deplacerPhase,
|
deplacerPhase,
|
||||||
|
dernierJourDuMois,
|
||||||
|
ecartMois,
|
||||||
lundiDeLaSemaine,
|
lundiDeLaSemaine,
|
||||||
|
moisDe,
|
||||||
|
premierJourDuMois,
|
||||||
redimensionnerPhase,
|
redimensionnerPhase,
|
||||||
versUTC,
|
versUTC,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
@@ -40,19 +45,45 @@ export function installerGlisser(conteneur, rappels) {
|
|||||||
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
||||||
if (evenement.button !== 0) return;
|
if (evenement.button !== 0) return;
|
||||||
|
|
||||||
const cible = evenement.target.closest('[data-role="barre"]');
|
const cible = evenement.target.closest('[data-role="barre"], [data-role="horizon"]');
|
||||||
if (!cible) return;
|
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 { projet: idProjet, phase: idPhase } = cible.dataset;
|
||||||
const phase = rappels.trouverPhase(idProjet, idPhase);
|
const phase = rappels.trouverPhase(idProjet, idPhase);
|
||||||
if (!phase) return;
|
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.
|
// 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 = {
|
geste = {
|
||||||
element: cible,
|
element: cible,
|
||||||
|
sorte: 'phase',
|
||||||
idProjet,
|
idProjet,
|
||||||
idPhase,
|
idPhase,
|
||||||
phaseInitiale: phase,
|
phaseInitiale: phase,
|
||||||
@@ -89,9 +120,21 @@ export function installerGlisser(conteneur, rappels) {
|
|||||||
const acheve = geste;
|
const acheve = geste;
|
||||||
geste = null;
|
geste = null;
|
||||||
|
|
||||||
acheve.element.releasePointerCapture?.(evenement.pointerId);
|
relacher(acheve, evenement.pointerId);
|
||||||
acheve.element.classList.remove('barre--glissee');
|
|
||||||
document.body.classList.remove('glisse-en-cours');
|
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) {
|
if (!acheve.demarre) {
|
||||||
rappels.surClic(acheve.idProjet, acheve.idPhase);
|
rappels.surClic(acheve.idProjet, acheve.idPhase);
|
||||||
@@ -110,26 +153,46 @@ export function installerGlisser(conteneur, rappels) {
|
|||||||
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres);
|
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
|
/** Abandonne le geste en cours et laisse le rendu se refaire depuis l'état. */
|
||||||
document.addEventListener('keydown', (evenement) => {
|
function annuler() {
|
||||||
if (evenement.key !== 'Escape' || !geste) return;
|
if (!geste) return;
|
||||||
const annule = geste;
|
const annule = geste;
|
||||||
geste = null;
|
geste = null;
|
||||||
annule.element.classList.remove('barre--glissee');
|
relacher(annule);
|
||||||
document.body.classList.remove('glisse-en-cours');
|
|
||||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
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
|
// 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.
|
// laisser l'interface dans un état propre plutôt qu'en glisser perpétuel.
|
||||||
conteneur.addEventListener('pointercancel', () => {
|
conteneur.addEventListener('pointercancel', annuler);
|
||||||
if (!geste) return;
|
}
|
||||||
const annule = geste;
|
|
||||||
geste = null;
|
/**
|
||||||
annule.element.classList.remove('barre--glissee');
|
* 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');
|
document.body.classList.remove('glisse-en-cours');
|
||||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,6 +200,8 @@ export function installerGlisser(conteneur, rappels) {
|
|||||||
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
||||||
*/
|
*/
|
||||||
function appliquerGeste(geste, decalage, echelle) {
|
function appliquerGeste(geste, decalage, echelle) {
|
||||||
|
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
|
||||||
|
|
||||||
const { phaseInitiale: phase, mode } = geste;
|
const { phaseInitiale: phase, mode } = geste;
|
||||||
|
|
||||||
if (mode === 'deplacer') {
|
if (mode === 'deplacer') {
|
||||||
@@ -164,6 +229,37 @@ function appliquerGeste(geste, decalage, echelle) {
|
|||||||
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
|
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Décale une date du nombre de jours correspondant au déplacement, puis
|
||||||
* l'accroche au lundi de sa semaine.
|
* l'accroche au lundi de sa semaine.
|
||||||
@@ -173,6 +269,21 @@ function accrocher(date, decalagePixels, echelle) {
|
|||||||
return lundiDeLaSemaine(ajouterJours(date, jours));
|
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.
|
* 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
|
* Utilisé par app.js sur les flèches gauche/droite quand une phase est
|
||||||
|
|||||||
410
js/model.js
410
js/model.js
@@ -15,18 +15,34 @@
|
|||||||
/**
|
/**
|
||||||
* Version du format de fichier.
|
* Version du format de fichier.
|
||||||
*
|
*
|
||||||
* Elle a changé pour la dernière fois quand le projet a reçu des `notes` (v4).
|
* Elle a changé pour la dernière fois quand le projet a reçu son cycle de vie —
|
||||||
* Un champ ajouté n'oblige pourtant à rien : la v3 se relit sans encombre, et
|
* `horizon`, `completedDate`, `discardedDate` (v5). Un champ ajouté n'oblige
|
||||||
* le validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement
|
* pourtant à rien : la v4 se relit sans encombre, et le validateur ne refuse
|
||||||
* là qu'est la raison d'incrémenter — le validateur reconstruit chaque projet
|
* qu'un fichier *plus récent* que lui. C'est justement là qu'est la raison
|
||||||
* champ par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un
|
* d'incrémenter — le validateur reconstruit chaque projet champ par champ et
|
||||||
* binaire antérieur ouvrirait un fichier v4 sans broncher et en effacerait
|
* laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire antérieur
|
||||||
* toutes les notes de projet à la première sauvegarde.
|
* 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'];
|
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 = {
|
export const LIBELLES_STATUT = {
|
||||||
todo: 'À venir',
|
todo: 'À venir',
|
||||||
doing: 'En cours',
|
doing: 'En cours',
|
||||||
@@ -56,6 +72,7 @@ export const MOIS_LONGS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const MOTIF_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
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 MOTIF_COULEUR = /^#[0-9a-f]{6}$/i;
|
||||||
const MS_PAR_JOUR = 86400000;
|
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)}`;
|
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
|
* 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
|
* 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;
|
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
|
// Validation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -337,11 +506,15 @@ export function validerPlanning(donnees) {
|
|||||||
}
|
}
|
||||||
idsProjets.add(projet.id);
|
idsProjets.add(projet.id);
|
||||||
|
|
||||||
if (projet.baselineDate !== undefined && !dateValide(projet.baselineDate)) {
|
for (const cle of ['baselineDate', 'completedDate', 'discardedDate']) {
|
||||||
|
if (projet[cle] !== undefined && !dateValide(projet[cle])) {
|
||||||
throw new ErreurValidation(
|
throw new ErreurValidation(
|
||||||
`Projet ${repere} : « baselineDate » n'est pas une date valide (${projet.baselineDate}).`
|
`Projet ${repere} : « ${cle} » n'est pas une date valide (${projet[cle]}).`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const horizon = validerHorizon(projet.horizon, repere);
|
||||||
|
|
||||||
if (!Array.isArray(projet.phases)) {
|
if (!Array.isArray(projet.phases)) {
|
||||||
throw new ErreurValidation(`Projet ${repere} : « phases » doit être un tableau.`);
|
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
|
// bloquer le chargement d'un planning entier pour un champ mal typé serait
|
||||||
// disproportionné là où le champ n'engage aucun calcul.
|
// disproportionné là où le champ n'engage aucun calcul.
|
||||||
notes: typeof projet.notes === 'string' ? projet.notes : '',
|
notes: typeof projet.notes === 'string' ? projet.notes : '',
|
||||||
|
...(horizon ? { horizon } : {}),
|
||||||
|
...(projet.completedDate ? { completedDate: projet.completedDate } : {}),
|
||||||
|
...(projet.discardedDate ? { discardedDate: projet.discardedDate } : {}),
|
||||||
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
|
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
|
||||||
phases: trierPhases(phases),
|
phases: trierPhases(phases),
|
||||||
};
|
};
|
||||||
@@ -371,6 +547,33 @@ export function validerPlanning(donnees) {
|
|||||||
return { version: VERSION_FORMAT, projects: projets };
|
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
|
* 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
|
* 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 };
|
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) {
|
export function bornesPlanning(planning) {
|
||||||
let debut = null;
|
let debut = null;
|
||||||
let fin = null;
|
let fin = null;
|
||||||
for (const projet of planning.projects) {
|
for (const projet of planning.projects) {
|
||||||
if (projet.hidden) continue;
|
if (projet.hidden || etatProjet(projet) === 'discarded') continue;
|
||||||
const bornes = bornesProjet(projet);
|
const bornes = empriseProjet(projet);
|
||||||
if (!bornes) continue;
|
if (!bornes) continue;
|
||||||
if (debut === null || versUTC(bornes.start) < versUTC(debut)) debut = bornes.start;
|
if (debut === null || versUTC(bornes.start) < versUTC(debut)) debut = bornes.start;
|
||||||
if (fin === null || versUTC(bornes.end) > versUTC(fin)) fin = bornes.end;
|
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
|
* Deux sortes d'entrées y cohabitent, et la seconde est venue plus tard :
|
||||||
|
*
|
||||||
|
* - 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
|
* 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 à « 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
|
* répond mal parce qu'elle éparpille les départs sur autant de couloirs qu'il
|
||||||
* a de projets. La répéter dans chaque mois traversé en aurait fait un tableau
|
* y a de projets. La répéter dans chaque mois traversé en aurait fait un
|
||||||
* de charge — utile, mais c'est une autre vue, et le doublon coûte la lecture
|
* tableau de charge — utile, mais c'est une autre vue, et le doublon coûte la
|
||||||
* en diagonale qui fait tout l'intérêt de celle-ci. Sa fin est écrite sur la
|
* lecture en diagonale qui fait tout l'intérêt de celle-ci. Sa fin est écrite
|
||||||
* ligne, ce qui suffit à voir qu'elle déborde.
|
* 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 deux tamis de la frise s'appliquent de la même façon (timeline.js) : le
|
* Les tamis de la frise s'appliquent tous de la même façon (timeline.js) : le
|
||||||
* filtre par tags retire le projet, l'œil (`hidden`) aussi — un projet dont on
|
* filtre par tags retire le projet, celui par état aussi, et l'œil (`hidden`)
|
||||||
* a masqué les barres n'a pas à revenir par la liste.
|
* 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 —
|
* 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
|
* 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.
|
* 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.
|
* 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 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) {
|
for (const projet of planning.projects) {
|
||||||
if (projet.hidden || !projetFiltre(projet, tagsActifs)) continue;
|
if (projet.hidden || !projetFiltre(projet, tagsActifs)) continue;
|
||||||
|
if (!projetFiltreEtat(projet, etatsActifs)) continue;
|
||||||
|
|
||||||
for (const phase of projet.phases) {
|
for (const phase of projet.phases) {
|
||||||
const mois = moisDe(phase.start);
|
poser(moisDe(phase.start), { projet, phase });
|
||||||
if (!groupes.has(mois)) groupes.set(mois, []);
|
}
|
||||||
groupes.get(mois).push({ 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,
|
mois,
|
||||||
entrees: groupes.get(mois).sort(
|
entrees: groupes.get(mois).sort(
|
||||||
(a, b) =>
|
(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.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
|
// 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));
|
const id = fabriquerId(nom, planning.projects.map((p) => p.id));
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -633,10 +888,103 @@ export function creerProjet(planning, nom, tags = []) {
|
|||||||
collapsed: false,
|
collapsed: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
notes: '',
|
notes: '',
|
||||||
|
horizon: { start: mois, end: mois },
|
||||||
phases: [],
|
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 = {}) {
|
export function creerPhase(projet, nom, debut, fin, options = {}) {
|
||||||
const id = fabriquerId(nom, projet.phases.map((p) => p.id));
|
const id = fabriquerId(nom, projet.phases.map((p) => p.id));
|
||||||
const jalon = Boolean(options.milestone);
|
const jalon = Boolean(options.milestone);
|
||||||
|
|||||||
121
js/mois.js
121
js/mois.js
@@ -19,15 +19,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
LIBELLES_ETAT,
|
||||||
LIBELLES_STATUT,
|
LIBELLES_STATUT,
|
||||||
aujourdhui,
|
aujourdhui,
|
||||||
duree,
|
duree,
|
||||||
|
entreesParMois,
|
||||||
|
etatProjet,
|
||||||
formaterDateLongue,
|
formaterDateLongue,
|
||||||
|
formaterHorizon,
|
||||||
formaterMois,
|
formaterMois,
|
||||||
formaterPeriode,
|
formaterPeriode,
|
||||||
moisDe,
|
moisDe,
|
||||||
phasesParMois,
|
|
||||||
projetFiltre,
|
projetFiltre,
|
||||||
|
projetFiltreEtat,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
import { aplatirMarkdown } from './markdown.js';
|
import { aplatirMarkdown } from './markdown.js';
|
||||||
import { rendreMarkdown } from './notes.js';
|
import { rendreMarkdown } from './notes.js';
|
||||||
@@ -38,13 +42,13 @@ import { pastillesTags } from './tags.js';
|
|||||||
*
|
*
|
||||||
* @param {HTMLElement} conteneur
|
* @param {HTMLElement} conteneur
|
||||||
* @param {object} planning
|
* @param {object} planning
|
||||||
* @param {object} options { tagsActifs }
|
* @param {object} options { tagsActifs, etatsActifs }
|
||||||
*/
|
*/
|
||||||
export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) {
|
export function rendreMois(conteneur, planning, { tagsActifs = [], etatsActifs = [] } = {}) {
|
||||||
const groupes = phasesParMois(planning, tagsActifs);
|
const groupes = entreesParMois(planning, tagsActifs, etatsActifs);
|
||||||
|
|
||||||
if (!groupes.length) {
|
if (!groupes.length) {
|
||||||
conteneur.replaceChildren(messageVide(planning, tagsActifs));
|
conteneur.replaceChildren(messageVide(planning, tagsActifs, etatsActifs));
|
||||||
return;
|
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
|
* 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.
|
* l'œil ici, puisque la liste n'a pas de colonne de libellés où le rouvrir.
|
||||||
*/
|
*/
|
||||||
function messageVide(planning, tagsActifs) {
|
function messageVide(planning, tagsActifs, etatsActifs = []) {
|
||||||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
const retenus = planning.projects.filter(
|
||||||
|
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
|
||||||
|
);
|
||||||
|
|
||||||
const paragraphe = document.createElement('p');
|
const paragraphe = document.createElement('p');
|
||||||
paragraphe.className = 'mois__vide';
|
paragraphe.className = 'mois__vide';
|
||||||
@@ -120,18 +126,89 @@ function construireGroupe({ mois, entrees }, moisCourant, tagsActifs) {
|
|||||||
return section;
|
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) {
|
function resumerCompte(entrees) {
|
||||||
const jalons = entrees.filter(({ phase }) => phase.milestone).length;
|
const sansPhase = entrees.filter(({ horizon }) => horizon);
|
||||||
const taches = entrees.length - jalons;
|
// 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 = [];
|
const morceaux = [];
|
||||||
if (taches) morceaux.push(`${taches} tâche${taches > 1 ? 's' : ''}`);
|
if (taches) morceaux.push(`${taches} tâche${taches > 1 ? 's' : ''}`);
|
||||||
if (jalons) morceaux.push(`${jalons} jalon${jalons > 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(' · ');
|
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');
|
const ligne = document.createElement('li');
|
||||||
ligne.className = `mois-entree mois-entree--${phase.status}`;
|
ligne.className = `mois-entree mois-entree--${phase.status}`;
|
||||||
ligne.style.setProperty('--couleur-projet', projet.color);
|
ligne.style.setProperty('--couleur-projet', projet.color);
|
||||||
@@ -141,7 +218,7 @@ function construireEntree({ projet, phase }, tagsActifs) {
|
|||||||
if (phase.notes) ligne.classList.add('mois-entree--commentee');
|
if (phase.notes) ligne.classList.add('mois-entree--commentee');
|
||||||
ligne.title = infobulle(projet, phase);
|
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
|
// La marque reprend les formes de la frise — losange pour un jalon, barre
|
||||||
// pour une tâche — pour qu'on reconnaisse au même dessin ce qu'on a appris à
|
// pour une tâche — 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];
|
statut.textContent = LIBELLES_STATUT[phase.status];
|
||||||
ligne.append(statut);
|
ligne.append(statut);
|
||||||
|
|
||||||
if (phase.notes) ligne.append(notes(phase));
|
if (phase.notes) ligne.append(notes(phase.notes));
|
||||||
|
|
||||||
return ligne;
|
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
|
* 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
|
* la grille sont comptées, et il n'y a pas de raison qu'une phase commentée
|
||||||
* décale toutes les autres.
|
* 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) {
|
function triangle(texte, nom) {
|
||||||
if (!phase.notes) {
|
if (!texte) {
|
||||||
const vide = document.createElement('span');
|
const vide = document.createElement('span');
|
||||||
vide.className = 'mois-entree__plier';
|
vide.className = 'mois-entree__plier';
|
||||||
return vide;
|
return vide;
|
||||||
@@ -210,9 +291,9 @@ function triangle(phase) {
|
|||||||
bouton.dataset.action = 'notes';
|
bouton.dataset.action = 'notes';
|
||||||
bouton.setAttribute('aria-expanded', 'false');
|
bouton.setAttribute('aria-expanded', 'false');
|
||||||
// La ligne entière déplie, mais elle n'est pas focusable : ce bouton est le
|
// 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
|
// seul chemin clavier vers les notes, il lui faut donc un nom qui dise de quoi
|
||||||
// quelle phase il parle.
|
// il parle.
|
||||||
bouton.setAttribute('aria-label', `Notes de ${phase.name}`);
|
bouton.setAttribute('aria-label', `Notes de ${nom}`);
|
||||||
bouton.title = 'Afficher les notes';
|
bouton.title = 'Afficher les notes';
|
||||||
bouton.textContent = '▶';
|
bouton.textContent = '▶';
|
||||||
return bouton;
|
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 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.
|
* qu'on vient y chercher quand on fait le point.
|
||||||
*/
|
*/
|
||||||
function notes(phase) {
|
function notes(texte) {
|
||||||
const bloc = document.createElement('div');
|
const bloc = document.createElement('div');
|
||||||
bloc.className = 'mois-entree__notes';
|
bloc.className = 'mois-entree__notes';
|
||||||
bloc.append(rendreMarkdown(phase.notes));
|
bloc.append(rendreMarkdown(texte));
|
||||||
bloc.hidden = true;
|
bloc.hidden = true;
|
||||||
return bloc;
|
return bloc;
|
||||||
}
|
}
|
||||||
|
|||||||
172
js/projet.js
172
js/projet.js
@@ -11,6 +11,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { creerBlocNotes } from './notes.js';
|
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) {
|
export function creerPanneauProjet(refs, outils, rappels) {
|
||||||
const {
|
const {
|
||||||
@@ -21,6 +35,12 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
|||||||
conteneurCouleurs,
|
conteneurCouleurs,
|
||||||
suggestions,
|
suggestions,
|
||||||
blocSuggestions,
|
blocSuggestions,
|
||||||
|
etat,
|
||||||
|
blocHorizon,
|
||||||
|
crans,
|
||||||
|
aideHorizon,
|
||||||
|
terminer,
|
||||||
|
ecarter,
|
||||||
notes,
|
notes,
|
||||||
supprimer,
|
supprimer,
|
||||||
fermer,
|
fermer,
|
||||||
@@ -106,6 +126,101 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
|||||||
blocSuggestions.hidden = tagsConnus.length === 0;
|
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) {
|
function remplir(projet) {
|
||||||
titre.textContent = projet.name;
|
titre.textContent = projet.name;
|
||||||
formulaire.name.value = projet.name;
|
formulaire.name.value = projet.name;
|
||||||
@@ -113,6 +228,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
|||||||
blocNotes.definir(projet.notes, { reinitialiserMode: true });
|
blocNotes.definir(projet.notes, { reinitialiserMode: true });
|
||||||
peindrePalette(projet.color);
|
peindrePalette(projet.color);
|
||||||
peindreSuggestions();
|
peindreSuggestions();
|
||||||
|
peindreCycleDeVie(projet);
|
||||||
afficherErreur(null);
|
afficherErreur(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +255,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
|||||||
name: nom,
|
name: nom,
|
||||||
tags: tagsSaisis(),
|
tags: tagsSaisis(),
|
||||||
notes: formulaire.notes.value,
|
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,
|
...forces,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,11 +268,21 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
|||||||
suivant.tags.length === projetCourant.tags.length &&
|
suivant.tags.length === projetCourant.tags.length &&
|
||||||
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
|
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
|
||||||
|
|
||||||
|
const memeHorizon =
|
||||||
|
suivant.horizon?.start === projetCourant.horizon?.start &&
|
||||||
|
suivant.horizon?.end === projetCourant.horizon?.end;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
suivant.name === projetCourant.name &&
|
suivant.name === projetCourant.name &&
|
||||||
suivant.color === projetCourant.color &&
|
suivant.color === projetCourant.color &&
|
||||||
suivant.notes === projetCourant.notes &&
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -161,6 +291,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
|||||||
titre.textContent = suivant.name;
|
titre.textContent = suivant.name;
|
||||||
peindrePalette(suivant.color);
|
peindrePalette(suivant.color);
|
||||||
peindreSuggestions();
|
peindreSuggestions();
|
||||||
|
peindreCycleDeVie(suivant);
|
||||||
rappels.surModification(idCourant, suivant);
|
rappels.surModification(idCourant, suivant);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +299,41 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
|||||||
formulaire.addEventListener('change', () => appliquer());
|
formulaire.addEventListener('change', () => appliquer());
|
||||||
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
|
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 () => {
|
supprimer.addEventListener('click', async () => {
|
||||||
if (!idCourant) return;
|
if (!idCourant) return;
|
||||||
const cible = idCourant;
|
const cible = idCourant;
|
||||||
@@ -222,6 +388,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
|
|||||||
if (formulaire.notes !== actif) blocNotes.definir(projet.notes);
|
if (formulaire.notes !== actif) blocNotes.definir(projet.notes);
|
||||||
peindrePalette(projet.color);
|
peindrePalette(projet.color);
|
||||||
peindreSuggestions();
|
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() {
|
fermer() {
|
||||||
|
|||||||
116
js/timeline.js
116
js/timeline.js
@@ -12,20 +12,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
LIBELLES_ETAT,
|
||||||
LIBELLES_STATUT,
|
LIBELLES_STATUT,
|
||||||
MOIS_COURTS,
|
MOIS_COURTS,
|
||||||
MOIS_LONGS,
|
MOIS_LONGS,
|
||||||
ajouterJours,
|
ajouterJours,
|
||||||
aujourdhui,
|
aujourdhui,
|
||||||
|
bornesHorizon,
|
||||||
bornesProjet,
|
bornesProjet,
|
||||||
bornesReference,
|
bornesReference,
|
||||||
deriveProjet,
|
deriveProjet,
|
||||||
dimancheDeLaSemaine,
|
dimancheDeLaSemaine,
|
||||||
ecartJours,
|
ecartJours,
|
||||||
|
etatProjet,
|
||||||
formaterDateLongue,
|
formaterDateLongue,
|
||||||
|
formaterHorizon,
|
||||||
formulerDerive,
|
formulerDerive,
|
||||||
lundiDeLaSemaine,
|
lundiDeLaSemaine,
|
||||||
|
projetADater,
|
||||||
projetFiltre,
|
projetFiltre,
|
||||||
|
projetFiltreEtat,
|
||||||
semaineISO,
|
semaineISO,
|
||||||
versUTC,
|
versUTC,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
@@ -85,19 +91,28 @@ export function creerEchelle(fenetre) {
|
|||||||
/**
|
/**
|
||||||
* Reconstruit toute la frise.
|
* 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
|
* **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
|
* répond à « je ne regarde que les projets clients ». Le **filtre par état** en
|
||||||
* la ligne de libellé, grisée, et n'ôte que les barres : il répond à « je sais
|
* fait autant, mais en élargissant à chaque case cochée plutôt qu'en resserrant,
|
||||||
* que ce projet existe, mais il encombre ma frise ».
|
* 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} refs éléments DOM ciblés (libelles, entete, couloirs)
|
||||||
* @param {object} planning l'état courant
|
* @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 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);
|
const visibles = retenus.filter((projet) => !projet.hidden);
|
||||||
|
|
||||||
refs.entete.style.width = `${echelle.largeur}px`;
|
refs.entete.style.width = `${echelle.largeur}px`;
|
||||||
@@ -303,6 +318,8 @@ function construireLibelles(projets, tagsActifs, curseur) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
||||||
|
const etat = etatProjet(projet);
|
||||||
|
|
||||||
const ligne = document.createElement('div');
|
const ligne = document.createElement('div');
|
||||||
ligne.className =
|
ligne.className =
|
||||||
'libelle-projet' +
|
'libelle-projet' +
|
||||||
@@ -310,6 +327,10 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
|||||||
(sousCurseur ? ' libelle-projet--curseur' : '');
|
(sousCurseur ? ' libelle-projet--curseur' : '');
|
||||||
ligne.style.setProperty('--couleur-projet', projet.color);
|
ligne.style.setProperty('--couleur-projet', projet.color);
|
||||||
ligne.dataset.projet = projet.id;
|
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');
|
const plier = document.createElement('button');
|
||||||
plier.className = 'plier';
|
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.
|
// reste le seul endroit où les deux se lisent toujours en entier.
|
||||||
nom.title =
|
nom.title =
|
||||||
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
|
(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
|
// 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ù
|
// 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.
|
// 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));
|
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));
|
const derive = formulerDerive(deriveProjet(projet));
|
||||||
if (derive) {
|
if (derive) {
|
||||||
const etiquette = document.createElement('span');
|
const etiquette = document.createElement('span');
|
||||||
@@ -395,6 +431,19 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
|
|||||||
return ligne;
|
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
|
// Couloirs
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -411,12 +460,16 @@ function construireCouloirs(projets, echelle, curseur) {
|
|||||||
const couloirProjet = document.createElement('div');
|
const couloirProjet = document.createElement('div');
|
||||||
couloirProjet.className = `couloir couloir--projet${surLeProjet ? ' couloir--curseur' : ''}`;
|
couloirProjet.className = `couloir couloir--projet${surLeProjet ? ' couloir--curseur' : ''}`;
|
||||||
couloirProjet.style.setProperty('--couleur-projet', projet.color);
|
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
|
// 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
|
// 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
|
// 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.
|
// 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);
|
if (barre) couloirProjet.append(barre);
|
||||||
const fantome = construireFantome(projet, echelle);
|
const fantome = construireFantome(projet, echelle);
|
||||||
if (fantome) couloirProjet.append(fantome);
|
if (fantome) couloirProjet.append(fantome);
|
||||||
@@ -554,6 +607,53 @@ function construireBarreCumulative(projet, echelle) {
|
|||||||
return conteneur;
|
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. */
|
/** Barre fantôme du projet, reflétant l'étendue de référence. */
|
||||||
function construireFantome(projet, echelle) {
|
function construireFantome(projet, echelle) {
|
||||||
const reference = bornesReference(projet);
|
const reference = bornesReference(projet);
|
||||||
|
|||||||
@@ -6,37 +6,56 @@ import {
|
|||||||
MAX_LONGUEUR_TAG,
|
MAX_LONGUEUR_TAG,
|
||||||
VERSION_FORMAT,
|
VERSION_FORMAT,
|
||||||
ajouterJours,
|
ajouterJours,
|
||||||
|
ajouterMois,
|
||||||
ajouterPhase,
|
ajouterPhase,
|
||||||
|
bornesHorizon,
|
||||||
bornesPlanning,
|
bornesPlanning,
|
||||||
bornesProjet,
|
bornesProjet,
|
||||||
bornesReference,
|
bornesReference,
|
||||||
cleTag,
|
cleTag,
|
||||||
|
clotureEcraseDesStatuts,
|
||||||
creerPhase,
|
creerPhase,
|
||||||
creerProjet,
|
creerProjet,
|
||||||
dateValide,
|
dateValide,
|
||||||
|
decalerHorizon,
|
||||||
|
definirHorizon,
|
||||||
deplacerPhase,
|
deplacerPhase,
|
||||||
|
dernierJourDuMois,
|
||||||
dimancheDeLaSemaine,
|
dimancheDeLaSemaine,
|
||||||
deriveProjet,
|
deriveProjet,
|
||||||
duree,
|
duree,
|
||||||
|
ecarterProjet,
|
||||||
ecartJours,
|
ecartJours,
|
||||||
|
ecartMois,
|
||||||
|
empriseProjet,
|
||||||
|
entreesParMois,
|
||||||
|
etatProjet,
|
||||||
fabriquerId,
|
fabriquerId,
|
||||||
figerReference,
|
figerReference,
|
||||||
formaterDateLongue,
|
formaterDateLongue,
|
||||||
|
formaterHorizon,
|
||||||
formaterMois,
|
formaterMois,
|
||||||
formaterPeriode,
|
formaterPeriode,
|
||||||
formulerDerive,
|
formulerDerive,
|
||||||
libererReference,
|
libererReference,
|
||||||
lundiDeLaSemaine,
|
lundiDeLaSemaine,
|
||||||
modifierPhase,
|
modifierPhase,
|
||||||
|
moisValide,
|
||||||
normaliserTags,
|
normaliserTags,
|
||||||
phasesParMois,
|
|
||||||
planningVide,
|
planningVide,
|
||||||
pourEcriture,
|
pourEcriture,
|
||||||
|
premierJourDuMois,
|
||||||
|
projetADater,
|
||||||
projetFiltre,
|
projetFiltre,
|
||||||
|
projetFiltreEtat,
|
||||||
|
reanimerProjet,
|
||||||
redimensionnerPhase,
|
redimensionnerPhase,
|
||||||
remplacerPhase,
|
remplacerPhase,
|
||||||
|
rouvrirProjet,
|
||||||
semaineISO,
|
semaineISO,
|
||||||
|
supprimerPhase,
|
||||||
teinteTag,
|
teinteTag,
|
||||||
|
terminerProjet,
|
||||||
tousLesTags,
|
tousLesTags,
|
||||||
validerPlanning,
|
validerPlanning,
|
||||||
} from '../js/model.js';
|
} 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 -----------------------------------------------------------
|
// --- identifiants -----------------------------------------------------------
|
||||||
|
|
||||||
describe('fabriquerId', () => {
|
describe('fabriquerId', () => {
|
||||||
@@ -458,7 +528,7 @@ describe('bornes', () => {
|
|||||||
|
|
||||||
// --- regroupement par mois --------------------------------------------------
|
// --- regroupement par mois --------------------------------------------------
|
||||||
|
|
||||||
describe('phasesParMois', () => {
|
describe('entreesParMois', () => {
|
||||||
const planning = {
|
const planning = {
|
||||||
version: VERSION_FORMAT,
|
version: VERSION_FORMAT,
|
||||||
projects: [
|
projects: [
|
||||||
@@ -493,7 +563,7 @@ describe('phasesParMois', () => {
|
|||||||
const noms = (groupe) => groupe.entrees.map(({ phase: p }) => p.name);
|
const noms = (groupe) => groupe.entrees.map(({ phase: p }) => p.name);
|
||||||
|
|
||||||
test('rattache chaque phase au seul mois de son début', () => {
|
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(cles(groupes), ['2026-09', '2026-11']);
|
||||||
assert.deepEqual(noms(groupes[0]), ['Cadrage', 'Rédaction']);
|
assert.deepEqual(noms(groupes[0]), ['Cadrage', 'Rédaction']);
|
||||||
// « Rédaction » traverse octobre et novembre sans y réapparaître.
|
// « 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', () => {
|
test('ne fabrique pas de mois vide entre deux mois pleins', () => {
|
||||||
assert.equal(phasesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
|
assert.equal(entreesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
|
||||||
assert.equal(cles(phasesParMois(planning)).includes('2026-10'), 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', () => {
|
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', () => {
|
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', () => {
|
test('applique le filtre par tags, et écarte les projets masqués', () => {
|
||||||
assert.deepEqual(cles(phasesParMois(planning, ['interne'])), ['2026-09']);
|
assert.deepEqual(cles(entreesParMois(planning, ['interne'])), ['2026-09']);
|
||||||
assert.deepEqual(noms(phasesParMois(planning, ['interne'])[0]), ['Rédaction']);
|
assert.deepEqual(noms(entreesParMois(planning, ['interne'])[0]), ['Rédaction']);
|
||||||
|
|
||||||
const masque = {
|
const masque = {
|
||||||
projects: planning.projects.map((p) =>
|
projects: planning.projects.map((p) =>
|
||||||
p.id === 'portail' ? { ...p, hidden: true } : 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', () => {
|
test('rend une liste vide plutôt que null quand rien ne reste', () => {
|
||||||
assert.deepEqual(phasesParMois(planningVide()), []);
|
assert.deepEqual(entreesParMois(planningVide()), []);
|
||||||
assert.deepEqual(phasesParMois(planning, ['inconnu']), []);
|
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