Files
gestion_projets/js/app.js
Bertrand Benjamin 7ff1329a86 Élargir le panneau en glissant son bord
Trois cent quarante pixels suffisaient à des champs de date ; ils ne suffisent
pas à une liste imbriquée, où chaque niveau mange une indentation.

Le panneau reçoit donc la même poignée que la colonne des libellés (décision
18) : même geste, même double-clic de remise à zéro, même mémorisation dans
`localStorage`. Le code de glisser, jusque-là écrit pour la seule colonne, est
paramétré par le sens et la largeur qu'il pilote plutôt que dupliqué.

Une largeur unique pour les deux panneaux : ce qu'on élargit est le volet, pas
l'un ou l'autre de ses contenus. La largeur voulue et la largeur appliquée sont
deux variables distinctes, faute de quoi réduire un instant la fenêtre ferait
perdre le réglage.

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

1118 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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'),
notes: $('notes-phase'),
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'),
notes: $('notes-projet'),
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 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;
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();
// ---------------------------------------------------------------------------
// Largeur du panneau
// ---------------------------------------------------------------------------
/**
* Le panneau porte désormais les notes, qui prennent toute sa hauteur restante
* et se rédigent en markdown. Trois cent quarante pixels suffisaient à des
* champs de date et à quatre lignes de texte ; ils ne suffisent plus à une liste
* à puces imbriquée, où chaque niveau mange une indentation.
*
* D'où la même poignée qu'à la colonne des libellés (décision 18) : même geste,
* même double-clic de remise à zéro, même mémorisation dans `localStorage`. Une
* largeur unique pour les deux panneaux, parce que ce qu'on élargit est le
* volet — l'espace où l'on écrit —, pas l'un ou l'autre de ses contenus.
*/
// `defaut` doit rester en accord avec `--largeur-panneau` dans style.css.
const LARGEUR_PANNEAU = { min: 300, max: 900, defaut: 380 };
const CLE_LARGEUR_PANNEAU = 'frise.largeurPanneau';
/**
* Deux largeurs plutôt qu'une : celle que l'utilisateur a demandée, et celle
* qui tient réellement dans la fenêtre.
*
* Le panneau ne peut pas couvrir tout l'écran — il laisse la frise vivante
* derrière lui (décision 17) —, et une largeur choisie sur un 27 pouces doit
* donc être rabotée sur un portable. Mais seulement rabotée : sans cette
* distinction, réduire un instant la fenêtre écraserait la largeur mémorisée
* par sa valeur contrainte, et l'agrandir de nouveau ne la rendrait pas.
*/
let largeurPanneauVoulue = LARGEUR_PANNEAU.defaut;
let largeurPanneau = LARGEUR_PANNEAU.defaut;
function appliquerLargeurPanneau(largeur, { memoriser = true } = {}) {
if (memoriser) {
largeurPanneauVoulue = Math.min(
LARGEUR_PANNEAU.max,
Math.max(LARGEUR_PANNEAU.min, Math.round(largeur))
);
try {
localStorage.setItem(CLE_LARGEUR_PANNEAU, String(largeurPanneauVoulue));
} catch {
// Mode privé, quota saturé : la largeur vaut pour la séance, sans plus.
}
}
const plafond = Math.max(LARGEUR_PANNEAU.min, window.innerWidth - 80);
largeurPanneau = Math.min(largeurPanneauVoulue, plafond);
document.documentElement.style.setProperty('--largeur-panneau', `${largeurPanneau}px`);
return largeurPanneau;
}
function restaurerLargeurPanneau() {
let memorisee = null;
try {
memorisee = localStorage.getItem(CLE_LARGEUR_PANNEAU);
} catch {
// Lecture refusée : on garde la valeur de la feuille de style.
}
const largeur = Number(memorisee);
if (Number.isFinite(largeur) && largeur > 0) appliquerLargeurPanneau(largeur);
}
restaurerLargeurPanneau();
/**
* Installe une poignée de redimensionnement.
*
* Deux réglages emploient le même geste, à deux détails près : le sens dans
* lequel tirer élargit, et l'endroit où l'on range le résultat. Le reste — la
* capture du pointeur, la classe qui fige le curseur pendant le glisser, le
* double-clic qui remet à la valeur d'usine, les flèches au clavier — est
* strictement le même, et n'a pas à être écrit deux fois.
*
* @param {object} options
* - sens : +1 si tirer vers la droite élargit (la colonne, ancrée à gauche),
* 1 sinon (le panneau, ancré à droite).
*/
function installerPoignee({ poignees, sens, largeur, appliquer, defaut }) {
for (const poignee of poignees) {
let depart = null;
poignee.addEventListener('pointerdown', (evenement) => {
if (evenement.button !== 0) return;
depart = { x: evenement.clientX, largeur: largeur() };
poignee.setPointerCapture(evenement.pointerId);
poignee.dataset.glisse = 'true';
document.body.classList.add('glisse-en-cours');
evenement.preventDefault();
});
poignee.addEventListener('pointermove', (evenement) => {
if (!depart) return;
appliquer(depart.largeur + sens * (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', () => appliquer(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();
appliquer(largeur() + sens * pas);
});
}
}
installerPoignee({
poignees: [$('poignee-colonne')],
sens: 1,
largeur: () => largeurLibelles,
appliquer: appliquerLargeurLibelles,
defaut: LARGEUR_LIBELLES.defaut,
});
installerPoignee({
poignees: document.querySelectorAll('[data-poignee-panneau]'),
sens: -1,
largeur: () => largeurPanneau,
appliquer: appliquerLargeurPanneau,
defaut: LARGEUR_PANNEAU.defaut,
});
// Rétrécir la fenêtre sous la largeur du panneau le laisserait déborder : on
// rejoue le plafond sans toucher à la largeur mémorisée, qu'on retrouvera
// intacte en rouvrant la fenêtre en grand.
window.addEventListener('resize', () =>
appliquerLargeurPanneau(largeurPanneauVoulue, { memoriser: false })
);
// ---------------------------------------------------------------------------
// 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 dafficher 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 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();