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

@@ -12,12 +12,14 @@ import {
bornesPlanning,
bornesProjet,
bornesReference,
ancreDecalage,
cleTag,
clotureEcraseDesStatuts,
creerPhase,
creerProjet,
dateValide,
decalerHorizon,
decalerProjet,
definirHorizon,
deplacerPhase,
dernierJourDuMois,
@@ -42,6 +44,7 @@ import {
modifierPhase,
moisValide,
normaliserTags,
phasesDecalables,
planningVide,
pourEcriture,
premierJourDuMois,
@@ -368,6 +371,24 @@ describe('validerPlanning', () => {
assert.equal(resultat.projects[0].notes, '');
});
test('une phase d un fichier en version 5 n a pas de date imposée', () => {
// Rien dans les données ne permettrait de deviner laquelle l'était : la
// migration ne devine pas, elle met tout à `false`.
const resultat = validerPlanning({ version: 5, projects: [projet({ phases: [phase()] })] });
assert.equal(resultat.projects[0].phases[0].fixed, false);
});
test('conserve une date imposée, et l écrit dans le fichier', () => {
const resultat = validerPlanning({
projects: [projet({ phases: [phase({ fixed: true })] })],
});
assert.equal(resultat.projects[0].phases[0].fixed, true);
const relu = JSON.parse(JSON.stringify(pourEcriture(resultat)));
assert.equal(relu.version, VERSION_FORMAT);
assert.equal(relu.projects[0].phases[0].fixed, true);
});
test('conserve les notes d un projet', () => {
const resultat = validerPlanning({
projects: [projet({ notes: '## Contexte\n\n- [ ] relancer' })],
@@ -901,6 +922,156 @@ describe('modifications', () => {
});
});
// --- décalage d'un projet ---------------------------------------------------
describe('décaler un projet', () => {
const enCours = projet({
phases: [
phase({ id: 'cadrage', start: '2026-08-03', end: '2026-08-21', status: 'done' }),
phase({ id: 'chantier', start: '2026-08-24', end: '2026-10-02', status: 'doing' }),
phase({
id: 'livraison',
start: '2026-10-12',
end: '2026-10-12',
status: 'todo',
milestone: true,
}),
],
});
const avecImposee = projet({
phases: [
phase({ id: 'chantier', start: '2026-08-24', end: '2026-10-02', status: 'doing' }),
phase({
id: 'audit-externe',
start: '2026-09-07',
end: '2026-09-18',
status: 'todo',
fixed: true,
}),
],
});
test('emporte tout ce qui n est pas terminé, et rien d autre', () => {
assert.deepEqual(
phasesDecalables(enCours).map((p) => p.id),
['chantier', 'livraison']
);
});
test('n emporte pas une phase à date imposée, quel que soit son statut', () => {
assert.deepEqual(
phasesDecalables(avecImposee).map((p) => p.id),
['chantier']
);
// Le statut ne rachète rien : c'est bien la date qui est tenue, pas le
// travail qui serait fini.
const enCoursEtImposee = projet({
phases: [phase({ status: 'doing', fixed: true })],
});
assert.deepEqual(phasesDecalables(enCoursEtImposee), []);
});
test('une phase à date imposée reste en place quand le projet est repoussé', () => {
const repousse = decalerProjet(avecImposee, 30);
const audit = repousse.phases.find((p) => p.id === 'audit-externe');
const chantier = repousse.phases.find((p) => p.id === 'chantier');
assert.deepEqual(
{ start: audit.start, end: audit.end },
{ start: '2026-09-07', end: '2026-09-18' }
);
assert.equal(chantier.start, '2026-09-23');
// Le chantier passe désormais par-dessus l'audit : c'est l'information que
// le geste doit faire apparaître, pas un cas à empêcher.
assert.ok(ecartJours(chantier.start, audit.end) < 0);
});
test('un projet dont tout est imposé ou terminé n offre aucune prise', () => {
const tenu = projet({
phases: [
phase({ id: 'a', status: 'done' }),
phase({ id: 'b', start: '2026-09-01', end: '2026-09-10', fixed: true }),
],
});
assert.equal(ancreDecalage(tenu), null);
assert.deepEqual(decalerProjet(tenu, 10).phases, tenu.phases);
});
test('s ancre sur la première phase qui bouge, pas sur le début du projet', () => {
// Le projet commence le 3 août, mais ce début est clos : c'est le 24 qui
// suit le pointeur.
assert.equal(ancreDecalage(enCours), '2026-08-24');
assert.equal(ancreDecalage(projet({ phases: [phase()] })), '2026-08-01');
});
test('un projet dont tout est terminé n a pas d ancre', () => {
const clos = projet({ phases: [phase({ status: 'done' })] });
assert.equal(ancreDecalage(clos), null);
assert.equal(ancreDecalage(projet()), null, 'ni un projet sans phase');
});
test('applique le même delta partout : les écarts sont conservés au jour près', () => {
const repousse = decalerProjet(enCours, 14);
const [cadrage, chantier, livraison] = repousse.phases;
assert.deepEqual(
{ start: cadrage.start, end: cadrage.end },
{ start: '2026-08-03', end: '2026-08-21' },
'la phase terminée reste où elle est'
);
assert.deepEqual(
{ start: chantier.start, end: chantier.end },
{ start: '2026-09-07', end: '2026-10-16' }
);
assert.deepEqual(
{ start: livraison.start, end: livraison.end },
{ start: '2026-10-26', end: '2026-10-26' },
'le jalon suit, et tient toujours sur un jour'
);
assert.equal(
ecartJours(chantier.end, livraison.start),
ecartJours(enCours.phases[1].end, enCours.phases[2].start)
);
});
test('recule aussi bien qu il avance', () => {
const recule = decalerProjet(enCours, -7);
assert.equal(recule.phases[1].start, '2026-08-17');
// Rien n'interdit de chevaucher une phase terminée : il n'y a pas de
// dépendances entre phases, et une butée en inventerait une.
assert.ok(ecartJours(recule.phases[1].start, recule.phases[0].end) > 0);
});
test('un décalage nul rend le projet inchangé', () => {
assert.equal(decalerProjet(enCours, 0), enCours);
});
test('la référence figée ne suit pas : c est elle qui mesure la dérive', () => {
const fige = figerReference(enCours, '2026-08-01');
const repousse = decalerProjet(fige, 21);
assert.deepEqual(repousse.phases[1].baseline, { start: '2026-08-24', end: '2026-10-02' });
assert.equal(deriveProjet(repousse), 21);
});
test('retrie les phases quand le décalage change leur ordre', () => {
const desordonne = projet({
phases: [
phase({ id: 'a', start: '2026-08-01', end: '2026-08-05', status: 'todo' }),
phase({ id: 'b', start: '2026-08-10', end: '2026-08-15', status: 'done' }),
],
});
assert.deepEqual(
decalerProjet(desordonne, 30).phases.map((p) => p.id),
['b', 'a']
);
});
});
// --- cycle de vie -----------------------------------------------------------
describe('cycle de vie', () => {