All checks were successful
Build / build (push) Successful in 1m2s
Statut : quatre boutons peints de leur texture, si bien que le panneau sert enfin de légende. « En cours » se raye et « terminé » redevient vif — la trame les distingue, la teinte n'a plus à le faire. Date imposée : un cadre tireté cerne la forme entière. Les taquets tombaient sous les poignées de redimensionnement et disparaissaient sur une barre courte. Son libellé passe dans le modèle, ce qui la fait apparaître dans la vue par mois, qui la taisait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
15 KiB
JavaScript
373 lines
15 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,
|
|
libelleStatut,
|
|
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');
|
|
// La vue par mois est celle où l'on parcourt les échéances : c'est
|
|
// exactement là qu'une date qu'on ne décide pas doit se voir. Elle ne le
|
|
// faisait pas, alors que la frise l'annonçait sur chaque barre.
|
|
if (phase.fixed) ligne.classList.add('mois-entree--imposee');
|
|
// 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, cadre tireté pour une date imposée — 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${libelleStatut(phase)}` +
|
|
// 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;
|
|
}
|