/** * 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, cleTag, creerPhase, creerProjet, figerReference, libererReference, normaliserTags, planningVide, pourEcriture, projetFiltre, remplacerPhase, remplacerProjet, supprimerPhase, supprimerProjet, teinteTag, tousLesTags, 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'), messageFiltre: $('message-filtre'), filtres: $('filtres'), filtresListe: $('filtres-liste'), effacerFiltres: $('effacer-filtres'), alerte: $('alerte'), alerteMessage: $('alerte-message'), }; let planning = planningVide(); let echelle = null; let selection = null; // { projet, phase } /** * Tags sélectionnés dans la barre de filtres. Comme la fenêtre temporelle, * c'est un état de **vue** : jamais enregistré, jamais partagé. * * Un filtre qui survivrait au rechargement rouvrirait le planning amputé sans * qu'on se souvienne pourquoi. `hidden`, lui, est bien enregistré — c'est une * décision durable sur un projet, pas un coup d'œil. * * La sélection est conjonctive : un projet doit porter *tous* les tags cochés. */ let tagsActifs = []; /** * 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'), champTags: $('dialogue-projet-tags'), suggestionsTags: $('dialogue-projet-suggestions'), blocSuggestions: $('dialogue-projet-suggestions-bloc'), erreur: $('dialogue-projet-erreur'), valider: $('dialogue-projet-ok'), }, COULEURS, { normaliserTags, cleTag, teinteTag } ); 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; // Retirer son dernier tag à un projet peut vider le filtre de sa substance : // sans cet élagage, un tag que plus personne ne porte masquerait tout. tagsActifs = tagsActifs.filter((tag) => planning.projects.some((projet) => projet.tags.some((pose) => cleTag(pose) === cleTag(tag))) ); dessiner(); stockage.sauvegarder(pourEcriture(planning)); } function dessiner() { const aDesProjets = planning.projects.length > 0; const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs)); refs.messageVide.hidden = aDesProjets; // Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce // message, la frise vide laisserait croire que les projets ont disparu. refs.messageFiltre.hidden = !aDesProjets || retenus.length > 0; refs.frise.hidden = !aDesProjets || retenus.length === 0; peindreFiltres(); if (!aDesProjets) { echelle = null; return; } elargirPourContenir(); echelle = rendre(refs, planning, { fenetre, selection, tagsActifs }); // 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 d’enregistrement', }; 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; refs.messageFiltre.hidden = true; refs.filtres.hidden = true; } // --------------------------------------------------------------------------- // Filtre par tags // --------------------------------------------------------------------------- /** * Reconstruit la barre de filtres depuis les tags réellement employés. * * La liste n'est pas un référentiel à tenir à jour : elle se déduit des projets, * si bien qu'un tag apparaît dès qu'on le pose et disparaît dès qu'il n'orne * plus rien. */ function peindreFiltres() { const tags = tousLesTags(planning); refs.filtres.hidden = tags.length === 0; refs.effacerFiltres.hidden = tagsActifs.length === 0; const clesActives = new Set(tagsActifs.map(cleTag)); refs.filtresListe.replaceChildren( ...tags.map((tag) => { const bouton = document.createElement('button'); bouton.type = 'button'; bouton.className = 'tag'; bouton.style.setProperty('--teinte-tag', String(teinteTag(tag))); bouton.textContent = tag; const actif = clesActives.has(cleTag(tag)); bouton.setAttribute('aria-pressed', String(actif)); bouton.title = actif ? `Retirer « ${tag} » du filtre` : `N'afficher que les projets « ${tag} »`; bouton.addEventListener('click', () => basculerTag(tag)); return bouton; }) ); } /** Ajoute le tag au filtre, ou l'en retire s'il y était déjà. */ function basculerTag(tag) { const cle = cleTag(tag); const restants = tagsActifs.filter((actif) => cleTag(actif) !== cle); tagsActifs = restants.length === tagsActifs.length ? normaliserTags([...tagsActifs, tag]) : restants; dessiner(); } function effacerFiltres() { tagsActifs = []; dessiner(); } refs.effacerFiltres.addEventListener('click', effacerFiltres); $('effacer-filtres-vide').addEventListener('click', effacerFiltres); // --------------------------------------------------------------------------- // 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; } if (cible.dataset.action === 'tag') { basculerTag(cible.dataset.tag); 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, tags: projet.tags, tagsConnus: tousLesTags(planning), }); 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, tags: reponse.tags, })) ); } 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 d’afficher 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], // Un filtre actif préremplit les tags : quand on crée un projet en ne // regardant que les projets « client », c'en est presque toujours un — et // sans cela le nouveau venu naîtrait invisible, hors du filtre courant. tags: tagsActifs, tagsConnus: tousLesTags(planning), }); if (!reponse) return; const projet = { ...creerProjet(planning, reponse.nom, reponse.tags), 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 n’a 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();