/** * 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; }