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

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() {