/** * 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. */ export const VERSION_FORMAT = 2; export const STATUTS = ['todo', 'doing', 'done', 'blocked']; 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', ]; const MOTIF_DATE = /^\d{4}-\d{2}-\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)), }; } // --------------------------------------------------------------------------- // 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}`; } // --------------------------------------------------------------------------- // 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); if (projet.baselineDate !== undefined && !dateValide(projet.baselineDate)) { throw new ErreurValidation( `Projet ${repere} : « baselineDate » n'est pas une date valide (${projet.baselineDate}).` ); } 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], collapsed: Boolean(projet.collapsed), hidden: Boolean(projet.hidden), ...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}), phases: trierPhases(phases), }; }); return { version: VERSION_FORMAT, projects: projets }; } 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, 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. */ export function bornesPlanning(planning) { let debut = null; let fin = null; for (const projet of planning.projects) { if (projet.hidden) continue; const bornes = bornesProjet(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}`; } // --------------------------------------------------------------------------- // Modifications // --------------------------------------------------------------------------- export function creerProjet(planning, nom) { const id = fabriquerId(nom, planning.projects.map((p) => p.id)); return { id, name: nom.trim(), color: COULEURS[planning.projects.length % COULEURS.length], collapsed: false, hidden: false, phases: [], }; } 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, 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) }; } /** * 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: [] }; }