/** * 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é', }; /** * Ce qu'une phase est aujourd'hui, en une ligne : son statut, et la mention de * sa date imposée quand elle en porte une. * * Les deux se lisent ensemble — « en cours, date imposée » — et occupent le même * registre, d'où une seule phrase plutôt qu'une ligne chacun. * * Ce libellé vit ici, et non dans la vue qui l'affiche, parce que les deux vues * l'écrivent : la frise sur ses barres, ses jalons et ses libellés, la vue par * mois sur ses lignes. Recomposé sur place, il avait fini par diverger — la vue * par mois taisait la date imposée que la frise annonçait. */ export function libelleStatut(phase) { const statut = LIBELLES_STATUT[phase.status] ?? LIBELLES_STATUT.todo; if (!phase.fixed) return statut; return `${statut} — date imposée, non emportée par un décalage du projet`; } /** 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: [] }; }