Porter le cycle de vie et l'horizon dans le modèle, en version 5

L'état n'est pas un champ : il se lit dans horizon, completedDate,
discardedDate et la présence de phases. Les phases commandent, sauf
quand on a prononcé quelque chose.

L'emprise d'un projet retombe sur son horizon faute de phases, et une
saisie n'est jamais réécrite par un calcul : un projet qui perd sa
dernière phase retrouve l'horizon qu'il avait déclaré.

Clore un projet termine aussi toutes ses phases, seule opération du
cycle qui écrase des données. phasesParMois devient entreesParMois :
elle émet aussi les projets sans phase, à leur horizon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 14:57:34 +02:00
parent 3c7cff3ac6
commit 8404f12656
4 changed files with 1003 additions and 72 deletions

View File

@@ -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;
}