Files
gestion_projets/js/timeline.js
Bertrand Benjamin 3092be86ae Piloter la frise au clavier, à la vim
Les flèches décalaient déjà une phase, mais rien ne permettait de la choisir :
`selection` n'était posée qu'au clic, si bien qu'il fallait la souris pour
commencer. Elle devient `curseur`, désigne une ligne — celle d'un projet en est
une, d'où l'on plie, renomme et ajoute — et survit à Échap, sans quoi elle ne
pourrait servir de point de départ au déplacement suivant. `dessiner` le
normalise, la ligne du projet servant de refuge quand la sienne disparaît.

Deux couches qui ne se recouvrent pas : les flèches modifient, les lettres
déplacent et commandent. Aucun raccourci documenté ne change de sens. Les
touches se taisent dès que le clavier est à quelqu'un d'autre — une saisie, un
dialogue, le menu, un glisser —, faute de quoi `a` dans un nom créerait une
phase. AltGr est toléré : sur un clavier français `{` et `}` n'existent qu'avec
lui, et Windows le présente comme Ctrl+Alt.

Les phases étant triées par date, `o` et `O` insèrent dans le temps et non dans
un rang. `P` reste hors de cette famille : un projet naît sans position.

Le curseur se peint dans la colonne des libellés, seul endroit qui porte une
ligne pour chaque position atteignable — projet plié, projet masqué, ligne de
projet. Et `?` ouvre la table des raccourcis, qui ne se devinent pas.

Décision 26.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 10:36:07 +02:00

571 lines
22 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 { aplatirMarkdown } from './markdown.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, curseur, tagsActifs }
*/
export function rendre(refs, planning, { fenetre, curseur, 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, curseur));
refs.couloirs.replaceChildren(
...construireTraits(echelle, hauteurTotale(visibles)),
...construireCouloirs(visibles, echelle, curseur)
);
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
// ---------------------------------------------------------------------------
/**
* La colonne est aussi la règle graduée du curseur clavier : c'est le seul
* endroit qui porte une ligne pour *chaque* position atteignable, y compris
* celles qui n'ont pas de barre en face — un projet plié, un projet masqué, ou
* simplement la ligne d'un projet. `app.js` compte donc ses enfants pour savoir
* où amener le défilement, et l'ordre produit ici est celui qu'il énumère.
*/
function construireLibelles(projets, tagsActifs, curseur) {
const elements = [];
for (const projet of projets) {
const surLeProjet = curseur && curseur.projet === projet.id && !curseur.phase;
elements.push(libelleProjet(projet, tagsActifs, surLeProjet));
// Un projet masqué n'a plus que sa ligne : ses phases ne se dessinent nulle
// part, et le curseur ne les traverse pas davantage qu'il ne les affiche.
if (projet.hidden || projet.collapsed) continue;
for (const phase of projet.phases) {
const sousCurseur = curseur && curseur.projet === projet.id && curseur.phase === phase.id;
const ligne = document.createElement('div');
ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
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 = [], sousCurseur = false) {
const ligne = document.createElement('div');
ligne.className =
'libelle-projet' +
(projet.hidden ? ' libelle-projet--masque' : '') +
(sousCurseur ? ' libelle-projet--curseur' : '');
ligne.style.setProperty('--couleur-projet', projet.color);
ligne.dataset.projet = projet.id;
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) +
// Les notes du projet passent avant l'invite : c'est le contenu, l'invite
// n'est qu'un mode d'emploi. Le libellé est le seul endroit de la frise où
// elles se lisent — un projet n'a pas de barre à lui.
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '') +
// 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, curseur) {
const elements = [];
for (const projet of projets) {
// Le curseur clavier se pose aussi sur la ligne d'un projet, où il commande
// le pli, les paramètres et l'ajout d'une phase. Elle n'a pas de barre à
// souligner : c'est le couloir entier qui porte la marque.
const surLeProjet = curseur && curseur.projet === projet.id && !curseur.phase;
const couloirProjet = document.createElement('div');
couloirProjet.className = `couloir couloir--projet${surLeProjet ? ' couloir--curseur' : ''}`;
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, curseur));
elements.push(couloir);
}
}
return elements;
}
function construirePhase(projet, phase, echelle, curseur) {
const selectionnee =
curseur && curseur.projet === projet.id && curseur.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';
// Un jalon porte des notes comme une tâche — souvent les plus utiles, un
// jalon étant justement le point où l'on consigne une décision. Son
// infobulle les taisait, alors que celle d'une barre les montrait.
losange.title =
`${phase.name}${formaterDateLongue(phase.start)}` +
`\n${LIBELLES_STATUT[phase.status]}` +
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
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]}` +
// Aplati : l'infobulle native ne connaît que le texte, et y déverser la
// source markdown rendrait la note moins lisible qu'avant qu'on ne
// l'enrichisse. `aplatirMarkdown` retire les marques et remplace celles qui
// portent du sens — une puce pour un tiret, une case dessinée pour `[x]`.
(phase.notes ? `\n\n${aplatirMarkdown(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;
}