Version 3.2.0 (pre-release) — Travail en cours sur la 3.2

This commit is contained in:
2026-04-17 14:00:00 +02:00
parent 94877cb816
commit f52095dc4d
2 changed files with 240 additions and 77 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Planning Techniciens — Vue claire",
"version": "3.1.0",
"version": "3.2.0-pre",
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch) avec navigation par date, détection automatique des interventions closes et cache 7 jours.",
"permissions": [
"activeTab",
+197 -34
View File
@@ -47,11 +47,16 @@ const LS_THEME = "planning_theme";
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
const CACHE_DAYS = 7;
// Concurrence du fetch en parallèle (fiches + timelines).
// Avant v3.1 : 12. Monté à 30 pour afficher les refs plus vite sur les jours
// chargés (~34 interventions → 2 vagues au lieu de 3). Si le serveur sature,
// redescendre à 20.
const FETCH_CONCURRENCY = 30;
// Concurrence des fetches en parallèle.
// En v3.1.1 : xhr2 (bulles) et fetches fiches tournent SIMULTANÉMENT pour
// que les refs arrivent plus vite. Chacun a sa propre concurrency, et le
// total reste raisonnable pour le serveur EasyVista.
// - xhr2 : petits (~400 o) et rapides → 10 workers suffisent
// - fiches : gros (~250 Ko) → 15 workers pour vraiment accélérer
// Total max simultané : 25 requêtes, ce qui reste confortable.
// Si le serveur renvoie des erreurs ou XML vides → baisser les deux.
const FETCH_CONCURRENCY_BULLES = 10;
const FETCH_CONCURRENCY_FICHES = 15;
// ============================================================================
// Mapping de catégorie → titre court + couleur
@@ -371,6 +376,16 @@ async function loadForDate(isoDate, opts = {}) {
return;
}
// (v3.1.1) Tout chargement = un nouveau jeton d'annulation. Le bouton
// "Arrêter" apparaît pour TOUT refresh (clic manuel, navigation date,
// ouverture vue claire), pas juste refreshPlanning(). Le bouton disparaît
// quand le chargement est vraiment fini (finally).
const myToken = startNewRefresh();
showAbortButton(true);
const t0 = performance.now();
console.log(`[load] début pour ${isoDate} (token=${myToken})`);
try {
// 1. Afficher immédiatement depuis le cache si disponible
const cached = await readCache(isoDate);
if (cached && !opts.forceRefetch) {
@@ -382,8 +397,6 @@ async function loadForDate(isoDate, opts = {}) {
});
// Si cache présent ET pas de refresh explicite demandé, on s'arrête là.
// Pas de fetch XML, pas de fetch xhr2, pas de fetch fiches.
// Le cache d'un jour précédent reste affiché jusqu'au prochain refresh manuel.
if (!opts.doStatusRefresh) {
return;
}
@@ -391,9 +404,14 @@ async function loadForDate(isoDate, opts = {}) {
showLoading();
}
if (isRefreshAborted()) return;
// 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko)
const tXml = performance.now();
const fresh = await fetchPlanningForDate(isoDate);
console.log(`[load] XML planning récupéré en ${Math.round(performance.now() - tXml)} ms`);
if (!fresh) return;
if (isRefreshAborted()) return;
// 3. Fusionner cache + frais
const merged = mergeCacheAndFresh(cached, fresh);
@@ -405,8 +423,13 @@ async function loadForDate(isoDate, opts = {}) {
captureTime: Date.now(),
source: "fresh"
});
console.log(`[load] 1er rendu (sans refs) à ${Math.round(performance.now() - t0)} ms`);
// 5. PHASE BULLES (xhr_2) : fetch planning_xhr_2.php pour chaque intervention
// 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) {
@@ -415,9 +438,22 @@ async function loadForDate(isoDate, opts = {}) {
bulleNeeded.push(iv);
}
}
if (bulleNeeded.length > 0) {
const needFetch = merged.techs.some(tech =>
tech.interventions.some(iv =>
iv.type === "AL-Intervention" && !iv.ficheTarget
)
);
const promises = [];
if (bulleNeeded.length > 0 && !isRefreshAborted()) {
const tBulles = performance.now();
console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`);
await fetchBullesForInterventions(bulleNeeded);
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,
@@ -425,21 +461,42 @@ async function loadForDate(isoDate, opts = {}) {
source: "fresh+bulles"
});
}
// 6. Sauvegarder dans le cache
await writeCache(isoDate, { techs: merged.techs });
// 7. Fetch fiches en arrière-plan (pour statut + target/checksum clic)
const needFetch = merged.techs.some(tech =>
tech.interventions.some(iv =>
iv.type === "AL-Intervention" && !iv.ficheTarget
)
})
);
if (opts.doStatusRefresh || needFetch) {
await refreshStatuses(merged.techs, isoDate);
}
if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted()) {
const tFiches = performance.now();
const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length;
console.log(`[load] début fetch des ${nFiches} fiches…`);
promises.push(
refreshStatuses(merged.techs, isoDate).then(() => {
console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`);
})
);
}
await Promise.all(promises);
// 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi)
if (!isRefreshAborted()) {
await writeCache(isoDate, { techs: merged.techs });
}
if (!isRefreshAborted()) {
showRefreshDone();
console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`);
} else {
console.log(`[load] annulé par l'utilisateur à ${Math.round(performance.now() - t0)} ms`);
}
} finally {
// Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui
// se termine (pas un chargement postérieur que l'utilisateur aurait lancé
// entre-temps en naviguant ailleurs).
if (currentRefreshToken === myToken) {
showAbortButton(false);
}
}
}
async function refreshPlanning(opts = {}) {
@@ -447,16 +504,9 @@ async function refreshPlanning(opts = {}) {
await refreshSessionAndLoad();
return;
}
// Rafraîchissement manuel (clic bouton) : on démarre un nouveau jeton et
// on fait apparaître le bouton "Arrêter". Les refresh auto (12h/15h) et
// les navigations de date n'ont pas ce bouton (ils ne passent pas ici).
startNewRefresh();
showAbortButton(true);
try {
// Refresh manuel : force le refetch des fiches. Le bouton "Arrêter" est
// géré par loadForDate lui-même.
await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true });
} finally {
showAbortButton(false);
}
}
// ============================================================================
@@ -482,6 +532,18 @@ async function fetchPlanningForDate(isoDate) {
return null;
}
// Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un
// corps vide — typiquement quand la session vient d'être invalidée, ou
// quand il soupçonne du scraping (trop de requêtes parallèles). Dans
// les deux cas, on traite ça comme une session expirée : inutile de
// parser (ça ferait "Document is empty") ni de retry en boucle.
if (!resp.xml || resp.xml.length < 20) {
console.warn("[viewer] XML planning vide — session probablement invalide");
state.session = null;
showSessionNeeded();
return null;
}
// Parser le HTML complet du planning (contient TOUT : ref, catégorie,
// contact, lieu, description, formLinks, request_id + checksum)
const techs = parsePlanningXml(resp.xml, isoDate);
@@ -658,6 +720,7 @@ async function fetchBullesForInterventions(interventions) {
async function worker() {
while (idx < interventions.length) {
if (isRefreshAborted()) return;
const i = idx++;
const iv = interventions[i];
try {
@@ -685,7 +748,7 @@ async function fetchBullesForInterventions(interventions) {
}
const workers = [];
const nWorkers = Math.min(FETCH_CONCURRENCY, interventions.length);
const nWorkers = Math.min(FETCH_CONCURRENCY_BULLES, interventions.length);
for (let w = 0; w < nWorkers; w++) workers.push(worker());
await Promise.all(workers);
console.log(`[xhr2] ${ok} OK, ${fail} échecs sur ${interventions.length}`);
@@ -826,7 +889,7 @@ async function refreshStatuses(techs, isoDate) {
setRefreshing(true);
try {
// Fetcher avec concurrence = FETCH_CONCURRENCY (30)
// Fetcher avec concurrence = FETCH_CONCURRENCY_FICHES (15)
// Chaque worker vérifie isRefreshAborted() AVANT de prendre la prochaine
// intervention : si l'utilisateur a cliqué "Arrêter", les workers
// s'arrêtent proprement dans ~100ms.
@@ -840,7 +903,8 @@ async function refreshStatuses(techs, isoDate) {
}
const workers = [];
for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker());
const nWorkers = Math.min(FETCH_CONCURRENCY_FICHES, toFetch.length);
for (let w = 0; w < nWorkers; w++) workers.push(worker());
await Promise.all(workers);
// Si annulé : on laisse les refs déjà arrivées s'afficher (le rendu
@@ -917,6 +981,22 @@ async function fetchAndUpdateIntervention(iv) {
}
iv.commentaireTech = fiche.commentaireTech;
// ─── Remplacement UNIQUEMENT du texte d'action affiché dans la popup ──────
// Le texte de la bulle (planning_xhr_2.php) est parfois tronqué/incomplet.
// La fiche contient le texte complet dans AM_ACTION.DESCRIPTION.
// SÉCURITÉ : on ne remplace QUE si l'Intervenant de la fiche correspond au
// tech de la ligne du planning (car une même fiche peut avoir plusieurs
// actions assignées à différents techs, et on fetche la MÊME fiche pour tous).
// NB : on NE touche PAS à bulleContact/bulleLieu (ils viennent de la bulle
// de base et sont utilisés tels quels ailleurs).
if (fiche.actionDescription && fiche.intervenant && iv.techId) {
const expectedTechName = TEAM[iv.techId];
if (expectedTechName && namesMatch(fiche.intervenant, expectedTechName)) {
iv.bulleDescription = fiche.actionDescription;
}
// Si ça ne matche pas : on garde bulleDescription tel quel (sécurité)
}
// Extraire le checksum CORRECT pour ouvrir la fiche :
// - Le target de la FICHE = iv.requestId (vient du XML)
// - Il faut trouver le checksum qui est accolé à ce target dans le HTML
@@ -994,7 +1074,9 @@ function parseFicheHtml(html) {
status: null,
rfc: null,
categoryLine: null,
commentaireTech: null
commentaireTech: null,
intervenant: null, // Nom du tech assigné à l'action (format "Nom, Prénom")
actionDescription: null // Texte complet "Date:... Lieu:... Contact:..." (propre, sans HTML)
};
// STATUS_FR (valeur parfois encodée en \u00XX)
@@ -1019,9 +1101,90 @@ function parseFicheHtml(html) {
}
}
// ─── Intervenant assigné (AM_EMPLOYEE.LAST_NAME dans la section "Action") ───
// HTML Angular rendu : <div field-label="Intervenant" field-label-id="AM_EMPLOYEE-LAST_NAME">
// ... <input ... value="Nom, Prénom" ng-attr-name="...">
// 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 : <div field-label-id="AM_ACTION-DESCRIPTION"> ... <div class="fr-element fr-view ...">TEXTE AVEC <br></div>
// 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]*?<div[^>]*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 :
* - <br>, <br/>, <br /> → \n
* - &nbsp; → espace, &gt; → >, &lt; → <, &amp; → &, &quot; → "
* - 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(/<br\s*\/?>/gi, "\n");
// Décoder les entités HTML courantes
s = s.replace(/&nbsp;/g, " ")
.replace(/&gt;/g, ">")
.replace(/&lt;/g, "<")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&amp;/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&#39;Brien" → "O'Brien").
*/
function decodeHtmlEntities(s) {
if (!s) return s;
return s.replace(/&nbsp;/g, " ")
.replace(/&gt;/g, ">")
.replace(/&lt;/g, "<")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&amp;/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, "")