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>
368 lines
14 KiB
JavaScript
368 lines
14 KiB
JavaScript
/**
|
|
* Vue par mois : toutes les tâches et tous les jalons du planning en une seule
|
|
* liste, regroupés par mois de début, projets mêlés.
|
|
*
|
|
* C'est la vue transversale que la frise ne peut pas donner. Sur la frise,
|
|
* chaque projet a son couloir : savoir ce qui démarre en octobre demande de
|
|
* balayer une colonne de dates à travers tous les couloirs. Ici les projets se
|
|
* mêlent et l'ordre est celui du calendrier — la question « qu'est-ce qui
|
|
* arrive le mois prochain » se lit d'un coup d'œil.
|
|
*
|
|
* Lecture seule, et sans exception : aucune poignée, aucun glisser, un clic sur
|
|
* une ligne n'ouvre rien. La vue sert à faire le point, pas à arbitrer ; les
|
|
* dates se modifient là où elles se voient les unes par rapport aux autres,
|
|
* c'est-à-dire sur la frise. Seules les pastilles de tags restent cliquables —
|
|
* filtrer ne change pas les données, c'est une façon de regarder.
|
|
*
|
|
* Comme timeline.js, le rendu est complet à chaque appel : on reconstruit le
|
|
* DOM depuis l'état plutôt que d'appliquer des différences.
|
|
*/
|
|
|
|
import {
|
|
LIBELLES_ETAT,
|
|
LIBELLES_STATUT,
|
|
aujourdhui,
|
|
duree,
|
|
entreesParMois,
|
|
etatProjet,
|
|
formaterDateLongue,
|
|
formaterHorizon,
|
|
formaterMois,
|
|
formaterPeriode,
|
|
moisDe,
|
|
projetFiltre,
|
|
projetFiltreEtat,
|
|
} from './model.js';
|
|
import { aplatirMarkdown } from './markdown.js';
|
|
import { rendreMarkdown } from './notes.js';
|
|
import { pastillesTags } from './tags.js';
|
|
|
|
/**
|
|
* Reconstruit la liste dans son conteneur.
|
|
*
|
|
* @param {HTMLElement} conteneur
|
|
* @param {object} planning
|
|
* @param {object} options { tagsActifs, etatsActifs }
|
|
*/
|
|
export function rendreMois(conteneur, planning, { tagsActifs = [], etatsActifs = [] } = {}) {
|
|
const groupes = entreesParMois(planning, tagsActifs, etatsActifs);
|
|
|
|
if (!groupes.length) {
|
|
conteneur.replaceChildren(messageVide(planning, tagsActifs, etatsActifs));
|
|
return;
|
|
}
|
|
|
|
const moisCourant = moisDe(aujourdhui());
|
|
conteneur.replaceChildren(
|
|
...groupes.map((groupe) => construireGroupe(groupe, moisCourant, tagsActifs))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Le cas « rien à montrer » n'a pas une seule cause, et elles ne se règlent pas
|
|
* de la même façon : des projets encore vides attendent qu'on leur ajoute des
|
|
* phases, un filtre trop serré attend qu'on le desserre, des projets masqués
|
|
* attendent qu'on rouvre l'œil. Les messages généraux d'app.js ne couvrent
|
|
* aucun des trois — ils parlent de projets, quand cette vue ne montre que des
|
|
* phases.
|
|
*
|
|
* 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, etatsActifs = []) {
|
|
const retenus = planning.projects.filter(
|
|
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
|
|
);
|
|
|
|
const paragraphe = document.createElement('p');
|
|
paragraphe.className = 'mois__vide';
|
|
|
|
if (retenus.length && retenus.every((projet) => projet.hidden)) {
|
|
paragraphe.textContent =
|
|
'Tous les projets retenus sont masqués de la frise : leurs tâches ne sont pas listées ' +
|
|
'ici non plus. Le menu ⋯ de la frise les rétablit.';
|
|
} else if (tagsActifs.length) {
|
|
paragraphe.textContent = 'Aucune tâche ni jalon dans les projets retenus par le filtre.';
|
|
} else {
|
|
paragraphe.textContent = 'Aucune tâche ni jalon : les projets du planning sont encore vides.';
|
|
}
|
|
|
|
return paragraphe;
|
|
}
|
|
|
|
function construireGroupe({ mois, entrees }, moisCourant, tagsActifs) {
|
|
const section = document.createElement('section');
|
|
section.className = 'mois-groupe';
|
|
section.dataset.mois = mois;
|
|
if (mois === moisCourant) section.dataset.courant = 'true';
|
|
|
|
const titre = document.createElement('h2');
|
|
titre.className = 'mois-groupe__titre';
|
|
|
|
const nom = document.createElement('span');
|
|
nom.className = 'mois-groupe__nom';
|
|
nom.textContent = formaterMois(mois);
|
|
titre.append(nom);
|
|
|
|
if (mois === moisCourant) {
|
|
const marque = document.createElement('span');
|
|
marque.className = 'mois-groupe__courant';
|
|
marque.textContent = 'ce mois-ci';
|
|
titre.append(marque);
|
|
}
|
|
|
|
const compte = document.createElement('span');
|
|
compte.className = 'mois-groupe__compte';
|
|
compte.textContent = resumerCompte(entrees);
|
|
titre.append(compte);
|
|
|
|
section.append(titre);
|
|
|
|
const liste = document.createElement('ul');
|
|
liste.className = 'mois-entrees';
|
|
liste.append(...entrees.map((entree) => construireEntree(entree, tagsActifs)));
|
|
section.append(liste);
|
|
|
|
return section;
|
|
}
|
|
|
|
/** « 4 tâches · 2 jalons · 1 envisagé », en taisant ce qui vaut zéro. */
|
|
function resumerCompte(entrees) {
|
|
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(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);
|
|
if (phase.milestone) ligne.classList.add('mois-entree--jalon');
|
|
// C'est cette classe qui rend la ligne entière cliquable (app.js) : sans
|
|
// notes, il n'y a rien à déplier et le clic ne doit rien promettre.
|
|
if (phase.notes) ligne.classList.add('mois-entree--commentee');
|
|
ligne.title = infobulle(projet, 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 à
|
|
// lire là-bas.
|
|
const marque = document.createElement('span');
|
|
marque.className = 'mois-entree__marque';
|
|
ligne.append(marque);
|
|
|
|
const dates = document.createElement('span');
|
|
dates.className = 'mois-entree__dates';
|
|
dates.textContent = formaterPeriode(phase);
|
|
ligne.append(dates);
|
|
|
|
// Le projet précède la phase : c'est l'ordre dans lequel on nomme les choses
|
|
// — « le cadrage du site web », pas l'inverse —, et il donne au regard une
|
|
// colonne stable où retrouver la provenance avant de lire l'intitulé.
|
|
const nomProjet = document.createElement('span');
|
|
nomProjet.className = 'mois-entree__projet';
|
|
nomProjet.textContent = projet.name;
|
|
ligne.append(nomProjet);
|
|
|
|
// Les tags suivent le projet, qu'ils qualifient — pas la phase, qui n'en
|
|
// porte pas. Toujours posés, même vides : les colonnes de la grille sont
|
|
// comptées, et un projet sans tag ferait autrement glisser toute la ligne.
|
|
ligne.append(pastillesTags(projet.tags, tagsActifs));
|
|
|
|
const nom = document.createElement('span');
|
|
nom.className = 'mois-entree__nom';
|
|
nom.textContent = phase.name;
|
|
ligne.append(nom);
|
|
|
|
const statut = document.createElement('span');
|
|
statut.className = 'mois-entree__statut';
|
|
statut.dataset.statut = phase.status;
|
|
statut.textContent = LIBELLES_STATUT[phase.status];
|
|
ligne.append(statut);
|
|
|
|
if (phase.notes) ligne.append(notes(phase.notes));
|
|
|
|
return ligne;
|
|
}
|
|
|
|
/**
|
|
* Triangle de dépli des notes, en gouttière à gauche de la ligne.
|
|
*
|
|
* C'est le même objet que le triangle qui plie un projet sur la frise, à la
|
|
* même place et avec le même geste : ce qui s'apprend d'un côté vaut de
|
|
* l'autre. Il ne paraît que sur les phases qui ont des notes — une phase sur
|
|
* trois environ —, si bien que la gouttière se lit d'un coup d'œil comme la
|
|
* liste de ce qui a quelque chose à dire.
|
|
*
|
|
* 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(texte, nom) {
|
|
if (!texte) {
|
|
const vide = document.createElement('span');
|
|
vide.className = 'mois-entree__plier';
|
|
return vide;
|
|
}
|
|
|
|
const bouton = document.createElement('button');
|
|
bouton.type = 'button';
|
|
bouton.className = 'mois-entree__plier';
|
|
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 quoi
|
|
// il parle.
|
|
bouton.setAttribute('aria-label', `Notes de ${nom}`);
|
|
bouton.title = 'Afficher les notes';
|
|
bouton.textContent = '▶';
|
|
return bouton;
|
|
}
|
|
|
|
/**
|
|
* Les notes occupent une seconde rangée de la grille, sous toutes les colonnes.
|
|
*
|
|
* Elles sont rendues en markdown, comme dans le panneau : une note doit se lire
|
|
* pareil des deux côtés, sans quoi on douterait d'avoir la même sous les yeux.
|
|
*
|
|
* Les cases à cocher, elles, restent **inertes** ici. La vue est en lecture
|
|
* seule (décision 24), et une case cliquable y serait la seule chose qu'on
|
|
* puisse modifier — une exception isolée dans une vue dont toute la promesse est
|
|
* 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(texte) {
|
|
const bloc = document.createElement('div');
|
|
bloc.className = 'mois-entree__notes';
|
|
bloc.append(rendreMarkdown(texte));
|
|
bloc.hidden = true;
|
|
return bloc;
|
|
}
|
|
|
|
function infobulle(projet, phase) {
|
|
const jours = duree(phase);
|
|
return (
|
|
`${phase.name}\n${projet.name}\n\n` +
|
|
(phase.milestone
|
|
? `Jalon du ${formaterDateLongue(phase.start)}`
|
|
: `${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
|
` (${jours} jour${jours > 1 ? 's' : ''})`) +
|
|
`\n${LIBELLES_STATUT[phase.status]}` +
|
|
// Aplati : un `title` ne connaît que le texte. Sans cela, survoler afficherait
|
|
// la source markdown, astérisques comprises — une note *moins* lisible
|
|
// qu'avant qu'on ne l'enrichisse.
|
|
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Amène le mois courant en haut de la vue, ou à défaut le premier mois qui le
|
|
* suit.
|
|
*
|
|
* Sans cela, la liste s'ouvre sur le mois le plus ancien du planning — souvent
|
|
* des phases terminées depuis des mois. Le repli sur le mois suivant compte :
|
|
* un planning peut n'avoir rien du tout au mois courant, et atterrir alors sur
|
|
* son passé serait le pire des deux mondes.
|
|
*/
|
|
export function revelerMoisCourant(conteneur) {
|
|
const courant = moisDe(aujourdhui());
|
|
const groupes = [...conteneur.querySelectorAll('.mois-groupe')];
|
|
const cible = groupes.find((groupe) => groupe.dataset.mois >= courant);
|
|
|
|
// Aucun mois à venir : tout le planning est derrière nous, on montre sa fin.
|
|
if (!cible) {
|
|
conteneur.scrollTop = conteneur.scrollHeight;
|
|
return;
|
|
}
|
|
|
|
// `offsetTop` est ici relatif au conteneur, qui est son `offsetParent` : la
|
|
// règle `position: relative` de `.mois` dans style.css en dépend.
|
|
//
|
|
// Quand le mois visé tient déjà à l'écran sans défiler — un planning court,
|
|
// ou qui commence bientôt —, on ne défile pas. Le navigateur plafonnerait
|
|
// sinon le défilement demandé à son maximum, ce qui glisse les premières
|
|
// lignes sous le titre collant du mois d'avant sans rien gagner.
|
|
const tientDejaAEcran = cible.offsetTop + cible.offsetHeight <= conteneur.clientHeight;
|
|
conteneur.scrollTop = tientDejaAEcran ? 0 : cible.offsetTop;
|
|
}
|