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>
This commit is contained in:
2026-08-09 14:57:34 +02:00
parent 3c7cff3ac6
commit 8404f12656
4 changed files with 1003 additions and 72 deletions

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.