Files
gestion_projets/js/app.js
Bertrand Benjamin 4a513f9a59 Repousser un projet en bloc, sauf ce dont on ne décide pas
Glisser la barre résumé décale toutes les phases du même nombre de jours,
écarts conservés : un projet engagé glisse souvent, et le faire phase par
phase les déformait. Restent en place ce qui est terminé — on ne réécrit
pas le passé — et les phases à date imposée, nouveau booléen `fixed` pour
ce qui est délégué ou tenu du dehors.

D'où le format en version 6 : sans l'incrément, un binaire antérieur
effacerait ces dates imposées à la première sauvegarde.

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

1778 lines
65 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 { installerClavier } from './clavier.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,
ETATS,
LIBELLES_ETAT,
STATUTS,
ajouterJours,
ajouterPhase,
ancreDecalage,
aujourdhui,
bornesPlanning,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
decalerProjet,
ecarterProjet,
etatProjet,
figerReference,
formaterDateLongue,
libererReference,
modifierPhase,
normaliserTags,
planningVide,
pourEcriture,
premierJourDuMois,
projetFiltre,
projetFiltreEtat,
reanimerProjet,
remplacerPhase,
remplacerProjet,
rouvrirProjet,
supprimerPhase,
supprimerProjet,
terminerProjet,
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'),
etats: $('etats'),
etatsListe: $('etats-liste'),
alerte: $('alerte'),
alerteMessage: $('alerte-message'),
panneau: $('panneau'),
};
let planning = planningVide();
let echelle = null;
/**
* Curseur clavier : la ligne de la frise sur laquelle portent les raccourcis.
*
* `{ projet, phase }`, où `phase` vaut `null` quand le curseur est posé sur la
* ligne d'un projet — position à part entière, d'où l'on plie, on renomme et on
* ajoute une phase.
*
* Il ne se confond plus avec « le panneau est ouvert sur cette phase », comme
* c'était le cas de l'ancienne `selection` : un curseur qui s'effaçait à la
* fermeture du panneau ne pouvait pas servir de point de départ aux
* déplacements. Il survit donc à `Échap`, et c'est le panneau qui le suit.
*/
let curseur = null;
/**
* 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 = [];
/**
* États affichés. État de vue lui aussi, jamais enregistré — un réglage qui
* survivrait au rechargement rouvrirait le planning amputé sans qu'on se
* souvienne pourquoi.
*
* La sélection est **disjonctive**, à l'inverse de celle des tags : un projet
* n'a qu'un état, cocher en élargit donc toujours. Et le défaut n'est pas la
* liste vide mais les trois états vivants : le cimetière ne s'ouvre que si on le
* demande, sans quoi il encombrerait la colonne dès la première année.
*/
let etatsActifs = ['considered', 'engaged', 'completed'];
/**
* 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))
);
},
// Le curseur n'est pas effacé : `Échap` referme le volet et laisse la
// frappe suivante repartir de la phase qu'on regardait.
surFermeture() {
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'),
etat: $('panneau-projet-etat'),
blocHorizon: $('panneau-projet-horizon'),
crans: $('panneau-projet-crans'),
aideHorizon: $('panneau-projet-horizon-aide'),
terminer: $('terminer-projet'),
ecarter: $('ecarter-projet'),
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);
},
demanderCloture(projet) {
return confirmerCloture(projet);
},
surSuppression(idProjet) {
if (curseur?.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() {
// Avant tout rendu : le curseur peut désigner une ligne que le changement
// vient d'emporter — phase supprimée, projet replié, filtre resserré.
normaliserCurseur();
const aDesProjets = planning.projects.length > 0;
const retenus = projetsRetenus();
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();
peindreEtats();
peindreOnglets();
if (!aDesProjets) {
echelle = null;
return;
}
if (vue === 'mois') {
rendreMois(refs.vueMois, planning, { tagsActifs, etatsActifs });
return;
}
elargirPourContenir();
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs, etatsActifs });
// 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 (curseur?.phase) {
const trouve = localiser(curseur.projet, curseur.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);
}
}
/**
* Projets que la frise montre : ceux que les deux filtres laissent passer.
*
* Centralisé parce que quatre parcours doivent en dire autant — le rendu, mais
* aussi les déplacements du curseur clavier, qui sauteraient sur des lignes
* invisibles si leur énumération divergeait de celle de `construireLibelles`
* (timeline.js). L'œil (`hidden`) n'entre pas ici : un projet masqué garde sa
* ligne de libellé, donc sa place dans le curseur.
*/
function projetsRetenus() {
return planning.projects.filter(
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
);
}
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();
}
// ---------------------------------------------------------------------------
// Filtre par état
// ---------------------------------------------------------------------------
/**
* Reconstruit la barre des états.
*
* Elle ne paraît que si le planning en compte plus d'un : tant que tous les
* projets sont engagés — le cas d'un fichier hérité de la version 4 —, elle
* n'aurait rien à trier.
*
* Chaque état affiche son compte. C'est ce qui rend le cimetière consultable
* sans lui construire d'écran : « écartés (7) » dit à la fois qu'ils existent et
* combien, et un clic les ramène dans la colonne.
*/
function peindreEtats() {
const comptes = new Map(ETATS.map((nom) => [nom, 0]));
for (const projet of planning.projects) {
comptes.set(etatProjet(projet), comptes.get(etatProjet(projet)) + 1);
}
const presents = ETATS.filter((nom) => comptes.get(nom) > 0);
refs.etats.hidden = presents.length < 2;
refs.etatsListe.replaceChildren(
...presents.map((nom) => {
const actif = etatsActifs.includes(nom);
const bouton = document.createElement('button');
bouton.type = 'button';
bouton.className = 'etat-filtre';
bouton.dataset.etat = nom;
bouton.textContent = `${LIBELLES_ETAT[nom]}s (${comptes.get(nom)})`;
bouton.setAttribute('aria-pressed', String(actif));
// Tout décocher afficherait le planning entier — l'inverse de ce que
// demande le geste. On bloque plutôt le dernier actif, ce qui se voit.
bouton.disabled = actif && etatsActifs.length === 1;
bouton.title = actif
? bouton.disabled
? 'Au moins un état doit rester affiché.'
: `Masquer les projets ${LIBELLES_ETAT[nom].toLowerCase()}s`
: `Afficher aussi les projets ${LIBELLES_ETAT[nom].toLowerCase()}s`;
bouton.addEventListener('click', () => basculerEtat(nom));
return bouton;
})
);
}
/** Ajoute l'état à l'affichage, ou l'en retire s'il y était déjà. */
function basculerEtat(nom) {
const restants = etatsActifs.filter((actif) => actif !== nom);
if (!restants.length) return;
etatsActifs = restants.length === etatsActifs.length ? [...etatsActifs, nom] : restants;
dessiner();
}
refs.effacerFiltres.addEventListener('click', effacerFiltres);
// Depuis la barre des tags, « Tout afficher » ne touche qu'aux tags : ouvrir le
// cimetière au passage serait une surprise. Depuis l'écran vide, en revanche, on
// ne sait pas lequel des deux tamis a tout emporté — on relâche donc les deux.
$('effacer-filtres-vide').addEventListener('click', () => {
tagsActifs = [];
etatsActifs = [...ETATS];
dessiner();
});
// ---------------------------------------------------------------------------
// 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);
},
trouverProjet(idProjet) {
return planning.projects.find((p) => p.id === idProjet) ?? null;
},
surDeplacementHorizon(idProjet, horizon) {
// Même convention que pour une phase : null veut dire « geste annulé ou sans
// effet », on redessine sans rien sauvegarder.
if (!horizon) {
dessiner();
return;
}
majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon })));
},
surDecalageProjet(idProjet, jours) {
// Même convention que pour une phase : null veut dire « geste annulé ou sans
// effet », on redessine sans rien sauvegarder.
if (!jours) {
dessiner();
return;
}
majPlanning(
remplacerProjet(planning, idProjet, (projet) => decalerProjet(projet, jours))
);
},
surClicProjet(idProjet) {
const projet = planning.projects.find((p) => p.id === idProjet);
if (projet) ouvrirProjet(projet);
},
});
/**
* @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();
curseur = { 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);
const etat = etatProjet(projet);
// Deux actes, et deux seulement : les autres états se déduisent. Chacun se
// présente dans un sens ou dans l'autre selon où en est le projet, comme le
// font déjà « plier » et « masquer » — une entrée qui bascule plutôt que deux
// dont une serait toujours grise.
const acter = (transformation) =>
majPlanning(remplacerProjet(planning, idProjet, transformation));
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: etat === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé',
separateurAvant: true,
// Un projet envisagé n'a rien à clore : la clôture est le bout du chemin
// qui commence avec la première phase. Un écarté non plus — il sort du
// cimetière par « Remettre en jeu », et retrouve alors l'état que ses
// données commandent ; le clore d'ici court-circuiterait le cycle.
desactive: etat === 'considered' || etat === 'discarded',
action: () =>
etat === 'completed' ? acter((p) => rouvrirProjet(p)) : terminerLeProjet(projet),
},
{
// Écarter n'est pas supprimer, et la manœuvre se défait : pas de dialogue
// de confirmation, réservé à ce qui ne se rattrape pas (décision 21). Le
// projet garde tout, notes comprises, et se retrouve en cochant
// « écartés » dans le filtre par état.
libelle: etat === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet',
action: () =>
acter(etat === 'discarded' ? (p) => reanimerProjet(p) : (p) => ecarterProjet(p)),
},
{
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, { debut = null } = {}) {
// 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.
//
// `debut` n'est fourni que par les raccourcis `a`, `o` et `O`, qui insèrent
// ailleurs qu'au bout : les phases étant triées par date, « avant » et
// « après » ne s'expriment que par une date, jamais par un rang.
//
// La toute première phase d'un projet envisagé démarre à son horizon, et non
// aujourd'hui : c'est le moment où l'on engage, et engager veut dire préciser
// ce qu'on avait déclaré au mois près. La faire naître ailleurs donnerait à
// croire que l'horizon n'était pas pris au sérieux — et laisserait la barre
// pâle et la barre ferme à deux endroits sans rapport.
const derniere = projet.phases[projet.phases.length - 1];
if (!debut) {
debut = derniere
? ajouterJours(derniere.end, 1)
: projet.horizon
? premierJourDuMois(projet.horizon.start)
: 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();
// Ouvrir les paramètres pose le curseur sur la ligne du projet, à la souris
// comme au clavier : le curseur désigne toujours ce que le volet montre.
curseur = { projet: projet.id, phase: null };
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="horizon"], [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,
});
}
/**
* Clore un projet **termine toutes ses phases avec lui**, et rien ne rétablira
* ensuite celle qui était bloquée ou celle qui n'avait pas commencé : rouvrir le
* projet ne peut pas deviner ce qu'elles valaient. C'est donc une action
* irréversible au sens de la décision 21, et elle s'annonce comme telle.
*
* On ne demande rien quand plus aucune phase n'est ouverte : il n'y a alors rien
* à écraser, et un dialogue qui ne prévient de rien apprend à cliquer sans lire.
*/
function confirmerCloture(projet) {
if (!clotureEcraseDesStatuts(projet)) return Promise.resolve(true);
const restantes = projet.phases.filter((phase) => phase.status !== 'done').length;
// Deux phrases écrites en entier plutôt qu'une suite de ternaires accordés :
// le texte d'un dialogue se relit, et une accumulation de « s » conditionnels
// finit toujours par produire une conjugaison de travers.
const message =
restantes > 1
? `${restantes} phases de « ${projet.name} » ne sont pas encore terminées. Elles passeront ` +
'au statut « Terminé », jalons compris, et rouvrir le projet ensuite ne leur rendra pas ' +
'leur statut précédent.'
: `Une phase de « ${projet.name} » n'est pas encore terminée. Elle passera au statut ` +
'« Terminé », et rouvrir le projet ensuite ne lui rendra pas son statut précédent.';
return demanderConfirmation({
titreTexte: 'Déclarer le projet terminé',
messageTexte: message,
libelleValider: 'Déclarer terminé',
});
}
async function terminerLeProjet(projet) {
if (!(await confirmerCloture(projet))) return;
majPlanning(remplacerProjet(planning, projet.id, (p) => terminerProjet(p)));
}
async function supprimerLeProjet(projet) {
if (!(await confirmerSuppressionProjet(projet))) return;
if (curseur?.projet === projet.id) panneau.fermer();
if (panneauProjet.projetOuvert() === projet.id) panneauProjet.fermer();
// Le curseur va se retrouver sur une ligne qui n'existe plus : on le pose sur
// le projet suivant — ou le précédent s'il n'y en a pas — plutôt que de
// l'effacer, pour pouvoir enchaîner les `dd` sans repasser par la souris.
//
// Avant `majPlanning`, pas après : le voisin survit à la suppression, donc
// `normaliserCurseur` le laissera en place, et un seul rendu suffit.
if (curseur?.projet === projet.id) curseur = projetVoisin(projet.id);
majPlanning(supprimerProjet(planning, projet.id));
}
/** Ligne du projet qui suit celui-ci, ou du précédent s'il fermait la liste. */
function projetVoisin(idProjet) {
const retenus = projetsRetenus();
const index = retenus.findIndex((p) => p.id === idProjet);
if (index === -1) return null;
const voisin = retenus[index + 1] ?? retenus[index - 1];
return voisin ? { projet: voisin.id, phase: null } : null;
}
/**
* 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.
//
// Même raison pour l'état : un projet naît envisagé, et le masquer aussitôt
// parce qu'on avait décoché « envisagés » ferait paraître le bouton sans
// effet. On rouvre donc l'état plutôt que de créer dans l'ombre.
if (!etatsActifs.includes('considered')) etatsActifs = [...etatsActifs, 'considered'];
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.
function allerAujourdhui() {
if (vue === 'mois') revelerMoisCourant(refs.vueMois);
else centrerSur(aujourdhui());
}
$('aller-aujourdhui').addEventListener('click', allerAujourdhui);
$('ouvrir-aide').addEventListener('click', () => $('dialogue-aide').showModal());
$('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, au jour près, de ce que porte le curseur.
document.addEventListener('keydown', (evenement) => {
if (!curseur) 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;
// Sur la ligne d'un projet, les flèches repoussent le projet entier — le même
// geste que le glisser de sa barre cumulative, au jour près. Maj n'y ajoute
// rien : un projet ne s'étire pas (docs/decisions.md, section 8).
if (!curseur.phase) {
const projet = planning.projects.find((p) => p.id === curseur.projet);
if (!projet || !ancreDecalage(projet)) return;
evenement.preventDefault();
majPlanning(
remplacerProjet(planning, curseur.projet, (aDecaler) => decalerProjet(aDecaler, pas))
);
return;
}
evenement.preventDefault();
const trouve = localiser(curseur.projet, curseur.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, curseur.projet, (projet) =>
remplacerPhase(projet, curseur.phase, () => suivante)
)
);
});
// ---------------------------------------------------------------------------
// Curseur clavier
// ---------------------------------------------------------------------------
/**
* Lignes de la frise, dans l'ordre où elles s'affichent.
*
* Même énumération que `construireLibelles` (timeline.js) : une ligne par projet
* retenu par le filtre, suivie de ses phases quand il est déplié et non masqué.
* Les deux doivent rester d'accord, le rang d'une ligne ici servant d'indice
* dans les enfants de la colonne pour amener le défilement dessus.
*/
function lignesFrise() {
const lignes = [];
for (const projet of projetsRetenus()) {
lignes.push({ projet: projet.id, phase: null });
if (projet.hidden || projet.collapsed) continue;
for (const phase of projet.phases) lignes.push({ projet: projet.id, phase: phase.id });
}
return lignes;
}
function memeLigne(a, b) {
return Boolean(a && b && a.projet === b.projet && a.phase === b.phase);
}
/**
* Ramène le curseur sur une ligne qui existe encore.
*
* Replier un projet, resserrer le filtre ou supprimer une phase peut faire
* disparaître la ligne sous le curseur. On retombe alors sur la ligne du projet,
* qui survit à ces trois cas, et on n'efface qu'en dernier recours : perdre sa
* place est plus coûteux que la voir remonter d'un cran.
*/
function normaliserCurseur() {
if (!curseur) return;
const lignes = lignesFrise();
if (lignes.some((ligne) => memeLigne(ligne, curseur))) return;
const surLeProjet = { projet: curseur.projet, phase: null };
curseur = lignes.some((ligne) => memeLigne(ligne, surLeProjet)) ? surLeProjet : null;
}
/**
* Pose le curseur sur une ligne, amène la vue dessus, et fait suivre le panneau
* s'il est ouvert.
*
* Ce suivi n'est pas un ornement : les raccourcis agissent sur le curseur, et un
* volet resté sur une autre phase donnerait deux cibles concurrentes à l'écran —
* on éditerait dans les champs une phase que `Espace` ou `dd` n'atteindraient
* pas. Ouvert, le panneau est donc la vue détaillée du curseur, et `j` `k`
* deviennent le moyen de passer les phases en revue une à une.
*/
function poserCurseur(ligne) {
if (!ligne) return;
curseur = ligne;
if (refs.panneau.hidden && !panneauProjet.projetOuvert()) dessiner();
else ouvrirSousCurseur();
revelerCurseur();
}
/**
* Ouvre le volet qui correspond à la ligne du curseur : le détail d'une phase,
* les paramètres d'un projet.
*
* @param {boolean} saisir place le curseur de frappe dans le champ « Nom ».
* Vrai quand la frappe le demande (`Entrée`, `e`), faux quand le volet ne fait
* que suivre un déplacement du curseur — et la nuance porte tout le clavier :
* prendre le focus au passage de `j` enverrait la frappe suivante dans le
* champ, où les raccourcis se taisent. On ne saisirait plus jamais qu'un nom.
*/
function ouvrirSousCurseur({ saisir = false } = {}) {
if (!curseur) return;
if (curseur.phase) {
ouvrirPhase(curseur.projet, curseur.phase, { reveler: true, nomAEcraser: saisir });
return;
}
const projet = planning.projects.find((p) => p.id === curseur.projet);
if (projet) ouvrirProjet(projet, { nomAEcraser: saisir });
}
/** Le projet auquel appartient la ligne du curseur, ou `null`. */
function projetDuCurseur() {
if (!curseur) return null;
return planning.projects.find((p) => p.id === curseur.projet) ?? null;
}
/**
* Amène la ligne du curseur dans le champ de vision.
*
* Verticalement toujours : sans cela `j` sortirait de l'écran au bout de quinze
* lignes. Horizontalement seulement si la barre est entièrement hors cadre —
* recentrer à chaque pas ferait sauter la frise sous les yeux alors qu'on ne
* fait que descendre une colonne.
*/
function revelerCurseur() {
if (!curseur || vue !== 'frise' || refs.frise.hidden) return;
const index = lignesFrise().findIndex((ligne) => memeLigne(ligne, curseur));
const element = refs.libelles.children[index];
if (element) revelerLigne(element);
if (!curseur.phase) return;
const trouve = localiser(curseur.projet, curseur.phase);
if (trouve && !phaseVisible(trouve.phase)) centrerSur(trouve.phase.start);
}
/**
* Défile le minimum pour qu'une ligne de la colonne soit entièrement visible.
*
* Les positions se lisent à l'écran plutôt que dans `offsetTop` : la colonne est
* `sticky` dans une grille, et son parent de référence n'est pas celui qu'on
* croirait. La hauteur de l'en-tête est retranchée du haut utile — il est collé
* par-dessus la frise, et une ligne amenée au ras du bord se glisserait dessous.
*/
function revelerLigne(element) {
const ligne = element.getBoundingClientRect();
const zone = refs.frise.getBoundingClientRect();
const hautUtile = zone.top + refs.entete.offsetHeight;
if (ligne.top < hautUtile) refs.frise.scrollTop -= hautUtile - ligne.top;
else if (ligne.bottom > zone.bottom) refs.frise.scrollTop += ligne.bottom - zone.bottom;
}
/**
* Déplace le curseur de `pas` lignes, sans boucler d'un bout à l'autre.
*
* Pas de curseur encore posé : la première frappe le fait apparaître sur la
* première ligne, plutôt que de ne rien faire et de laisser croire que les
* raccourcis ne marchent pas.
*/
function deplacerCurseur(pas) {
// La vue par mois n'a pas de curseur : elle se lit, elle ne s'édite pas. `j`
// et `k` y font donc ce qu'ils font dans un document — ils déroulent la liste.
if (vue === 'mois') {
refs.vueMois.scrollTop += pas * PAS_DEFILEMENT_MOIS;
return;
}
const lignes = lignesFrise();
if (!lignes.length) return;
if (!curseur) {
poserCurseur(pas < 0 ? lignes[lignes.length - 1] : lignes[0]);
return;
}
const index = lignes.findIndex((ligne) => memeLigne(ligne, curseur));
const suivant = Math.min(lignes.length - 1, Math.max(0, index + pas));
poserCurseur(lignes[suivant]);
}
/**
* `h` et `l` : la phase précédente ou suivante **du même projet**, sans jamais
* déborder sur le voisin — c'est ce qui les distingue de `k` et `j`. La ligne du
* projet est la butée haute : `h` depuis sa première phase y remonte, et `l`
* depuis elle redescend sur cette première phase.
*/
function deplacerDansLeProjet(pas) {
const projet = projetDuCurseur();
if (!projet) {
deplacerCurseur(pas);
return;
}
const lignes = lignesFrise().filter((ligne) => ligne.projet === projet.id);
const index = lignes.findIndex((ligne) => memeLigne(ligne, curseur));
const suivant = index + pas;
if (suivant < 0 || suivant >= lignes.length) return;
poserCurseur(lignes[suivant]);
}
/** `{` et `}` : la ligne du projet précédent ou suivant. */
function deplacerDeProjet(pas) {
const retenus = projetsRetenus();
if (!retenus.length) return;
const depart = retenus.findIndex((p) => p.id === curseur?.projet);
// Sans curseur, `}` doit atteindre le premier projet et non le second.
const index = depart === -1 ? (pas > 0 ? 0 : retenus.length - 1) : depart + pas;
const projet = retenus[Math.min(retenus.length - 1, Math.max(0, index))];
poserCurseur({ projet: projet.id, phase: null });
}
// ---------------------------------------------------------------------------
// Raccourcis à la vim
// ---------------------------------------------------------------------------
/** Décalage horizontal de la frise, en jours. */
function defilerDe(jours) {
if (vue !== 'frise' || refs.frise.hidden) return;
refs.frise.scrollLeft += jours * PIXELS_PAR_JOUR;
}
/**
* Pas de `j` et `k` dans la vue par mois, où ils déroulent la liste faute de
* curseur à déplacer. Trois lignes environ : assez pour avancer, assez peu pour
* ne pas perdre le fil de ce qu'on lisait.
*/
const PAS_DEFILEMENT_MOIS = 96;
/**
* Plie ou déplie le projet du curseur.
*
* Depuis une phase, le pli emporte la ligne où l'on se trouve : le curseur monte
* sur celle du projet, seule position qui reste. Un second `za` déplie, et un
* `l` ramène dans le projet — l'aller-retour se fait sans la souris.
*/
function basculerPli(projet, replie) {
if (replie) curseur = { projet: projet.id, phase: null };
majPlanning(remplacerProjet(planning, projet.id, (p) => ({ ...p, collapsed: replie })));
revelerCurseur();
}
/** `zR` et `zM` : déplie ou plie tous les projets d'un coup. */
function plierTous(replie) {
majPlanning({
...planning,
projects: planning.projects.map((projet) => ({ ...projet, collapsed: replie })),
});
revelerCurseur();
}
/** `Espace` : fait tourner le statut de la phase du curseur. */
function statutSuivant() {
if (!curseur?.phase) return;
const trouve = localiser(curseur.projet, curseur.phase);
if (!trouve) return;
const suivant = STATUTS[(STATUTS.indexOf(trouve.phase.status) + 1) % STATUTS.length];
majPlanning(
remplacerProjet(planning, curseur.projet, (projet) =>
remplacerPhase(projet, curseur.phase, (phase) => modifierPhase(phase, { status: suivant }))
)
);
}
/**
* `a`, `A`, `o`, `O` : quatre façons d'ajouter une phase, qui ne diffèrent que
* par la date de départ.
*
* Les phases sont triées par date (`model.js`) : insérer « avant » ou « après »
* ne peut donc se dire qu'en dates, pas en rangs. Une nouvelle phase dure vingt
* jours, comme celle du bouton `+`, et se pose bord à bord avec sa voisine.
*
* Depuis la ligne d'un projet, `o` et `O` n'ont pas de phase de référence : ils
* retombent sur la fin et le début du projet, ce que `A` et `a` font déjà.
*/
function ajouterAuClavier(ou) {
const projet = projetDuCurseur();
if (!projet) return;
const phase = curseur.phase ? localiser(projet.id, curseur.phase)?.phase : null;
const premiere = projet.phases[0];
let debut = null;
if (ou === 'apres' && phase) debut = ajouterJours(phase.end, 1);
else if (ou === 'avant' && phase) debut = ajouterJours(phase.start, -21);
else if (ou === 'debut' && premiere) debut = ajouterJours(premiere.start, -21);
ajouterUnePhase(projet, { debut });
}
/** `dd` : supprime la phase du curseur, ou son projet depuis la ligne du projet. */
async function supprimerAuClavier() {
const projet = projetDuCurseur();
if (!projet) return;
if (!curseur.phase) {
await supprimerLeProjet(projet);
return;
}
const trouve = localiser(projet.id, curseur.phase);
if (!trouve) return;
const cible = curseur;
const ok = await demanderConfirmation({
titreTexte: 'Supprimer la phase',
messageTexte: `« ${trouve.phase.name} » sera définitivement supprimée du planning.`,
libelleValider: 'Supprimer',
danger: true,
});
if (!ok || !memeLigne(curseur, cible)) return;
// Le curseur se pose sur la phase suivante du même projet, ce qui permet
// d'enchaîner les suppressions sans repasser par la souris. En bout de liste
// il n'y en a pas, et `normaliserCurseur` le rattrapera sur la ligne du
// projet. Le déplacement précède la modification : la ligne visée survit, un
// seul rendu suffit donc à faire les deux.
const lignes = lignesFrise();
const suivante = lignes[lignes.findIndex((ligne) => memeLigne(ligne, cible)) + 1];
if (suivante?.projet === projet.id) curseur = suivante;
panneau.fermer();
majPlanning(remplacerProjet(planning, projet.id, (p) => supprimerPhase(p, cible.phase)));
revelerCurseur();
}
installerClavier({
// Déplacements
ligneSuivante: () => deplacerCurseur(1),
lignePrecedente: () => deplacerCurseur(-1),
phaseSuivante: () => deplacerDansLeProjet(1),
phasePrecedente: () => deplacerDansLeProjet(-1),
projetSuivant: () => deplacerDeProjet(1),
projetPrecedent: () => deplacerDeProjet(-1),
premiereLigne: () => {
if (vue === 'mois') refs.vueMois.scrollTop = 0;
else poserCurseur(lignesFrise()[0]);
},
derniereLigne: () => {
if (vue === 'mois') refs.vueMois.scrollTop = refs.vueMois.scrollHeight;
else poserCurseur(lignesFrise().at(-1));
},
// Plis et cadrage
basculerPli: () => {
const projet = projetDuCurseur();
if (projet) basculerPli(projet, !projet.collapsed);
},
deplierTout: () => plierTous(false),
plierTout: () => plierTous(true),
recentrer: () => {
if (!curseur) return;
const trouve = curseur.phase ? localiser(curseur.projet, curseur.phase) : null;
if (trouve) centrerSur(trouve.phase.start);
centrerLigne();
},
// Temps et vues
moisSuivant: () => defilerDe(30),
moisPrecedent: () => defilerDe(-30),
anneeSuivante: () => defilerDe(365),
anneePrecedente: () => defilerDe(-365),
allerAujourdhui: () => allerAujourdhui(),
basculerVue: () => basculerVue(vue === 'frise' ? 'mois' : 'frise'),
// Actions sur la ligne du curseur
editer: () => ouvrirSousCurseur({ saisir: true }),
ajouterFin: () => ajouterAuClavier('fin'),
ajouterDebut: () => ajouterAuClavier('debut'),
ajouterApres: () => ajouterAuClavier('apres'),
ajouterAvant: () => ajouterAuClavier('avant'),
nouveauProjet,
supprimer: () => supprimerAuClavier(),
statutSuivant,
aide: () => $('dialogue-aide').showModal(),
});
/** `zz` : amène la ligne du curseur au milieu de la hauteur visible. */
function centrerLigne() {
if (!curseur || vue !== 'frise' || refs.frise.hidden) return;
const index = lignesFrise().findIndex((ligne) => memeLigne(ligne, curseur));
const element = refs.libelles.children[index];
if (!element) return;
const ligne = element.getBoundingClientRect();
const zone = refs.frise.getBoundingClientRect();
const entete = refs.entete.offsetHeight;
const centre = zone.top + entete + (zone.height - entete) / 2;
refs.frise.scrollTop += ligne.top + ligne.height / 2 - centre;
}
// ---------------------------------------------------------------------------
// 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();