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

@@ -1,6 +1,12 @@
/**
* Glisser et redimensionner les barres.
*
* Trois sortes de barres se saisissent, et le même appui les sert toutes :
* celle d'une **phase**, accrochée au lundi ; celle de l'**horizon** d'un projet
* envisagé, accrochée au mois ; et la barre **cumulative** d'un projet, qui
* repousse d'un coup toutes ses phases non terminées. Chacune sait ce qu'elle
* déplace par son `data-role`.
*
* Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un
* glisser déplace la barre. On les départage au seuil : tant que le pointeur
* n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme
@@ -15,15 +21,20 @@
import {
ajouterJours,
ajouterMois,
ancreDecalage,
decalerProjet,
deplacerPhase,
dernierJourDuMois,
ecartJours,
ecartMois,
lundiDeLaSemaine,
moisDe,
phasesDecalables,
premierJourDuMois,
redimensionnerPhase,
versUTC,
} from './model.js';
import { poserBarreCumulative } from './timeline.js';
/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */
const SEUIL_GLISSER = 4;
@@ -45,12 +56,43 @@ 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"], [data-role="horizon"]');
const cible = evenement.target.closest(
'[data-role="barre"], [data-role="horizon"], [data-role="cumul"]'
);
if (!cible) return;
const poignee = evenement.target.closest('[data-role="poignee"]');
const bord = poignee ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
// La barre cumulative repousse le projet entier. Elle n'a pas de poignées :
// étirer un projet supposerait de dilater chaque phase au prorata, ce qui
// n'est pas le même besoin (docs/decisions.md, section 8).
if (cible.dataset.role === 'cumul') {
const projet = rappels.trouverProjet(cible.dataset.projet);
const ancre = projet && ancreDecalage(projet);
if (!ancre) return;
geste = {
element: cible,
sorte: 'cumul',
idProjet: projet.id,
projetInitial: projet,
ancre,
jours: 0,
mode: 'deplacer',
xDepart: evenement.clientX,
// Les barres de phase suivent le résumé pendant le geste : sans elles,
// on repousserait plusieurs mois de travail sans voir ce qui bouge —
// l'objection qui avait d'abord fait écarter ce geste.
suiveurs: suiveursDuProjet(conteneur, projet),
demarre: false,
};
cible.setPointerCapture(evenement.pointerId);
evenement.preventDefault();
return;
}
// 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).
@@ -122,6 +164,14 @@ export function installerGlisser(conteneur, rappels) {
relacher(acheve, evenement.pointerId);
if (acheve.sorte === 'cumul') {
// Un appui sans glisser ouvre les paramètres du projet, comme un clic sur
// son nom ou sur sa barre d'horizon.
if (!acheve.demarre) rappels.surClicProjet(acheve.idProjet);
else rappels.surDecalageProjet(acheve.idProjet, acheve.jours || null);
return;
}
if (acheve.sorte === 'horizon') {
const avant = acheve.horizonInitial;
const apres = acheve.horizonCourant;
@@ -160,7 +210,8 @@ export function installerGlisser(conteneur, rappels) {
geste = null;
relacher(annule);
if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
if (annule.sorte === 'cumul') rappels.surDecalageProjet(annule.idProjet, null);
else if (annule.sorte === 'horizon') rappels.surDeplacementHorizon(annule.idProjet, null);
else rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
}
@@ -200,6 +251,7 @@ function relacher(geste, pointerId) {
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
*/
function appliquerGeste(geste, decalage, echelle) {
if (geste.sorte === 'cumul') return appliquerGesteCumul(geste, decalage, echelle);
if (geste.sorte === 'horizon') return appliquerGesteHorizon(geste, decalage, echelle);
const { phaseInitiale: phase, mode } = geste;
@@ -229,6 +281,57 @@ function appliquerGeste(geste, decalage, echelle) {
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
}
/**
* Éléments de la frise qu'un décalage de projet emporte, avec leur position de
* départ.
*
* La position est relue dans `style.left` plutôt que dans `offsetLeft` : le nom
* d'un jalon porte une marge, que le second compte et le premier non. Repartir
* de la valeur qu'on a soi-même posée est le seul moyen d'y ajouter un décalage
* sans en décaler deux fois.
*
* Le fantôme de référence n'en est délibérément pas : il ne porte aucune phase,
* il tient la place du plan figé, et le voir rester en arrière pendant qu'on
* repousse le projet *est* la dérive qui s'affiche.
*/
function suiveursDuProjet(conteneur, projet) {
const decalables = new Set(phasesDecalables(projet).map((phase) => phase.id));
const suiveurs = [];
for (const element of conteneur.querySelectorAll('.barre, .jalon, .jalon-nom')) {
if (element.dataset.projet !== projet.id) continue;
if (!decalables.has(element.dataset.phase)) continue;
suiveurs.push({ element, gaucheInitiale: parseFloat(element.style.left) });
}
return suiveurs;
}
/**
* Repousse un projet entier : le même nombre de jours sur toutes ses phases non
* terminées, accroché au lundi comme une phase seule (décision 9).
*
* L'accroche porte sur la première phase qui bouge, et non sur le début du
* projet — sur un projet dont les premières phases sont closes, ce début reste
* en place et ne dirait rien du geste en cours.
*
* La barre cumulative est reposée depuis le modèle plutôt que translatée : elle
* se déforme quand une partie du projet ne suit pas, et son bord gauche tient
* pendant que le reste s'en va.
*/
function appliquerGesteCumul(geste, decalage, echelle) {
const jours = ecartJours(geste.ancre, accrocher(geste.ancre, decalage, echelle));
if (jours === geste.jours) return;
geste.jours = jours;
poserBarreCumulative(geste.element, decalerProjet(geste.projetInitial, jours), echelle);
const pixels = jours * echelle.pixelsParJour;
for (const suiveur of geste.suiveurs) {
suiveur.element.style.left = `${suiveur.gaucheInitiale + pixels}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