From 8404f12656ea881e450e8d4323f921c920356055 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Sun, 9 Aug 2026 14:57:34 +0200 Subject: [PATCH] =?UTF-8?q?Porter=20le=20cycle=20de=20vie=20et=20l'horizon?= =?UTF-8?q?=20dans=20le=20mod=C3=A8le,=20en=20version=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/modele-donnees.md | 83 +++++++- js/model.js | 418 +++++++++++++++++++++++++++++++++---- js/mois.js | 121 +++++++++-- tests/model.test.js | 453 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 1003 insertions(+), 72 deletions(-) diff --git a/docs/modele-donnees.md b/docs/modele-donnees.md index 492d873..2d5424f 100644 --- a/docs/modele-donnees.md +++ b/docs/modele-donnees.md @@ -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. diff --git a/js/model.js b/js/model.js index 8d4f18b..d5e40b2 100644 --- a/js/model.js +++ b/js/model.js @@ -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'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 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 ré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); diff --git a/js/mois.js b/js/mois.js index 123c214..f0d06fa 100644 --- a/js/mois.js +++ b/js/mois.js @@ -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; } diff --git a/tests/model.test.js b/tests/model.test.js index 2f73094..d88e4d3 100644 --- a/tests/model.test.js +++ b/tests/model.test.js @@ -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'); + }); +});