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

@@ -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.