/** * 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_STATUT, aujourdhui, duree, formaterDateLongue, formaterMois, formaterPeriode, moisDe, phasesParMois, projetFiltre, } from './model.js'; import { pastillesTags } from './tags.js'; /** * Reconstruit la liste dans son conteneur. * * @param {HTMLElement} conteneur * @param {object} planning * @param {object} options { tagsActifs } */ export function rendreMois(conteneur, planning, { tagsActifs = [] } = {}) { const groupes = phasesParMois(planning, tagsActifs); if (!groupes.length) { conteneur.replaceChildren(messageVide(planning, tagsActifs)); 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) { const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs)); 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 », en taisant celui des deux qui vaut zéro. */ function resumerCompte(entrees) { const jalons = entrees.filter(({ phase }) => phase.milestone).length; const taches = entrees.length - jalons; const morceaux = []; if (taches) morceaux.push(`${taches} tâche${taches > 1 ? 's' : ''}`); if (jalons) morceaux.push(`${jalons} jalon${jalons > 1 ? 's' : ''}`); return morceaux.join(' · '); } function construireEntree({ 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)); // 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)); 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. */ function triangle(phase) { if (!phase.notes) { 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 // quelle phase il parle. bouton.setAttribute('aria-label', `Notes de ${phase.name}`); bouton.title = 'Afficher les notes'; bouton.textContent = '▶'; return bouton; } /** * Les notes occupent une seconde rangée de la grille, sous toutes les colonnes. * `white-space: pre-wrap` (style.css) leur conserve leurs retours à la ligne : * elles sont souvent une petite liste, et la mettre à plat en changerait le * sens. */ function notes(phase) { const bloc = document.createElement('p'); bloc.className = 'mois-entree__notes'; bloc.textContent = phase.notes; 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]}` + (phase.notes ? `\n\n${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; }