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:
2026-08-10 18:07:31 +02:00
parent 55128fd0d4
commit 4a513f9a59
15 changed files with 788 additions and 74 deletions

View File

@@ -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;
}
/**