Dessiner l'horizon sur la frise, l'éditer et le glisser

La barre d'un projet envisagé est pâle et hachurée, aux bords fondus.
Elle se glisse et se redimensionne comme une phase, mais accrochée au
mois : le geste existe, il ne permet pas de poser une date qu'on n'a
pas. Le panneau ajoute deux champs de mois et trois crans.

Une barre « Afficher » filtre par état, disjonctive là où celle des
tags est conjonctive — d'où des boutons carrés et non des pastilles.
Les écartés en sont décochés au départ.

Engager démarre au premier jour de l'horizon, et clore un projet
demande confirmation quand il reste des phases à écraser.

Au passage : releasePointerCapture peut lever quand le pointeur a
disparu, ce qui sautait l'application du geste. Le nettoyage en est
maintenant isolé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 14:58:05 +02:00
parent 8404f12656
commit 60a91ba5b2
6 changed files with 1018 additions and 40 deletions

228
js/app.js
View File

@@ -17,14 +17,19 @@ import { rendreMois, revelerMoisCourant } from './mois.js';
import { creerStockage, viderAvantFermeture } from './storage.js';
import {
COULEURS,
ETATS,
LIBELLES_ETAT,
STATUTS,
ajouterJours,
ajouterPhase,
aujourdhui,
bornesPlanning,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
ecarterProjet,
etatProjet,
figerReference,
formaterDateLongue,
libererReference,
@@ -32,11 +37,16 @@ import {
normaliserTags,
planningVide,
pourEcriture,
premierJourDuMois,
projetFiltre,
projetFiltreEtat,
reanimerProjet,
remplacerPhase,
remplacerProjet,
rouvrirProjet,
supprimerPhase,
supprimerProjet,
terminerProjet,
teinteTag,
tousLesTags,
validerPlanning,
@@ -58,6 +68,8 @@ const refs = {
filtres: $('filtres'),
filtresListe: $('filtres-liste'),
effacerFiltres: $('effacer-filtres'),
etats: $('etats'),
etatsListe: $('etats-liste'),
alerte: $('alerte'),
alerteMessage: $('alerte-message'),
panneau: $('panneau'),
@@ -113,6 +125,18 @@ let defilementFrise = 0;
*/
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é.
*
@@ -197,6 +221,12 @@ const panneauProjet = creerPanneauProjet(
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'),
@@ -211,6 +241,9 @@ const panneauProjet = creerPanneauProjet(
demanderSuppression(_idProjet, projet) {
return confirmerSuppressionProjet(projet);
},
demanderCloture(projet) {
return confirmerCloture(projet);
},
surSuppression(idProjet) {
if (curseur?.projet === idProjet) panneau.fermer();
majPlanning(supprimerProjet(planning, idProjet));
@@ -243,7 +276,7 @@ function dessiner() {
normaliserCurseur();
const aDesProjets = planning.projects.length > 0;
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const retenus = projetsRetenus();
refs.messageVide.hidden = aDesProjets;
// Filtrer jusqu'à ne plus rien retenir est facile avec un ET : sans ce
@@ -257,6 +290,7 @@ function dessiner() {
refs.vueMois.hidden = !aQuelqueChoseAMontrer || vue !== 'mois';
peindreFiltres();
peindreEtats();
peindreOnglets();
if (!aDesProjets) {
@@ -265,12 +299,12 @@ function dessiner() {
}
if (vue === 'mois') {
rendreMois(refs.vueMois, planning, { tagsActifs });
rendreMois(refs.vueMois, planning, { tagsActifs, etatsActifs });
return;
}
elargirPourContenir();
echelle = rendre(refs, planning, { fenetre, curseur, tagsActifs });
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.
@@ -286,6 +320,21 @@ function dessiner() {
}
}
/**
* 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;
@@ -365,8 +414,71 @@ function effacerFiltres() {
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);
$('effacer-filtres-vide').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
@@ -745,6 +857,22 @@ installerGlisser(refs.couloirs, {
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 })));
},
surClicProjet(idProjet) {
const projet = planning.projects.find((p) => p.id === idProjet);
if (projet) ouvrirProjet(projet);
},
});
/**
@@ -854,6 +982,14 @@ function ouvrirMenuProjet(idProjet, ancre) {
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) },
@@ -871,6 +1007,26 @@ function ouvrirMenuProjet(idProjet, ancre) {
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,
@@ -909,8 +1065,20 @@ function ajouterUnePhase(projet, { debut = null } = {}) {
// `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) : aujourdhui();
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));
@@ -945,7 +1113,7 @@ function ouvrirProjet(projet, options = {}) {
* 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]';
'[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';
@@ -1006,6 +1174,42 @@ function confirmerSuppressionProjet(projet) {
});
}
/**
* 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;
@@ -1025,7 +1229,7 @@ async function supprimerLeProjet(projet) {
/** Ligne du projet qui suit celui-ci, ou du précédent s'il fermait la liste. */
function projetVoisin(idProjet) {
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
const retenus = projetsRetenus();
const index = retenus.findIndex((p) => p.id === idProjet);
if (index === -1) return null;
const voisin = retenus[index + 1] ?? retenus[index - 1];
@@ -1052,6 +1256,12 @@ function nouveauProjet() {
// Un filtre actif préremplit les tags : quand on crée un projet en ne
// regardant que les projets « client », c'en est presque toujours un — et
// sans cela le nouveau venu naîtrait invisible, hors du filtre courant.
//
// 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 });
@@ -1146,7 +1356,7 @@ document.addEventListener('keydown', (evenement) => {
function lignesFrise() {
const lignes = [];
for (const projet of planning.projects.filter((p) => projetFiltre(p, tagsActifs))) {
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 });
@@ -1312,7 +1522,7 @@ function deplacerDansLeProjet(pas) {
/** `{` et `}` : la ligne du projet précédent ou suivant. */
function deplacerDeProjet(pas) {
const retenus = planning.projects.filter((p) => projetFiltre(p, tagsActifs));
const retenus = projetsRetenus();
if (!retenus.length) return;
const depart = retenus.findIndex((p) => p.id === curseur?.projet);