From 94877cb8161eff92a4e261427b60518d6bf840b4 Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Fri, 17 Apr 2026 11:00:00 +0200 Subject: [PATCH] =?UTF-8?q?Version=203.1.0=20=E2=80=94=20Am=C3=A9lioration?= =?UTF-8?q?s=20affichage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 2 +- viewer.css | 12 ++++ viewer.html | 3 + viewer.js | 184 ++++++++++++++++++++++++++++++++++++++------------ 4 files changed, 156 insertions(+), 45 deletions(-) diff --git a/manifest.json b/manifest.json index de29ead..ddaf17f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planning Techniciens — Vue claire", - "version": "3.0.0", + "version": "3.1.0", "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch) avec navigation par date, détection automatique des interventions closes et cache 7 jours.", "permissions": [ "activeTab", diff --git a/viewer.css b/viewer.css index c10ae10..30da2ea 100644 --- a/viewer.css +++ b/viewer.css @@ -259,6 +259,18 @@ html, body { opacity: 0.9; } +/* Bouton "Arrêter" (apparaît pendant un refresh manuel) */ +.btn-abort { + background: var(--danger-soft); + color: var(--danger); + border-color: var(--danger); +} +.btn-abort:hover { + background: var(--danger); + color: white; + border-color: var(--danger); +} + #refresh-icon.spinning { display: inline-block; animation: spin 0.8s linear infinite; diff --git a/viewer.html b/viewer.html index aebdb5a..5fec1f3 100644 --- a/viewer.html +++ b/viewer.html @@ -22,6 +22,9 @@ + diff --git a/viewer.js b/viewer.js index 2a2497b..59f510a 100644 --- a/viewer.js +++ b/viewer.js @@ -47,8 +47,11 @@ const LS_THEME = "planning_theme"; const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD const CACHE_DAYS = 7; -// Concurrence du fetch en parallèle (fiches + timelines) -const FETCH_CONCURRENCY = 12; +// Concurrence du fetch en parallèle (fiches + timelines). +// Avant v3.1 : 12. Monté à 30 pour afficher les refs plus vite sur les jours +// chargés (~34 interventions → 2 vagues au lieu de 3). Si le serveur sature, +// redescendre à 20. +const FETCH_CONCURRENCY = 30; // ============================================================================ // Mapping de catégorie → titre court + couleur @@ -152,6 +155,24 @@ let state = { loading: false }; +// ─── Annulation coopérative d'un refresh manuel (v3.1) ────────────────────── +// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent +// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si +// l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement. +let currentRefreshToken = 0; +let abortedToken = -1; + +function startNewRefresh() { + currentRefreshToken++; + return currentRefreshToken; +} +function abortCurrentRefresh() { + abortedToken = currentRefreshToken; +} +function isRefreshAborted() { + return abortedToken === currentRefreshToken; +} + // ============================================================================ // Boot // ============================================================================ @@ -226,6 +247,10 @@ function toggleTheme() { function bindTopbar() { document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning()); + document.getElementById("abort-btn").addEventListener("click", () => { + abortCurrentRefresh(); + showAbortButton(false); + }); document.getElementById("clear-cache-btn").addEventListener("click", onClearCache); document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1)); @@ -422,8 +447,16 @@ async function refreshPlanning(opts = {}) { await refreshSessionAndLoad(); return; } - // Bouton Rafraîchir manuel : on force le refetch des fiches - await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true }); + // Rafraîchissement manuel (clic bouton) : on démarre un nouveau jeton et + // on fait apparaître le bouton "Arrêter". Les refresh auto (12h/15h) et + // les navigations de date n'ont pas ce bouton (ils ne passent pas ici). + startNewRefresh(); + showAbortButton(true); + try { + await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true }); + } finally { + showAbortButton(false); + } } // ============================================================================ @@ -793,10 +826,14 @@ async function refreshStatuses(techs, isoDate) { setRefreshing(true); try { - // Fetcher avec concurrence = FETCH_CONCURRENCY (12) + // Fetcher avec concurrence = FETCH_CONCURRENCY (30) + // Chaque worker vérifie isRefreshAborted() AVANT de prendre la prochaine + // intervention : si l'utilisateur a cliqué "Arrêter", les workers + // s'arrêtent proprement dans ~100ms. let idx = 0; async function worker() { while (idx < toFetch.length) { + if (isRefreshAborted()) return; const i = idx++; await fetchAndUpdateIntervention(toFetch[i]); } @@ -806,6 +843,13 @@ async function refreshStatuses(techs, isoDate) { for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker()); await Promise.all(workers); + // Si annulé : on laisse les refs déjà arrivées s'afficher (le rendu + // incrémental les a mises dans le DOM), on skip juste le re-render + // final et le nettoyage ghosts/cache. + if (isRefreshAborted()) { + return; + } + // Résoudre le sort des ghosts for (const tech of techs) { tech.interventions = tech.interventions.filter(iv => { @@ -832,14 +876,29 @@ async function refreshStatuses(techs, isoDate) { async function fetchAndUpdateIntervention(iv) { try { + // Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter", + // on ne fetch pas cette intervention. + if (isRefreshAborted()) { + iv.ficheFetched = true; + iv.ficheFetchError = "aborted"; + return; + } + // Fetch de la fiche (HTML) pour récupérer statut + commentaire tech + // extraire target/checksum qui servent à : // - l'API timeline (texte validé de l'action, si xhr2 n'avait pas été assez) // - construire une URL d'ouverture qui marche (clic sur intervention) - const ficheResp = await sendMessage({ - type: "fetchFiche", - formLink: iv.formLink - }); + // + // Retry léger : une erreur réseau ponctuelle (timeout, 5xx) ne doit pas + // perdre la ligne. 1 seul retry après 400ms. Session expirée n'est PAS + // retryée (ça ne passera pas mieux la 2e fois). + let ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); + if (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted()) { + await new Promise(r => setTimeout(r, 400)); + if (!isRefreshAborted()) { + ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink }); + } + } if (!ficheResp.ok) { iv.ficheFetched = true; @@ -872,6 +931,12 @@ async function fetchAndUpdateIntervention(iv) { } iv.ficheFetched = true; + // ─── RENDU INCRÉMENTAL (v3.1) ───────────────────────────────────────── + // La ref (RFC_NUMBER) et le statut sont déjà connus : on met à jour la + // ligne correspondante DANS LE DOM immédiatement, sans attendre que les + // autres workers aient fini. Pas de re-rendu global. + updateInterventionRow(iv); + // Pour l'API timeline, on utilise le MÊME target + checksum (celui de la fiche) const timelineTarget = iv.ficheTarget; const timelineChecksum = iv.ficheChecksum; @@ -1209,6 +1274,16 @@ function clearCheckMark() { } } +// Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh +// manuel (clic utilisateur), pas pendant les chargements normaux ni les +// refresh auto 12h/15h. +function showAbortButton(on) { + const btn = document.getElementById("abort-btn"); + if (!btn) return; + if (on) btn.classList.remove("hidden"); + else btn.classList.add("hidden"); +} + function renderFromData(data) { state.currentData = data; document.getElementById("loading").classList.add("hidden"); @@ -2323,18 +2398,42 @@ async function copyRef(ref, btn) { } } -// Met à jour dans le DOM la ligne correspondant à une intervention (après fetch) -function updateInterventionInDom(iv) { - const row = document.querySelector(`.intervention[data-action-id="${iv.actionId}"]`); +// ─── Rendu incrémental (v3.1) ─────────────────────────────────────────────── +// Met à jour UNE ligne d'intervention dans le DOM (après qu'un fetch fiche +// ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par +// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans +// attendre que tous les workers aient fini ni re-rendre toute la vue. +// +// Doit rester en phase avec la structure DOM construite par +// buildInterventionRow (classes iv-ref-header, iv-status-check, +// intervention-copy, intervention-dot, timeline-slot...). +const ALL_COLOR_CLASSES = [ + "color-livraison", "color-installation", "color-recup", + "color-remplacement", "color-incident", "color-rollout", + "color-reservation", "color-autre" +]; + +function updateInterventionRow(iv) { + // Réservations : pas concerné (pas de fetch fiche pour elles) + if (iv.type === "AL-Reservation") return; + + const row = document.querySelector( + `.intervention-v2[data-action-id="${iv.actionId}"]` + ); if (!row) return; - // Statut visuel - row.classList.remove("status-closed", "status-resolved"); + // Classes de statut sur la ligne const sc = getStatusClass(iv); + row.classList.remove("status-closed", "status-resolved"); if (sc) row.classList.add(sc); - // Ref (S260xxx) : mise à jour si on l'a trouvée dans la fiche - const refEl = row.querySelector(".intervention-refhdr"); + // Classe de couleur sur la ligne (la pastille hérite via CSS) + const colorKey = deriveColorKey(iv); + row.classList.remove(...ALL_COLOR_CLASSES); + row.classList.add("color-" + colorKey); + + // Ref (le titre gros en haut de la ligne) + const refEl = row.querySelector(".iv-ref-header"); if (refEl) { if (iv.ref) { refEl.textContent = iv.ref; @@ -2345,28 +2444,22 @@ function updateInterventionInDom(iv) { } } - // Titre (catégorie) - const title = row.querySelector(".intervention-title"); - if (title) title.textContent = deriveShortTitle(iv); - - // Meta - const meta = row.querySelector(".intervention-meta"); - if (meta) meta.textContent = shortMeta(iv); - - // Check ✓ : ajouter ou retirer - let statusEl = row.querySelector(".intervention-status"); - if (sc && !statusEl) { - statusEl = document.createElement("div"); - statusEl.className = "intervention-status"; - statusEl.textContent = "✓"; + // Check ✓ : ajouter/retirer selon statut + let checkEl = row.querySelector(".iv-status-check"); + if (sc && !checkEl) { + checkEl = document.createElement("div"); + checkEl.className = "iv-status-check"; + checkEl.textContent = "✓"; + // Insérer après la ref (avant le bouton copier s'il existe) const copy = row.querySelector(".intervention-copy"); - if (copy) row.insertBefore(statusEl, copy); - else row.appendChild(statusEl); - } else if (!sc && statusEl) { - statusEl.remove(); + if (copy) row.insertBefore(checkEl, copy); + else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling); + else row.appendChild(checkEl); + } else if (!sc && checkEl) { + checkEl.remove(); } - // Bouton copier : ajouter si on a maintenant une ref + // Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas let copyBtn = row.querySelector(".intervention-copy"); if (iv.ref && !copyBtn) { copyBtn = document.createElement("button"); @@ -2381,20 +2474,23 @@ function updateInterventionInDom(iv) { row.appendChild(copyBtn); } - // Mettre à jour la classe couleur (pour la pastille) - const colorKey = deriveColorKey(iv); - row.classList.remove("color-livraison", "color-recup", "color-remplacement", "color-autre"); - row.classList.add("color-" + colorKey); + // Catégorie affichée en bas (dépend de la ref pour Incident, etc.) + const catEl = row.querySelector(".iv-category"); + if (catEl) catEl.textContent = deriveShortTitle(iv); - // Mettre à jour le bloc timeline correspondant + // Segment timeline correspondant : même couleur + même classe statut const card = row.closest(".card"); - if (card) { - const slot = card.querySelector(`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`); + if (card && row.dataset.ivIdx !== undefined) { + const slot = card.querySelector( + `.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]` + ); if (slot) { - slot.classList.remove("status-closed", "status-resolved", - "color-livraison", "color-recup", "color-remplacement", "color-autre"); + slot.classList.remove("status-closed", "status-resolved", ...ALL_COLOR_CLASSES); slot.classList.add("color-" + colorKey); if (sc) slot.classList.add(sc); + // Maj du dataset pour le popover (titre + ref) + slot.dataset.title = deriveShortTitle(iv); + if (iv.ref) slot.dataset.ref = iv.ref; } } }