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;
}
}
}