Files
gestion_projets/js/app.js
Bertrand Benjamin a0b8ef9dcf Rendre les notes d'une phase consultables depuis la vue par mois
Elles n'étaient accessibles que dans l'infobulle de la ligne, où l'on ne pense
pas à aller les chercher et d'où l'on ne peut rien copier. Or une note est
précisément ce qu'on va lire quand on fait le point — « pourquoi cette phase
est-elle bloquée ».

Un triangle en tête de ligne les déplie en dessous, sur les phases qui en
portent. C'est celui qui plie un projet sur la frise, au même endroit et avec
la même rotation : ce qui s'apprend d'un côté vaut de l'autre. La gouttière se
lit ainsi d'un coup d'œil comme la liste de ce qui a quelque chose à dire.

Toute la ligne déplie, pas seulement le triangle. Une première version faisait
de l'intitulé lui-même le bouton, avec un chevron à sa suite : la cible était
trop petite pour un geste aussi banal, et rien ne justifiait cette parcimonie
dans une vue où le clic n'a aucun autre effet. Le triangle reste comme
indicateur plutôt que comme cible, le curseur et le fond au survol disent que
la ligne réagit. Les lignes sans notes, elles, ne réagissent pas : promettre un
geste qui ne fait rien est pire que ne rien promettre.

Il n'en reste pas moins un vrai button : la ligne n'est pas focusable, c'est
donc le seul chemin clavier vers les notes.

Les notes restent repliées par défaut pour deux raisons. La densité d'abord :
trois lignes sous chaque intitulé étalent un mois sur deux écrans et ruinent la
lecture en diagonale. Et parce qu'une phase sur trois seulement en porte, les
afficher d'office donnerait des lignes de hauteurs inégales, ce qui casse
l'alignement des colonnes sur lequel repose tout le balayage du regard.

L'état déplié vit dans le DOM et nulle part ailleurs : il ne survit pas à un
changement de filtre, qui reconstruit la liste. Le remonter dans l'état de
l'application achèterait cher une continuité que personne ne réclame — quand la
liste change, on la relit.

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

1015 lines
36 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'),
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 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();
{
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 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();