diff --git a/background.js b/background.js index 7a64086..0025a54 100644 --- a/background.js +++ b/background.js @@ -1,13 +1,18 @@ -// background.js — Service worker (Manifest V3) +// background.js — Service worker (Manifest V3) — v4 // // Rôles : // 1. Au clic sur l'icône : ouvrir le viewer // 2. Répondre aux messages du viewer : // - getSession : trouve l'onglet EasyVista ouvert, renvoie {phpsessid, origin} -// - fetchPlanning : fetch le XML du planning pour une date -// - fetchFiche : fetch une fiche individuelle (HTML) +// - fetchPlanning : fetch le XML du planning pour une date (1 requête = tout) +// - fetchXhr2 : fetch un texte d'action détaillé (utilisé en lazy-load au survol) +// - fetchFiche : fetch une fiche individuelle (HTML) pour statut + commentaire tech // 3. Programmer les alarmes de refresh auto (12h, 15h) // 4. Nettoyer les vieux caches (>7 jours) +// +// v4 : suppression de fetchTimeline (pu utilisé). Le calendar_block contient +// directement ref/contact/lieu/catégorie dans ses attributs attr1/attr2/attr3, +// donc on n'a plus besoin ni de xhr2 en masse, ni de l'API timeline. // Domaines EasyVista reconnus (interne d'abord, externe en fallback) const EV_ORIGINS = [ @@ -110,42 +115,6 @@ async function fetchFicheHtml(origin, phpsessid, formLink) { return html; } -// GUID du "sender" du menu/workflow — observé dans les URLs EasyVista du planning. -// Le sender du formLink du XML planning est {9C395E45-...} mais l'API timeline -// utilise le sender de la FICHE parent ({C99ECD05-...}). -const TIMELINE_SENDER = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D"; - -/** - * Fetch l'API timeline JSON pour récupérer le texte des actions d'une fiche. - * Params : - * - target : ID interne de la fiche (pas l'action_id) - * - checksum : checksum frais extrait depuis le HTML de la fiche - * Retour : texte JSON de l'API, ou null en cas d'erreur. - */ -async function fetchTimelineJson(origin, phpsessid, target, checksum) { - const url = - `${origin}/api/v1/internal/forms/${TIMELINE_SENDER}/timeline` + - `?target=${encodeURIComponent(target)}` + - `&checksum=${encodeURIComponent(checksum)}` + - `&type=todo` + - `§ionId=1` + - `&navigator=` + - `&nbRecord=0` + - `&PHPSESSID=${encodeURIComponent(phpsessid)}`; - console.log("[bg] fetchTimelineJson →", url.substring(0, 120)); - const r = await fetch(url, { - credentials: "include", - headers: { - "Accept": "application/json", - "X-Requested-With": "XMLHttpRequest" - } - }); - if (!r.ok) throw new Error("HTTP " + r.status); - const body = await r.text(); - console.log("[bg] timeline status =", r.status, "| taille =", body.length); - return body; -} - // ============================================================================ // Détection "session invalide" // ============================================================================ @@ -214,24 +183,6 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return; } - if (msg.type === "fetchTimeline") { - const session = await findEasyVistaSession(); - if (!session) { - sendResponse({ ok: false, error: "no_session" }); - return; - } - const body = await fetchTimelineJson( - session.origin, session.phpsessid, msg.target, msg.checksum - ); - // Si on reçoit du HTML au lieu de JSON, c'est une page d'erreur / login - if (body.trimStart().startsWith("<")) { - sendResponse({ ok: false, error: "not_json" }); - return; - } - sendResponse({ ok: true, body }); - return; - } - if (msg.type === "scheduleAutoRefresh") { scheduleAutoRefreshAlarms(); sendResponse({ ok: true }); diff --git a/manifest.json b/manifest.json index 56cb678..63c24c3 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "Planning Techniciens — Vue claire", - "version": "3.3.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.", + "version": "4.1.3", + "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.1.3 : fix ouverture intervention (tentative 3 regex retirée car elle écrasait le bon checksum avec le mauvais).", "permissions": [ "activeTab", "scripting", diff --git a/viewer.js b/viewer.js index 9d2c718..6fe60b3 100644 --- a/viewer.js +++ b/viewer.js @@ -1,17 +1,24 @@ // ============================================================================ -// viewer.js v3 — vue claire du planning techniciens +// viewer.js v4.1 — vue claire du planning techniciens // ============================================================================ -// Différences clés avec v2 : -// 1. Fetch direct EasyVista (plus besoin de capturer la page manuellement) -// 2. Parsing XML (planning_xhr.php?div=calendar_block) au lieu de HTML -// 3. Fetch des fiches individuelles pour détecter les statuts Clôturé/Résolu -// 4. Cache persistant 7 jours par date (chrome.storage.local) -// 5. Navigation ◀ / date picker / ▶ -// 6. Refresh auto 12h / 15h +// Différences clés avec v3 : +// 1. Une SEULE requête initiale (calendar_block) pour TOUT récupérer : +// ref, contact, lieu, catégorie, formLink, deadline — tout est déjà dans +// les attributs attr1/attr2/attr3/textContent du XML EasyVista. +// 2. Suppression du fetch xhr2 en masse au chargement (74 requêtes éliminées) +// 3. Suppression du fetch timeline (plus nécessaire) +// 4. Lazy-load du texte d'action détaillé : on fetch xhr2 UNIQUEMENT sur hover, +// et seulement pour l'intervention survolée (pour enrichir le tooltip avec +// Problème/À faire/Matériel/etc.) +// 5. Rendu utilisateur IDENTIQUE à v3 (même UI, mêmes infos au tooltip). // -// Les fetches se font dans le service worker (background.js) pour éviter -// les problèmes de CORS : viewer.js envoie des messages, background fait les -// requêtes et renvoie les données. +// Différences v4 → v4.1 : +// - Fetch des fiches SÉQUENTIEL (1 par 1) au lieu de 5 workers en parallèle. +// Raison : le serveur EasyVista sérialise de toute façon, et le séquentiel +// rend l'abort instantané quand l'user change de date. +// - Cache INCRÉMENTAL : écrit toutes les 5 fiches pendant le fetch, pas juste +// à la fin. Si l'user change de date en cours, les statuts déjà récupérés +// ne sont pas perdus. // ============================================================================ // ============================================================================ @@ -47,16 +54,9 @@ const LS_THEME = "planning_theme"; const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD const CACHE_DAYS = 7; -// 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; +// v4.1 : plus de constante de concurrence. Les fiches sont fetchées +// séquentiellement (1 à la fois) car le serveur EasyVista est lent de toute +// façon, et ça garantit un abort instantané + pas de race sur le DOM. // ============================================================================ // Mapping de catégorie → titre court + couleur @@ -81,7 +81,6 @@ const CATEGORY_TO_TITLE = [ function isRollOut(iv) { const texts = [ iv.bulleDescription, - iv.actionText, iv.infobulle && iv.infobulle.aFaire, iv.label ]; @@ -101,7 +100,6 @@ function isRollOut(iv) { function isRecupAction(iv) { const texts = [ iv.bulleDescription, - iv.actionText, iv.infobulle && iv.infobulle.aFaire, iv.label ]; @@ -441,95 +439,49 @@ async function loadForDate(isoDate, opts = {}) { // 3. Fusionner cache + frais const merged = mergeCacheAndFresh(cached, fresh); - // 4. Afficher immédiatement avec ce qu'on a + // 4. Afficher immédiatement (v4 : tout est déjà rempli depuis le XML !) + // Le calendar_block contient attr1/attr2/attr3 = contact/lieu/catégorie, + // et textContent = ref. Donc ce 1er rendu est DÉJÀ complet visuellement + // (manquent juste : statut clos/résolu, et détails dans le tooltip au + // survol). Plus d'étapes 5a et 5b successives comme en v3. renderFromData({ techs: merged.techs, targetDate: isoDate, captureTime: Date.now(), source: "fresh" }); - console.log(`[load] 1er rendu (sans refs) à ${Math.round(performance.now() - t0)} ms`); + console.log(`[load] 1er rendu complet à ${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); - } - } - - // On refetche les fiches si : - // - au moins une intervention n'a jamais été fetchée (pas de ficheTarget), OU - // - au moins une intervention n'a pas encore l'actionDescription complète de la fiche - // (cas du cache chargé depuis une version antérieure à v3.2) + // 5. Fetch des fiches en arrière-plan UNIQUEMENT pour obtenir : + // - le statut Clôturé/Résolu (pour le ✓ vert et le fond vert) + // - le commentaire technicien (affiché dans le tooltip) + // - le checksum pour ouvrir la fiche (en vrai déjà dans formLink, mais + // on garde la fiche comme source de vérité pour le statut) + // + // v4.1 : fetch séquentiel (1 à la fois) avec cache écrit tous les 5 fiches. + // Voir refreshStatuses() pour les détails. const needFetch = merged.techs.some(tech => tech.interventions.some(iv => - iv.type === "AL-Intervention" && - (!iv.ficheTarget || !iv.actionDescriptionFetched) + iv.type === "AL-Intervention" && !iv.ficheFetched ) ); - 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`); - }) - ); + console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`); + await refreshStatuses(merged.techs, isoDate); + console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`); } - // Race du Promise.all avec le signal d'annulation : dès que l'user clique - // Arrêter, loadForDate sort immédiatement (masque le bouton, fait un toast) - // sans attendre que les 15 workers en cours finissent leurs fetches. - // Les fetches continuent en arrière-plan mais le token a changé donc ils - // ne peuvent plus écrire le cache ni rafraîchir le DOM. - const abortPromise = makeAbortPromise(myToken); - const allDone = Promise.all(promises).then(() => "done"); - const raceResult = await Promise.race([allDone, abortPromise]); - // 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi) - // Uniquement si on est allé au bout (pas d'annulation). - if (raceResult === "done" && !isRefreshAborted()) { + if (!isRefreshAborted()) { await writeCache(isoDate, { techs: merged.techs }); } - if (raceResult === "done" && !isRefreshAborted()) { + if (!isRefreshAborted()) { showRefreshDone(); console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`); - - // Retry silencieux en arrière-plan pour les interventions dont le texte - // d'action n'a pas pu être récupéré (timeline partielle au 1er coup). - // Lancé SANS await : l'user peut continuer à utiliser l'extension. - // La fonction respecte le token : si l'user change de jour, elle s'arrête. - runBackgroundTimelineRetry(merged.techs, isoDate, myToken).catch(() => {}); } else { console.log(`[load] annulé par l'utilisateur à ${Math.round(performance.now() - t0)} ms`); showAbortToast(); @@ -665,9 +617,25 @@ function actionNodeToIntervention(node) { const deadline = get("max_resolution_date") || get("max_intervention_date"); const requestId = get("request_id"); - // Extraire la ref S260/I260 du label si présente - const refMatch = label.match(/\b([SI]2\d{5}_\d{5})\b/); - const ref = refMatch ? refMatch[1] : null; + // ─── v4 : infos enrichies disponibles directement dans le XML ────────────── + // EasyVista envoie déjà contact/lieu/catégorie dans attr1/attr2/attr3. + // La ref est dans le textContent du nœud (format "SYYMMDD_NNNNN (CM)" ou + // "IYYMMDD_NNNNN (SD)"). Plus besoin de fetcher xhr2 ni la fiche pour ça. + const attr1 = get("attr1"); // contact + const attr2 = get("attr2"); // lieu + const attr3 = get("attr3"); // catégorie complète + const nodeText = (node.textContent || "").trim(); + + // Extraire la ref en priorité du textContent (où elle est complète), sinon + // fallback sur le label (historique, au cas où un jour le format changerait). + let ref = null; + const refFromText = nodeText.match(/\b([SI]2\d{5}_\d{5})\b/); + if (refFromText) { + ref = refFromText[1]; + } else { + const refFromLabel = label.match(/\b([SI]2\d{5}_\d{5})\b/); + if (refFromLabel) ref = refFromLabel[1]; + } // Détection du type "Réservation" : un coordinateur a bloqué un créneau. // Dans le XML, action_type = "AL-Absence" pour ce genre de créneau, mais @@ -696,6 +664,16 @@ function actionNodeToIntervention(node) { } } + // ─── v4 : pré-remplissage immédiat depuis les attributs XML ───────────────── + // On renseigne bulleContact/bulleLieu/categoryLine DÈS la création de l'objet. + // Plus besoin d'attendre xhr2 ou la fiche pour avoir l'affichage de base. + // Seuls restent à fetcher (en arrière-plan, sur fiche) : status + commentaireTech. + // Et sur hover (lazy, seulement si l'user survole) : bulleDescription complet. + const isIntervention = effectiveType === "AL-Intervention"; + const bulleContact = isIntervention && attr1 ? attr1 : null; + const bulleLieu = isIntervention && attr2 ? attr2 : null; + const categoryLine = isIntervention && attr3 ? attr3 : null; + return { actionId: actionId, requestId: requestId, @@ -715,17 +693,21 @@ function actionNodeToIntervention(node) { currentDate: currentDate, formLink: formLink, deadline: deadline, - bulleContact: null, - bulleLieu: null, - bulleDescription: null, - infobulle: null, - status: null, - categoryLine: null, - commentaireTech: null, + // v4 : renseignés directement depuis le XML (plus d'attente de xhr2) + bulleContact: bulleContact, + bulleLieu: bulleLieu, + categoryLine: categoryLine, + bulleDescription: null, // reste null, rempli lazy au premier hover (xhr2) + infobulle: null, // reste null, rempli lazy aussi + status: null, // toujours rempli par fetch fiche (en arrière-plan) + commentaireTech: null, // toujours rempli par fetch fiche (en arrière-plan) + // v4 : ficheTarget/Checksum déjà présents dans formLink (extraits à la demande) ficheTarget: null, ficheChecksum: null, ficheFetched: false, ficheFetchError: null, + xhr2Fetched: false, // lazy : passe à true après le 1er hover + xhr2Fetching: false, // évite les doubles fetchs simultanés ghost: false }; } @@ -754,53 +736,11 @@ function parseXhr2Body(body) { return out; } -/** - * Fetch planning_xhr_2.php pour chaque intervention en parallèle (12 workers) - * et renseigne bulleContact / bulleLieu / bulleDescription / infobulle. - */ -async function fetchBullesForInterventions(interventions) { - if (!interventions || interventions.length === 0) return { ok: 0, fail: 0 }; - setRefreshing(true); - let idx = 0; - let ok = 0, fail = 0; - - async function worker() { - while (idx < interventions.length) { - if (isRefreshAborted()) return; - const i = idx++; - const iv = interventions[i]; - try { - const resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId }); - if (!resp || !resp.ok) { fail++; continue; } - const parsed = parseXhr2Body(resp.body); - if (!parsed) { fail++; continue; } - if (parsed.description) { - iv.bulleDescription = parsed.description; - const infob = parseActionText(parsed.description); - if (infob) { - iv.infobulle = infob; - if (infob.contact) iv.bulleContact = infob.contact; - if (infob.lieu) iv.bulleLieu = infob.lieu; - } - } - if (parsed.label) iv.label = parsed.label; - iv.xhr2Fetched = true; - ok++; - } catch (err) { - fail++; - console.warn("[xhr2] erreur iv", iv.actionId, err); - } - } - } - - const workers = []; - 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}`); - setRefreshing(false); - return { ok, fail }; -} +// v4 : fetchBullesForInterventions (fetch xhr2 en masse au chargement) a été +// supprimée. Le contact/lieu/catégorie viennent maintenant directement des +// attributs attr1/attr2/attr3 du calendar_block. Pour le TEXTE complet de +// l'action (Problème/À faire/Matériel/TFS/...), voir ensureBulleDescription() +// qui lazy-load UNIQUEMENT au premier hover de l'intervention. function actionCoversDate(iv, isoDate) { if (!iv.startDate || !iv.endDate) return true; // manque info → on garde @@ -825,11 +765,15 @@ function mergeCacheAndFresh(cached, fresh) { // fresh.techs : liste des techs avec interventions d'aujourd'hui (depuis EasyVista) // cached.techs : dernière liste sauvegardée pour ce jour (avec statuts) // - // Règles : - // - Chaque intervention fresh APPORTE : actionId, type, startTime, endTime, formLink... - // - Le cache APPORTE : ref, categoryLine, status, infobulle (contact/lieu/...), - // commentaireTech, actionText, ficheFetched - // - Pour les CHAMPS ENRICHIS : cache wins (sauf si fresh en a de meilleurs) + // Règles v4 : + // - Le fresh APPORTE (depuis le XML calendar_block) : actionId, type, + // startTime/endTime, formLink, ref (textContent), bulleContact (attr1), + // bulleLieu (attr2), categoryLine (attr3), deadline. + // - Le cache APPORTE : status (clôturé/résolu), commentaireTech, + // bulleDescription (lazy-load xhr2 au hover) + infobulle, ficheFetched, + // xhr2Fetched. + // - Règle générale : fresh wins sur les champs live, cache wins sur les + // champs enrichis qui ne sont pas dans le fresh. // - Une intervention en cache mais plus en fresh → marquée "ghost" if (!cached || !cached.techs) { @@ -871,12 +815,19 @@ function mergeCacheAndFresh(cached, fresh) { formLink: iv.formLink || cachedIv.formLink, deadline: iv.deadline || cachedIv.deadline, requestId: iv.requestId || cachedIv.requestId, - // Ref : on privilégie celle qu'on a (fresh ou cached) - ref: cachedIv.ref || iv.ref, - // Bulle (HTML planning) : fresh est plus à jour + // v4 : la ref du fresh est maintenant FIABLE (textContent XML), + // on la privilégie sur le cache (inversé vs v3). + ref: iv.ref || cachedIv.ref, + // v4 : categoryLine vient désormais du XML (attr3), on la privilégie. + categoryLine: iv.categoryLine || cachedIv.categoryLine, + // Contact/lieu : fresh est plus à jour (attr1/attr2 du XML) bulleContact: iv.bulleContact || cachedIv.bulleContact, bulleLieu: iv.bulleLieu || cachedIv.bulleLieu, - bulleDescription: iv.bulleDescription || cachedIv.bulleDescription, + // bulleDescription : on privilégie le cache, qui contient le texte + // lazy-load au hover. Le fresh n'a pas ce texte (null au chargement). + bulleDescription: cachedIv.bulleDescription || iv.bulleDescription, + infobulle: cachedIv.infobulle || iv.infobulle, + xhr2Fetched: cachedIv.xhr2Fetched || iv.xhr2Fetched, // ghost : on retire (cette intervention est bien là dans le fresh) ghost: false }; @@ -922,13 +873,12 @@ async function refreshStatuses(techs, isoDate) { for (const iv of tech.interventions) { if (iv.type !== "AL-Intervention") continue; if (!iv.formLink) continue; - // On skip si : - // - Déjà clos / résolu ET ficheTarget déjà connu (statut + requestId OK) - // ET actionDescription déjà remplacée depuis la fiche - // - Sinon on garde (pour avoir statut frais OU ficheTarget pour clic - // OU le texte complet de l'action) + // v4 : on skip les interventions déjà closes/résolues dont la fiche a + // déjà été fetchée une fois (statut + commentaire tech déjà récupérés). + // Le statut "Clôturé" ne change plus une fois atteint, pas la peine de + // refetcher à chaque refresh. const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status); - if (statusClosed && iv.ficheTarget && iv.actionDescriptionFetched) continue; + if (statusClosed && iv.ficheFetched) continue; toFetch.push(iv); } } @@ -937,28 +887,40 @@ async function refreshStatuses(techs, isoDate) { setRefreshing(true); try { - // 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. - let idx = 0; - async function worker() { - while (idx < toFetch.length) { - if (isRefreshAborted()) return; - const i = idx++; - await fetchAndUpdateIntervention(toFetch[i]); + // v4.1 : SÉQUENTIEL (1 fiche à la fois) au lieu de 5 workers en parallèle. + // Raisons : + // - Le serveur EasyVista est lent et sérialise les requêtes de toute façon + // - L'abort devient instantané : un seul fetch en vol, si l'user change + // de date, le prochain await sendMessage() n'est même pas lancé + // - Plus de races de DOM (5 workers qui écrivaient la même carte en + // concurrence, ça générait des artefacts visuels) + // + // Cache incrémental : on sauve le cache toutes les CACHE_WRITE_EVERY fiches + // ET à la fin. Comme ça si l'user change de date en cours, on ne perd pas + // les statuts déjà récupérés. + const CACHE_WRITE_EVERY = 5; + let sinceLastCacheWrite = 0; + + for (let i = 0; i < toFetch.length; i++) { + if (isRefreshAborted()) break; + await fetchAndUpdateIntervention(toFetch[i]); + sinceLastCacheWrite++; + + // Sauvegarde périodique du cache pendant le fetch + if (sinceLastCacheWrite >= CACHE_WRITE_EVERY) { + try { + await writeCache(isoDate, { techs }); + sinceLastCacheWrite = 0; + } catch (err) { + console.warn("[cache] écriture intermédiaire échouée:", err); + } } } - const workers = []; - 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 - // incrémental les a mises dans le DOM), on skip juste le re-render - // final et le nettoyage ghosts/cache. + // Si annulé : on laisse les résultats partiels dans le DOM et on sauve + // quand même ce qu'on a déjà récupéré (cache incrémental). if (isRefreshAborted()) { + try { await writeCache(isoDate, { techs }); } catch {} return; } @@ -971,10 +933,12 @@ async function refreshStatuses(techs, isoDate) { }); } - // Sauvegarder le résultat enrichi dans le cache + // Sauvegarde finale du cache await writeCache(isoDate, { techs }); - // Re-rendre pour afficher les mises à jour (un seul rendu à la fin) + // Re-rendre pour afficher les mises à jour finales (ghosts filtrés, + // tri à jour, etc.). updateInterventionRow a déjà patché chaque ligne, + // mais ce re-render final garantit la cohérence globale. renderFromData({ techs, targetDate: isoDate, @@ -996,11 +960,39 @@ async function fetchAndUpdateIntervention(iv) { 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) - // + // v4.1.2 : pour chaque intervention on fait xhr2 PUIS fiche. + // - xhr2 : récupère les VRAIES infos contact/lieu (attr1/attr2 du XML + // sont parfois erronées si le tech a corrigé après planif). + // On met à jour la carte tout de suite avec les vraies infos. + // - fiche : récupère statut Clôturé/Résolu + commentaire tech + checksum + // valide pour l'ouverture au clic. + + // ─── Étape 1 : xhr2 (rapide, ~400 o) ──────────────────────────────── + if (!iv.xhr2Fetched && !isRefreshAborted()) { + try { + const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId }); + if (xhr2Resp && xhr2Resp.ok) { + const parsed = parseXhr2Body(xhr2Resp.body); + if (parsed) { + if (parsed.description) { + iv.bulleDescription = parsed.description; + const infob = parseActionText(parsed.description); + if (infob) iv.infobulle = infob; + } + if (parsed.label) iv.label = parsed.label; + iv.xhr2Fetched = true; + // Met à jour la carte avec les vraies infos xhr2 + updateInterventionRow(iv); + } + } + } catch (err) { + console.warn("[xhr2] erreur iv", iv.actionId, err); + } + } + + if (isRefreshAborted()) return; + + // ─── Étape 2 : fetch fiche (statut + commentaire + checksum) ────────── // 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). @@ -1023,121 +1015,49 @@ async function fetchAndUpdateIntervention(iv) { const fiche = parseFicheHtml(ficheResp.html); iv.status = fiche.status; - iv.categoryLine = fiche.categoryLine || iv.categoryLine; + iv.commentaireTech = fiche.commentaireTech; + // Fallback : si l'XML n'avait pas la ref (rare, mais possible pour des + // actions hors-standard), on prend celle de la fiche. if (fiche.rfc && !iv.ref) { iv.ref = fiche.rfc; } - iv.commentaireTech = fiche.commentaireTech; - // ─── Remplacement du texte d'action + contact/lieu depuis la fiche ───────── - // 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). - const expectedTechName = iv.techId ? TEAM[iv.techId] : null; - const matchOk = fiche.intervenant && expectedTechName && - namesMatch(fiche.intervenant, expectedTechName); - if (fiche.actionDescription && matchOk) { - // Remplace le texte d'action (affiché dans la popup) - iv.bulleDescription = fiche.actionDescription; - iv.actionDescriptionFetched = true; // flag : déjà remplacé depuis la fiche - // Reparse contact/lieu depuis le nouveau texte : la carte affiche - // bulleContact/bulleLieu, donc il faut les mettre à jour aussi. - const infob = parseActionText(fiche.actionDescription); - if (infob) { - iv.infobulle = infob; - if (infob.contact) iv.bulleContact = infob.contact; - if (infob.lieu) iv.bulleLieu = infob.lieu; - } - } - // Si ça ne matche pas : on garde bulleDescription/Contact/Lieu tels quels (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 - // La regex principale cherche "target=REQUEST_ID&checksum=XXX" mais peut - // échouer si ce pattern n'apparaît pas dans le HTML (selon les sections - // hydratées par Angular). On a plusieurs fallbacks robustes. - if (iv.requestId) { - let checksumFound = false; - // Tentative 1 : target=ID&checksum=... (pattern le plus courant dans les liens) + // ─── Extraire le checksum pour ouvrir la fiche ───────────────────── + // STRICTEMENT IDENTIQUE à v4 originale (qui fonctionne pour l'ouverture) : + // - On n'extrait QUE si ficheChecksum n'est pas déjà là (une fois trouvé + // c'est bon, pas la peine de ré-extraire à chaque refresh et risquer + // de l'écraser avec une mauvaise valeur). + // - Pas de "Tentative 3" ultime : elle peut matcher le checksum du form + // principal qui n'est PAS le bon pour l'action → casse l'ouverture. + if (iv.requestId && !iv.ficheChecksum) { + // Tentative 1 : target=ID&checksum=... (pattern le plus courant) const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`); const m1 = ficheResp.html.match(rx1); if (m1) { iv.ficheTarget = iv.requestId; iv.ficheChecksum = m1[1]; - checksumFound = true; } else { - // Tentative 2 : dans le JSON formData : "id":"REQUEST_ID"..."checksum":"..." - // ou l'inverse : "checksum":"..."..."id":"REQUEST_ID" + // Tentative 2 : JSON formData const rx2a = new RegExp(`"id"\\s*:\\s*"${iv.requestId}"[\\s\\S]{0,200}?"checksum"\\s*:\\s*"([a-f0-9]{40})"`); const m2a = ficheResp.html.match(rx2a); if (m2a) { iv.ficheTarget = iv.requestId; iv.ficheChecksum = m2a[1]; - checksumFound = true; } else { const rx2b = new RegExp(`"checksum"\\s*:\\s*"([a-f0-9]{40})"[\\s\\S]{0,200}?"id"\\s*:\\s*"${iv.requestId}"`); const m2b = ficheResp.html.match(rx2b); if (m2b) { iv.ficheTarget = iv.requestId; iv.ficheChecksum = m2b[1]; - checksumFound = true; } } } - // Tentative 3 (ultime) : le checksum global du form principal. - if (!checksumFound) { - const rx3 = /"form"\s*:\s*\{[^}]*?"checksum"\s*:\s*"([a-f0-9]{40})"[\s\S]{0,2000}?"id"\s*:\s*"(\d+)"/; - const m3 = ficheResp.html.match(rx3); - if (m3 && m3[2] === String(iv.requestId)) { - iv.ficheTarget = iv.requestId; - iv.ficheChecksum = m3[1]; - } - } } 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. + // Rendu incrémental : mettre à jour la ligne dans le DOM immédiatement + // (statut clos → fond vert + ✓, commentaire tech dans le tooltip). 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; - - // Étape timeline API : on veut le texte COMPLET de l'action. - // planning_xhr_2.php tronque souvent à ~300 chars, mais l'API timeline - // retourne le texte intégral. On la fetch à chaque fois que possible. - // - // PROBLÈME OBSERVÉ : EasyVista retourne parfois une timeline "partielle" - // au 1er appel (ex: 8 Ko au lieu de 44 Ko), sans le texte de l'action - // courante. Le serveur a besoin de "construire" le contexte après le fetch - // de la fiche. Dans ce cas on MARQUE l'intervention pour un retry silencieux - // en arrière-plan (fait plus tard par runBackgroundTimelineRetry). - const needsTimelineValidation = !iv.actionText; - if (needsTimelineValidation && timelineTarget && timelineChecksum) { - if (isRefreshAborted()) return; - const tlResp = await sendMessage({ - type: "fetchTimeline", - target: timelineTarget, - checksum: timelineChecksum - }); - if (tlResp && tlResp.ok) { - const actionDetails = parseTimelineJson(tlResp.body, iv.actionId); - if (actionDetails && actionDetails.text) { - applyActionTextToIv(iv, actionDetails); - } else { - // Timeline partielle : marquer pour retry silencieux en arrière-plan - iv.actionTextPending = true; - } - } else { - iv.actionTextPending = true; - } - } } catch (err) { iv.ficheFetched = true; iv.ficheFetchError = String(err); @@ -1146,90 +1066,56 @@ async function fetchAndUpdateIntervention(iv) { } /** - * Applique les détails d'action (texte timeline) à une intervention : - * - met à jour bulleDescription (texte affiché dans la popup) - * - reparse contact/lieu pour mettre à jour la carte - * - rafraîchit la ligne dans le DOM - * Utilisé à la fois par le flow principal et par le retry silencieux. + * v4 : Lazy-load du texte d'action détaillé au premier survol d'une intervention. + * + * Le calendar_block nous donne déjà contact/lieu/catégorie via attr1/attr2/attr3 + * (planification initiale), mais pas le TEXTE COMPLET de l'action (Problème/ + * À faire/Matériel/TFS/...) et surtout pas les VRAIES infos à jour : un tech + * peut avoir mis à jour le contact ou le lieu après la planification initiale, + * et ces vraies infos ne sont PAS dans attr1/attr2. + * + * Ce texte vient de planning_xhr_2.php. On le fetch à la demande (premier hover) + * pour ne pas surcharger le serveur au chargement initial. + * + * v4.1.2 : quand les infos arrivent, on MET À JOUR la carte car ces infos + * (venant du texte d'action validé par le tech) sont plus fiables que + * attr1/attr2 (planification initiale parfois erronée). */ -function applyActionTextToIv(iv, actionDetails) { - iv.actionText = actionDetails.text; - iv.actionDone = actionDetails.doneById; - iv.bulleDescription = actionDetails.text; - iv.actionDescriptionFetched = true; - iv.actionTextPending = false; - const infob = parseActionText(actionDetails.text); - if (infob) { - iv.infobulle = infob; - if (infob.contact) iv.bulleContact = infob.contact; - if (infob.lieu) iv.bulleLieu = infob.lieu; - } - // Rafraîchir la ligne dans le DOM (lieu/contact mis à jour en live) - updateInterventionRow(iv); -} +async function ensureBulleDescription(iv) { + // Déjà chargé : rien à faire + if (iv.xhr2Fetched) return true; + // Fetch déjà en cours (évite les races si l'utilisateur survole plusieurs fois) + if (iv.xhr2Fetching) return false; + // Pas applicable (réservation, absence, ghost, ou pas d'actionId) + if (iv.type !== "AL-Intervention") return false; + if (!iv.actionId || iv.ghost) return false; -/** - * Retry silencieux en arrière-plan : liste les interventions dont le texte - * d'action n'a pas pu être récupéré (timeline partielle au 1er coup), et - * refait un fetch timeline pour chacune, avec un petit délai entre les appels - * pour ne pas surcharger le serveur. - * - * Cette fonction est lancée sans await — elle tourne en tâche de fond pendant - * que l'utilisateur navigue. Elle respecte le jeton de refresh : si l'user - * change de jour, le jeton change et le retry s'arrête silencieusement. - * - * Aucun spinner ni indication visuelle : l'user ne voit rien, sauf que les - * popups se mettent à jour quand le texte arrive. - */ -async function runBackgroundTimelineRetry(techs, isoDate, myToken) { - // Collecter les interventions qui ont besoin d'un retry - const pending = []; - for (const tech of techs) { - for (const iv of tech.interventions) { - if (iv.actionTextPending && iv.ficheTarget && iv.ficheChecksum) { - pending.push(iv); + iv.xhr2Fetching = true; + try { + const resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId }); + if (!resp || !resp.ok) return false; + const parsed = parseXhr2Body(resp.body); + if (!parsed) return false; + + if (parsed.description) { + iv.bulleDescription = parsed.description; + const infob = parseActionText(parsed.description); + if (infob) { + iv.infobulle = infob; } } - } - if (pending.length === 0) return; + if (parsed.label) iv.label = parsed.label; + iv.xhr2Fetched = true; - // Attendre un peu avant de démarrer (laisser le serveur "respirer") - await new Promise(r => setTimeout(r, 1500)); - - // Si l'user a changé de jour entre-temps, abandonner - if (currentRefreshToken !== myToken) return; - - for (const iv of pending) { - // Si l'user a navigué ailleurs OU cliqué arrêter : on sort sans bruit - if (currentRefreshToken !== myToken) return; - if (isRefreshAborted()) return; - - try { - const tlResp = await sendMessage({ - type: "fetchTimeline", - target: iv.ficheTarget, - checksum: iv.ficheChecksum - }); - if (tlResp && tlResp.ok) { - const actionDetails = parseTimelineJson(tlResp.body, iv.actionId); - if (actionDetails && actionDetails.text) { - applyActionTextToIv(iv, actionDetails); - } - } - } catch { - // Silence : c'est du retry en arrière-plan, on ne dérange pas l'user - } - - // Petit délai entre chaque retry pour ménager le serveur - await new Promise(r => setTimeout(r, 400)); - } - - // Sauvegarder le cache avec les nouvelles infos (si on est toujours - // sur la même date et même token) - if (currentRefreshToken === myToken && !isRefreshAborted()) { - try { - await writeCache(isoDate, { techs }); - } catch {} + // Mettre à jour la carte : lieu/contact du xhr2 sont les VRAIES infos à + // jour (le tech les a peut-être corrigées après la planification initiale). + updateInterventionRow(iv); + return true; + } catch (err) { + console.warn("[xhr2 lazy] erreur iv", iv.actionId, err); + return false; + } finally { + iv.xhr2Fetching = false; } } @@ -1246,29 +1132,29 @@ function isCancelledStatus(s) { // ============================================================================ // Parsing d'une fiche individuelle (HTML) // ============================================================================ +// v4 : simplifié. On ne cherche plus dans la fiche que : +// - le statut Clôturé/Résolu (pour le ✓ vert) +// - le commentaire technicien (affiché dans le tooltip) +// - la ref RFC_NUMBER (utilisée seulement en fallback, si le XML n'avait pas) +// Les autres extractions (categoryLine, intervenant, actionDescription) sont +// supprimées car ces infos viennent maintenant du XML attr1/attr2/attr3 ou du +// lazy-load xhr2 au hover. function parseFicheHtml(html) { const out = { status: null, rfc: null, - categoryLine: 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) + commentaireTech: null }; // STATUS_FR (valeur parfois encodée en \u00XX) let m = html.match(/"dbFieldName"\s*:\s*"STATUS_FR"[^}]*?"value"\s*:\s*"([^"]{2,30})"/); if (m) out.status = decodeJsonString(m[1]); - // RFC_NUMBER + // RFC_NUMBER (fallback au cas où le XML n'aurait pas la ref) m = html.match(/"dbFieldName"\s*:\s*"RFC_NUMBER"[^}]*?"value"\s*:\s*"([^"]{5,30})"/); if (m) out.rfc = m[1]; - // TITLE_FR contient la catégorie complète - m = html.match(/"dbFieldName"\s*:\s*"TITLE_FR"[^}]*?"value"\s*:\s*"([^"]{5,300})"/); - if (m) out.categoryLine = decodeJsonString(m[1]); - // Commentaire tech à la fin de DESCRIPTION : "

techN: ..." m = html.match(/"dbFieldName"\s*:\s*"DESCRIPTION"[^}]*?"value"\s*:\s*"((?:[^"\\]|\\.)+)"/); if (m) { @@ -1279,90 +1165,9 @@ function parseFicheHtml(html) { } } - // ─── Intervenant assigné (AM_EMPLOYEE.LAST_NAME dans la section "Action") ─── - // HTML Angular rendu :
- // ... - // On cherche la PREMIÈRE occurrence (celle de l'action courante, pas la timeline). - m = html.match(/field-label="Intervenant"\s+field-label-id="AM_EMPLOYEE-LAST_NAME"[\s\S]*?value="([^"]+)"\s+ng-attr-name/); - if (m) { - out.intervenant = decodeHtmlEntities(m[1]).trim(); - } - - // ─── Texte complet de l'action (AM_ACTION.DESCRIPTION) ─── - // HTML Angular :
...
TEXTE AVEC
- // On extrait le HTML entre les balises, puis on nettoie (br → \n, décode entités, strip tags). - m = html.match(/field-label-id="AM_ACTION-DESCRIPTION"[\s\S]*?]*class="fr-element fr-view[^"]*"[^>]*>([\s\S]*?)<\/div>/); - if (m) { - const cleaned = cleanActionDescriptionHtml(m[1]); - if (cleaned && cleaned.length > 5) { - out.actionDescription = cleaned; - } - } - return out; } -/** - * Nettoie le HTML du champ AM_ACTION.DESCRIPTION pour obtenir un texte propre : - * -
,
,
→ \n - * -   → espace, > → >, < → <, & → &, " → " - * - Supprime toute autre balise HTML résiduelle - * - Trim lignes et supprime lignes vides multiples - */ -function cleanActionDescriptionHtml(html) { - if (!html) return ""; - let s = html; - // Normaliser les retours à la ligne - s = s.replace(//gi, "\n"); - // Décoder les entités HTML courantes - s = s.replace(/ /g, " ") - .replace(/>/g, ">") - .replace(/</g, "<") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/&/g, "&"); - // Virer les balises HTML restantes (au cas où) - s = s.replace(/<[^>]+>/g, ""); - // Nettoyer : trim chaque ligne, retirer lignes vides en excès - s = s.split("\n").map(l => l.trim()).join("\n"); - s = s.replace(/\n{3,}/g, "\n\n").trim(); - return s; -} - -/** - * Décode les entités HTML dans une chaîne courte (ex: un nom "O'Brien" → "O'Brien"). - */ -function decodeHtmlEntities(s) { - if (!s) return s; - return s.replace(/ /g, " ") - .replace(/>/g, ">") - .replace(/</g, "<") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/&/g, "&"); -} - -/** - * Compare deux noms de personne en étant tolérant : - * - casse - * - accents (é/è/ê → e, ç → c, etc.) - * - espaces multiples - * - espace autour de la virgule - * Ex: "Ciuppa, Mathieu" matche "ciuppa,mathieu" ou "CIUPPA , Mathieu" - */ -function namesMatch(a, b) { - if (!a || !b) return false; - const norm = s => String(s) - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // retire accents - .toLowerCase() - .replace(/\s+/g, " ") - .replace(/\s*,\s*/g, ",") - .trim(); - return norm(a) === norm(b); -} - function decodeJsonString(s) { return s .replace(/\\r/g, "") @@ -1377,95 +1182,6 @@ function decodeJsonString(s) { }); } -// ============================================================================ -// Parse de la réponse JSON de /api/v1/.../timeline -// Extrait le texte de l'action correspondant à un actionId donné. -// ============================================================================ - -function parseTimelineJson(body, actionId) { - let json; - try { - json = JSON.parse(body); - } catch { - return null; - } - - const values = json && json.data && json.data.data && json.data.data.values; - if (!Array.isArray(values)) return null; - - // Chaque élément de values a : - // - rows: [{value}, {value}, ...] (la ligne du tableau) - // - dans la colonne d'index 11 : le texte de l'action (ce qu'on veut) - // - dans la colonne d'index 13 : un objet JSON stringifié avec ACTION_ID, AM_DONE_BY_ID, etc. - // - // L'ordre des colonnes peut varier. On ne se fie pas à des index magiques : - // - on cherche la colonne avec ACTION_ID==actionId pour identifier la bonne ligne - // - dans cette ligne, on prend la colonne qui ressemble à une description - // (contient "
" ou plusieurs ":" typiques de "Date :", "Lieu :", etc.) - - for (const row of values) { - const cells = Array.isArray(row && row.rows) ? row.rows : []; - - // Chercher la colonne "data" qui est un JSON avec ACTION_ID - let meta = null; - for (const c of cells) { - const v = c && c.value; - if (typeof v === "string" && v.startsWith('{"') && v.includes("ACTION_ID")) { - try { - meta = JSON.parse(v); - break; - } catch { /* ignore */ } - } - } - if (!meta) continue; - if (String(meta.ACTION_ID) !== String(actionId)) continue; - - // On a trouvé notre action. Chercher la cellule texte (la plus longue contenant
) - let best = ""; - for (const c of cells) { - const v = c && c.value; - if (typeof v !== "string") continue; - if (v.startsWith('{"')) continue; // c'est un JSON meta, pas le texte - if (v.length < 20) continue; - if (v.length > best.length) best = v; - } - - // Décoder les entités (
→ \n, &/</>/ , \uXXXX) - const text = decodeActionText(best); - - return { - text: text, - doneById: meta.AM_DONE_BY_ID || null, - actionLabel: meta.NAME || null - }; - } - - return null; -} - -function decodeActionText(s) { - if (!s) return ""; - // \uXXXX échappés en JSON (déjà décodés par JSON.parse normalement, - // mais au cas où on reçoit un fragment non parsé) - let out = s.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { - try { return String.fromCharCode(parseInt(hex, 16)); } - catch { return _; } - }); - // Tags
→ retour à la ligne - out = out.replace(//gi, "\n"); - // Autres tags HTML : on les enlève - out = out.replace(/<[^>]+>/g, ""); - // Entités HTML - out = out - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'"); - return out.trim(); -} - /** * Parse le texte d'une action au format : * Date : lundi 20.04 Heure : matin @@ -2192,9 +1908,12 @@ function buildInterventionRow(iv, cardEl) { return row; } - const i = iv.infobulle || {}; - const contactRaw = i.contact || iv.bulleContact || null; - const lieuRaw = i.lieu || iv.bulleLieu || null; + // v4.1.2 : priorité à iv.infobulle (venant du xhr2 = données réelles vérifiées + // par le tech sur place) puis fallback sur iv.bulleContact/iv.bulleLieu + // (venant de attr1/attr2 = planification initiale, parfois incorrecte). + const info = iv.infobulle || {}; + const contactRaw = info.contact || iv.bulleContact || null; + const lieuRaw = info.lieu || iv.bulleLieu || null; // Rendu initial de lieu + contacts dans rightCol renderLieuContactBlocks(rightCol, lieuRaw, contactRaw); @@ -2236,53 +1955,6 @@ function buildInterventionRow(iv, cardEl) { // Sender correct pour ouvrir une fiche EasyVista (vu dans les URLs qui marchent) const FICHE_SENDER = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D"; -// ============================================================================ -// Toasts de notification -// ============================================================================ - -const TOAST_MAX = 3; -const TOAST_DURATION_MS = 2400; - -/** - * Affiche un toast en bas à droite. S'empile, max 3, animations in/out. - */ -function showToast(label, ref) { - const stack = document.getElementById("toast-stack"); - if (!stack) return; - - // Si on dépasse le max, supprimer le plus ancien (= premier enfant) - while (stack.children.length >= TOAST_MAX) { - const oldest = stack.firstChild; - if (oldest) stack.removeChild(oldest); - } - - const toast = document.createElement("div"); - toast.className = "toast"; - const labelEl = document.createElement("span"); - labelEl.className = "toast-label"; - labelEl.textContent = label; - const refEl = document.createElement("span"); - refEl.className = "toast-ref"; - refEl.textContent = ref || "…"; - toast.appendChild(labelEl); - toast.appendChild(refEl); - - stack.appendChild(toast); - - // Forcer reflow puis animer en entrée - void toast.offsetWidth; - toast.classList.add("visible"); - - // Auto-disparition après TOAST_DURATION_MS - setTimeout(() => { - toast.classList.remove("visible"); - toast.classList.add("leaving"); - setTimeout(() => { - if (toast.parentNode === stack) stack.removeChild(toast); - }, 220); - }, TOAST_DURATION_MS); -} - async function openInterventionInNewTab(iv, opts = {}) { if (!iv.formLink) return; @@ -2365,7 +2037,7 @@ async function openInterventionInNewTab(iv, opts = {}) { } } - // Construire l'URL qui fonctionne + // Construire l'URL qui fonctionne (format v3/v4) const internalurltime = Math.floor(Date.now() / 1000); const url = `${session.origin}/index.php` + @@ -2382,6 +2054,49 @@ async function openInterventionInNewTab(iv, opts = {}) { await chrome.tabs.create({ url, active: !opts.background }); } +const TOAST_MAX = 3; +const TOAST_DURATION_MS = 2400; + +/** + * Affiche un toast en bas à droite. S'empile, max 3, animations in/out. + */ +function showToast(label, ref) { + const stack = document.getElementById("toast-stack"); + if (!stack) return; + + // Si on dépasse le max, supprimer le plus ancien (= premier enfant) + while (stack.children.length >= TOAST_MAX) { + const oldest = stack.firstChild; + if (oldest) stack.removeChild(oldest); + } + + const toast = document.createElement("div"); + toast.className = "toast"; + const labelEl = document.createElement("span"); + labelEl.className = "toast-label"; + labelEl.textContent = label; + const refEl = document.createElement("span"); + refEl.className = "toast-ref"; + refEl.textContent = ref || "…"; + toast.appendChild(labelEl); + toast.appendChild(refEl); + + stack.appendChild(toast); + + // Forcer reflow puis animer en entrée + void toast.offsetWidth; + toast.classList.add("visible"); + + // Auto-disparition après TOAST_DURATION_MS + setTimeout(() => { + toast.classList.remove("visible"); + toast.classList.add("leaving"); + setTimeout(() => { + if (toast.parentNode === stack) stack.removeChild(toast); + }, 220); + }, TOAST_DURATION_MS); +} + /** * Formate un numéro de téléphone suisse / français. * 079 123 45 67 (mobile CH) @@ -2609,85 +2324,8 @@ function extractPlanifSignature(actionText) { return null; } -function shortMeta(iv) { - const i = iv.infobulle || {}; - const parts = []; - - // Contact : priorité aux données VALIDÉES de l'action (infobulle) - // sinon on utilise la bulle (attr1 du actions_block) - let contact = i.contact || iv.bulleContact || null; - if (contact) { - // Retirer le numéro de téléphone pour compacter - const c = contact.replace(/\s*\+?\d[\d\s.\-]{6,}/, "").trim(); - parts.push(c || contact); - } - - // Lieu : priorité aux données VALIDÉES, sinon bulle - const lieu = i.lieu || iv.bulleLieu || null; - if (lieu) parts.push(lieu); - - return parts.join(" · ") || "—"; -} - -/** - * Construit le bloc avec Lieu, Contact, Téléphone sur 3 lignes séparées. - * L'ordre d'affichage : Lieu, puis Contact, puis Téléphone. - * Source : priorité action validée (infobulle) > bulle (bulleContact/bulleLieu). - */ -function buildMetaDom(iv) { - const i = iv.infobulle || {}; - const container = document.createElement("div"); - container.className = "intervention-meta-block"; - - const contactRaw = i.contact || iv.bulleContact || null; - const lieu = i.lieu || iv.bulleLieu || null; - - // Séparer nom et téléphone du contact - // Format observé : "Nom, Prénom +41000000001" ou "Nom, Prénom 000000001" - let contactName = contactRaw; - let phone = null; - if (contactRaw) { - const phoneMatch = contactRaw.match(/(\+?\d[\d\s.\-]{6,})/); - if (phoneMatch) { - phone = phoneMatch[1].trim(); - contactName = contactRaw.replace(phoneMatch[0], "").trim(); - } - } - - // Ligne 1 : Lieu - if (lieu) { - const el = document.createElement("div"); - el.className = "intervention-meta-line meta-lieu"; - el.textContent = lieu; - container.appendChild(el); - } - - // Ligne 2 : Contact - if (contactName) { - const el = document.createElement("div"); - el.className = "intervention-meta-line meta-contact"; - el.textContent = contactName; - container.appendChild(el); - } - - // Ligne 3 : Téléphone (plus discret) - if (phone) { - const el = document.createElement("div"); - el.className = "intervention-meta-line meta-phone"; - el.textContent = phone; - container.appendChild(el); - } - - // Si aucun info, afficher un petit placeholder - if (!lieu && !contactName && !phone) { - const el = document.createElement("div"); - el.className = "intervention-meta-line meta-empty"; - el.textContent = "—"; - container.appendChild(el); - } - - return container; -} +// v4.1.1 : shortMeta() et buildMetaDom() supprimées (code mort, héritage v1). +// Le rendu actuel utilise renderLieuContactBlocks() + buildInterventionRow(). async function copyRef(ref, btn) { if (!ref) return; @@ -2848,8 +2486,8 @@ function updateInterventionRow(iv) { const catEl = row.querySelector(".iv-category"); if (catEl) catEl.textContent = deriveShortTitle(iv); - // Lieu + Contact(s) : régénérés depuis les valeurs actuelles de iv - // (elles peuvent avoir été mises à jour par le fetch de la fiche). + // v4.1.2 : régénérer les blocs lieu/contact depuis les valeurs actuelles. + // Priorité à iv.infobulle (xhr2 lazy, vraies infos) puis attr1/attr2 (planif). const rightCol = row.querySelector(".iv-right"); if (rightCol) { const info = iv.infobulle || {}; @@ -2887,11 +2525,34 @@ function showTooltip(e, iv) { el.classList.remove("hidden"); el.classList.add("visible"); moveTooltip(e); + + // v4 : lazy-load du texte complet de l'action au premier hover. + // Sans await : on affiche le tooltip IMMÉDIATEMENT avec ce qu'on a (lieu, + // contact, catégorie, ref venant du XML) ; quand le xhr2 arrive (50-200 ms + // plus tard typiquement), on régénère le tooltip s'il est encore visible. + if (iv && iv.type === "AL-Intervention" && !iv.xhr2Fetched && !iv.xhr2Fetching) { + ensureBulleDescription(iv).then(ok => { + // Si ça a marché ET que le tooltip est toujours visible sur CETTE iv, + // on régénère le HTML pour afficher les détails Problème/À faire/Matériel. + if (!ok) return; + const tip = tooltipEl(); + if (!tip.classList.contains("visible")) return; + // Vérifie qu'on affiche toujours la même intervention (pas un autre hover + // intervenu entretemps) + if (state.currentTooltipIv === iv) { + tip.innerHTML = buildTooltipHTML(iv); + } + }); + } + // Mémoriser quelle iv est actuellement affichée (utilisé pour éviter + // d'écraser un tooltip différent si un autre hover s'est produit entretemps) + state.currentTooltipIv = iv; } function hideTooltip() { const el = tooltipEl(); el.classList.remove("visible"); el.classList.add("hidden"); + state.currentTooltipIv = null; } function moveTooltip(e) { const el = tooltipEl();