diff --git a/manifest.json b/manifest.json index ddaf17f..f939d89 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planning Techniciens — Vue claire", - "version": "3.1.0", + "version": "3.2.0-pre", "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.js b/viewer.js index 59f510a..3b1ca4b 100644 --- a/viewer.js +++ b/viewer.js @@ -47,11 +47,16 @@ 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). -// 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; +// Concurrence des fetches en parallèle. +// En v3.1.1 : xhr2 (bulles) et fetches fiches tournent SIMULTANÉMENT pour +// que les refs arrivent plus vite. Chacun a sa propre concurrency, et le +// total reste raisonnable pour le serveur EasyVista. +// - xhr2 : petits (~400 o) et rapides → 10 workers suffisent +// - fiches : gros (~250 Ko) → 15 workers pour vraiment accélérer +// Total max simultané : 25 requêtes, ce qui reste confortable. +// Si le serveur renvoie des erreurs ou XML vides → baisser les deux. +const FETCH_CONCURRENCY_BULLES = 10; +const FETCH_CONCURRENCY_FICHES = 15; // ============================================================================ // Mapping de catégorie → titre court + couleur @@ -371,75 +376,127 @@ async function loadForDate(isoDate, opts = {}) { return; } - // 1. Afficher immédiatement depuis le cache si disponible - const cached = await readCache(isoDate); - if (cached && !opts.forceRefetch) { - renderFromData({ - techs: cached.techs, - targetDate: isoDate, - captureTime: cached.savedAt || null, - source: "cache" - }); + // (v3.1.1) Tout chargement = un nouveau jeton d'annulation. Le bouton + // "Arrêter" apparaît pour TOUT refresh (clic manuel, navigation date, + // ouverture vue claire), pas juste refreshPlanning(). Le bouton disparaît + // quand le chargement est vraiment fini (finally). + const myToken = startNewRefresh(); + showAbortButton(true); + const t0 = performance.now(); + console.log(`[load] début pour ${isoDate} (token=${myToken})`); - // Si cache présent ET pas de refresh explicite demandé, on s'arrête là. - // Pas de fetch XML, pas de fetch xhr2, pas de fetch fiches. - // Le cache d'un jour précédent reste affiché jusqu'au prochain refresh manuel. - if (!opts.doStatusRefresh) { - return; + try { + // 1. Afficher immédiatement depuis le cache si disponible + const cached = await readCache(isoDate); + if (cached && !opts.forceRefetch) { + renderFromData({ + techs: cached.techs, + targetDate: isoDate, + captureTime: cached.savedAt || null, + source: "cache" + }); + + // Si cache présent ET pas de refresh explicite demandé, on s'arrête là. + if (!opts.doStatusRefresh) { + return; + } + } else { + showLoading(); } - } else { - showLoading(); - } - // 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko) - const fresh = await fetchPlanningForDate(isoDate); - if (!fresh) return; + if (isRefreshAborted()) return; - // 3. Fusionner cache + frais - const merged = mergeCacheAndFresh(cached, fresh); + // 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko) + const tXml = performance.now(); + const fresh = await fetchPlanningForDate(isoDate); + console.log(`[load] XML planning récupéré en ${Math.round(performance.now() - tXml)} ms`); + if (!fresh) return; + if (isRefreshAborted()) return; - // 4. Afficher immédiatement avec ce qu'on a - renderFromData({ - techs: merged.techs, - targetDate: isoDate, - captureTime: Date.now(), - source: "fresh" - }); + // 3. Fusionner cache + frais + const merged = mergeCacheAndFresh(cached, fresh); - // 5. PHASE BULLES (xhr_2) : fetch planning_xhr_2.php pour chaque intervention - const bulleNeeded = []; - for (const tech of merged.techs) { - for (const iv of tech.interventions) { - if (iv.type !== "AL-Intervention") continue; - if (iv.infobulle && iv.bulleContact) continue; - bulleNeeded.push(iv); - } - } - if (bulleNeeded.length > 0) { - console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`); - await fetchBullesForInterventions(bulleNeeded); + // 4. Afficher immédiatement avec ce qu'on a renderFromData({ techs: merged.techs, targetDate: isoDate, captureTime: Date.now(), - source: "fresh+bulles" + source: "fresh" }); + console.log(`[load] 1er rendu (sans refs) à ${Math.round(performance.now() - t0)} ms`); + + // 5. PARALLÈLE : xhr2 (lieu/contact) + fetches fiches (ref/statut) + // Avant v3.1.1 : séquentiel, on devait attendre les 34 xhr2 avant de + // lancer les 34 fiches. Résultat : première ref arrivait après ~1s. + // Maintenant : les deux démarrent en même temps, chacun met à jour + // la ligne correspondante via le rendu incrémental. + const bulleNeeded = []; + for (const tech of merged.techs) { + for (const iv of tech.interventions) { + if (iv.type !== "AL-Intervention") continue; + if (iv.infobulle && iv.bulleContact) continue; + bulleNeeded.push(iv); + } + } + + const needFetch = merged.techs.some(tech => + tech.interventions.some(iv => + iv.type === "AL-Intervention" && !iv.ficheTarget + ) + ); + + const promises = []; + + if (bulleNeeded.length > 0 && !isRefreshAborted()) { + const tBulles = performance.now(); + console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`); + promises.push( + fetchBullesForInterventions(bulleNeeded).then(() => { + console.log(`[load] xhr2 finis en ${Math.round(performance.now() - tBulles)} ms`); + if (!isRefreshAborted()) { + renderFromData({ + techs: merged.techs, + targetDate: isoDate, + captureTime: Date.now(), + source: "fresh+bulles" + }); + } + }) + ); + } + + if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted()) { + const tFiches = performance.now(); + const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length; + console.log(`[load] début fetch des ${nFiches} fiches…`); + promises.push( + refreshStatuses(merged.techs, isoDate).then(() => { + console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`); + }) + ); + } + + await Promise.all(promises); + + // 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi) + if (!isRefreshAborted()) { + await writeCache(isoDate, { techs: merged.techs }); + } + + if (!isRefreshAborted()) { + showRefreshDone(); + console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`); + } else { + console.log(`[load] annulé par l'utilisateur à ${Math.round(performance.now() - t0)} ms`); + } + } finally { + // Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui + // se termine (pas un chargement postérieur que l'utilisateur aurait lancé + // entre-temps en naviguant ailleurs). + if (currentRefreshToken === myToken) { + showAbortButton(false); + } } - - // 6. Sauvegarder dans le cache - await writeCache(isoDate, { techs: merged.techs }); - - // 7. Fetch fiches en arrière-plan (pour statut + target/checksum clic) - const needFetch = merged.techs.some(tech => - tech.interventions.some(iv => - iv.type === "AL-Intervention" && !iv.ficheTarget - ) - ); - if (opts.doStatusRefresh || needFetch) { - await refreshStatuses(merged.techs, isoDate); - } - - showRefreshDone(); } async function refreshPlanning(opts = {}) { @@ -447,16 +504,9 @@ async function refreshPlanning(opts = {}) { await refreshSessionAndLoad(); return; } - // 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); - } + // Refresh manuel : force le refetch des fiches. Le bouton "Arrêter" est + // géré par loadForDate lui-même. + await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true }); } // ============================================================================ @@ -482,6 +532,18 @@ async function fetchPlanningForDate(isoDate) { return null; } + // Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un + // corps vide — typiquement quand la session vient d'être invalidée, ou + // quand il soupçonne du scraping (trop de requêtes parallèles). Dans + // les deux cas, on traite ça comme une session expirée : inutile de + // parser (ça ferait "Document is empty") ni de retry en boucle. + if (!resp.xml || resp.xml.length < 20) { + console.warn("[viewer] XML planning vide — session probablement invalide"); + state.session = null; + showSessionNeeded(); + return null; + } + // Parser le HTML complet du planning (contient TOUT : ref, catégorie, // contact, lieu, description, formLinks, request_id + checksum) const techs = parsePlanningXml(resp.xml, isoDate); @@ -658,6 +720,7 @@ async function fetchBullesForInterventions(interventions) { async function worker() { while (idx < interventions.length) { + if (isRefreshAborted()) return; const i = idx++; const iv = interventions[i]; try { @@ -685,7 +748,7 @@ async function fetchBullesForInterventions(interventions) { } const workers = []; - const nWorkers = Math.min(FETCH_CONCURRENCY, interventions.length); + const nWorkers = Math.min(FETCH_CONCURRENCY_BULLES, interventions.length); for (let w = 0; w < nWorkers; w++) workers.push(worker()); await Promise.all(workers); console.log(`[xhr2] ${ok} OK, ${fail} échecs sur ${interventions.length}`); @@ -826,7 +889,7 @@ async function refreshStatuses(techs, isoDate) { setRefreshing(true); try { - // Fetcher avec concurrence = FETCH_CONCURRENCY (30) + // Fetcher avec concurrence = FETCH_CONCURRENCY_FICHES (15) // 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. @@ -840,7 +903,8 @@ async function refreshStatuses(techs, isoDate) { } const workers = []; - for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker()); + const nWorkers = Math.min(FETCH_CONCURRENCY_FICHES, toFetch.length); + for (let w = 0; w < nWorkers; w++) workers.push(worker()); await Promise.all(workers); // Si annulé : on laisse les refs déjà arrivées s'afficher (le rendu @@ -917,6 +981,22 @@ async function fetchAndUpdateIntervention(iv) { } iv.commentaireTech = fiche.commentaireTech; + // ─── Remplacement UNIQUEMENT du texte d'action affiché dans la popup ────── + // Le texte de la bulle (planning_xhr_2.php) est parfois tronqué/incomplet. + // La fiche contient le texte complet dans AM_ACTION.DESCRIPTION. + // SÉCURITÉ : on ne remplace QUE si l'Intervenant de la fiche correspond au + // tech de la ligne du planning (car une même fiche peut avoir plusieurs + // actions assignées à différents techs, et on fetche la MÊME fiche pour tous). + // NB : on NE touche PAS à bulleContact/bulleLieu (ils viennent de la bulle + // de base et sont utilisés tels quels ailleurs). + if (fiche.actionDescription && fiche.intervenant && iv.techId) { + const expectedTechName = TEAM[iv.techId]; + if (expectedTechName && namesMatch(fiche.intervenant, expectedTechName)) { + iv.bulleDescription = fiche.actionDescription; + } + // Si ça ne matche pas : on garde bulleDescription tel quel (sécurité) + } + // Extraire le checksum CORRECT pour ouvrir la fiche : // - Le target de la FICHE = iv.requestId (vient du XML) // - Il faut trouver le checksum qui est accolé à ce target dans le HTML @@ -994,7 +1074,9 @@ function parseFicheHtml(html) { status: null, rfc: null, categoryLine: null, - commentaireTech: null + commentaireTech: null, + intervenant: null, // Nom du tech assigné à l'action (format "Nom, Prénom") + actionDescription: null // Texte complet "Date:... Lieu:... Contact:..." (propre, sans HTML) }; // STATUS_FR (valeur parfois encodée en \u00XX) @@ -1019,9 +1101,90 @@ function parseFicheHtml(html) { } } + // ─── Intervenant assigné (AM_EMPLOYEE.LAST_NAME dans la section "Action") ─── + // HTML Angular rendu :