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);

View File

@@ -14,8 +14,13 @@
import {
ajouterJours,
ajouterMois,
deplacerPhase,
dernierJourDuMois,
ecartMois,
lundiDeLaSemaine,
moisDe,
premierJourDuMois,
redimensionnerPhase,
versUTC,
} from './model.js';
@@ -40,19 +45,45 @@ export function installerGlisser(conteneur, rappels) {
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
if (evenement.button !== 0) return;
const cible = evenement.target.closest('[data-role="barre"]');
const cible = evenement.target.closest('[data-role="barre"], [data-role="horizon"]');
if (!cible) return;
const poignee = evenement.target.closest('[data-role="poignee"]');
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
// La barre d'un projet envisagé se manœuvre au mois, pas au jour : même
// mécanique de geste, autre grain, et rien de commun avec une phase — il n'y
// en a pas encore (docs/decisions.md, section 27).
if (cible.dataset.role === 'horizon') {
const projet = rappels.trouverProjet(cible.dataset.projet);
if (!projet?.horizon) return;
geste = {
element: cible,
sorte: 'horizon',
idProjet: projet.id,
horizonInitial: projet.horizon,
horizonCourant: projet.horizon,
mode: bord,
xDepart: evenement.clientX,
demarre: false,
};
cible.setPointerCapture(evenement.pointerId);
evenement.preventDefault();
return;
}
const { projet: idProjet, phase: idPhase } = cible.dataset;
const phase = rappels.trouverPhase(idProjet, idPhase);
if (!phase) return;
const poignee = evenement.target.closest('[data-role="poignee"]');
// Un jalon n'a pas de durée : ses poignées n'ont pas de sens.
const mode = poignee && !phase.milestone ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
const mode = poignee && !phase.milestone ? bord : 'deplacer';
geste = {
element: cible,
sorte: 'phase',
idProjet,
idPhase,
phaseInitiale: phase,
@@ -89,9 +120,21 @@ export function installerGlisser(conteneur, rappels) {
const acheve = geste;
geste = null;
acheve.element.releasePointerCapture?.(evenement.pointerId);
acheve.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
relacher(acheve, evenement.pointerId);
if (acheve.sorte === 'horizon') {
const avant = acheve.horizonInitial;
const apres = acheve.horizonCourant;
// Un appui sans glisser ouvre les paramètres du projet : c'est là que se
// saisissent son horizon et le reste, comme un clic sur son nom.
if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet);
else if (apres.start !== avant.start || apres.end !== avant.end) {
rappels.surDeplacementHorizon(acheve.idProjet, apres);
} else {
rappels.surDeplacementHorizon(acheve.idProjet, null);
}
return;
}
if (!acheve.demarre) {
rappels.surClic(acheve.idProjet, acheve.idPhase);
@@ -110,26 +153,46 @@ export function installerGlisser(conteneur, rappels) {
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres);
});
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
document.addEventListener('keydown', (evenement) => {
if (evenement.key !== 'Escape' || !geste) return;
/** Abandonne le geste en cours et laisse le rendu se refaire depuis l'état. */
function annuler() {
if (!geste) return;
const annule = geste;
geste = null;
annule.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
relacher(annule);
if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
else rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
}
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
document.addEventListener('keydown', (evenement) => {
if (evenement.key === 'Escape') annuler();
});
// Un pointeur perdu (fenêtre qui perd le focus, périphérique débranché) doit
// laisser l'interface dans un état propre plutôt qu'en glisser perpétuel.
conteneur.addEventListener('pointercancel', () => {
if (!geste) return;
const annule = geste;
geste = null;
annule.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
});
conteneur.addEventListener('pointercancel', annuler);
}
/**
* Rend le pointeur et retire les marques du glisser.
*
* `releasePointerCapture` lève quand le pointeur a déjà disparu — périphérique
* débranché, fenêtre qui perd le focus au mauvais moment. L'exception ne doit
* pas empêcher ce qui suit : le geste de l'utilisateur est achevé et son
* résultat mérite d'être appliqué, même si le nettoyage n'avait plus rien à
* rendre.
*/
function relacher(geste, pointerId) {
if (pointerId !== undefined) {
try {
geste.element.releasePointerCapture?.(pointerId);
} catch {
// Le pointeur n'était plus capturé : il n'y a rien à réparer.
}
}
geste.element.classList.remove('barre--glissee');
document.body.classList.remove('glisse-en-cours');
}
/**
@@ -137,6 +200,8 @@ export function installerGlisser(conteneur, rappels) {
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
*/
function appliquerGeste(geste, decalage, echelle) {
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
const { phaseInitiale: phase, mode } = geste;
if (mode === 'deplacer') {
@@ -164,6 +229,37 @@ function appliquerGeste(geste, decalage, echelle) {
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
}
/**
* Même geste que ci-dessus pour la barre d'un projet envisagé, mais au **mois** :
* l'horizon n'a pas de bornes au jour, et le geste ne doit pas laisser croire
* qu'il en pose.
*
* Les bornes ne se retournent pas — la borne opposée fait butée, comme pour une
* phase —, si bien qu'un horizon fait toujours au moins un mois plein.
*/
function appliquerGesteHorizon(geste, decalage, echelle) {
const { horizonInitial: horizon, mode } = geste;
const largeur = ecartMois(horizon.start, horizon.end);
let suivant;
if (mode === 'deplacer') {
const debut = accrocherAuMois(horizon.start, decalage, echelle);
suivant = { start: debut, end: ajouterMois(debut, largeur) };
} else if (mode === 'redimensionner-debut') {
const debut = accrocherAuMois(horizon.start, decalage, echelle);
suivant = { start: debut > horizon.end ? horizon.end : debut, end: horizon.end };
} else {
const fin = accrocherAuMois(horizon.end, decalage, echelle);
suivant = { start: horizon.start, end: fin < horizon.start ? horizon.start : fin };
}
geste.horizonCourant = suivant;
const gauche = premierJourDuMois(suivant.start);
const droite = dernierJourDuMois(suivant.end);
geste.element.style.left = `${echelle.x(gauche)}px`;
geste.element.style.width = `${echelle.largeurDe(gauche, droite)}px`;
}
/**
* Décale une date du nombre de jours correspondant au déplacement, puis
* l'accroche au lundi de sa semaine.
@@ -173,6 +269,21 @@ function accrocher(date, decalagePixels, echelle) {
return lundiDeLaSemaine(ajouterJours(date, jours));
}
/**
* Décale un mois du déplacement demandé, puis l'accroche au mois le plus proche.
*
* L'arrondi se fait à la moitié du mois atteint plutôt qu'à un nombre fixe de
* jours : les mois n'ont pas tous la même longueur, et découper en tranches de
* trente jours ferait dériver le geste d'un cran au fil de l'année.
*/
function accrocherAuMois(mois, decalagePixels, echelle) {
const jours = Math.round(decalagePixels / echelle.pixelsParJour);
const cible = ajouterJours(premierJourDuMois(mois), jours);
const moisCible = moisDe(cible);
const longueur = Number(dernierJourDuMois(moisCible).slice(8, 10));
return Number(cible.slice(8, 10)) > longueur / 2 ? ajouterMois(moisCible, 1) : moisCible;
}
/**
* Décale une phase au clavier, pour un ajustement fin sans souris.
* Utilisé par app.js sur les flèches gauche/droite quand une phase est

View File

@@ -11,6 +11,20 @@
*/
import { creerBlocNotes } from './notes.js';
import {
LIBELLES_ETAT,
decalerHorizon,
definirHorizon,
ecarterProjet,
etatProjet,
formaterDateLongue,
formaterHorizon,
moisValide,
projetADater,
reanimerProjet,
rouvrirProjet,
terminerProjet,
} from './model.js';
export function creerPanneauProjet(refs, outils, rappels) {
const {
@@ -21,6 +35,12 @@ export function creerPanneauProjet(refs, outils, rappels) {
conteneurCouleurs,
suggestions,
blocSuggestions,
etat,
blocHorizon,
crans,
aideHorizon,
terminer,
ecarter,
notes,
supprimer,
fermer,
@@ -106,6 +126,101 @@ export function creerPanneauProjet(refs, outils, rappels) {
blocSuggestions.hidden = tagsConnus.length === 0;
}
/**
* Horizon lu dans les deux champs de mois.
*
* Une borne laissée vide prend la valeur de l'autre : on saisit souvent un
* mois unique, et exiger de le taper deux fois serait une formalité. Les deux
* vides valent « pas d'horizon » — un état légal, celui d'un projet hérité
* d'un fichier en version 4, qui se signalera comme réclamant une date.
*
* Le champ `type="month"` n'est pas un sélecteur partout : sous Firefox c'est
* un champ texte, d'où la vérification plutôt qu'une confiance faite au
* navigateur.
*/
function horizonSaisi() {
const debut = formulaire.horizonStart.value;
const fin = formulaire.horizonEnd.value;
if (!moisValide(debut) && !moisValide(fin)) return undefined;
if (!moisValide(debut)) return { start: fin, end: fin };
if (!moisValide(fin)) return { start: debut, end: debut };
return definirHorizon({}, debut, fin).horizon;
}
/**
* Le bloc « Horizon » ne paraît que pour un projet envisagé.
*
* Dès qu'une phase existe, ce sont les phases qui commandent l'emprise et
* l'horizon n'est plus qu'une valeur de repli conservée en silence : la
* proposer à la saisie laisserait croire qu'elle déplace encore quelque chose.
*/
function peindreCycleDeVie(projet) {
const nom = etatProjet(projet);
const envisage = nom === 'considered';
etat.textContent = LIBELLES_ETAT[nom] + legendeEtat(projet, nom);
etat.dataset.etat = nom;
// Chaque acte se présente dans un sens ou dans l'autre selon où en est le
// projet : une commande qui bascule plutôt que deux dont l'une serait
// toujours grise.
terminer.textContent = nom === 'completed' ? 'Rouvrir le projet' : 'Déclarer terminé';
// Un projet envisagé n'a rien à clore, et un écarté sort du cimetière par
// « Remettre en jeu » avant tout autre chose.
terminer.disabled = nom === 'considered' || nom === 'discarded';
terminer.title = terminer.disabled
? nom === 'considered'
? "Un projet envisagé n'a pas encore commencé."
: "Remettre le projet en jeu d'abord."
: nom === 'completed'
? 'Les phases resteront terminées : leur statut davant est perdu.'
: 'Toutes les phases et tous les jalons passeront à « Terminé ».';
ecarter.textContent = nom === 'discarded' ? 'Remettre en jeu' : 'Écarter le projet';
ecarter.title =
nom === 'discarded'
? "Le projet retrouvera l'état que ses données commandent."
: 'Le projet quitte la frise mais garde tout, notes comprises.';
blocHorizon.hidden = !envisage;
if (!envisage) return;
// On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur au
// milieu d'une frappe, comme pour le nom et les tags.
const actif = document.activeElement;
if (formulaire.horizonStart !== actif) {
formulaire.horizonStart.value = projet.horizon?.start ?? '';
}
if (formulaire.horizonEnd !== actif) {
formulaire.horizonEnd.value = projet.horizon?.end ?? '';
}
const aDater = projetADater(projet);
aideHorizon.textContent = aDater
? projet.horizon
? 'Cet horizon est passé : le repousser, ou écarter le projet.'
: "Sans horizon, ce projet n'est situé nulle part sur la frise."
: 'Ajouter une phase engage le projet et lui donne des dates fermes.';
aideHorizon.dataset.alerte = String(aDater);
// Rien à décaler tant qu'aucun horizon n'est posé : les crans conservent la
// largeur d'un intervalle, ils n'en inventent pas.
for (const bouton of crans.children) bouton.disabled = !projet.horizon;
}
/** Ce qui suit le nom de l'état : l'horizon, ou la date de l'acte. */
function legendeEtat(projet, nom) {
if (nom === 'considered') {
return projet.horizon ? `${formaterHorizon(projet.horizon)}` : ' — sans horizon';
}
if (nom === 'engaged') {
return `${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}`;
}
if (nom === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
return ` le ${formaterDateLongue(projet.discardedDate)}`;
}
function remplir(projet) {
titre.textContent = projet.name;
formulaire.name.value = projet.name;
@@ -113,6 +228,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
blocNotes.definir(projet.notes, { reinitialiserMode: true });
peindrePalette(projet.color);
peindreSuggestions();
peindreCycleDeVie(projet);
afficherErreur(null);
}
@@ -139,6 +255,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
name: nom,
tags: tagsSaisis(),
notes: formulaire.notes.value,
// L'horizon d'un projet engagé n'est plus modifiable, mais il n'est pas
// effacé pour autant : c'est ce qui permet à un projet ayant perdu sa
// dernière phase de le retrouver plutôt qu'une enveloppe reconstituée.
horizon: etatProjet(projetCourant) === 'considered' ? horizonSaisi() : projetCourant.horizon,
...forces,
};
@@ -148,11 +268,21 @@ export function creerPanneauProjet(refs, outils, rappels) {
suivant.tags.length === projetCourant.tags.length &&
suivant.tags.every((tag, rang) => tag === projetCourant.tags[rang]);
const memeHorizon =
suivant.horizon?.start === projetCourant.horizon?.start &&
suivant.horizon?.end === projetCourant.horizon?.end;
if (
suivant.name === projetCourant.name &&
suivant.color === projetCourant.color &&
suivant.notes === projetCourant.notes &&
memesTags
// Les deux dates d'acte comptent ici comme les autres champs : sans elles,
// déclarer un projet terminé ne changerait aucune des valeurs comparées et
// la modification serait tue.
suivant.completedDate === projetCourant.completedDate &&
suivant.discardedDate === projetCourant.discardedDate &&
memesTags &&
memeHorizon
) {
return;
}
@@ -161,6 +291,7 @@ export function creerPanneauProjet(refs, outils, rappels) {
titre.textContent = suivant.name;
peindrePalette(suivant.color);
peindreSuggestions();
peindreCycleDeVie(suivant);
rappels.surModification(idCourant, suivant);
}
@@ -168,6 +299,41 @@ export function creerPanneauProjet(refs, outils, rappels) {
formulaire.addEventListener('change', () => appliquer());
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
// Repousser par crans est le seul geste qui déplace un horizon : la barre de
// la frise, elle, ne se glisse pas. Les trois boutons couvrent ce qu'on fait
// réellement — reculer d'un trimestre, en gagner un, renvoyer à l'an prochain.
crans.addEventListener('click', (evenement) => {
const bouton = evenement.target.closest('[data-cran]');
if (!bouton || !projetCourant?.horizon) return;
appliquer({ horizon: decalerHorizon(projetCourant, Number(bouton.dataset.cran)).horizon });
});
// Les deux actes passent par `appliquer`, comme le reste du panneau : c'est
// lui qui compare, remonte la modification et repeint.
terminer.addEventListener('click', async () => {
if (!projetCourant) return;
if (etatProjet(projetCourant) === 'completed') {
appliquer(rouvrirProjet(projetCourant));
return;
}
// Clore termine aussi les phases : la confirmation vit dans app.js, qui la
// partage avec l'entrée du menu ⋯ — le même acte ne doit pas prévenir depuis
// un endroit et se taire depuis l'autre.
const cible = idCourant;
if (!(await rappels.demanderCloture(projetCourant))) return;
// Le panneau a pu être fermé ou changer de projet pendant la confirmation.
if (idCourant !== cible || !projetCourant) return;
appliquer(terminerProjet(projetCourant));
});
ecarter.addEventListener('click', () => {
if (!projetCourant) return;
const nom = etatProjet(projetCourant);
appliquer(nom === 'discarded' ? reanimerProjet(projetCourant) : ecarterProjet(projetCourant));
});
supprimer.addEventListener('click', async () => {
if (!idCourant) return;
const cible = idCourant;
@@ -222,6 +388,10 @@ export function creerPanneauProjet(refs, outils, rappels) {
if (formulaire.notes !== actif) blocNotes.definir(projet.notes);
peindrePalette(projet.color);
peindreSuggestions();
// L'état a pu changer sans passer par ce panneau : ajouter la première
// phase engage le projet, et le bloc « Horizon » doit disparaître au même
// instant que la barre pâle sur la frise.
peindreCycleDeVie(projet);
},
fermer() {

View File

@@ -12,20 +12,26 @@
*/
import {
LIBELLES_ETAT,
LIBELLES_STATUT,
MOIS_COURTS,
MOIS_LONGS,
ajouterJours,
aujourdhui,
bornesHorizon,
bornesProjet,
bornesReference,
deriveProjet,
dimancheDeLaSemaine,
ecartJours,
etatProjet,
formaterDateLongue,
formaterHorizon,
formulerDerive,
lundiDeLaSemaine,
projetADater,
projetFiltre,
projetFiltreEtat,
semaineISO,
versUTC,
} from './model.js';
@@ -85,19 +91,28 @@ export function creerEchelle(fenetre) {
/**
* Reconstruit toute la frise.
*
* Deux tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
* Trois tamis s'appliquent aux projets, et ils ne font pas la même chose. Le
* **filtre par tags** retire le projet de la vue entière, libellé compris : il
* répond à « je ne regarde que les projets clients ». L'**œil** (`hidden`) garde
* la ligne de libellé, grisée, et n'ôte que les barres : il répond à « je sais
* que ce projet existe, mais il encombre ma frise ».
* répond à « je ne regarde que les projets clients ». Le **filtre par état** en
* fait autant, mais en élargissant à chaque case cochée plutôt qu'en resserrant,
* un projet n'ayant qu'un état : il répond à « je ne veux pas voir le
* cimetière ». L'**œil** (`hidden`) garde la ligne de libellé, grisée, et n'ôte
* que les barres : il répond à « je sais que ce projet existe, mais il encombre
* ma frise ».
*
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
* @param {object} planning l'état courant
* @param {object} options { fenetre, curseur, tagsActifs }
* @param {object} options { fenetre, curseur, tagsActifs, etatsActifs }
*/
export function rendre(refs, planning, { fenetre, curseur, tagsActifs = [] }) {
export function rendre(
refs,
planning,
{ fenetre, curseur, tagsActifs = [], etatsActifs = [] }
) {
const echelle = creerEchelle(fenetre);
const retenus = planning.projects.filter((projet) => projetFiltre(projet, tagsActifs));
const retenus = planning.projects.filter(
(projet) => projetFiltre(projet, tagsActifs) && projetFiltreEtat(projet, etatsActifs)
);
const visibles = retenus.filter((projet) => !projet.hidden);
refs.entete.style.width = `${echelle.largeur}px`;
@@ -303,6 +318,8 @@ function construireLibelles(projets, tagsActifs, curseur) {
}
function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
const etat = etatProjet(projet);
const ligne = document.createElement('div');
ligne.className =
'libelle-projet' +
@@ -310,6 +327,10 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
(sousCurseur ? ' libelle-projet--curseur' : '');
ligne.style.setProperty('--couleur-projet', projet.color);
ligne.dataset.projet = projet.id;
// L'état se peint en CSS plutôt qu'en classes empilées : il est exclusif, et
// un attribut qui ne peut porter qu'une valeur le dit mieux que quatre
// modificateurs dont un seul doit être posé à la fois.
ligne.dataset.etat = etat;
const plier = document.createElement('button');
plier.className = 'plier';
@@ -333,6 +354,7 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
// reste le seul endroit où les deux se lisent toujours en entier.
nom.title =
(projet.tags.length ? `${projet.name}\nTags : ${projet.tags.join(', ')}` : projet.name) +
`\n${LIBELLES_ETAT[etat]}${legendeEtat(projet, etat)}` +
// Les notes du projet passent avant l'invite : c'est le contenu, l'invite
// n'est qu'un mode d'emploi. Le libellé est le seul endroit de la frise où
// elles se lisent — un projet n'a pas de barre à lui.
@@ -345,6 +367,20 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
if (projet.tags.length) ligne.append(pastillesTags(projet.tags, tagsActifs));
// Un projet envisagé dont l'horizon est passé — ou qui n'en a jamais eu — le
// dit sur sa ligne. C'est le garde-fou qui empêche les envisagés de devenir
// une décharge : on ne peut pas ne pas le voir en parcourant la colonne, et
// il ne s'éteint qu'en repoussant l'horizon ou en écartant le projet.
if (projetADater(projet)) {
const alerte = document.createElement('span');
alerte.className = 'libelle-projet__adater';
alerte.textContent = 'à dater';
alerte.title = projet.horizon
? `Horizon dépassé (${formaterHorizon(projet.horizon)}) — repousser ou écarter.`
: "Aucun horizon : ce projet n'est situé nulle part sur la frise.";
ligne.append(alerte);
}
const derive = formulerDerive(deriveProjet(projet));
if (derive) {
const etiquette = document.createElement('span');
@@ -395,6 +431,19 @@ function libelleProjet(projet, tagsActifs = [], sousCurseur = false) {
return ligne;
}
/**
* Ce qui suit le nom de l'état dans une infobulle : l'horizon d'un projet
* envisagé, la date de l'acte pour les deux qu'on prononce.
*/
function legendeEtat(projet, etat) {
if (etat === 'considered') {
return projet.horizon ? `${formaterHorizon(projet.horizon)}` : ' — sans horizon';
}
if (etat === 'completed') return ` le ${formaterDateLongue(projet.completedDate)}`;
if (etat === 'discarded') return ` le ${formaterDateLongue(projet.discardedDate)}`;
return '';
}
// ---------------------------------------------------------------------------
// Couloirs
// ---------------------------------------------------------------------------
@@ -411,12 +460,16 @@ function construireCouloirs(projets, echelle, curseur) {
const couloirProjet = document.createElement('div');
couloirProjet.className = `couloir couloir--projet${surLeProjet ? ' couloir--curseur' : ''}`;
couloirProjet.style.setProperty('--couleur-projet', projet.color);
couloirProjet.dataset.etat = etatProjet(projet);
// La barre cumulative n'est plus réservée au mode plié : dépliée, elle
// coiffe les phases du projet et garde sa forme d'ensemble sous les yeux
// pendant qu'on travaille sur le détail. Sans elle, il fallait replier pour
// savoir où en était le projet entier, donc perdre la vue qu'on éditait.
const barre = construireBarreCumulative(projet, echelle);
//
// Un projet envisagé n'a pas de phase à cumuler : c'est son horizon qui
// occupe la ligne, et rien d'autre ne s'affiche sous elle.
const barre = construireBarreCumulative(projet, echelle) ?? construireHorizon(projet, echelle);
if (barre) couloirProjet.append(barre);
const fantome = construireFantome(projet, echelle);
if (fantome) couloirProjet.append(fantome);
@@ -554,6 +607,53 @@ function construireBarreCumulative(projet, echelle) {
return conteneur;
}
/**
* Barre d'horizon d'un projet envisagé : pâle, aux bords fondus.
*
* Elle se glisse et se redimensionne comme une barre de phase, mais **accrochée
* au mois** là où celle d'une phase l'est au lundi. C'est le même parti pris
* qu'à la décision 9, poussé d'un cran : on ne pose pas un horizon au jour près,
* donc le geste ne le permet pas — tandis qu'interdire le glisser tout court, ce
* qui fut d'abord essayé, obligeait à passer par le panneau pour un ajustement
* qu'on veut faire en regardant les voisins.
*
* Sa largeur *est* l'incertitude : un projet vague occupe une large plage floue,
* un projet mûr un seul mois. Rien à écrire pour le dire, l'œil le lit.
*/
function construireHorizon(projet, echelle) {
const bornes = bornesHorizon(projet);
if (!bornes) return null;
const barre = document.createElement('div');
barre.className = 'barre-horizon';
barre.style.left = `${echelle.x(bornes.start)}px`;
barre.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
barre.dataset.projet = projet.id;
// Un rôle distinct de celui d'une phase : `drag.js` y reconnaît qu'il déplace
// des mois et non des jours, et qu'il n'y a pas de phase à aller chercher.
barre.dataset.role = 'horizon';
if (projetADater(projet)) barre.classList.add('barre-horizon--depasse');
barre.title =
`${projet.name}\nEnvisagé — ${formaterHorizon(projet.horizon)}` +
(projet.notes ? `\n\n${aplatirMarkdown(projet.notes)}` : '') +
'\n\nSe glisse au mois. Ajouter une phase engage le projet et lui donne des dates fermes.';
const nom = document.createElement('span');
nom.className = 'barre__nom';
nom.textContent = projet.name;
barre.append(nom);
for (const bord of ['debut', 'fin']) {
const poignee = document.createElement('div');
poignee.className = `poignee poignee--${bord}`;
poignee.dataset.role = 'poignee';
poignee.dataset.bord = bord;
barre.append(poignee);
}
return barre;
}
/** Barre fantôme du projet, reflétant l'étendue de référence. */
function construireFantome(projet, echelle) {
const reference = bornesReference(projet);