Un projet porte désormais une liste d'étiquettes libres, destinées à filtrer la frise. Il n'y a pas de référentiel à tenir : les tags disponibles sont ceux que portent les projets. normaliserTags() nettoie la liste à l'enregistrement — espaces réduits, vides écartés, doublons fusionnés à la casse près, tri alphabétique pour qu'une ressaisie dans un autre ordre ne produise aucun diff. Les accents, eux, comptent : « éditeur » et « editeur » restent deux tags distincts, contrairement aux identifiants qui doivent tenir dans une URL. Un tag trop long est tronqué plutôt que refusé : sa longueur est cosmétique, et bloquer le chargement d'un planning entier pour un libellé bavard serait disproportionné. La validation ne vérifie donc que le type — un tag mal typé serait un tag qu'on croit poser et qui ne filtre rien. teinteTag() dérive une teinte HSL du nom par hachage : la couleur d'un tag n'est ni stockée ni saisie, et reste la même partout où il apparaît. Le format passe en version 3. Un fichier en version 2 se charge sans rien demander, ses projets recevant une liste vide, et il est réécrit en version 3 à la première sauvegarde. Le bump empêche une version antérieure de l'outil d'effacer silencieusement les tags en réécrivant le fichier. Dix tests couvrent la normalisation, la comparaison, la teinte et le filtre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
614 lines
21 KiB
JavaScript
614 lines
21 KiB
JavaScript
/**
|
||
* Données et règles métier du planning.
|
||
*
|
||
* Ce module ne touche ni au DOM ni au réseau : il transforme des objets et
|
||
* lève des erreurs. C'est ce qui le rend testable sous Node (tests/model.test.js).
|
||
*
|
||
* Convention de dates : partout des chaînes « AAAA-MM-JJ », jamais d'objets
|
||
* Date. `new Date("2026-08-01")` est interprétée en UTC alors que
|
||
* `new Date(2026, 7, 1)` l'est en heure locale — mélanger les deux décale d'un
|
||
* jour selon le fuseau. Les conversions restent confinées aux helpers ci-dessous.
|
||
*
|
||
* La date de fin est *incluse* : du 01/08 au 01/08 dure un jour.
|
||
*/
|
||
|
||
export const VERSION_FORMAT = 3;
|
||
|
||
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}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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} »` : `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],
|
||
tags: validerTags(projet.tags, repere),
|
||
collapsed: Boolean(projet.collapsed),
|
||
hidden: Boolean(projet.hidden),
|
||
...(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} »` : `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, 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,
|
||
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: [] };
|
||
}
|