Frise multi-projets : outil de planification macro

Une frise chronologique unique où plusieurs projets s'empilent en couloirs
pliables, pour voir d'un coup d'œil où en est chacun et comment ils se situent
les uns par rapport aux autres. Ce n'est pas un outil de suivi quotidien : une
phase se compte en semaines, et il n'y a ni sous-tâches, ni tickets, ni
dépendances entre phases.

- Manipulation directe des barres (glisser, redimensionner), accrochées au lundi
- Couloirs pliables : plié, un projet devient une barre segmentée par phase
- Planning de référence figeable, avec barre fantôme et calcul de dérive
- En-tête à trois bandes : année, mois, numéro de semaine ISO 8601
- Frise qui s'élargit au défilement, pour planifier dans un futur encore vide
- Micro-serveur Python (bibliothèque standard) exposant GET/PUT sur /api/data,
  avec sauvegarde horodatée avant chaque écriture
- Zéro build : modules ES natifs, aucune dépendance à installer

50 tests unitaires sur la logique métier (node --test, sans dépendance).
Vérifié dans Chromium et Firefox.

Les arbitrages de conception et surtout leurs raisons sont consignés dans
docs/decisions.md — notamment l'abandon des dépendances entre phases, de la
File System Access API, du SVG et des niveaux de zoom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 06:53:57 +02:00
commit 98ae311545
16 changed files with 4766 additions and 0 deletions

596
js/app.js Normal file
View File

@@ -0,0 +1,596 @@
/**
* Amorçage et câblage.
*
* L'état vit ici, dans une seule variable `planning`. Toute modification passe
* par `majPlanning`, qui rend la frise et déclenche la sauvegarde : il n'y a
* qu'un seul chemin pour changer quelque chose, ce qui rend le flux facile à
* suivre.
*/
import { creerPanneau } from './detail.js';
import { creerDialogueConfirmation, creerDialogueProjet, creerMenu } from './menu.js';
import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
import { PIXELS_PAR_JOUR, formaterDateLongue, rendre } from './timeline.js';
import { creerStockage, viderAvantFermeture } from './storage.js';
import {
COULEURS,
ajouterJours,
ajouterPhase,
aujourdhui,
bornesPlanning,
creerPhase,
creerProjet,
figerReference,
libererReference,
planningVide,
pourEcriture,
remplacerPhase,
remplacerProjet,
supprimerPhase,
supprimerProjet,
validerPlanning,
versUTC,
} from './model.js';
const $ = (id) => document.getElementById(id);
const refs = {
libelles: $('libelles'),
entete: $('entete'),
couloirs: $('couloirs'),
frise: $('frise'),
etat: $('etat-sauvegarde'),
messageVide: $('message-vide'),
alerte: $('alerte'),
alerteMessage: $('alerte-message'),
};
let planning = planningVide();
let echelle = null;
let selection = null; // { projet, phase }
/**
* Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré.
*
* Elle démarre autour des phases existantes puis s'élargit toute seule quand le
* défilement approche d'un bord, ce qui permet d'aller poser une phase dans un
* futur qu'aucune donnée n'occupe encore.
*/
let fenetre = null;
/** Marge conservée autour des phases : on peut toujours glisser au-delà. */
const MARGE_JOURS = 120;
/** Longueur ajoutée à chaque élargissement, environ six mois. */
const PAS_ELARGISSEMENT = 182;
/** Distance au bord, en pixels, à partir de laquelle on élargit. */
const SEUIL_ELARGISSEMENT = 400;
/**
* Garde-fou : l'élargissement s'arrête à dix ans de part et d'autre des
* données. « Infini » au sens de l'usage — on ne planifie pas au-delà — sans
* laisser un défilement prolongé faire enfler le DOM sans limite.
*/
const PORTEE_MAX_ANNEES = 10;
const stockage = creerStockage(afficherEtat);
const menu = creerMenu($('menu'));
const demanderProjet = creerDialogueProjet(
{
dialogue: $('dialogue-projet'),
titre: $('dialogue-projet-titre'),
champNom: $('dialogue-projet-nom'),
conteneurCouleurs: $('dialogue-projet-couleurs'),
erreur: $('dialogue-projet-erreur'),
valider: $('dialogue-projet-ok'),
},
COULEURS
);
const demanderConfirmation = creerDialogueConfirmation({
dialogue: $('dialogue-confirmation'),
titre: $('dialogue-confirmation-titre'),
message: $('dialogue-confirmation-message'),
valider: $('dialogue-confirmation-ok'),
});
const panneau = creerPanneau(
{
panneau: $('panneau'),
voile: $('voile'),
formulaire: $('formulaire-phase'),
titre: $('panneau-titre'),
erreur: $('erreur-phase'),
champFin: $('champ-fin'),
supprimer: $('supprimer-phase'),
fermer: $('fermer-panneau'),
},
{
surModification(idProjet, idPhase, phase) {
majPlanning(
remplacerProjet(planning, idProjet, (projet) =>
remplacerPhase(projet, idPhase, () => phase)
)
);
},
demanderSuppression(_idProjet, _idPhase, nomPhase) {
return demanderConfirmation({
titreTexte: 'Supprimer la phase',
messageTexte: `« ${nomPhase} » sera définitivement supprimée du planning.`,
libelleValider: 'Supprimer',
danger: true,
});
},
surSuppression(idProjet, idPhase) {
majPlanning(
remplacerProjet(planning, idProjet, (projet) => supprimerPhase(projet, idPhase))
);
},
surFermeture() {
selection = null;
dessiner();
},
}
);
// ---------------------------------------------------------------------------
// État
// ---------------------------------------------------------------------------
/** Unique point d'entrée des modifications : rend puis sauvegarde. */
function majPlanning(suivant) {
planning = suivant;
dessiner();
stockage.sauvegarder(pourEcriture(planning));
}
function dessiner() {
const aDesProjets = planning.projects.length > 0;
refs.frise.hidden = !aDesProjets;
refs.messageVide.hidden = aDesProjets;
if (!aDesProjets) {
echelle = null;
return;
}
elargirPourContenir();
echelle = rendre(refs, planning, { fenetre, selection });
// Le panneau reflète la phase telle qu'elle est après le rendu : un glisser
// sur la frise doit se voir dans les champs.
if (selection) {
const trouve = localiser(selection.projet, selection.phase);
if (trouve) panneau.rafraichir(trouve.projet, trouve.phase);
}
}
function localiser(idProjet, idPhase) {
const projet = planning.projects.find((p) => p.id === idProjet);
if (!projet) return null;
const phase = projet.phases.find((p) => p.id === idPhase);
return phase ? { projet, phase } : null;
}
function afficherEtat(etat, detail) {
const messages = {
inactif: '',
'en-cours': 'Enregistrement…',
enregistre: 'Enregistré',
erreur: detail || 'Erreur denregistrement',
};
refs.etat.textContent = messages[etat] ?? '';
refs.etat.dataset.etat = etat;
refs.etat.title = etat === 'erreur' ? detail || '' : '';
}
function afficherAlerte(message) {
refs.alerteMessage.textContent = message;
refs.alerte.hidden = false;
// L'alerte recouvre déjà la page, mais laisser une frise vide « visible »
// derrière elle brouillerait le diagnostic si le style venait à changer.
refs.frise.hidden = true;
refs.messageVide.hidden = true;
}
// ---------------------------------------------------------------------------
// Fenêtre temporelle et défilement sans fin
// ---------------------------------------------------------------------------
/** Fenêtre de départ : les phases existantes, avec une marge de chaque côté. */
function fenetreInitiale() {
const bornes = bornesPlanning(planning);
if (!bornes) {
const jour = aujourdhui();
return { debut: ajouterJours(jour, -MARGE_JOURS), fin: ajouterJours(jour, MARGE_JOURS * 2) };
}
return {
debut: ajouterJours(bornes.start, -MARGE_JOURS),
fin: ajouterJours(bornes.end, MARGE_JOURS),
};
}
/**
* Élargit la fenêtre si une phase est sortie de son cadre, sans jamais la
* rétrécir : le terrain déjà exploré au défilement doit rester accessible.
*/
function elargirPourContenir() {
if (!fenetre) fenetre = fenetreInitiale();
const bornes = bornesPlanning(planning);
if (!bornes) return;
const voulu = {
debut: ajouterJours(bornes.start, -MARGE_JOURS),
fin: ajouterJours(bornes.end, MARGE_JOURS),
};
if (versUTC(voulu.debut) < versUTC(fenetre.debut)) fenetre.debut = voulu.debut;
if (versUTC(voulu.fin) > versUTC(fenetre.fin)) fenetre.fin = voulu.fin;
}
/** Bornes au-delà desquelles on cesse d'élargir. */
function porteeMaximale() {
const bornes = bornesPlanning(planning) ?? { start: aujourdhui(), end: aujourdhui() };
const jours = 365 * PORTEE_MAX_ANNEES;
return {
min: ajouterJours(bornes.start, -jours),
max: ajouterJours(bornes.end, jours),
};
}
// Vrai pendant qu'on élargit, pour que l'ajustement de `scrollLeft` ne relance
// pas immédiatement ce même gestionnaire.
let elargissementEnCours = false;
/**
* Élargit la fenêtre quand le défilement approche d'un bord, ce qui donne
* l'impression d'une frise sans fin.
*
* Élargir vers la gauche déplace l'origine de l'échelle : tout le contenu se
* décale vers la droite. On compense `scrollLeft` de la même quantité, sinon la
* vue sauterait en arrière à chaque élargissement.
*/
refs.frise.addEventListener('scroll', () => {
if (elargissementEnCours || !echelle || refs.frise.hidden) return;
const zone = refs.frise;
const portee = porteeMaximale();
const resteADroite = zone.scrollWidth - zone.scrollLeft - zone.clientWidth;
if (zone.scrollLeft < SEUIL_ELARGISSEMENT && versUTC(fenetre.debut) > versUTC(portee.min)) {
elargissementEnCours = true;
fenetre.debut = ajouterJours(fenetre.debut, -PAS_ELARGISSEMENT);
dessiner();
zone.scrollLeft += PAS_ELARGISSEMENT * PIXELS_PAR_JOUR;
elargissementEnCours = false;
return;
}
if (resteADroite < SEUIL_ELARGISSEMENT && versUTC(fenetre.fin) < versUTC(portee.max)) {
elargissementEnCours = true;
fenetre.fin = ajouterJours(fenetre.fin, PAS_ELARGISSEMENT);
dessiner();
elargissementEnCours = false;
}
});
// ---------------------------------------------------------------------------
// Interactions de la frise
// ---------------------------------------------------------------------------
installerGlisser(refs.couloirs, {
echelle: () => echelle,
trouverPhase(idProjet, idPhase) {
return localiser(idProjet, idPhase)?.phase ?? null;
},
surDeplacement(idProjet, idPhase, phase) {
// `phase` vaut null quand le geste a été annulé ou n'a rien changé : on
// redessine pour remettre la barre à sa place, sans rien sauvegarder.
if (!phase) {
dessiner();
return;
}
majPlanning(
remplacerProjet(planning, idProjet, (projet) =>
remplacerPhase(projet, idPhase, () => phase)
)
);
},
surClic(idProjet, idPhase) {
ouvrirPhase(idProjet, idPhase);
},
});
function ouvrirPhase(idProjet, idPhase) {
const trouve = localiser(idProjet, idPhase);
if (!trouve) return;
selection = { projet: idProjet, phase: idPhase };
panneau.ouvrir(trouve.projet, trouve.phase);
dessiner();
}
// Clics sur la colonne des libellés : plier, masquer, ouvrir le menu ou la phase.
refs.libelles.addEventListener('click', (evenement) => {
const cible = evenement.target.closest('[data-action]');
if (!cible) {
const ligne = evenement.target.closest('.libelle-phase');
if (ligne) ouvrirPhase(ligne.dataset.projet, ligne.dataset.phase);
return;
}
const idProjet = cible.dataset.projet;
if (cible.dataset.action === 'plier') {
majPlanning(
remplacerProjet(planning, idProjet, (projet) => ({
...projet,
collapsed: !projet.collapsed,
}))
);
} else if (cible.dataset.action === 'masquer') {
majPlanning(
remplacerProjet(planning, idProjet, (projet) => ({ ...projet, hidden: !projet.hidden }))
);
} else if (cible.dataset.action === 'menu') {
ouvrirMenuProjet(idProjet, cible);
}
});
// Clic droit sur un libellé de projet : même menu, ancré sur le nom.
refs.libelles.addEventListener('contextmenu', (evenement) => {
const ligne = evenement.target.closest('.libelle-projet');
if (!ligne) return;
evenement.preventDefault();
const bouton = ligne.querySelector('[data-action="menu"]');
ouvrirMenuProjet(bouton.dataset.projet, bouton);
});
// ---------------------------------------------------------------------------
// Menu d'un projet
// ---------------------------------------------------------------------------
function ouvrirMenuProjet(idProjet, ancre) {
const projet = planning.projects.find((p) => p.id === idProjet);
if (!projet) return;
const aUneReference = projet.phases.some((phase) => phase.baseline);
menu.ouvrir(ancre, [
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet, false) },
{ libelle: 'Ajouter un jalon', action: () => ajouterUnePhase(projet, true) },
{
libelle: projet.collapsed ? 'Déplier le projet' : 'Plier le projet',
separateurAvant: true,
action: () =>
majPlanning(
remplacerProjet(planning, idProjet, (p) => ({ ...p, collapsed: !p.collapsed }))
),
},
{
libelle: projet.hidden ? 'Afficher sur la frise' : 'Masquer de la frise',
action: () =>
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
},
{ libelle: 'Renommer, changer la couleur…', action: () => renommerProjet(projet) },
{
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
separateurAvant: true,
desactive: projet.phases.length === 0,
action: () => figerLaReference(projet, aUneReference),
},
{
libelle: 'Retirer la référence',
desactive: !aUneReference,
action: () => retirerLaReference(projet),
},
{
libelle: 'Supprimer le projet',
separateurAvant: true,
danger: true,
action: () => supprimerLeProjet(projet),
},
]);
}
function ajouterUnePhase(projet, jalon) {
// On enchaîne à la suite de la dernière phase, ce qui est presque toujours ce
// qu'on veut. Plutôt que de demander le nom dans un dialogue, on crée la
// phase tout de suite et on ouvre le panneau de détail avec le nom
// présélectionné : l'utilisateur tape directement par-dessus.
const derniere = projet.phases[projet.phases.length - 1];
const debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
const fin = jalon ? debut : ajouterJours(debut, 20);
const phase = creerPhase(projet, jalon ? 'Nouveau jalon' : 'Nouvelle phase', debut, fin, {
milestone: jalon,
});
majPlanning(
remplacerProjet(planning, projet.id, (p) => ({
...ajouterPhase(p, phase),
collapsed: false, // inutile d'ajouter une phase dans un couloir replié
}))
);
ouvrirPhase(projet.id, phase.id);
// La frise défile librement bien au-delà des phases existantes : sans ce
// recentrage, une phase créée alors qu'on regardait 2029 apparaîtrait hors
// écran, et l'ajout semblerait n'avoir rien fait.
centrerSur(phase.start);
}
async function renommerProjet(projet) {
const reponse = await demanderProjet({
titreTexte: 'Modifier le projet',
libelleValider: 'Enregistrer',
nom: projet.name,
couleur: projet.color,
});
if (!reponse) return;
// L'identifiant ne change pas : il identifie, il ne décrit pas.
majPlanning(
remplacerProjet(planning, projet.id, (p) => ({
...p,
name: reponse.nom,
color: reponse.couleur,
}))
);
}
async function figerLaReference(projet, dejaFigee) {
const ok = await demanderConfirmation({
titreTexte: dejaFigee ? 'Refiger la référence' : 'Figer la référence',
messageTexte: dejaFigee
? `Le planning actuel de « ${projet.name} » deviendra la nouvelle référence. ` +
'La dérive affichée repartira de zéro.'
: `Le planning actuel de « ${projet.name} » sera mémorisé comme référence. ` +
'La frise affichera ensuite son écart avec cette référence.',
libelleValider: dejaFigee ? 'Refiger' : 'Figer',
});
if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => figerReference(p)));
}
async function retirerLaReference(projet) {
const ok = await demanderConfirmation({
titreTexte: 'Retirer la référence',
messageTexte:
`Le planning de référence de « ${projet.name} » sera oublié, ` +
'et la frise cessera dafficher sa dérive.',
libelleValider: 'Retirer',
danger: true,
});
if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => libererReference(p)));
}
async function supprimerLeProjet(projet) {
const nombre = projet.phases.length;
const ok = await demanderConfirmation({
titreTexte: 'Supprimer le projet',
messageTexte:
`« ${projet.name} »` +
(nombre ? ` et ses ${nombre} phase${nombre > 1 ? 's' : ''}` : '') +
' seront définitivement supprimés du planning.',
libelleValider: 'Supprimer',
danger: true,
});
if (!ok) return;
if (selection?.projet === projet.id) panneau.fermer();
majPlanning(supprimerProjet(planning, projet.id));
}
async function nouveauProjet() {
const reponse = await demanderProjet({
titreTexte: 'Nouveau projet',
libelleValider: 'Créer',
couleur: COULEURS[planning.projects.length % COULEURS.length],
});
if (!reponse) return;
const projet = { ...creerProjet(planning, reponse.nom), color: reponse.couleur };
majPlanning({ ...planning, projects: [...planning.projects, projet] });
}
// ---------------------------------------------------------------------------
// Barre d'outils
// ---------------------------------------------------------------------------
$('aller-aujourdhui').addEventListener('click', () => centrerSur(aujourdhui()));
$('nouveau-projet').addEventListener('click', nouveauProjet);
$('premier-projet').addEventListener('click', nouveauProjet);
/**
* Amène une date au centre de la vue, instantanément.
*
* Volontairement pas de défilement fluide : la frise s'élargit au défilement et
* peut atteindre plusieurs dizaines de milliers de pixels. Une animation sur une
* telle distance est lente, et le gestionnaire de défilement l'interromprait en
* élargissant la fenêtre au passage près d'un bord.
*/
function centrerSur(date) {
if (!echelle) return;
// `scrollLeft` porte sur toute la grille, dont la première colonne est celle
// des libellés. Comme elle est `sticky`, elle recouvre en permanence les
// premiers pixels de la vue : le centre utile des couloirs est donc décalé
// d'une demi-largeur de colonne par rapport au centre de la frise.
const largeurLibelles = refs.libelles.offsetWidth;
const centreUtile = (largeurLibelles + refs.frise.clientWidth) / 2;
refs.frise.scrollLeft = Math.max(0, largeurLibelles + echelle.x(date) - centreUtile);
}
// Molette + Maj : défilement horizontal, geste habituel sur une frise.
refs.frise.addEventListener(
'wheel',
(evenement) => {
if (!evenement.shiftKey || evenement.ctrlKey) return;
evenement.preventDefault();
refs.frise.scrollLeft += evenement.deltaY;
},
{ passive: false }
);
// Flèches : ajustement fin de la phase sélectionnée, au jour près.
document.addEventListener('keydown', (evenement) => {
if (!selection) return;
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0;
if (pas === 0) return;
evenement.preventDefault();
const trouve = localiser(selection.projet, selection.phase);
if (!trouve) return;
// Maj + flèche étire la phase au lieu de la déplacer.
const suivante = evenement.shiftKey
? etirerAuClavier(trouve.phase, pas)
: decalerAuClavier(trouve.phase, pas);
majPlanning(
remplacerProjet(planning, selection.projet, (projet) =>
remplacerPhase(projet, selection.phase, () => suivante)
)
);
});
// ---------------------------------------------------------------------------
// Démarrage
// ---------------------------------------------------------------------------
async function demarrer() {
try {
const brut = await stockage.charger();
planning = validerPlanning(brut);
} catch (err) {
afficherAlerte(
`${err.message}\n\n` +
'Le planning na pas été chargé, et rien ne sera enregistré tant que le ' +
'problème persiste — les données existantes sont donc intactes.'
);
return;
}
dessiner();
viderAvantFermeture(stockage);
if (planning.projects.length) {
// Sans ce report, la largeur de la zone n'est pas encore connue et le
// centrage tombe à côté.
requestAnimationFrame(() => centrerSur(aujourdhui()));
}
console.info(
`Frise chargée : ${planning.projects.length} projet(s), ` +
`${planning.projects.reduce((n, p) => n + p.phases.length, 0)} phase(s). ` +
`Aujourd'hui : ${formaterDateLongue(aujourdhui())}.`
);
}
demarrer();