Files
gestion_projets/js/app.js
Bertrand Benjamin eaca2f7813 Revoir l'ergonomie des panneaux et de la colonne des projets
Les panneaux d'édition étaient modaux et la colonne de gauche tronquait
tout ce qu'elle portait. Sept corrections, mesurées sur data/exemple.json.

Panneaux
- Le voile qui recouvrait la page disparaît : la frise reste manipulable
  pendant l'édition, et cliquer une autre barre y bascule la sélection.
  Il rendait aussi le raccourci flèches inatteignable, le focus partant
  dans le champ « Nom » sans qu'on puisse cliquer ailleurs pour en sortir.
- Un clic dans une zone inerte referme le panneau : ce que rendait le
  voile, sans le calque qui figeait la frise.
- Les paramètres d'un projet quittent le <dialog> modal pour le même
  panneau qu'une phase, avec la même édition au fil de la saisie.
  « Nouveau projet » crée donc le projet aussitôt, comme le fait déjà
  l'ajout d'une phase : plus aucune création ne passe par un formulaire.

Colonne des projets
- Le nom ne disposait que de 56 px sur 250, et « client » s'affichait
  « c. ». Les commandes passent en surimpression au survol, les tags
  cèdent en bloc derrière un compteur, et le nom cède en dernier.
- La colonne se redimensionne, largeur conservée dans localStorage.
- L'œil disparaît : il faisait doublon avec l'entrée « Masquer » du menu.
  Un « + » le remplace, l'ajout d'une phase étant l'action la plus
  fréquente, et le nom du projet devient cliquable — il portait déjà un
  curseur « main » sans aucune action derrière.
- Plus de commande « Ajouter un jalon » : un jalon est une phase dont on
  coche la case, pas un autre objet.

Frise
- Le nom d'une phase reste accroché au bord visible quand sa barre
  s'enfonce sous la colonne.
- Cliquer un nom de phase hors écran ramène la frise sur elle.
- La barre cumulative d'un projet s'affiche aussi quand il est déplié,
  amincie pour ne pas se faire passer pour une barre de phase.

Arbitrages consignés dans docs/decisions.md, sections 8 et 17 à 21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:58:47 +02:00

892 lines
31 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, 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'),
panneau: $('panneau'),
};
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 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;
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);
}
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);
// ---------------------------------------------------------------------------
// 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 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 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
// ---------------------------------------------------------------------------
$('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 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();