/** * 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 en lançant l'exécutable " + '« frise », 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(); } }); }