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:
32
js/app.js
32
js/app.js
@@ -22,12 +22,14 @@ import {
|
||||
STATUTS,
|
||||
ajouterJours,
|
||||
ajouterPhase,
|
||||
ancreDecalage,
|
||||
aujourdhui,
|
||||
bornesPlanning,
|
||||
cleTag,
|
||||
clotureEcraseDesStatuts,
|
||||
creerPhase,
|
||||
creerProjet,
|
||||
decalerProjet,
|
||||
ecarterProjet,
|
||||
etatProjet,
|
||||
figerReference,
|
||||
@@ -869,6 +871,17 @@ installerGlisser(refs.couloirs, {
|
||||
}
|
||||
majPlanning(remplacerProjet(planning, idProjet, (projet) => ({ ...projet, horizon })));
|
||||
},
|
||||
surDecalageProjet(idProjet, jours) {
|
||||
// Même convention que pour une phase : null veut dire « geste annulé ou sans
|
||||
// effet », on redessine sans rien sauvegarder.
|
||||
if (!jours) {
|
||||
dessiner();
|
||||
return;
|
||||
}
|
||||
majPlanning(
|
||||
remplacerProjet(planning, idProjet, (projet) => decalerProjet(projet, jours))
|
||||
);
|
||||
},
|
||||
surClicProjet(idProjet) {
|
||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||
if (projet) ouvrirProjet(projet);
|
||||
@@ -1315,16 +1328,29 @@ refs.frise.addEventListener(
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Flèches : ajustement fin de la phase du curseur, au jour près.
|
||||
// Flèches : ajustement fin, au jour près, de ce que porte le curseur.
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
// Une ligne de projet n'a pas de dates : les flèches n'y ont rien à décaler.
|
||||
if (!curseur?.phase) return;
|
||||
if (!curseur) return;
|
||||
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
||||
|
||||
const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0;
|
||||
if (pas === 0) return;
|
||||
|
||||
// Sur la ligne d'un projet, les flèches repoussent le projet entier — le même
|
||||
// geste que le glisser de sa barre cumulative, au jour près. Maj n'y ajoute
|
||||
// rien : un projet ne s'étire pas (docs/decisions.md, section 8).
|
||||
if (!curseur.phase) {
|
||||
const projet = planning.projects.find((p) => p.id === curseur.projet);
|
||||
if (!projet || !ancreDecalage(projet)) return;
|
||||
|
||||
evenement.preventDefault();
|
||||
majPlanning(
|
||||
remplacerProjet(planning, curseur.projet, (aDecaler) => decalerProjet(aDecaler, pas))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
evenement.preventDefault();
|
||||
const trouve = localiser(curseur.projet, curseur.phase);
|
||||
if (!trouve) return;
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
* des séquences et le préfixe en attente.
|
||||
*
|
||||
* Le clavier de la frise est ainsi séparé en deux couches qui ne se recouvrent
|
||||
* pas : les **flèches modifient** la phase du curseur (app.js, hérité), les
|
||||
* **lettres déplacent** le curseur et commandent. Aucun raccourci documenté n'a
|
||||
* changé de sens.
|
||||
* pas : les **flèches modifient** ce que porte le curseur — la phase, ou le
|
||||
* projet entier quand il est sur sa ligne (app.js, hérité) —, les **lettres
|
||||
* déplacent** le curseur et commandent. Aucun raccourci documenté n'a changé de
|
||||
* sens.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,7 @@ export function creerPanneau(refs, rappels) {
|
||||
formulaire.end.value = phase.end;
|
||||
formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo';
|
||||
formulaire.milestone.checked = phase.milestone;
|
||||
formulaire.fixed.checked = phase.fixed;
|
||||
// Ouverture sur l'aperçu si la phase est commentée, sur la saisie sinon :
|
||||
// on ouvre une phase bloquée pour relire pourquoi, une phase vierge pour
|
||||
// y écrire quelque chose.
|
||||
@@ -77,6 +78,7 @@ export function creerPanneau(refs, rappels) {
|
||||
end: fin,
|
||||
status: formulaire.status.value,
|
||||
milestone: jalon,
|
||||
fixed: formulaire.fixed.checked,
|
||||
notes: formulaire.notes.value,
|
||||
});
|
||||
|
||||
@@ -170,6 +172,7 @@ export function creerPanneau(refs, rappels) {
|
||||
// serait le pire moment pour changer d'état.
|
||||
if (formulaire.notes !== actif) blocNotes.definir(phase.notes);
|
||||
if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone;
|
||||
if (formulaire.fixed !== actif) formulaire.fixed.checked = phase.fixed;
|
||||
champFin.hidden = phase.milestone;
|
||||
},
|
||||
|
||||
@@ -198,6 +201,7 @@ function sontEgales(a, b) {
|
||||
a.end === b.end &&
|
||||
a.status === b.status &&
|
||||
a.milestone === b.milestone &&
|
||||
a.fixed === b.fixed &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
|
||||
107
js/drag.js
107
js/drag.js
@@ -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
|
||||
|
||||
100
js/model.js
100
js/model.js
@@ -15,16 +15,17 @@
|
||||
/**
|
||||
* Version du format de fichier.
|
||||
*
|
||||
* Elle a changé pour la dernière fois quand le projet a reçu son cycle de vie —
|
||||
* `horizon`, `completedDate`, `discardedDate` (v5). Un champ ajouté n'oblige
|
||||
* pourtant à rien : la v4 se relit sans encombre, et le validateur ne refuse
|
||||
* qu'un fichier *plus récent* que lui. C'est justement là qu'est la raison
|
||||
* d'incrémenter — le validateur reconstruit chaque projet champ par champ et
|
||||
* laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire antérieur
|
||||
* ouvrirait un fichier v5 sans broncher et en effacerait tous les horizons à la
|
||||
* première sauvegarde.
|
||||
* Elle a changé pour la dernière fois quand la phase a reçu sa date imposée —
|
||||
* `fixed` (v6) ; avant cela, quand le projet a reçu son cycle de vie (v5). Un
|
||||
* champ ajouté n'oblige pourtant à rien : la v5 se relit sans encombre, et le
|
||||
* validateur ne refuse qu'un fichier *plus récent* que lui. C'est justement là
|
||||
* qu'est la raison d'incrémenter — le validateur reconstruit chaque projet champ
|
||||
* par champ et laisse tomber ce qu'il ne connaît pas. Sans ce numéro, un binaire
|
||||
* antérieur ouvrirait un fichier v6 sans broncher et en effacerait toutes les
|
||||
* dates imposées à la première sauvegarde : les phases concernées se remettraient
|
||||
* alors à suivre le décalage de leur projet, en silence.
|
||||
*/
|
||||
export const VERSION_FORMAT = 5;
|
||||
export const VERSION_FORMAT = 6;
|
||||
|
||||
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
|
||||
|
||||
@@ -652,6 +653,10 @@ function validerPhase(phase, rang, repereProjet, idsPhases) {
|
||||
// chargement : la valeur est cosmétique, contrairement aux dates.
|
||||
status: STATUTS.includes(phase.status) ? phase.status : 'todo',
|
||||
milestone: jalon,
|
||||
// Absente d'un fichier en version 5, où elle vaut donc `false` : aucune date
|
||||
// n'y était déclarée imposée, et rien dans les données ne permettrait de
|
||||
// deviner laquelle l'était.
|
||||
fixed: Boolean(phase.fixed),
|
||||
notes: typeof phase.notes === 'string' ? phase.notes : '',
|
||||
...(reference ? { baseline: reference } : {}),
|
||||
};
|
||||
@@ -996,6 +1001,7 @@ export function creerPhase(projet, nom, debut, fin, options = {}) {
|
||||
end: jalon ? debut : fin,
|
||||
status: STATUTS.includes(options.status) ? options.status : 'todo',
|
||||
milestone: jalon,
|
||||
fixed: Boolean(options.fixed),
|
||||
notes: options.notes || '',
|
||||
};
|
||||
}
|
||||
@@ -1006,6 +1012,82 @@ export function deplacerPhase(phase, nouveauDebut) {
|
||||
return { ...phase, start: nouveauDebut, end: ajouterJours(nouveauDebut, jours) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Les phases qu'un décalage de projet emporte. Deux sortes en sont exclues, pour
|
||||
* deux raisons qui n'ont rien à voir.
|
||||
*
|
||||
* **Ce qui est terminé.** Décaler un projet, c'est reconnaître qu'il prendra plus
|
||||
* de temps que prévu — et ce qui est fait est fait. Emporter les phases `done`
|
||||
* réécrirait un passé qu'on a vécu, et effacerait du même coup la dérive qu'on
|
||||
* cherche justement à lire : la référence figée, elle, ne bouge pas. C'est le
|
||||
* statut qui décide, jamais la position dans le calendrier : une phase terminée
|
||||
* en avance reste où elle est, une phase `blocked` ou `doing` déjà commencée se
|
||||
* décale avec le reste — c'est bien elle qui glisse.
|
||||
*
|
||||
* **Ce dont on ne décide pas.** Une phase à `fixed` porte une date qui ne nous
|
||||
* appartient pas : elle est déléguée, contractuelle ou réglementaire. Repousser
|
||||
* le projet ne repousse pas l'audit du commissaire aux comptes ; le décalage lui
|
||||
* passe à travers, et le chevauchement qui en résulte est précisément
|
||||
* l'information qu'on veut voir.
|
||||
*/
|
||||
export function phasesDecalables(projet) {
|
||||
return projet.phases.filter((phase) => phase.status !== 'done' && !phase.fixed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Date à laquelle s'accroche le geste de décalage : le début de la première
|
||||
* phase qui bougera. Null si tout est terminé — il n'y a alors rien à décaler.
|
||||
*
|
||||
* C'est cette date, et non le début du projet, qui sert d'ancre : sur un projet
|
||||
* dont les premières phases sont closes, le bord gauche de ce qui bouge doit
|
||||
* suivre le pointeur, pas un bord qui reste sur place.
|
||||
*/
|
||||
export function ancreDecalage(projet) {
|
||||
const decalables = phasesDecalables(projet);
|
||||
if (!decalables.length) return null;
|
||||
return decalables.reduce(
|
||||
(debut, phase) => (versUTC(phase.start) < versUTC(debut) ? phase.start : debut),
|
||||
decalables[0].start
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Décale d'un même nombre de jours les phases que le projet emporte —
|
||||
* `phasesDecalables` dit lesquelles.
|
||||
*
|
||||
* Le même delta partout, jalons compris : les écarts entre phases emportées sont
|
||||
* conservés au jour près, ce qui est tout l'intérêt du geste — on repousse un
|
||||
* plan sans le replanifier. Celles qui restent font que le projet ne se déplace
|
||||
* pas tant qu'il s'étire.
|
||||
*
|
||||
* L'appartenance se décide **une fois**, avant de rien décaler, et se retient par
|
||||
* identifiant : la parcourir au fil de la transformation reviendrait à interroger
|
||||
* des phases déjà déplacées.
|
||||
*
|
||||
* Rien n'empêche le décalage de faire chevaucher deux phases : il n'y a pas de
|
||||
* dépendances entre phases dans ce modèle (docs/decisions.md, section 2), et
|
||||
* inventer une butée ici reviendrait à en poser une par la bande.
|
||||
*/
|
||||
export function decalerProjet(projet, jours) {
|
||||
if (!jours) return projet;
|
||||
const emportees = new Set(phasesDecalables(projet).map((phase) => phase.id));
|
||||
|
||||
return {
|
||||
...projet,
|
||||
phases: trierPhases(
|
||||
projet.phases.map((phase) =>
|
||||
emportees.has(phase.id)
|
||||
? {
|
||||
...phase,
|
||||
start: ajouterJours(phase.start, jours),
|
||||
end: ajouterJours(phase.end, jours),
|
||||
}
|
||||
: phase
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Change une seule borne — le glisser d'un bord de barre.
|
||||
* La borne opposée fait butée : une phase ne peut pas se retourner.
|
||||
|
||||
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