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>
1165 lines
44 KiB
JavaScript
1165 lines
44 KiB
JavaScript
/**
|
||
* Données et règles métier du planning.
|
||
*
|
||
* Ce module ne touche ni au DOM ni au réseau : il transforme des objets et
|
||
* lève des erreurs. C'est ce qui le rend testable sous Node (tests/model.test.js).
|
||
*
|
||
* Convention de dates : partout des chaînes « AAAA-MM-JJ », jamais d'objets
|
||
* Date. `new Date("2026-08-01")` est interprétée en UTC alors que
|
||
* `new Date(2026, 7, 1)` l'est en heure locale — mélanger les deux décale d'un
|
||
* jour selon le fuseau. Les conversions restent confinées aux helpers ci-dessous.
|
||
*
|
||
* La date de fin est *incluse* : du 01/08 au 01/08 dure un jour.
|
||
*/
|
||
|
||
/**
|
||
* Version du format de fichier.
|
||
*
|
||
* 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 = 6;
|
||
|
||
export const STATUTS = ['todo', 'doing', 'done', 'blocked'];
|
||
|
||
/**
|
||
* États d'un projet, dans l'ordre du cycle de vie.
|
||
*
|
||
* Contrairement au statut d'une phase, ce n'est **pas** un champ du fichier : il
|
||
* se déduit de `etatProjet()`. Voir docs/decisions.md, section 27.
|
||
*/
|
||
export const ETATS = ['considered', 'engaged', 'completed', 'discarded'];
|
||
|
||
export const LIBELLES_ETAT = {
|
||
considered: 'Envisagé',
|
||
engaged: 'Engagé',
|
||
completed: 'Terminé',
|
||
discarded: 'Écarté',
|
||
};
|
||
|
||
export const LIBELLES_STATUT = {
|
||
todo: 'À venir',
|
||
doing: 'En cours',
|
||
done: 'Terminé',
|
||
blocked: 'Bloqué',
|
||
};
|
||
|
||
/** Palette par défaut, parcourue à la création de chaque nouveau projet. */
|
||
export const COULEURS = [
|
||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
|
||
'#8b5cf6', '#06b6d4', '#ec4899', '#84cc16',
|
||
];
|
||
|
||
/**
|
||
* Noms de mois, en deux longueurs. Ils vivent ici plutôt que dans une vue :
|
||
* la frise et la vue par mois les emploient toutes les deux, et aucune des deux
|
||
* n'a à dépendre de l'autre pour écrire « septembre ».
|
||
*/
|
||
export const MOIS_COURTS = [
|
||
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
|
||
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.',
|
||
];
|
||
|
||
export const MOIS_LONGS = [
|
||
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||
];
|
||
|
||
const MOTIF_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||
const MOTIF_MOIS = /^\d{4}-\d{2}$/;
|
||
const MOTIF_COULEUR = /^#[0-9a-f]{6}$/i;
|
||
const MS_PAR_JOUR = 86400000;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Dates
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Vrai si la chaîne est une date « AAAA-MM-JJ » qui existe au calendrier. */
|
||
export function dateValide(valeur) {
|
||
if (typeof valeur !== 'string' || !MOTIF_DATE.test(valeur)) return false;
|
||
// Date.UTC normalise silencieusement le 30 février en 2 mars : on compare
|
||
// donc le résultat à l'entrée pour débusquer ces dates inexistantes.
|
||
const [a, m, j] = valeur.split('-').map(Number);
|
||
const date = new Date(Date.UTC(a, m - 1, j));
|
||
return (
|
||
date.getUTCFullYear() === a && date.getUTCMonth() === m - 1 && date.getUTCDate() === j
|
||
);
|
||
}
|
||
|
||
/** « AAAA-MM-JJ » vers un instant UTC, pour l'arithmétique uniquement. */
|
||
export function versUTC(texte) {
|
||
const [a, m, j] = texte.split('-').map(Number);
|
||
return Date.UTC(a, m - 1, j);
|
||
}
|
||
|
||
/** Instant UTC vers « AAAA-MM-JJ ». */
|
||
export function depuisUTC(instant) {
|
||
return new Date(instant).toISOString().slice(0, 10);
|
||
}
|
||
|
||
/** Décale une date d'un nombre de jours, éventuellement négatif. */
|
||
export function ajouterJours(texte, jours) {
|
||
return depuisUTC(versUTC(texte) + jours * MS_PAR_JOUR);
|
||
}
|
||
|
||
/** Nombre de jours de `debut` à `fin`, signé. Deux dates égales donnent 0. */
|
||
export function ecartJours(debut, fin) {
|
||
return Math.round((versUTC(fin) - versUTC(debut)) / MS_PAR_JOUR);
|
||
}
|
||
|
||
/** Durée d'une phase, fin incluse. Une phase d'un seul jour dure 1. */
|
||
export function duree(phase) {
|
||
return ecartJours(phase.start, phase.end) + 1;
|
||
}
|
||
|
||
/** Date du jour dans le fuseau local, au format « AAAA-MM-JJ ». */
|
||
export function aujourdhui() {
|
||
const maintenant = new Date();
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
return `${maintenant.getFullYear()}-${pad(maintenant.getMonth() + 1)}-${pad(maintenant.getDate())}`;
|
||
}
|
||
|
||
/** Recule une date jusqu'au lundi de sa semaine — l'accroche du glisser. */
|
||
export function lundiDeLaSemaine(texte) {
|
||
const instant = versUTC(texte);
|
||
const jour = new Date(instant).getUTCDay(); // 0 = dimanche
|
||
const recul = jour === 0 ? 6 : jour - 1;
|
||
return depuisUTC(instant - recul * MS_PAR_JOUR);
|
||
}
|
||
|
||
/** Avance une date jusqu'au dimanche qui clôt sa semaine. */
|
||
export function dimancheDeLaSemaine(texte) {
|
||
return ajouterJours(lundiDeLaSemaine(texte), 6);
|
||
}
|
||
|
||
/**
|
||
* Numéro de semaine ISO 8601 et année à laquelle il se rattache.
|
||
*
|
||
* La semaine ISO est celle qui contient son jeudi, ce qui fait qu'une semaine à
|
||
* cheval sur deux années appartient à celle où tombe ce jeudi. Le 1er janvier
|
||
* 2027 est ainsi en semaine 53 de 2026 — d'où l'année renvoyée en même temps
|
||
* que le numéro : afficher « S53 » sous le bandeau « 2027 » serait faux.
|
||
*/
|
||
export function semaineISO(texte) {
|
||
const date = new Date(versUTC(texte));
|
||
|
||
// On se place sur le jeudi de la semaine ISO courante.
|
||
const jourLundiZero = (date.getUTCDay() + 6) % 7;
|
||
date.setUTCDate(date.getUTCDate() - jourLundiZero + 3);
|
||
const jeudi = date.getTime();
|
||
const annee = date.getUTCFullYear();
|
||
|
||
// Puis sur le premier jeudi de cette année-là, qui définit la semaine 1.
|
||
const premierJanvier = new Date(Date.UTC(annee, 0, 1));
|
||
const decalage = (4 - premierJanvier.getUTCDay() + 7) % 7;
|
||
const premierJeudi = Date.UTC(annee, 0, 1 + decalage);
|
||
|
||
return {
|
||
annee,
|
||
numero: 1 + Math.round((jeudi - premierJeudi) / (7 * MS_PAR_JOUR)),
|
||
};
|
||
}
|
||
|
||
/** Date « AAAA-MM-JJ » rendue lisible : « 12 sept. 2026 ». */
|
||
export function formaterDateLongue(date) {
|
||
const [a, m, j] = date.split('-');
|
||
return `${j} ${MOIS_COURTS[Number(m) - 1]} ${a}`;
|
||
}
|
||
|
||
/** Mois d'une date, sous la forme « AAAA-MM » — la clé de regroupement. */
|
||
export function moisDe(date) {
|
||
return date.slice(0, 7);
|
||
}
|
||
|
||
/** Mois « AAAA-MM » rendu lisible : « septembre 2026 ». */
|
||
export function formaterMois(mois) {
|
||
return `${MOIS_LONGS[Number(mois.slice(5, 7)) - 1]} ${mois.slice(0, 4)}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mois
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Le mois est le grain de l'horizon d'un projet envisagé (décision 27). Comme
|
||
// les dates, il se manipule en chaîne — « AAAA-MM » se compare et se trie comme
|
||
// du texte, ce qui évite tout objet Date dans les comparaisons.
|
||
|
||
/** Vrai si la chaîne est un mois « AAAA-MM » plausible. */
|
||
export function moisValide(valeur) {
|
||
if (typeof valeur !== 'string' || !MOTIF_MOIS.test(valeur)) return false;
|
||
const numero = Number(valeur.slice(5, 7));
|
||
return numero >= 1 && numero <= 12;
|
||
}
|
||
|
||
/** Mois en cours, au format « AAAA-MM ». */
|
||
export function moisCourant() {
|
||
return moisDe(aujourdhui());
|
||
}
|
||
|
||
/** Premier jour d'un mois : « 2027-03 » → « 2027-03-01 ». */
|
||
export function premierJourDuMois(mois) {
|
||
return `${mois}-01`;
|
||
}
|
||
|
||
/**
|
||
* Dernier jour d'un mois : « 2027-03 » → « 2027-03-31 ».
|
||
*
|
||
* Le jour 0 du mois suivant est le dernier du mois demandé — `Date.UTC` fait le
|
||
* report d'année tout seul, décembre compris.
|
||
*/
|
||
export function dernierJourDuMois(mois) {
|
||
const annee = Number(mois.slice(0, 4));
|
||
const numero = Number(mois.slice(5, 7));
|
||
return depuisUTC(Date.UTC(annee, numero, 0));
|
||
}
|
||
|
||
/** Décale un mois d'un nombre de mois, éventuellement négatif. */
|
||
export function ajouterMois(mois, nombre) {
|
||
const annee = Number(mois.slice(0, 4));
|
||
const numero = Number(mois.slice(5, 7));
|
||
return depuisUTC(Date.UTC(annee, numero - 1 + nombre, 1)).slice(0, 7);
|
||
}
|
||
|
||
/** Nombre de mois de `debut` à `fin`, signé. Deux mois égaux donnent 0. */
|
||
export function ecartMois(debut, fin) {
|
||
return (
|
||
(Number(fin.slice(0, 4)) - Number(debut.slice(0, 4))) * 12 +
|
||
(Number(fin.slice(5, 7)) - Number(debut.slice(5, 7)))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Période d'une phase, abrégée pour une liste où le mois est déjà écrit en
|
||
* titre : « 12 » pour un jalon, « 12 → 30 » pour une tâche qui tient dans le
|
||
* mois, « 12 → 4 nov. » pour une qui en déborde, « 12 → 4 janv. 2027 » pour une
|
||
* qui déborde de l'année. Rien n'est répété tant que rien ne change, ce qui
|
||
* fait de l'apparition d'un mois — ou d'une année — le signal du dépassement.
|
||
*/
|
||
export function formaterPeriode(phase) {
|
||
const quantieme = (date) => String(Number(date.slice(8, 10)));
|
||
if (phase.milestone) return quantieme(phase.start);
|
||
|
||
if (phase.end.slice(0, 7) === phase.start.slice(0, 7)) {
|
||
return `${quantieme(phase.start)} → ${quantieme(phase.end)}`;
|
||
}
|
||
|
||
const moisFin = MOIS_COURTS[Number(phase.end.slice(5, 7)) - 1];
|
||
const anneeFin =
|
||
phase.end.slice(0, 4) === phase.start.slice(0, 4) ? '' : ` ${phase.end.slice(0, 4)}`;
|
||
return `${quantieme(phase.start)} → ${quantieme(phase.end)} ${moisFin}${anneeFin}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Identifiants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Fabrique un identifiant lisible à partir d'un nom : minuscules, accents
|
||
* retirés, tout le reste en tirets.
|
||
*
|
||
* Un identifiant ne change jamais quand le nom est modifié : il identifie, il
|
||
* ne décrit pas.
|
||
*/
|
||
export function fabriquerId(nom, dejaPris = []) {
|
||
const base =
|
||
nom
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '') // marques diacritiques combinantes
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '')
|
||
.slice(0, 40) || 'sans-nom';
|
||
|
||
if (!dejaPris.includes(base)) return base;
|
||
|
||
let compteur = 2;
|
||
while (dejaPris.includes(`${base}-${compteur}`)) compteur += 1;
|
||
return `${base}-${compteur}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tags
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Longueur maximale d'un tag, au-delà de laquelle `normaliserTags` tronque.
|
||
*
|
||
* Un tag est une étiquette, pas une phrase. La limite est appliquée en
|
||
* normalisant plutôt qu'en refusant : elle est cosmétique, comme le statut d'une
|
||
* phase, et bloquer le chargement d'un planning entier pour un libellé trop
|
||
* bavard serait disproportionné.
|
||
*/
|
||
export const MAX_LONGUEUR_TAG = 24;
|
||
|
||
/**
|
||
* Clé de comparaison d'un tag : c'est elle qui décide que « Client » et
|
||
* « client » sont le même tag. Minuscules et espaces normalisés, mais accents
|
||
* conservés — « éditeur » et « editeur » restent deux tags distincts, contrairement
|
||
* aux identifiants (`fabriquerId`) qui, eux, doivent tenir dans une URL.
|
||
*/
|
||
export function cleTag(tag) {
|
||
return tag.trim().replace(/\s+/g, ' ').toLocaleLowerCase('fr');
|
||
}
|
||
|
||
/**
|
||
* Nettoie une liste de tags : espaces retirés, vides écartés, doublons
|
||
* fusionnés à la clé près, ordre alphabétique.
|
||
*
|
||
* Le tri rend le fichier stable : ressaisir les mêmes tags dans un autre ordre
|
||
* ne produit aucun diff git.
|
||
*/
|
||
export function normaliserTags(tags) {
|
||
const vus = new Map();
|
||
for (const brut of tags) {
|
||
const tag = String(brut).trim().replace(/\s+/g, ' ').slice(0, MAX_LONGUEUR_TAG).trim();
|
||
if (!tag) continue;
|
||
const cle = cleTag(tag);
|
||
if (!vus.has(cle)) vus.set(cle, tag);
|
||
}
|
||
return [...vus.values()].sort((a, b) => a.localeCompare(b, 'fr'));
|
||
}
|
||
|
||
/** Tous les tags employés dans le planning, dédoublonnés et triés. */
|
||
export function tousLesTags(planning) {
|
||
return normaliserTags(planning.projects.flatMap((projet) => projet.tags));
|
||
}
|
||
|
||
/**
|
||
* Vrai si le projet passe le filtre, c'est-à-dire s'il porte *tous* les tags
|
||
* sélectionnés. Un filtre vide laisse tout passer.
|
||
*
|
||
* La conjonction plutôt que la disjonction : cocher un tag de plus resserre
|
||
* toujours la sélection, ce qui rend le filtre prévisible — on part du tout et
|
||
* on élague, sans jamais voir la frise se repeupler en cochant.
|
||
*/
|
||
export function projetFiltre(projet, tagsActifs) {
|
||
if (!tagsActifs.length) return true;
|
||
const cles = new Set(projet.tags.map(cleTag));
|
||
return tagsActifs.every((tag) => cles.has(cleTag(tag)));
|
||
}
|
||
|
||
/**
|
||
* Teinte HSL attribuée à un tag, entre 0 et 359.
|
||
*
|
||
* Dérivée du nom par un hachage, donc stable d'une session à l'autre et
|
||
* identique partout où le tag apparaît, sans rien avoir à stocker. Deux tags
|
||
* peuvent tomber sur des teintes voisines : la couleur aide à repérer, elle ne
|
||
* porte pas d'information à elle seule — le nom est toujours écrit à côté.
|
||
*/
|
||
export function teinteTag(tag) {
|
||
const cle = cleTag(tag);
|
||
let hachage = 0;
|
||
for (let i = 0; i < cle.length; i += 1) {
|
||
hachage = (hachage * 31 + cle.charCodeAt(i)) % 360;
|
||
}
|
||
return hachage;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cycle de vie et horizon
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* État d'un projet, déduit de ses données. Voir docs/decisions.md, section 27.
|
||
*
|
||
* Une seule règle : **les phases commandent l'état, sauf quand on a prononcé
|
||
* quelque chose**. Terminer et écarter sont des décisions — livré n'est pas
|
||
* clos, et abandonner n'est pas une conséquence des dates —, elles laissent
|
||
* donc une trace horodatée. Le reste se déduit : un projet qui a des phases est
|
||
* engagé, un projet qui n'en a pas encore est envisagé.
|
||
*
|
||
* Un projet ne peut pas porter les deux dates à la fois — les transitions
|
||
* ci-dessous s'en assurent —, mais un fichier retouché à la main le pourrait :
|
||
* l'écartement l'emporte, pour que la lecture reste déterministe.
|
||
*/
|
||
export function etatProjet(projet) {
|
||
if (projet.discardedDate) return 'discarded';
|
||
if (projet.completedDate) return 'completed';
|
||
return projet.phases.length ? 'engaged' : 'considered';
|
||
}
|
||
|
||
/**
|
||
* Vrai si le projet passe le filtre par état.
|
||
*
|
||
* Disjonctif, là où le filtre par tags est conjonctif (`projetFiltre`) : un
|
||
* projet porte plusieurs tags mais un seul état, si bien que cocher un état de
|
||
* plus ne peut qu'élargir la sélection. Une liste vide laisse tout passer, mais
|
||
* l'interface n'en pose jamais : elle part des états qu'on veut voir d'ordinaire
|
||
* — les écartés n'en font pas partie.
|
||
*/
|
||
export function projetFiltreEtat(projet, etatsActifs) {
|
||
if (!etatsActifs.length) return true;
|
||
return etatsActifs.includes(etatProjet(projet));
|
||
}
|
||
|
||
/**
|
||
* Emprise d'un horizon, ramenée en dates pour être dessinée : du premier jour
|
||
* de son premier mois au dernier jour de son dernier. Null si le projet n'en a
|
||
* pas.
|
||
*/
|
||
export function bornesHorizon(projet) {
|
||
if (!projet.horizon) return null;
|
||
return {
|
||
start: premierJourDuMois(projet.horizon.start),
|
||
end: dernierJourDuMois(projet.horizon.end),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Emprise temporelle d'un projet, quel que soit son état.
|
||
*
|
||
* L'emprise se calcule depuis les phases ; un projet qui n'en a pas encore la
|
||
* déclare à la main, c'est son horizon. Les phases gagnent donc toujours, et
|
||
* l'horizon n'est qu'une valeur de repli — jamais réécrite par le calcul, ce
|
||
* qui fait qu'un projet ayant perdu sa dernière phase retrouve l'horizon qu'il
|
||
* avait déclaré.
|
||
*/
|
||
export function empriseProjet(projet) {
|
||
return bornesProjet(projet) ?? bornesHorizon(projet);
|
||
}
|
||
|
||
/**
|
||
* Vrai si le projet réclame une date : envisagé sans horizon, ou dont l'horizon
|
||
* est déjà passé.
|
||
*
|
||
* C'est le garde-fou qui empêche la liste des envisagés de devenir une décharge
|
||
* (décision 27). Plutôt qu'un plafond ou une péremption qui effacerait — deux
|
||
* choses interdites par la crainte d'oublier —, un projet dont l'horizon est
|
||
* dépassé se signale : on repousse, ou on écarte.
|
||
*
|
||
* Les deux cas se confondent volontairement en un seul signal. Un projet hérité
|
||
* d'un fichier en version 4, sans phase et sans horizon, réclame une date au
|
||
* même titre qu'un projet qu'on visait pour le trimestre dernier.
|
||
*/
|
||
export function projetADater(projet, mois = moisCourant()) {
|
||
if (etatProjet(projet) !== 'considered') return false;
|
||
return !projet.horizon || projet.horizon.end < mois;
|
||
}
|
||
|
||
/**
|
||
* Horizon rendu lisible : « mars 2027 », « mars → juin 2027 »,
|
||
* « novembre 2026 → février 2027 ».
|
||
*
|
||
* L'année n'est écrite qu'une fois quand les deux bornes la partagent : comme
|
||
* pour la période d'une phase (`formaterPeriode`), son apparition en tête
|
||
* signale à elle seule le débordement.
|
||
*/
|
||
export function formaterHorizon(horizon) {
|
||
if (!horizon) return '';
|
||
if (horizon.start === horizon.end) return formaterMois(horizon.start);
|
||
|
||
const memeAnnee = horizon.start.slice(0, 4) === horizon.end.slice(0, 4);
|
||
const debut = memeAnnee
|
||
? MOIS_LONGS[Number(horizon.start.slice(5, 7)) - 1]
|
||
: formaterMois(horizon.start);
|
||
return `${debut} → ${formaterMois(horizon.end)}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Validation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class ErreurValidation extends Error {}
|
||
|
||
/**
|
||
* Vérifie un planning entier et le renvoie normalisé.
|
||
*
|
||
* Un fichier invalide n'est jamais réparé en silence : on lève une erreur qui
|
||
* nomme le projet et la phase fautifs, pour que le fichier puisse être corrigé
|
||
* à la main.
|
||
*/
|
||
export function validerPlanning(donnees) {
|
||
if (!donnees || typeof donnees !== 'object') {
|
||
throw new ErreurValidation('Le fichier ne contient pas un objet JSON.');
|
||
}
|
||
if (!Array.isArray(donnees.projects)) {
|
||
throw new ErreurValidation("Le fichier ne contient pas de tableau « projects ».");
|
||
}
|
||
if (donnees.version !== undefined && donnees.version > VERSION_FORMAT) {
|
||
throw new ErreurValidation(
|
||
`Fichier en version ${donnees.version}, alors que cet outil lit la version ${VERSION_FORMAT}. ` +
|
||
'Il a probablement été écrit par une version plus récente.'
|
||
);
|
||
}
|
||
|
||
const idsProjets = new Set();
|
||
const projets = donnees.projects.map((projet, index) => {
|
||
const repere = projet && projet.name ? `« ${projet.name} »` : `n°${index + 1}`;
|
||
|
||
if (!projet || typeof projet !== 'object') {
|
||
throw new ErreurValidation(`Projet ${repere} : ce n'est pas un objet.`);
|
||
}
|
||
if (typeof projet.name !== 'string' || !projet.name.trim()) {
|
||
throw new ErreurValidation(`Projet ${repere} : le nom est vide.`);
|
||
}
|
||
if (typeof projet.id !== 'string' || !projet.id) {
|
||
throw new ErreurValidation(`Projet ${repere} : identifiant manquant.`);
|
||
}
|
||
if (idsProjets.has(projet.id)) {
|
||
throw new ErreurValidation(`Deux projets portent l'identifiant « ${projet.id} ».`);
|
||
}
|
||
idsProjets.add(projet.id);
|
||
|
||
for (const cle of ['baselineDate', 'completedDate', 'discardedDate']) {
|
||
if (projet[cle] !== undefined && !dateValide(projet[cle])) {
|
||
throw new ErreurValidation(
|
||
`Projet ${repere} : « ${cle} » n'est pas une date valide (${projet[cle]}).`
|
||
);
|
||
}
|
||
}
|
||
const horizon = validerHorizon(projet.horizon, repere);
|
||
|
||
if (!Array.isArray(projet.phases)) {
|
||
throw new ErreurValidation(`Projet ${repere} : « phases » doit être un tableau.`);
|
||
}
|
||
|
||
const idsPhases = new Set();
|
||
const phases = projet.phases.map((phase, rang) =>
|
||
validerPhase(phase, rang, repere, idsPhases)
|
||
);
|
||
|
||
return {
|
||
id: projet.id,
|
||
name: projet.name.trim(),
|
||
color: MOTIF_COULEUR.test(projet.color || '') ? projet.color : COULEURS[0],
|
||
tags: validerTags(projet.tags, repere),
|
||
collapsed: Boolean(projet.collapsed),
|
||
hidden: Boolean(projet.hidden),
|
||
// Absentes d'un fichier en version 3, et ramenées à vide si elles ne sont
|
||
// pas du texte : une note est de la prose, comme celles d'une phase, et
|
||
// bloquer le chargement d'un planning entier pour un champ mal typé serait
|
||
// disproportionné là où le champ n'engage aucun calcul.
|
||
notes: typeof projet.notes === 'string' ? projet.notes : '',
|
||
...(horizon ? { horizon } : {}),
|
||
...(projet.completedDate ? { completedDate: projet.completedDate } : {}),
|
||
...(projet.discardedDate ? { discardedDate: projet.discardedDate } : {}),
|
||
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
|
||
phases: trierPhases(phases),
|
||
};
|
||
});
|
||
|
||
return { version: VERSION_FORMAT, projects: projets };
|
||
}
|
||
|
||
/**
|
||
* L'horizon est facultatif — aucun fichier en version 4 n'en porte — mais s'il
|
||
* est là, il est vérifié aussi strictement que les dates d'une phase et non
|
||
* réparé en silence comme le sont le statut ou les notes : il décide d'une
|
||
* position sur la frise, et un horizon avalé laisserait un projet envisagé
|
||
* invisible sans qu'on sache pourquoi.
|
||
*/
|
||
function validerHorizon(horizon, repere) {
|
||
if (horizon === undefined) return undefined;
|
||
if (!horizon || typeof horizon !== 'object') {
|
||
throw new ErreurValidation(`Projet ${repere} : « horizon » doit être un objet.`);
|
||
}
|
||
if (!moisValide(horizon.start) || !moisValide(horizon.end)) {
|
||
throw new ErreurValidation(
|
||
`Projet ${repere} : l'horizon doit porter deux mois « AAAA-MM » ` +
|
||
`(${horizon.start} → ${horizon.end}).`
|
||
);
|
||
}
|
||
// « AAAA-MM » se compare comme du texte.
|
||
if (horizon.end < horizon.start) {
|
||
throw new ErreurValidation(
|
||
`Projet ${repere} : la fin de l'horizon (${horizon.end}) précède son début (${horizon.start}).`
|
||
);
|
||
}
|
||
return { start: horizon.start, end: horizon.end };
|
||
}
|
||
|
||
/**
|
||
* Les tags sont facultatifs — un fichier en version 2 n'en a aucun — mais s'ils
|
||
* sont là, ils doivent être des chaînes : un tag avalé en silence parce qu'il
|
||
* était mal typé serait un tag qu'on croit poser et qui ne filtre rien.
|
||
*/
|
||
function validerTags(tags, repere) {
|
||
if (tags === undefined) return [];
|
||
if (!Array.isArray(tags)) {
|
||
throw new ErreurValidation(`Projet ${repere} : « tags » doit être un tableau.`);
|
||
}
|
||
for (const tag of tags) {
|
||
if (typeof tag !== 'string') {
|
||
throw new ErreurValidation(`Projet ${repere} : un tag n'est pas une chaîne.`);
|
||
}
|
||
}
|
||
return normaliserTags(tags);
|
||
}
|
||
|
||
function validerPhase(phase, rang, repereProjet, idsPhases) {
|
||
const repere = phase && phase.name ? `« ${phase.name} »` : `n°${rang + 1}`;
|
||
const ou = `Projet ${repereProjet}, phase ${repere}`;
|
||
|
||
if (!phase || typeof phase !== 'object') {
|
||
throw new ErreurValidation(`${ou} : ce n'est pas un objet.`);
|
||
}
|
||
if (typeof phase.name !== 'string' || !phase.name.trim()) {
|
||
throw new ErreurValidation(`${ou} : le nom est vide.`);
|
||
}
|
||
if (typeof phase.id !== 'string' || !phase.id) {
|
||
throw new ErreurValidation(`${ou} : identifiant manquant.`);
|
||
}
|
||
if (idsPhases.has(phase.id)) {
|
||
throw new ErreurValidation(
|
||
`Projet ${repereProjet} : deux phases portent l'identifiant « ${phase.id} ».`
|
||
);
|
||
}
|
||
idsPhases.add(phase.id);
|
||
|
||
if (!dateValide(phase.start)) {
|
||
throw new ErreurValidation(`${ou} : date de début invalide (${phase.start}).`);
|
||
}
|
||
if (!dateValide(phase.end)) {
|
||
throw new ErreurValidation(`${ou} : date de fin invalide (${phase.end}).`);
|
||
}
|
||
if (versUTC(phase.end) < versUTC(phase.start)) {
|
||
throw new ErreurValidation(
|
||
`${ou} : la fin (${phase.end}) précède le début (${phase.start}).`
|
||
);
|
||
}
|
||
|
||
const jalon = Boolean(phase.milestone);
|
||
if (jalon && phase.start !== phase.end) {
|
||
throw new ErreurValidation(
|
||
`${ou} : un jalon doit tenir sur un seul jour (${phase.start} → ${phase.end}).`
|
||
);
|
||
}
|
||
|
||
let reference;
|
||
if (phase.baseline !== undefined) {
|
||
const b = phase.baseline;
|
||
if (!b || !dateValide(b.start) || !dateValide(b.end)) {
|
||
throw new ErreurValidation(`${ou} : la référence contient une date invalide.`);
|
||
}
|
||
if (versUTC(b.end) < versUTC(b.start)) {
|
||
throw new ErreurValidation(`${ou} : la fin de référence précède son début.`);
|
||
}
|
||
reference = { start: b.start, end: b.end };
|
||
}
|
||
|
||
return {
|
||
id: phase.id,
|
||
name: phase.name.trim(),
|
||
start: phase.start,
|
||
end: phase.end,
|
||
// Un statut inconnu est ramené à « à venir » plutôt que de bloquer le
|
||
// 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 } : {}),
|
||
};
|
||
}
|
||
|
||
function trierPhases(phases) {
|
||
return [...phases].sort(
|
||
(a, b) => versUTC(a.start) - versUTC(b.start) || a.name.localeCompare(b.name, 'fr')
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bornes et dérive
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Étendue d'un projet, de sa première phase à sa dernière.
|
||
* Renvoie null pour un projet sans phase.
|
||
*/
|
||
export function bornesProjet(projet) {
|
||
if (!projet.phases.length) return null;
|
||
let debut = projet.phases[0].start;
|
||
let fin = projet.phases[0].end;
|
||
for (const phase of projet.phases) {
|
||
if (versUTC(phase.start) < versUTC(debut)) debut = phase.start;
|
||
if (versUTC(phase.end) > versUTC(fin)) fin = phase.end;
|
||
}
|
||
return { start: debut, end: fin };
|
||
}
|
||
|
||
/**
|
||
* Étendue de tous les projets visibles réunis. Null si rien à afficher.
|
||
*
|
||
* Elle sert à cadrer la fenêtre au premier affichage, et prend donc l'emprise
|
||
* plutôt que les seules phases : sans cela, un planning fait de projets encore
|
||
* tous envisagés s'ouvrirait sur rien. Les écartés en sont exclus — ils ne
|
||
* s'affichent pas d'ordinaire, et un projet abandonné il y a trois ans n'a pas à
|
||
* tirer la vue en arrière.
|
||
*/
|
||
export function bornesPlanning(planning) {
|
||
let debut = null;
|
||
let fin = null;
|
||
for (const projet of planning.projects) {
|
||
if (projet.hidden || etatProjet(projet) === 'discarded') continue;
|
||
const bornes = empriseProjet(projet);
|
||
if (!bornes) continue;
|
||
if (debut === null || versUTC(bornes.start) < versUTC(debut)) debut = bornes.start;
|
||
if (fin === null || versUTC(bornes.end) > versUTC(fin)) fin = bornes.end;
|
||
}
|
||
return debut === null ? null : { start: debut, end: fin };
|
||
}
|
||
|
||
/**
|
||
* Fige le planning courant comme référence : chaque phase mémorise ses dates
|
||
* actuelles. L'opération est rejouable — la refiger après un arbitrage assumé
|
||
* repart d'une base propre.
|
||
*/
|
||
export function figerReference(projet, date = aujourdhui()) {
|
||
return {
|
||
...projet,
|
||
baselineDate: date,
|
||
phases: projet.phases.map((phase) => ({
|
||
...phase,
|
||
baseline: { start: phase.start, end: phase.end },
|
||
})),
|
||
};
|
||
}
|
||
|
||
/** Retire la référence d'un projet. */
|
||
export function libererReference(projet) {
|
||
return {
|
||
...projet,
|
||
baselineDate: undefined,
|
||
phases: projet.phases.map(({ baseline, ...reste }) => reste),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Dérive d'un projet, en jours, entre sa fin actuelle et sa fin de référence.
|
||
* Positive si le projet a glissé. Null si aucune référence n'a été figée.
|
||
*
|
||
* Une phase ajoutée après le figeage n'a pas de référence : elle ne compte pas
|
||
* dans la fin de référence mais compte dans la fin actuelle. Ajouter une phase
|
||
* en fin de projet crée donc bien une dérive, ce qui est voulu.
|
||
*/
|
||
export function deriveProjet(projet) {
|
||
const avecReference = projet.phases.filter((phase) => phase.baseline);
|
||
if (!avecReference.length) return null;
|
||
|
||
const finReference = avecReference.reduce(
|
||
(max, phase) => (versUTC(phase.baseline.end) > versUTC(max) ? phase.baseline.end : max),
|
||
avecReference[0].baseline.end
|
||
);
|
||
|
||
const bornes = bornesProjet(projet);
|
||
if (!bornes) return null;
|
||
|
||
return ecartJours(finReference, bornes.end);
|
||
}
|
||
|
||
/** Étendue de référence d'un projet, pour la barre fantôme. Null si non figée. */
|
||
export function bornesReference(projet) {
|
||
const avecReference = projet.phases.filter((phase) => phase.baseline);
|
||
if (!avecReference.length) return null;
|
||
|
||
let debut = avecReference[0].baseline.start;
|
||
let fin = avecReference[0].baseline.end;
|
||
for (const { baseline } of avecReference) {
|
||
if (versUTC(baseline.start) < versUTC(debut)) debut = baseline.start;
|
||
if (versUTC(baseline.end) > versUTC(fin)) fin = baseline.end;
|
||
}
|
||
return { start: debut, end: fin };
|
||
}
|
||
|
||
/** Formule une dérive en semaines, prête à afficher. Null si négligeable. */
|
||
export function formulerDerive(jours) {
|
||
if (jours === null || jours === 0) return null;
|
||
const semaines = Math.round(Math.abs(jours) / 7);
|
||
// Sous la demi-semaine, on annonce les jours plutôt qu'un « 0 semaine ».
|
||
const quantite =
|
||
semaines === 0
|
||
? `${Math.abs(jours)} j`
|
||
: `${semaines} sem`;
|
||
return jours > 0 ? `+${quantite}` : `−${quantite}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Regroupement par mois
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Tout ce que le planning fait arriver, regroupé par mois, projets mêlés.
|
||
*
|
||
* Deux sortes d'entrées y cohabitent, et la seconde est venue plus tard :
|
||
*
|
||
* - une **phase**, rattachée au mois de son début et à lui seul. Une phase de
|
||
* trois mois n'apparaît donc qu'une fois, au mois où elle démarre : la liste
|
||
* répond à « qu'est-ce qui commence, et quand », question à laquelle la frise
|
||
* répond mal parce qu'elle éparpille les départs sur autant de couloirs qu'il
|
||
* y a de projets. La répéter dans chaque mois traversé en aurait fait un
|
||
* tableau de charge — utile, mais c'est une autre vue, et le doublon coûte la
|
||
* lecture en diagonale qui fait tout l'intérêt de celle-ci. Sa fin est écrite
|
||
* sur la ligne, ce qui suffit à voir qu'elle déborde.
|
||
* - un **projet sans phase**, rattaché au premier mois de son horizon. Il n'a
|
||
* rien à lister autrement, donc il ne paraissait nulle part ici — alors qu'un
|
||
* projet visé pour mars 2027 *arrive* bel et bien en mars 2027. L'omettre
|
||
* revenait à dire que la liste ne montre que l'engagé, quand toute la
|
||
* conception pose qu'un projet envisagé n'est pas hors du temps (décision 27).
|
||
* La règle porte sur l'absence de phases et non sur l'état, exactement comme
|
||
* `empriseProjet` : un projet écarté avant d'avoir été engagé n'a lui non plus
|
||
* que son horizon pour se situer, et il doit se retrouver là quand on ouvre le
|
||
* cimetière.
|
||
*
|
||
* Les tamis de la frise s'appliquent tous de la même façon (timeline.js) : le
|
||
* filtre par tags retire le projet, celui par état aussi, et l'œil (`hidden`)
|
||
* de même — un projet dont on a masqué les barres n'a pas à revenir par la
|
||
* liste, et un projet écarté n'a pas à peupler les mois à venir.
|
||
*
|
||
* Les mois sans rien ne sont pas représentés : intercaler « novembre 2026 —
|
||
* rien » entre deux mois pleins allongerait la liste de tout le temps mort d'un
|
||
* planning, alors que l'absence se lit déjà dans le saut d'un titre à l'autre.
|
||
*
|
||
* @returns {Array<{mois: string, entrees: Array<{projet: object, phase?: object, horizon?: object}>}>}
|
||
* trié du plus ancien au plus récent, chaque mois trié par date puis par nom.
|
||
*/
|
||
export function entreesParMois(planning, tagsActifs = [], etatsActifs = []) {
|
||
const groupes = new Map();
|
||
const poser = (mois, entree) => {
|
||
if (!groupes.has(mois)) groupes.set(mois, []);
|
||
groupes.get(mois).push(entree);
|
||
};
|
||
|
||
for (const projet of planning.projects) {
|
||
if (projet.hidden || !projetFiltre(projet, tagsActifs)) continue;
|
||
if (!projetFiltreEtat(projet, etatsActifs)) continue;
|
||
|
||
for (const phase of projet.phases) {
|
||
poser(moisDe(phase.start), { projet, phase });
|
||
}
|
||
|
||
// Un projet sans horizon n'a rien à quoi se rattacher : il se signale déjà
|
||
// dans la colonne de la frise, où sa pastille « à dater » réclame une date.
|
||
if (!projet.phases.length && projet.horizon) {
|
||
poser(projet.horizon.start, { projet, horizon: projet.horizon });
|
||
}
|
||
}
|
||
|
||
return [...groupes.keys()]
|
||
.sort() // « AAAA-MM » se trie comme du texte
|
||
.map((mois) => ({
|
||
mois,
|
||
entrees: groupes.get(mois).sort(
|
||
(a, b) =>
|
||
versUTC(debutEntree(a)) - versUTC(debutEntree(b)) ||
|
||
a.projet.name.localeCompare(b.projet.name, 'fr') ||
|
||
nomEntree(a).localeCompare(nomEntree(b), 'fr')
|
||
),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Date à laquelle une entrée commence, pour le tri. Un horizon n'ayant pas de
|
||
* jour, il prend le premier du mois — ce qui le place en tête des entrées de son
|
||
* mois, à sa place : c'est ce qui est le moins arrêté qui ouvre la liste.
|
||
*/
|
||
function debutEntree(entree) {
|
||
return entree.phase ? entree.phase.start : premierJourDuMois(entree.horizon.start);
|
||
}
|
||
|
||
function nomEntree(entree) {
|
||
return entree.phase ? entree.phase.name : entree.projet.name;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Modifications
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Un projet naît **envisagé** : sans phase, avec pour seul ancrage un horizon
|
||
* d'un mois, celui où on le crée.
|
||
*
|
||
* Ce mois par défaut n'est pas une prévision, c'est une position de départ — la
|
||
* barre paraît sous les yeux, là où on regarde, et se repousse aussitôt. Naître
|
||
* sans horizon aurait été plus honnête mais invisible, et la décision 27 pose
|
||
* qu'un projet envisagé n'est jamais hors du temps.
|
||
*/
|
||
export function creerProjet(planning, nom, tags = [], mois = moisCourant()) {
|
||
const id = fabriquerId(nom, planning.projects.map((p) => p.id));
|
||
return {
|
||
id,
|
||
name: nom.trim(),
|
||
color: COULEURS[planning.projects.length % COULEURS.length],
|
||
tags: normaliserTags(tags),
|
||
collapsed: false,
|
||
hidden: false,
|
||
notes: '',
|
||
horizon: { start: mois, end: mois },
|
||
phases: [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Pose l'horizon d'un projet. Les bornes se remettent d'aplomb plutôt que d'être
|
||
* refusées : la saisie se fait au fil de la frappe, et un instant où la fin
|
||
* précède le début est l'état normal de quelqu'un qui écrit son intervalle.
|
||
*/
|
||
export function definirHorizon(projet, debut, fin = debut) {
|
||
return { ...projet, horizon: { start: debut, end: fin < debut ? debut : fin } };
|
||
}
|
||
|
||
/**
|
||
* Repousse — ou avance — l'horizon d'un nombre de mois, largeur conservée.
|
||
*
|
||
* C'est le seul geste qui déplace un horizon. La barre d'un projet envisagé ne
|
||
* se glisse pas au jour : l'imprécision doit rester visible, et l'interdire
|
||
* physiquement vaut mieux que de compter sur une convention (décision 27).
|
||
*/
|
||
export function decalerHorizon(projet, mois) {
|
||
if (!projet.horizon) return projet;
|
||
return {
|
||
...projet,
|
||
horizon: {
|
||
start: ajouterMois(projet.horizon.start, mois),
|
||
end: ajouterMois(projet.horizon.end, mois),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Acte la clôture d'un projet, et **termine toutes ses phases avec lui**.
|
||
*
|
||
* Livré n'est pas clos : c'est quelqu'un qui le prononce, même quand une phase
|
||
* traîne encore en « à venir ». Mais prononcer la clôture d'un projet dont une
|
||
* phase resterait bloquée laisserait la frise se contredire — la barre
|
||
* cumulative garderait ses segments pâles sous une ligne éteinte, et la vue par
|
||
* mois annoncerait des tâches à venir dans un projet fini.
|
||
*
|
||
* L'opération est donc **destructive** et ne se défait pas : `rouvrirProjet` ne
|
||
* peut pas savoir laquelle des phases était bloquée et laquelle était à venir.
|
||
* D'où la confirmation demandée par l'interface dès qu'une phase resterait à
|
||
* changer (docs/decisions.md, section 27).
|
||
*/
|
||
export function terminerProjet(projet, date = aujourdhui()) {
|
||
return {
|
||
...projet,
|
||
completedDate: date,
|
||
discardedDate: undefined,
|
||
phases: projet.phases.map((phase) =>
|
||
phase.status === 'done' ? phase : { ...phase, status: 'done' }
|
||
),
|
||
};
|
||
}
|
||
|
||
/** Vrai si clore le projet changerait le statut d'au moins une de ses phases. */
|
||
export function clotureEcraseDesStatuts(projet) {
|
||
return projet.phases.some((phase) => phase.status !== 'done');
|
||
}
|
||
|
||
/**
|
||
* Annule la clôture : le projet retourne à l'état que ses phases commandent.
|
||
*
|
||
* Les statuts, eux, restent à « terminé » — ils ont été écrasés à la clôture, et
|
||
* rien ne dit ce qu'ils valaient avant. C'est le prix de l'opération, et la
|
||
* raison pour laquelle elle se confirme.
|
||
*/
|
||
export function rouvrirProjet(projet) {
|
||
return { ...projet, completedDate: undefined };
|
||
}
|
||
|
||
/**
|
||
* Écarte un projet. Ce n'est pas le supprimer : il quitte la frise mais garde
|
||
* tout, notes comprises — la raison pour laquelle on a dit non a de la valeur le
|
||
* jour où la même idée revient.
|
||
*
|
||
* La date est le seul ancrage temporel qui reste à un projet jamais engagé, dont
|
||
* l'horizon a cessé d'être une prévision. Elle est posée, jamais saisie.
|
||
*/
|
||
export function ecarterProjet(projet, date = aujourdhui()) {
|
||
return { ...projet, discardedDate: date, completedDate: undefined };
|
||
}
|
||
|
||
/**
|
||
* Sort un projet du cimetière. Aucune destination à choisir : puisque les états
|
||
* se déduisent, retirer l'écartement suffit — le projet retrouve seul l'état que
|
||
* ses données commandent, engagé s'il a des phases, envisagé sinon.
|
||
*
|
||
* Son horizon, lui, est resté celui d'avant : il se signalera aussitôt comme
|
||
* dépassé, et c'est voulu.
|
||
*/
|
||
export function reanimerProjet(projet) {
|
||
return { ...projet, discardedDate: undefined };
|
||
}
|
||
|
||
export function creerPhase(projet, nom, debut, fin, options = {}) {
|
||
const id = fabriquerId(nom, projet.phases.map((p) => p.id));
|
||
const jalon = Boolean(options.milestone);
|
||
return {
|
||
id,
|
||
name: nom.trim(),
|
||
start: debut,
|
||
// Un jalon tient sur un jour : cocher la case ramène la fin sur le début.
|
||
end: jalon ? debut : fin,
|
||
status: STATUTS.includes(options.status) ? options.status : 'todo',
|
||
milestone: jalon,
|
||
fixed: Boolean(options.fixed),
|
||
notes: options.notes || '',
|
||
};
|
||
}
|
||
|
||
/** Décale une phase en conservant sa durée — le glisser du corps de la barre. */
|
||
export function deplacerPhase(phase, nouveauDebut) {
|
||
const jours = duree(phase) - 1;
|
||
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.
|
||
*/
|
||
export function redimensionnerPhase(phase, bord, date) {
|
||
if (phase.milestone) return phase;
|
||
if (bord === 'debut') {
|
||
return { ...phase, start: versUTC(date) > versUTC(phase.end) ? phase.end : date };
|
||
}
|
||
return { ...phase, end: versUTC(date) < versUTC(phase.start) ? phase.start : date };
|
||
}
|
||
|
||
/**
|
||
* Applique des changements à une phase, en maintenant les invariants :
|
||
* fin postérieure au début, et jalon tenant sur un seul jour.
|
||
*/
|
||
export function modifierPhase(phase, changements) {
|
||
const suivante = { ...phase, ...changements };
|
||
|
||
if (suivante.milestone) {
|
||
suivante.end = suivante.start;
|
||
} else if (versUTC(suivante.end) < versUTC(suivante.start)) {
|
||
// L'utilisateur vient de saisir une fin antérieure au début : on suppose
|
||
// qu'il voulait déplacer la phase, et on conserve la durée précédente.
|
||
suivante.end = ajouterJours(suivante.start, duree(phase) - 1);
|
||
}
|
||
|
||
return suivante;
|
||
}
|
||
|
||
/** Renvoie un planning où le projet d'identifiant `id` a été transformé. */
|
||
export function remplacerProjet(planning, id, transformation) {
|
||
return {
|
||
...planning,
|
||
projects: planning.projects.map((projet) =>
|
||
projet.id === id ? transformation(projet) : projet
|
||
),
|
||
};
|
||
}
|
||
|
||
/** Renvoie un projet où la phase d'identifiant `id` a été transformée. */
|
||
export function remplacerPhase(projet, id, transformation) {
|
||
return {
|
||
...projet,
|
||
phases: trierPhases(
|
||
projet.phases.map((phase) => (phase.id === id ? transformation(phase) : phase))
|
||
),
|
||
};
|
||
}
|
||
|
||
export function supprimerPhase(projet, id) {
|
||
return { ...projet, phases: projet.phases.filter((phase) => phase.id !== id) };
|
||
}
|
||
|
||
export function supprimerProjet(planning, id) {
|
||
return { ...planning, projects: planning.projects.filter((projet) => projet.id !== id) };
|
||
}
|
||
|
||
export function ajouterPhase(projet, phase) {
|
||
return { ...projet, phases: trierPhases([...projet.phases, phase]) };
|
||
}
|
||
|
||
/**
|
||
* Prépare le planning pour l'écriture : on retire les clés `undefined`, que
|
||
* `JSON.stringify` omettrait de toute façon, pour que le fichier écrit soit
|
||
* exactement ce que le modèle décrit.
|
||
*/
|
||
export function pourEcriture(planning) {
|
||
return JSON.parse(JSON.stringify({ version: VERSION_FORMAT, projects: planning.projects }));
|
||
}
|
||
|
||
export function planningVide() {
|
||
return { version: VERSION_FORMAT, projects: [] };
|
||
}
|