Porter le cycle de vie et l'horizon dans le modèle, en version 5
L'état n'est pas un champ : il se lit dans horizon, completedDate, discardedDate et la présence de phases. Les phases commandent, sauf quand on a prononcé quelque chose. L'emprise d'un projet retombe sur son horizon faute de phases, et une saisie n'est jamais réécrite par un calcul : un projet qui perd sa dernière phase retrouve l'horizon qu'il avait déclaré. Clore un projet termine aussi toutes ses phases, seule opération du cycle qui écrase des données. phasesParMois devient entreesParMois : elle émet aussi les projets sans phase, à leur horizon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ dans un diff git et éditable à la main.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 4,
|
"version": 5,
|
||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"id": "site-web",
|
"id": "site-web",
|
||||||
@@ -19,6 +19,7 @@ dans un diff git et éditable à la main.
|
|||||||
"collapsed": false,
|
"collapsed": false,
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"notes": "## Contexte\n\nPiloté par **Marie D.**\n\n- [x] cadrage validé\n- [ ] recette",
|
"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",
|
"baselineDate": "2026-06-12",
|
||||||
"phases": [
|
"phases": [
|
||||||
{
|
{
|
||||||
@@ -50,12 +51,18 @@ dans un diff git et éditable à la main.
|
|||||||
|
|
||||||
| Champ | Type | Description |
|
| 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. |
|
| `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
|
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
|
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
|
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
|
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é. |
|
| `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. |
|
| `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. |
|
| `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é. |
|
| `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. |
|
| `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
|
- `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
|
aucun calcul, contrairement aux dates : bloquer le chargement d'un planning entier pour lui serait
|
||||||
disproportionné.
|
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
|
## 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.
|
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
|
## Dates : conventions
|
||||||
|
|
||||||
Les dates sont manipulées comme des **chaînes `AAAA-MM-JJ`**, pas comme des objets `Date`. Cela évite
|
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
|
## Bornes et dérive
|
||||||
|
|
||||||
- Les **bornes d'un projet** vont de la plus petite `start` à la plus grande `end` de ses phases. Un
|
- Les **bornes d'un projet** (`bornesProjet()`) vont de la plus petite `start` à la plus grande `end`
|
||||||
projet sans phase n'a pas de bornes et s'affiche comme un couloir vide.
|
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
|
- **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
|
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.
|
refiger après un arbitrage assumé repart d'une base propre.
|
||||||
|
|||||||
418
js/model.js
418
js/model.js
@@ -15,18 +15,34 @@
|
|||||||
/**
|
/**
|
||||||
* Version du format de fichier.
|
* Version du format de fichier.
|
||||||
*
|
*
|
||||||
* Elle a changé pour la dernière fois quand le projet a reçu des `notes` (v4).
|
* Elle a changé pour la dernière fois quand le projet a reçu son cycle de vie —
|
||||||
* Un champ ajouté n'oblige pourtant à rien : la v3 se relit sans encombre, et
|
* `horizon`, `completedDate`, `discardedDate` (v5). Un champ ajouté n'oblige
|
||||||
* le validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement
|
* pourtant à rien : la v4 se relit sans encombre, et le validateur ne refuse
|
||||||
* là qu'est la raison d'incrémenter — le validateur reconstruit chaque projet
|
* qu'un fichier *plus récent* que lui. C'est justement là qu'est la raison
|
||||||
* champ par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un
|
* d'incrémenter — le validateur reconstruit chaque projet champ par champ et
|
||||||
* binaire antérieur ouvrirait un fichier v4 sans broncher et en effacerait
|
* laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire antérieur
|
||||||
* toutes les notes de projet à la première sauvegarde.
|
* 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'];
|
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 = {
|
export const LIBELLES_STATUT = {
|
||||||
todo: 'À venir',
|
todo: 'À venir',
|
||||||
doing: 'En cours',
|
doing: 'En cours',
|
||||||
@@ -56,6 +72,7 @@ export const MOIS_LONGS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const MOTIF_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
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 MOTIF_COULEUR = /^#[0-9a-f]{6}$/i;
|
||||||
const MS_PAR_JOUR = 86400000;
|
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)}`;
|
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
|
* 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
|
* 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;
|
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
|
// Validation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -337,11 +506,15 @@ export function validerPlanning(donnees) {
|
|||||||
}
|
}
|
||||||
idsProjets.add(projet.id);
|
idsProjets.add(projet.id);
|
||||||
|
|
||||||
if (projet.baselineDate !== undefined && !dateValide(projet.baselineDate)) {
|
for (const cle of ['baselineDate', 'completedDate', 'discardedDate']) {
|
||||||
throw new ErreurValidation(
|
if (projet[cle] !== undefined && !dateValide(projet[cle])) {
|
||||||
`Projet ${repere} : « baselineDate » n'est pas une date valide (${projet.baselineDate}).`
|
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)) {
|
if (!Array.isArray(projet.phases)) {
|
||||||
throw new ErreurValidation(`Projet ${repere} : « phases » doit être un tableau.`);
|
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
|
// bloquer le chargement d'un planning entier pour un champ mal typé serait
|
||||||
// disproportionné là où le champ n'engage aucun calcul.
|
// disproportionné là où le champ n'engage aucun calcul.
|
||||||
notes: typeof projet.notes === 'string' ? projet.notes : '',
|
notes: typeof projet.notes === 'string' ? projet.notes : '',
|
||||||
|
...(horizon ? { horizon } : {}),
|
||||||
|
...(projet.completedDate ? { completedDate: projet.completedDate } : {}),
|
||||||
|
...(projet.discardedDate ? { discardedDate: projet.discardedDate } : {}),
|
||||||
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
|
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
|
||||||
phases: trierPhases(phases),
|
phases: trierPhases(phases),
|
||||||
};
|
};
|
||||||
@@ -371,6 +547,33 @@ export function validerPlanning(donnees) {
|
|||||||
return { version: VERSION_FORMAT, projects: projets };
|
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
|
* 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
|
* 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 };
|
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) {
|
export function bornesPlanning(planning) {
|
||||||
let debut = null;
|
let debut = null;
|
||||||
let fin = null;
|
let fin = null;
|
||||||
for (const projet of planning.projects) {
|
for (const projet of planning.projects) {
|
||||||
if (projet.hidden) continue;
|
if (projet.hidden || etatProjet(projet) === 'discarded') continue;
|
||||||
const bornes = bornesProjet(projet);
|
const bornes = empriseProjet(projet);
|
||||||
if (!bornes) continue;
|
if (!bornes) continue;
|
||||||
if (debut === null || versUTC(bornes.start) < versUTC(debut)) debut = bornes.start;
|
if (debut === null || versUTC(bornes.start) < versUTC(debut)) debut = bornes.start;
|
||||||
if (fin === null || versUTC(bornes.end) > versUTC(fin)) fin = bornes.end;
|
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
|
* Deux sortes d'entrées y cohabitent, et la seconde est venue plus tard :
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Les deux tamis de la frise s'appliquent de la même façon (timeline.js) : le
|
* - une **phase**, rattachée au mois de son début et à lui seul. Une phase de
|
||||||
* filtre par tags retire le projet, l'œil (`hidden`) aussi — un projet dont on
|
* trois mois n'apparaît donc qu'une fois, au mois où elle démarre : la liste
|
||||||
* a masqué les barres n'a pas à revenir par 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 —
|
* 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
|
* 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.
|
* 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.
|
* 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 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) {
|
for (const projet of planning.projects) {
|
||||||
if (projet.hidden || !projetFiltre(projet, tagsActifs)) continue;
|
if (projet.hidden || !projetFiltre(projet, tagsActifs)) continue;
|
||||||
|
if (!projetFiltreEtat(projet, etatsActifs)) continue;
|
||||||
|
|
||||||
for (const phase of projet.phases) {
|
for (const phase of projet.phases) {
|
||||||
const mois = moisDe(phase.start);
|
poser(moisDe(phase.start), { projet, phase });
|
||||||
if (!groupes.has(mois)) groupes.set(mois, []);
|
}
|
||||||
groupes.get(mois).push({ 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,
|
mois,
|
||||||
entrees: groupes.get(mois).sort(
|
entrees: groupes.get(mois).sort(
|
||||||
(a, b) =>
|
(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.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
|
// 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));
|
const id = fabriquerId(nom, planning.projects.map((p) => p.id));
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -633,10 +888,103 @@ export function creerProjet(planning, nom, tags = []) {
|
|||||||
collapsed: false,
|
collapsed: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
notes: '',
|
notes: '',
|
||||||
|
horizon: { start: mois, end: mois },
|
||||||
phases: [],
|
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 = {}) {
|
export function creerPhase(projet, nom, debut, fin, options = {}) {
|
||||||
const id = fabriquerId(nom, projet.phases.map((p) => p.id));
|
const id = fabriquerId(nom, projet.phases.map((p) => p.id));
|
||||||
const jalon = Boolean(options.milestone);
|
const jalon = Boolean(options.milestone);
|
||||||
|
|||||||
121
js/mois.js
121
js/mois.js
@@ -19,15 +19,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
LIBELLES_ETAT,
|
||||||
LIBELLES_STATUT,
|
LIBELLES_STATUT,
|
||||||
aujourdhui,
|
aujourdhui,
|
||||||
duree,
|
duree,
|
||||||
|
entreesParMois,
|
||||||
|
etatProjet,
|
||||||
formaterDateLongue,
|
formaterDateLongue,
|
||||||
|
formaterHorizon,
|
||||||
formaterMois,
|
formaterMois,
|
||||||
formaterPeriode,
|
formaterPeriode,
|
||||||
moisDe,
|
moisDe,
|
||||||
phasesParMois,
|
|
||||||
projetFiltre,
|
projetFiltre,
|
||||||
|
projetFiltreEtat,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
import { aplatirMarkdown } from './markdown.js';
|
import { aplatirMarkdown } from './markdown.js';
|
||||||
import { rendreMarkdown } from './notes.js';
|
import { rendreMarkdown } from './notes.js';
|
||||||
@@ -38,13 +42,13 @@ import { pastillesTags } from './tags.js';
|
|||||||
*
|
*
|
||||||
* @param {HTMLElement} conteneur
|
* @param {HTMLElement} conteneur
|
||||||
* @param {object} planning
|
* @param {object} planning
|
||||||
* @param {object} options { tagsActifs }
|
* @param {object} options { tagsActifs, etatsActifs }
|
||||||
*/
|
*/
|
||||||
export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) {
|
export function rendreMois(conteneur, planning, { tagsActifs = [], etatsActifs = [] } = {}) {
|
||||||
const groupes = phasesParMois(planning, tagsActifs);
|
const groupes = entreesParMois(planning, tagsActifs, etatsActifs);
|
||||||
|
|
||||||
if (!groupes.length) {
|
if (!groupes.length) {
|
||||||
conteneur.replaceChildren(messageVide(planning, tagsActifs));
|
conteneur.replaceChildren(messageVide(planning, tagsActifs, etatsActifs));
|
||||||
return;
|
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
|
* 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.
|
* l'œil ici, puisque la liste n'a pas de colonne de libellés où le rouvrir.
|
||||||
*/
|
*/
|
||||||
function messageVide(planning, tagsActifs) {
|
function messageVide(planning, tagsActifs, etatsActifs = []) {
|
||||||
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
|
const retenus = planning.projects.filter(
|
||||||
|
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
|
||||||
|
);
|
||||||
|
|
||||||
const paragraphe = document.createElement('p');
|
const paragraphe = document.createElement('p');
|
||||||
paragraphe.className = 'mois__vide';
|
paragraphe.className = 'mois__vide';
|
||||||
@@ -120,18 +126,89 @@ function construireGroupe({ mois, entrees }, moisCourant, tagsActifs) {
|
|||||||
return section;
|
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) {
|
function resumerCompte(entrees) {
|
||||||
const jalons = entrees.filter(({ phase }) => phase.milestone).length;
|
const sansPhase = entrees.filter(({ horizon }) => horizon);
|
||||||
const taches = entrees.length - jalons;
|
// 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 = [];
|
const morceaux = [];
|
||||||
if (taches) morceaux.push(`${taches} tâche${taches > 1 ? 's' : ''}`);
|
if (taches) morceaux.push(`${taches} tâche${taches > 1 ? 's' : ''}`);
|
||||||
if (jalons) morceaux.push(`${jalons} jalon${jalons > 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(' · ');
|
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');
|
const ligne = document.createElement('li');
|
||||||
ligne.className = `mois-entree mois-entree--${phase.status}`;
|
ligne.className = `mois-entree mois-entree--${phase.status}`;
|
||||||
ligne.style.setProperty('--couleur-projet', projet.color);
|
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');
|
if (phase.notes) ligne.classList.add('mois-entree--commentee');
|
||||||
ligne.title = infobulle(projet, phase);
|
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
|
// 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 à
|
// 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];
|
statut.textContent = LIBELLES_STATUT[phase.status];
|
||||||
ligne.append(statut);
|
ligne.append(statut);
|
||||||
|
|
||||||
if (phase.notes) ligne.append(notes(phase));
|
if (phase.notes) ligne.append(notes(phase.notes));
|
||||||
|
|
||||||
return ligne;
|
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
|
* 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
|
* la grille sont comptées, et il n'y a pas de raison qu'une phase commentée
|
||||||
* décale toutes les autres.
|
* 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) {
|
function triangle(texte, nom) {
|
||||||
if (!phase.notes) {
|
if (!texte) {
|
||||||
const vide = document.createElement('span');
|
const vide = document.createElement('span');
|
||||||
vide.className = 'mois-entree__plier';
|
vide.className = 'mois-entree__plier';
|
||||||
return vide;
|
return vide;
|
||||||
@@ -210,9 +291,9 @@ function triangle(phase) {
|
|||||||
bouton.dataset.action = 'notes';
|
bouton.dataset.action = 'notes';
|
||||||
bouton.setAttribute('aria-expanded', 'false');
|
bouton.setAttribute('aria-expanded', 'false');
|
||||||
// La ligne entière déplie, mais elle n'est pas focusable : ce bouton est le
|
// 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
|
// seul chemin clavier vers les notes, il lui faut donc un nom qui dise de quoi
|
||||||
// quelle phase il parle.
|
// il parle.
|
||||||
bouton.setAttribute('aria-label', `Notes de ${phase.name}`);
|
bouton.setAttribute('aria-label', `Notes de ${nom}`);
|
||||||
bouton.title = 'Afficher les notes';
|
bouton.title = 'Afficher les notes';
|
||||||
bouton.textContent = '▶';
|
bouton.textContent = '▶';
|
||||||
return bouton;
|
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 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.
|
* qu'on vient y chercher quand on fait le point.
|
||||||
*/
|
*/
|
||||||
function notes(phase) {
|
function notes(texte) {
|
||||||
const bloc = document.createElement('div');
|
const bloc = document.createElement('div');
|
||||||
bloc.className = 'mois-entree__notes';
|
bloc.className = 'mois-entree__notes';
|
||||||
bloc.append(rendreMarkdown(phase.notes));
|
bloc.append(rendreMarkdown(texte));
|
||||||
bloc.hidden = true;
|
bloc.hidden = true;
|
||||||
return bloc;
|
return bloc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,37 +6,56 @@ import {
|
|||||||
MAX_LONGUEUR_TAG,
|
MAX_LONGUEUR_TAG,
|
||||||
VERSION_FORMAT,
|
VERSION_FORMAT,
|
||||||
ajouterJours,
|
ajouterJours,
|
||||||
|
ajouterMois,
|
||||||
ajouterPhase,
|
ajouterPhase,
|
||||||
|
bornesHorizon,
|
||||||
bornesPlanning,
|
bornesPlanning,
|
||||||
bornesProjet,
|
bornesProjet,
|
||||||
bornesReference,
|
bornesReference,
|
||||||
cleTag,
|
cleTag,
|
||||||
|
clotureEcraseDesStatuts,
|
||||||
creerPhase,
|
creerPhase,
|
||||||
creerProjet,
|
creerProjet,
|
||||||
dateValide,
|
dateValide,
|
||||||
|
decalerHorizon,
|
||||||
|
definirHorizon,
|
||||||
deplacerPhase,
|
deplacerPhase,
|
||||||
|
dernierJourDuMois,
|
||||||
dimancheDeLaSemaine,
|
dimancheDeLaSemaine,
|
||||||
deriveProjet,
|
deriveProjet,
|
||||||
duree,
|
duree,
|
||||||
|
ecarterProjet,
|
||||||
ecartJours,
|
ecartJours,
|
||||||
|
ecartMois,
|
||||||
|
empriseProjet,
|
||||||
|
entreesParMois,
|
||||||
|
etatProjet,
|
||||||
fabriquerId,
|
fabriquerId,
|
||||||
figerReference,
|
figerReference,
|
||||||
formaterDateLongue,
|
formaterDateLongue,
|
||||||
|
formaterHorizon,
|
||||||
formaterMois,
|
formaterMois,
|
||||||
formaterPeriode,
|
formaterPeriode,
|
||||||
formulerDerive,
|
formulerDerive,
|
||||||
libererReference,
|
libererReference,
|
||||||
lundiDeLaSemaine,
|
lundiDeLaSemaine,
|
||||||
modifierPhase,
|
modifierPhase,
|
||||||
|
moisValide,
|
||||||
normaliserTags,
|
normaliserTags,
|
||||||
phasesParMois,
|
|
||||||
planningVide,
|
planningVide,
|
||||||
pourEcriture,
|
pourEcriture,
|
||||||
|
premierJourDuMois,
|
||||||
|
projetADater,
|
||||||
projetFiltre,
|
projetFiltre,
|
||||||
|
projetFiltreEtat,
|
||||||
|
reanimerProjet,
|
||||||
redimensionnerPhase,
|
redimensionnerPhase,
|
||||||
remplacerPhase,
|
remplacerPhase,
|
||||||
|
rouvrirProjet,
|
||||||
semaineISO,
|
semaineISO,
|
||||||
|
supprimerPhase,
|
||||||
teinteTag,
|
teinteTag,
|
||||||
|
terminerProjet,
|
||||||
tousLesTags,
|
tousLesTags,
|
||||||
validerPlanning,
|
validerPlanning,
|
||||||
} from '../js/model.js';
|
} 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 -----------------------------------------------------------
|
// --- identifiants -----------------------------------------------------------
|
||||||
|
|
||||||
describe('fabriquerId', () => {
|
describe('fabriquerId', () => {
|
||||||
@@ -458,7 +528,7 @@ describe('bornes', () => {
|
|||||||
|
|
||||||
// --- regroupement par mois --------------------------------------------------
|
// --- regroupement par mois --------------------------------------------------
|
||||||
|
|
||||||
describe('phasesParMois', () => {
|
describe('entreesParMois', () => {
|
||||||
const planning = {
|
const planning = {
|
||||||
version: VERSION_FORMAT,
|
version: VERSION_FORMAT,
|
||||||
projects: [
|
projects: [
|
||||||
@@ -493,7 +563,7 @@ describe('phasesParMois', () => {
|
|||||||
const noms = (groupe) => groupe.entrees.map(({ phase: p }) => p.name);
|
const noms = (groupe) => groupe.entrees.map(({ phase: p }) => p.name);
|
||||||
|
|
||||||
test('rattache chaque phase au seul mois de son début', () => {
|
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(cles(groupes), ['2026-09', '2026-11']);
|
||||||
assert.deepEqual(noms(groupes[0]), ['Cadrage', 'Rédaction']);
|
assert.deepEqual(noms(groupes[0]), ['Cadrage', 'Rédaction']);
|
||||||
// « Rédaction » traverse octobre et novembre sans y réapparaître.
|
// « 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', () => {
|
test('ne fabrique pas de mois vide entre deux mois pleins', () => {
|
||||||
assert.equal(phasesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
|
assert.equal(entreesParMois(planning).some((groupe) => groupe.entrees.length === 0), false);
|
||||||
assert.equal(cles(phasesParMois(planning)).includes('2026-10'), 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', () => {
|
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', () => {
|
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', () => {
|
test('applique le filtre par tags, et écarte les projets masqués', () => {
|
||||||
assert.deepEqual(cles(phasesParMois(planning, ['interne'])), ['2026-09']);
|
assert.deepEqual(cles(entreesParMois(planning, ['interne'])), ['2026-09']);
|
||||||
assert.deepEqual(noms(phasesParMois(planning, ['interne'])[0]), ['Rédaction']);
|
assert.deepEqual(noms(entreesParMois(planning, ['interne'])[0]), ['Rédaction']);
|
||||||
|
|
||||||
const masque = {
|
const masque = {
|
||||||
projects: planning.projects.map((p) =>
|
projects: planning.projects.map((p) =>
|
||||||
p.id === 'portail' ? { ...p, hidden: true } : 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', () => {
|
test('rend une liste vide plutôt que null quand rien ne reste', () => {
|
||||||
assert.deepEqual(phasesParMois(planningVide()), []);
|
assert.deepEqual(entreesParMois(planningVide()), []);
|
||||||
assert.deepEqual(phasesParMois(planning, ['inconnu']), []);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user