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:
2026-07-31 06:53:57 +02:00
commit 98ae311545
16 changed files with 4766 additions and 0 deletions

142
js/storage.js Normal file
View 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();
}
});
}