Ajouter une vue des tâches et jalons groupés par mois

La frise répond mal à « qu'est-ce qui arrive le mois prochain » : la question
traverse les projets, alors que la frise les range précisément en couloirs
séparés. Il fallait balayer une colonne de dates à travers tous les couloirs
en gardant en tête ce qu'on avait vu plus haut — d'où le tableur ressorti à
côté.

Deux boutons dans la barre d'outils basculent vers une liste qui déroule
toutes les phases, projets mêlés, dans l'ordre du calendrier. Chaque ligne se
lit quand, chez qui, puis quoi : période, projet et ses tags, intitulé,
statut.

Une phase est rattachée au seul mois de son début. La répéter dans chaque mois
traversé aurait donné un tableau de charge — utile, mais c'est une autre vue,
et le doublon coûte la lecture en diagonale qui fait tout l'intérêt de
celle-ci. Sa fin est écrite sur la ligne, ce qui suffit à voir qu'elle
déborde. Les mois sans rien ne sont pas représentés : l'absence se lit déjà
dans le saut d'un titre au suivant.

La liste ne s'édite pas, et ce n'est pas une lacune à combler : déplacer une
phase demande de voir ce qu'elle chevauche et ce qu'elle repousse, or c'est
justement ce voisinage que l'ordre chronologique dissout. Seules les pastilles
de tags restent cliquables — filtrer ne change pas les données.

Le filtre par tags vaut pour les deux vues, sans rien changer à sa mécanique,
si bien qu'on ne se demande jamais si l'une montre plus que l'autre. L'œil
(hidden) écarte un projet de la liste comme il écarte ses barres.

La vue choisie n'est pas enregistrée, contrairement à la largeur de la colonne
des libellés : cette largeur est un réglage d'écran, la vue est un geste de
consultation. L'outil s'ouvre donc sur la frise, celle où l'on modifie. Le
défilement horizontal de la frise, lui, est mémorisé le temps de
l'aller-retour, puisque masquer un élément lui fait perdre son scrollLeft.

Deux extractions au passage, chacune parce que deux vues sœurs en ont besoin
et qu'aucune n'a à importer l'autre : les noms de mois et le formatage des
dates rejoignent model.js, les pastilles de tags un module tags.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 05:09:18 +02:00
parent faac28172e
commit 8f8707c461
10 changed files with 971 additions and 84 deletions

220
js/mois.js Normal file
View File

@@ -0,0 +1,220 @@
/**
* 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');
ligne.title = infobulle(projet, 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);
return ligne;
}
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;
}