Files
gestion_projets/docs/modele-donnees.md
Bertrand Benjamin 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

280 lines
15 KiB
Markdown

# Modèle de données
Tout le planning tient dans un seul fichier JSON, par défaut `data/projets.json`.
Le fichier est écrit indenté sur 2 espaces, avec les clés dans un ordre stable, pour rester lisible
dans un diff git et éditable à la main.
## Structure
```json
{
"version": 5,
"projects": [
{
"id": "site-web",
"name": "Refonte du site web",
"color": "#3b82f6",
"tags": ["client", "web"],
"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": [
{
"id": "cadrage",
"name": "Cadrage",
"start": "2026-08-01",
"end": "2026-08-20",
"status": "done",
"milestone": false,
"notes": "Ateliers avec les trois pôles.\n\nPérimètre arrêté le **8 juillet**.",
"baseline": { "start": "2026-07-25", "end": "2026-08-10" }
},
{
"id": "livraison",
"name": "Mise en ligne",
"start": "2026-11-02",
"end": "2026-11-02",
"status": "todo",
"milestone": true,
"notes": ""
}
]
}
]
}
```
## Racine
| Champ | Type | Description |
|---|---|---|
| `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, 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
pas, si bien qu'un binaire antérieur ouvrirait sans broncher un fichier plus riche que lui et en
effacerait les champs inconnus à la première sauvegarde.
## Projet
| Champ | Type | Description |
|---|---|---|
| `id` | chaîne | Identifiant parlant, dérivé du nom (`Refonte du site web``site-web`). Unique dans le fichier. |
| `name` | chaîne | Nom affiché. Non vide. |
| `color` | chaîne | Couleur du couloir, en hexadécimal `#rrggbb`. |
| `tags` | tableau | Étiquettes libres servant à filtrer la frise. Trié, sans doublon, éventuellement vide. |
| `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. |
## Phase
| Champ | Type | Description |
|---|---|---|
| `id` | chaîne | Identifiant parlant, unique **au sein de son projet**. |
| `name` | chaîne | Nom affiché. Non vide. |
| `start` | chaîne | Date de début, `AAAA-MM-JJ`. |
| `end` | chaîne | Date de fin **incluse**, `AAAA-MM-JJ`. |
| `status` | chaîne | `todo`, `doing`, `done` ou `blocked`. |
| `milestone` | booléen | `true` pour un jalon. |
| `notes` | chaîne | Texte libre en markdown, éventuellement vide. |
| `baseline` | objet ou absent | Dates de référence : `{ "start": …, "end": … }`. Absent tant que la référence n'a pas été figée. |
## Règles de validation
Appliquées par `js/model.js` et couvertes par `tests/model.test.js`.
- `end` est postérieure ou égale à `start`.
- Un jalon (`milestone: true`) a nécessairement `start === end`. Cocher « jalon » sur une phase de
plusieurs jours ramène `end` sur `start`.
- Les dates suivent strictement `AAAA-MM-JJ` et doivent exister réellement dans le calendrier
(`2026-02-30` est rejetée).
- `id` de projet unique dans le fichier ; `id` de phase unique dans son projet.
- Un `id` est engendré à partir du nom : minuscules, accents retirés, tout ce qui n'est ni lettre ni
chiffre remplacé par un tiret. En cas de collision, un suffixe numérique est ajouté (`cadrage-2`).
Un `id` ne change jamais si le nom est modifié ensuite — il identifie, il ne décrit pas.
- `status` fait partie des quatre valeurs autorisées ; toute autre valeur est ramenée à `todo`.
- `color` est un hexadécimal `#rrggbb` valide.
- `tags` est un tableau de chaînes. Le tableau lui-même et le type de ses éléments sont vérifiés —
un tag mal typé serait un tag qu'on croit poser et qui ne filtre rien. Leur *contenu*, en revanche,
est normalisé sans erreur (voir ci-dessous).
- `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
Une étiquette libre, posée sur un projet, sans liste fermée à tenir à jour : les tags disponibles
sont simplement ceux que portent les projets.
À l'enregistrement, `normaliserTags()` nettoie la liste :
- espaces de bord retirés, espaces internes réduits à un seul ;
- tags vides écartés ;
- doublons fusionnés **à la casse près**`Client` et `client` sont le même tag, et c'est la
première graphie rencontrée qui est conservée ;
- tag tronqué au-delà de **24 caractères** (`MAX_LONGUEUR_TAG`) ;
- tri alphabétique, pour que ressaisir les mêmes tags dans un autre ordre ne produise aucun diff git.
Les **accents comptent** : `éditeur` et `editeur` restent deux tags distincts. C'est la différence
avec `fabriquerId()`, qui les retire parce qu'un identifiant doit tenir dans une URL — un tag, lui,
n'est jamais qu'affiché.
La **couleur** d'un tag n'est pas stockée : elle se déduit du nom par un hachage
(`teinteTag()` → une teinte HSL entre 0 et 359). Elle est donc stable d'une session à l'autre et
identique partout où le tag apparaît, sans rien avoir à gérer. Deux tags peuvent tomber sur des
teintes voisines : la couleur aide à repérer, elle ne porte pas d'information à elle seule — le nom
est toujours écrit à côté.
Le **filtre**, lui, n'est pas dans le fichier : c'est un état de vue, au même titre que la fenêtre
temporelle. Voir [decisions.md](decisions.md), section 16.
Un fichier invalide n'est jamais réparé en silence : le chargement échoue avec un message qui pointe
le projet et la phase fautifs, pour que le fichier puisse être corrigé à la main.
## Notes
Un projet et une phase portent chacun un champ `notes`, en **markdown**. Rien n'est stocké d'autre
que le texte source : le rendu est refait à l'affichage par `js/markdown.js` (analyse) et
`js/notes.js` (mise en DOM). Un fichier reste donc lisible et modifiable à la main, et une note
écrite hors de l'outil s'affiche sans conversion.
Le markdown reconnu est un **sous-ensemble**, choisi pour ce qu'on écrit dans une note de suivi :
| Syntaxe | Rendu |
|---|---|
| `# Titre` à `###### Titre` | Titres, décalés de deux niveaux sous celui du panneau |
| `**gras**`, `*italique*`, `_italique_` | Emphase |
| `` `code` `` et blocs ```` ``` ```` | Code littéral |
| `- item`, `* item`, `1. item` | Listes, imbricables à l'indentation |
| `- [ ] item`, `- [x] item` | Cases à cocher, cliquables dans le panneau |
| `[texte](https://…)` | Lien |
| `> cité` | Citation, réanalysée (elle peut contenir une liste) |
| `---` | Séparateur |
| `\*` | Le caractère littéral qui suit la contre-oblique |
Trois écarts assumés au markdown standard :
- **Un retour à la ligne en est un.** Le standard recolle les lignes d'un même paragraphe et exige
deux espaces en fin de ligne pour un vrai retour ; c'est un piège invisible dans un champ de
saisie, où l'on écrit souvent une petite liste à la volée.
- **Ce qui n'est pas reconnu reste du texte.** `**gras` sans fermeture s'affiche tel quel plutôt que
d'avaler la suite : une note à moitié écrite est l'état normal d'une note.
- **Ni tableaux, ni images, ni HTML brut.** Les deux premiers ne tiennent pas dans un volet latéral ;
le troisième rouvrirait la porte à l'injection que la construction du DOM nœud par nœud ferme.
Les **liens** ne sont suivis que si leur schéma est `http`, `https` ou `mailto` — liste blanche, et
non liste noire. Un planning s'échange, et rien ne garantit que la note affichée a été écrite par
celui qui la lit. Un lien refusé n'est pas effacé : son libellé redevient du texte.
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
tous les pièges de fuseau horaire : `new Date("2026-08-01")` est interprétée en UTC alors que
`new Date(2026, 7, 1)` l'est en heure locale, ce qui décale d'un jour selon le fuseau. Les
conversions n'ont lieu que dans les helpers de calcul de `model.js`.
`end` est **incluse** : une phase du `2026-08-01` au `2026-08-01` dure un jour.
Tout se compte en **jours calendaires**, week-ends et jours fériés compris.
Les numéros de semaine affichés dans l'en-tête suivent l'**ISO 8601** : la semaine
appartient à l'année où tombe son jeudi. `semaineISO()` renvoie donc le numéro *et*
l'année correspondante, qui peut différer de celle de la date.
## Bornes et dérive
- 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.
- La **dérive** d'un projet est l'écart, en jours, entre la plus grande `end` actuelle et la plus
grande `end` de référence. Elle est affichée arrondie en semaines. Une phase créée après le figeage
n'a pas de `baseline` et ne compte pas dans la référence, mais compte dans les dates actuelles :
ajouter une phase en fin de projet crée donc bien une dérive, ce qui est le comportement voulu.