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 :