Files
gestion_projets/js/model.js
Bertrand Benjamin ff147a17b9 Rendre les notes en markdown, et en donner au projet
Une phase avait quatre lignes de texte brut ; un projet n'avait rien. Les deux
manques se tiennent : la frise ne dit pas qui pilote ni sur quel budget, et ce
contexte finissait dans les notes de la première phase venue, où il ne survit
pas à sa suppression.

Le bloc de notes est le même dans les deux panneaux et prend toute la hauteur
restante : les autres champs ont une taille dictée par leur contenu, une note
fait ce qu'on a à dire. Il s'ouvre sur l'aperçu quand la note existe, sur la
saisie quand elle est vide.

Les cases à cocher se cliquent dans l'aperçu — seul geste de l'aperçu qui touche
aux données. La bascule réécrit la seule ligne visée plutôt que de régénérer la
note, qui verrait sinon sa mise en forme normalisée. Inertes dans la vue par
mois, en lecture seule (décision 24).

La barre d'outils écrit par `document.execCommand('insertText')` et non en
affectant `value`, qui viderait la pile d'annulation du navigateur : `Ctrl+Z`
doit continuer de marcher. Le DOM est bâti nœud par nœud, sans un `innerHTML`.

Les infobulles aplatissent le markdown — `title` ne connaît que le texte —, et
celles d'un jalon et d'un libellé de projet les affichent désormais.

Le format passe en version 4 : le validateur reconstruit chaque projet champ par
champ, un binaire antérieur effacerait les notes de projet à la première
sauvegarde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 18:35:17 +02:00

735 lines
26 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 le projet a reçu des `notes` (v4).
* Un champ ajouté n'oblige pourtant à rien : la v3 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 v4 sans broncher et en effacerait
* toutes les notes de projet à la première sauvegarde.
*/
export const VERSION_FORMAT = 4;
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',
];
/**
* 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_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)}`;
}
/**
* 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;
}
// ---------------------------------------------------------------------------
// 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);
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],
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 : '',
...(projet.baselineDate ? { baselineDate: projet.baselineDate } : {}),
phases: trierPhases(phases),
};
});
return { version: VERSION_FORMAT, projects: projets };
}
/**
* 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,
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}`;
}
// ---------------------------------------------------------------------------
// Regroupement par mois
// ---------------------------------------------------------------------------
/**
* Toutes les phases du planning regroupées par mois, projets mêlés.
*
* Une phase est 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.
*
* Les deux tamis de la frise s'appliquent de la même façon (timeline.js) : le
* filtre par tags retire le projet, l'œil (`hidden`) aussi — un projet dont on
* a masqué les barres n'a pas à revenir par la liste.
*
* 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}>}>}
* trié du plus ancien au plus récent, chaque mois trié par date puis par nom.
*/
export function phasesParMois(planning, tagsActifs = []) {
const groupes = new Map();
for (const projet of planning.projects) {
if (projet.hidden || !projetFiltre(projet, tagsActifs)) continue;
for (const phase of projet.phases) {
const mois = moisDe(phase.start);
if (!groupes.has(mois)) groupes.set(mois, []);
groupes.get(mois).push({ projet, phase });
}
}
return [...groupes.keys()]
.sort() // « AAAA-MM » se trie comme du texte
.map((mois) => ({
mois,
entrees: groupes.get(mois).sort(
(a, b) =>
versUTC(a.phase.start) - versUTC(b.phase.start) ||
a.projet.name.localeCompare(b.projet.name, 'fr') ||
a.phase.name.localeCompare(b.phase.name, 'fr')
),
}));
}
// ---------------------------------------------------------------------------
// Modifications
// ---------------------------------------------------------------------------
export function creerProjet(planning, nom, tags = []) {
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: '',
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: [] };
}