Compare commits

...

4 Commits

Author SHA1 Message Date
d6f3b2961b Décrire le cycle de vie dans le README, et l'illustrer
Le jeu de démonstration reçoit trois projets sans phase — un envisagé,
un dont l'horizon est passé, un écarté — sans quoi la fonctionnalité
reste invisible à qui découvre l'outil.

Corrige aussi « node --test tests/ », que Node 26 refuse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:58:36 +02:00
60a91ba5b2 Dessiner l'horizon sur la frise, l'éditer et le glisser
La barre d'un projet envisagé est pâle et hachurée, aux bords fondus.
Elle se glisse et se redimensionne comme une phase, mais accrochée au
mois : le geste existe, il ne permet pas de poser une date qu'on n'a
pas. Le panneau ajoute deux champs de mois et trois crans.

Une barre « Afficher » filtre par état, disjonctive là où celle des
tags est conjonctive — d'où des boutons carrés et non des pastilles.
Les écartés en sont décochés au départ.

Engager démarre au premier jour de l'horizon, et clore un projet
demande confirmation quand il reste des phases à écraser.

Au passage : releasePointerCapture peut lever quand le pointeur a
disparu, ce qui sautait l'application du geste. Le nettoyage en est
maintenant isolé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:58:05 +02:00
8404f12656 Porter le cycle de vie et l'horizon dans le modèle, en version 5
L'état n'est pas un champ : il se lit dans horizon, completedDate,
discardedDate et la présence de phases. Les phases commandent, sauf
quand on a prononcé quelque chose.

L'emprise d'un projet retombe sur son horizon faute de phases, et une
saisie n'est jamais réécrite par un calcul : un projet qui perd sa
dernière phase retrouve l'horizon qu'il avait déclaré.

Clore un projet termine aussi toutes ses phases, seule opération du
cycle qui écrase des données. phasesParMois devient entreesParMois :
elle émet aussi les projets sans phase, à leur horizon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:57:34 +02:00
3c7cff3ac6 Décider d'un cycle de vie pour les projets, et d'un horizon
Une petite mairie a plus de projets imaginables que de temps pour les
mener, et la crainte première est d'en oublier. La section 27 pose les
quatre états — envisagé, engagé, terminé, écarté —, l'horizon en mois
des envisagés, et surtout ce qu'on écarte : la matrice d'Eisenhower, un
backlog séparé, une vue de rétrospective.

Elle garde aussi la trace de ce que le premier essai a corrigé, dont
l'immobilité de la barre d'horizon, abandonnée à l'usage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:56:14 +02:00
13 changed files with 2384 additions and 122 deletions

114
README.md
View File

@@ -65,7 +65,7 @@ alors `data/projets.json`, comme avant.
```sh
go test ./... # le serveur
node --test tests/ # le modèle métier
node --test # le modèle métier
```
## Prise en main
@@ -75,13 +75,18 @@ ce nom présélectionné : on tape simplement par-dessus. Il n'y a aucun formula
ici, ni pour une phase. Tout s'applique au fil de la saisie, et un projet créé par erreur se
supprime depuis son propre panneau.
Il naît **envisagé** : sans phase, avec pour seul ancrage un horizon d'un mois, celui où on le
crée. On le repousse à sa place, et on l'engage le jour où on lui donne des dates fermes — voir
[le cycle de vie](#le-cycle-de-vie-dun-projet) juste en dessous.
Dans la colonne de gauche, le **triangle** plie et déplie le couloir d'un projet, et un clic sur
son **nom** ouvre ses paramètres : nom, couleur, tags. Deux commandes apparaissent au survol de
la ligne, sur son bord droit :
son **nom** ouvre ses paramètres : nom, couleur, tags, horizon. Deux commandes apparaissent au
survol de la ligne, sur son bord droit :
- **`+`** ajoute une phase à la suite de la dernière, et ouvre son panneau de détail ;
- **`⋯`** ouvre le menu du projet : ajouter une phase, plier, masquer de la frise, renommer,
figer la référence, supprimer. Un clic droit sur le libellé ouvre le même menu.
déclarer terminé, écarter, figer la référence, supprimer. Un clic droit sur le libellé ouvre le
même menu.
Il n'y a pas de commande séparée pour créer un **jalon** : c'est une phase dont on coche la case
« Jalon » dans le panneau, ce qui ramène sa fin sur son début. La bascule joue dans les deux sens
@@ -96,6 +101,7 @@ Une fois un projet en place :
| Geste | Effet |
|---|---|
| Glisser le corps d'une barre | Décale la phase, durée conservée |
| Glisser la barre pâle d'un envisagé | Décale son horizon, au mois |
| Glisser le bord d'une barre | Change la date de début ou de fin |
| Glisser le bord gauche du panneau | Change sa largeur (double-clic : d'origine) |
| Clic sur une barre ou son nom | Ouvre le panneau de détail |
@@ -111,6 +117,65 @@ Une fois un projet en place :
Faire défiler jusqu'au bord droit élargit la frise vers le futur, et jusqu'au bord gauche
vers le passé. Le bouton **Aujourd'hui** ramène la vue sur la date du jour.
### Le cycle de vie d'un projet
On a presque toujours plus de projets imaginables que de temps pour les mener, et l'enjeu n'est pas
de les oublier en attendant. Un projet traverse donc quatre états, dont **deux se déduisent tout
seuls** — il n'y a rien à tenir à jour :
| État | Comment on le sait |
|---|---|
| **Envisagé** | il n'a qu'un horizon, pas encore de phase |
| **Engagé** | on lui a donné au moins une phase datée |
| **Terminé** | toutes ses phases sont finies **et** on l'a acté |
| **Écarté** | on a décidé de ne pas le faire |
Le flux tient en une phrase : **une idée entre avec un horizon, on la situe, on l'engage en lui
donnant des dates, elle vit sur la frise, elle s'achève ou on l'écarte.**
Engager n'est donc pas un bouton, c'est un geste : ajouter la première phase. Terminer et écarter,
eux, s'actent — livré n'est pas clos, et abandonner est une décision. Leurs deux commandes sont au
bas du **panneau du projet**, sous le champ qui dit où il en est, et aussi dans son menu **`⋯`**.
**Déclarer un projet terminé termine aussi toutes ses phases**, jalons compris : sans quoi la frise
se contredirait, avec des tâches « à venir » dans un projet fini. C'est la seule opération du cycle
qui écrase quelque chose — le statut d'une phase bloquée est perdu, et rouvrir le projet ne le
rendra pas. Elle demande donc confirmation, mais seulement s'il reste des phases à changer.
#### L'horizon
Un projet envisagé n'est jamais hors du temps : il porte un **horizon en mois** — « mars 2027 »,
« de mars à septembre 2027 », « courant 2028 » — et il se pose **sur la frise**, à cet horizon.
C'est là que l'arbitrage devient concret : quand le printemps 2027 est déjà chargé et que six
barres pâles s'y superposent, on décide devant l'évidence.
Sa barre est hachurée et ses bords sont fondus, parce que ces dates ne sont pas fermes. Elle se
glisse et se redimensionne comme une barre de phase, mais **accrochée au mois** là où celle d'une
phase l'est au lundi : le geste est possible, il ne permet simplement pas de poser une date qu'on
n'a pas. Le panneau du projet offre en plus trois crans — `3 mois`, `+3 mois`, `+1 an` — et les
deux mois au clavier.
La largeur de la barre *est* l'incertitude, et elle se resserre à mesure que le projet mûrit :
d'abord une plage large, puis un mois, puis les dates fermes de l'engagement.
**Engager démarre à l'horizon** : la première phase d'un projet envisagé naît au premier jour de son
horizon, là où la barre pâle commençait. On précise ensuite ses dates comme n'importe quelle phase.
Un projet dont l'**horizon est passé** — ou qui n'en a jamais eu — le dit sur sa ligne, par une
pastille **« à dater »**. C'est ce qui empêche la liste des envisagés de devenir une décharge : on
repousse, ou on écarte, mais on ne laisse rien dormir.
#### Écarter n'est pas supprimer
Un projet écarté quitte la frise, mais il garde tout — notes comprises, où l'on aura écrit
pourquoi on a dit non. Les mêmes idées reviennent tous les deux ans, portées par d'autres, et
savoir qu'on les a déjà examinées vaut mieux que de refaire le débat.
On le retrouve en cochant **Écartés** dans la barre *Afficher*, et **Remettre en jeu** l'en sort. Il
n'y a alors aucune destination à choisir : le projet retrouve seul l'état que ses données
commandent — engagé s'il a des phases, envisagé sinon. Son horizon étant resté celui d'avant, il
réclamera aussitôt une date, et c'est voulu.
### Au clavier, à la vim
La frise a un **curseur** : la ligne sur laquelle portent les raccourcis. Il se pose en cliquant une
@@ -188,6 +253,12 @@ liste, projets mêlés, groupés par mois — celui où chaque phase **commence*
« qu'est-ce qui arrive le mois prochain », que la frise donne mal parce qu'elle sépare les projets
en couloirs.
Les **projets sans phase** y figurent aussi, au premier mois de leur horizon : un projet visé pour
mars 2027 arrive bel et bien en mars 2027, et la liste répond à « qu'est-ce qui arrive », pas à
« qu'est-ce qui est déjà décidé ». Leur ligne occupe les mêmes colonnes, en italique, et porte
« Envisagé » là où les autres portent leur statut. Un projet **écarté** avant d'avoir jamais été
engagé s'y retrouve de la même façon — barré, et seulement si l'on a coché *Écartés*.
Chaque ligne se lit dans cet ordre : quand — la période, `31 → 13 nov.` quand la phase déborde du
mois —, chez qui — le projet et ses tags —, puis quoi — l'intitulé de la tâche ou du jalon — et
enfin son statut. Une phase longue n'est **pas** répétée dans les mois qu'elle traverse : elle
@@ -224,10 +295,27 @@ portant les deux. Le filtre n'est pas enregistré, il ne vaut que pour la sessio
Les barres s'accrochent au lundi pendant le glisser. Pour une date au jour près, passer
par les champs du panneau de détail.
### Afficher par état
Une seconde barre, **Afficher**, paraît au-dessus dès que le planning compte plus d'un état. Elle
montre chacun avec son compte — *Envisagés (4)*, *Écartés (7)* — et un clic l'affiche ou le masque.
Les écartés en sont décochés au départ : le cimetière ne s'ouvre que si on le demande.
Elle ne joue **pas** dans le même sens que le filtre par tags, et c'est pourquoi elle ne lui
ressemble pas : un projet porte plusieurs tags mais un seul état, si bien que cocher un tag de plus
resserre la sélection quand cocher un état de plus l'élargit. D'où « Filtrer » d'un côté, « Afficher »
de l'autre, et des boutons carrés plutôt que des pastilles. Comme le filtre par tags, celui-ci vaut
pour les deux vues et n'est pas enregistré.
## Ce que fait l'outil
- **Plusieurs projets**, chacun décomposé en **phases** ayant un nom, des dates de début et de fin,
un statut, des notes. Une phase peut être un **jalon** (une date unique, rendue en losange).
- **Un cycle de vie** — envisagé, engagé, terminé, écarté — dont deux états se déduisent des
données. Ce qu'on envisage sans pouvoir encore le mener vit sur la même frise que le reste, et
ne s'y perd pas.
- **Un horizon en mois** pour les projets envisagés, rendu en barre hachurée aux bords fondus : la
largeur dit l'incertitude, et un horizon dépassé réclame de lui-même qu'on tranche.
- **Des notes en markdown**, sur un projet comme sur une phase : titres, listes, cases à cocher,
liens. Elles occupent toute la hauteur restante du panneau, et se cochent d'un clic dans l'aperçu.
- **Une frise commune**, les projets empilés en couloirs, pour les comparer d'un coup d'œil.
@@ -249,7 +337,7 @@ par les champs du panneau de détail.
- **Une vue par mois** : toutes les tâches et tous les jalons en une liste, projets mêlés, groupés
par mois de début. Elle se lit, elle ne s'édite pas.
- **Des tags sur les projets**, et un filtre qui restreint les deux vues à ceux qui les portent
tous.
tous — doublé d'un affichage par état, qui joue lui en sens inverse.
- **Repère « aujourd'hui »** et masquage d'un projet.
## Ce que l'outil ne fait pas
@@ -257,6 +345,11 @@ par les champs du panneau de détail.
Volontairement : pas de dépendances entre phases, pas de sous-tâches, pas de collaboration temps
réel, pas d'authentification, pas de gestion de charge par personne, pas de notifications.
**Pas de note de priorité non plus, ni de matrice.** L'arbitrage passe par l'horizon : dire « celui-ci
au printemps, celui-là on verra en 2028 » *est* la décision, et un score posé à côté des dates se
périmerait en silence. Ce qui manque encore, c'est le départage fin entre deux projets visant le même
trimestre — il attend d'avoir un endroit où ranger, la frise sachant situer mais pas ordonner.
Les durées se comptent en **jours calendaires** : ni week-ends ni jours fériés ne sont gérés. À
l'échelle de phases de plusieurs semaines, la distinction n'apporte rien.
@@ -308,8 +401,9 @@ node --test # la logique métier
go test ./... # le serveur
```
Côté navigateur : validation des dates, bornes d'un projet, calcul de dérive, génération des
identifiants, normalisation et filtrage des tags, regroupement des phases par mois. Côté serveur : lecture et écriture du planning,
Côté navigateur : validation des dates et des horizons, bornes et emprise d'un projet, calcul de
dérive, génération des identifiants, normalisation et filtrage des tags, regroupement des phases par
mois, dérivation de l'état d'un projet et transitions de son cycle de vie. Côté serveur : lecture et écriture du planning,
refus des corps invalides, rotation des sauvegardes, écriture atomique, résolution du chemin de
données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node et à Go suffisent.
@@ -319,12 +413,12 @@ données. Aucune dépendance de part ni d'autre, les lanceurs intégrés à Node
|---|---|
| `main.go` | Sert l'interface embarquée, expose `GET`/`PUT` sur `/api/data`, écrit les sauvegardes |
| `build.sh` | Compile les binaires des cinq plateformes dans `dist/` |
| `js/model.js` | Données et règles métier : CRUD, validation, bornes, référence, dérive, tags, regroupement par mois |
| `js/model.js` | Données et règles métier : CRUD, validation, bornes, cycle de vie et horizon, référence, dérive, tags, regroupement par mois |
| `js/storage.js` | Dialogue avec le serveur, sauvegarde debouncée, indicateur d'état |
| `js/timeline.js` | Rendu de la frise : échelle, couloirs, barres, jalons, pli/dépli, curseur |
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes, ancrage sur le mois courant |
| `js/mois.js` | Rendu de la vue par mois : groupes, lignes de phase et de projet envisagé, ancrage sur le mois courant |
| `js/tags.js` | Pastilles de tags, partagées par les deux vues |
| `js/drag.js` | Glisser et redimensionner les barres |
| `js/drag.js` | Glisser et redimensionner les barres — au lundi pour une phase, au mois pour un horizon |
| `js/clavier.js` | Table des raccourcis à la vim, préfixes `g` `z` `d`, garde-fou de saisie |
| `js/markdown.js` | Analyse du markdown des notes, aplatissement pour les infobulles. Sans DOM, donc testable sous Node |
| `js/notes.js` | Rendu du markdown en DOM et bloc de notes — aperçu, barre d'outils, saisie — partagé par les deux panneaux |

View File

@@ -21,6 +21,10 @@
--accent: #3b82f6;
--danger: #dc2626;
--aujourdhui: #ef4444;
/* Réservé à ce qui réclame un arbitrage sans être une erreur : un horizon
dépassé n'est pas une faute, c'est une décision à reprendre. D'où une teinte
distincte de `--danger`, qui signale ce qui ne va pas. */
--attention: #b45309;
--hauteur-barre: 26px;
/* Barre cumulative d'un projet, et fantôme qui la double. Ils sont frères,
@@ -53,6 +57,7 @@
--texte: #f1f5f9;
--texte-doux: #94a3b8;
--ombre: 0 1px 2px rgb(0 0 0 / 0.3);
--attention: #fbbf24;
}
}
@@ -209,6 +214,68 @@ button[aria-pressed="true"] {
white-space: nowrap;
}
/* --- filtre par état ------------------------------------------------------ */
/* Même bandeau que le filtre par tags, mais des boutons carrés et non des
pastilles rondes : les deux filtres jouent en sens contraire — cocher un tag
resserre, cocher un état élargit — et rien ne serait plus trompeur que deux
commandes identiques aux effets opposés. */
.etats {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 16px;
background: var(--surface);
border-bottom: 1px solid var(--bordure);
flex-shrink: 0;
}
.etats__liste {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.etat-filtre {
font-size: 12px;
padding: 3px 10px;
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon);
background: var(--surface);
color: var(--texte-doux);
cursor: pointer;
white-space: nowrap;
}
/* Un état affiché est plein, un état masqué est creux. La différence se lit au
remplissage et pas seulement à la teinte : la barre en porte quatre côte à
côte, et la couleur seule n'y suffirait pas. */
.etat-filtre[aria-pressed="true"] {
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
border-color: var(--accent);
color: var(--accent);
font-weight: 500;
}
.etat-filtre:hover:not(:disabled) {
border-color: var(--accent);
}
/* Le dernier état affiché ne se décoche pas : tout masquer reviendrait à vider
la frise sans qu'aucun geste ne l'ait demandé. */
.etat-filtre:disabled {
cursor: default;
opacity: 0.75;
}
/* Les écartés se distinguent des trois états vivants, cochés ou non : c'est le
seul dont l'ouverture change la nature de ce qu'on regarde. */
.etat-filtre[data-etat="discarded"][aria-pressed="true"] {
background: color-mix(in srgb, var(--attention) 12%, var(--surface));
border-color: var(--attention);
color: var(--attention);
}
/* --- tags ----------------------------------------------------------------- */
/* Chaque pastille porte sa teinte dans `--teinte-tag`, calculée par hachage du
@@ -539,6 +606,48 @@ button[aria-pressed="true"] {
color: #16a34a;
}
/* Un projet envisagé dont l'horizon est passé — ou qui n'en a jamais eu. Même
forme que la dérive, dont c'est le pendant : l'une dit qu'un projet engagé a
glissé, l'autre qu'un projet envisagé attend qu'on tranche.
Volontairement sobre : ce signal doit rester lisible sur une colonne qui en
porte plusieurs, sans devenir l'alerte qu'on apprend à ignorer. */
.libelle-projet__adater {
flex: 0 0 auto;
font-size: 11px;
font-weight: 500;
padding: 1px 5px;
border-radius: 10px;
white-space: nowrap;
background: color-mix(in srgb, var(--attention) 15%, transparent);
color: var(--attention);
cursor: default;
}
/* --- états du cycle de vie ------------------------------------------------ */
/* Un projet terminé **reste** sur la frise : elle est notre mémoire, et l'en
retirer perdrait le bilan (décision 27). Il s'éteint donc au lieu de
disparaître — assez pour que l'œil aille d'abord à ce qui est vivant, pas
assez pour qu'on ne puisse plus le lire.
L'écarté va plus loin : il n'est visible que si on l'a demandé au filtre, et
le gris dit qu'il ne se passera plus rien. */
.libelle-projet[data-etat="completed"],
.couloir--projet[data-etat="completed"] {
opacity: 0.65;
}
.libelle-projet[data-etat="discarded"],
.couloir--projet[data-etat="discarded"] {
opacity: 0.5;
filter: grayscale(0.85);
}
/* Le nom d'un projet écarté se barre : l'opacité seule dirait « en retrait »,
là où il faut lire « on ne le fera pas ». */
.libelle-projet[data-etat="discarded"] .libelle-projet__nom {
text-decoration: line-through;
}
.plier {
border: 0;
background: none;
@@ -731,10 +840,17 @@ button[aria-pressed="true"] {
background: rgb(255 255 255 / 0.5);
}
.barre:hover .poignee {
.barre:hover .poignee,
.barre-horizon:hover .poignee {
opacity: 1;
}
/* Sur fond pâle et hachuré, un voile blanc à demi transparent ne se voit pas :
les poignées d'un horizon prennent la teinte de son projet. */
.barre-horizon .poignee {
background: color-mix(in srgb, var(--couleur-projet) 55%, transparent);
}
.poignee--debut {
left: 0;
border-radius: 4px 0 0 4px;
@@ -858,6 +974,67 @@ button[aria-pressed="true"] {
height: 4px;
}
/* --- horizon d'un projet envisagé ---------------------------------------- */
/* La barre d'un projet qui n'a pas encore de phase. Tout, ici, sert à dire que
les dates ne sont pas fermes (décision 27) :
- les **bords fondus** (`mask-image`), qui suppriment le trait net qu'on
pourrait prendre pour une date de début ou de fin ;
- les **hachures obliques**, qui la distinguent au premier coup d'œil d'une
barre de phase, y compris pour qui ne perçoit pas la nuance de teinte.
Elle se glisse et se redimensionne comme une barre de phase, mais accrochée au
**mois** plutôt qu'au lundi : le geste est possible, il ne permet simplement
pas de poser une date qu'on n'a pas.
Sa largeur porte à elle seule l'incertitude : un mois pour un projet mûr, une
large plage pour un projet vague. */
.barre-horizon {
position: absolute;
top: calc((var(--hauteur-ligne) - var(--hauteur-barre)) / 2);
height: var(--hauteur-barre);
border-radius: 4px;
color: var(--texte-doux);
font-size: 12px;
font-style: italic;
line-height: var(--hauteur-barre);
cursor: grab;
user-select: none;
z-index: 2;
background:
repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--couleur-projet) 26%, transparent),
color-mix(in srgb, var(--couleur-projet) 26%, transparent) 5px,
transparent 5px,
transparent 10px
),
color-mix(in srgb, var(--couleur-projet) 12%, var(--surface));
mask-image: linear-gradient(
90deg,
transparent,
#000 14px,
#000 calc(100% - 14px),
transparent
);
}
/* Un horizon dépassé se teinte, en écho à la pastille « à dater » de la colonne.
La marque est portée deux fois — dans la colonne et sur la frise — parce que
les deux se lisent séparément : on parcourt l'une, on balaie l'autre. */
.barre-horizon--depasse {
background:
repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--attention) 30%, transparent),
color-mix(in srgb, var(--attention) 30%, transparent) 5px,
transparent 5px,
transparent 10px
),
color-mix(in srgb, var(--attention) 12%, var(--surface));
}
/* --- panneau de détail --------------------------------------------------- */
/* Non modal : aucun voile derrière lui, la frise reste manipulable pendant
@@ -1011,6 +1188,106 @@ button[aria-pressed="true"] {
margin-top: 4px;
}
/* Les deux actes du cycle de vie, côte à côte et à parts égales : aucun des deux
n'est plus courant que l'autre, et les mettre l'un sous l'autre suggérerait
une progression qui n'existe pas — on termine *ou* on écarte. */
.panneau__actions--cycle {
display: flex;
gap: 8px;
}
.panneau__actions--cycle button {
flex: 1 1 0;
white-space: nowrap;
}
/* --- cycle de vie dans le panneau ---------------------------------------- */
/* Où en est le projet. Une ligne de texte et non un sélecteur : l'état se
déduit des données (décision 27), il n'y a rien à y choisir. */
.panneau__etat {
margin: 0;
font-size: var(--taille-annotation);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--texte-doux);
}
.panneau__etat[data-etat="discarded"] {
color: var(--attention);
}
.horizon {
border: 1px solid var(--bordure);
border-radius: var(--rayon);
padding: 8px 10px 10px;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.horizon > legend {
font-size: var(--taille-annotation);
color: var(--texte-doux);
padding: 0 4px;
}
/* Les deux bornes côte à côte : c'est un intervalle, et le lire sur deux lignes
ferait perdre ce que sa largeur veut dire. */
.horizon__intervalle {
display: flex;
gap: 8px;
}
.horizon__intervalle label {
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 3px;
font-size: var(--taille-annotation);
color: var(--texte-doux);
}
.horizon__crans {
display: flex;
gap: 6px;
}
.horizon__crans button {
flex: 1 1 0;
padding: 4px 6px;
font-size: var(--taille-annotation);
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon);
background: var(--surface);
color: var(--texte);
cursor: pointer;
}
.horizon__crans button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.horizon__crans button:disabled {
opacity: 0.45;
cursor: default;
}
.horizon__aide {
margin: 0;
font-size: var(--taille-annotation);
line-height: 1.35;
color: var(--texte-doux);
}
/* Un horizon passé le dit ici comme il le dit sur la frise et dans la colonne :
c'est le même signal, vu depuis les trois endroits où l'on travaille. */
.horizon__aide[data-alerte="true"] {
color: var(--attention);
}
/* --- bloc de notes -------------------------------------------------------- */
/* Le bloc prend toute la hauteur que les champs au-dessus lui laissent. C'est
@@ -1791,6 +2068,48 @@ button.mois-entree__plier {
color: var(--texte-doux);
}
/* --- ligne d'un projet envisagé ------------------------------------------ */
/* Mêmes colonnes qu'une ligne de phase — la liste reste une grille qu'on lit en
diagonale —, mais tout y dit que rien n'est arrêté : l'italique de l'intitulé
et les hachures de la marque, qui reprennent celles de la barre sur la frise.
Ce qu'on a appris à lire d'un côté vaut de l'autre. */
.mois-entree--envisage .mois-entree__nom {
font-style: italic;
color: var(--texte-doux);
}
.mois-entree--envisage .mois-entree__marque {
background: repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--couleur-projet) 60%, transparent),
color-mix(in srgb, var(--couleur-projet) 60%, transparent) 3px,
transparent 3px,
transparent 6px
);
border: 1px solid color-mix(in srgb, var(--couleur-projet) 45%, transparent);
}
.mois-entree__statut[data-statut="considered"],
.mois-entree__statut[data-statut="discarded"] {
font-style: italic;
}
/* Un projet écarté qui n'a jamais été engagé passe aussi par cette ligne — il
n'a que son horizon pour se situer. Il s'y lit comme sur la frise : éteint et
barré, pour qu'on ne le confonde pas avec ce qui va arriver. */
.mois-entree--envisage[data-etat="discarded"] {
opacity: 0.55;
}
.mois-entree--envisage[data-etat="discarded"] .mois-entree__projet {
text-decoration: line-through;
}
.mois-entree--envisage[data-etat="discarded"] .mois-entree__statut {
color: var(--attention);
}
.mois__vide {
max-width: 1080px;
margin: 48px auto;

View File

@@ -156,6 +156,40 @@
"notes": ""
}
]
},
{
"id": "refonte-intranet",
"name": "Refonte de l'intranet",
"color": "#8b5cf6",
"tags": ["interne", "web"],
"collapsed": false,
"hidden": false,
"notes": "Envisagé, pas encore engagé : l'horizon dit qu'on y viendra, pas quand exactement.\n\n- [ ] recenser ce que l'ancien intranet porte encore\n- [ ] estimer le budget\n",
"horizon": { "start": "2027-04", "end": "2027-09" },
"phases": []
},
{
"id": "archivage",
"name": "Archivage des dossiers papier",
"color": "#06b6d4",
"tags": ["interne"],
"collapsed": false,
"hidden": false,
"notes": "Horizon dépassé : à repousser ou à écarter.",
"horizon": { "start": "2026-03", "end": "2026-05" },
"phases": []
},
{
"id": "application-mobile",
"name": "Application mobile",
"color": "#ec4899",
"tags": ["client"],
"collapsed": false,
"hidden": false,
"notes": "Écartée : le site refondu sera responsive, une application native ne se justifie pas.\n\nÀ reprendre si un besoin hors ligne apparaît.",
"horizon": { "start": "2027-01", "end": "2027-06" },
"discardedDate": "2026-06-22",
"phases": []
}
]
}

View File

@@ -827,3 +827,228 @@ vont par paires. La génération aurait produit une liste exacte et illisible.
Elle se lit, elle ne s'édite pas (décision 24) : rien n'y aurait de sens à désigner. `j`, `k`, `gg`
et `G` y font donc ce qu'ils font dans un document — ils déroulent la liste. `gm` et `ga` marchent
dans les deux vues, le reste attend le retour à la frise.
## 27. Le cycle de vie d'un projet, et l'horizon
L'outil est employé pour piloter les chantiers d'une petite mairie : beaucoup plus de projets
imaginables que de temps pour les mener, et une crainte première — **ne rien oublier**. Une idée
qu'on ne peut pas traiter cette année ne doit pas disparaître pour autant.
Or l'outil ne connaissait que les projets déjà planifiés. Tout ce qui n'était pas encore engagé
vivait ailleurs — un carnet, une tête, un compte rendu de réunion — de sorte que l'arbitrage se
rendait **sans voir le planning**, alors que c'est précisément le planning qui dit s'il y a la
place.
### La priorité est une décision d'avant l'engagement
Le point de départ était une matrice d'Eisenhower dans le panneau d'un projet. Deux objections
l'ont déplacée.
L'urgence est **déjà dans les dates**. La resaisir crée un champ qui se périme en silence : un
projet coché « urgent » en juin ne l'est plus en septembre, et rien ne viendra le décocher.
Surtout, une fois un projet planifié, **l'arbitrage est déjà rendu** — ce sont ses dates qui le
portent. Une priorité posée sur un projet engagé ne déciderait plus rien, elle commenterait. Le
lieu de la priorisation est donc en amont, sur ce qui n'est pas encore engagé.
### Quatre états, dont deux se déduisent
Un projet traverse quatre états, et deux d'entre eux se lisent dans les données sans qu'on ait
rien à tenir à jour :
| État | Comment on le sait |
|---|---|
| **Envisagé** | il n'a qu'un horizon, pas encore de phase |
| **Engagé** | on lui a donné au moins une phase datée |
| **Terminé** | toutes ses phases sont finies **et** on l'a acté |
| **Écarté** | on a décidé de ne pas le faire |
Une règle unique les résume : **les phases commandent l'état, sauf quand on a prononcé quelque
chose.** Livré n'est pas clos, et abandonner est une décision — ces deux-là s'actent. Le reste se
déduit.
**Clore un projet termine toutes ses phases avec lui.** Sans cela la frise se contredirait : une
ligne éteinte au-dessus de segments pâles, et la vue par mois annonçant des tâches à venir dans un
projet fini. C'est donc la seule opération du cycle qui **écrase des données** — le statut d'une
phase bloquée est perdu, et rouvrir le projet ne peut pas le deviner. Elle se confirme à ce titre,
comme une suppression (décision 21), mais **seulement quand il reste quelque chose à écraser** : un
dialogue qui ne prévient de rien apprend à cliquer sans lire.
Engager n'est donc pas un bouton : c'est un geste, celui de donner ses premières dates fermes. Et
écarter n'est pas supprimer — le projet quitte la frise mais reste retrouvable, avec dans ses notes
la raison pour laquelle on a dit non. Les mêmes idées reviennent tous les deux ans, portées par
d'autres : savoir qu'on les a déjà examinées, et sur quel motif, a de la valeur.
### L'horizon est le repli des bornes
Un projet envisagé n'est pas une ligne hors du temps : il porte un **horizon**, exprimé en mois —
« mars 2027 », « de mars à septembre 2027 », « courant 2028 » — et il se pose **sur la frise**, à
cet horizon. C'est là que l'arbitrage devient concret : quand le printemps 2027 est déjà chargé et
que six projets pâles s'y superposent, on décide devant l'évidence plutôt que dans l'abstrait.
C'est ce qui remplace le calcul de charge que l'outil ne fera jamais.
La règle tient en une phrase, et elle ne mélange pas le saisi et le calculé :
> L'emprise temporelle d'un projet se calcule depuis ses phases. Un projet qui n'en a pas encore la
> déclare à la main : c'est son horizon.
Les phases gagnent toujours. `bornesProjet()` fait déjà ce calcul et retourne `null` quand il n'y a
aucune phase — l'horizon bouche ce trou, il n'ajoute pas de règle. Corollaire : **on ne réécrit
jamais une saisie par un calcul**. Un projet qui perdrait sa dernière phase retrouve l'horizon qu'il
avait déclaré, pas l'enveloppe des phases disparues.
Le grain est le mois, jamais l'année : en début de mandat, tout est à faire, et un horizon annuel ne
range plus rien. Un horizon s'exprime comme un intervalle de mois plutôt que par des crans nommés —
ni trimestre ni semestre à apprendre, un seul mécanisme, et trois propriétés qui en découlent :
- **la largeur de la barre pâle *est* l'incertitude**, l'œil la lit sans qu'on l'écrive ;
- **la précision de l'horizon suit la maturité du projet** : on resserre à mesure qu'on y voit
clair. Il n'y a pas de frontière entre envisagé et engagé, il y a un dégradé ;
- **engager, c'est préciser au jour près** ce qui était au mois près.
La barre d'un projet envisagé se dessine donc autrement : pâle, aux bords fondus, et **accrochée au
mois** — jamais au jour. C'est l'accroche au lundi de la décision 9 poussée d'un cran : le geste
existe, il ne permet simplement pas de poser une date qu'on n'a pas.
Elle fut d'abord rendue **immobile**, au motif qu'interdire physiquement valait mieux qu'une
convention. L'usage a tranché autrement, et pour une bonne raison : on ajuste un horizon *en
regardant ses voisins*, pour voir si le printemps 2027 est déjà chargé. Obliger à passer par un
panneau pour ce réglage-là, c'est le faire à l'aveugle. L'accroche au mois suffisait à protéger ce
qui devait l'être.
L'horizon **survit à l'engagement**. Non pour le comparer à ce qui a été planifié, mais parce que ne
rien effacer est ici plus simple qu'effacer, et parce qu'il sert de filet si le projet se dépeuple.
### Un horizon échu remonte à la surface
Un backlog échoue toujours de la même façon : tout ce qu'on n'ose pas refuser y atterrit, il gonfle,
plus personne ne le relit. Les deux garde-fous habituels — un plafond, une péremption qui efface —
sont ici interdits par la crainte de départ.
L'horizon en fournit un meilleur : **un projet dont l'horizon est dépassé se signale**. On est en
juin, quatre projets visaient le premier trimestre : il faut trancher, on repousse ou on écarte.
Rien ne disparaît, mais rien ne dort — et la relecture tombe d'elle-même au rythme des arbitrages
budgétaires.
### Ce qui ancre un projet écarté
La question n'a pas la même réponse selon d'où vient l'écartement.
Un projet écarté **depuis l'état engagé** garde ses phases datées, dont certaines ont réellement eu
lieu. Il a un ancrage, et un vrai : le travail fait ne doit pas s'effacer, puisque la frise est notre
mémoire.
Un projet écarté **depuis l'état envisagé** n'a rien. Son horizon a cessé d'être une prévision — on
ne le fera pas à cette date, on ne le fera pas du tout — et l'y laisser serait un mensonge sur la
frise.
D'où **la date d'écartement**, posée automatiquement, jamais saisie. C'est l'ancrage commun aux deux
cas, celui sans lequel les écartés ne forment plus qu'un tas. Il ne s'agit pas de journaliser les
décisions — les comptes rendus de réunion font ce travail, et mieux — mais d'horodater une sortie.
La clôture est horodatée de la même façon, et c'est ce qui donne au modèle sa forme définitive :
**les deux actes prononcés laissent une date, et ces deux dates sont l'unique trace de l'état dans le
fichier.** Acter, c'est dater. Il n'y a pas de champ « état » — il se lit dans ces deux dates et dans
la présence de phases.
On les retrouve d'abord en les cochant dans le filtre par état, ce qui les ramène dans la colonne
des libellés, groupés et éteints. Cela répond à « je sais que ça existe, je veux le retrouver ». Cela
ne répond pas à « relire d'un coup deux ans d'idées écartées avec leurs motifs », qui demanderait une
liste — geste assez rare pour attendre que le manque se fasse sentir.
### La réanimation ne demande aucun mécanisme
Puisque les états se déduisent, il suffit de **retirer l'écartement** : le projet retrouve tout seul
l'état que ses données commandent. Des phases, il redevient engagé ; un horizon seul, envisagé. Pas
de destination à choisir, une seule commande.
Le système se referme bien : un projet écarté en 2026 et réanimé aujourd'hui revient avec un horizon
périmé, donc se signale aussitôt comme échu. On le redate dans la foulée.
```
écarté ←──────┐
↗ ↑ │ réanimer
/ │ │ (l'état se
envisagé ──────┴─ engagé ──→ terminé recalcule)
(un horizon) (≥ 1 phase) (acté)
↑ │
└──────────────┘
s'il perd sa dernière phase
```
### L'état ne passe pas par les tags
Un tag est libre, disparaît quand plus personne ne le porte, et dérive orthographiquement
(décision 16) : trois propriétés inacceptables pour un état. Ce sont deux mécanismes indépendants
qui filtrent la même frise, et les tags restent disponibles pour ce à quoi ils servent.
Les deux filtres ne se comportent d'ailleurs pas pareil, et il faut que cela se voie : **celui des
tags est conjonctif, celui des états est disjonctif**. Un projet porte plusieurs tags mais un seul
état — cocher `client` + `urgent` resserre, cocher `envisagé` + `engagé` élargit. La même case à
cocher voudrait sinon dire deux choses opposées selon la ligne où elle se trouve.
Comme celui des tags, le filtre par état **n'est pas enregistré** : c'est un état de vue, pas un
réglage.
### Ce que le premier essai a corrigé
Trois choses ne se sont vues qu'en manipulant l'outil, et toutes disaient la même : **un projet
envisagé ne doit pas être un citoyen de seconde zone**.
**Engager démarre à l'horizon.** La première phase d'un projet envisagé naissait à la date du jour,
comme celle de n'importe quel projet vide. Elle apparaissait donc à des mois de la barre pâle qu'elle
venait remplacer, et le lien entre les deux se perdait. Elle commence maintenant au premier jour de
l'horizon : engager, c'est préciser au jour près ce qu'on avait déclaré au mois près, pas repartir
d'ailleurs.
**Les deux actes sont dans le panneau, pas seulement dans le menu.** Ils avaient été rangés dans le
menu ⋯ au nom de la décision 20 — un bouton permanent se mérite, et clore un projet est rare. Mais
c'est dans le panneau qu'on **lit** l'état du projet, donc c'est là qu'on cherche à en changer : les
y chercher et ne pas les trouver coûte plus cher que deux boutons de plus. Ils figurent aux deux
endroits, comme « plier » ou « masquer ».
**Les projets sans phase paraissent dans la vue par mois.** Ils n'y figuraient pas, faute de phase à
lister — alors qu'un projet visé pour mars 2027 *arrive* bel et bien en mars 2027. Les omettre
revenait à dire que la liste ne montre que l'engagé, quand toute cette décision pose le contraire.
Ils s'y rattachent au premier mois de leur horizon, dans les mêmes colonnes que les phases mais en
italique. C'est ce qui a fait renommer `phasesParMois` en `entreesParMois` : la liste ne montre plus
seulement des phases.
La règle porte sur **l'absence de phases et non sur l'état**, exactement comme `empriseProjet` : un
projet écarté avant d'avoir jamais été engagé n'a lui non plus que son horizon pour se situer, et
il doit se retrouver là quand on ouvre le cimetière. La distinguer par l'état aurait fait du
cimetière de la liste par mois une collection des seuls projets qui avaient démarré — soit
l'inverse de ce qu'on y cherche.
### Ce qui a été reporté, et pourquoi
Ces choix ne sont pas des refus définitifs. Ils attendent leur moment, et il vaut mieux savoir
lequel.
**La matrice d'Eisenhower.** Elle n'a pas de sens sur des projets engagés, pour les raisons
ci-dessus. Sur les seuls envisagés elle redevient cohérente, et se branche même bien : l'horizon
fournit l'axe d'urgence, il ne resterait que l'importance à saisir. À reprendre quand une liste
existera, et seulement si l'horizon seul ne suffit pas à départager.
**Une vue liste des envisagés.** La frise sait situer, elle ne sait pas ranger : on ne peut pas
ordonner six projets par priorité dans un empilement de couloirs. Cette première étape ne livre donc
**aucun mécanisme d'ordre explicite entre projets** — ce qu'elle livre, c'est l'horizon, et c'est
déjà l'arbitrage le plus décisif. Reste le départage fin entre projets visant le même trimestre.
**Un dézoom de l'axe temporel**, pour embrasser plusieurs années d'un coup. Le jour où il arrivera,
il faudra rouvrir la décision 14, « une seule échelle, pas de zoom ».
**Une vue de rétrospective.** La frise défile en arrière sans limite (décision 15) : le passé y est
déjà lisible, et un écran de plus l'aurait redit. Corollaire à tenir : **un projet terminé reste sur
la frise**, éteint mais présent, sinon on perd le bilan.
**Des dates réelles à côté des dates prévues.** Ce serait doubler chaque champ pour un gain nul :
dans cet outil on recale les barres au fil de l'eau, donc la date de fin *devient* le réel. La
contrepartie est assumée — la qualité du bilan dépend de l'entretien de la frise, et aucun champ ne
répare un planning qu'on ne tient pas.
**Un repère de fin de mandat**, comme le repère « aujourd'hui ». Pertinent sur le principe, sans
urgence en début de mandat.
**Un retour arrière déclaré**, d'engagé vers envisagé. On déplace les dates, cela suffit. Le seul
cas où il survient — retirer la dernière phase — se résout tout seul par la règle des bornes.

View File

@@ -9,7 +9,7 @@ dans un diff git et éditable à la main.
```json
{
"version": 4,
"version": 5,
"projects": [
{
"id": "site-web",
@@ -19,6 +19,7 @@ dans un diff git et éditable à la main.
"collapsed": false,
"hidden": false,
"notes": "## Contexte\n\nPiloté par **Marie D.**\n\n- [x] cadrage validé\n- [ ] recette",
"horizon": { "start": "2026-08", "end": "2026-11" },
"baselineDate": "2026-06-12",
"phases": [
{
@@ -50,12 +51,18 @@ dans un diff git et éditable à la main.
| Champ | Type | Description |
|---|---|---|
| `version` | entier | Version du format. Vaut `4`. Sert à détecter un fichier trop ancien au chargement. |
| `version` | entier | Version du format. Vaut `5`. Sert à détecter un fichier trop ancien au chargement. |
| `projects` | tableau | Les projets, dans l'ordre d'affichage des couloirs. |
Un fichier plus ancien se charge sans rien demander et est réécrit au format courant à la première
sauvegarde : une version `2` — celle d'avant les tags — voit ses projets recevoir une liste de tags
vide, une version `3` — celle d'avant les notes de projet — des notes vides.
vide, une version `3` — celle d'avant les notes de projet — des notes vides, une version `4` — celle
d'avant le cycle de vie — des projets sans horizon ni date d'acte.
Cette dernière migration ne demande rien et ne devine rien : un projet qui a des phases est *engagé*,
un projet qui n'en a pas est *envisagé sans horizon*, et il se signalera comme réclamant une date.
Aucun projet n'est déclaré terminé ni écarté au chargement — ces deux états s'actent, ils ne se
devinent pas, fût-ce d'un planning dont toutes les phases sont finies.
Un fichier écrit par une version **plus récente** est en revanche refusé. C'est la raison d'être du
numéro : le validateur reconstruit chaque projet champ par champ et laisse tomber ce qu'il ne connaît
@@ -73,6 +80,9 @@ effacerait les champs inconnus à la première sauvegarde.
| `collapsed` | booléen | `true` si le couloir est plié. |
| `hidden` | booléen | `true` si le projet est masqué de la frise. Ses données restent intactes. |
| `notes` | chaîne | Texte libre en markdown, éventuellement vide. Ce que la frise ne sait pas dire : contexte, interlocuteurs, décisions. |
| `horizon` | objet ou absent | Emprise déclarée, en mois : `{ "start": "2027-03", "end": "2027-06" }`. Fin **incluse**. C'est l'ancrage d'un projet qui n'a pas encore de phase. |
| `completedDate` | chaîne ou absent | Date à laquelle le projet a été déclaré terminé. Absent tant qu'il ne l'a pas été. |
| `discardedDate` | chaîne ou absent | Date à laquelle le projet a été écarté. Absent tant qu'il ne l'a pas été. |
| `baselineDate` | chaîne ou absent | Date à laquelle la référence a été figée. Absent si elle ne l'a jamais été. |
| `phases` | tableau | Les phases, triées par date de début croissante. |
@@ -110,6 +120,12 @@ Appliquées par `js/model.js` et couvertes par `tests/model.test.js`.
- `notes`, de projet comme de phase, est ramenée à `""` si ce n'est pas une chaîne. Le champ n'engage
aucun calcul, contrairement aux dates : bloquer le chargement d'un planning entier pour lui serait
disproportionné.
- `horizon` est facultatif, mais s'il est présent il porte deux mois `AAAA-MM` réels, et sa fin
n'est pas antérieure à son début. Il est vérifié aussi strictement que les dates d'une phase, et
non réparé en silence : il décide d'une position sur la frise, et un horizon avalé laisserait un
projet envisagé invisible sans qu'on sache pourquoi.
- `completedDate` et `discardedDate` suivent les mêmes règles que `baselineDate` : absentes, ou
dates `AAAA-MM-JJ` réelles.
## Tags
@@ -178,6 +194,58 @@ celui qui la lit. Un lien refusé n'est pas effacé : son libellé redevient du
Voir [decisions.md](decisions.md), section 25.
## Cycle de vie et horizon
L'état d'un projet — envisagé, engagé, terminé, écarté — **n'est pas un champ**. Il se lit dans les
données, et `etatProjet()` applique une règle unique : *les phases commandent l'état, sauf quand on a
prononcé quelque chose.*
| État | Comment il se lit | Ce qu'il veut dire |
|---|---|---|
| `considered` | ni `discardedDate`, ni `completedDate`, ni phase | Envisagé : un horizon pour seul ancrage |
| `engaged` | au moins une phase | Engagé : il a des dates fermes |
| `completed` | `completedDate` présente | Terminé, et quelqu'un l'a prononcé |
| `discarded` | `discardedDate` présente | Écarté : on a décidé de ne pas le faire |
Terminer et écarter s'actent, ils ne se devinent pas : un projet dont toutes les phases sont `done`
reste *engagé* tant que sa clôture n'a pas été prononcée — livré n'est pas clos. Ces deux actes
laissent une date, et ces deux dates sont l'unique trace de l'état dans le fichier. Un projet ne
porte jamais les deux à la fois ; si un fichier retouché à la main le fait, l'écartement l'emporte.
**Clore un projet passe toutes ses phases à `done`**, jalons compris. C'est la seule opération du
cycle qui écrase des données : le statut d'une phase `blocked` ou `todo` est perdu, et
`rouvrirProjet()` ne le rétablit pas — il n'a aucun moyen de savoir ce qu'il valait.
`clotureEcraseDesStatuts()` dit s'il reste quelque chose à écraser, ce qui permet à l'interface de
ne demander confirmation que dans ce cas.
**Réanimer un projet écarté** ne demande donc rien d'autre que d'effacer `discardedDate` : il
retrouve seul l'état que ses données commandent, engagé s'il a des phases, envisagé sinon. Son
horizon est resté celui d'avant et se signalera aussitôt comme dépassé, ce qui est voulu.
### L'horizon
Un projet envisagé n'est jamais hors du temps : il déclare son emprise en **mois**, fin incluse. Un
horizon d'un seul mois a `start === end`.
Le grain est le mois et jamais l'année, et l'horizon s'exprime comme un intervalle plutôt que par des
crans nommés — ni trimestre ni semestre à apprendre. La largeur de l'intervalle *est* l'incertitude,
et elle se resserre à mesure que le projet mûrit, jusqu'aux dates fermes de l'engagement.
L'horizon **survit à l'engagement** : il n'est ni effacé ni réécrit quand des phases arrivent. C'est
ce qui permet à un projet ayant perdu sa dernière phase de retrouver l'horizon qu'il avait déclaré,
plutôt que l'enveloppe de phases disparues — **on ne réécrit jamais une saisie par un calcul**.
Il se manœuvre au **mois** partout : les crans du panneau, le glisser sur la frise et le
redimensionnement de ses bords passent tous par `ajouterMois()` et n'atteignent jamais le jour.
`entreesParMois()` rattache d'ailleurs un projet envisagé au premier mois de son horizon, pour qu'il
paraisse dans la vue par mois au même titre qu'une phase qui démarre.
Un projet envisagé **sans** horizon est légal : c'est l'état de tous les projets sans phase hérités
d'un fichier en version 4. Il réclame une date, exactement comme un projet dont l'horizon est
dépassé — `projetADater()` confond volontairement les deux cas en un seul signal.
Voir [decisions.md](decisions.md), section 27.
## Dates : conventions
Les dates sont manipulées comme des **chaînes `AAAA-MM-JJ`**, pas comme des objets `Date`. Cela évite
@@ -195,8 +263,13 @@ l'année correspondante, qui peut différer de celle de la date.
## Bornes et dérive
- Les **bornes d'un projet** vont de la plus petite `start` à la plus grande `end` de ses phases. Un
projet sans phase n'a pas de bornes et s'affiche comme un couloir vide.
- Les **bornes d'un projet** (`bornesProjet()`) vont de la plus petite `start` à la plus grande `end`
de ses phases. Un projet sans phase n'a pas de bornes.
- Son **emprise** (`empriseProjet()`) est celle de ses phases, ou à défaut celle de son horizon,
ramenée du premier jour de son premier mois au dernier jour de son dernier. C'est elle qui cadre la
fenêtre du planning au premier affichage — sans quoi un planning fait de projets encore tous
envisagés s'ouvrirait sur rien. Les projets écartés en sont exclus : ils ne s'affichent pas
d'ordinaire, et un projet abandonné il y a trois ans n'a pas à tirer la vue en arrière.
- **Figer la référence** copie, pour chaque phase du projet, ses `start` et `end` courantes dans son
champ `baseline`, et inscrit la date du jour dans `baselineDate`. L'opération est rejouable : la
refiger après un arbitrage assumé repart d'une base propre.

View File

@@ -54,6 +54,22 @@
<p class="outils__etat" id="etat-sauvegarde" role="status" aria-live="polite"></p>
</header>
<!--
Filtre par état, masqué tant que tous les projets sont dans le même : lui
aussi n'apparaît que le jour où il sert.
Il ne se comporte pas comme celui des tags, et c'est pourquoi il ne lui
ressemble pas. Un projet porte plusieurs tags mais un seul état : cocher un
tag de plus resserre la sélection, cocher un état de plus l'élargit. D'où
« Afficher » ici et « Filtrer » en dessous, et deux formes distinctes —
sans quoi la même case voudrait dire deux choses opposées selon la ligne
où elle se trouve.
-->
<div class="etats" id="etats" hidden role="group" aria-label="Afficher par état">
<span class="filtres__titre">Afficher</span>
<div class="etats__liste" id="etats-liste"></div>
</div>
<!--
Barre de filtres, masquée tant qu'aucun projet ne porte de tag : elle
n'apparaît que le jour où elle sert à quelque chose.
@@ -107,8 +123,13 @@
</button>
</p>
<!--
Deux tamis peuvent vider la vue — les tags cochés, les états décochés — et
celui qui les a resserrés ne sait pas toujours lequel a fini par tout
emporter. Le bouton relâche donc les deux d'un coup.
-->
<p class="vide" id="message-filtre" hidden>
Aucun projet ne porte tous les tags sélectionnés.<br />
Aucun projet ne passe les filtres en cours.<br />
<button type="button" id="effacer-filtres-vide">Tout afficher</button>
</p>
@@ -215,11 +236,48 @@
<button type="button" id="fermer-panneau-projet" aria-label="Fermer">&times;</button>
</div>
<form class="panneau__corps" id="formulaire-projet">
<!--
Où en est le projet dans son cycle de vie. En lecture seule : l'état
se déduit des données, il ne se choisit pas dans une liste. Les deux
qui s'actent — terminer, écarter — sont dans le menu ⋯ du projet, avec
les autres décisions qu'on ne prend qu'une fois.
-->
<p class="panneau__etat" id="panneau-projet-etat"></p>
<label>
Nom
<input type="text" name="name" required autocomplete="off" />
</label>
<!--
Horizon : l'ancrage temporel d'un projet envisagé, au mois. Le bloc
disparaît dès que le projet a une phase — ce sont elles qui commandent
alors son emprise (docs/decisions.md, section 27).
-->
<fieldset class="horizon" id="panneau-projet-horizon" hidden>
<legend>Horizon</legend>
<div class="horizon__intervalle">
<label>
De
<input type="month" name="horizonStart" autocomplete="off" />
</label>
<label>
à
<input type="month" name="horizonEnd" autocomplete="off" />
</label>
</div>
<!--
Un horizon se repousse par crans et jamais au jour : c'est ce qui
garde l'imprécision visible. Les mêmes crans dans les deux sens.
-->
<div class="horizon__crans" id="panneau-projet-crans">
<button type="button" data-cran="-3" title="Avancer de trois mois">3&nbsp;mois</button>
<button type="button" data-cran="3" title="Repousser de trois mois">+3&nbsp;mois</button>
<button type="button" data-cran="12" title="Repousser d'un an">+1&nbsp;an</button>
</div>
<p class="horizon__aide" id="panneau-projet-horizon-aide"></p>
</fieldset>
<fieldset class="couleurs">
<legend>Couleur du couloir</legend>
<div id="panneau-projet-couleurs"></div>
@@ -245,6 +303,16 @@
<p class="panneau__erreur" id="erreur-projet" role="alert" hidden></p>
<!--
Les deux actes du cycle de vie. Ils sont aussi dans le menu ⋯, avec les
autres décisions qu'on ne prend qu'une fois — mais c'est ici qu'on lit
l'état du projet, donc ici qu'on cherche à en changer.
-->
<div class="panneau__actions panneau__actions--cycle">
<button type="button" id="terminer-projet"></button>
<button type="button" id="ecarter-projet"></button>
</div>
<div class="panneau__actions">
<button type="button" id="supprimer-projet" class="bouton--danger">
Supprimer le projet

228
js/app.js
View File

@@ -17,14 +17,19 @@ import { rendreMois, revelerMoisCourant } from './mois.js';
import { creerStockage, viderAvantFermeture } from './storage.js';
import {
COULEURS,
ETATS,
LIBELLES_ETAT,
STATUTS,
ajouterJours,
ajouterPhase,
aujourdhui,
bornesPlanning,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
ecarterProjet,
etatProjet,
figerReference,
formaterDateLongue,
libererReference,
@@ -32,11 +37,16 @@ import {
normaliserTags,
planningVide,
pourEcriture,
premierJourDuMois,
projetFiltre,
projetFiltreEtat,
reanimerProjet,
remplacerPhase,
remplacerProjet,
rouvrirProjet,
supprimerPhase,
supprimerProjet,
terminerProjet,
teinteTag,
tousLesTags,
validerPlanning,
@@ -58,6 +68,8 @@ const refs = {
filtres: $('filtres'),
filtresListe: $('filtres-liste'),
effacerFiltres: $('effacer-filtres'),
etats: $('etats'),
etatsListe: $('etats-liste'),
alerte: $('alerte'),
alerteMessage: $('alerte-message'),
panneau: $('panneau'),
@@ -113,6 +125,18 @@ let defilementFrise = 0;
*/
let tagsActifs = [];
/**
* États affichés. État de vue lui aussi, jamais enregistré — un réglage qui
* survivrait au rechargement rouvrirait le planning amputé sans qu'on se
* souvienne pourquoi.
*
* La sélection est **disjonctive**, à l'inverse de celle des tags : un projet
* n'a qu'un état, cocher en élargit donc toujours. Et le défaut n'est pas la
* liste vide mais les trois états vivants : le cimetière ne s'ouvre que si on le
* demande, sans quoi il encombrerait la colonne dès la première année.
*/
let etatsActifs = ['considered', 'engaged', 'completed'];
/**
* Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré.
*
@@ -197,6 +221,12 @@ const panneauProjet = creerPanneauProjet(
conteneurCouleurs: $('panneau-projet-couleurs'),
suggestions: $('panneau-projet-suggestions'),
blocSuggestions: $('panneau-projet-suggestions-bloc'),
etat: $('panneau-projet-etat'),
blocHorizon: $('panneau-projet-horizon'),
crans: $('panneau-projet-crans'),
aideHorizon: $('panneau-projet-horizon-aide'),
terminer: $('terminer-projet'),
ecarter: $('ecarter-projet'),
notes: $('notes-projet'),
supprimer: $('supprimer-projet'),
fermer: $('fermer-panneau-projet'),
@@ -211,6 +241,9 @@ const panneauProjet = creerPanneauProjet(
demanderSuppression(_idProjet, projet) {
return confirmerSuppressionProjet(projet);
},
demanderCloture(projet) {
return confirmerCloture(projet);
},
surSuppression(idProjet) {
if (curseur?.projet === idProjet) panneau.fermer();
majPlanning(supprimerProjet(planning, idProjet));
@@ -243,7 +276,7 @@ function dessiner() {
normaliserCurseur();
const aDesProjets = planning.projects.length > 0;
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const retenus = projetsRetenus();
refs.messageVide.hidden = aDesProjets;
// Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce
@@ -257,6 +290,7 @@ function dessiner() {
refs.vueMois.hidden = !aQuelqueChoseAMontrer || vue !== 'mois';
peindreFiltres();
peindreEtats();
peindreOnglets();
if (!aDesProjets) {
@@ -265,12 +299,12 @@ function dessiner() {
}
if (vue === 'mois') {
rendreMois(refs.vueMois, planning, { tagsActifs });
rendreMois(refs.vueMois, planning, { tagsActifs, etatsActifs });
return;
}
elargirPourContenir();
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs });
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs, etatsActifs });
// Le panneau reflète la phase telle qu'elle est après le rendu : un glisser
// sur la frise doit se voir dans les champs.
@@ -286,6 +320,21 @@ function dessiner() {
}
}
/**
* Projets que la frise montre : ceux que les deux filtres laissent passer.
*
* Centralisé parce que quatre parcours doivent en dire autant — le rendu, mais
* aussi les déplacements du curseur clavier, qui sauteraient sur des lignes
* invisibles si leur énumération divergeait de celle de `construireLibelles`
* (timeline.js). L'œil (`hidden`) n'entre pas ici : un projet masqué garde sa
* ligne de libellé, donc sa place dans le curseur.
*/
function projetsRetenus() {
return planning.projects.filter(
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
);
}
function localiser(idProjet, idPhase) {
const projet = planning.projects.find((p) => p.id === idProjet);
if (!projet) return null;
@@ -365,8 +414,71 @@ function effacerFiltres() {
dessiner();
}
// ---------------------------------------------------------------------------
// Filtre par état
// ---------------------------------------------------------------------------
/**
* Reconstruit la barre des états.
*
* Elle ne paraît que si le planning en compte plus d'un : tant que tous les
* projets sont engagés — le cas d'un fichier hérité de la version 4 —, elle
* n'aurait rien à trier.
*
* Chaque état affiche son compte. C'est ce qui rend le cimetière consultable
* sans lui construire d'écran : « écartés (7) » dit à la fois qu'ils existent et
* combien, et un clic les ramène dans la colonne.
*/
function peindreEtats() {
const comptes = new Map(ETATS.map((nom) => [nom, 0]));
for (const projet of planning.projects) {
comptes.set(etatProjet(projet), comptes.get(etatProjet(projet)) + 1);
}
const presents = ETATS.filter((nom) => comptes.get(nom) > 0);
refs.etats.hidden = presents.length < 2;
refs.etatsListe.replaceChildren(
...presents.map((nom) => {
const actif = etatsActifs.includes(nom);
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'etat-filtre';
bouton.dataset.etat = nom;
bouton.textContent = `${LIBELLES_ETAT[nom]}s (${comptes.get(nom)})`;
bouton.setAttribute('aria-pressed', String(actif));
// Tout décocher afficherait le planning entier — l'inverse de ce que
// demande le geste. On bloque plutôt le dernier actif, ce qui se voit.
bouton.disabled = actif && etatsActifs.length === 1;
bouton.title = actif
? bouton.disabled
? 'Au moins un état doit rester affiché.'
: `Masquer les projets ${LIBELLES_ETAT[nom].toLowerCase()}s`
: `Afficher aussi les projets ${LIBELLES_ETAT[nom].toLowerCase()}s`;
bouton.addEventListener('click', () => basculerEtat(nom));
return bouton;
})
);
}
/** Ajoute l'état à l'affichage, ou l'en retire s'il y était déjà. */
function basculerEtat(nom) {
const restants = etatsActifs.filter((actif) => actif !== nom);
if (!restants.length) return;
etatsActifs = restants.length === etatsActifs.length ? [...etatsActifs, nom] : restants;
dessiner();
}
refs.effacerFiltres.addEventListener('click', effacerFiltres);
$('effacer-filtres-vide').addEventListener('click', effacerFiltres);
// Depuis la barre des tags, « Tout afficher » ne touche qu'aux tags : ouvrir le
// cimetière au passage serait une surprise. Depuis l'écran vide, en revanche, on
// ne sait pas lequel des deux tamis a tout emporté — on relâche donc les deux.
$('effacer-filtres-vide').addEventListener('click', () => {
tagsActifs = [];
etatsActifs = [...ETATS];
dessiner();
});
// ---------------------------------------------------------------------------
// Bascule entre la frise et la vue par mois
@@ -745,6 +857,22 @@ installerGlisser(refs.couloirs, {
surClic(idProjet, idPhase) {
ouvrirPhase(idProjet, idPhase);
},
trouverProjet(idProjet) {
return planning.projects.find((p) => p.id === idProjet) ?? null;
},
surDeplacementHorizon(idProjet, horizon) {
// Même convention que pour une phase : null veut dire « geste annulé ou sans
// effet », on redessine sans rien sauvegarder.
if (!horizon) {
dessiner();
return;
}
majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon })));
},
surClicProjet(idProjet) {
const projet = planning.projects.find((p) => p.id === idProjet);
if (projet) ouvrirProjet(projet);
},
});
/**
@@ -854,6 +982,14 @@ function ouvrirMenuProjet(idProjet, ancre) {
if (!projet) return;
const aUneReference = projet.phases.some((phase) => phase.baseline);
const etat = etatProjet(projet);
// Deux actes, et deux seulement : les autres états se déduisent. Chacun se
// présente dans un sens ou dans l'autre selon où en est le projet, comme le
// font déjà « plier » et « masquer » — une entrée qui bascule plutôt que deux
// dont une serait toujours grise.
const acter = (transformation) =>
majPlanning(remplacerProjet(planning, idProjet, transformation));
menu.ouvrir(ancre, [
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet) },
@@ -871,6 +1007,26 @@ function ouvrirMenuProjet(idProjet, ancre) {
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
},
{ libelle: 'Renommer, changer la couleur…', action: () => ouvrirProjet(projet) },
{
libelle: etat === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé',
separateurAvant: true,
// Un projet envisagé n'a rien à clore : la clôture est le bout du chemin
// qui commence avec la première phase. Un écarté non plus — il sort du
// cimetière par « Remettre en jeu », et retrouve alors l'état que ses
// données commandent ; le clore d'ici court-circuiterait le cycle.
desactive: etat === 'considered' || etat === 'discarded',
action: () =>
etat === 'completed' ? acter((p) => rouvrirProjet(p)) : terminerLeProjet(projet),
},
{
// Écarter n'est pas supprimer, et la manœuvre se défait : pas de dialogue
// de confirmation, réservé à ce qui ne se rattrape pas (décision 21). Le
// projet garde tout, notes comprises, et se retrouve en cochant
// « écartés » dans le filtre par état.
libelle: etat === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet',
action: () =>
acter(etat === 'discarded' ? (p) => reanimerProjet(p) : (p) => ecarterProjet(p)),
},
{
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
separateurAvant: true,
@@ -909,8 +1065,20 @@ function ajouterUnePhase(projet, { debut = null } = {}) {
// `debut` n'est fourni que par les raccourcis `a`, `o` et `O`, qui insèrent
// ailleurs qu'au bout : les phases étant triées par date, « avant » et
// « après » ne s'expriment que par une date, jamais par un rang.
//
// La toute première phase d'un projet envisagé démarre à son horizon, et non
// aujourd'hui : c'est le moment où l'on engage, et engager veut dire préciser
// ce qu'on avait déclaré au mois près. La faire naître ailleurs donnerait à
// croire que l'horizon n'était pas pris au sérieux — et laisserait la barre
// pâle et la barre ferme à deux endroits sans rapport.
const derniere = projet.phases[projet.phases.length - 1];
if (!debut) debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
if (!debut) {
debut = derniere
? ajouterJours(derniere.end, 1)
: projet.horizon
? premierJourDuMois(projet.horizon.start)
: aujourdhui();
}
const phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20));
@@ -945,7 +1113,7 @@ function ouvrirProjet(projet, options = {}) {
* gestionnaire — ou le glisser qui commence peut-être — n'ait fini son travail.
*/
const CIBLES_OUVRANTES =
'[data-role="barre"], [data-role="poignee"], .libelle-phase, [data-action]';
'[data-role="barre"], [data-role="horizon"], [data-role="poignee"], .libelle-phase, [data-action]';
/** Surfaces où un clic n'est pas un « ailleurs » : on y reste au travail. */
const CIBLES_PROTEGEES = '.panneau, .menu, dialog, .poignee-colonne';
@@ -1006,6 +1174,42 @@ function confirmerSuppressionProjet(projet) {
});
}
/**
* Clore un projet **termine toutes ses phases avec lui**, et rien ne rétablira
* ensuite celle qui était bloquée ou celle qui n'avait pas commencé : rouvrir le
* projet ne peut pas deviner ce qu'elles valaient. C'est donc une action
* irréversible au sens de la décision 21, et elle s'annonce comme telle.
*
* On ne demande rien quand plus aucune phase n'est ouverte : il n'y a alors rien
* à écraser, et un dialogue qui ne prévient de rien apprend à cliquer sans lire.
*/
function confirmerCloture(projet) {
if (!clotureEcraseDesStatuts(projet)) return Promise.resolve(true);
const restantes = projet.phases.filter((phase) => phase.status !== 'done').length;
// Deux phrases écrites en entier plutôt qu'une suite de ternaires accordés :
// le texte d'un dialogue se relit, et une accumulation de « s » conditionnels
// finit toujours par produire une conjugaison de travers.
const message =
restantes > 1
? `${restantes} phases de « ${projet.name} » ne sont pas encore terminées. Elles passeront ` +
'au statut « Terminé », jalons compris, et rouvrir le projet ensuite ne leur rendra pas ' +
'leur statut précédent.'
: `Une phase de « ${projet.name} » n'est pas encore terminée. Elle passera au statut ` +
'« Terminé », et rouvrir le projet ensuite ne lui rendra pas son statut précédent.';
return demanderConfirmation({
titreTexte: 'Déclarer le projet terminé',
messageTexte: message,
libelleValider: 'Déclarer terminé',
});
}
async function terminerLeProjet(projet) {
if (!(await confirmerCloture(projet))) return;
majPlanning(remplacerProjet(planning, projet.id, (p) => terminerProjet(p)));
}
async function supprimerLeProjet(projet) {
if (!(await confirmerSuppressionProjet(projet))) return;
@@ -1025,7 +1229,7 @@ async function supprimerLeProjet(projet) {
/** Ligne du projet qui suit celui-ci, ou du précédent s'il fermait la liste. */
function projetVoisin(idProjet) {
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
const retenus = projetsRetenus();
const index = retenus.findIndex((p) => p.id === idProjet);
if (index === -1) return null;
const voisin = retenus[index + 1] ?? retenus[index - 1];
@@ -1052,6 +1256,12 @@ function nouveauProjet() {
// Un filtre actif préremplit les tags : quand on crée un projet en ne
// regardant que les projets « client », c'en est presque toujours un — et
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
//
// Même raison pour l'état : un projet naît envisagé, et le masquer aussitôt
// parce qu'on avait décoché « envisagés » ferait paraître le bouton sans
// effet. On rouvre donc l'état plutôt que de créer dans l'ombre.
if (!etatsActifs.includes('considered')) etatsActifs = [...etatsActifs, 'considered'];
const projet = creerProjet(planning, 'Nouveau projet', tagsActifs);
majPlanning({ ...planning, projects: [...planning.projects, projet] });
ouvrirProjet(projet, { nomAEcraser: true });
@@ -1146,7 +1356,7 @@ document.addEventListener('keydown', (evenement) => {
function lignesFrise() {
const lignes = [];
for (const projet of planning.projects.filter((p) => projetFiltre(p, tagsActifs))) {
for (const projet of projetsRetenus()) {
lignes.push({ projet: projet.id, phase: null });
if (projet.hidden || projet.collapsed) continue;
for (const phase of projet.phases) lignes.push({ projet: projet.id, phase: phase.id });
@@ -1312,7 +1522,7 @@ function deplacerDansLeProjet(pas) {
/** `{` et `}` : la ligne du projet précédent ou suivant. */
function deplacerDeProjet(pas) {
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
const retenus = projetsRetenus();
if (!retenus.length) return;
const depart = retenus.findIndex((p) => p.id === curseur?.projet);

View File

@@ -14,8 +14,13 @@
import {
ajouterJours,
ajouterMois,
deplacerPhase,
dernierJourDuMois,
ecartMois,
lundiDeLaSemaine,
moisDe,
premierJourDuMois,
redimensionnerPhase,
versUTC,
} from './model.js';
@@ -40,19 +45,45 @@ export function installerGlisser(conteneur, rappels) {
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
if (evenement.button !== 0) return;
const cible = evenement.target.closest('[data-role="barre"]');
const cible = evenement.target.closest('[data-role="barre"], [data-role="horizon"]');
if (!cible) return;
const poignee = evenement.target.closest('[data-role="poignee"]');
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
// La barre d'un projet envisagé se manœuvre au mois, pas au jour : même
// mécanique de geste, autre grain, et rien de commun avec une phase — il n'y
// en a pas encore (docs/decisions.md, section 27).
if (cible.dataset.role === 'horizon') {
const projet = rappels.trouverProjet(cible.dataset.projet);
if (!projet?.horizon) return;
geste = {
element: cible,
sorte: 'horizon',
idProjet: projet.id,
horizonInitial: projet.horizon,
horizonCourant: projet.horizon,
mode: bord,
xDepart: evenement.clientX,
demarre: false,
};
cible.setPointerCapture(evenement.pointerId);
evenement.preventDefault();
return;
}
const { projet: idProjet, phase: idPhase } = cible.dataset;
const phase = rappels.trouverPhase(idProjet, idPhase);
if (!phase) return;
const poignee = evenement.target.closest('[data-role="poignee"]');
// Un jalon n'a pas de durée : ses poignées n'ont pas de sens.
const mode = poignee && !phase.milestone ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
const mode = poignee && !phase.milestone ? bord : 'deplacer';
geste = {
element: cible,
sorte: 'phase',
idProjet,
idPhase,
phaseInitiale: phase,
@@ -89,9 +120,21 @@ export function installerGlisser(conteneur, rappels) {
const acheve = geste;
geste = null;
acheve.element.releasePointerCapture?.(evenement.pointerId);
acheve.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
relacher(acheve, evenement.pointerId);
if (acheve.sorte === 'horizon') {
const avant = acheve.horizonInitial;
const apres = acheve.horizonCourant;
// Un appui sans glisser ouvre les paramètres du projet : c'est là que se
// saisissent son horizon et le reste, comme un clic sur son nom.
if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet);
else if (apres.start !== avant.start || apres.end !== avant.end) {
rappels.surDeplacementHorizon(acheve.idProjet, apres);
} else {
rappels.surDeplacementHorizon(acheve.idProjet, null);
}
return;
}
if (!acheve.demarre) {
rappels.surClic(acheve.idProjet, acheve.idPhase);
@@ -110,26 +153,46 @@ export function installerGlisser(conteneur, rappels) {
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres);
});
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
document.addEventListener('keydown', (evenement) => {
if (evenement.key !== 'Escape' || !geste) return;
/** Abandonne le geste en cours et laisse le rendu se refaire depuis l'état. */
function annuler() {
if (!geste) return;
const annule = geste;
geste = null;
annule.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
relacher(annule);
if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
else rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
}
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
document.addEventListener('keydown', (evenement) => {
if (evenement.key === 'Escape') annuler();
});
// Un pointeur perdu (fenêtre qui perd le focus, périphérique débranché) doit
// laisser l'interface dans un état propre plutôt qu'en glisser perpétuel.
conteneur.addEventListener('pointercancel', () => {
if (!geste) return;
const annule = geste;
geste = null;
annule.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
});
conteneur.addEventListener('pointercancel', annuler);
}
/**
* Rend le pointeur et retire les marques du glisser.
*
* `releasePointerCapture` lève quand le pointeur a déjà disparu — périphérique
* débranché, fenêtre qui perd le focus au mauvais moment. L'exception ne doit
* pas empêcher ce qui suit : le geste de l'utilisateur est achevé et son
* résultat mérite d'être appliqué, même si le nettoyage n'avait plus rien à
* rendre.
*/
function relacher(geste, pointerId) {
if (pointerId !== undefined) {
try {
geste.element.releasePointerCapture?.(pointerId);
} catch {
// Le pointeur n'était plus capturé : il n'y a rien à réparer.
}
}
geste.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
}
/**
@@ -137,6 +200,8 @@ export function installerGlisser(conteneur, rappels) {
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
*/
function appliquerGeste(geste, decalage, echelle) {
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
const { phaseInitiale: phase, mode } = geste;
if (mode === 'deplacer') {
@@ -164,6 +229,37 @@ function appliquerGeste(geste, decalage, echelle) {
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
}
/**
* Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** :
* l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire
* qu'il en pose.
*
* Les bornes ne se retournent pas — la borne opposée fait butée, comme pour une
* phase —, si bien qu'un horizon fait toujours au moins un mois plein.
*/
function appliquerGesteHorizon(geste, decalage, echelle) {
const { horizonInitial: horizon, mode } = geste;
const largeur = ecartMois(horizon.start, horizon.end);
let suivant;
if (mode === 'deplacer') {
const debut = accrocherAuMois(horizon.start, decalage, echelle);
suivant = { start: debut, end: ajouterMois(debut, largeur) };
} else if (mode === 'redimensionner-debut') {
const debut = accrocherAuMois(horizon.start, decalage, echelle);
suivant = { start: debut > horizon.end ? horizon.end : debut, end: horizon.end };
} else {
const fin = accrocherAuMois(horizon.end, decalage, echelle);
suivant = { start: horizon.start, end: fin < horizon.start ? horizon.start : fin };
}
geste.horizonCourant = suivant;
const gauche = premierJourDuMois(suivant.start);
const droite = dernierJourDuMois(suivant.end);
geste.element.style.left = `${echelle.x(gauche)}px`;
geste.element.style.width = `${echelle.largeurDe(gauche, droite)}px`;
}
/**
* Décale une date du nombre de jours correspondant au déplacement, puis
* l'accroche au lundi de sa semaine.
@@ -173,6 +269,21 @@ function accrocher(date, decalagePixels, echelle) {
return lundiDeLaSemaine(ajouterJours(date, jours));
}
/**
* Décale un mois du déplacement demandé, puis l'accroche au mois le plus proche.
*
* L'arrondi se fait à la moitié du mois atteint plutôt qu'à un nombre fixe de
* jours : les mois n'ont pas tous la même longueur, et découper en tranches de
* trente jours ferait dériver le geste d'un cran au fil de l'année.
*/
function accrocherAuMois(mois, decalagePixels, echelle) {
const jours = Math.round(decalagePixels / echelle.pixelsParJour);
const cible = ajouterJours(premierJourDuMois(mois), jours);
const moisCible = moisDe(cible);
const longueur = Number(dernierJourDuMois(moisCible).slice(8, 10));
return Number(cible.slice(8, 10)) > longueur / 2 ? ajouterMois(moisCible, 1) : moisCible;
}
/**
* Décale une phase au clavier, pour un ajustement fin sans souris.
* Utilisé par app.js sur les flèches gauche/droite quand une phase est

View File

@@ -15,18 +15,34 @@
/**
* Version du format de fichier.
*
* Elle a changé pour la dernière fois quand le projet a reçu des `notes` (v4).
* Un champ ajouté n'oblige pourtant à rien : la v3 se relit sans encombre, et
* le validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement
* là qu'est la raison d'incmenter — le validateur reconstruit chaque projet
* champ par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un
* binaire antérieur ouvrirait un fichier v4 sans broncher et en effacerait
* toutes les notes de projet à la première sauvegarde.
* Elle a changé pour la dernière fois quand le projet a reçu son cycle de vie —
* `horizon`, `completedDate`, `discardedDate` (v5). Un champ ajouté n'oblige
* pourtant à rien : la v4 se relit sans encombre, et le validateur ne refuse
* qu'un fichier *plus cent* que lui. C'est justement là qu'est la raison
* d'incrémenter — le validateur reconstruit chaque projet champ par champ et
* laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire antérieur
* ouvrirait un fichier v5 sans broncher et en effacerait tous les horizons à la
* première sauvegarde.
*/
export const VERSION_FORMAT = 4;
export const VERSION_FORMAT = 5;
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
/**
* États d'un projet, dans l'ordre du cycle de vie.
*
* Contrairement au statut d'une phase, ce n'est **pas** un champ du fichier : il
* se déduit de `etatProjet()`. Voir docs/decisions.md, section 27.
*/
export const ETATS = ['considered', 'engaged', 'completed', 'discarded'];
export const LIBELLES_ETAT = {
considered: 'Envisagé',
engaged: 'Engagé',
completed: 'Terminé',
discarded: 'Écarté',
};
export const LIBELLES_STATUT = {
todo: 'À venir',
doing: 'En cours',
@@ -56,6 +72,7 @@ export const MOIS_LONGS = [
];
const MOTIF_DATE = /^\d{4}-\d{2}-\d{2}$/;
const MOTIF_MOIS = /^\d{4}-\d{2}$/;
const MOTIF_COULEUR = /^#[0-9a-f]{6}$/i;
const MS_PAR_JOUR = 86400000;
@@ -165,6 +182,58 @@ export function formaterMois(mois) {
return `${MOIS_LONGS[Number(mois.slice(5, 7)) - 1]} ${mois.slice(0, 4)}`;
}
// ---------------------------------------------------------------------------
// Mois
// ---------------------------------------------------------------------------
//
// Le mois est le grain de l'horizon d'un projet envisagé (décision 27). Comme
// les dates, il se manipule en chaîne — « AAAA-MM » se compare et se trie comme
// du texte, ce qui évite tout objet Date dans les comparaisons.
/** Vrai si la chaîne est un mois « AAAA-MM » plausible. */
export function moisValide(valeur) {
if (typeof valeur !== 'string' || !MOTIF_MOIS.test(valeur)) return false;
const numero = Number(valeur.slice(5, 7));
return numero >= 1 && numero <= 12;
}
/** Mois en cours, au format « AAAA-MM ». */
export function moisCourant() {
return moisDe(aujourdhui());
}
/** Premier jour d'un mois : « 2027-03 » → « 2027-03-01 ». */
export function premierJourDuMois(mois) {
return `${mois}-01`;
}
/**
* Dernier jour d'un mois : « 2027-03 » → « 2027-03-31 ».
*
* Le jour 0 du mois suivant est le dernier du mois demandé — `Date.UTC` fait le
* report d'année tout seul, décembre compris.
*/
export function dernierJourDuMois(mois) {
const annee = Number(mois.slice(0, 4));
const numero = Number(mois.slice(5, 7));
return depuisUTC(Date.UTC(annee, numero, 0));
}
/** Décale un mois d'un nombre de mois, éventuellement négatif. */
export function ajouterMois(mois, nombre) {
const annee = Number(mois.slice(0, 4));
const numero = Number(mois.slice(5, 7));
return depuisUTC(Date.UTC(annee, numero - 1 + nombre, 1)).slice(0, 7);
}
/** Nombre de mois de `debut` à `fin`, signé. Deux mois égaux donnent 0. */
export function ecartMois(debut, fin) {
return (
(Number(fin.slice(0, 4)) - Number(debut.slice(0, 4))) * 12 +
(Number(fin.slice(5, 7)) - Number(debut.slice(5, 7)))
);
}
/**
* Période d'une phase, abrégée pour une liste où le mois est déjà écrit en
* titre : « 12 » pour un jalon, « 12 → 30 » pour une tâche qui tient dans le
@@ -292,6 +361,106 @@ export function teinteTag(tag) {
return hachage;
}
// ---------------------------------------------------------------------------
// Cycle de vie et horizon
// ---------------------------------------------------------------------------
/**
* État d'un projet, déduit de ses données. Voir docs/decisions.md, section 27.
*
* Une seule règle : **les phases commandent l'état, sauf quand on a prononcé
* quelque chose**. Terminer et écarter sont des décisions — livré n'est pas
* clos, et abandonner n'est pas une conséquence des dates —, elles laissent
* donc une trace horodatée. Le reste se déduit : un projet qui a des phases est
* engagé, un projet qui n'en a pas encore est envisagé.
*
* Un projet ne peut pas porter les deux dates à la fois — les transitions
* ci-dessous s'en assurent —, mais un fichier retouché à la main le pourrait :
* l'écartement l'emporte, pour que la lecture reste déterministe.
*/
export function etatProjet(projet) {
if (projet.discardedDate) return 'discarded';
if (projet.completedDate) return 'completed';
return projet.phases.length ? 'engaged' : 'considered';
}
/**
* Vrai si le projet passe le filtre par état.
*
* Disjonctif, là où le filtre par tags est conjonctif (`projetFiltre`) : un
* projet porte plusieurs tags mais un seul état, si bien que cocher un état de
* plus ne peut qu'élargir la sélection. Une liste vide laisse tout passer, mais
* l'interface n'en pose jamais : elle part des états qu'on veut voir d'ordinaire
* — les écartés n'en font pas partie.
*/
export function projetFiltreEtat(projet, etatsActifs) {
if (!etatsActifs.length) return true;
return etatsActifs.includes(etatProjet(projet));
}
/**
* Emprise d'un horizon, ramenée en dates pour être dessinée : du premier jour
* de son premier mois au dernier jour de son dernier. Null si le projet n'en a
* pas.
*/
export function bornesHorizon(projet) {
if (!projet.horizon) return null;
return {
start: premierJourDuMois(projet.horizon.start),
end: dernierJourDuMois(projet.horizon.end),
};
}
/**
* Emprise temporelle d'un projet, quel que soit son état.
*
* L'emprise se calcule depuis les phases ; un projet qui n'en a pas encore la
* déclare à la main, c'est son horizon. Les phases gagnent donc toujours, et
* l'horizon n'est qu'une valeur de repli — jamais réécrite par le calcul, ce
* qui fait qu'un projet ayant perdu sa dernière phase retrouve l'horizon qu'il
* avait déclaré.
*/
export function empriseProjet(projet) {
return bornesProjet(projet) ?? bornesHorizon(projet);
}
/**
* Vrai si le projet réclame une date : envisagé sans horizon, ou dont l'horizon
* est déjà passé.
*
* C'est le garde-fou qui empêche la liste des envisagés de devenir une décharge
* (décision 27). Plutôt qu'un plafond ou une péremption qui effacerait — deux
* choses interdites par la crainte d'oublier —, un projet dont l'horizon est
* dépassé se signale : on repousse, ou on écarte.
*
* Les deux cas se confondent volontairement en un seul signal. Un projet hérité
* d'un fichier en version 4, sans phase et sans horizon, réclame une date au
* même titre qu'un projet qu'on visait pour le trimestre dernier.
*/
export function projetADater(projet, mois = moisCourant()) {
if (etatProjet(projet) !== 'considered') return false;
return !projet.horizon || projet.horizon.end < mois;
}
/**
* Horizon rendu lisible : « mars 2027 », « mars → juin 2027 »,
* « novembre 2026 → février 2027 ».
*
* L'année n'est écrite qu'une fois quand les deux bornes la partagent : comme
* pour la période d'une phase (`formaterPeriode`), son apparition en tête
* signale à elle seule le débordement.
*/
export function formaterHorizon(horizon) {
if (!horizon) return '';
if (horizon.start === horizon.end) return formaterMois(horizon.start);
const memeAnnee = horizon.start.slice(0, 4) === horizon.end.slice(0, 4);
const debut = memeAnnee
? MOIS_LONGS[Number(horizon.start.slice(5, 7)) - 1]
: formaterMois(horizon.start);
return `${debut}${formaterMois(horizon.end)}`;
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
@@ -337,11 +506,15 @@ export function validerPlanning(donnees) {
}
idsProjets.add(projet.id);
if (projet.baselineDate !== undefined && !dateValide(projet.baselineDate)) {
throw new ErreurValidation(
`Projet ${repere} : « baselineDate » n'est pas une date valide (${projet.baselineDate}).`
);
for (const cle of ['baselineDate', 'completedDate', 'discardedDate']) {
if (projet[cle] !== undefined && !dateValide(projet[cle])) {
throw new ErreurValidation(
`Projet ${repere} : « ${cle} » n'est pas une date valide (${projet[cle]}).`
);
}
}
const horizon = validerHorizon(projet.horizon, repere);
if (!Array.isArray(projet.phases)) {
throw new ErreurValidation(`Projet ${repere} : « phases » doit être un tableau.`);
}
@@ -363,6 +536,9 @@ export function validerPlanning(donnees) {
// bloquer le chargement d'un planning entier pour un champ mal typé serait
// disproportionné là où le champ n'engage aucun calcul.
notes: typeof projet.notes === 'string' ? projet.notes : '',
...(horizon ? { horizon } : {}),
...(projet.completedDate ? { completedDate: projet.completedDate } : {}),
...(projet.discardedDate ? { discardedDate: projet.discardedDate } : {}),
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
phases: trierPhases(phases),
};
@@ -371,6 +547,33 @@ export function validerPlanning(donnees) {
return { version: VERSION_FORMAT, projects: projets };
}
/**
* L'horizon est facultatif — aucun fichier en version 4 n'en porte — mais s'il
* est là, il est vérifié aussi strictement que les dates d'une phase et non
* réparé en silence comme le sont le statut ou les notes : il décide d'une
* position sur la frise, et un horizon avalé laisserait un projet envisagé
* invisible sans qu'on sache pourquoi.
*/
function validerHorizon(horizon, repere) {
if (horizon === undefined) return undefined;
if (!horizon || typeof horizon !== 'object') {
throw new ErreurValidation(`Projet ${repere} : « horizon » doit être un objet.`);
}
if (!moisValide(horizon.start) || !moisValide(horizon.end)) {
throw new ErreurValidation(
`Projet ${repere} : l'horizon doit porter deux mois « AAAA-MM » ` +
`(${horizon.start}${horizon.end}).`
);
}
// « AAAA-MM » se compare comme du texte.
if (horizon.end < horizon.start) {
throw new ErreurValidation(
`Projet ${repere} : la fin de l'horizon (${horizon.end}) précède son début (${horizon.start}).`
);
}
return { start: horizon.start, end: horizon.end };
}
/**
* Les tags sont facultatifs — un fichier en version 2 n'en a aucun — mais s'ils
* sont là, ils doivent être des chaînes : un tag avalé en silence parce qu'il
@@ -479,13 +682,21 @@ export function bornesProjet(projet) {
return { start: debut, end: fin };
}
/** Étendue de tous les projets visibles réunis. Null si rien à afficher. */
/**
* Étendue de tous les projets visibles réunis. Null si rien à afficher.
*
* Elle sert à cadrer la fenêtre au premier affichage, et prend donc l'emprise
* plutôt que les seules phases : sans cela, un planning fait de projets encore
* tous envisagés s'ouvrirait sur rien. Les écartés en sont exclus — ils ne
* s'affichent pas d'ordinaire, et un projet abandonné il y a trois ans n'a pas à
* tirer la vue en arrière.
*/
export function bornesPlanning(planning) {
let debut = null;
let fin = null;
for (const projet of planning.projects) {
if (projet.hidden) continue;
const bornes = bornesProjet(projet);
if (projet.hidden || etatProjet(projet) === 'discarded') continue;
const bornes = empriseProjet(projet);
if (!bornes) continue;
if (debut === null || versUTC(bornes.start) < versUTC(debut)) debut = bornes.start;
if (fin === null || versUTC(bornes.end) > versUTC(fin)) fin = bornes.end;
@@ -572,37 +783,59 @@ export function formulerDerive(jours) {
// ---------------------------------------------------------------------------
/**
* Toutes les phases du planning regroupées par mois, projets mêlés.
* Tout ce que le planning fait arriver, regroupé par mois, projets mêlés.
*
* Une phase est rattachée au **mois de son début**, et à lui seul. Une phase de
* trois mois n'apparaît donc qu'une fois, au mois où elle démarre : la liste
* répond à « qu'est-ce qui commence, et quand », question à laquelle la frise
* répond mal parce qu'elle éparpille les départs sur autant de couloirs qu'il y
* a de projets. La répéter dans chaque mois traversé en aurait fait un tableau
* de charge — utile, mais c'est une autre vue, et le doublon coûte la lecture
* en diagonale qui fait tout l'intérêt de celle-ci. Sa fin est écrite sur la
* ligne, ce qui suffit à voir qu'elle déborde.
* Deux sortes d'entrées y cohabitent, et la seconde est venue plus tard :
*
* Les deux tamis de la frise s'appliquent de la même façon (timeline.js) : le
* filtre par tags retire le projet, l'œil (`hidden`) aussi — un projet dont on
* a masqué les barres n'a pas à revenir par la liste.
* - une **phase**, rattachée au mois de son début et à lui seul. Une phase de
* trois mois n'apparaît donc qu'une fois, au mois où elle démarre : la liste
* répond à « qu'est-ce qui commence, et quand », question à laquelle la frise
* répond mal parce qu'elle éparpille les départs sur autant de couloirs qu'il
* y a de projets. La répéter dans chaque mois traversé en aurait fait un
* tableau de charge — utile, mais c'est une autre vue, et le doublon coûte la
* lecture en diagonale qui fait tout l'intérêt de celle-ci. Sa fin est écrite
* sur la ligne, ce qui suffit à voir qu'elle déborde.
* - un **projet sans phase**, rattaché au premier mois de son horizon. Il n'a
* rien à lister autrement, donc il ne paraissait nulle part ici — alors qu'un
* projet visé pour mars 2027 *arrive* bel et bien en mars 2027. L'omettre
* revenait à dire que la liste ne montre que l'engagé, quand toute la
* conception pose qu'un projet envisagé n'est pas hors du temps (décision 27).
* La règle porte sur l'absence de phases et non sur l'état, exactement comme
* `empriseProjet` : un projet écarté avant d'avoir été engagé n'a lui non plus
* que son horizon pour se situer, et il doit se retrouver là quand on ouvre le
* cimetière.
*
* Les tamis de la frise s'appliquent tous de la même façon (timeline.js) : le
* filtre par tags retire le projet, celui par état aussi, et l'œil (`hidden`)
* de même — un projet dont on a masqué les barres n'a pas à revenir par la
* liste, et un projet écarté n'a pas à peupler les mois à venir.
*
* Les mois sans rien ne sont pas représentés : intercaler « novembre 2026 —
* rien » entre deux mois pleins allongerait la liste de tout le temps mort d'un
* planning, alors que l'absence se lit déjà dans le saut d'un titre à l'autre.
*
* @returns {Array<{mois: string, entrees: Array<{projet: object, phase: object}>}>}
* @returns {Array<{mois: string, entrees: Array<{projet: object, phase?: object, horizon?: object}>}>}
* trié du plus ancien au plus récent, chaque mois trié par date puis par nom.
*/
export function phasesParMois(planning, tagsActifs = []) {
export function entreesParMois(planning, tagsActifs = [], etatsActifs = []) {
const groupes = new Map();
const poser = (mois, entree) => {
if (!groupes.has(mois)) groupes.set(mois, []);
groupes.get(mois).push(entree);
};
for (const projet of planning.projects) {
if (projet.hidden || !projetFiltre(projet, tagsActifs)) continue;
if (!projetFiltreEtat(projet, etatsActifs)) continue;
for (const phase of projet.phases) {
const mois = moisDe(phase.start);
if (!groupes.has(mois)) groupes.set(mois, []);
groupes.get(mois).push({ projet, phase });
poser(moisDe(phase.start), { projet, phase });
}
// Un projet sans horizon n'a rien à quoi se rattacher : il se signale déjà
// dans la colonne de la frise, où sa pastille « à dater » réclame une date.
if (!projet.phases.length && projet.horizon) {
poser(projet.horizon.start, { projet, horizon: projet.horizon });
}
}
@@ -612,18 +845,40 @@ export function phasesParMois(planning, tagsActifs = []) {
mois,
entrees: groupes.get(mois).sort(
(a, b) =>
versUTC(a.phase.start) - versUTC(b.phase.start) ||
versUTC(debutEntree(a)) - versUTC(debutEntree(b)) ||
a.projet.name.localeCompare(b.projet.name, 'fr') ||
a.phase.name.localeCompare(b.phase.name, 'fr')
nomEntree(a).localeCompare(nomEntree(b), 'fr')
),
}));
}
/**
* Date à laquelle une entrée commence, pour le tri. Un horizon n'ayant pas de
* jour, il prend le premier du mois — ce qui le place en tête des entrées de son
* mois, à sa place : c'est ce qui est le moins arrêté qui ouvre la liste.
*/
function debutEntree(entree) {
return entree.phase ? entree.phase.start : premierJourDuMois(entree.horizon.start);
}
function nomEntree(entree) {
return entree.phase ? entree.phase.name : entree.projet.name;
}
// ---------------------------------------------------------------------------
// Modifications
// ---------------------------------------------------------------------------
export function creerProjet(planning, nom, tags = []) {
/**
* Un projet naît **envisagé** : sans phase, avec pour seul ancrage un horizon
* d'un mois, celui où on le crée.
*
* Ce mois par défaut n'est pas une prévision, c'est une position de départ — la
* barre paraît sous les yeux, là où on regarde, et se repousse aussitôt. Naître
* sans horizon aurait été plus honnête mais invisible, et la décision 27 pose
* qu'un projet envisagé n'est jamais hors du temps.
*/
export function creerProjet(planning, nom, tags = [], mois = moisCourant()) {
const id = fabriquerId(nom, planning.projects.map((p) => p.id));
return {
id,
@@ -633,10 +888,103 @@ export function creerProjet(planning, nom, tags = []) {
collapsed: false,
hidden: false,
notes: '',
horizon: { start: mois, end: mois },
phases: [],
};
}
/**
* Pose l'horizon d'un projet. Les bornes se remettent d'aplomb plutôt que d'être
* refusées : la saisie se fait au fil de la frappe, et un instant où la fin
* précède le début est l'état normal de quelqu'un qui écrit son intervalle.
*/
export function definirHorizon(projet, debut, fin = debut) {
return { ...projet, horizon: { start: debut, end: fin < debut ? debut : fin } };
}
/**
* Repousse — ou avance — l'horizon d'un nombre de mois, largeur conservée.
*
* C'est le seul geste qui déplace un horizon. La barre d'un projet envisagé ne
* se glisse pas au jour : l'imprécision doit rester visible, et l'interdire
* physiquement vaut mieux que de compter sur une convention (décision 27).
*/
export function decalerHorizon(projet, mois) {
if (!projet.horizon) return projet;
return {
...projet,
horizon: {
start: ajouterMois(projet.horizon.start, mois),
end: ajouterMois(projet.horizon.end, mois),
},
};
}
/**
* Acte la clôture d'un projet, et **termine toutes ses phases avec lui**.
*
* Livré n'est pas clos : c'est quelqu'un qui le prononce, même quand une phase
* traîne encore en « à venir ». Mais prononcer la clôture d'un projet dont une
* phase resterait bloquée laisserait la frise se contredire — la barre
* cumulative garderait ses segments pâles sous une ligne éteinte, et la vue par
* mois annoncerait des tâches à venir dans un projet fini.
*
* L'opération est donc **destructive** et ne se défait pas : `rouvrirProjet` ne
* peut pas savoir laquelle des phases était bloquée et laquelle était à venir.
* D'où la confirmation demandée par l'interface dès qu'une phase resterait à
* changer (docs/decisions.md, section 27).
*/
export function terminerProjet(projet, date = aujourdhui()) {
return {
...projet,
completedDate: date,
discardedDate: undefined,
phases: projet.phases.map((phase) =>
phase.status === 'done' ? phase : { ...phase, status: 'done' }
),
};
}
/** Vrai si clore le projet changerait le statut d'au moins une de ses phases. */
export function clotureEcraseDesStatuts(projet) {
return projet.phases.some((phase) => phase.status !== 'done');
}
/**
* Annule la clôture : le projet retourne à l'état que ses phases commandent.
*
* Les statuts, eux, restent à « terminé » — ils ont été écrasés à la clôture, et
* rien ne dit ce qu'ils valaient avant. C'est le prix de l'opération, et la
* raison pour laquelle elle se confirme.
*/
export function rouvrirProjet(projet) {
return { ...projet, completedDate: undefined };
}
/**
* Écarte un projet. Ce n'est pas le supprimer : il quitte la frise mais garde
* tout, notes comprises — la raison pour laquelle on a dit non a de la valeur le
* jour où la même idée revient.
*
* La date est le seul ancrage temporel qui reste à un projet jamais engagé, dont
* l'horizon a cessé d'être une prévision. Elle est posée, jamais saisie.
*/
export function ecarterProjet(projet, date = aujourdhui()) {
return { ...projet, discardedDate: date, completedDate: undefined };
}
/**
* Sort un projet du cimetière. Aucune destination à choisir : puisque les états
* se déduisent, retirer l'écartement suffit — le projet retrouve seul l'état que
* ses données commandent, engagé s'il a des phases, envisagé sinon.
*
* Son horizon, lui, est resté celui d'avant : il se signalera aussitôt comme
* dépassé, et c'est voulu.
*/
export function reanimerProjet(projet) {
return { ...projet, discardedDate: undefined };
}
export function creerPhase(projet, nom, debut, fin, options = {}) {
const id = fabriquerId(nom, projet.phases.map((p) => p.id));
const jalon = Boolean(options.milestone);

View File

@@ -19,15 +19,19 @@
*/
import {
LIBELLES_ETAT,
LIBELLES_STATUT,
aujourdhui,
duree,
entreesParMois,
etatProjet,
formaterDateLongue,
formaterHorizon,
formaterMois,
formaterPeriode,
moisDe,
phasesParMois,
projetFiltre,
projetFiltreEtat,
} from './model.js';
import { aplatirMarkdown } from './markdown.js';
import { rendreMarkdown } from './notes.js';
@@ -38,13 +42,13 @@ import { pastillesTags } from './tags.js';
*
* @param {HTMLElement} conteneur
* @param {object} planning
* @param {object} options { tagsActifs }
* @param {object} options { tagsActifs, etatsActifs }
*/
export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) {
const groupes = phasesParMois(planning, tagsActifs);
export function rendreMois(conteneur, planning, { tagsActifs = [], etatsActifs = [] } = {}) {
const groupes = entreesParMois(planning, tagsActifs, etatsActifs);
if (!groupes.length) {
conteneur.replaceChildren(messageVide(planning, tagsActifs));
conteneur.replaceChildren(messageVide(planning, tagsActifs, etatsActifs));
return;
}
@@ -65,8 +69,10 @@ export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) {
* Le cas des projets masqués mérite son message : rien à l'écran ne rappelle
* l'œil ici, puisque la liste n'a pas de colonne de libellés où le rouvrir.
*/
function messageVide(planning, tagsActifs) {
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
function messageVide(planning, tagsActifs, etatsActifs = []) {
const retenus = planning.projects.filter(
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
);
const paragraphe = document.createElement('p');
paragraphe.className = 'mois__vide';
@@ -120,18 +126,89 @@ function construireGroupe({ mois, entrees }, moisCourant, tagsActifs) {
return section;
}
/** « 4 tâches · 2 jalons », en taisant celui des deux qui vaut zéro. */
/** « 4 tâches · 2 jalons · 1 envisagé », en taisant ce qui vaut zéro. */
function resumerCompte(entrees) {
const jalons = entrees.filter(({ phase }) => phase.milestone).length;
const taches = entrees.length - jalons;
const sansPhase = entrees.filter(({ horizon }) => horizon);
// Les projets sans phase ne se comptent pas ensemble : « 3 envisagés » et
// « 3 écartés » ne disent pas du tout la même chose du mois qui vient.
const envisages = sansPhase.filter(({ projet }) => etatProjet(projet) === 'considered').length;
const ecartes = sansPhase.length - envisages;
const jalons = entrees.filter(({ phase }) => phase?.milestone).length;
const taches = entrees.length - jalons - sansPhase.length;
const morceaux = [];
if (taches) morceaux.push(`${taches} tâche${taches > 1 ? 's' : ''}`);
if (jalons) morceaux.push(`${jalons} jalon${jalons > 1 ? 's' : ''}`);
if (envisages) morceaux.push(`${envisages} envisagé${envisages > 1 ? 's' : ''}`);
if (ecartes) morceaux.push(`${ecartes} écarté${ecartes > 1 ? 's' : ''}`);
return morceaux.join(' · ');
}
function construireEntree({ projet, phase }, tagsActifs) {
function construireEntree(entree, tagsActifs) {
return entree.horizon
? construireEntreeHorizon(entree, tagsActifs)
: construireEntreePhase(entree, tagsActifs);
}
/**
* Ligne d'un projet qui n'a pas de phase : mêmes colonnes que celle d'une phase,
* pour que la liste reste une grille qu'on lit en diagonale, mais un contenu qui
* dit partout que rien n'est arrêté.
*
* L'intitulé est en italique et l'emplacement du statut porte l'état : c'est
* bien la même question — qu'est-ce qui arrive, et où ça en est — posée à un
* objet qui n'a pas encore de dates. Un projet écarté avant d'avoir été engagé
* passe par ici lui aussi, et se lit « Écarté » à la même place.
*/
function construireEntreeHorizon({ projet, horizon }, tagsActifs) {
const etat = etatProjet(projet);
const ligne = document.createElement('li');
ligne.className = 'mois-entree mois-entree--envisage';
ligne.dataset.etat = etat;
ligne.style.setProperty('--couleur-projet', projet.color);
if (projet.notes) ligne.classList.add('mois-entree--commentee');
ligne.title =
`${projet.name}\n\n${LIBELLES_ETAT[etat]}${formaterHorizon(horizon)}` +
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '');
ligne.append(triangle(projet.notes, projet.name));
const marque = document.createElement('span');
marque.className = 'mois-entree__marque';
ligne.append(marque);
const dates = document.createElement('span');
dates.className = 'mois-entree__dates';
// Le mois du groupe est déjà écrit en titre : on n'en répète que la fin quand
// l'horizon déborde, comme `formaterPeriode` le fait pour une phase.
dates.textContent = horizon.start === horizon.end ? '—' : `${formaterMois(horizon.end)}`;
ligne.append(dates);
const nomProjet = document.createElement('span');
nomProjet.className = 'mois-entree__projet';
nomProjet.textContent = projet.name;
ligne.append(nomProjet);
ligne.append(pastillesTags(projet.tags, tagsActifs));
const nom = document.createElement('span');
nom.className = 'mois-entree__nom';
nom.textContent = formaterHorizon(horizon);
ligne.append(nom);
const statut = document.createElement('span');
statut.className = 'mois-entree__statut';
statut.dataset.statut = etat;
statut.textContent = LIBELLES_ETAT[etat];
ligne.append(statut);
if (projet.notes) ligne.append(notes(projet.notes));
return ligne;
}
function construireEntreePhase({ projet, phase }, tagsActifs) {
const ligne = document.createElement('li');
ligne.className = `mois-entree mois-entree--${phase.status}`;
ligne.style.setProperty('--couleur-projet', projet.color);
@@ -141,7 +218,7 @@ function construireEntree({ projet, phase }, tagsActifs) {
if (phase.notes) ligne.classList.add('mois-entree--commentee');
ligne.title = infobulle(projet, phase);
ligne.append(triangle(phase));
ligne.append(triangle(phase.notes, phase.name));
// La marque reprend les formes de la frise — losange pour un jalon, barre
// pour une tâche — pour qu'on reconnaisse au même dessin ce qu'on a appris à
@@ -179,7 +256,7 @@ function construireEntree({ projet, phase }, tagsActifs) {
statut.textContent = LIBELLES_STATUT[phase.status];
ligne.append(statut);
if (phase.notes) ligne.append(notes(phase));
if (phase.notes) ligne.append(notes(phase.notes));
return ligne;
}
@@ -196,9 +273,13 @@ function construireEntree({ projet, phase }, tagsActifs) {
* Les lignes sans notes reçoivent tout de même une case vide : les colonnes de
* la grille sont comptées, et il n'y a pas de raison qu'une phase commentée
* décale toutes les autres.
*
* Il reçoit le texte et le nom plutôt que l'objet qui les porte : une ligne de
* projet envisagé déplie les notes du projet là où une ligne de phase déplie les
* siennes, et le triangle n'a pas à savoir laquelle des deux il sert.
*/
function triangle(phase) {
if (!phase.notes) {
function triangle(texte, nom) {
if (!texte) {
const vide = document.createElement('span');
vide.className = 'mois-entree__plier';
return vide;
@@ -210,9 +291,9 @@ function triangle(phase) {
bouton.dataset.action = 'notes';
bouton.setAttribute('aria-expanded', 'false');
// La ligne entière déplie, mais elle n'est pas focusable : ce bouton est le
// seul chemin clavier vers les notes, il lui faut donc un nom qui dise de
// quelle phase il parle.
bouton.setAttribute('aria-label', `Notes de ${phase.name}`);
// seul chemin clavier vers les notes, il lui faut donc un nom qui dise de quoi
// il parle.
bouton.setAttribute('aria-label', `Notes de ${nom}`);
bouton.title = 'Afficher les notes';
bouton.textContent = '▶';
return bouton;
@@ -230,10 +311,10 @@ function triangle(phase) {
* qu'on n'y casse rien. On les voit cochées ou non, ce qui est justement ce
* qu'on vient y chercher quand on fait le point.
*/
function notes(phase) {
function notes(texte) {
const bloc = document.createElement('div');
bloc.className = 'mois-entree__notes';
bloc.append(rendreMarkdown(phase.notes));
bloc.append(rendreMarkdown(texte));
bloc.hidden = true;
return bloc;
}

View File

@@ -11,6 +11,20 @@
*/
import { creerBlocNotes } from './notes.js';
import {
LIBELLES_ETAT,
decalerHorizon,
definirHorizon,
ecarterProjet,
etatProjet,
formaterDateLongue,
formaterHorizon,
moisValide,
projetADater,
reanimerProjet,
rouvrirProjet,
terminerProjet,
} from './model.js';
export function creerPanneauProjet(refs, outils, rappels) {
const {
@@ -21,6 +35,12 @@ export function creerPanneauProjet(refs, outils, rappels) {
conteneurCouleurs,
suggestions,
blocSuggestions,
etat,
blocHorizon,
crans,
aideHorizon,
terminer,
ecarter,
notes,
supprimer,
fermer,
@@ -106,6 +126,101 @@ export function creerPanneauProjet(refs, outils, rappels) {
blocSuggestions.hidden = tagsConnus.length === 0;
}
/**
* Horizon lu dans les deux champs de mois.
*
* Une borne laissée vide prend la valeur de l'autre : on saisit souvent un
* mois unique, et exiger de le taper deux fois serait une formalité. Les deux
* vides valent « pas d'horizon » — un état légal, celui d'un projet hérité
* d'un fichier en version 4, qui se signalera comme réclamant une date.
*
* Le champ `type="month"` n'est pas un sélecteur partout : sous Firefox c'est
* un champ texte, d'où la vérification plutôt qu'une confiance faite au
* navigateur.
*/
function horizonSaisi() {
const debut = formulaire.horizonStart.value;
const fin = formulaire.horizonEnd.value;
if (!moisValide(debut) && !moisValide(fin)) return undefined;
if (!moisValide(debut)) return { start: fin, end: fin };
if (!moisValide(fin)) return { start: debut, end: debut };
return definirHorizon({}, debut, fin).horizon;
}
/**
* Le bloc « Horizon » ne paraît que pour un projet envisagé.
*
* Dès qu'une phase existe, ce sont les phases qui commandent l'emprise et
* l'horizon n'est plus qu'une valeur de repli conservée en silence : la
* proposer à la saisie laisserait croire qu'elle déplace encore quelque chose.
*/
function peindreCycleDeVie(projet) {
const nom = etatProjet(projet);
const envisage = nom === 'considered';
etat.textContent = LIBELLES_ETAT[nom] + legendeEtat(projet, nom);
etat.dataset.etat = nom;
// Chaque acte se présente dans un sens ou dans l'autre selon où en est le
// projet : une commande qui bascule plutôt que deux dont l'une serait
// toujours grise.
terminer.textContent = nom === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé';
// Un projet envisagé n'a rien à clore, et un écarté sort du cimetière par
// « Remettre en jeu » avant tout autre chose.
terminer.disabled = nom === 'considered' || nom === 'discarded';
terminer.title = terminer.disabled
? nom === 'considered'
? "Un projet envisagé n'a pas encore commencé."
: "Remettre le projet en jeu d'abord."
: nom === 'completed'
? 'Les phases resteront terminées : leur statut davant est perdu.'
: 'Toutes les phases et tous les jalons passeront à « Terminé ».';
ecarter.textContent = nom === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet';
ecarter.title =
nom === 'discarded'
? "Le projet retrouvera l'état que ses données commandent."
: 'Le projet quitte la frise mais garde tout, notes comprises.';
blocHorizon.hidden = !envisage;
if (!envisage) return;
// On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur au
// milieu d'une frappe, comme pour le nom et les tags.
const actif = document.activeElement;
if (formulaire.horizonStart !== actif) {
formulaire.horizonStart.value = projet.horizon?.start ?? '';
}
if (formulaire.horizonEnd !== actif) {
formulaire.horizonEnd.value = projet.horizon?.end ?? '';
}
const aDater = projetADater(projet);
aideHorizon.textContent = aDater
? projet.horizon
? 'Cet horizon est passé : le repousser, ou écarter le projet.'
: "Sans horizon, ce projet n'est situé nulle part sur la frise."
: 'Ajouter une phase engage le projet et lui donne des dates fermes.';
aideHorizon.dataset.alerte = String(aDater);
// Rien à décaler tant qu'aucun horizon n'est posé : les crans conservent la
// largeur d'un intervalle, ils n'en inventent pas.
for (const bouton of crans.children) bouton.disabled = !projet.horizon;
}
/** Ce qui suit le nom de l'état : l'horizon, ou la date de l'acte. */
function legendeEtat(projet, nom) {
if (nom === 'considered') {
return projet.horizon ? `${formaterHorizon(projet.horizon)}` : ' — sans horizon';
}
if (nom === 'engaged') {
return `${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}`;
}
if (nom === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
return ` le ${formaterDateLongue(projet.discardedDate)}`;
}
function remplir(projet) {
titre.textContent = projet.name;
formulaire.name.value = projet.name;
@@ -113,6 +228,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
blocNotes.definir(projet.notes, { reinitialiserMode: true });
peindrePalette(projet.color);
peindreSuggestions();
peindreCycleDeVie(projet);
afficherErreur(null);
}
@@ -139,6 +255,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
name: nom,
tags: tagsSaisis(),
notes: formulaire.notes.value,
// L'horizon d'un projet engagé n'est plus modifiable, mais il n'est pas
// effacé pour autant : c'est ce qui permet à un projet ayant perdu sa
// dernière phase de le retrouver plutôt qu'une enveloppe reconstituée.
horizon: etatProjet(projetCourant) === 'considered' ? horizonSaisi() : projetCourant.horizon,
...forces,
};
@@ -148,11 +268,21 @@ export function creerPanneauProjet(refs, outils, rappels) {
suivant.tags.length === projetCourant.tags.length &&
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
const memeHorizon =
suivant.horizon?.start === projetCourant.horizon?.start &&
suivant.horizon?.end === projetCourant.horizon?.end;
if (
suivant.name === projetCourant.name &&
suivant.color === projetCourant.color &&
suivant.notes === projetCourant.notes &&
memesTags
// Les deux dates d'acte comptent ici comme les autres champs : sans elles,
// déclarer un projet terminé ne changerait aucune des valeurs comparées et
// la modification serait tue.
suivant.completedDate === projetCourant.completedDate &&
suivant.discardedDate === projetCourant.discardedDate &&
memesTags &&
memeHorizon
) {
return;
}
@@ -161,6 +291,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
titre.textContent = suivant.name;
peindrePalette(suivant.color);
peindreSuggestions();
peindreCycleDeVie(suivant);
rappels.surModification(idCourant, suivant);
}
@@ -168,6 +299,41 @@ export function creerPanneauProjet(refs, outils, rappels) {
formulaire.addEventListener('change', () => appliquer());
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
// Repousser par crans est le seul geste qui déplace un horizon : la barre de
// la frise, elle, ne se glisse pas. Les trois boutons couvrent ce qu'on fait
// réellement — reculer d'un trimestre, en gagner un, renvoyer à l'an prochain.
crans.addEventListener('click', (evenement) => {
const bouton = evenement.target.closest('[data-cran]');
if (!bouton || !projetCourant?.horizon) return;
appliquer({ horizon: decalerHorizon(projetCourant, Number(bouton.dataset.cran)).horizon });
});
// Les deux actes passent par `appliquer`, comme le reste du panneau : c'est
// lui qui compare, remonte la modification et repeint.
terminer.addEventListener('click', async () => {
if (!projetCourant) return;
if (etatProjet(projetCourant) === 'completed') {
appliquer(rouvrirProjet(projetCourant));
return;
}
// Clore termine aussi les phases : la confirmation vit dans app.js, qui la
// partage avec l'entrée du menu ⋯ — le même acte ne doit pas prévenir depuis
// un endroit et se taire depuis l'autre.
const cible = idCourant;
if (!(await rappels.demanderCloture(projetCourant))) return;
// Le panneau a pu être fermé ou changer de projet pendant la confirmation.
if (idCourant !== cible || !projetCourant) return;
appliquer(terminerProjet(projetCourant));
});
ecarter.addEventListener('click', () => {
if (!projetCourant) return;
const nom = etatProjet(projetCourant);
appliquer(nom === 'discarded' ? reanimerProjet(projetCourant) : ecarterProjet(projetCourant));
});
supprimer.addEventListener('click', async () => {
if (!idCourant) return;
const cible = idCourant;
@@ -222,6 +388,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
if (formulaire.notes !== actif) blocNotes.definir(projet.notes);
peindrePalette(projet.color);
peindreSuggestions();
// L'état a pu changer sans passer par ce panneau : ajouter la première
// phase engage le projet, et le bloc « Horizon » doit disparaître au même
// instant que la barre pâle sur la frise.
peindreCycleDeVie(projet);
},
fermer() {

View File

@@ -12,20 +12,26 @@
*/
import {
LIBELLES_ETAT,
LIBELLES_STATUT,
MOIS_COURTS,
MOIS_LONGS,
ajouterJours,
aujourdhui,
bornesHorizon,
bornesProjet,
bornesReference,
deriveProjet,
dimancheDeLaSemaine,
ecartJours,
etatProjet,
formaterDateLongue,
formaterHorizon,
formulerDerive,
lundiDeLaSemaine,
projetADater,
projetFiltre,
projetFiltreEtat,
semaineISO,
versUTC,
} from './model.js';
@@ -85,19 +91,28 @@ export function creerEchelle(fenetre) {
/**
* Reconstruit toute la frise.
*
* Deux tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
* Trois tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
* **filtre par tags** retire le projet de la vue entière, libellé compris : il
* répond à « je ne regarde que les projets clients ». L'**œil** (`hidden`) garde
* la ligne de libellé, grisée, et n'ôte que les barres : il répond à « je sais
* que ce projet existe, mais il encombre ma frise ».
* répond à « je ne regarde que les projets clients ». Le **filtre par état** en
* fait autant, mais en élargissant à chaque case cochée plutôt qu'en resserrant,
* un projet n'ayant qu'un état : il répond à « je ne veux pas voir le
* cimetière ». L'**œil** (`hidden`) garde la ligne de libellé, grisée, et n'ôte
* que les barres : il répond à « je sais que ce projet existe, mais il encombre
* ma frise ».
*
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
* @param {object} planning l'état courant
* @param {object} options { fenetre, curseur, tagsActifs }
* @param {object} options { fenetre, curseur, tagsActifs, etatsActifs }
*/
export function rendre(refs, planning, { fenetre, curseur, tagsActifs = [] }) {
export function rendre(
refs,
planning,
{ fenetre, curseur, tagsActifs = [], etatsActifs = [] }
) {
const echelle = creerEchelle(fenetre);
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const retenus = planning.projects.filter(
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
);
const visibles = retenus.filter((projet) => !projet.hidden);
refs.entete.style.width = `${echelle.largeur}px`;
@@ -303,6 +318,8 @@ function construireLibelles(projets, tagsActifs, curseur) {
}
function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
const etat = etatProjet(projet);
const ligne = document.createElement('div');
ligne.className =
'libelle-projet' +
@@ -310,6 +327,10 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
(sousCurseur ? ' libelle-projet--curseur' : '');
ligne.style.setProperty('--couleur-projet', projet.color);
ligne.dataset.projet = projet.id;
// L'état se peint en CSS plutôt qu'en classes empilées : il est exclusif, et
// un attribut qui ne peut porter qu'une valeur le dit mieux que quatre
// modificateurs dont un seul doit être posé à la fois.
ligne.dataset.etat = etat;
const plier = document.createElement('button');
plier.className = 'plier';
@@ -333,6 +354,7 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
// reste le seul endroit où les deux se lisent toujours en entier.
nom.title =
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
`\n${LIBELLES_ETAT[etat]}${legendeEtat(projet, etat)}` +
// Les notes du projet passent avant l'invite : c'est le contenu, l'invite
// n'est qu'un mode d'emploi. Le libellé est le seul endroit de la frise où
// elles se lisent — un projet n'a pas de barre à lui.
@@ -345,6 +367,20 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
// Un projet envisagé dont l'horizon est passé — ou qui n'en a jamais eu — le
// dit sur sa ligne. C'est le garde-fou qui empêche les envisagés de devenir
// une décharge : on ne peut pas ne pas le voir en parcourant la colonne, et
// il ne s'éteint qu'en repoussant l'horizon ou en écartant le projet.
if (projetADater(projet)) {
const alerte = document.createElement('span');
alerte.className = 'libelle-projet__adater';
alerte.textContent = 'à dater';
alerte.title = projet.horizon
? `Horizon dépassé (${formaterHorizon(projet.horizon)}) — repousser ou écarter.`
: "Aucun horizon : ce projet n'est situé nulle part sur la frise.";
ligne.append(alerte);
}
const derive = formulerDerive(deriveProjet(projet));
if (derive) {
const etiquette = document.createElement('span');
@@ -395,6 +431,19 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
return ligne;
}
/**
* Ce qui suit le nom de l'état dans une infobulle : l'horizon d'un projet
* envisagé, la date de l'acte pour les deux qu'on prononce.
*/
function legendeEtat(projet, etat) {
if (etat === 'considered') {
return projet.horizon ? `${formaterHorizon(projet.horizon)}` : ' — sans horizon';
}
if (etat === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
if (etat === 'discarded') return ` le ${formaterDateLongue(projet.discardedDate)}`;
return '';
}
// ---------------------------------------------------------------------------
// Couloirs
// ---------------------------------------------------------------------------
@@ -411,12 +460,16 @@ function construireCouloirs(projets, echelle, curseur) {
const couloirProjet = document.createElement('div');
couloirProjet.className = `couloir couloir--projet${surLeProjet ? ' couloir--curseur' : ''}`;
couloirProjet.style.setProperty('--couleur-projet', projet.color);
couloirProjet.dataset.etat = etatProjet(projet);
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
// coiffe les phases du projet et garde sa forme d'ensemble sous les yeux
// pendant qu'on travaille sur le détail. Sans elle, il fallait replier pour
// savoir où en était le projet entier, donc perdre la vue qu'on éditait.
const barre = construireBarreCumulative(projet, echelle);
//
// Un projet envisagé n'a pas de phase à cumuler : c'est son horizon qui
// occupe la ligne, et rien d'autre ne s'affiche sous elle.
const barre = construireBarreCumulative(projet, echelle) ?? construireHorizon(projet, echelle);
if (barre) couloirProjet.append(barre);
const fantome = construireFantome(projet, echelle);
if (fantome) couloirProjet.append(fantome);
@@ -554,6 +607,53 @@ function construireBarreCumulative(projet, echelle) {
return conteneur;
}
/**
* Barre d'horizon d'un projet envisagé : pâle, aux bords fondus.
*
* Elle se glisse et se redimensionne comme une barre de phase, mais **accrochée
* au mois** là où celle d'une phase l'est au lundi. C'est le même parti pris
* qu'à la décision 9, poussé d'un cran : on ne pose pas un horizon au jour près,
* donc le geste ne le permet pas — tandis qu'interdire le glisser tout court, ce
* qui fut d'abord essayé, obligeait à passer par le panneau pour un ajustement
* qu'on veut faire en regardant les voisins.
*
* Sa largeur *est* l'incertitude : un projet vague occupe une large plage floue,
* un projet mûr un seul mois. Rien à écrire pour le dire, l'œil le lit.
*/
function construireHorizon(projet, echelle) {
const bornes = bornesHorizon(projet);
if (!bornes) return null;
const barre = document.createElement('div');
barre.className = 'barre-horizon';
barre.style.left = `${echelle.x(bornes.start)}px`;
barre.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
barre.dataset.projet = projet.id;
// Un rôle distinct de celui d'une phase : `drag.js` y reconnaît qu'il déplace
// des mois et non des jours, et qu'il n'y a pas de phase à aller chercher.
barre.dataset.role = 'horizon';
if (projetADater(projet)) barre.classList.add('barre-horizon--depasse');
barre.title =
`${projet.name}\nEnvisagé — ${formaterHorizon(projet.horizon)}` +
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '') +
'\n\nSe glisse au mois. Ajouter une phase engage le projet et lui donne des dates fermes.';
const nom = document.createElement('span');
nom.className = 'barre__nom';
nom.textContent = projet.name;
barre.append(nom);
for (const bord of ['debut', 'fin']) {
const poignee = document.createElement('div');
poignee.className = `poignee poignee--${bord}`;
poignee.dataset.role = 'poignee';
poignee.dataset.bord = bord;
barre.append(poignee);
}
return barre;
}
/** Barre fantôme du projet, reflétant l'étendue de référence. */
function construireFantome(projet, echelle) {
const reference = bornesReference(projet);

View File

@@ -6,37 +6,56 @@ import {
MAX_LONGUEUR_TAG,
VERSION_FORMAT,
ajouterJours,
ajouterMois,
ajouterPhase,
bornesHorizon,
bornesPlanning,
bornesProjet,
bornesReference,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
dateValide,
decalerHorizon,
definirHorizon,
deplacerPhase,
dernierJourDuMois,
dimancheDeLaSemaine,
deriveProjet,
duree,
ecarterProjet,
ecartJours,
ecartMois,
empriseProjet,
entreesParMois,
etatProjet,
fabriquerId,
figerReference,
formaterDateLongue,
formaterHorizon,
formaterMois,
formaterPeriode,
formulerDerive,
libererReference,
lundiDeLaSemaine,
modifierPhase,
moisValide,
normaliserTags,
phasesParMois,
planningVide,
pourEcriture,
premierJourDuMois,
projetADater,
projetFiltre,
projetFiltreEtat,
reanimerProjet,
redimensionnerPhase,
remplacerPhase,
rouvrirProjet,
semaineISO,
supprimerPhase,
teinteTag,
terminerProjet,
tousLesTags,
validerPlanning,
} from '../js/model.js';
@@ -164,6 +183,57 @@ describe('dates', () => {
});
});
// --- mois -------------------------------------------------------------------
describe('mois', () => {
test('moisValide accepte « AAAA-MM » et rejette le reste', () => {
assert.ok(moisValide('2027-03'));
assert.ok(moisValide('2027-12'));
assert.ok(!moisValide('2027-13'), 'mois inexistant');
assert.ok(!moisValide('2027-00'), 'mois zéro');
assert.ok(!moisValide('2027-3'), 'sans zéro de remplissage');
assert.ok(!moisValide('2027-03-01'), 'une date n est pas un mois');
assert.ok(!moisValide(''));
assert.ok(!moisValide(null));
assert.ok(!moisValide(202703));
});
test('les bornes d un mois encadrent ses jours, bissextiles comprises', () => {
assert.equal(premierJourDuMois('2027-03'), '2027-03-01');
assert.equal(dernierJourDuMois('2027-03'), '2027-03-31');
assert.equal(dernierJourDuMois('2027-04'), '2027-04-30');
assert.equal(dernierJourDuMois('2028-02'), '2028-02-29', 'année bissextile');
assert.equal(dernierJourDuMois('2027-02'), '2027-02-28');
assert.equal(dernierJourDuMois('2026-12'), '2026-12-31', 'report d année');
});
test('ajouterMois traverse les années dans les deux sens', () => {
assert.equal(ajouterMois('2026-11', 3), '2027-02');
assert.equal(ajouterMois('2026-02', -3), '2025-11');
assert.equal(ajouterMois('2026-12', 1), '2027-01');
assert.equal(ajouterMois('2026-01', -1), '2025-12');
assert.equal(ajouterMois('2026-08', 0), '2026-08');
assert.equal(ajouterMois('2026-08', 24), '2028-08');
});
test('ecartMois est signé et vaut zéro entre deux mois égaux', () => {
assert.equal(ecartMois('2026-11', '2027-02'), 3);
assert.equal(ecartMois('2027-02', '2026-11'), -3);
assert.equal(ecartMois('2026-08', '2026-08'), 0);
});
test('formaterHorizon n écrit l année qu une fois quand elle est partagée', () => {
assert.equal(formaterHorizon({ start: '2027-03', end: '2027-03' }), 'mars 2027');
assert.equal(formaterHorizon({ start: '2027-03', end: '2027-06' }), 'mars → juin 2027');
assert.equal(
formaterHorizon({ start: '2026-11', end: '2027-02' }),
'novembre 2026 → février 2027'
);
assert.equal(formaterHorizon(undefined), '');
});
});
// --- identifiants -----------------------------------------------------------
describe('fabriquerId', () => {
@@ -458,7 +528,7 @@ describe('bornes', () => {
// --- regroupement par mois --------------------------------------------------
describe('phasesParMois', () => {
describe('entreesParMois', () => {
const planning = {
version: VERSION_FORMAT,
projects: [
@@ -493,7 +563,7 @@ describe('phasesParMois', () => {
const noms = (groupe) => groupe.entrees.map(({ phase: p }) => p.name);
test('rattache chaque phase au seul mois de son début', () => {
const groupes = phasesParMois(planning);
const groupes = entreesParMois(planning);
assert.deepEqual(cles(groupes), ['2026-09', '2026-11']);
assert.deepEqual(noms(groupes[0]), ['Cadrage', 'Rédaction']);
// « Rédaction » traverse octobre et novembre sans y réapparaître.
@@ -501,8 +571,8 @@ describe('phasesParMois', () => {
});
test('ne fabrique pas de mois vide entre deux mois pleins', () => {
assert.equal(phasesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
assert.equal(cles(phasesParMois(planning)).includes('2026-10'), false);
assert.equal(entreesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
assert.equal(cles(entreesParMois(planning)).includes('2026-10'), false);
});
test('trie les mois du plus ancien au plus récent, à cheval sur les années', () => {
@@ -516,7 +586,7 @@ describe('phasesParMois', () => {
}),
],
};
assert.deepEqual(cles(phasesParMois(surDeuxAns)), ['2026-12', '2027-01']);
assert.deepEqual(cles(entreesParMois(surDeuxAns)), ['2026-12', '2027-01']);
});
test('trie un mois par date, puis par projet, puis par nom de phase', () => {
@@ -537,24 +607,98 @@ describe('phasesParMois', () => {
}),
],
};
assert.deepEqual(noms(phasesParMois(memeJour)[0]), ['Amorçage', 'Bilan', 'Atelier']);
assert.deepEqual(noms(entreesParMois(memeJour)[0]), ['Amorçage', 'Bilan', 'Atelier']);
});
test('applique le filtre par tags, et écarte les projets masqués', () => {
assert.deepEqual(cles(phasesParMois(planning, ['interne'])), ['2026-09']);
assert.deepEqual(noms(phasesParMois(planning, ['interne'])[0]), ['Rédaction']);
assert.deepEqual(cles(entreesParMois(planning, ['interne'])), ['2026-09']);
assert.deepEqual(noms(entreesParMois(planning, ['interne'])[0]), ['Rédaction']);
const masque = {
projects: planning.projects.map((p) =>
p.id === 'portail' ? { ...p, hidden: true } : p
),
};
assert.deepEqual(noms(phasesParMois(masque)[0]), ['Cadrage']);
assert.deepEqual(noms(entreesParMois(masque)[0]), ['Cadrage']);
});
test('rend une liste vide plutôt que null quand rien ne reste', () => {
assert.deepEqual(phasesParMois(planningVide()), []);
assert.deepEqual(phasesParMois(planning, ['inconnu']), []);
assert.deepEqual(entreesParMois(planningVide()), []);
assert.deepEqual(entreesParMois(planning, ['inconnu']), []);
});
test('un projet envisagé paraît au premier mois de son horizon', () => {
// Il n'a aucune phase : sans cette entrée, un projet visé pour mars 2027
// n'apparaîtrait nulle part dans une liste qui répond pourtant à « qu'est-ce
// qui arrive, et quand ».
const avecEnvisage = {
projects: [projet({ id: 'idee', name: 'Idée', horizon: { start: '2027-03', end: '2027-06' } })],
};
const groupes = entreesParMois(avecEnvisage);
assert.deepEqual(cles(groupes), ['2027-03'], "au début de l'horizon, pas à sa fin");
assert.deepEqual(groupes[0].entrees[0].horizon, { start: '2027-03', end: '2027-06' });
assert.equal(groupes[0].entrees[0].phase, undefined);
});
test('un projet envisagé sans horizon ne paraît nulle part', () => {
// Il n'a rien à quoi se rattacher, et sa pastille « à dater » le signale
// déjà dans la colonne de la frise.
assert.deepEqual(entreesParMois({ projects: [projet({ id: 'flottant' })] }), []);
});
test('un projet écarté avant d avoir été engagé paraît à son horizon', () => {
// C'est l'absence de phases qui compte, pas l'état : sans cela, ouvrir le
// cimetière dans la liste ne montrerait que les projets qui avaient démarré.
const cimetiere = {
projects: [
projet({
id: 'abandonne',
name: 'Abandonné',
horizon: { start: '2027-03', end: '2027-03' },
discardedDate: '2026-09-14',
}),
],
};
const groupes = entreesParMois(cimetiere, [], ['discarded']);
assert.deepEqual(cles(groupes), ['2027-03']);
assert.equal(etatProjet(groupes[0].entrees[0].projet), 'discarded');
// Et il reste hors de vue tant qu'on n'a pas coché son état.
assert.deepEqual(entreesParMois(cimetiere, [], ['considered', 'engaged']), []);
});
test('un projet engagé ne paraît pas deux fois malgré son horizon conservé', () => {
// L'horizon survit à l'engagement, mais ce sont les phases qui parlent
// désormais : le lister en plus ferait un doublon.
const engage = {
projects: [
projet({
id: 'engage',
horizon: { start: '2027-03', end: '2027-06' },
phases: [phase({ start: '2026-08-01', end: '2026-08-20' })],
}),
],
};
const groupes = entreesParMois(engage);
assert.deepEqual(cles(groupes), ['2026-08']);
assert.equal(groupes[0].entrees.length, 1);
});
test('un envisagé ouvre son mois, devant les phases qui y démarrent plus tard', () => {
const melange = {
projects: [
projet({ id: 'idee', name: 'Idée', horizon: { start: '2026-09', end: '2026-09' } }),
projet({
id: 'autre',
name: 'Autre',
phases: [phase({ id: 'p', name: 'Tâche', start: '2026-09-15', end: '2026-09-20' })],
}),
],
};
assert.deepEqual(
entreesParMois(melange)[0].entrees.map((e) => e.projet.id),
['idee', 'autre']
);
});
});
@@ -756,3 +900,288 @@ describe('modifications', () => {
});
});
});
// --- cycle de vie -----------------------------------------------------------
describe('cycle de vie', () => {
const envisage = projet({ horizon: { start: '2027-03', end: '2027-06' } });
const engage = projet({ horizon: { start: '2027-03', end: '2027-06' }, phases: [phase()] });
test('l état se déduit des phases tant que rien n a été prononcé', () => {
assert.equal(etatProjet(envisage), 'considered');
assert.equal(etatProjet(projet()), 'considered', 'même sans horizon');
assert.equal(etatProjet(engage), 'engaged');
});
test('terminer et écarter sont des actes, et ils horodatent', () => {
const clos = terminerProjet(engage, '2027-11-08');
assert.equal(etatProjet(clos), 'completed');
assert.equal(clos.completedDate, '2027-11-08');
const ecarte = ecarterProjet(envisage, '2026-09-14');
assert.equal(etatProjet(ecarte), 'discarded');
assert.equal(ecarte.discardedDate, '2026-09-14');
});
test('clore le projet termine toutes ses phases, jalons compris', () => {
// Sans cela la frise se contredirait : une ligne éteinte au-dessus de
// segments pâles, et des tâches « à venir » listées dans un projet fini.
const enCours = projet({
phases: [
phase({ id: 'a', name: 'A', status: 'done' }),
phase({ id: 'b', name: 'B', status: 'blocked' }),
phase({ id: 'c', name: 'C', status: 'todo', milestone: true, end: '2026-08-01' }),
],
});
const clos = terminerProjet(enCours, '2027-11-08');
assert.deepEqual(
clos.phases.map((p) => p.status),
['done', 'done', 'done']
);
assert.equal(clos.phases[2].milestone, true, 'un jalon reste un jalon');
});
test('rouvrir ne rend pas aux phases le statut qu elles avaient', () => {
// L'écrasement est le prix assumé de la clôture, et la raison pour laquelle
// l'interface la fait confirmer.
const enCours = projet({ phases: [phase({ status: 'blocked' })] });
const rouvert = rouvrirProjet(terminerProjet(enCours));
assert.equal(etatProjet(rouvert), 'engaged');
assert.equal(rouvert.phases[0].status, 'done');
});
test('clotureEcraseDesStatuts ne prévient que s il reste quelque chose à écraser', () => {
assert.ok(clotureEcraseDesStatuts(projet({ phases: [phase({ status: 'todo' })] })));
assert.ok(!clotureEcraseDesStatuts(projet({ phases: [phase({ status: 'done' })] })));
assert.ok(!clotureEcraseDesStatuts(projet()), 'aucune phase, rien à écraser');
});
test('des phases toutes finies ne closent pas le projet à elles seules', () => {
// Livré n'est pas clos : c'est quelqu'un qui le prononce.
const livre = projet({ phases: [phase({ status: 'done' })] });
assert.equal(etatProjet(livre), 'engaged');
});
test('un projet ne porte jamais les deux dates à la fois', () => {
const clos = terminerProjet(engage, '2027-11-08');
const puisEcarte = ecarterProjet(clos, '2027-12-01');
assert.equal(puisEcarte.completedDate, undefined);
assert.equal(etatProjet(puisEcarte), 'discarded');
});
test("l'écartement l'emporte sur la clôture dans un fichier retouché à la main", () => {
const incoherent = projet({ completedDate: '2027-01-01', discardedDate: '2027-02-01' });
assert.equal(etatProjet(incoherent), 'discarded');
});
test('réanimer rend au projet l état que ses données commandent', () => {
// Aucune destination à choisir : c'est tout l'intérêt d'un état déduit.
assert.equal(etatProjet(reanimerProjet(ecarterProjet(engage))), 'engaged');
assert.equal(etatProjet(reanimerProjet(ecarterProjet(envisage))), 'considered');
});
test('rouvrir annule la seule clôture, sans retirer ni ajouter de phase', () => {
const rouvert = rouvrirProjet(terminerProjet(engage, '2027-11-08'));
assert.equal(etatProjet(rouvert), 'engaged');
assert.deepEqual(
rouvert.phases.map((p) => p.id),
engage.phases.map((p) => p.id)
);
// Leur statut, lui, a été écrasé par la clôture — voir le test dédié.
});
test('un projet qui perd sa dernière phase redevient envisagé', () => {
// Le seul retour arrière du cycle, et il n'a rien à déclarer.
const vide = supprimerPhase(engage, 'cadrage');
assert.equal(etatProjet(vide), 'considered');
assert.deepEqual(empriseProjet(vide), { start: '2027-03-01', end: '2027-06-30' });
});
test('creerProjet fait naître un projet envisagé, ancré sur le mois courant', () => {
const p = creerProjet(planningVide(), 'Salle des fêtes', [], '2026-08');
assert.equal(etatProjet(p), 'considered');
assert.deepEqual(p.horizon, { start: '2026-08', end: '2026-08' });
});
test('projetFiltreEtat élargit à chaque état coché, contrairement aux tags', () => {
assert.ok(projetFiltreEtat(envisage, ['considered']));
assert.ok(!projetFiltreEtat(envisage, ['engaged']));
assert.ok(projetFiltreEtat(envisage, ['considered', 'engaged']));
assert.ok(projetFiltreEtat(engage, ['considered', 'engaged']));
assert.ok(projetFiltreEtat(envisage, []), 'une liste vide laisse tout passer');
});
test('la vue par mois écarte les projets dont l état n est pas coché', () => {
const planning = {
projects: [
projet({ id: 'vivant', name: 'Vivant', phases: [phase({ start: '2026-08-01' })] }),
projet({
id: 'mort',
name: 'Mort',
discardedDate: '2026-07-01',
phases: [phase({ id: 'p', start: '2026-08-05' })],
}),
],
};
const tout = entreesParMois(planning, [], []);
assert.equal(tout[0].entrees.length, 2);
const sansEcartes = entreesParMois(planning, [], ['considered', 'engaged', 'completed']);
assert.deepEqual(
sansEcartes[0].entrees.map((e) => e.projet.id),
['vivant']
);
});
});
// --- horizon ----------------------------------------------------------------
describe('horizon', () => {
const horizon = { start: '2027-03', end: '2027-06' };
test('les bornes d un horizon couvrent ses mois entiers', () => {
assert.deepEqual(bornesHorizon(projet({ horizon })), {
start: '2027-03-01',
end: '2027-06-30',
});
assert.equal(bornesHorizon(projet()), null);
});
test('les phases commandent l emprise, l horizon n est qu un repli', () => {
const avecPhases = projet({ horizon, phases: [phase()] });
assert.deepEqual(empriseProjet(avecPhases), { start: '2026-08-01', end: '2026-08-20' });
assert.deepEqual(empriseProjet(projet({ horizon })), {
start: '2027-03-01',
end: '2027-06-30',
});
assert.equal(empriseProjet(projet()), null, 'ni phase ni horizon');
});
test('definirHorizon remet une fin antérieure au début d aplomb', () => {
// La saisie se fait au fil de la frappe : un intervalle à l'envers est un
// état de passage, pas une erreur à signaler.
assert.deepEqual(definirHorizon(projet(), '2027-06', '2027-03').horizon, {
start: '2027-06',
end: '2027-06',
});
assert.deepEqual(definirHorizon(projet(), '2027-03').horizon, {
start: '2027-03',
end: '2027-03',
});
});
test('decalerHorizon conserve la largeur de l intervalle', () => {
const decale = decalerHorizon(projet({ horizon }), 6);
assert.deepEqual(decale.horizon, { start: '2027-09', end: '2027-12' });
assert.deepEqual(decalerHorizon(projet({ horizon }), -3).horizon, {
start: '2026-12',
end: '2027-03',
});
assert.deepEqual(decalerHorizon(projet(), 6), projet(), 'sans horizon, rien à décaler');
});
test('un horizon dépassé réclame une date, un horizon à venir non', () => {
assert.ok(!projetADater(projet({ horizon }), '2027-04'), 'on est dedans');
assert.ok(!projetADater(projet({ horizon }), '2027-06'), 'dernier mois inclus');
assert.ok(projetADater(projet({ horizon }), '2027-07'), 'dépassé');
});
test('un projet envisagé sans horizon réclame une date lui aussi', () => {
// C'est le cas d'un projet hérité d'un fichier en version 4 : les deux
// manques se signalent pareil.
assert.ok(projetADater(projet(), '2026-08'));
});
test('un projet engagé, clos ou écarté ne réclame jamais de date', () => {
assert.ok(!projetADater(projet({ horizon, phases: [phase()] }), '2030-01'));
assert.ok(!projetADater(projet({ horizon, completedDate: '2027-01-01' }), '2030-01'));
assert.ok(!projetADater(projet({ horizon, discardedDate: '2027-01-01' }), '2030-01'));
});
test('la fenêtre du planning tient compte des horizons', () => {
// Sans cela, un planning fait de projets encore tous envisagés s'ouvrirait
// sur rien.
const planning = { projects: [projet({ horizon })] };
assert.deepEqual(bornesPlanning(planning), { start: '2027-03-01', end: '2027-06-30' });
});
test('la fenêtre du planning ignore les projets écartés', () => {
const planning = {
projects: [
projet({ id: 'vivant', phases: [phase()] }),
projet({
id: 'mort',
discardedDate: '2026-07-01',
phases: [phase({ id: 'p', start: '2019-01-01', end: '2019-06-01' })],
}),
],
};
assert.deepEqual(bornesPlanning(planning), { start: '2026-08-01', end: '2026-08-20' });
});
});
// --- validation du cycle de vie ---------------------------------------------
describe('validerPlanning : horizon et états', () => {
const avec = (champs) => ({ version: 5, projects: [projet(champs)] });
test('accepte un horizon correct et le conserve tel quel', () => {
const relu = validerPlanning(avec({ horizon: { start: '2027-03', end: '2027-06' } }));
assert.deepEqual(relu.projects[0].horizon, { start: '2027-03', end: '2027-06' });
});
test('laisse sans horizon un projet qui n en a pas', () => {
// C'est le cas de tous les projets d'un fichier en version 4.
const relu = validerPlanning(avec({}));
assert.ok(!('horizon' in relu.projects[0]));
assert.equal(etatProjet(relu.projects[0]), 'considered');
});
test('rejette un horizon mal formé plutôt que de l avaler', () => {
// Contrairement au statut ou aux notes : l'horizon décide d'une position sur
// la frise, et l'avaler laisserait un projet invisible sans raison visible.
assert.throws(
() => validerPlanning(avec({ horizon: { start: '2027-13', end: '2027-06' } })),
(err) => err instanceof ErreurValidation && /horizon/.test(err.message)
);
assert.throws(
() => validerPlanning(avec({ horizon: { start: '2027-03-01', end: '2027-06' } })),
ErreurValidation
);
assert.throws(() => validerPlanning(avec({ horizon: 'mars 2027' })), ErreurValidation);
});
test('rejette un horizon dont la fin précède le début, en nommant le fautif', () => {
assert.throws(
() => validerPlanning(avec({ horizon: { start: '2027-06', end: '2027-03' } })),
(err) => err instanceof ErreurValidation && /« Site web »/.test(err.message)
);
});
test('rejette une date d acte invalide', () => {
assert.throws(
() => validerPlanning(avec({ completedDate: '2027-02-30' })),
(err) => err instanceof ErreurValidation && /completedDate/.test(err.message)
);
assert.throws(
() => validerPlanning(avec({ discardedDate: 'hier' })),
(err) => err instanceof ErreurValidation && /discardedDate/.test(err.message)
);
});
test('un projet du cycle de vie survit à un aller-retour par la validation', () => {
let planning = planningVide();
const p = ecarterProjet(
definirHorizon(creerProjet(planning, 'Salle des fêtes', [], '2026-08'), '2027-03', '2027-06'),
'2026-09-14'
);
planning = { ...planning, projects: [p] };
const relu = validerPlanning(pourEcriture(planning));
assert.deepEqual(relu, validerPlanning(pourEcriture(relu)));
assert.deepEqual(relu.projects[0].horizon, { start: '2027-03', end: '2027-06' });
assert.equal(relu.projects[0].discardedDate, '2026-09-14');
assert.ok(!('completedDate' in relu.projects[0]), 'les clés undefined ne sont pas écrites');
});
});