Files
gestion_projets/docs/modele-donnees.md
Bertrand Benjamin 4a513f9a59 Repousser un projet en bloc, sauf ce dont on ne décide pas
Glisser la barre résumé décale toutes les phases du même nombre de jours,
écarts conservés : un projet engagé glisse souvent, et le faire phase par
phase les déformait. Restent en place ce qui est terminé — on ne réécrit
pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour
ce qui est délégué ou tenu du dehors.

D'où le format en version 6 : sans l'incrément, un binaire antérieur
effacerait ces dates imposées à la première sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:07:31 +02:00

17 KiB

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

{
  "version": 6,
  "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,
          "fixed": 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,
          "fixed": true,
          "notes": "Date annoncée au client."
        }
      ]
    }
  ]
}

Racine

Champ Type Description
version entier Version du format. Vaut 6. 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, une version 5 — celle d'avant les dates imposées — des phases à fixed: false.

Aucune de ces migrations ne demande ni ne devine quoi que ce soit. 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. Et aucune phase n'est déclarée à date imposée : rien dans les données ne dirait laquelle est déléguée, et un faux positif immobiliserait une phase sans qu'on comprenne pourquoi.

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 website-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.
fixed booléen true si la date est imposée du dehors — phase déléguée, contrainte contractuelle ou réglementaire. Un décalage du projet ne l'emporte pas.
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.
  • fixed est orthogonal au reste : aucune combinaison n'est interdite. Une phase peut être à la fois jalon et à date imposée — c'est même le cas le plus courant des deux —, ou blocked et imposée, ce qui n'est pas une contradiction : blocked dit que ça n'avance pas, fixed que la date ne nous appartient pas.
  • 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èsClient 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, 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, 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, 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.
  • Repousser un projet (decalerProjet()) ajoute le même nombre de jours aux start et end des phases qu'il emporte — phasesDecalables() les désigne, ancreDecalage() donne la date sur laquelle le geste s'accroche. Deux sortes en sont exclues, pour des raisons sans rapport : les phases terminées, parce qu'on ne réécrit pas le passé, et les phases à date imposée, parce qu'on n'en décide pas. Les baseline ne bougent pas non plus : le décalage se lit alors intégralement dans la dérive.
  • 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.