/** * 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 { creerPanneauProjet } from './projet.js'; import { creerDialogueConfirmation, creerMenu } from './menu.js'; import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js'; import { PIXELS_PAR_JOUR, rendre } from './timeline.js'; import { rendreMois, revelerMoisCourant } from './mois.js'; import { creerStockage, viderAvantFermeture } from './storage.js'; import { COULEURS, ajouterJours, ajouterPhase, aujourdhui, bornesPlanning, cleTag, creerPhase, creerProjet, figerReference, formaterDateLongue, 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'), vueMois: $('vue-mois'), onglets: $('onglets'), etat: $('etat-sauvegarde'), messageVide: $('message-vide'), messageFiltre: $('message-filtre'), filtres: $('filtres'), filtresListe: $('filtres-liste'), effacerFiltres: $('effacer-filtres'), alerte: $('alerte'), alerteMessage: $('alerte-message'), panneau: $('panneau'), }; let planning = planningVide(); let echelle = null; let selection = null; // { projet, phase } /** * Vue affichée : 'frise' ou 'mois'. État de vue, comme le filtre et la fenêtre * temporelle — jamais enregistré. * * Contrairement à la largeur de la colonne, qui est un réglage d'écran, la vue * choisie est un geste de consultation : on passe par la liste pour faire le * point, on revient à la frise pour travailler. La retrouver au rechargement * imposerait à la séance suivante le regard de la précédente ; l'outil s'ouvre * donc toujours sur la frise, qui est celle où l'on modifie. */ let vue = 'frise'; /** * Défilement horizontal de la frise au moment où on l'a quittée. * * Masquer un élément lui fait perdre son `scrollLeft` : sans cette mémoire, un * aller-retour par la liste ramènerait la frise à son extrême gauche, soit des * années avant ce qu'on regardait. */ let defilementFrise = 0; /** * 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 demanderConfirmation = creerDialogueConfirmation({ dialogue: $('dialogue-confirmation'), titre: $('dialogue-confirmation-titre'), message: $('dialogue-confirmation-message'), valider: $('dialogue-confirmation-ok'), }); const panneau = creerPanneau( { panneau: $('panneau'), 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(); }, } ); const panneauProjet = creerPanneauProjet( { panneau: $('panneau-projet'), formulaire: $('formulaire-projet'), titre: $('panneau-projet-titre'), erreur: $('erreur-projet'), conteneurCouleurs: $('panneau-projet-couleurs'), suggestions: $('panneau-projet-suggestions'), blocSuggestions: $('panneau-projet-suggestions-bloc'), supprimer: $('supprimer-projet'), fermer: $('fermer-panneau-projet'), }, { couleurs: COULEURS, normaliserTags, cleTag, teinteTag }, { tagsConnus: () => tousLesTags(planning), surModification(idProjet, projet) { // L'identifiant ne change pas : il identifie, il ne décrit pas. majPlanning(remplacerProjet(planning, idProjet, () => projet)); }, demanderSuppression(_idProjet, projet) { return confirmerSuppressionProjet(projet); }, surSuppression(idProjet) { if (selection?.projet === idProjet) panneau.fermer(); majPlanning(supprimerProjet(planning, idProjet)); }, surFermeture() { 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; // Les deux vues partagent le même sort face aux messages : ce qui n'a aucun // projet à montrer n'en a pas davantage à lister. const aQuelqueChoseAMontrer = aDesProjets && retenus.length > 0; refs.frise.hidden = !aQuelqueChoseAMontrer || vue !== 'frise'; refs.vueMois.hidden = !aQuelqueChoseAMontrer || vue !== 'mois'; peindreFiltres(); peindreOnglets(); if (!aDesProjets) { echelle = null; return; } if (vue === 'mois') { rendreMois(refs.vueMois, planning, { tagsActifs }); 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); } const idProjetOuvert = panneauProjet.projetOuvert(); if (idProjetOuvert) { const projet = planning.projects.find((p) => p.id === idProjetOuvert); if (projet) panneauProjet.rafraichir(projet); } } 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); // --------------------------------------------------------------------------- // Bascule entre la frise et la vue par mois // --------------------------------------------------------------------------- function peindreOnglets() { for (const onglet of refs.onglets.querySelectorAll('[data-vue]')) { onglet.setAttribute('aria-pressed', String(onglet.dataset.vue === vue)); } } function basculerVue(suivante) { if (suivante === vue || !suivante) return; if (vue === 'frise') defilementFrise = refs.frise.scrollLeft; vue = suivante; // Les deux panneaux modifient une phase ou un projet ; la vue par mois se // lit, elle ne s'édite pas. Les laisser ouverts par-dessus une liste où plus // rien ne désigne leur objet en ferait des fenêtres orphelines. panneau.fermer(); panneauProjet.fermer(); dessiner(); // Les deux restaurations sont synchrones : l'élément vient d'être démasqué, // et lire ou écrire `scroll*` force le calcul de mise en page dont elles ont // besoin. if (vue === 'frise') refs.frise.scrollLeft = defilementFrise; else revelerMoisCourant(refs.vueMois); } refs.onglets.addEventListener('click', (evenement) => { const onglet = evenement.target.closest('[data-vue]'); if (onglet) basculerVue(onglet.dataset.vue); }); // La vue par mois n'a ni barres ni poignées. Ses deux seuls gestes ne touchent // pas au planning : filtrer sur un tag, et déplier les notes d'une phase. refs.vueMois.addEventListener('click', (evenement) => { const cible = evenement.target.closest('[data-action]'); if (!cible) return; if (cible.dataset.action === 'tag') { basculerTag(cible.dataset.tag); return; } if (cible.dataset.action === 'notes') basculerNotes(cible.closest('.mois-entree')); }); // Toute la ligne déplie, pas seulement son triangle : viser une cible de dix // pixels pour lire un commentaire est une exigence inutile, et il n'y a rien // d'autre à faire d'un clic sur une ligne d'une vue qui ne s'édite pas. Le // gestionnaire ci-dessus s'occupe des tags, qui eux ont leur propre effet. refs.vueMois.addEventListener('click', (evenement) => { if (evenement.target.closest('[data-action]')) return; const ligne = evenement.target.closest('.mois-entree--commentee'); if (ligne) basculerNotes(ligne); }); /** * Ouvre ou referme les notes d'une ligne. * * La bascule est appliquée directement au DOM, sans repasser par `dessiner` : * ce qui est déplié relève de l'écran, pas du planning. Un rendu complet * replierait d'ailleurs tout le reste au passage. */ function basculerNotes(ligne) { const bouton = ligne.querySelector('.mois-entree__plier'); const deplie = bouton.getAttribute('aria-expanded') !== 'true'; bouton.setAttribute('aria-expanded', String(deplie)); bouton.title = deplie ? 'Masquer les notes' : 'Afficher les notes'; ligne.querySelector('.mois-entree__notes').hidden = !deplie; } // --------------------------------------------------------------------------- // Largeur de la colonne des libellés // --------------------------------------------------------------------------- /** * La colonne héberge le nom du projet, ses tags, sa dérive et trois commandes. * Aucune largeur fixe ne convient à la fois à « Site web » et à « Refonte du * portail fournisseurs — lot 2 » : c'est donc à l'utilisateur de trancher. * * Contrairement à la fenêtre temporelle et au filtre par tags, cette largeur * **est** conservée — dans `localStorage`, pas dans le planning. C'est un * réglage d'affichage propre à un écran, non une donnée à synchroniser entre * machines : le stocker dans le fichier imposerait à un portable la largeur * choisie sur un 27 pouces. Et sans persistance, une poignée qu'il faut * réajuster à chaque ouverture ne sert à rien. */ // `defaut` doit rester en accord avec `--largeur-libelles` dans style.css. const LARGEUR_LIBELLES = { min: 150, max: 560, defaut: 300 }; const CLE_LARGEUR_LIBELLES = 'frise.largeurLibelles'; /** * Largeur courante, tenue ici plutôt que lue sur le DOM : `offsetWidth` vaut * zéro tant que la frise est masquée — planning vide, ou filtre qui ne retient * rien —, et le premier glisser repartirait alors de zéro. */ let largeurLibelles = LARGEUR_LIBELLES.defaut; function appliquerLargeurLibelles(largeur) { const borne = Math.min(LARGEUR_LIBELLES.max, Math.max(LARGEUR_LIBELLES.min, Math.round(largeur))); largeurLibelles = borne; document.documentElement.style.setProperty('--largeur-libelles', `${borne}px`); try { localStorage.setItem(CLE_LARGEUR_LIBELLES, String(borne)); } catch { // Mode privé, quota saturé : la largeur vaut pour la session, sans plus. } return borne; } function restaurerLargeurLibelles() { let memorisee = null; try { memorisee = localStorage.getItem(CLE_LARGEUR_LIBELLES); } catch { // Lecture refusée : on garde la valeur de la feuille de style. } const largeur = Number(memorisee); if (Number.isFinite(largeur) && largeur > 0) appliquerLargeurLibelles(largeur); } restaurerLargeurLibelles(); { const poignee = $('poignee-colonne'); let depart = null; poignee.addEventListener('pointerdown', (evenement) => { if (evenement.button !== 0) return; depart = { x: evenement.clientX, largeur: largeurLibelles }; poignee.setPointerCapture(evenement.pointerId); poignee.dataset.glisse = 'true'; document.body.classList.add('glisse-en-cours'); evenement.preventDefault(); }); poignee.addEventListener('pointermove', (evenement) => { if (!depart) return; appliquerLargeurLibelles(depart.largeur + (evenement.clientX - depart.x)); }); const terminer = (evenement) => { if (!depart) return; depart = null; poignee.releasePointerCapture?.(evenement.pointerId); delete poignee.dataset.glisse; document.body.classList.remove('glisse-en-cours'); }; poignee.addEventListener('pointerup', terminer); poignee.addEventListener('pointercancel', terminer); poignee.addEventListener('dblclick', () => appliquerLargeurLibelles(LARGEUR_LIBELLES.defaut)); // Les flèches ajustent aussi la largeur quand la poignée a le focus. // `stopPropagation` est indispensable : sans lui, le gestionnaire posé plus // bas sur `document` décalerait en même temps la phase sélectionnée. poignee.addEventListener('keydown', (evenement) => { const pas = evenement.key === 'ArrowLeft' ? -10 : evenement.key === 'ArrowRight' ? 10 : 0; if (pas === 0) return; evenement.preventDefault(); evenement.stopPropagation(); appliquerLargeurLibelles(largeurLibelles + pas); }); } // --------------------------------------------------------------------------- // 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); }, }); /** * @param {object} options * - nomAEcraser : transmis au panneau, pour une phase fraîchement créée. * - reveler : recentre la frise si la barre n'est pas dans le champ de * vision. Utile depuis la colonne des libellés, où l'on peut cliquer une * phase dont la barre est à des mois de la vue courante — sans cela, le * panneau s'ouvre sur une phase qu'on ne voit pas, et rien n'indique de * quel côté elle se trouve. */ function ouvrirPhase(idProjet, idPhase, { reveler = false, ...options } = {}) { const trouve = localiser(idProjet, idPhase); if (!trouve) return; // Les deux panneaux occupent le même bord de l'écran : ouvrir l'un ferme // l'autre. panneauProjet.fermer(); selection = { projet: idProjet, phase: idPhase }; panneau.ouvrir(trouve.projet, trouve.phase, options); dessiner(); // Après l'ouverture du panneau : il recouvre le bord droit de la frise, et // `phaseVisible` doit en tenir compte. if (reveler && !phaseVisible(trouve.phase)) centrerSur(trouve.phase.start); } /** * La barre de la phase est-elle dans la portion réellement visible de la frise ? * * Deux occlusions à retrancher : la colonne des libellés, `sticky` à gauche, et * le panneau de détail quand il est ouvert, posé sur le bord droit. */ function phaseVisible(phase) { if (!echelle || refs.frise.hidden) return true; const gauche = refs.frise.scrollLeft + largeurLibelles; const droite = refs.frise.scrollLeft + refs.frise.clientWidth - (refs.panneau.hidden ? 0 : refs.panneau.offsetWidth); const debut = largeurLibelles + echelle.x(phase.start); const fin = largeurLibelles + echelle.x(phase.end) + PIXELS_PAR_JOUR; return fin > gauche && debut < droite; } // 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, { reveler: true }); return; } if (cible.dataset.action === 'tag') { basculerTag(cible.dataset.tag); return; } const idProjet = cible.dataset.projet; if (cible.dataset.action === 'parametres') { const projet = planning.projects.find((p) => p.id === idProjet); if (projet) ouvrirProjet(projet); return; } if (cible.dataset.action === 'ajouter') { const projet = planning.projects.find((p) => p.id === idProjet); if (projet) ajouterUnePhase(projet); return; } 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) }, { 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: () => ouvrirProjet(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), }, ]); } /** * Ajoute une phase et ouvre son panneau. * * Il n'y a **pas** de commande distincte pour créer un jalon : un jalon n'est * pas un autre objet, c'est une phase dont la fin rejoint le début — une case à * cocher du panneau, au même titre que son statut. Proposer deux entrées * revenait à faire choisir avant d'avoir saisi quoi que ce soit, alors que la * bascule reste possible à tout moment ensuite. */ function ajouterUnePhase(projet) { // 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 phase = creerPhase(projet, 'Nouvelle phase', debut, ajouterJours(debut, 20)); 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, { nomAEcraser: true }); // 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); } /** Ouvre le panneau des paramètres d'un projet : nom, couleur, tags. */ function ouvrirProjet(projet, options = {}) { panneau.fermer(); panneauProjet.ouvrir(projet, options); dessiner(); } /** * Éléments dont le propre gestionnaire ouvre ou rouvre un panneau. Un clic * dessus ne doit surtout pas passer par la fermeture générale ci-dessous : elle * redessine la frise, ce qui détacherait du DOM l'élément visé avant que son * gestionnaire — ou le glisser qui commence peut-être — n'ait fini son travail. */ const CIBLES_OUVRANTES = '[data-role="barre"], [data-role="poignee"], .libelle-phase, [data-action]'; /** Surfaces où un clic n'est pas un « ailleurs » : on y reste au travail. */ const CIBLES_PROTEGEES = '.panneau, .menu, dialog, .poignee-colonne'; /** * Un clic dans le vide referme le panneau ouvert. * * C'est ce que remplace le voile supprimé en décision 17 : celui-ci fermait au * clic à côté, mais au prix d'une frise inerte. En filtrant les cibles plutôt * qu'en interposant un calque, on garde les deux — la frise reste manipulable, * et cliquer à côté ferme quand même. */ document.addEventListener('pointerdown', (evenement) => { if (evenement.button !== 0) return; if (evenement.target.closest(CIBLES_PROTEGEES)) return; if (evenement.target.closest(CIBLES_OUVRANTES)) return; panneau.fermer(); panneauProjet.fermer(); }); 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))); } /** Confirmation partagée par le menu du projet et son panneau. */ function confirmerSuppressionProjet(projet) { const nombre = projet.phases.length; return 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, }); } async function supprimerLeProjet(projet) { if (!(await confirmerSuppressionProjet(projet))) return; if (selection?.projet === projet.id) panneau.fermer(); if (panneauProjet.projetOuvert() === projet.id) panneauProjet.fermer(); majPlanning(supprimerProjet(planning, projet.id)); } /** * Crée un projet et ouvre ses paramètres, sans passer par un formulaire à * valider. * * Même parti pris que pour l'ajout d'une phase (docs/decisions.md, section 13) : * le projet est créé tout de suite avec un nom provisoire, et le panneau s'ouvre * avec ce nom présélectionné — on tape simplement par-dessus. Un dialogue de * plus n'apportait rien, et il présentait les mêmes champs sous une autre forme * que celle qui sert ensuite à les modifier. */ function nouveauProjet() { // Un projet naît sans phase : il n'apparaîtrait nulle part dans une liste qui // ne montre que des tâches et des jalons, et le bouton semblerait sans effet. // On revient donc à la frise, où le nouveau couloir se voit — et où l'on // pourra lui ajouter ses phases. basculerVue('frise'); // 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. const projet = creerProjet(planning, 'Nouveau projet', tagsActifs); majPlanning({ ...planning, projects: [...planning.projects, projet] }); ouvrirProjet(projet, { nomAEcraser: true }); } // --------------------------------------------------------------------------- // Barre d'outils // --------------------------------------------------------------------------- // « Aujourd'hui » veut dire la même chose dans les deux vues — ramène-moi à la // date du jour — même si le geste diffère : un défilement horizontal sur la // frise, vertical dans la liste. $('aller-aujourdhui').addEventListener('click', () => { if (vue === 'mois') revelerMoisCourant(refs.vueMois); else 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 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();