Files
gestion_projets/js/timeline.js
Bertrand Benjamin 8f8707c461 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>
2026-08-02 05:09:18 +02:00

540 lines
20 KiB
JavaScript

/**
* Rendu de la frise : échelle temporelle, graduations, couloirs, barres,
* jalons, barre segmentée du mode plié et barre fantôme de référence.
*
* Le rendu est complet à chaque appel : on reconstruit le DOM depuis l'état
* plutôt que d'appliquer des différences. À l'échelle visée — quelques dizaines
* de phases — c'est instantané, et ça supprime toute une classe de bugs de
* synchronisation entre l'état et l'affichage.
*
* Aucun SVG : des éléments positionnés en absolu suffisent depuis que les
* dépendances ont été retirées (docs/decisions.md, section 4).
*/
import {
LIBELLES_STATUT,
MOIS_COURTS,
MOIS_LONGS,
ajouterJours,
aujourdhui,
bornesProjet,
bornesReference,
deriveProjet,
dimancheDeLaSemaine,
ecartJours,
formaterDateLongue,
formulerDerive,
lundiDeLaSemaine,
projetFiltre,
semaineISO,
versUTC,
} from './model.js';
import { pastillesTags } from './tags.js';
/**
* Échelle unique : 6 pixels par jour, soit 42 pixels par semaine.
*
* Il n'y a délibérément plus de niveaux de zoom. L'en-tête empile déjà l'année,
* le mois et le numéro de semaine : les trois repères utiles sont visibles en
* permanence, et changer d'échelle n'apportait qu'un réglage de plus à
* comprendre. Voir docs/decisions.md, section 14.
*/
export const PIXELS_PAR_JOUR = 6;
/**
* Largeur minimale, en pixels, qu'une cellule doit occuper pour porter son
* libellé : en dessous, elle reste vide plutôt que d'afficher un texte tronqué.
*/
const LIBELLE_MINI = { annee: 40, mois: 34, semaine: 30 };
/**
* Calcule l'échelle : origine, largeur totale et conversions date <-> pixel.
* Toute la géométrie de la frise passe par cet objet.
*
* La fenêtre affichée est décidée par app.js, qui l'élargit au fil du
* défilement — l'échelle ne la déduit plus des bornes du planning.
*
* @param {{debut: string, fin: string}} fenetre
*/
export function creerEchelle(fenetre) {
const { debut, fin } = fenetre;
const jours = ecartJours(debut, fin) + 1;
const pixelsParJour = PIXELS_PAR_JOUR;
return {
debut,
fin,
pixelsParJour,
largeur: jours * pixelsParJour,
/** Date « AAAA-MM-JJ » vers l'abscisse de son bord gauche. */
x(date) {
return ecartJours(this.debut, date) * pixelsParJour;
},
/** Largeur d'une phase, date de fin incluse. */
largeurDe(debutPhase, finPhase) {
return (ecartJours(debutPhase, finPhase) + 1) * pixelsParJour;
},
/** Abscisse vers la date correspondante — l'inverse de `x`. */
dateEn(abscisse) {
return ajouterJours(this.debut, Math.round(abscisse / pixelsParJour));
},
};
}
/**
* Reconstruit toute la frise.
*
* Deux tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
* **filtre par tags** retire le projet de la vue entière, libellé compris : il
* répond à « je ne regarde que les projets clients ». L'**œil** (`hidden`) garde
* la ligne de libellé, grisée, et n'ôte que les barres : il répond à « je sais
* que ce projet existe, mais il encombre ma frise ».
*
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
* @param {object} planning l'état courant
* @param {object} options { fenetre, selection, tagsActifs }
*/
export function rendre(refs, planning, { fenetre, selection, tagsActifs = [] }) {
const echelle = creerEchelle(fenetre);
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const visibles = retenus.filter((projet) => !projet.hidden);
refs.entete.style.width = `${echelle.largeur}px`;
refs.couloirs.style.width = `${echelle.largeur}px`;
refs.entete.replaceChildren(...construireEntete(echelle));
refs.libelles.replaceChildren(...construireLibelles(retenus, tagsActifs));
refs.couloirs.replaceChildren(
...construireTraits(echelle, hauteurTotale(visibles)),
...construireCouloirs(visibles, echelle, selection)
);
return echelle;
}
function hauteurTotale(projets) {
const lignes = projets.reduce(
(total, projet) => total + 1 + (projet.collapsed ? 0 : projet.phases.length),
0
);
return lignes * 38; // --hauteur-ligne
}
// En-tête de dates : trois bandes empilées
// ---------------------------------------------------------------------------
/**
* L'en-tête empile trois bandes — année, mois, semaine ISO — plutôt qu'une
* seule rangée de graduations. Chaque cellule est un bloc couvrant exactement
* son intervalle, ce qui place le libellé au centre de la période qu'il désigne
* au lieu de le laisser flotter après un trait.
*
* La semaine est le grain le plus fin : on ne descend jamais au jour.
*/
function construireEntete(echelle) {
return [
bande(echelle, [...segmentsAnnees(echelle)], 'annee', LIBELLE_MINI.annee),
bande(echelle, [...segmentsMois(echelle)], 'mois', LIBELLE_MINI.mois),
bande(echelle, [...segmentsSemaines(echelle)], 'semaine', LIBELLE_MINI.semaine),
];
}
/**
* @param {number} largeurMiniLibelle largeur en pixels sous laquelle la cellule
* reste vide : mieux vaut pas de texte qu'un texte tronqué illisible.
*/
function bande(echelle, segments, nom, largeurMiniLibelle) {
const conteneur = document.createElement('div');
conteneur.className = `bande bande--${nom}`;
for (const segment of segments) {
const largeur = echelle.largeurDe(segment.debut, segment.fin);
const cellule = document.createElement('div');
cellule.className = 'bande__cellule';
cellule.style.left = `${echelle.x(segment.debut)}px`;
cellule.style.width = `${largeur}px`;
if (largeur >= largeurMiniLibelle) cellule.textContent = segment.libelle;
cellule.title = segment.titre ?? segment.libelle;
conteneur.append(cellule);
}
return conteneur;
}
function plusTard(a, b) {
return versUTC(a) >= versUTC(b) ? a : b;
}
function plusTot(a, b) {
return versUTC(a) <= versUTC(b) ? a : b;
}
function* segmentsAnnees(echelle) {
const derniere = Number(echelle.fin.slice(0, 4));
for (let annee = Number(echelle.debut.slice(0, 4)); annee <= derniere; annee += 1) {
yield {
debut: plusTard(`${annee}-01-01`, echelle.debut),
fin: plusTot(`${annee}-12-31`, echelle.fin),
libelle: String(annee),
};
}
}
function* segmentsMois(echelle) {
let annee = Number(echelle.debut.slice(0, 4));
let mois = Number(echelle.debut.slice(5, 7));
const finUTC = versUTC(echelle.fin);
while (true) {
const premier = `${annee}-${String(mois).padStart(2, '0')}-01`;
if (versUTC(premier) > finUTC) return;
// Le dernier jour du mois s'obtient en reculant d'un jour depuis le
// premier du mois suivant, ce qui gère les années bissextiles sans table.
const suivant = moisSuivant(annee, mois);
const premierSuivant = `${suivant.annee}-${String(suivant.mois).padStart(2, '0')}-01`;
yield {
debut: plusTard(premier, echelle.debut),
fin: plusTot(ajouterJours(premierSuivant, -1), echelle.fin),
libelle: MOIS_COURTS[mois - 1],
titre: `${MOIS_LONGS[mois - 1]} ${annee}`,
};
({ annee, mois } = suivant);
}
}
function* segmentsSemaines(echelle) {
const finUTC = versUTC(echelle.fin);
let lundi = lundiDeLaSemaine(echelle.debut);
while (versUTC(lundi) <= finUTC) {
const { annee, numero } = semaineISO(lundi);
const dimanche = dimancheDeLaSemaine(lundi);
yield {
debut: plusTard(lundi, echelle.debut),
fin: plusTot(dimanche, echelle.fin),
libelle: `S${numero}`,
titre: `Semaine ${numero} de ${annee} — du ${formaterDateLongue(lundi)} au ${formaterDateLongue(dimanche)}`,
};
lundi = ajouterJours(lundi, 7);
}
}
// ---------------------------------------------------------------------------
// Traits verticaux des couloirs
// ---------------------------------------------------------------------------
/**
* Les traits reprennent le découpage de l'en-tête : un trait fort à chaque
* début de mois, un trait léger à chaque lundi.
*/
function construireTraits(echelle, hauteur) {
const elements = [];
for (const semaine of segmentsSemaines(echelle)) {
elements.push(trait(echelle, semaine.debut, hauteur, ''));
}
for (const mois of segmentsMois(echelle)) {
elements.push(trait(echelle, mois.debut, hauteur, ' trait--majeur'));
}
// Repère « aujourd'hui », seulement s'il tombe dans la fenêtre affichée.
const jour = aujourdhui();
if (versUTC(jour) >= versUTC(echelle.debut) && versUTC(jour) <= versUTC(echelle.fin)) {
const repere = trait(echelle, jour, hauteur, ' trait--aujourdhui');
repere.title = `Aujourd'hui, ${formaterDateLongue(jour)}`;
elements.push(repere);
}
return elements;
}
function trait(echelle, date, hauteur, suffixeClasse) {
const element = document.createElement('div');
element.className = `trait${suffixeClasse}`;
element.style.left = `${echelle.x(date)}px`;
element.style.height = `${hauteur}px`;
return element;
}
function moisSuivant(annee, mois) {
return mois === 12 ? { annee: annee + 1, mois: 1 } : { annee, mois: mois + 1 };
}
// ---------------------------------------------------------------------------
// Colonne des libellés
// ---------------------------------------------------------------------------
function construireLibelles(projets, tagsActifs) {
const elements = [];
for (const projet of projets) {
if (projet.hidden) {
elements.push(libelleProjet(projet, tagsActifs));
continue;
}
elements.push(libelleProjet(projet, tagsActifs));
if (projet.collapsed) continue;
for (const phase of projet.phases) {
const ligne = document.createElement('div');
ligne.className = 'libelle-phase';
ligne.textContent = phase.name;
ligne.title = `${phase.name}${LIBELLES_STATUT[phase.status]}`;
ligne.dataset.projet = projet.id;
ligne.dataset.phase = phase.id;
elements.push(ligne);
}
}
return elements;
}
function libelleProjet(projet, tagsActifs = []) {
const ligne = document.createElement('div');
ligne.className = `libelle-projet${projet.hidden ? ' libelle-projet--masque' : ''}`;
ligne.style.setProperty('--couleur-projet', projet.color);
const plier = document.createElement('button');
plier.className = 'plier';
plier.type = 'button';
plier.dataset.action = 'plier';
plier.dataset.projet = projet.id;
plier.setAttribute('aria-expanded', String(!projet.collapsed));
plier.setAttribute(
'aria-label',
projet.collapsed ? `Déplier ${projet.name}` : `Plier ${projet.name}`
);
plier.textContent = '▶';
ligne.append(plier);
const nom = document.createElement('span');
nom.className = 'libelle-projet__nom';
nom.textContent = projet.name;
nom.dataset.action = 'parametres';
nom.dataset.projet = projet.id;
// Les pastilles rognent le nom quand la colonne se remplit : l'infobulle
// reste le seul endroit où les deux se lisent toujours en entier.
nom.title =
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
// Le grisé de la ligne dit qu'il se passe quelque chose ; encore faut-il
// pouvoir apprendre quoi, et comment revenir en arrière.
(projet.hidden ? '\n\nMasqué de la frise — voir le menu ⋯ pour le rétablir.' : '') +
'\n\nCliquer pour renommer, changer la couleur et les tags.';
ligne.append(nom);
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
const derive = formulerDerive(deriveProjet(projet));
if (derive) {
const etiquette = document.createElement('span');
etiquette.className = 'libelle-projet__derive';
etiquette.dataset.sens = derive.startsWith('+') ? 'retard' : 'avance';
etiquette.textContent = derive;
etiquette.title = projet.baselineDate
? `Écart au planning figé le ${formaterDateLongue(projet.baselineDate)}`
: 'Écart au planning de référence';
ligne.append(etiquette);
}
// Les commandes sont posées *au-dessus* de la ligne, révélées au survol,
// plutôt que rangées dans son flux. Elles y occupaient une soixantaine de
// pixels en permanence — de quoi tronquer le nom de tous les projets dans une
// colonne de largeur courante. En surimpression, le nom dispose de toute la
// colonne et rien ne se déplace à l'approche du curseur.
const commandes = document.createElement('span');
commandes.className = 'libelle-projet__commandes';
// Ajouter une phase est de loin l'action la plus fréquente : elle mérite son
// bouton, pas une entrée à aller chercher dans un menu.
const ajout = document.createElement('button');
ajout.className = 'commande-projet';
ajout.type = 'button';
ajout.dataset.action = 'ajouter';
ajout.dataset.projet = projet.id;
ajout.textContent = '+';
ajout.title = `Ajouter une phase à ${projet.name}`;
ajout.setAttribute('aria-label', ajout.title);
commandes.append(ajout);
// Bouton de menu explicite : les actions du projet passaient auparavant par
// un clic sur son nom, ce que rien n'indiquait.
const menu = document.createElement('button');
menu.className = 'commande-projet';
menu.type = 'button';
menu.dataset.action = 'menu';
menu.dataset.projet = projet.id;
menu.textContent = '⋯';
menu.title = `Actions sur ${projet.name}`;
menu.setAttribute('aria-label', `Actions sur ${projet.name}`);
menu.setAttribute('aria-haspopup', 'menu');
commandes.append(menu);
ligne.append(commandes);
return ligne;
}
// ---------------------------------------------------------------------------
// Couloirs
// ---------------------------------------------------------------------------
function construireCouloirs(projets, echelle, selection) {
const elements = [];
for (const projet of projets) {
const couloirProjet = document.createElement('div');
couloirProjet.className = 'couloir couloir--projet';
couloirProjet.style.setProperty('--couleur-projet', projet.color);
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
// coiffe les phases du projet et garde sa forme d'ensemble sous les yeux
// pendant qu'on travaille sur le détail. Sans elle, il fallait replier pour
// savoir où en était le projet entier, donc perdre la vue qu'on éditait.
const barre = construireBarreCumulative(projet, echelle);
if (barre) couloirProjet.append(barre);
const fantome = construireFantome(projet, echelle);
if (fantome) couloirProjet.append(fantome);
elements.push(couloirProjet);
if (projet.collapsed) continue;
for (const phase of projet.phases) {
const couloir = document.createElement('div');
couloir.className = 'couloir';
couloir.style.setProperty('--couleur-projet', projet.color);
couloir.append(...construirePhase(projet, phase, echelle, selection));
elements.push(couloir);
}
}
return elements;
}
function construirePhase(projet, phase, echelle, selection) {
const selectionnee =
selection && selection.projet === projet.id && selection.phase === phase.id;
const elements = [];
// La référence se dessine sous la barre, donc en premier.
if (phase.baseline) {
const fantome = document.createElement('div');
fantome.className = 'fantome';
fantome.style.left = `${echelle.x(phase.baseline.start)}px`;
fantome.style.width = `${echelle.largeurDe(phase.baseline.start, phase.baseline.end)}px`;
fantome.title = `Référence : ${formaterDateLongue(phase.baseline.start)}${formaterDateLongue(phase.baseline.end)}`;
elements.push(fantome);
}
if (phase.milestone) {
const losange = document.createElement('div');
losange.className = `jalon jalon--${phase.status}${selectionnee ? ' jalon--selectionne' : ''}`;
losange.style.left = `${echelle.x(phase.start)}px`;
losange.dataset.projet = projet.id;
losange.dataset.phase = phase.id;
losange.dataset.role = 'barre';
losange.title = `${phase.name}${formaterDateLongue(phase.start)}`;
elements.push(losange);
const nom = document.createElement('span');
nom.className = 'jalon-nom';
nom.style.left = `${echelle.x(phase.start)}px`;
nom.textContent = phase.name;
elements.push(nom);
return elements;
}
const barre = document.createElement('div');
barre.className = `barre barre--${phase.status}${selectionnee ? ' barre--selectionnee' : ''}`;
barre.style.left = `${echelle.x(phase.start)}px`;
barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`;
barre.dataset.projet = projet.id;
barre.dataset.phase = phase.id;
barre.dataset.role = 'barre';
barre.title =
`${phase.name}\n${formaterDateLongue(phase.start)}${formaterDateLongue(phase.end)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${phase.notes}` : '');
// Le nom vit dans son propre élément plutôt que directement dans la barre :
// il est `sticky`, ce qui le maintient visible quand la barre s'enfonce sous
// la colonne des libellés. Une phase de plusieurs mois pouvait autrement
// occuper tout l'écran sans qu'on lise son nom, parti hors cadre à gauche.
const nom = document.createElement('span');
nom.className = 'barre__nom';
nom.textContent = phase.name;
barre.append(nom);
for (const bord of ['debut', 'fin']) {
const poignee = document.createElement('div');
poignee.className = `poignee poignee--${bord}`;
poignee.dataset.role = 'poignee';
poignee.dataset.bord = bord;
barre.append(poignee);
}
elements.push(barre);
return elements;
}
/**
* Barre unique du projet, segmentée par phase — sa forme d'ensemble.
*
* Affichée qu'il soit plié ou déplié. Lecture seule dans les deux cas : aucun
* `data-role`, donc drag.js l'ignore (docs/decisions.md, section 8).
*/
function construireBarreCumulative(projet, echelle) {
const bornes = bornesProjet(projet);
if (!bornes) return null;
const conteneur = document.createElement('div');
conteneur.className = 'barre-cumulative';
conteneur.style.left = `${echelle.x(bornes.start)}px`;
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
const derive = formulerDerive(deriveProjet(projet));
conteneur.title =
`${projet.name}\n${formaterDateLongue(bornes.start)}${formaterDateLongue(bornes.end)}` +
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
(derive ? `\nÉcart à la référence : ${derive}` : '') +
// Déplié, les barres modifiables sont juste en dessous : le dire serait
// enfoncer une porte ouverte.
(projet.collapsed ? '\n\nDéplier le projet pour modifier les dates.' : '');
const origine = echelle.x(bornes.start);
for (const phase of projet.phases) {
const segment = document.createElement('div');
segment.className = `segment segment--${phase.status}`;
segment.style.left = `${echelle.x(phase.start) - origine}px`;
// Un jalon n'a pas de largeur propre : on lui donne 3 px pour rester visible.
segment.style.width = phase.milestone
? '3px'
: `${echelle.largeurDe(phase.start, phase.end)}px`;
conteneur.append(segment);
}
return conteneur;
}
/** Barre fantôme du projet, reflétant l'étendue de référence. */
function construireFantome(projet, echelle) {
const reference = bornesReference(projet);
if (!reference) return null;
const fantome = document.createElement('div');
// Se cale sous la barre cumulative, plus mince qu'une barre de phase : sans
// cette variante, il flotterait dans le vide sous elle.
fantome.className = 'fantome fantome--cumulatif';
fantome.style.left = `${echelle.x(reference.start)}px`;
fantome.style.width = `${echelle.largeurDe(reference.start, reference.end)}px`;
fantome.title = `Référence : ${formaterDateLongue(reference.start)}${formaterDateLongue(reference.end)}`;
return fantome;
}