Une frise chronologique unique où plusieurs projets s'empilent en couloirs pliables, pour voir d'un coup d'œil où en est chacun et comment ils se situent les uns par rapport aux autres. Ce n'est pas un outil de suivi quotidien : une phase se compte en semaines, et il n'y a ni sous-tâches, ni tickets, ni dépendances entre phases. - Manipulation directe des barres (glisser, redimensionner), accrochées au lundi - Couloirs pliables : plié, un projet devient une barre segmentée par phase - Planning de référence figeable, avec barre fantôme et calcul de dérive - En-tête à trois bandes : année, mois, numéro de semaine ISO 8601 - Frise qui s'élargit au défilement, pour planifier dans un futur encore vide - Micro-serveur Python (bibliothèque standard) exposant GET/PUT sur /api/data, avec sauvegarde horodatée avant chaque écriture - Zéro build : modules ES natifs, aucune dépendance à installer 50 tests unitaires sur la logique métier (node --test, sans dépendance). Vérifié dans Chromium et Firefox. Les arbitrages de conception et surtout leurs raisons sont consignés dans docs/decisions.md — notamment l'abandon des dépendances entre phases, de la File System Access API, du SVG et des niveaux de zoom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
516 lines
18 KiB
JavaScript
516 lines
18 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 = 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: [] };
|
||
}
|