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>
This commit is contained in:
113
js/timeline.js
113
js/timeline.js
@@ -29,6 +29,7 @@ import {
|
||||
formaterHorizon,
|
||||
formulerDerive,
|
||||
lundiDeLaSemaine,
|
||||
phasesDecalables,
|
||||
projetADater,
|
||||
projetFiltre,
|
||||
projetFiltreEtat,
|
||||
@@ -307,7 +308,7 @@ function construireLibelles(projets, tagsActifs, curseur) {
|
||||
const ligne = document.createElement('div');
|
||||
ligne.className = `libelle-phase${sousCurseur ? ' libelle-phase--curseur' : ''}`;
|
||||
ligne.textContent = phase.name;
|
||||
ligne.title = `${phase.name} — ${LIBELLES_STATUT[phase.status]}`;
|
||||
ligne.title = `${phase.name} — ${LIBELLES_STATUT[phase.status]}${mentionImposee(phase)}`;
|
||||
ligne.dataset.projet = projet.id;
|
||||
ligne.dataset.phase = phase.id;
|
||||
elements.push(ligne);
|
||||
@@ -506,9 +507,16 @@ function construirePhase(projet, phase, echelle, curseur) {
|
||||
elements.push(fantome);
|
||||
}
|
||||
|
||||
// Une date imposée se lit sur la barre, et pas seulement dans le panneau : un
|
||||
// planning se parcourt des yeux, et savoir ce qui suivra un décalage doit se
|
||||
// voir avant de faire le geste, pas après.
|
||||
const impose = phase.fixed ? ' barre--imposee' : '';
|
||||
|
||||
if (phase.milestone) {
|
||||
const losange = document.createElement('div');
|
||||
losange.className = `jalon jalon--${phase.status}${selectionnee ? ' jalon--selectionne' : ''}`;
|
||||
losange.className =
|
||||
`jalon jalon--${phase.status}${phase.fixed ? ' jalon--impose' : ''}` +
|
||||
(selectionnee ? ' jalon--selectionne' : '');
|
||||
losange.style.left = `${echelle.x(phase.start)}px`;
|
||||
losange.dataset.projet = projet.id;
|
||||
losange.dataset.phase = phase.id;
|
||||
@@ -518,7 +526,7 @@ function construirePhase(projet, phase, echelle, curseur) {
|
||||
// infobulle les taisait, alors que celle d'une barre les montrait.
|
||||
losange.title =
|
||||
`${phase.name} — ${formaterDateLongue(phase.start)}` +
|
||||
`\n${LIBELLES_STATUT[phase.status]}` +
|
||||
`\n${LIBELLES_STATUT[phase.status]}${mentionImposee(phase)}` +
|
||||
(phase.notes ? `\n\n${aplatirMarkdown(phase.notes)}` : '');
|
||||
elements.push(losange);
|
||||
|
||||
@@ -526,13 +534,19 @@ function construirePhase(projet, phase, echelle, curseur) {
|
||||
nom.className = 'jalon-nom';
|
||||
nom.style.left = `${echelle.x(phase.start)}px`;
|
||||
nom.textContent = phase.name;
|
||||
// Le nom vit hors du losange — il déborderait de ses 12 pixels. Il porte donc
|
||||
// sa propre identité, faute de quoi le décalage d'un projet emporterait le
|
||||
// jalon en laissant son nom sur place.
|
||||
nom.dataset.projet = projet.id;
|
||||
nom.dataset.phase = phase.id;
|
||||
elements.push(nom);
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
const barre = document.createElement('div');
|
||||
barre.className = `barre barre--${phase.status}${selectionnee ? ' barre--selectionnee' : ''}`;
|
||||
barre.className =
|
||||
`barre barre--${phase.status}${impose}` + (selectionnee ? ' barre--selectionnee' : '');
|
||||
barre.style.left = `${echelle.x(phase.start)}px`;
|
||||
barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`;
|
||||
barre.dataset.projet = projet.id;
|
||||
@@ -540,7 +554,7 @@ function construirePhase(projet, phase, echelle, curseur) {
|
||||
barre.dataset.role = 'barre';
|
||||
barre.title =
|
||||
`${phase.name}\n${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
||||
`\n${LIBELLES_STATUT[phase.status]}` +
|
||||
`\n${LIBELLES_STATUT[phase.status]}${mentionImposee(phase)}` +
|
||||
// Aplati : l'infobulle native ne connaît que le texte, et y déverser la
|
||||
// source markdown rendrait la note moins lisible qu'avant qu'on ne
|
||||
// l'enrichisse. `aplatirMarkdown` retire les marques et remplace celles qui
|
||||
@@ -571,40 +585,107 @@ function construirePhase(projet, phase, echelle, curseur) {
|
||||
/**
|
||||
* Barre unique du projet, segmentée par phase — sa forme d'ensemble.
|
||||
*
|
||||
* Affichée qu'il soit plié ou déplié. Lecture seule dans les deux cas : aucun
|
||||
* `data-role`, donc drag.js l'ignore (docs/decisions.md, section 8).
|
||||
* Affichée qu'il soit plié ou déplié. Elle se saisit pour repousser le projet
|
||||
* entier, à condition qu'il reste quelque chose à repousser : un projet dont
|
||||
* toutes les phases sont terminées n'a rien qui bouge, et sa barre reste inerte
|
||||
* plutôt que d'offrir une prise qui ne mènerait à rien (docs/decisions.md,
|
||||
* section 8).
|
||||
*/
|
||||
function construireBarreCumulative(projet, echelle) {
|
||||
const bornes = bornesProjet(projet);
|
||||
if (!bornes) return null;
|
||||
|
||||
const decalables = phasesDecalables(projet);
|
||||
|
||||
const conteneur = document.createElement('div');
|
||||
conteneur.className = 'barre-cumulative';
|
||||
conteneur.style.left = `${echelle.x(bornes.start)}px`;
|
||||
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
|
||||
conteneur.dataset.projet = projet.id;
|
||||
// Rôle distinct de `barre` et de `horizon` : drag.js y reconnaît qu'il décale
|
||||
// un projet entier, et non l'objet unique qu'il a sous le pointeur.
|
||||
if (decalables.length) conteneur.dataset.role = 'cumul';
|
||||
|
||||
const derive = formulerDerive(deriveProjet(projet));
|
||||
conteneur.title =
|
||||
`${projet.name}\n${formaterDateLongue(bornes.start)} → ${formaterDateLongue(bornes.end)}` +
|
||||
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
|
||||
(derive ? `\nÉcart à la référence : ${derive}` : '') +
|
||||
// Déplié, les barres modifiables sont juste en dessous : le dire serait
|
||||
// enfoncer une porte ouverte.
|
||||
(projet.collapsed ? '\n\nDéplier le projet pour modifier les dates.' : '');
|
||||
`\n\n${modeDEmploiDecalage(projet, decalables.length)}`;
|
||||
|
||||
const origine = echelle.x(bornes.start);
|
||||
for (const phase of projet.phases) {
|
||||
const segment = document.createElement('div');
|
||||
segment.className = `segment segment--${phase.status}`;
|
||||
// Le geste rejoue la géométrie sur ces mêmes nœuds à chaque mouvement de
|
||||
// souris, et le décalage retrie les phases : c'est l'identifiant qui les
|
||||
// apparie, jamais leur rang.
|
||||
segment.dataset.phase = phase.id;
|
||||
conteneur.append(segment);
|
||||
}
|
||||
|
||||
poserBarreCumulative(conteneur, projet, echelle);
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce qui suit le statut dans l'infobulle d'une phase à date imposée.
|
||||
*
|
||||
* Accolé au statut, et non sur sa propre ligne : les deux se lisent ensemble —
|
||||
* « en cours, date imposée » — et occupent le même registre, celui de ce que la
|
||||
* phase est aujourd'hui.
|
||||
*/
|
||||
function mentionImposee(phase) {
|
||||
return phase.fixed ? ' — date imposée, non emportée par un décalage du projet' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que dit l'infobulle du résumé sur le geste qu'il offre.
|
||||
*
|
||||
* La restriction aux phases non terminées ne s'annonce que lorsqu'elle joue :
|
||||
* sur un projet dont rien n'est clos — le cas courant — le geste emporte tout,
|
||||
* et il n'y a aucune nuance à apprendre.
|
||||
*/
|
||||
function modeDEmploiDecalage(projet, nombreDecalables) {
|
||||
if (!nombreDecalables) {
|
||||
return 'Rien à repousser : ses phases sont toutes terminées ou à date imposée.';
|
||||
}
|
||||
if (nombreDecalables === projet.phases.length) return 'Glisser pour repousser le projet.';
|
||||
|
||||
const retenues = projet.phases.length - nombreDecalables;
|
||||
const compte =
|
||||
retenues > 1 ? `${retenues} de ses phases ne suivront pas` : 'Une de ses phases ne suivra pas';
|
||||
return `Glisser pour repousser le projet. ${compte} — terminée${retenues > 1 ? 's' : ''} ou à date imposée.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose la géométrie de la barre cumulative et de ses segments d'après l'état du
|
||||
* projet.
|
||||
*
|
||||
* Séparée de sa construction parce que `drag.js` la rejoue à chaque mouvement de
|
||||
* souris : décaler un projet dont une partie est terminée ne translate pas cette
|
||||
* barre, il la déforme — le bord gauche tient, le reste s'en va. Elle ne peut
|
||||
* donc pas se contenter d'un `left` mis à jour, et la formule doit vivre au même
|
||||
* endroit que celle du rendu, sous peine de voir l'aperçu et le résultat
|
||||
* diverger.
|
||||
*/
|
||||
export function poserBarreCumulative(conteneur, projet, echelle) {
|
||||
const bornes = bornesProjet(projet);
|
||||
if (!bornes) return;
|
||||
|
||||
const origine = echelle.x(bornes.start);
|
||||
conteneur.style.left = `${origine}px`;
|
||||
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
|
||||
|
||||
const segments = new Map();
|
||||
for (const segment of conteneur.children) segments.set(segment.dataset.phase, segment);
|
||||
|
||||
for (const phase of projet.phases) {
|
||||
const segment = segments.get(phase.id);
|
||||
if (!segment) continue;
|
||||
segment.style.left = `${echelle.x(phase.start) - origine}px`;
|
||||
// Un jalon n'a pas de largeur propre : on lui donne 3 px pour rester visible.
|
||||
segment.style.width = phase.milestone
|
||||
? '3px'
|
||||
: `${echelle.largeurDe(phase.start, phase.end)}px`;
|
||||
conteneur.append(segment);
|
||||
}
|
||||
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user