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:
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user