Files
gestion_projets/js/model.js
Bertrand Benjamin 4a513f9a59 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>
2026-08-10 18:07:31 +02:00

1165 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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} »` : `${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} »` : `${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: [] };
}