Frise multi-projets : outil de planification macro
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>
This commit is contained in:
596
js/app.js
Normal file
596
js/app.js
Normal file
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* Amorçage et câblage.
|
||||
*
|
||||
* L'état vit ici, dans une seule variable `planning`. Toute modification passe
|
||||
* par `majPlanning`, qui rend la frise et déclenche la sauvegarde : il n'y a
|
||||
* qu'un seul chemin pour changer quelque chose, ce qui rend le flux facile à
|
||||
* suivre.
|
||||
*/
|
||||
|
||||
import { creerPanneau } from './detail.js';
|
||||
import { creerDialogueConfirmation, creerDialogueProjet, creerMenu } from './menu.js';
|
||||
import { decalerAuClavier, etirerAuClavier, installerGlisser } from './drag.js';
|
||||
import { PIXELS_PAR_JOUR, formaterDateLongue, rendre } from './timeline.js';
|
||||
import { creerStockage, viderAvantFermeture } from './storage.js';
|
||||
import {
|
||||
COULEURS,
|
||||
ajouterJours,
|
||||
ajouterPhase,
|
||||
aujourdhui,
|
||||
bornesPlanning,
|
||||
creerPhase,
|
||||
creerProjet,
|
||||
figerReference,
|
||||
libererReference,
|
||||
planningVide,
|
||||
pourEcriture,
|
||||
remplacerPhase,
|
||||
remplacerProjet,
|
||||
supprimerPhase,
|
||||
supprimerProjet,
|
||||
validerPlanning,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const refs = {
|
||||
libelles: $('libelles'),
|
||||
entete: $('entete'),
|
||||
couloirs: $('couloirs'),
|
||||
frise: $('frise'),
|
||||
etat: $('etat-sauvegarde'),
|
||||
messageVide: $('message-vide'),
|
||||
alerte: $('alerte'),
|
||||
alerteMessage: $('alerte-message'),
|
||||
};
|
||||
|
||||
let planning = planningVide();
|
||||
let echelle = null;
|
||||
let selection = null; // { projet, phase }
|
||||
|
||||
/**
|
||||
* Fenêtre temporelle affichée. C'est un état de vue, jamais enregistré.
|
||||
*
|
||||
* Elle démarre autour des phases existantes puis s'élargit toute seule quand le
|
||||
* défilement approche d'un bord, ce qui permet d'aller poser une phase dans un
|
||||
* futur qu'aucune donnée n'occupe encore.
|
||||
*/
|
||||
let fenetre = null;
|
||||
|
||||
/** Marge conservée autour des phases : on peut toujours glisser au-delà. */
|
||||
const MARGE_JOURS = 120;
|
||||
|
||||
/** Longueur ajoutée à chaque élargissement, environ six mois. */
|
||||
const PAS_ELARGISSEMENT = 182;
|
||||
|
||||
/** Distance au bord, en pixels, à partir de laquelle on élargit. */
|
||||
const SEUIL_ELARGISSEMENT = 400;
|
||||
|
||||
/**
|
||||
* Garde-fou : l'élargissement s'arrête à dix ans de part et d'autre des
|
||||
* données. « Infini » au sens de l'usage — on ne planifie pas au-delà — sans
|
||||
* laisser un défilement prolongé faire enfler le DOM sans limite.
|
||||
*/
|
||||
const PORTEE_MAX_ANNEES = 10;
|
||||
|
||||
const stockage = creerStockage(afficherEtat);
|
||||
const menu = creerMenu($('menu'));
|
||||
|
||||
const demanderProjet = creerDialogueProjet(
|
||||
{
|
||||
dialogue: $('dialogue-projet'),
|
||||
titre: $('dialogue-projet-titre'),
|
||||
champNom: $('dialogue-projet-nom'),
|
||||
conteneurCouleurs: $('dialogue-projet-couleurs'),
|
||||
erreur: $('dialogue-projet-erreur'),
|
||||
valider: $('dialogue-projet-ok'),
|
||||
},
|
||||
COULEURS
|
||||
);
|
||||
|
||||
const demanderConfirmation = creerDialogueConfirmation({
|
||||
dialogue: $('dialogue-confirmation'),
|
||||
titre: $('dialogue-confirmation-titre'),
|
||||
message: $('dialogue-confirmation-message'),
|
||||
valider: $('dialogue-confirmation-ok'),
|
||||
});
|
||||
|
||||
const panneau = creerPanneau(
|
||||
{
|
||||
panneau: $('panneau'),
|
||||
voile: $('voile'),
|
||||
formulaire: $('formulaire-phase'),
|
||||
titre: $('panneau-titre'),
|
||||
erreur: $('erreur-phase'),
|
||||
champFin: $('champ-fin'),
|
||||
supprimer: $('supprimer-phase'),
|
||||
fermer: $('fermer-panneau'),
|
||||
},
|
||||
{
|
||||
surModification(idProjet, idPhase, phase) {
|
||||
majPlanning(
|
||||
remplacerProjet(planning, idProjet, (projet) =>
|
||||
remplacerPhase(projet, idPhase, () => phase)
|
||||
)
|
||||
);
|
||||
},
|
||||
demanderSuppression(_idProjet, _idPhase, nomPhase) {
|
||||
return demanderConfirmation({
|
||||
titreTexte: 'Supprimer la phase',
|
||||
messageTexte: `« ${nomPhase} » sera définitivement supprimée du planning.`,
|
||||
libelleValider: 'Supprimer',
|
||||
danger: true,
|
||||
});
|
||||
},
|
||||
surSuppression(idProjet, idPhase) {
|
||||
majPlanning(
|
||||
remplacerProjet(planning, idProjet, (projet) => supprimerPhase(projet, idPhase))
|
||||
);
|
||||
},
|
||||
surFermeture() {
|
||||
selection = null;
|
||||
dessiner();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// État
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Unique point d'entrée des modifications : rend puis sauvegarde. */
|
||||
function majPlanning(suivant) {
|
||||
planning = suivant;
|
||||
dessiner();
|
||||
stockage.sauvegarder(pourEcriture(planning));
|
||||
}
|
||||
|
||||
function dessiner() {
|
||||
const aDesProjets = planning.projects.length > 0;
|
||||
refs.frise.hidden = !aDesProjets;
|
||||
refs.messageVide.hidden = aDesProjets;
|
||||
|
||||
if (!aDesProjets) {
|
||||
echelle = null;
|
||||
return;
|
||||
}
|
||||
|
||||
elargirPourContenir();
|
||||
echelle = rendre(refs, planning, { fenetre, selection });
|
||||
|
||||
// Le panneau reflète la phase telle qu'elle est après le rendu : un glisser
|
||||
// sur la frise doit se voir dans les champs.
|
||||
if (selection) {
|
||||
const trouve = localiser(selection.projet, selection.phase);
|
||||
if (trouve) panneau.rafraichir(trouve.projet, trouve.phase);
|
||||
}
|
||||
}
|
||||
|
||||
function localiser(idProjet, idPhase) {
|
||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||
if (!projet) return null;
|
||||
const phase = projet.phases.find((p) => p.id === idPhase);
|
||||
return phase ? { projet, phase } : null;
|
||||
}
|
||||
|
||||
function afficherEtat(etat, detail) {
|
||||
const messages = {
|
||||
inactif: '',
|
||||
'en-cours': 'Enregistrement…',
|
||||
enregistre: 'Enregistré',
|
||||
erreur: detail || 'Erreur d’enregistrement',
|
||||
};
|
||||
refs.etat.textContent = messages[etat] ?? '';
|
||||
refs.etat.dataset.etat = etat;
|
||||
refs.etat.title = etat === 'erreur' ? detail || '' : '';
|
||||
}
|
||||
|
||||
function afficherAlerte(message) {
|
||||
refs.alerteMessage.textContent = message;
|
||||
refs.alerte.hidden = false;
|
||||
// L'alerte recouvre déjà la page, mais laisser une frise vide « visible »
|
||||
// derrière elle brouillerait le diagnostic si le style venait à changer.
|
||||
refs.frise.hidden = true;
|
||||
refs.messageVide.hidden = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fenêtre temporelle et défilement sans fin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fenêtre de départ : les phases existantes, avec une marge de chaque côté. */
|
||||
function fenetreInitiale() {
|
||||
const bornes = bornesPlanning(planning);
|
||||
if (!bornes) {
|
||||
const jour = aujourdhui();
|
||||
return { debut: ajouterJours(jour, -MARGE_JOURS), fin: ajouterJours(jour, MARGE_JOURS * 2) };
|
||||
}
|
||||
return {
|
||||
debut: ajouterJours(bornes.start, -MARGE_JOURS),
|
||||
fin: ajouterJours(bornes.end, MARGE_JOURS),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Élargit la fenêtre si une phase est sortie de son cadre, sans jamais la
|
||||
* rétrécir : le terrain déjà exploré au défilement doit rester accessible.
|
||||
*/
|
||||
function elargirPourContenir() {
|
||||
if (!fenetre) fenetre = fenetreInitiale();
|
||||
|
||||
const bornes = bornesPlanning(planning);
|
||||
if (!bornes) return;
|
||||
|
||||
const voulu = {
|
||||
debut: ajouterJours(bornes.start, -MARGE_JOURS),
|
||||
fin: ajouterJours(bornes.end, MARGE_JOURS),
|
||||
};
|
||||
if (versUTC(voulu.debut) < versUTC(fenetre.debut)) fenetre.debut = voulu.debut;
|
||||
if (versUTC(voulu.fin) > versUTC(fenetre.fin)) fenetre.fin = voulu.fin;
|
||||
}
|
||||
|
||||
/** Bornes au-delà desquelles on cesse d'élargir. */
|
||||
function porteeMaximale() {
|
||||
const bornes = bornesPlanning(planning) ?? { start: aujourdhui(), end: aujourdhui() };
|
||||
const jours = 365 * PORTEE_MAX_ANNEES;
|
||||
return {
|
||||
min: ajouterJours(bornes.start, -jours),
|
||||
max: ajouterJours(bornes.end, jours),
|
||||
};
|
||||
}
|
||||
|
||||
// Vrai pendant qu'on élargit, pour que l'ajustement de `scrollLeft` ne relance
|
||||
// pas immédiatement ce même gestionnaire.
|
||||
let elargissementEnCours = false;
|
||||
|
||||
/**
|
||||
* Élargit la fenêtre quand le défilement approche d'un bord, ce qui donne
|
||||
* l'impression d'une frise sans fin.
|
||||
*
|
||||
* Élargir vers la gauche déplace l'origine de l'échelle : tout le contenu se
|
||||
* décale vers la droite. On compense `scrollLeft` de la même quantité, sinon la
|
||||
* vue sauterait en arrière à chaque élargissement.
|
||||
*/
|
||||
refs.frise.addEventListener('scroll', () => {
|
||||
if (elargissementEnCours || !echelle || refs.frise.hidden) return;
|
||||
|
||||
const zone = refs.frise;
|
||||
const portee = porteeMaximale();
|
||||
const resteADroite = zone.scrollWidth - zone.scrollLeft - zone.clientWidth;
|
||||
|
||||
if (zone.scrollLeft < SEUIL_ELARGISSEMENT && versUTC(fenetre.debut) > versUTC(portee.min)) {
|
||||
elargissementEnCours = true;
|
||||
fenetre.debut = ajouterJours(fenetre.debut, -PAS_ELARGISSEMENT);
|
||||
dessiner();
|
||||
zone.scrollLeft += PAS_ELARGISSEMENT * PIXELS_PAR_JOUR;
|
||||
elargissementEnCours = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (resteADroite < SEUIL_ELARGISSEMENT && versUTC(fenetre.fin) < versUTC(portee.max)) {
|
||||
elargissementEnCours = true;
|
||||
fenetre.fin = ajouterJours(fenetre.fin, PAS_ELARGISSEMENT);
|
||||
dessiner();
|
||||
elargissementEnCours = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactions de la frise
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
installerGlisser(refs.couloirs, {
|
||||
echelle: () => echelle,
|
||||
trouverPhase(idProjet, idPhase) {
|
||||
return localiser(idProjet, idPhase)?.phase ?? null;
|
||||
},
|
||||
surDeplacement(idProjet, idPhase, phase) {
|
||||
// `phase` vaut null quand le geste a été annulé ou n'a rien changé : on
|
||||
// redessine pour remettre la barre à sa place, sans rien sauvegarder.
|
||||
if (!phase) {
|
||||
dessiner();
|
||||
return;
|
||||
}
|
||||
majPlanning(
|
||||
remplacerProjet(planning, idProjet, (projet) =>
|
||||
remplacerPhase(projet, idPhase, () => phase)
|
||||
)
|
||||
);
|
||||
},
|
||||
surClic(idProjet, idPhase) {
|
||||
ouvrirPhase(idProjet, idPhase);
|
||||
},
|
||||
});
|
||||
|
||||
function ouvrirPhase(idProjet, idPhase) {
|
||||
const trouve = localiser(idProjet, idPhase);
|
||||
if (!trouve) return;
|
||||
selection = { projet: idProjet, phase: idPhase };
|
||||
panneau.ouvrir(trouve.projet, trouve.phase);
|
||||
dessiner();
|
||||
}
|
||||
|
||||
// Clics sur la colonne des libellés : plier, masquer, ouvrir le menu ou la phase.
|
||||
refs.libelles.addEventListener('click', (evenement) => {
|
||||
const cible = evenement.target.closest('[data-action]');
|
||||
|
||||
if (!cible) {
|
||||
const ligne = evenement.target.closest('.libelle-phase');
|
||||
if (ligne) ouvrirPhase(ligne.dataset.projet, ligne.dataset.phase);
|
||||
return;
|
||||
}
|
||||
|
||||
const idProjet = cible.dataset.projet;
|
||||
|
||||
if (cible.dataset.action === 'plier') {
|
||||
majPlanning(
|
||||
remplacerProjet(planning, idProjet, (projet) => ({
|
||||
...projet,
|
||||
collapsed: !projet.collapsed,
|
||||
}))
|
||||
);
|
||||
} else if (cible.dataset.action === 'masquer') {
|
||||
majPlanning(
|
||||
remplacerProjet(planning, idProjet, (projet) => ({ ...projet, hidden: !projet.hidden }))
|
||||
);
|
||||
} else if (cible.dataset.action === 'menu') {
|
||||
ouvrirMenuProjet(idProjet, cible);
|
||||
}
|
||||
});
|
||||
|
||||
// Clic droit sur un libellé de projet : même menu, ancré sur le nom.
|
||||
refs.libelles.addEventListener('contextmenu', (evenement) => {
|
||||
const ligne = evenement.target.closest('.libelle-projet');
|
||||
if (!ligne) return;
|
||||
evenement.preventDefault();
|
||||
const bouton = ligne.querySelector('[data-action="menu"]');
|
||||
ouvrirMenuProjet(bouton.dataset.projet, bouton);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menu d'un projet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ouvrirMenuProjet(idProjet, ancre) {
|
||||
const projet = planning.projects.find((p) => p.id === idProjet);
|
||||
if (!projet) return;
|
||||
|
||||
const aUneReference = projet.phases.some((phase) => phase.baseline);
|
||||
|
||||
menu.ouvrir(ancre, [
|
||||
{ libelle: 'Ajouter une phase', action: () => ajouterUnePhase(projet, false) },
|
||||
{ libelle: 'Ajouter un jalon', action: () => ajouterUnePhase(projet, true) },
|
||||
{
|
||||
libelle: projet.collapsed ? 'Déplier le projet' : 'Plier le projet',
|
||||
separateurAvant: true,
|
||||
action: () =>
|
||||
majPlanning(
|
||||
remplacerProjet(planning, idProjet, (p) => ({ ...p, collapsed: !p.collapsed }))
|
||||
),
|
||||
},
|
||||
{
|
||||
libelle: projet.hidden ? 'Afficher sur la frise' : 'Masquer de la frise',
|
||||
action: () =>
|
||||
majPlanning(remplacerProjet(planning, idProjet, (p) => ({ ...p, hidden: !p.hidden }))),
|
||||
},
|
||||
{ libelle: 'Renommer, changer la couleur…', action: () => renommerProjet(projet) },
|
||||
{
|
||||
libelle: aUneReference ? 'Refiger la référence' : 'Figer la référence',
|
||||
separateurAvant: true,
|
||||
desactive: projet.phases.length === 0,
|
||||
action: () => figerLaReference(projet, aUneReference),
|
||||
},
|
||||
{
|
||||
libelle: 'Retirer la référence',
|
||||
desactive: !aUneReference,
|
||||
action: () => retirerLaReference(projet),
|
||||
},
|
||||
{
|
||||
libelle: 'Supprimer le projet',
|
||||
separateurAvant: true,
|
||||
danger: true,
|
||||
action: () => supprimerLeProjet(projet),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function ajouterUnePhase(projet, jalon) {
|
||||
// On enchaîne à la suite de la dernière phase, ce qui est presque toujours ce
|
||||
// qu'on veut. Plutôt que de demander le nom dans un dialogue, on crée la
|
||||
// phase tout de suite et on ouvre le panneau de détail avec le nom
|
||||
// présélectionné : l'utilisateur tape directement par-dessus.
|
||||
const derniere = projet.phases[projet.phases.length - 1];
|
||||
const debut = derniere ? ajouterJours(derniere.end, 1) : aujourdhui();
|
||||
const fin = jalon ? debut : ajouterJours(debut, 20);
|
||||
|
||||
const phase = creerPhase(projet, jalon ? 'Nouveau jalon' : 'Nouvelle phase', debut, fin, {
|
||||
milestone: jalon,
|
||||
});
|
||||
|
||||
majPlanning(
|
||||
remplacerProjet(planning, projet.id, (p) => ({
|
||||
...ajouterPhase(p, phase),
|
||||
collapsed: false, // inutile d'ajouter une phase dans un couloir replié
|
||||
}))
|
||||
);
|
||||
ouvrirPhase(projet.id, phase.id);
|
||||
|
||||
// La frise défile librement bien au-delà des phases existantes : sans ce
|
||||
// recentrage, une phase créée alors qu'on regardait 2029 apparaîtrait hors
|
||||
// écran, et l'ajout semblerait n'avoir rien fait.
|
||||
centrerSur(phase.start);
|
||||
}
|
||||
|
||||
async function renommerProjet(projet) {
|
||||
const reponse = await demanderProjet({
|
||||
titreTexte: 'Modifier le projet',
|
||||
libelleValider: 'Enregistrer',
|
||||
nom: projet.name,
|
||||
couleur: projet.color,
|
||||
});
|
||||
if (!reponse) return;
|
||||
|
||||
// L'identifiant ne change pas : il identifie, il ne décrit pas.
|
||||
majPlanning(
|
||||
remplacerProjet(planning, projet.id, (p) => ({
|
||||
...p,
|
||||
name: reponse.nom,
|
||||
color: reponse.couleur,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
async function figerLaReference(projet, dejaFigee) {
|
||||
const ok = await demanderConfirmation({
|
||||
titreTexte: dejaFigee ? 'Refiger la référence' : 'Figer la référence',
|
||||
messageTexte: dejaFigee
|
||||
? `Le planning actuel de « ${projet.name} » deviendra la nouvelle référence. ` +
|
||||
'La dérive affichée repartira de zéro.'
|
||||
: `Le planning actuel de « ${projet.name} » sera mémorisé comme référence. ` +
|
||||
'La frise affichera ensuite son écart avec cette référence.',
|
||||
libelleValider: dejaFigee ? 'Refiger' : 'Figer',
|
||||
});
|
||||
if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => figerReference(p)));
|
||||
}
|
||||
|
||||
async function retirerLaReference(projet) {
|
||||
const ok = await demanderConfirmation({
|
||||
titreTexte: 'Retirer la référence',
|
||||
messageTexte:
|
||||
`Le planning de référence de « ${projet.name} » sera oublié, ` +
|
||||
'et la frise cessera d’afficher sa dérive.',
|
||||
libelleValider: 'Retirer',
|
||||
danger: true,
|
||||
});
|
||||
if (ok) majPlanning(remplacerProjet(planning, projet.id, (p) => libererReference(p)));
|
||||
}
|
||||
|
||||
async function supprimerLeProjet(projet) {
|
||||
const nombre = projet.phases.length;
|
||||
const ok = await demanderConfirmation({
|
||||
titreTexte: 'Supprimer le projet',
|
||||
messageTexte:
|
||||
`« ${projet.name} »` +
|
||||
(nombre ? ` et ses ${nombre} phase${nombre > 1 ? 's' : ''}` : '') +
|
||||
' seront définitivement supprimés du planning.',
|
||||
libelleValider: 'Supprimer',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
if (selection?.projet === projet.id) panneau.fermer();
|
||||
majPlanning(supprimerProjet(planning, projet.id));
|
||||
}
|
||||
|
||||
async function nouveauProjet() {
|
||||
const reponse = await demanderProjet({
|
||||
titreTexte: 'Nouveau projet',
|
||||
libelleValider: 'Créer',
|
||||
couleur: COULEURS[planning.projects.length % COULEURS.length],
|
||||
});
|
||||
if (!reponse) return;
|
||||
|
||||
const projet = { ...creerProjet(planning, reponse.nom), color: reponse.couleur };
|
||||
majPlanning({ ...planning, projects: [...planning.projects, projet] });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Barre d'outils
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$('aller-aujourdhui').addEventListener('click', () => centrerSur(aujourdhui()));
|
||||
$('nouveau-projet').addEventListener('click', nouveauProjet);
|
||||
$('premier-projet').addEventListener('click', nouveauProjet);
|
||||
|
||||
/**
|
||||
* Amène une date au centre de la vue, instantanément.
|
||||
*
|
||||
* Volontairement pas de défilement fluide : la frise s'élargit au défilement et
|
||||
* peut atteindre plusieurs dizaines de milliers de pixels. Une animation sur une
|
||||
* telle distance est lente, et le gestionnaire de défilement l'interromprait en
|
||||
* élargissant la fenêtre au passage près d'un bord.
|
||||
*/
|
||||
function centrerSur(date) {
|
||||
if (!echelle) return;
|
||||
|
||||
// `scrollLeft` porte sur toute la grille, dont la première colonne est celle
|
||||
// des libellés. Comme elle est `sticky`, elle recouvre en permanence les
|
||||
// premiers pixels de la vue : le centre utile des couloirs est donc décalé
|
||||
// d'une demi-largeur de colonne par rapport au centre de la frise.
|
||||
const largeurLibelles = refs.libelles.offsetWidth;
|
||||
const centreUtile = (largeurLibelles + refs.frise.clientWidth) / 2;
|
||||
|
||||
refs.frise.scrollLeft = Math.max(0, largeurLibelles + echelle.x(date) - centreUtile);
|
||||
}
|
||||
|
||||
// Molette + Maj : défilement horizontal, geste habituel sur une frise.
|
||||
refs.frise.addEventListener(
|
||||
'wheel',
|
||||
(evenement) => {
|
||||
if (!evenement.shiftKey || evenement.ctrlKey) return;
|
||||
evenement.preventDefault();
|
||||
refs.frise.scrollLeft += evenement.deltaY;
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Flèches : ajustement fin de la phase sélectionnée, au jour près.
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
if (!selection) return;
|
||||
// Pas d'interception pendant une saisie dans le panneau ou un dialogue.
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) return;
|
||||
|
||||
const pas = evenement.key === 'ArrowLeft' ? -1 : evenement.key === 'ArrowRight' ? 1 : 0;
|
||||
if (pas === 0) return;
|
||||
|
||||
evenement.preventDefault();
|
||||
const trouve = localiser(selection.projet, selection.phase);
|
||||
if (!trouve) return;
|
||||
|
||||
// Maj + flèche étire la phase au lieu de la déplacer.
|
||||
const suivante = evenement.shiftKey
|
||||
? etirerAuClavier(trouve.phase, pas)
|
||||
: decalerAuClavier(trouve.phase, pas);
|
||||
|
||||
majPlanning(
|
||||
remplacerProjet(planning, selection.projet, (projet) =>
|
||||
remplacerPhase(projet, selection.phase, () => suivante)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Démarrage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function demarrer() {
|
||||
try {
|
||||
const brut = await stockage.charger();
|
||||
planning = validerPlanning(brut);
|
||||
} catch (err) {
|
||||
afficherAlerte(
|
||||
`${err.message}\n\n` +
|
||||
'Le planning n’a pas été chargé, et rien ne sera enregistré tant que le ' +
|
||||
'problème persiste — les données existantes sont donc intactes.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dessiner();
|
||||
viderAvantFermeture(stockage);
|
||||
|
||||
if (planning.projects.length) {
|
||||
// Sans ce report, la largeur de la zone n'est pas encore connue et le
|
||||
// centrage tombe à côté.
|
||||
requestAnimationFrame(() => centrerSur(aujourdhui()));
|
||||
}
|
||||
|
||||
console.info(
|
||||
`Frise chargée : ${planning.projects.length} projet(s), ` +
|
||||
`${planning.projects.reduce((n, p) => n + p.phases.length, 0)} phase(s). ` +
|
||||
`Aujourd'hui : ${formaterDateLongue(aujourdhui())}.`
|
||||
);
|
||||
}
|
||||
|
||||
demarrer();
|
||||
170
js/detail.js
Normal file
170
js/detail.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Panneau de détail : édition précise d'une phase.
|
||||
*
|
||||
* Le panneau applique les changements au fil de la saisie plutôt qu'à la
|
||||
* validation d'un formulaire. La frise se met à jour en direct, et la
|
||||
* sauvegarde debouncée de storage.js absorbe la fréquence des modifications.
|
||||
*
|
||||
* C'est aussi ici qu'on saisit une date exacte : le glisser sur la frise
|
||||
* s'accroche au lundi, ce panneau est le recours quand une phase doit démarrer
|
||||
* un jeudi précis.
|
||||
*/
|
||||
|
||||
import { STATUTS, dateValide, modifierPhase } from './model.js';
|
||||
|
||||
export function creerPanneau(refs, rappels) {
|
||||
const { panneau, voile, formulaire, titre, erreur, champFin, supprimer, fermer } = refs;
|
||||
|
||||
let selection = null; // { projet, phase }
|
||||
let phaseCourante = null;
|
||||
|
||||
function afficherErreur(message) {
|
||||
erreur.textContent = message || '';
|
||||
erreur.hidden = !message;
|
||||
}
|
||||
|
||||
function remplir(projet, phase) {
|
||||
titre.textContent = `${projet.name} — ${phase.name}`;
|
||||
formulaire.name.value = phase.name;
|
||||
formulaire.start.value = phase.start;
|
||||
formulaire.end.value = phase.end;
|
||||
formulaire.status.value = STATUTS.includes(phase.status) ? phase.status : 'todo';
|
||||
formulaire.milestone.checked = phase.milestone;
|
||||
formulaire.notes.value = phase.notes;
|
||||
|
||||
// Un jalon tient sur une seule date : le champ « fin » n'a rien à dire.
|
||||
champFin.hidden = phase.milestone;
|
||||
afficherErreur(null);
|
||||
}
|
||||
|
||||
/** Lit le formulaire, applique les invariants et remonte le changement. */
|
||||
function appliquer() {
|
||||
if (!selection || !phaseCourante) return;
|
||||
|
||||
const nom = formulaire.name.value.trim();
|
||||
if (!nom) {
|
||||
afficherErreur('Le nom ne peut pas être vide.');
|
||||
return;
|
||||
}
|
||||
|
||||
const debut = formulaire.start.value;
|
||||
if (!dateValide(debut)) {
|
||||
// Saisie en cours dans le champ date : on attend sans rien changer.
|
||||
return;
|
||||
}
|
||||
|
||||
const jalon = formulaire.milestone.checked;
|
||||
const fin = jalon ? debut : formulaire.end.value;
|
||||
if (!jalon && !dateValide(fin)) return;
|
||||
|
||||
const avant = phaseCourante;
|
||||
const apres = modifierPhase(phaseCourante, {
|
||||
name: nom,
|
||||
start: debut,
|
||||
end: fin,
|
||||
status: formulaire.status.value,
|
||||
milestone: jalon,
|
||||
notes: formulaire.notes.value,
|
||||
});
|
||||
|
||||
// modifierPhase a pu corriger la fin (jalon, ou fin antérieure au début).
|
||||
// On reflète cette correction dans le formulaire pour que l'utilisateur
|
||||
// voie ce qui a réellement été enregistré.
|
||||
if (apres.end !== fin) {
|
||||
formulaire.end.value = apres.end;
|
||||
if (!jalon) {
|
||||
afficherErreur('La fin précédait le début : la durée précédente a été conservée.');
|
||||
}
|
||||
} else {
|
||||
afficherErreur(null);
|
||||
}
|
||||
|
||||
champFin.hidden = jalon;
|
||||
phaseCourante = apres;
|
||||
|
||||
if (!sontEgales(avant, apres)) {
|
||||
rappels.surModification(selection.projet, selection.phase, apres);
|
||||
}
|
||||
}
|
||||
|
||||
formulaire.addEventListener('input', appliquer);
|
||||
formulaire.addEventListener('change', appliquer);
|
||||
formulaire.addEventListener('submit', (evenement) => evenement.preventDefault());
|
||||
|
||||
supprimer.addEventListener('click', async () => {
|
||||
if (!selection) return;
|
||||
const cible = selection;
|
||||
const nom = phaseCourante.name;
|
||||
|
||||
if (!(await rappels.demanderSuppression(cible.projet, cible.phase, nom))) return;
|
||||
|
||||
// Le panneau a pu être fermé ou changer de phase pendant la confirmation.
|
||||
if (selection?.phase !== cible.phase || selection?.projet !== cible.projet) return;
|
||||
|
||||
api.fermer();
|
||||
rappels.surSuppression(cible.projet, cible.phase);
|
||||
});
|
||||
|
||||
fermer.addEventListener('click', () => api.fermer());
|
||||
voile.addEventListener('click', () => api.fermer());
|
||||
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
// Échap ferme le panneau, sauf pendant une saisie où il annule d'abord la
|
||||
// complétion native du champ.
|
||||
if (evenement.key === 'Escape' && !panneau.hidden) {
|
||||
api.fermer();
|
||||
}
|
||||
});
|
||||
|
||||
const api = {
|
||||
ouvrir(projet, phase) {
|
||||
selection = { projet: projet.id, phase: phase.id };
|
||||
phaseCourante = phase;
|
||||
remplir(projet, phase);
|
||||
panneau.hidden = false;
|
||||
voile.hidden = false;
|
||||
formulaire.name.focus();
|
||||
formulaire.name.select();
|
||||
},
|
||||
|
||||
/** Recharge le contenu après une modification venue de la frise. */
|
||||
rafraichir(projet, phase) {
|
||||
if (!selection || selection.phase !== phase.id || selection.projet !== projet.id) return;
|
||||
// On ne réécrit pas le champ qui a le focus : cela déplacerait le curseur
|
||||
// au milieu d'une frappe.
|
||||
const actif = document.activeElement;
|
||||
phaseCourante = phase;
|
||||
titre.textContent = `${projet.name} — ${phase.name}`;
|
||||
for (const champ of ['name', 'start', 'end', 'status', 'notes']) {
|
||||
if (formulaire[champ] !== actif) formulaire[champ].value = phase[champ];
|
||||
}
|
||||
if (formulaire.milestone !== actif) formulaire.milestone.checked = phase.milestone;
|
||||
champFin.hidden = phase.milestone;
|
||||
},
|
||||
|
||||
fermer() {
|
||||
selection = null;
|
||||
phaseCourante = null;
|
||||
panneau.hidden = true;
|
||||
voile.hidden = true;
|
||||
rappels.surFermeture();
|
||||
},
|
||||
|
||||
selection() {
|
||||
return selection;
|
||||
},
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
function sontEgales(a, b) {
|
||||
return (
|
||||
a.name === b.name &&
|
||||
a.start === b.start &&
|
||||
a.end === b.end &&
|
||||
a.status === b.status &&
|
||||
a.milestone === b.milestone &&
|
||||
a.notes === b.notes
|
||||
);
|
||||
}
|
||||
191
js/drag.js
Normal file
191
js/drag.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Glisser et redimensionner les barres.
|
||||
*
|
||||
* Deux gestes partagent le même appui : un clic ouvre le panneau de détail, un
|
||||
* glisser déplace la barre. On les départage au seuil : tant que le pointeur
|
||||
* n'a pas franchi quelques pixels, rien ne bouge et le relâchement compte comme
|
||||
* un clic. Sans ce seuil, ouvrir le panneau décalerait la phase d'un jour à
|
||||
* chaque fois que la souris frémit.
|
||||
*
|
||||
* Pendant le geste, seule la géométrie de l'élément est modifiée — l'état n'est
|
||||
* touché qu'au relâchement. Cela évite de reconstruire toute la frise à chaque
|
||||
* mouvement de souris, et laisse la possibilité d'annuler avec Échap.
|
||||
*/
|
||||
|
||||
import {
|
||||
ajouterJours,
|
||||
deplacerPhase,
|
||||
lundiDeLaSemaine,
|
||||
redimensionnerPhase,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
|
||||
/** Déplacement en pixels au-delà duquel l'appui devient un glisser. */
|
||||
const SEUIL_GLISSER = 4;
|
||||
|
||||
/**
|
||||
* Installe la gestion du glisser sur le conteneur des couloirs.
|
||||
*
|
||||
* @param {HTMLElement} conteneur
|
||||
* @param {object} rappels
|
||||
* - echelle() : l'échelle courante
|
||||
* - trouverPhase(idProjet, idPhase)
|
||||
* - surDeplacement(idProjet, idPhase, phase) : appelé au relâchement
|
||||
* - surClic(idProjet, idPhase)
|
||||
*/
|
||||
export function installerGlisser(conteneur, rappels) {
|
||||
let geste = null;
|
||||
|
||||
conteneur.addEventListener('pointerdown', (evenement) => {
|
||||
// Bouton principal uniquement : le clic droit ouvre le menu contextuel.
|
||||
if (evenement.button !== 0) return;
|
||||
|
||||
const cible = evenement.target.closest('[data-role="barre"]');
|
||||
if (!cible) return;
|
||||
|
||||
const { projet: idProjet, phase: idPhase } = cible.dataset;
|
||||
const phase = rappels.trouverPhase(idProjet, idPhase);
|
||||
if (!phase) return;
|
||||
|
||||
const poignee = evenement.target.closest('[data-role="poignee"]');
|
||||
// Un jalon n'a pas de durée : ses poignées n'ont pas de sens.
|
||||
const mode = poignee && !phase.milestone ? `redimensionner-${poignee.dataset.bord}` : 'deplacer';
|
||||
|
||||
geste = {
|
||||
element: cible,
|
||||
idProjet,
|
||||
idPhase,
|
||||
phaseInitiale: phase,
|
||||
phaseCourante: phase,
|
||||
mode,
|
||||
xDepart: evenement.clientX,
|
||||
gaucheInitiale: cible.offsetLeft,
|
||||
largeurInitiale: cible.offsetWidth,
|
||||
demarre: false,
|
||||
};
|
||||
|
||||
cible.setPointerCapture(evenement.pointerId);
|
||||
// Empêche la sélection de texte et le glisser natif de l'élément.
|
||||
evenement.preventDefault();
|
||||
});
|
||||
|
||||
conteneur.addEventListener('pointermove', (evenement) => {
|
||||
if (!geste) return;
|
||||
|
||||
const decalage = evenement.clientX - geste.xDepart;
|
||||
|
||||
if (!geste.demarre) {
|
||||
if (Math.abs(decalage) < SEUIL_GLISSER) return;
|
||||
geste.demarre = true;
|
||||
geste.element.classList.add('barre--glissee');
|
||||
document.body.classList.add('glisse-en-cours');
|
||||
}
|
||||
|
||||
appliquerGeste(geste, decalage, rappels.echelle());
|
||||
});
|
||||
|
||||
conteneur.addEventListener('pointerup', (evenement) => {
|
||||
if (!geste) return;
|
||||
const acheve = geste;
|
||||
geste = null;
|
||||
|
||||
acheve.element.releasePointerCapture?.(evenement.pointerId);
|
||||
acheve.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
|
||||
if (!acheve.demarre) {
|
||||
rappels.surClic(acheve.idProjet, acheve.idPhase);
|
||||
return;
|
||||
}
|
||||
|
||||
// Le geste n'a rien changé (revenu à son point de départ) : inutile de
|
||||
// déclencher une sauvegarde.
|
||||
const avant = acheve.phaseInitiale;
|
||||
const apres = acheve.phaseCourante;
|
||||
if (apres.start === avant.start && apres.end === avant.end) {
|
||||
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, null);
|
||||
return;
|
||||
}
|
||||
|
||||
rappels.surDeplacement(acheve.idProjet, acheve.idPhase, apres);
|
||||
});
|
||||
|
||||
// Échap pendant le geste : on annule et on laisse le rendu se refaire.
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
if (evenement.key !== 'Escape' || !geste) return;
|
||||
const annule = geste;
|
||||
geste = null;
|
||||
annule.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
});
|
||||
|
||||
// Un pointeur perdu (fenêtre qui perd le focus, périphérique débranché) doit
|
||||
// laisser l'interface dans un état propre plutôt qu'en glisser perpétuel.
|
||||
conteneur.addEventListener('pointercancel', () => {
|
||||
if (!geste) return;
|
||||
const annule = geste;
|
||||
geste = null;
|
||||
annule.element.classList.remove('barre--glissee');
|
||||
document.body.classList.remove('glisse-en-cours');
|
||||
rappels.surDeplacement(annule.idProjet, annule.idPhase, null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la géométrie de l'élément et mémorise les dates correspondantes.
|
||||
* Les dates s'accrochent au lundi (docs/decisions.md, section 9).
|
||||
*/
|
||||
function appliquerGeste(geste, decalage, echelle) {
|
||||
const { phaseInitiale: phase, mode } = geste;
|
||||
|
||||
if (mode === 'deplacer') {
|
||||
const debut = accrocher(phase.start, decalage, echelle);
|
||||
geste.phaseCourante = deplacerPhase(phase, debut);
|
||||
geste.element.style.left = `${echelle.x(debut)}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'redimensionner-debut') {
|
||||
let debut = accrocher(phase.start, decalage, echelle);
|
||||
// La borne opposée fait butée : une phase ne peut pas se retourner.
|
||||
if (versUTC(debut) > versUTC(phase.end)) debut = phase.end;
|
||||
|
||||
geste.phaseCourante = redimensionnerPhase(phase, 'debut', debut);
|
||||
geste.element.style.left = `${echelle.x(debut)}px`;
|
||||
geste.element.style.width = `${echelle.largeurDe(debut, phase.end)}px`;
|
||||
return;
|
||||
}
|
||||
|
||||
let fin = accrocher(phase.end, decalage, echelle);
|
||||
if (versUTC(fin) < versUTC(phase.start)) fin = phase.start;
|
||||
|
||||
geste.phaseCourante = redimensionnerPhase(phase, 'fin', fin);
|
||||
geste.element.style.width = `${echelle.largeurDe(phase.start, fin)}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Décale une date du nombre de jours correspondant au déplacement, puis
|
||||
* l'accroche au lundi de sa semaine.
|
||||
*/
|
||||
function accrocher(date, decalagePixels, echelle) {
|
||||
const jours = Math.round(decalagePixels / echelle.pixelsParJour);
|
||||
return lundiDeLaSemaine(ajouterJours(date, jours));
|
||||
}
|
||||
|
||||
/**
|
||||
* Décale une phase au clavier, pour un ajustement fin sans souris.
|
||||
* Utilisé par app.js sur les flèches gauche/droite quand une phase est
|
||||
* sélectionnée.
|
||||
*/
|
||||
export function decalerAuClavier(phase, jours) {
|
||||
return deplacerPhase(phase, ajouterJours(phase.start, jours));
|
||||
}
|
||||
|
||||
/** Allonge ou raccourcit une phase au clavier, sans jamais la retourner. */
|
||||
export function etirerAuClavier(phase, jours) {
|
||||
if (phase.milestone) return phase;
|
||||
const fin = ajouterJours(phase.end, jours);
|
||||
if (versUTC(fin) < versUTC(phase.start)) return phase;
|
||||
return { ...phase, end: fin };
|
||||
}
|
||||
215
js/menu.js
Normal file
215
js/menu.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Menu contextuel et boîtes de dialogue.
|
||||
*
|
||||
* Remplace les `prompt()` et `confirm()` natifs, qui bloquaient le fil
|
||||
* d'exécution, ne se stylaient pas, et — pour le menu d'un projet — obligeaient
|
||||
* à saisir un numéro d'action au clavier.
|
||||
*
|
||||
* Les dialogues s'appuient sur l'élément `<dialog>` natif, qui apporte
|
||||
* gratuitement le voile, le piège à focus, la fermeture par Échap et la
|
||||
* restauration du focus à la fermeture.
|
||||
*/
|
||||
|
||||
const MARGE_ECRAN = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menu contextuel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} racine l'élément `.menu` du document
|
||||
*/
|
||||
export function creerMenu(racine) {
|
||||
let fermerCourant = null;
|
||||
|
||||
function fermer() {
|
||||
if (!fermerCourant) return;
|
||||
const rendreLeFocus = fermerCourant;
|
||||
fermerCourant = null;
|
||||
racine.hidden = true;
|
||||
racine.replaceChildren();
|
||||
rendreLeFocus();
|
||||
}
|
||||
|
||||
// En capture : un clic ailleurs referme le menu avant que la cible ne le
|
||||
// traite, sans quoi on pourrait activer un bouton et laisser le menu ouvert.
|
||||
document.addEventListener(
|
||||
'pointerdown',
|
||||
(evenement) => {
|
||||
if (fermerCourant && !racine.contains(evenement.target)) fermer();
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
document.addEventListener('keydown', (evenement) => {
|
||||
if (!fermerCourant) return;
|
||||
|
||||
if (evenement.key === 'Escape') {
|
||||
evenement.preventDefault();
|
||||
fermer();
|
||||
return;
|
||||
}
|
||||
|
||||
const entrees = [...racine.querySelectorAll('button:not(:disabled)')];
|
||||
if (!entrees.length) return;
|
||||
const index = entrees.indexOf(document.activeElement);
|
||||
|
||||
if (evenement.key === 'ArrowDown') {
|
||||
evenement.preventDefault();
|
||||
entrees[(index + 1) % entrees.length].focus();
|
||||
} else if (evenement.key === 'ArrowUp') {
|
||||
evenement.preventDefault();
|
||||
entrees[(index - 1 + entrees.length) % entrees.length].focus();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', fermer);
|
||||
|
||||
return {
|
||||
/**
|
||||
* @param {HTMLElement} ancre élément près duquel afficher le menu
|
||||
* @param {Array} entrees { libelle, action, danger?, separateurAvant?, desactive? }
|
||||
*/
|
||||
ouvrir(ancre, entrees) {
|
||||
fermer();
|
||||
|
||||
const aRendre = document.activeElement;
|
||||
fermerCourant = () => aRendre?.focus?.();
|
||||
|
||||
for (const entree of entrees) {
|
||||
if (entree.separateurAvant) {
|
||||
const separateur = document.createElement('hr');
|
||||
separateur.className = 'menu__separateur';
|
||||
racine.append(separateur);
|
||||
}
|
||||
|
||||
const bouton = document.createElement('button');
|
||||
bouton.type = 'button';
|
||||
bouton.className = `menu__entree${entree.danger ? ' menu__entree--danger' : ''}`;
|
||||
bouton.setAttribute('role', 'menuitem');
|
||||
bouton.textContent = entree.libelle;
|
||||
bouton.disabled = Boolean(entree.desactive);
|
||||
bouton.addEventListener('click', () => {
|
||||
fermer();
|
||||
entree.action();
|
||||
});
|
||||
racine.append(bouton);
|
||||
}
|
||||
|
||||
racine.hidden = false;
|
||||
positionner(racine, ancre);
|
||||
racine.querySelector('button:not(:disabled)')?.focus();
|
||||
},
|
||||
|
||||
fermer,
|
||||
};
|
||||
}
|
||||
|
||||
/** Place le menu sous son ancre, en le rabattant s'il déborde de la fenêtre. */
|
||||
function positionner(menu, ancre) {
|
||||
const cadre = ancre.getBoundingClientRect();
|
||||
|
||||
// Mesure d'abord au coin supérieur gauche : la taille du menu dépend de son
|
||||
// contenu, et la lire avant de le placer évite un saut visible.
|
||||
menu.style.left = '0px';
|
||||
menu.style.top = '0px';
|
||||
const taille = menu.getBoundingClientRect();
|
||||
|
||||
let gauche = cadre.left;
|
||||
let haut = cadre.bottom + 2;
|
||||
|
||||
if (gauche + taille.width > window.innerWidth - MARGE_ECRAN) {
|
||||
gauche = Math.max(MARGE_ECRAN, window.innerWidth - taille.width - MARGE_ECRAN);
|
||||
}
|
||||
if (haut + taille.height > window.innerHeight - MARGE_ECRAN) {
|
||||
// Pas la place en dessous : on bascule au-dessus de l'ancre.
|
||||
haut = Math.max(MARGE_ECRAN, cadre.top - taille.height - 2);
|
||||
}
|
||||
|
||||
menu.style.left = `${Math.round(gauche)}px`;
|
||||
menu.style.top = `${Math.round(haut)}px`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialogues
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Demande le nom et la couleur d'un projet.
|
||||
* @returns {Promise<{nom: string, couleur: string} | null>} null si annulé.
|
||||
*/
|
||||
export function creerDialogueProjet(refs, couleurs) {
|
||||
const { dialogue, titre, champNom, conteneurCouleurs, erreur, valider } = refs;
|
||||
let choisie = couleurs[0];
|
||||
|
||||
function peindrePalette() {
|
||||
conteneurCouleurs.replaceChildren();
|
||||
for (const couleur of couleurs) {
|
||||
const bouton = document.createElement('button');
|
||||
bouton.type = 'button';
|
||||
bouton.className = 'pastille';
|
||||
bouton.style.background = couleur;
|
||||
bouton.setAttribute('aria-label', `Couleur ${couleur}`);
|
||||
bouton.setAttribute('aria-pressed', String(couleur === choisie));
|
||||
bouton.addEventListener('click', () => {
|
||||
choisie = couleur;
|
||||
peindrePalette();
|
||||
});
|
||||
conteneurCouleurs.append(bouton);
|
||||
}
|
||||
}
|
||||
|
||||
return function demander({ titreTexte, libelleValider, nom = '', couleur = couleurs[0] }) {
|
||||
titre.textContent = titreTexte;
|
||||
valider.textContent = libelleValider;
|
||||
champNom.value = nom;
|
||||
choisie = couleur;
|
||||
erreur.hidden = true;
|
||||
peindrePalette();
|
||||
|
||||
dialogue.showModal();
|
||||
champNom.focus();
|
||||
champNom.select();
|
||||
|
||||
return new Promise((resoudre) => {
|
||||
dialogue.addEventListener(
|
||||
'close',
|
||||
() => {
|
||||
// `returnValue` vaut '' quand le dialogue est fermé par Échap.
|
||||
if (dialogue.returnValue !== 'ok') return resoudre(null);
|
||||
const saisi = champNom.value.trim();
|
||||
if (!saisi) return resoudre(null);
|
||||
resoudre({ nom: saisi, couleur: choisie });
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande confirmation avant une action irréversible.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export function creerDialogueConfirmation(refs) {
|
||||
const { dialogue, titre, message, valider } = refs;
|
||||
|
||||
return function demander({ titreTexte, messageTexte, libelleValider = 'Confirmer', danger = false }) {
|
||||
titre.textContent = titreTexte;
|
||||
message.textContent = messageTexte;
|
||||
valider.textContent = libelleValider;
|
||||
valider.classList.toggle('bouton--danger', danger);
|
||||
valider.classList.toggle('bouton--principal', !danger);
|
||||
|
||||
dialogue.showModal();
|
||||
// Le focus part sur « Annuler » : sur une action destructrice, un appui
|
||||
// réflexe sur Entrée ne doit pas valider.
|
||||
dialogue.querySelector('button[value="annuler"]').focus();
|
||||
|
||||
return new Promise((resoudre) => {
|
||||
dialogue.addEventListener('close', () => resoudre(dialogue.returnValue === 'ok'), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
515
js/model.js
Normal file
515
js/model.js
Normal file
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* 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: [] };
|
||||
}
|
||||
142
js/storage.js
Normal file
142
js/storage.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Dialogue avec le serveur local : chargement du planning au démarrage, et
|
||||
* sauvegarde automatique debouncée à chaque modification.
|
||||
*
|
||||
* Le module ne connaît rien au contenu du planning : il transporte un objet
|
||||
* JSON. Les règles métier sont dans model.js.
|
||||
*/
|
||||
|
||||
/** Délai d'inactivité avant d'écrire sur le disque. */
|
||||
const DELAI_DEBOUNCE = 1000;
|
||||
|
||||
/** Le rendu « enregistré » disparaît après ce délai, pour ne pas rester affiché. */
|
||||
const DUREE_CONFIRMATION = 2000;
|
||||
|
||||
const URL_DONNEES = '/api/data';
|
||||
|
||||
/**
|
||||
* @param {(etat: 'inactif'|'en-cours'|'enregistre'|'erreur', detail?: string) => void} surEtat
|
||||
* Appelé à chaque changement d'état, pour l'indicateur de l'interface.
|
||||
*/
|
||||
export function creerStockage(surEtat = () => {}) {
|
||||
let minuteurDebounce = null;
|
||||
let minuteurConfirmation = null;
|
||||
|
||||
// Dernier instantané en attente d'écriture. On ne garde que le plus récent :
|
||||
// si trois modifications s'enchaînent, seule la dernière part sur le réseau.
|
||||
let enAttente = null;
|
||||
|
||||
// Une écriture est en vol. Toute modification survenue entre-temps sera
|
||||
// envoyée à la fin de celle-ci, pour éviter deux PUT concurrents.
|
||||
let ecritureEnCours = false;
|
||||
|
||||
function annoncer(etat, detail) {
|
||||
clearTimeout(minuteurConfirmation);
|
||||
surEtat(etat, detail);
|
||||
if (etat === 'enregistre') {
|
||||
minuteurConfirmation = setTimeout(() => surEtat('inactif'), DUREE_CONFIRMATION);
|
||||
}
|
||||
}
|
||||
|
||||
async function ecrire() {
|
||||
if (ecritureEnCours || enAttente === null) return;
|
||||
|
||||
const charge = enAttente;
|
||||
enAttente = null;
|
||||
ecritureEnCours = true;
|
||||
annoncer('en-cours');
|
||||
|
||||
try {
|
||||
const reponse = await fetch(URL_DONNEES, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(charge),
|
||||
});
|
||||
|
||||
if (!reponse.ok) {
|
||||
const corps = await reponse.json().catch(() => ({}));
|
||||
throw new Error(corps.error || `Le serveur a répondu ${reponse.status}.`);
|
||||
}
|
||||
|
||||
annoncer('enregistre');
|
||||
} catch (err) {
|
||||
// On remet la charge en attente : la prochaine modification, ou la
|
||||
// prochaine tentative, la renverra. Rien n'est perdu tant que l'onglet
|
||||
// reste ouvert.
|
||||
if (enAttente === null) enAttente = charge;
|
||||
annoncer('erreur', decrireErreur(err));
|
||||
} finally {
|
||||
ecritureEnCours = false;
|
||||
// Une modification est arrivée pendant l'écriture : on enchaîne.
|
||||
if (enAttente !== null) planifier();
|
||||
}
|
||||
}
|
||||
|
||||
function planifier() {
|
||||
clearTimeout(minuteurDebounce);
|
||||
minuteurDebounce = setTimeout(ecrire, DELAI_DEBOUNCE);
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Charge le planning. Lève une erreur explicite si le serveur est
|
||||
* injoignable ou si le fichier est corrompu — l'application ne doit
|
||||
* jamais démarrer sur des données douteuses.
|
||||
*/
|
||||
async charger() {
|
||||
let reponse;
|
||||
try {
|
||||
reponse = await fetch(URL_DONNEES, { headers: { Accept: 'application/json' } });
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Serveur injoignable. L'application doit être ouverte via « python3 serve.py », " +
|
||||
'pas en ouvrant index.html directement.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!reponse.ok) {
|
||||
const corps = await reponse.json().catch(() => ({}));
|
||||
throw new Error(corps.error || `Le serveur a répondu ${reponse.status}.`);
|
||||
}
|
||||
|
||||
return reponse.json();
|
||||
},
|
||||
|
||||
/** Enregistre après un délai d'inactivité. Appelable à chaque frappe. */
|
||||
sauvegarder(donnees) {
|
||||
enAttente = donnees;
|
||||
planifier();
|
||||
},
|
||||
|
||||
/** Écrit immédiatement, sans attendre le debounce. */
|
||||
async vider() {
|
||||
clearTimeout(minuteurDebounce);
|
||||
await ecrire();
|
||||
},
|
||||
|
||||
/** Vrai s'il reste des modifications non écrites sur le disque. */
|
||||
aDesModificationsEnAttente() {
|
||||
return enAttente !== null || ecritureEnCours;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decrireErreur(err) {
|
||||
if (err instanceof TypeError) return 'Serveur injoignable. Est-il toujours lancé ?';
|
||||
return err.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Écrit les modifications en attente quand l'onglet passe en arrière-plan.
|
||||
*
|
||||
* C'est plus fiable que « beforeunload » : sur mobile et lors d'une fermeture
|
||||
* d'onglet, « visibilitychange » est le dernier événement dont la livraison
|
||||
* soit garantie.
|
||||
*/
|
||||
export function viderAvantFermeture(stockage) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden' && stockage.aDesModificationsEnAttente()) {
|
||||
stockage.vider();
|
||||
}
|
||||
});
|
||||
}
|
||||
502
js/timeline.js
Normal file
502
js/timeline.js
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Rendu de la frise : échelle temporelle, graduations, couloirs, barres,
|
||||
* jalons, barre segmentée du mode plié et barre fantôme de référence.
|
||||
*
|
||||
* Le rendu est complet à chaque appel : on reconstruit le DOM depuis l'état
|
||||
* plutôt que d'appliquer des différences. À l'échelle visée — quelques dizaines
|
||||
* de phases — c'est instantané, et ça supprime toute une classe de bugs de
|
||||
* synchronisation entre l'état et l'affichage.
|
||||
*
|
||||
* Aucun SVG : des éléments positionnés en absolu suffisent depuis que les
|
||||
* dépendances ont été retirées (docs/decisions.md, section 4).
|
||||
*/
|
||||
|
||||
import {
|
||||
LIBELLES_STATUT,
|
||||
ajouterJours,
|
||||
aujourdhui,
|
||||
bornesProjet,
|
||||
bornesReference,
|
||||
deriveProjet,
|
||||
dimancheDeLaSemaine,
|
||||
ecartJours,
|
||||
formulerDerive,
|
||||
lundiDeLaSemaine,
|
||||
semaineISO,
|
||||
versUTC,
|
||||
} from './model.js';
|
||||
|
||||
/**
|
||||
* Échelle unique : 6 pixels par jour, soit 42 pixels par semaine.
|
||||
*
|
||||
* Il n'y a délibérément plus de niveaux de zoom. L'en-tête empile déjà l'année,
|
||||
* le mois et le numéro de semaine : les trois repères utiles sont visibles en
|
||||
* permanence, et changer d'échelle n'apportait qu'un réglage de plus à
|
||||
* comprendre. Voir docs/decisions.md, section 14.
|
||||
*/
|
||||
export const PIXELS_PAR_JOUR = 6;
|
||||
|
||||
/**
|
||||
* Largeur minimale, en pixels, qu'une cellule doit occuper pour porter son
|
||||
* libellé : en dessous, elle reste vide plutôt que d'afficher un texte tronqué.
|
||||
*/
|
||||
const LIBELLE_MINI = { annee: 40, mois: 34, semaine: 30 };
|
||||
|
||||
const MOIS_COURTS = [
|
||||
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
|
||||
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.',
|
||||
];
|
||||
|
||||
const MOIS_LONGS = [
|
||||
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||
];
|
||||
|
||||
/**
|
||||
* Calcule l'échelle : origine, largeur totale et conversions date <-> pixel.
|
||||
* Toute la géométrie de la frise passe par cet objet.
|
||||
*
|
||||
* La fenêtre affichée est décidée par app.js, qui l'élargit au fil du
|
||||
* défilement — l'échelle ne la déduit plus des bornes du planning.
|
||||
*
|
||||
* @param {{debut: string, fin: string}} fenetre
|
||||
*/
|
||||
export function creerEchelle(fenetre) {
|
||||
const { debut, fin } = fenetre;
|
||||
const jours = ecartJours(debut, fin) + 1;
|
||||
const pixelsParJour = PIXELS_PAR_JOUR;
|
||||
|
||||
return {
|
||||
debut,
|
||||
fin,
|
||||
pixelsParJour,
|
||||
largeur: jours * pixelsParJour,
|
||||
/** Date « AAAA-MM-JJ » vers l'abscisse de son bord gauche. */
|
||||
x(date) {
|
||||
return ecartJours(this.debut, date) * pixelsParJour;
|
||||
},
|
||||
/** Largeur d'une phase, date de fin incluse. */
|
||||
largeurDe(debutPhase, finPhase) {
|
||||
return (ecartJours(debutPhase, finPhase) + 1) * pixelsParJour;
|
||||
},
|
||||
/** Abscisse vers la date correspondante — l'inverse de `x`. */
|
||||
dateEn(abscisse) {
|
||||
return ajouterJours(this.debut, Math.round(abscisse / pixelsParJour));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruit toute la frise.
|
||||
*
|
||||
* @param {object} refs éléments DOM ciblés (libelles, entete, couloirs)
|
||||
* @param {object} planning l'état courant
|
||||
* @param {object} options { fenetre, selection }
|
||||
*/
|
||||
export function rendre(refs, planning, { fenetre, selection }) {
|
||||
const echelle = creerEchelle(fenetre);
|
||||
const visibles = planning.projects.filter((projet) => !projet.hidden);
|
||||
|
||||
refs.entete.style.width = `${echelle.largeur}px`;
|
||||
refs.couloirs.style.width = `${echelle.largeur}px`;
|
||||
|
||||
refs.entete.replaceChildren(...construireEntete(echelle));
|
||||
refs.libelles.replaceChildren(...construireLibelles(planning));
|
||||
refs.couloirs.replaceChildren(
|
||||
...construireTraits(echelle, hauteurTotale(visibles)),
|
||||
...construireCouloirs(visibles, echelle, selection)
|
||||
);
|
||||
|
||||
return echelle;
|
||||
}
|
||||
|
||||
function hauteurTotale(projets) {
|
||||
const lignes = projets.reduce(
|
||||
(total, projet) => total + 1 + (projet.collapsed ? 0 : projet.phases.length),
|
||||
0
|
||||
);
|
||||
return lignes * 38; // --hauteur-ligne
|
||||
}
|
||||
|
||||
// En-tête de dates : trois bandes empilées
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* L'en-tête empile trois bandes — année, mois, semaine ISO — plutôt qu'une
|
||||
* seule rangée de graduations. Chaque cellule est un bloc couvrant exactement
|
||||
* son intervalle, ce qui place le libellé au centre de la période qu'il désigne
|
||||
* au lieu de le laisser flotter après un trait.
|
||||
*
|
||||
* La semaine est le grain le plus fin : on ne descend jamais au jour.
|
||||
*/
|
||||
function construireEntete(echelle) {
|
||||
return [
|
||||
bande(echelle, [...segmentsAnnees(echelle)], 'annee', LIBELLE_MINI.annee),
|
||||
bande(echelle, [...segmentsMois(echelle)], 'mois', LIBELLE_MINI.mois),
|
||||
bande(echelle, [...segmentsSemaines(echelle)], 'semaine', LIBELLE_MINI.semaine),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} largeurMiniLibelle largeur en pixels sous laquelle la cellule
|
||||
* reste vide : mieux vaut pas de texte qu'un texte tronqué illisible.
|
||||
*/
|
||||
function bande(echelle, segments, nom, largeurMiniLibelle) {
|
||||
const conteneur = document.createElement('div');
|
||||
conteneur.className = `bande bande--${nom}`;
|
||||
|
||||
for (const segment of segments) {
|
||||
const largeur = echelle.largeurDe(segment.debut, segment.fin);
|
||||
const cellule = document.createElement('div');
|
||||
cellule.className = 'bande__cellule';
|
||||
cellule.style.left = `${echelle.x(segment.debut)}px`;
|
||||
cellule.style.width = `${largeur}px`;
|
||||
if (largeur >= largeurMiniLibelle) cellule.textContent = segment.libelle;
|
||||
cellule.title = segment.titre ?? segment.libelle;
|
||||
conteneur.append(cellule);
|
||||
}
|
||||
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
function plusTard(a, b) {
|
||||
return versUTC(a) >= versUTC(b) ? a : b;
|
||||
}
|
||||
|
||||
function plusTot(a, b) {
|
||||
return versUTC(a) <= versUTC(b) ? a : b;
|
||||
}
|
||||
|
||||
function* segmentsAnnees(echelle) {
|
||||
const derniere = Number(echelle.fin.slice(0, 4));
|
||||
for (let annee = Number(echelle.debut.slice(0, 4)); annee <= derniere; annee += 1) {
|
||||
yield {
|
||||
debut: plusTard(`${annee}-01-01`, echelle.debut),
|
||||
fin: plusTot(`${annee}-12-31`, echelle.fin),
|
||||
libelle: String(annee),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function* segmentsMois(echelle) {
|
||||
let annee = Number(echelle.debut.slice(0, 4));
|
||||
let mois = Number(echelle.debut.slice(5, 7));
|
||||
const finUTC = versUTC(echelle.fin);
|
||||
|
||||
while (true) {
|
||||
const premier = `${annee}-${String(mois).padStart(2, '0')}-01`;
|
||||
if (versUTC(premier) > finUTC) return;
|
||||
|
||||
// Le dernier jour du mois s'obtient en reculant d'un jour depuis le
|
||||
// premier du mois suivant, ce qui gère les années bissextiles sans table.
|
||||
const suivant = moisSuivant(annee, mois);
|
||||
const premierSuivant = `${suivant.annee}-${String(suivant.mois).padStart(2, '0')}-01`;
|
||||
|
||||
yield {
|
||||
debut: plusTard(premier, echelle.debut),
|
||||
fin: plusTot(ajouterJours(premierSuivant, -1), echelle.fin),
|
||||
libelle: MOIS_COURTS[mois - 1],
|
||||
titre: `${MOIS_LONGS[mois - 1]} ${annee}`,
|
||||
};
|
||||
|
||||
({ annee, mois } = suivant);
|
||||
}
|
||||
}
|
||||
|
||||
function* segmentsSemaines(echelle) {
|
||||
const finUTC = versUTC(echelle.fin);
|
||||
let lundi = lundiDeLaSemaine(echelle.debut);
|
||||
|
||||
while (versUTC(lundi) <= finUTC) {
|
||||
const { annee, numero } = semaineISO(lundi);
|
||||
const dimanche = dimancheDeLaSemaine(lundi);
|
||||
yield {
|
||||
debut: plusTard(lundi, echelle.debut),
|
||||
fin: plusTot(dimanche, echelle.fin),
|
||||
libelle: `S${numero}`,
|
||||
titre: `Semaine ${numero} de ${annee} — du ${formaterDateLongue(lundi)} au ${formaterDateLongue(dimanche)}`,
|
||||
};
|
||||
lundi = ajouterJours(lundi, 7);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Traits verticaux des couloirs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Les traits reprennent le découpage de l'en-tête : un trait fort à chaque
|
||||
* début de mois, un trait léger à chaque lundi.
|
||||
*/
|
||||
function construireTraits(echelle, hauteur) {
|
||||
const elements = [];
|
||||
|
||||
for (const semaine of segmentsSemaines(echelle)) {
|
||||
elements.push(trait(echelle, semaine.debut, hauteur, ''));
|
||||
}
|
||||
|
||||
for (const mois of segmentsMois(echelle)) {
|
||||
elements.push(trait(echelle, mois.debut, hauteur, ' trait--majeur'));
|
||||
}
|
||||
|
||||
// Repère « aujourd'hui », seulement s'il tombe dans la fenêtre affichée.
|
||||
const jour = aujourdhui();
|
||||
if (versUTC(jour) >= versUTC(echelle.debut) && versUTC(jour) <= versUTC(echelle.fin)) {
|
||||
const repere = trait(echelle, jour, hauteur, ' trait--aujourdhui');
|
||||
repere.title = `Aujourd'hui, ${formaterDateLongue(jour)}`;
|
||||
elements.push(repere);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function trait(echelle, date, hauteur, suffixeClasse) {
|
||||
const element = document.createElement('div');
|
||||
element.className = `trait${suffixeClasse}`;
|
||||
element.style.left = `${echelle.x(date)}px`;
|
||||
element.style.height = `${hauteur}px`;
|
||||
return element;
|
||||
}
|
||||
|
||||
function moisSuivant(annee, mois) {
|
||||
return mois === 12 ? { annee: annee + 1, mois: 1 } : { annee, mois: mois + 1 };
|
||||
}
|
||||
|
||||
export function formaterDateLongue(date) {
|
||||
const [a, m, j] = date.split('-');
|
||||
return `${j} ${MOIS_COURTS[Number(m) - 1]} ${a}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colonne des libellés
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function construireLibelles(planning) {
|
||||
const elements = [];
|
||||
|
||||
for (const projet of planning.projects) {
|
||||
if (projet.hidden) {
|
||||
elements.push(libelleProjet(projet));
|
||||
continue;
|
||||
}
|
||||
|
||||
elements.push(libelleProjet(projet));
|
||||
if (projet.collapsed) continue;
|
||||
|
||||
for (const phase of projet.phases) {
|
||||
const ligne = document.createElement('div');
|
||||
ligne.className = 'libelle-phase';
|
||||
ligne.textContent = phase.name;
|
||||
ligne.title = `${phase.name} — ${LIBELLES_STATUT[phase.status]}`;
|
||||
ligne.dataset.projet = projet.id;
|
||||
ligne.dataset.phase = phase.id;
|
||||
elements.push(ligne);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function libelleProjet(projet) {
|
||||
const ligne = document.createElement('div');
|
||||
ligne.className = `libelle-projet${projet.hidden ? ' libelle-projet--masque' : ''}`;
|
||||
ligne.style.setProperty('--couleur-projet', projet.color);
|
||||
|
||||
const plier = document.createElement('button');
|
||||
plier.className = 'plier';
|
||||
plier.type = 'button';
|
||||
plier.dataset.action = 'plier';
|
||||
plier.dataset.projet = projet.id;
|
||||
plier.setAttribute('aria-expanded', String(!projet.collapsed));
|
||||
plier.setAttribute(
|
||||
'aria-label',
|
||||
projet.collapsed ? `Déplier ${projet.name}` : `Plier ${projet.name}`
|
||||
);
|
||||
plier.textContent = '▶';
|
||||
ligne.append(plier);
|
||||
|
||||
const nom = document.createElement('span');
|
||||
nom.className = 'libelle-projet__nom';
|
||||
nom.textContent = projet.name;
|
||||
nom.title = projet.name;
|
||||
ligne.append(nom);
|
||||
|
||||
const derive = formulerDerive(deriveProjet(projet));
|
||||
if (derive) {
|
||||
const etiquette = document.createElement('span');
|
||||
etiquette.className = 'libelle-projet__derive';
|
||||
etiquette.dataset.sens = derive.startsWith('+') ? 'retard' : 'avance';
|
||||
etiquette.textContent = derive;
|
||||
etiquette.title = projet.baselineDate
|
||||
? `Écart au planning figé le ${formaterDateLongue(projet.baselineDate)}`
|
||||
: 'Écart au planning de référence';
|
||||
ligne.append(etiquette);
|
||||
}
|
||||
|
||||
const oeil = document.createElement('button');
|
||||
oeil.className = 'icone-oeil';
|
||||
oeil.type = 'button';
|
||||
oeil.dataset.action = 'masquer';
|
||||
oeil.dataset.projet = projet.id;
|
||||
oeil.textContent = projet.hidden ? '🚫' : '👁';
|
||||
oeil.setAttribute('aria-label', projet.hidden ? `Afficher ${projet.name}` : `Masquer ${projet.name}`);
|
||||
ligne.append(oeil);
|
||||
|
||||
// Bouton de menu explicite : les actions du projet passaient auparavant par
|
||||
// un clic sur son nom, ce que rien n'indiquait.
|
||||
const menu = document.createElement('button');
|
||||
menu.className = 'menu-projet';
|
||||
menu.type = 'button';
|
||||
menu.dataset.action = 'menu';
|
||||
menu.dataset.projet = projet.id;
|
||||
menu.textContent = '⋯';
|
||||
menu.title = `Actions sur ${projet.name}`;
|
||||
menu.setAttribute('aria-label', `Actions sur ${projet.name}`);
|
||||
menu.setAttribute('aria-haspopup', 'menu');
|
||||
ligne.append(menu);
|
||||
|
||||
return ligne;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Couloirs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function construireCouloirs(projets, echelle, selection) {
|
||||
const elements = [];
|
||||
|
||||
for (const projet of projets) {
|
||||
const couloirProjet = document.createElement('div');
|
||||
couloirProjet.className = 'couloir couloir--projet';
|
||||
couloirProjet.style.setProperty('--couleur-projet', projet.color);
|
||||
|
||||
if (projet.collapsed) {
|
||||
const barre = construireBarrePliee(projet, echelle);
|
||||
if (barre) couloirProjet.append(barre);
|
||||
const fantome = construireFantome(projet, echelle);
|
||||
if (fantome) couloirProjet.append(fantome);
|
||||
}
|
||||
|
||||
elements.push(couloirProjet);
|
||||
|
||||
if (projet.collapsed) continue;
|
||||
|
||||
for (const phase of projet.phases) {
|
||||
const couloir = document.createElement('div');
|
||||
couloir.className = 'couloir';
|
||||
couloir.style.setProperty('--couleur-projet', projet.color);
|
||||
couloir.append(...construirePhase(projet, phase, echelle, selection));
|
||||
elements.push(couloir);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function construirePhase(projet, phase, echelle, selection) {
|
||||
const selectionnee =
|
||||
selection && selection.projet === projet.id && selection.phase === phase.id;
|
||||
|
||||
const elements = [];
|
||||
|
||||
// La référence se dessine sous la barre, donc en premier.
|
||||
if (phase.baseline) {
|
||||
const fantome = document.createElement('div');
|
||||
fantome.className = 'fantome';
|
||||
fantome.style.left = `${echelle.x(phase.baseline.start)}px`;
|
||||
fantome.style.width = `${echelle.largeurDe(phase.baseline.start, phase.baseline.end)}px`;
|
||||
fantome.title = `Référence : ${formaterDateLongue(phase.baseline.start)} → ${formaterDateLongue(phase.baseline.end)}`;
|
||||
elements.push(fantome);
|
||||
}
|
||||
|
||||
if (phase.milestone) {
|
||||
const losange = document.createElement('div');
|
||||
losange.className = `jalon jalon--${phase.status}${selectionnee ? ' jalon--selectionne' : ''}`;
|
||||
losange.style.left = `${echelle.x(phase.start)}px`;
|
||||
losange.dataset.projet = projet.id;
|
||||
losange.dataset.phase = phase.id;
|
||||
losange.dataset.role = 'barre';
|
||||
losange.title = `${phase.name} — ${formaterDateLongue(phase.start)}`;
|
||||
elements.push(losange);
|
||||
|
||||
const nom = document.createElement('span');
|
||||
nom.className = 'jalon-nom';
|
||||
nom.style.left = `${echelle.x(phase.start)}px`;
|
||||
nom.textContent = phase.name;
|
||||
elements.push(nom);
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
const barre = document.createElement('div');
|
||||
barre.className = `barre barre--${phase.status}${selectionnee ? ' barre--selectionnee' : ''}`;
|
||||
barre.style.left = `${echelle.x(phase.start)}px`;
|
||||
barre.style.width = `${echelle.largeurDe(phase.start, phase.end)}px`;
|
||||
barre.dataset.projet = projet.id;
|
||||
barre.dataset.phase = phase.id;
|
||||
barre.dataset.role = 'barre';
|
||||
barre.textContent = phase.name;
|
||||
barre.title =
|
||||
`${phase.name}\n${formaterDateLongue(phase.start)} → ${formaterDateLongue(phase.end)}` +
|
||||
`\n${LIBELLES_STATUT[phase.status]}` +
|
||||
(phase.notes ? `\n\n${phase.notes}` : '');
|
||||
|
||||
for (const bord of ['debut', 'fin']) {
|
||||
const poignee = document.createElement('div');
|
||||
poignee.className = `poignee poignee--${bord}`;
|
||||
poignee.dataset.role = 'poignee';
|
||||
poignee.dataset.bord = bord;
|
||||
barre.append(poignee);
|
||||
}
|
||||
|
||||
elements.push(barre);
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Barre unique du mode plié, segmentée par phase.
|
||||
* Lecture seule : aucun `data-role`, donc drag.js l'ignore.
|
||||
*/
|
||||
function construireBarrePliee(projet, echelle) {
|
||||
const bornes = bornesProjet(projet);
|
||||
if (!bornes) return null;
|
||||
|
||||
const conteneur = document.createElement('div');
|
||||
conteneur.className = 'barre-pliee';
|
||||
conteneur.style.left = `${echelle.x(bornes.start)}px`;
|
||||
conteneur.style.width = `${echelle.largeurDe(bornes.start, bornes.end)}px`;
|
||||
|
||||
const derive = formulerDerive(deriveProjet(projet));
|
||||
conteneur.title =
|
||||
`${projet.name}\n${formaterDateLongue(bornes.start)} → ${formaterDateLongue(bornes.end)}` +
|
||||
`\n${projet.phases.length} phase${projet.phases.length > 1 ? 's' : ''}` +
|
||||
(derive ? `\nÉcart à la référence : ${derive}` : '') +
|
||||
'\n\nDéplier le projet pour modifier les dates.';
|
||||
|
||||
const origine = echelle.x(bornes.start);
|
||||
for (const phase of projet.phases) {
|
||||
const segment = document.createElement('div');
|
||||
segment.className = `segment segment--${phase.status}`;
|
||||
segment.style.left = `${echelle.x(phase.start) - origine}px`;
|
||||
// Un jalon n'a pas de largeur propre : on lui donne 3 px pour rester visible.
|
||||
segment.style.width = phase.milestone
|
||||
? '3px'
|
||||
: `${echelle.largeurDe(phase.start, phase.end)}px`;
|
||||
conteneur.append(segment);
|
||||
}
|
||||
|
||||
return conteneur;
|
||||
}
|
||||
|
||||
/** Barre fantôme du projet plié, reflétant l'étendue de référence. */
|
||||
function construireFantome(projet, echelle) {
|
||||
const reference = bornesReference(projet);
|
||||
if (!reference) return null;
|
||||
|
||||
const fantome = document.createElement('div');
|
||||
fantome.className = 'fantome';
|
||||
fantome.style.left = `${echelle.x(reference.start)}px`;
|
||||
fantome.style.width = `${echelle.largeurDe(reference.start, reference.end)}px`;
|
||||
fantome.title = `Référence : ${formaterDateLongue(reference.start)} → ${formaterDateLongue(reference.end)}`;
|
||||
return fantome;
|
||||
}
|
||||
Reference in New Issue
Block a user