Files
Planification/viewer.js
T

3427 lines
128 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// viewer.js v4.1 — vue claire du planning techniciens
// ============================================================================
// 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).
//
// 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.
// ============================================================================
// ============================================================================
// Configuration
// ============================================================================
// Équipe : ID EasyVista → nom affiché
const TEAM = {
"76272": "Ciuppa, Mathieu",
"83725": "De Almeida Martins, Solange",
"66635": "Makonda, Yannick",
"92235": "Mamouni, Anas",
"90070": "Paisana, David",
"40944": "Pillonel, Olivier",
"72485": "Rosset, Pascal",
"86874": "Rouiller, Quentin"
};
// Absences récurrentes (id tech → [jour JS, 0=dim..6=sam])
const RECURRING_ABSENCES = {
"40944": [5] // Pillonel absent tous les vendredis
};
// Statuts EasyVista qui déclenchent l'affichage "clos"
const CLOSED_STATUS = ["Clôturé", "Cloture", "Clôture"];
const RESOLVED_STATUS = ["Résolu", "Resolu"];
// Statuts qui indiquent qu'une intervention a été supprimée/annulée
// → si présente dans le cache mais disparue du planning : on retire
const CANCELLED_STATUS = ["Annulé", "Annule", "Supprimé", "Supprime"];
// Clés de stockage
const LS_THEME = "planning_theme";
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
const CACHE_DAYS = 7;
// 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
// ============================================================================
const CATEGORY_TO_TITLE = [
// Arrivées / nouvelles installations → Installation (bleu)
[/Arriv[ée]e\s+ou\s+mutation/i, "Installation", "installation"],
[/Accessoire\s+pour\s+PC/i, "Installation", "installation"],
[/Nouveau\s+Poste\s+Windows/i, "Installation", "installation"],
[/Nouveau\s+Poste\s+macOS/i, "Installation", "installation"],
// Récupération / départ (vert)
[/D[ée]part\s+d[\u2018\u2019']un\s+utilisateur/i, "Récupération", "recup"],
[/Reprise\s+du\s+mat[ée]riel/i, "Récupération", "recup"],
// Remplacement (orange)
[/Remplacement\s+de\s+mat[ée]riel/i, "Remplacement", "remplacement"],
];
/**
* Détecte si le texte de l'action commence par "Roll Out".
*/
function isRollOut(iv) {
const texts = [
iv.bulleDescription,
iv.infobulle && iv.infobulle.aFaire,
iv.label
];
for (const t of texts) {
if (!t) continue;
if (/^\s*[«"']?\s*roll[\s\-]*out/i.test(String(t))) return true;
if (/(?:^|\bA faire\s*:\s*)roll[\s\-]*out/i.test(String(t))) return true;
}
return false;
}
/**
* Détecte si le texte de l'action mentionne une récupération de matériel.
* Accepté : "RÉCUPÉRATION DE MATÉRIEL" ou "Récupération" au début de l'action,
* ou dans "A faire : Récupération ...".
*/
function isRecupAction(iv) {
const texts = [
iv.bulleDescription,
iv.infobulle && iv.infobulle.aFaire,
iv.label
];
for (const t of texts) {
if (!t) continue;
const s = String(t);
if (/^\s*r[ée]cup[ée]ration/i.test(s)) return true;
if (/\bA\s+faire\s*:\s*r[ée]cup[ée]ration/i.test(s)) return true;
}
return false;
}
/**
* Dérive un titre court et une clé de couleur à partir d'une intervention.
* Priorité :
* 1. Si la ref commence par I260 → "Incident" (violet)
* 2. Si l'action commence par "Roll Out" → "Roll Out" (brun)
* 3. Si l'action mentionne récupération → "Récupération" (vert)
* 4. Sinon, mapping par catégorie (fiche)
* 5. Sinon, "Autres" (gris)
*/
function deriveShortTitle(iv) {
if (iv.type === "AL-Reservation") return "Réservation";
if (iv.ref && /^I\d/.test(iv.ref)) return "Incident";
if (isRollOut(iv)) return "Roll Out";
if (isRecupAction(iv)) return "Récupération";
const cat = iv.categoryLine || "";
if (!cat) return "Autres";
for (const [regex, title] of CATEGORY_TO_TITLE) {
if (regex.test(cat)) return title;
}
return "Autres";
}
function deriveColorKey(iv) {
if (iv.type === "AL-Reservation") return "reservation";
if (iv.ref && /^I\d/.test(iv.ref)) return "incident";
if (isRollOut(iv)) return "rollout";
if (isRecupAction(iv)) return "recup";
const cat = iv.categoryLine || "";
if (!cat) return "autre";
for (const [regex, , colorKey] of CATEGORY_TO_TITLE) {
if (regex.test(cat)) return colorKey;
}
return "autre";
}
// ============================================================================
// État global
// ============================================================================
let state = {
session: null, // { phpsessid, origin, tabId }
currentDate: null, // "YYYY-MM-DD" affiché
currentData: null, // résultat parsé (techs, stats, ...)
loading: false
};
// ─── Annulation coopérative d'un refresh manuel (v3.1) ──────────────────────
// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent
// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si
// l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement.
//
// v3.2 : on ajoute une "abortPromise" par refresh. loadForDate race cette
// promesse avec son Promise.all, donc dès qu'on clique Arrêter, loadForDate
// sort immédiatement (masque le bouton, fait un toast), même si les fetches
// en cours continuent silencieusement. Le changement de token les rend
// inoffensifs (ils ne peuvent plus écrire le cache ni updater le DOM).
let currentRefreshToken = 0;
let abortedToken = -1;
let abortResolvers = new Map(); // token → resolve fn of the abort promise
function startNewRefresh() {
currentRefreshToken++;
return currentRefreshToken;
}
function makeAbortPromise(myToken) {
return new Promise(resolve => {
abortResolvers.set(myToken, resolve);
});
}
function abortCurrentRefresh() {
abortedToken = currentRefreshToken;
// Réveiller tous les loadForDate en attente (normalement un seul)
for (const [token, resolve] of abortResolvers) {
if (token <= currentRefreshToken) {
resolve("aborted");
abortResolvers.delete(token);
}
}
}
// v4.1.9 : isRefreshAborted(myToken) retourne true si :
// - un nouveau refresh a été lancé (currentRefreshToken > myToken), OU
// - l'utilisateur a explicitement cliqué "Arrêter" (abortedToken).
// Sans myToken fourni (compat), on ne teste que l'abort explicite.
function isRefreshAborted(myToken) {
if (abortedToken === currentRefreshToken) return true;
if (typeof myToken === "number" && myToken < currentRefreshToken) return true;
return false;
}
function cleanupAbortResolver(myToken) {
abortResolvers.delete(myToken);
}
// ============================================================================
// Boot
// ============================================================================
document.addEventListener("DOMContentLoaded", init);
async function init() {
initTheme();
bindTopbar();
bindTooltipInteractions();
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
document.getElementById("date-picker").value = state.currentDate;
// Écouter les messages d'auto-refresh du service worker
chrome.runtime.onMessage.addListener((msg) => {
if (msg && msg.type === "autoRefresh") {
console.log("Auto-refresh 12h/15h déclenché");
refreshPlanning({ keepStatuses: true });
}
});
// Charger la session puis le planning
await refreshSessionAndLoad();
}
async function refreshSessionAndLoad() {
const resp = await sendMessage({ type: "getSession" });
if (!resp.ok || !resp.session) {
showSessionNeeded();
return;
}
state.session = resp.session;
hideSessionNeeded();
hideSessionExpiredBanner();
await loadForDate(state.currentDate);
}
// ============================================================================
// Thème clair/sombre
// ============================================================================
function initTheme() {
const saved = localStorage.getItem(LS_THEME);
const theme = (saved === "light" || saved === "dark") ? saved : detectDefaultTheme();
applyTheme(theme);
}
function detectDefaultTheme() {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
const icon = document.getElementById("theme-icon");
if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙";
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme") || "light";
const next = current === "dark" ? "light" : "dark";
applyTheme(next);
localStorage.setItem(LS_THEME, next);
}
// ============================================================================
// Topbar handlers
// ============================================================================
function bindTopbar() {
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
// v4.1.10 : 2 boutons de rafraîchissement.
// - refresh-btn (Total) : force le re-fetch de toutes les fiches (même celles
// déjà enrichies), utile pour voir les statuts évoluer.
// - refresh-partial-btn (Partiel) : re-fetch juste le XML planning pour
// détecter nouvelles/disparues interventions, mais ne refetch PAS les
// fiches déjà connues → rapide.
document.getElementById("refresh-btn").addEventListener("click", () => {
setActiveRefreshButton("total");
refreshPlanning({ total: true });
});
const partialBtn = document.getElementById("refresh-partial-btn");
if (partialBtn) {
partialBtn.addEventListener("click", () => {
setActiveRefreshButton("partial");
refreshPlanning({ partial: true });
});
}
document.getElementById("abort-btn").addEventListener("click", () => {
// Feedback visuel instantané : masquer le bouton tout de suite, sans
// attendre que loadForDate finisse sa race.
showAbortButton(false);
abortCurrentRefresh();
showAbortToast();
});
document.getElementById("clear-cache-btn").addEventListener("click", onClearCache);
document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1));
document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1));
document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO()));
document.getElementById("date-picker").addEventListener("change", (e) => {
if (e.target.value) loadForDate(e.target.value);
});
document.getElementById("open-ev-btn").addEventListener("click", openEasyVista);
// v4.1.12 : bindings bannière session expirée
const reconnectBtn = document.getElementById("session-banner-reconnect");
if (reconnectBtn) reconnectBtn.addEventListener("click", openEasyVista);
const closeBtn = document.getElementById("session-banner-close");
if (closeBtn) closeBtn.addEventListener("click", hideSessionExpiredBanner);
}
async function openEasyVista() {
// Ouvrir sur le domaine externe (accessible depuis l'extérieur).
// Le domaine interne (itsma.etat-de-vaud.ch) n'est accessible que depuis le réseau VD.
// Une fois connecté, l'extension détectera automatiquement le PHPSESSID quel que
// soit le domaine où tu es connecté.
await chrome.tabs.create({ url: "https://itsma.vd.ch/" });
}
// Navigation ±1 jour en sautant les week-ends
function navigateDate(direction) {
const d = isoToDate(state.currentDate);
d.setDate(d.getDate() + direction);
// Sauter les week-ends
while (d.getDay() === 0 || d.getDay() === 6) {
d.setDate(d.getDate() + direction);
}
loadForDate(dateToISO(d));
}
async function onClearCache() {
if (!confirm(`Vider le cache du ${formatDateDM(state.currentDate)} ?`)) return;
await chrome.storage.local.remove(CACHE_PREFIX + state.currentDate);
await loadForDate(state.currentDate, { forceRefetch: true });
}
// ============================================================================
// Date helpers
// ============================================================================
function todayISO() {
const d = new Date();
return dateToISO(d);
}
function dateToISO(d) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function isoToDate(iso) {
const [y, m, d] = iso.split("-").map(n => parseInt(n, 10));
return new Date(y, m - 1, d);
}
function isoToDDMMYYYY(iso) {
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
}
function formatDateDM(iso) {
const [, m, d] = iso.split("-");
return `${d}/${m}`;
}
function isoToUnixDate(iso) {
// Renvoie le timestamp Unix à midi local du jour (pour que le serveur comprenne bien le jour demandé)
const d = isoToDate(iso);
d.setHours(12, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
// ============================================================================
// Messages → background
// ============================================================================
function sendMessage(msg) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(msg, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(response || {});
});
});
}
// ============================================================================
// Cache (chrome.storage.local)
// ============================================================================
async function readCache(isoDate) {
const key = CACHE_PREFIX + isoDate;
const obj = await chrome.storage.local.get(key);
return obj[key] || null;
}
async function writeCache(isoDate, data) {
const key = CACHE_PREFIX + isoDate;
await chrome.storage.local.set({ [key]: { ...data, savedAt: Date.now() } });
}
// ============================================================================
// Flux principal : charger une date
// ============================================================================
async function loadForDate(isoDate, opts = {}) {
state.currentDate = isoDate;
document.getElementById("date-picker").value = isoDate;
if (!state.session) {
showSessionNeeded();
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})`);
// v4.1.14 : choix du bouton qui tourne
// - Clic explicite "Actualiser" → _fromPartialBtn → "partial"
// - Clic explicite "Tout recharger" → doStatusRefresh → "total"
// - Sinon (nav date / chargement auto) :
// - cache présent → "partial" (c'est juste un diff XML)
// - cache absent → "total" (on charge tout pour la 1re fois)
// La détermination se fait APRÈS readCache.
try {
// 1. Afficher immédiatement depuis le cache si disponible
const cached = await readCache(isoDate);
if (!opts._fromPartialBtn) {
if (opts.doStatusRefresh) {
setActiveRefreshButton("total");
} else {
setActiveRefreshButton(cached ? "partial" : "total");
}
}
if (cached && !opts.forceRefetch) {
renderFromData({
techs: cached.techs,
targetDate: isoDate,
captureTime: cached.savedAt || null,
source: "cache"
});
// v4.1.9 : on NE retourne PAS ici. On continue pour refetch le XML
// du planning afin de détecter les nouvelles iv et celles disparues
// (diff avec le cache). Les iv déjà présentes dans le cache gardent
// leur enrichissement (ficheActionText, statut) → pas de re-fetch
// inutile, seules les nouvelles passent par refreshStatuses.
} else {
showLoading();
}
if (isRefreshAborted(myToken)) 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(myToken)) return;
// 3. Fusionner cache + frais
const merged = mergeCacheAndFresh(cached, fresh);
// v4.1.9 : retirer immédiatement les iv du cache qui ne sont plus dans
// le fresh (elles ont été supprimées / déplacées / annulées dans
// EasyVista). Le user veut qu'elles disparaissent visuellement tout de
// suite, pas qu'elles restent en "ghost".
for (const tech of merged.techs) {
tech.interventions = tech.interventions.filter(iv => !iv.ghost);
}
// 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 complet à ${Math.round(performance.now() - t0)} ms`);
// 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.ficheFetched
)
);
if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted(myToken)) {
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 (statuts)…`);
// forceAll : uniquement si refresh manuel (bouton "Rafraîchir").
// À la navigation normale entre dates, on ne refetch que les iv non
// encore enrichies (ficheFetched=false) — ça reprend là où on s'était
// arrêté si un refresh précédent a été interrompu par un changement de
// date.
await refreshStatuses(merged.techs, isoDate, { forceAll: !!opts.doStatusRefresh, myToken });
console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`);
}
// 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi)
if (!isRefreshAborted(myToken)) {
await writeCache(isoDate, { techs: merged.techs });
}
if (!isRefreshAborted(myToken)) {
showRefreshDone();
console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`);
} else {
// v4.1.9 : toast "annulé" uniquement si c'était un vrai clic "Arrêter",
// pas un simple changement de date (qui abort l'ancien silencieusement).
const wasExplicitAbort = (abortedToken === myToken);
console.log(`[load] annulé à ${Math.round(performance.now() - t0)} ms (explicite=${wasExplicitAbort})`);
if (wasExplicitAbort) showAbortToast();
}
} 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);
}
cleanupAbortResolver(myToken);
}
}
async function refreshPlanning(opts = {}) {
if (!state.session) {
await refreshSessionAndLoad();
return;
}
if (opts.partial) {
// v4.1.13 : _fromPartialBtn empêche loadForDate de reset activeRefreshButton à "total"
await loadForDate(state.currentDate, { doStatusRefresh: false, _fromPartialBtn: true });
} else {
await loadForDate(state.currentDate, { doStatusRefresh: true });
}
}
// ============================================================================
// Fetch du planning (via background)
// ============================================================================
async function fetchPlanningForDate(isoDate) {
setRefreshing(true);
try {
const unixDate = isoToUnixDate(isoDate);
const resp = await sendMessage({
type: "fetchPlanning",
session: state.session,
unixDate: unixDate
});
if (!resp.ok) {
if (resp.error === "no_session" || resp.error === "session_expired") {
state.session = null;
showSessionNeeded();
} else {
showError("Erreur de fetch : " + (resp.error || "inconnue"));
}
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);
return { techs };
} catch (err) {
showError("Erreur inattendue : " + (err.message || err));
return null;
} finally {
setRefreshing(false);
}
}
// ============================================================================
// Parsing du XML du planning
// ============================================================================
/**
* Parse le XML retourné par planning_xhr.php?div=calendar_block.
* Contient les interventions (actions) par technicien, avec :
* - action_id, done_by_id, action_label (parfois juste "AL-Intervention"),
* - start_time / end_time, start_date / end_date,
* - formLink (eventName=formEvent&target=ACTIONID&checksum=...) pour ouvrir l'action,
* - request_id (ID de la fiche SD_REQUEST, utilisé pour ouvrir la fiche).
*/
function parsePlanningXml(xml, isoDate) {
const doc = new DOMParser().parseFromString(xml, "text/xml");
const parserError = doc.querySelector("parsererror");
if (parserError) {
console.warn("Parser error:", parserError.textContent);
}
const actionNodes = doc.querySelectorAll("action");
const byTechId = new Map();
for (const id of Object.keys(TEAM)) {
byTechId.set(id, { id, name: TEAM[id], interventions: [] });
}
for (const node of actionNodes) {
const iv = actionNodeToIntervention(node);
if (!iv) continue;
if (!byTechId.has(iv.techId)) continue;
if (!actionCoversDate(iv, isoDate)) continue;
byTechId.get(iv.techId).interventions.push(iv);
}
for (const tech of byTechId.values()) {
tech.interventions.sort((a, b) =>
(a.startTime || "").localeCompare(b.startTime || "")
);
}
return [...byTechId.values()];
}
function actionNodeToIntervention(node) {
const get = name => node.getAttribute(name) || "";
const actionId = get("action_id");
if (!actionId) return null;
const actionType = get("action_type");
const techId = get("done_by_id");
const label = get("action_label");
const cssClass = get("Css_Class");
const startDate = get("start_date");
const endDate = get("end_date");
const startTime = get("start_time");
const endTime = get("end_time");
const currentDate = get("current_date");
const formLink = get("formLink");
const deadline = get("max_resolution_date") || get("max_intervention_date");
const requestId = get("request_id");
// ─── 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. v4.1.9 : pattern générique [SI]\d+_\d+ (plus
// hardcodé sur "2..." qui était pour 2020-2029).
let ref = null;
const refFromText = nodeText.match(/\b([SI]\d{5,8}_\d{4,6})\b/);
if (refFromText) {
ref = refFromText[1];
} else {
const refFromLabel = label.match(/\b([SI]\d{5,8}_\d{4,6})\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
// action_label contient le vrai pattern :
// action_label = "Xxxxx / Créé par : Nom, Prénom"
// Ex: "Ecrans / Créé par : Nom20, Prénom20"
// "Rollout / Créé par : Nom24, Prénom24"
// "Congés / Créé par : ..." → pas une réservation, c'est une absence
// "Maladie / Créé par : ..." → idem
// "Pompier / Créé par : ..." → idem
let effectiveType = actionType;
let reservationLabel = null;
let reservationCreator = null;
const reservationMatch = label.match(/^([^/]+?)\s*\/\s*Créé par\s*:\s*(.+)$/i);
if (reservationMatch) {
const label1 = reservationMatch[1].trim();
const creator = reservationMatch[2].trim();
// Les "absences" connues (Congés/Maladie/Pompier) restent des absences
if (/^(cong[ée]s|maladie|pompier)$/i.test(label1)) {
effectiveType = "AL-Absence";
} else {
// Tout autre label (Ecrans, Rollout, ...) → Réservation
effectiveType = "AL-Reservation";
reservationLabel = label1;
reservationCreator = creator;
}
}
// ─── 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,
techId: techId,
label: label,
type: effectiveType, // "AL-Intervention" | "AL-Absence" | "AL-Reservation"
originalType: actionType, // type brut (pour debug)
reservationLabel: reservationLabel, // "Ecrans", "Rollout", etc.
reservationCreator: reservationCreator, // "Nom, Prénom" du coordinateur
cssClass: cssClass,
isPompier: /pompier/i.test(label) || /pompier/i.test(actionType),
ref: ref,
startDate: startDate,
endDate: endDate,
startTime: startTime,
endTime: endTime,
currentDate: currentDate,
formLink: formLink,
deadline: deadline,
// 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
};
}
/**
* Parse le body de planning_xhr_2.php?id=ACTIONID (ou similaire).
* Format observé :
* @@DESCRIPTION_S@@...texte complet de l'action...@@DESCRIPTION_E@@
* @@LABEL_S@@AL-Intervention@@LABEL_E@@
* @@LAST_S@@Nom, Prénom@@LAST_E@@
* @@PLANNED_TIME_S@@@@PLANNED_TIME_E@@
* @@PLANNED_CHANGE_S@@@@PLANNED_CHANGE_E@@
*/
function parseXhr2Body(body) {
if (!body || typeof body !== "string") return null;
const out = { description: null, label: null, last: null };
const rxD = /@@DESCRIPTION_S@@([\s\S]*?)@@DESCRIPTION_E@@/;
const rxL = /@@LABEL_S@@([\s\S]*?)@@LABEL_E@@/;
const rxLa = /@@LAST_S@@([\s\S]*?)@@LAST_E@@/;
const md = body.match(rxD);
const ml = body.match(rxL);
const mla = body.match(rxLa);
if (md) out.description = md[1].trim();
if (ml) out.label = ml[1].trim();
if (mla) out.last = mla[1].trim();
return out;
}
// 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
const target = isoToDDMMYYYY(isoDate);
return ddmmyyyyLE(iv.startDate, target) && ddmmyyyyLE(target, iv.endDate);
}
function ddmmyyyyLE(a, b) {
// Compare deux dates JJ/MM/AAAA
const toNum = s => {
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
return m ? parseInt(m[3] + m[2] + m[1], 10) : 0;
};
return toNum(a) <= toNum(b);
}
// ============================================================================
// Fusion cache ↔ fresh
// ============================================================================
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 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) {
return { techs: fresh.techs };
}
// Indexer le cache par actionId
const cachedByAction = new Map();
for (const tech of cached.techs) {
for (const iv of tech.interventions || []) {
cachedByAction.set(iv.actionId, iv);
}
}
const resultTechs = fresh.techs.map(t => ({ ...t, interventions: [] }));
const freshActionIds = new Set();
for (const tech of fresh.techs) {
const outTech = resultTechs.find(t => t.id === tech.id);
for (const iv of tech.interventions) {
freshActionIds.add(iv.actionId);
const cachedIv = cachedByAction.get(iv.actionId);
if (cachedIv) {
// On part du cache (qui a les champs enrichis), puis on remplace
// les champs "live" depuis le fresh (horaires, type, formLink).
const merged = {
...cachedIv,
// Champs live venant du fresh (le planning peut avoir bougé)
techId: iv.techId || cachedIv.techId,
type: iv.type || cachedIv.type,
label: iv.label || cachedIv.label,
cssClass: iv.cssClass || cachedIv.cssClass,
isPompier: iv.isPompier,
startDate: iv.startDate || cachedIv.startDate,
endDate: iv.endDate || cachedIv.endDate,
startTime: iv.startTime || cachedIv.startTime,
endTime: iv.endTime || cachedIv.endTime,
currentDate: iv.currentDate || cachedIv.currentDate,
formLink: iv.formLink || cachedIv.formLink,
deadline: iv.deadline || cachedIv.deadline,
requestId: iv.requestId || cachedIv.requestId,
// 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 : 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
};
outTech.interventions.push(merged);
} else {
outTech.interventions.push(iv);
}
}
}
// Ajouter les interventions qui sont en cache mais plus en fresh
for (const tech of cached.techs) {
const outTech = resultTechs.find(t => t.id === tech.id);
if (!outTech) continue;
for (const iv of tech.interventions || []) {
if (!freshActionIds.has(iv.actionId)) {
const ghost = { ...iv, ghost: true };
outTech.interventions.push(ghost);
}
}
// Retrier
outTech.interventions.sort((a, b) =>
(a.startTime || "").localeCompare(b.startTime || "")
);
}
return { techs: resultTechs };
}
// ============================================================================
// Fetch des fiches individuelles (pour obtenir le statut et les détails)
// ============================================================================
async function refreshStatuses(techs, isoDate, opts = {}) {
const forceAll = !!opts.forceAll;
const myToken = opts.myToken;
// Construire la liste des interventions à fetcher, dans l'ordre de priorité :
// 1. Interventions du (des) pompier(s) en premier
// 2. Puis les autres techs par ordre alphabétique du nom de famille
// 3. (Les absents n'ont pas d'interventions à fetcher)
const sortedTechs = [...techs].sort((a, b) => compareTechs(a, b, isoDate));
const toFetch = [];
for (const tech of sortedTechs) {
for (const iv of tech.interventions) {
if (iv.type !== "AL-Intervention") continue;
if (!iv.formLink) continue;
// 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.ficheFetched) continue;
// v4.1.7 : pause/reprise par date. Sans forceAll (= chargement normal
// au retour sur une date), on skip les iv déjà enrichies (ficheFetched)
// pour ne pas refetcher inutilement. Un clic sur "Rafraîchir" active
// forceAll, ce qui refetche les non-closes même si déjà enrichies (pour
// voir passer les statuts "En cours" → "Exécution" → "Clôturé").
if (!forceAll && iv.ficheFetched) continue;
toFetch.push(iv);
}
}
if (toFetch.length === 0) return;
setRefreshing(true);
// v4.1.7 : barre de progression visible uniquement si on est en train de
// rafraîchir la date actuellement affichée. Si l'user change de date
// pendant le refresh, isRefreshAborted() deviendra true et on sortira.
const showBar = (state.currentDate === isoDate);
if (showBar) {
updateProgressBar(0, toFetch.length);
showProgressBar();
}
try {
// 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(myToken)) break;
await fetchAndUpdateIntervention(toFetch[i], myToken);
sinceLastCacheWrite++;
// Progression — uniquement si la barre concerne la date visible
if (showBar && state.currentDate === isoDate) {
updateProgressBar(i + 1, toFetch.length);
}
// 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);
}
}
}
// 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(myToken)) {
try { await writeCache(isoDate, { techs }); } catch {}
return;
}
// Résoudre le sort des ghosts
for (const tech of techs) {
tech.interventions = tech.interventions.filter(iv => {
if (!iv.ghost) return true;
if (CANCELLED_STATUS.includes(iv.status)) return false;
return true;
});
}
// Sauvegarde finale du cache
await writeCache(isoDate, { techs });
// 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,
captureTime: Date.now(),
source: "fresh+statuses"
});
} finally {
setRefreshing(false);
if (showBar) hideProgressBar();
}
}
async function fetchAndUpdateIntervention(iv, myToken) {
try {
// Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter" ou a
// changé de date, on ne fetch pas cette intervention.
if (isRefreshAborted(myToken)) {
iv.ficheFetched = true;
iv.ficheFetchError = "aborted";
return;
}
// 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(myToken)) {
try {
const xhr2Resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
// v4.1.9 : si on a été aborté pendant l'attente, ne PAS appliquer
// le résultat au DOM (on ne doit plus toucher à une ligne qui
// appartient à la date précédente).
if (isRefreshAborted(myToken)) return;
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(myToken)) 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).
let ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
if (isRefreshAborted(myToken)) return;
if (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted(myToken)) {
await new Promise(r => setTimeout(r, 400));
if (!isRefreshAborted(myToken)) {
ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
}
}
if (isRefreshAborted(myToken)) return;
if (!ficheResp.ok) {
iv.ficheFetched = true;
iv.ficheFetchError = ficheResp.error || "fetch_failed";
if (ficheResp.error === "session_expired") {
state.session = null;
// v4.1.12 : afficher immédiatement la bannière de session expirée
// pour que l'utilisateur voie pourquoi le fetch s'arrête.
showSessionExpiredBanner();
}
return;
}
const fiche = parseFicheHtml(ficheResp.html);
iv.status = fiche.status;
// Rétrocompat : champ plus utilisé, on le laisse à null pour ne pas casser
// d'anciens caches avec un champ undefined.
iv.commentaireTech = null;
// 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;
}
// ─── Étape 3 : API timeline → texte complet de l'action ─────────────
// Le HTML brut de la fiche ne contient PAS les valeurs d'action (elles
// sont injectées côté client par Angular via un appel REST). On appelle
// donc le même endpoint REST qu'Angular pour récupérer la description
// complète, match par ACTION_ID === iv.actionId (fiable, numérique).
//
// Ce texte REMPLACE le texte xhr2 tronqué dans le tooltip.
// Si l'appel échoue ou ne trouve rien, on garde le fallback xhr2 dans
// iv.bulleDescription (déjà stocké à l'étape 1).
if (fiche.formId && fiche.formChecksum && fiche.formSenderGuid &&
iv.actionId && !isRefreshAborted(myToken)) {
try {
const tlResp = await sendMessage({
type: "fetchTimelineApi",
guid: fiche.formSenderGuid,
formId: fiche.formId,
formChecksum: fiche.formChecksum
});
if (isRefreshAborted(myToken)) return;
if (tlResp && tlResp.ok) {
const fullText = parseTimelineJsonForAction(tlResp.body, iv.actionId);
if (fullText) {
iv.ficheActionText = fullText;
}
} else if (tlResp && tlResp.error === "session_expired") {
state.session = null;
showSessionExpiredBanner();
}
} catch (err) {
console.warn("[timeline] erreur iv", iv.actionId, err);
}
}
// ─── 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];
} else {
// 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];
} 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];
}
}
}
}
iv.ficheFetched = true;
// Rendu incrémental : mettre à jour la ligne dans le DOM immédiatement
// (statut clos → fond vert + ✓, commentaire tech dans le tooltip).
// v4.1.9 : ne touche au DOM que si on est toujours sur la même date
// qui a été demandée initialement (sinon on corromprait la nouvelle vue).
if (!isRefreshAborted(myToken)) {
updateInterventionRow(iv);
}
} catch (err) {
iv.ficheFetched = true;
iv.ficheFetchError = String(err);
console.warn("fetchAndUpdate error:", err);
}
}
/**
* 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).
*/
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;
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 (parsed.label) iv.label = parsed.label;
iv.xhr2Fetched = true;
// 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;
}
}
function isClosedStatus(s) {
return !!s && CLOSED_STATUS.some(x => s.includes(x));
}
function isResolvedStatus(s) {
return !!s && RESOLVED_STATUS.some(x => s.includes(x));
}
function isCancelledStatus(s) {
return !!s && CANCELLED_STATUS.some(x => s.includes(x));
}
// ============================================================================
// 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.
/**
* Parse le HTML brut d'une fiche EasyVista (rendu serveur, ~460 Ko, NON hydraté
* par Angular donc ne contient PAS les valeurs d'actions — celles-ci sont
* chargées séparément via l'API timeline).
*
* Rôle : extraire les champs nécessaires :
* - status : STATUS_FR (affichage ✓ et fond vert si clos)
* - rfc : RFC_NUMBER (fallback si pas dans XML)
* - formId : id numérique du form (SD_REQUEST pour S... ou incident)
* - formChecksum : checksum du form (pour appel API timeline)
* - formSenderGuid : v4.1.9 — GUID du form (différent pour incident I...
* vs demande S...). Extrait dynamiquement depuis les
* liens target=FORM_ID&checksum=...&sender={GUID} du
* HTML lui-même. Pour les demandes S → C99ECD05..., pour
* les incidents I → 07ED9C68... (ou autre selon config).
*/
function parseFicheHtml(html) {
const out = {
status: null,
rfc: null,
formId: null,
formChecksum: null,
formSenderGuid: 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 (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];
// formData.form.{id,checksum} : indispensable pour l'API timeline.
// On matche dans les deux ordres possibles.
m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"checksum":"([a-f0-9]{40})"[^}]*?"id":"(\d+)"/);
if (m) {
out.formChecksum = m[1];
out.formId = m[2];
} else {
m = html.match(/var formData\s*=\s*\{"form":\{[^}]*?"id":"(\d+)"[^}]*?"checksum":"([a-f0-9]{40})"/);
if (m) {
out.formId = m[1];
out.formChecksum = m[2];
}
}
// v4.1.9 : déduire le GUID du form. On cherche dans le HTML un lien qui
// référence notre formId (target=FORM_ID...) avec un sender. C'est le GUID
// du form principal utilisé pour l'API timeline :
// - demande S... → {C99ECD05-3D48-4C62-ABF0-66292053AED6}
// - incident I... → {07ED9C68-6172-48EA-8A58-90912B0A283E}
// v4.1.10 (fix) : regex robuste qui accepte &, &amp;, et parcourt jusqu'à
// 300 chars entre target=ID et sender= (au lieu de stopper au 1er "/'/espace
// ce qui peut échouer sur certains HTML).
if (out.formId) {
const rx = new RegExp(
`target=${out.formId}(?:&(?:amp;)?\\w+=[^&"'\\s<>]*){0,10}?&(?:amp;)?sender=(%7B[A-F0-9\\-]{36}%7D)`,
"i"
);
const sm = html.match(rx);
if (sm) {
out.formSenderGuid = sm[1]; // garder encodé (déjà prêt pour URL)
} else {
// Fallback : chercher le GUID le plus fréquent associé à notre formId
// dans tout le HTML (tolérant à n'importe quelle séquence entre les 2).
const rxLoose = new RegExp(
`target=${out.formId}[\\s\\S]{0,300}?sender=(%7B[A-F0-9\\-]{36}%7D)`,
"gi"
);
const counts = new Map();
let lm;
while ((lm = rxLoose.exec(html)) !== null) {
counts.set(lm[1], (counts.get(lm[1]) || 0) + 1);
}
// Prendre le plus fréquent
let best = null;
let bestCount = 0;
for (const [guid, c] of counts) {
if (c > bestCount) { best = guid; bestCount = c; }
}
if (best) out.formSenderGuid = best;
}
// v4.1.10 (fix définitif) : si toujours pas trouvé, fallback par défaut
// sur le GUID des demandes S... (le plus courant). Pour les rares
// incidents I... où le HTML brut n'aurait aucun lien target=FORM_ID, le
// timeline ne sera pas chargé mais le reste fonctionne.
if (!out.formSenderGuid && out.rfc) {
if (/^S/i.test(out.rfc)) {
out.formSenderGuid = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D";
} else if (/^I/i.test(out.rfc)) {
out.formSenderGuid = "%7B07ED9C68-6172-48EA-8A58-90912B0A283E%7D";
}
}
}
return out;
}
/**
* Parse le JSON renvoyé par /api/v1/internal/forms/{GUID}/timeline et en
* extrait le texte de description complet pour UNE action donnée.
*
* Structure du JSON :
* { data: { data: {
* columns: [...13 cols],
* values: [ ← 1 entrée par action dans la fiche
* { rows: [
* {value:"..."}, // [0..10] statut, groupe, dates, etc.
* {value:"Date : ... Heure : ... Lieu : ..."}, // [11] DESCRIPTION ⭐
* {value:""},
* {value:"{\"ACTION_ID\":\"57700033\",...}"} // [13] JSON stringifié
* ] }
* ] }}}
*
* On cherche l'action dont rows[13].ACTION_ID === actionId ; si trouvée, on
* retourne rows[11] nettoyé (br→\n, entités décodées) ; sinon null.
*/
function parseTimelineJsonForAction(jsonText, actionId) {
if (!jsonText || !actionId) return null;
let data;
try {
data = JSON.parse(jsonText);
} catch (e) {
console.warn("[timeline] JSON parse failed:", e);
return null;
}
const values = data?.data?.data?.values;
if (!Array.isArray(values)) return null;
const targetId = String(actionId);
for (const entry of values) {
const rows = entry?.rows;
if (!Array.isArray(rows) || rows.length < 14) continue;
// rows[13] = JSON stringifié qui contient ACTION_ID
const extraRaw = rows[13]?.value;
if (!extraRaw || typeof extraRaw !== "string") continue;
let extra;
try {
extra = JSON.parse(extraRaw);
} catch {
continue;
}
if (String(extra.ACTION_ID) !== targetId) continue;
// Trouvé : extraire la description (rows[11]) et la nettoyer.
const rawDesc = rows[11]?.value || extra["AM_ACTION.DESCRIPTION"] || "";
const cleaned = cleanHtmlBlock(rawDesc);
return cleaned || null;
}
return null;
}
/**
* Nettoie un bloc HTML pour obtenir du texte brut lisible.
* - <br> (avec ou sans attributs) → \n
* - entités HTML décodées (&nbsp; &gt; etc.)
* - tags HTML restants supprimés
* - espaces multiples compactés
*/
function cleanHtmlBlock(html) {
if (!html) return "";
let s = html;
// <br>, <br/>, <br id="...">, <br style="..."> → \n
s = s.replace(/<br\b[^>]*>/gi, "\n");
// Entités HTML
s = s.replace(/&nbsp;/g, " ")
.replace(/&gt;/g, ">")
.replace(/&lt;/g, "<")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&")
.replace(/\u200b/g, ""); // zero-width space
// Tags HTML restants
s = s.replace(/<[^>]+>/g, "");
// Espaces compactés, lignes trimmed, lignes vides retirées
s = s.split("\n").map(l => l.trim().replace(/[ \t]+/g, " ")).filter(Boolean).join("\n");
return s;
}
function decodeJsonString(s) {
return s
.replace(/\\r/g, "")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\\//g, "/")
.replace(/\\"/g, '"')
.replace(/\\\\/g, "\\")
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
try { return String.fromCharCode(parseInt(hex, 16)); }
catch { return _; }
});
}
/**
* Parse le texte d'une action au format :
* Date : lundi 20.04 Heure : matin
* Lieu : Ville1/Rue1 1
* Service : Service1/...
* Contact : Nom1, Prénom1 +41000000001
* ...
*
* → renvoie un objet { date, heure, lieu, service, contact, etage, bureau,
* probleme, aFaire, tfsAncien, tfsNouveau, materiel, dateProposee, autres }
*/
function parseActionText(text) {
if (!text) return null;
const out = { _raw: text };
// Pré-filtrer les lignes "Date proposée par ..." : on NE prend PAS ce champ
// nulle part (ni en infobulle.dateProposee, ni dans autres).
const lines = text.split(/\n+/)
.map(l => l.trim())
.filter(Boolean)
.filter(l => !/^\s*date\s+propos[ée]e\s+par\b/i.test(l));
const labelMap = {
"date": "date",
"heure": "heure",
"lieu": "lieu",
"service": "service",
"contact": "contact",
"bénéficiaire": "beneficiaire",
"beneficiaire": "beneficiaire",
"étage": "etage",
"etage": "etage",
"bureau": "bureau",
"problème": "probleme",
"probleme": "probleme",
"a faire": "aFaire",
"à faire": "aFaire",
"matériel": "materiel",
"materiel": "materiel",
"tfs ancien poste": "tfsAncien",
"tfs nouveau poste": "tfsNouveau"
};
const autres = [];
for (const line of lines) {
// Si la ligne CONTIENT "Date proposée par ..." à l'intérieur (pas juste au
// début), on coupe cette partie-là avant de parser le reste.
// Ex: "...Matériel : xxx Date proposée par contact : oui" → on garde la
// partie Matériel mais on jette "Date proposée..."
let cleanLine = line.replace(/\bdate\s+propos[ée]e\s+par\s+(?:le\s+|la\s+)?contact\s*[:?]\s*\S+.*$/i, "").trim();
if (!cleanLine) continue;
// "Date : lundi 20.04 Heure : matin" → split en plusieurs paires
const markers = [];
const rx = /(Date|Heure|Lieu|Service|Contact|B[ée]n[ée]ficiaire|[ÉE]tage|Bureau|Probl[èe]me|A\s*faire|À\s*faire|Mat[ée]riel|TFS\s+ancien\s+poste|TFS\s+nouveau\s+poste)\s*:\s*/gi;
let m;
while ((m = rx.exec(cleanLine)) !== null) {
markers.push({ label: m[1], valueStart: m.index + m[0].length });
}
if (markers.length === 0) {
autres.push(cleanLine);
continue;
}
for (let i = 0; i < markers.length; i++) {
const mk = markers[i];
let val;
if (i + 1 < markers.length) {
const nextStart = cleanLine.indexOf(markers[i + 1].label, mk.valueStart);
val = cleanLine.substring(mk.valueStart, nextStart).trim();
} else {
val = cleanLine.substring(mk.valueStart).trim();
}
const keyNorm = mk.label.toLowerCase().replace(/\s+/g, " ");
const outKey = labelMap[keyNorm];
if (outKey && val) {
out[outKey] = out[outKey] ? out[outKey] + " / " + val : val;
}
}
}
if (autres.length) out.autres = autres.join("\n");
return out;
}
// ============================================================================
// Rendu général
// ============================================================================
// Compteur de fetches en cours. La flèche tourne tant que ce compteur > 0.
// On le maintient manuellement au lieu d'un booléen pour gérer correctement
// les appels imbriqués (loadForDate + refreshStatuses en parallèle).
let refreshCounter = 0;
// Timer pour effacer le ✓ vert après 5 s
let refreshDoneTimer = null;
// v4.1.13 : quel bouton doit tourner pendant le refresh en cours.
// Valeurs : "total" (par défaut / chargement auto), "partial", ou "xml_only".
let activeRefreshButton = "total";
function setActiveRefreshButton(kind) {
activeRefreshButton = kind || "total";
}
function setRefreshing(on) {
const iconTotal = document.getElementById("refresh-icon");
const iconPartial = document.getElementById("refresh-partial-icon");
// Quel icône doit tourner ? Seulement celui correspondant au bouton
// qui a lancé le refresh (ou "total" par défaut).
const targetIcon = (activeRefreshButton === "partial") ? iconPartial : iconTotal;
if (on) {
refreshCounter++;
if (targetIcon) targetIcon.classList.add("spinning");
clearCheckMark();
// Afficher "Rafraîchissement en cours…" si on n'a pas déjà les données
updateCaptureInfoText();
} else {
refreshCounter = Math.max(0, refreshCounter - 1);
if (refreshCounter === 0) {
// Arrêt : stopper les deux icônes au cas où
if (iconTotal) iconTotal.classList.remove("spinning");
if (iconPartial) iconPartial.classList.remove("spinning");
}
updateCaptureInfoText();
}
}
// Force le rafraîchissement du texte "MAJ HH:MM" ou "Rafraîchissement en cours…"
// selon refreshCounter.
function updateCaptureInfoText() {
if (state.currentData) {
renderCaptureInfo(state.currentData);
}
}
/**
* Appelé quand TOUS les fetches (y compris les fetches fiches en
* arrière-plan) sont terminés. Affiche un ✓ vert à côté de l'heure MAJ
* pendant 5 secondes.
*/
function showRefreshDone() {
const check = document.getElementById("refresh-check");
if (!check) return;
check.classList.remove("hidden");
check.classList.add("visible");
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
refreshDoneTimer = setTimeout(() => {
check.classList.remove("visible");
setTimeout(() => check.classList.add("hidden"), 300); // après transition
}, 5000);
}
function clearCheckMark() {
const check = document.getElementById("refresh-check");
if (check) {
check.classList.remove("visible");
check.classList.add("hidden");
}
if (refreshDoneTimer) {
clearTimeout(refreshDoneTimer);
refreshDoneTimer = null;
}
}
// ─── Barre de progression (v4.1.7) ─────────────────────────────────────
// État global : on affiche la progression du fetch en cours, uniquement si
// c'est le fetch de la page actuellement visible. Si l'utilisateur change
// de date, la barre suit la nouvelle date (son propre état).
function showProgressBar() {
const bar = document.getElementById("progress-bar");
if (bar) bar.classList.remove("hidden");
}
function hideProgressBar() {
const bar = document.getElementById("progress-bar");
if (bar) bar.classList.add("hidden");
updateProgressBar(0, 0);
}
function updateProgressBar(done, total) {
const fill = document.getElementById("progress-bar-fill");
const label = document.getElementById("progress-bar-label");
if (!fill || !label) return;
if (total <= 0) {
fill.style.width = "0%";
label.textContent = "";
return;
}
const pct = Math.min(100, Math.round((done / total) * 100));
fill.style.width = pct + "%";
label.textContent = `Rafraîchissement… ${done} / ${total}`;
}
// Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh
// manuel (clic utilisateur), pas pendant les chargements normaux ni les
// refresh auto 12h/15h.
function showAbortButton(on) {
const btn = document.getElementById("abort-btn");
if (!btn) return;
if (on) btn.classList.remove("hidden");
else btn.classList.add("hidden");
}
/**
* Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent
* encore prendre 1-2 secondes avant de se terminer (on ne peut pas vraiment
* annuler un fetch() en cours), mais du point de vue de l'interface tout
* est arrêté : plus de mise à jour, plus de cache, plus rien.
*/
function showAbortToast() {
showToast("Rafraîchissement", "arrêté");
}
function renderFromData(data) {
state.currentData = data;
document.getElementById("loading").classList.add("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("cards").classList.remove("hidden");
// Calculer les stats
const stats = computeStats(data.techs, data.targetDate);
renderCaptureInfo(data, stats);
renderStats(stats);
renderCards(data);
}
function renderCaptureInfo(data, stats) {
const info = document.getElementById("capture-info");
if (refreshCounter > 0) {
info.textContent = "Rafraîchissement en cours…";
info.classList.add("refreshing");
return;
}
info.classList.remove("refreshing");
const parts = [];
if (data.captureTime) {
const d = new Date(data.captureTime);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
// Comparer la date du cache avec aujourd'hui :
// - si c'est aujourd'hui → juste l'heure
// - sinon → date + heure (format "17.04 14:32")
const today = new Date();
const isSameDay = d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const prefix = data.source === "cache" ? "Cache de " : "MAJ ";
if (isSameDay) {
parts.push(`${prefix}${hh}:${mm}`);
} else {
const dd = String(d.getDate()).padStart(2, "0");
const mo = String(d.getMonth() + 1).padStart(2, "0");
const prefixDate = data.source === "cache" ? "Cache du " : "MAJ ";
parts.push(`${prefixDate}${dd}.${mo} ${hh}:${mm}`);
}
}
info.textContent = parts.join(" · ");
}
function computeStats(techs, targetDate) {
let pompiers = 0, absents = 0;
let totalInterventions = 0, morning = 0, afternoon = 0;
let closed = 0, resolved = 0;
for (const tech of techs) {
const isPompier = tech.interventions.some(iv => iv.isPompier);
const isAbsent = isTechAbsent(tech, targetDate);
if (isPompier) pompiers++;
if (isAbsent) absents++;
const real = tech.interventions.filter(iv =>
iv.type !== "AL-Absence" && !iv.isPompier
);
for (const iv of real) {
totalInterventions++;
const s = timeToMinutes(iv.startTime);
if (s !== null && s < 12 * 60) morning++;
else if (s !== null) afternoon++;
if (isClosedStatus(iv.status)) closed++;
else if (isResolvedStatus(iv.status)) resolved++;
}
}
return { totalTechs: techs.length, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved };
}
function renderStats(s) {
const el = document.getElementById("stats");
el.innerHTML = `
<span class="global-stat global-stat-main"><b>${s.totalInterventions}</b> intervention${s.totalInterventions > 1 ? "s" : ""}</span>
<span class="global-stat global-stat-sub">(${s.morning} matin · ${s.afternoon} après-midi)</span>
${(s.closed + s.resolved > 0) ? `<span class="global-stat-sep">·</span><span class="global-stat"><b>${s.closed + s.resolved}</b> clos</span>` : ""}
<span class="global-stat-sep">·</span>
<span class="global-stat"><b>${s.totalTechs}</b> techs</span>
<span class="global-stat-sep">·</span>
<span class="global-stat"><b>${s.pompiers}</b> pompier${s.pompiers > 1 ? "s" : ""}</span>
<span class="global-stat-sep">·</span>
<span class="global-stat"><b>${s.absents}</b> absent${s.absents > 1 ? "s" : ""}</span>
`;
el.classList.remove("hidden");
}
function renderCards(data) {
const container = document.getElementById("cards");
container.innerHTML = "";
// Tri : pompier(s) > actifs alphabétique nom de famille > absents alphabétique
const sorted = [...data.techs].sort((a, b) => compareTechs(a, b, data.targetDate));
for (const tech of sorted) {
container.appendChild(buildCard(tech, data.targetDate));
}
}
function compareTechs(a, b, targetDate) {
const aP = a.interventions.some(iv => iv.isPompier);
const bP = b.interventions.some(iv => iv.isPompier);
if (aP && !bP) return -1;
if (bP && !aP) return 1;
const aAbs = isTechAbsent(a, targetDate);
const bAbs = isTechAbsent(b, targetDate);
if (aAbs && !bAbs) return 1;
if (bAbs && !aAbs) return -1;
// Sinon : alphabétique sur le nom de famille
// Les noms sont stockés au format "Nom, Prénom"
const aLast = (a.name || "").split(",")[0].trim();
const bLast = (b.name || "").split(",")[0].trim();
return aLast.localeCompare(bLast, "fr");
}
function isTechAbsent(tech, isoDate) {
const recurring = RECURRING_ABSENCES[tech.id];
if (recurring) {
const day = isoToDate(isoDate).getDay();
if (recurring.includes(day)) return true;
}
if (tech.interventions.length === 0) return false;
return tech.interventions.every(iv => iv.type === "AL-Absence" && !iv.isPompier);
}
// ============================================================================
// Construction d'une carte
// ============================================================================
function buildCard(tech, isoDate) {
const card = document.createElement("section");
card.className = "card";
card.dataset.techId = tech.id;
const isPompier = tech.interventions.some(iv => iv.isPompier);
const isAbsent = isTechAbsent(tech, isoDate);
if (isPompier) card.classList.add("is-pompier");
if (isAbsent) card.classList.add("is-absent");
const realInterventions = tech.interventions.filter(iv =>
iv.type !== "AL-Absence" && !iv.isPompier
);
const absenceBlocks = tech.interventions.filter(iv => iv.type === "AL-Absence");
const pompierBlocks = tech.interventions.filter(iv => iv.isPompier);
const morning = realInterventions.filter(iv => {
const s = timeToMinutes(iv.startTime);
return s !== null && s < 12 * 60;
}).length;
const afternoon = realInterventions.length - morning;
// --- Header ---
const header = document.createElement("div");
header.className = "card-header";
const nameEl = document.createElement("div");
nameEl.className = "card-tech-name";
nameEl.textContent = tech.name;
header.appendChild(nameEl);
if (isPompier || isAbsent) {
const badge = document.createElement("div");
badge.className = "card-tech-badge";
if (isPompier) {
badge.classList.add("badge-pompier");
badge.textContent = "Pompier";
} else {
badge.classList.add("badge-absent");
badge.textContent = "Absent";
}
header.appendChild(badge);
}
card.appendChild(header);
// --- Body ---
const body = document.createElement("div");
body.className = "card-body";
// Note statut
if (isPompier && pompierBlocks.length) {
const note = document.createElement("div");
note.className = "card-status-note pompier";
const pb = pompierBlocks[0];
if (pb.startDate && pb.endDate && pb.startDate !== pb.endDate) {
note.textContent = `En pompier du ${pb.startDate.substring(0, 5)} au ${pb.endDate.substring(0, 5)}`;
} else {
note.textContent = "En pompier aujourd'hui";
}
body.appendChild(note);
} else if (isAbsent && absenceBlocks.length) {
const note = document.createElement("div");
note.className = "card-status-note absent";
const ab = absenceBlocks[0];
if (ab.startDate && ab.endDate && ab.startDate !== ab.endDate) {
note.textContent = `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}`;
} else {
note.textContent = "Absent toute la journée";
}
body.appendChild(note);
}
// Absent sans interv → on stop là
if (isAbsent && realInterventions.length === 0) {
card.appendChild(body);
return card;
}
if (realInterventions.length === 0 && !isPompier) {
const empty = document.createElement("div");
empty.className = "card-empty";
empty.textContent = "Pas d'intervention planifiée";
body.appendChild(empty);
card.appendChild(body);
return card;
}
// Timeline
body.appendChild(buildTimeline(realInterventions, pompierBlocks, absenceBlocks, card, isPompier, isAbsent));
// Stats de carte
if (realInterventions.length > 0) {
const stats = document.createElement("div");
stats.className = "card-stats";
stats.innerHTML = `
<div class="stat-total">
<span class="stat-total-num">${realInterventions.length}</span>
<span class="stat-total-lbl">intervention${realInterventions.length > 1 ? "s" : ""}</span>
</div>
<div class="stat-split">
<span class="stat-split-item"><b>${morning}</b> matin</span>
<span class="stat-split-sep">·</span>
<span class="stat-split-item"><b>${afternoon}</b> après-midi</span>
</div>
`;
body.appendChild(stats);
}
// Liste interventions
for (const iv of realInterventions) {
body.appendChild(buildInterventionRow(iv, card));
}
card.appendChild(body);
return card;
}
// ============================================================================
// Timeline
// ============================================================================
function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
const DAY_START = 8 * 60;
const DAY_END = 18 * 60;
const DAY_LEN = DAY_END - DAY_START;
const wrap = document.createElement("div");
wrap.className = "timeline";
if (isPompier) wrap.classList.add("timeline-pompier");
const bar = document.createElement("div");
bar.className = "timeline-bar";
const segments = [];
for (let i = 0; i < realInterventions.length; i++) {
const iv = realInterventions[i];
const s = timeToMinutes(iv.startTime);
const e = timeToMinutes(iv.endTime);
if (s === null || e === null) continue;
const cs = Math.max(s, DAY_START);
const ce = Math.min(e, DAY_END);
if (ce <= cs) continue;
segments.push({
kind: "intervention",
colorKey: deriveColorKey(iv),
iv, ivIdx: i,
start: cs, end: ce,
statusClass: getStatusClass(iv)
});
}
for (const ab of absenceBlocks || []) {
const s = timeToMinutes(ab.startTime);
const e = timeToMinutes(ab.endTime);
if (s === null || e === null) continue;
const cs = Math.max(s, DAY_START);
const ce = Math.min(e, DAY_END);
if (cs <= DAY_START && ce >= DAY_END) continue;
if (ce <= cs) continue;
segments.push({ kind: "absence", start: cs, end: ce, iv: ab });
}
// Calcul des trous (que si pas absent complet)
const occupiedRanges = segments.map(s => [s.start, s.end]).sort((a, b) => a[0] - b[0]);
const merged = [];
for (const [s, e] of occupiedRanges) {
if (merged.length && s <= merged[merged.length - 1][1]) {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], e);
} else {
merged.push([s, e]);
}
}
const holes = [];
let cursor = DAY_START;
for (const [s, e] of merged) {
if (s > cursor) holes.push([cursor, s]);
cursor = Math.max(cursor, e);
}
if (cursor < DAY_END) holes.push([cursor, DAY_END]);
if (!isAbsent) {
for (const [s, e] of holes) {
if (e - s < 15) continue;
const h = document.createElement("div");
h.className = "timeline-hole";
h.style.left = ((s - DAY_START) / DAY_LEN) * 100 + "%";
h.style.width = ((e - s) / DAY_LEN) * 100 + "%";
h.dataset.startMin = s;
h.dataset.endMin = e;
h.dataset.kind = "hole";
bindTimelinePopover(h);
bar.appendChild(h);
}
}
for (const seg of segments) {
const el = document.createElement("div");
el.className = "timeline-slot kind-" + seg.kind;
if (seg.colorKey) el.classList.add("color-" + seg.colorKey);
if (seg.statusClass) el.classList.add(seg.statusClass);
el.style.left = ((seg.start - DAY_START) / DAY_LEN) * 100 + "%";
el.style.width = ((seg.end - seg.start) / DAY_LEN) * 100 + "%";
el.dataset.startMin = seg.start;
el.dataset.endMin = seg.end;
el.dataset.kind = seg.kind;
if (seg.iv) {
el.dataset.title = deriveShortTitle(seg.iv);
if (seg.iv.ref) el.dataset.ref = seg.iv.ref;
}
if (seg.ivIdx !== undefined) {
el.dataset.ivIdx = seg.ivIdx;
el.addEventListener("mouseenter", () => highlightIntervention(cardEl, seg.ivIdx, true));
el.addEventListener("mouseleave", () => highlightIntervention(cardEl, seg.ivIdx, false));
}
bindTimelinePopover(el);
bar.appendChild(el);
}
const noon = document.createElement("div");
noon.className = "timeline-noon";
noon.style.left = (((12 * 60) - DAY_START) / DAY_LEN) * 100 + "%";
bar.appendChild(noon);
wrap.appendChild(bar);
const scale = document.createElement("div");
scale.className = "timeline-scale";
for (const h of [8, 10, 12, 14, 16, 18]) {
const t = document.createElement("span");
t.className = "timeline-tick";
t.style.left = (((h * 60) - DAY_START) / DAY_LEN * 100) + "%";
t.textContent = h + "h";
scale.appendChild(t);
}
wrap.appendChild(scale);
return wrap;
}
function getStatusClass(iv) {
if (isClosedStatus(iv.status)) return "status-closed";
if (isResolvedStatus(iv.status)) return "status-resolved";
return null;
}
function bindTimelinePopover(el) {
el.addEventListener("mouseenter", (e) => showTimelinePopover(e, el));
el.addEventListener("mousemove", moveTooltip);
el.addEventListener("mouseleave", hideTooltip);
}
function showTimelinePopover(e, el) {
const s = parseInt(el.dataset.startMin, 10);
const eMin = parseInt(el.dataset.endMin, 10);
const kind = el.dataset.kind;
const dur = eMin - s;
let html;
if (kind === "hole") {
const h = Math.floor(dur / 60);
const min = dur % 60;
let d;
if (h === 0) d = `${min} min`;
else if (min === 0) d = `${h} h`;
else d = `${h} h ${min} min`;
html = `<dl>
<dt>Libre</dt><dd>${minutesToTime(s)}${minutesToTime(eMin)}</dd>
<dt>Durée</dt><dd>${d} disponible</dd>
</dl>`;
} else {
const t = el.dataset.title || "";
const ref = el.dataset.ref || "";
const k = kind === "absence" ? "Absence" : "Intervention";
html = `<dl>
<dt>${k}</dt><dd>${minutesToTime(s)}${minutesToTime(eMin)}</dd>
${t ? `<dt>Type</dt><dd>${escapeHtml(t)}</dd>` : ""}
${ref ? `<dt>Réf</dt><dd>${escapeHtml(ref)}</dd>` : ""}
</dl>`;
}
const tip = tooltipEl();
tip.innerHTML = html;
tip.classList.remove("hidden");
tip.classList.add("visible");
moveTooltip(e);
}
// ============================================================================
// Ligne d'intervention
// ============================================================================
function buildInterventionRow(iv, cardEl) {
const row = document.createElement("div");
row.className = "intervention-v2";
row.dataset.actionId = iv.actionId;
if (iv.isPompier) row.classList.add("is-pompier-line");
if (iv.ghost) row.classList.add("is-ghost");
const colorKey = deriveColorKey(iv);
row.classList.add("color-" + colorKey);
const statusClass = getStatusClass(iv);
if (statusClass) row.classList.add(statusClass);
const ivIdx = cardEl._rowIdxCounter || 0;
cardEl._rowIdxCounter = ivIdx + 1;
row.dataset.ivIdx = ivIdx;
if (iv.formLink && !iv.ghost) {
row.classList.add("clickable");
// v4.1.8 : plus de title au survol (info déjà dans le tooltip en bas)
// Clic normal : ouvre l'onglet et change de page
// Ctrl/Cmd+Clic : ouvre en arrière-plan (reste sur le planning)
row.addEventListener("click", (e) => {
if (e.target.closest(".intervention-copy")) return;
const background = !!(e.ctrlKey || e.metaKey);
openInterventionInNewTab(iv, { background });
});
// Clic molette (button === 1) : ouvre en arrière-plan
// On utilise 'auxclick' pour les boutons du milieu/droite (standard W3C).
row.addEventListener("auxclick", (e) => {
if (e.button !== 1) return; // que la molette
if (e.target.closest(".intervention-copy")) return;
e.preventDefault();
openInterventionInNewTab(iv, { background: true });
});
// Empêcher le scroll auto quand on clique la molette sur la ligne
row.addEventListener("mousedown", (e) => {
if (e.button === 1) e.preventDefault();
});
}
// Pastille colorée à gauche (barre verticale, toute la hauteur)
const dot = document.createElement("div");
dot.className = "intervention-dot";
row.appendChild(dot);
// ─── Ligne 1 : Ref centrée (TITRE en gros + gras) ────────────────────────
const refHeader = document.createElement("div");
refHeader.className = "iv-ref-header";
if (iv.type === "AL-Reservation") {
refHeader.textContent = "Réservation";
refHeader.classList.add("is-reservation-title");
} else if (iv.ref) {
refHeader.textContent = iv.ref;
} else {
refHeader.textContent = "—";
refHeader.classList.add("no-ref");
}
row.appendChild(refHeader);
// Check ✓ + bouton copier à droite de la ref (pas pour réservation)
if (statusClass && iv.type !== "AL-Reservation") {
const statusEl = document.createElement("div");
statusEl.className = "iv-status-check";
statusEl.textContent = "✓";
row.appendChild(statusEl);
}
if (iv.ref && iv.type !== "AL-Reservation") {
const copyBtn = document.createElement("button");
copyBtn.className = "intervention-copy";
copyBtn.type = "button";
copyBtn.title = "Copier la référence";
copyBtn.innerHTML = "📋";
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
copyRef(iv.ref, copyBtn);
});
row.appendChild(copyBtn);
}
// ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ─────────────────
const timeEl = document.createElement("div");
timeEl.className = "iv-time-vertical";
if (iv.startTime && iv.endTime) {
const s = document.createElement("div");
s.className = "iv-time-start";
s.textContent = iv.startTime;
const sep = document.createElement("div");
sep.className = "iv-time-arrow";
sep.textContent = "↓";
const e = document.createElement("div");
e.className = "iv-time-end";
e.textContent = iv.endTime;
timeEl.appendChild(s);
timeEl.appendChild(sep);
timeEl.appendChild(e);
} else {
timeEl.textContent = "—";
}
row.appendChild(timeEl);
// ─── Ligne 2 droite : lieu / contact+tél / catégorie+signature ───────────
// Pour une RÉSERVATION : affichage différent (par + sujet)
const rightCol = document.createElement("div");
rightCol.className = "iv-right";
if (iv.type === "AL-Reservation") {
// Bloc "Par Nom, Prénom" (en gras)
if (iv.reservationCreator) {
const parEl = document.createElement("div");
parEl.className = "iv-reservation-par";
parEl.textContent = "Par " + iv.reservationCreator;
rightCol.appendChild(parEl);
}
// Sujet (ex: "Ecrans", "Rollout")
if (iv.reservationLabel) {
const sujetEl = document.createElement("div");
sujetEl.className = "iv-reservation-sujet";
sujetEl.textContent = "Sujet : " + iv.reservationLabel;
rightCol.appendChild(sujetEl);
}
row.appendChild(rightCol);
// Tooltip (fixe, ne suit pas la souris — v4.1.12)
row.addEventListener("mouseenter", (e) => {
showTooltip(e, iv, row);
highlightIntervention(cardEl, ivIdx, true);
});
row.addEventListener("mouseleave", () => {
hideTooltip();
highlightIntervention(cardEl, ivIdx, false);
});
return row;
}
// 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);
// ── Bas : Catégorie (à gauche) + Signature planificateur (à droite) ──────
const bottomEl = document.createElement("div");
bottomEl.className = "iv-bottom-line";
const categoryEl = document.createElement("span");
categoryEl.className = "iv-category";
categoryEl.textContent = deriveShortTitle(iv);
bottomEl.appendChild(categoryEl);
// v4.1.8 : extraire la signature depuis le texte COMPLET (fiche) en
// priorité, sinon depuis le xhr2 tronqué. Le xhr2 tronqué peut couper la
// signature, la fiche a toujours le texte complet.
const signature = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription);
if (signature) {
const sigEl = document.createElement("span");
sigEl.className = "iv-signature";
sigEl.textContent = signature;
bottomEl.appendChild(sigEl);
}
rightCol.appendChild(bottomEl);
row.appendChild(rightCol);
// Tooltip (fixe, ne suit pas la souris — v4.1.12)
row.addEventListener("mouseenter", (e) => {
showTooltip(e, iv, row);
highlightIntervention(cardEl, ivIdx, true);
});
row.addEventListener("mouseleave", () => {
hideTooltip();
highlightIntervention(cardEl, ivIdx, false);
});
return row;
}
// Sender correct pour ouvrir une fiche EasyVista (vu dans les URLs qui marchent)
const FICHE_SENDER = "%7BC99ECD05-3D48-4C62-ABF0-66292053AED6%7D";
async function openInterventionInNewTab(iv, opts = {}) {
if (!iv.formLink) return;
// Toast de feedback visuel dès le clic
showToast("Ouverture", iv.ref || iv.actionId);
// Récupérer la session actuelle pour construire une URL valide
let session = state.session;
if (!session) {
const resp = await sendMessage({ type: "getSession" });
session = resp && resp.session;
}
if (!session) {
alert("Pas de session EasyVista active. Ouvre d'abord un onglet EasyVista.");
return;
}
if (!iv.requestId) {
alert("Impossible d'ouvrir : identifiant de fiche (request_id) manquant.\n" +
"Essaie d'actualiser le planning (bouton Rafraîchir).");
return;
}
let target = null;
let checksum = null;
// v4.1.4 : on fetch TOUJOURS la fiche à la volée au clic pour extraire un
// checksum FRAIS. Ne pas utiliser iv.ficheChecksum du cache : les checksums
// EasyVista peuvent expirer entre le fetch arrière-plan et le clic utilisateur.
//
// Retry automatique en cas d'échec du pattern checksum.
{
console.log("[click] fetch fiche fraîche pour iv", iv.actionId, "requestId=", iv.requestId);
let attempts = 0;
const maxAttempts = 2;
while (attempts < maxAttempts && (!target || !checksum)) {
attempts++;
try {
const ficheResp = await sendMessage({
type: "fetchFiche",
formLink: iv.formLink
});
if (!ficheResp.ok) {
if (attempts >= maxAttempts) {
alert("Impossible d'ouvrir la fiche : " + (ficheResp.error || "erreur"));
return;
}
continue; // retry
}
// Extraire le checksum lié au requestId précis
const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`, 'g');
const allMatches = [...ficheResp.html.matchAll(rx)];
console.log(`[click] Trouvé ${allMatches.length} occurrence(s) de target=${iv.requestId}&checksum=... dans HTML de la fiche (taille ${ficheResp.html.length})`);
allMatches.forEach((m, idx) => console.log(` [${idx}] checksum = ${m[1]}`));
if (allMatches.length === 0) {
console.warn(`[click] tentative ${attempts}: pattern target=${iv.requestId} introuvable`);
if (attempts >= maxAttempts) {
alert("Impossible de trouver le checksum pour cette fiche (après retry).");
return;
}
// Attendre un peu avant retry
await new Promise(r => setTimeout(r, 300));
continue;
}
// On prend le PREMIER checksum trouvé (comme avant, comportement v4)
target = iv.requestId;
checksum = allMatches[0][1];
console.log(`[click] checksum retenu: ${checksum}`);
// On stocke aussi en cache pour accélérer le prochain clic (au cas où)
iv.ficheTarget = target;
iv.ficheChecksum = checksum;
} catch (err) {
if (attempts >= maxAttempts) {
alert("Erreur lors du fetch de la fiche : " + err.message);
return;
}
}
}
}
// Construire l'URL qui fonctionne (format identique à l'URL manuelle qui
// marche dans le navigateur quand on ouvre une fiche depuis l'UI EasyVista).
const internalurltime = Math.floor(Date.now() / 1000);
const url =
`${session.origin}/index.php` +
`?PHPSESSID=${encodeURIComponent(session.phpsessid)}` +
`&internalurltime=${internalurltime}` +
`&eventName=formEvent` +
`&target=${encodeURIComponent(target)}` +
`&checksum=${encodeURIComponent(checksum)}` +
`&sender=${FICHE_SENDER}`;
console.log("[click] ouverture fiche iv=", iv.actionId, "ref=", iv.ref, "target=", target, "bg=", !!opts.background);
// Si background (Ctrl+Clic ou clic molette) : onglet ouvert mais pas actif,
// on reste sur la page du planning.
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)
* 021 123 45 67 (fixe CH)
* +41 79 123 45 67
* +33 1 23 45 67 89
* Si le format n'est pas reconnu, renvoie le numéro tel quel (avec les chiffres seuls).
*/
function formatPhone(raw) {
if (!raw) return null;
const digits = String(raw).replace(/[^\d+]/g, "");
if (!digits) return null;
// +41 (Suisse international, 9 chiffres après +41)
let m = digits.match(/^\+41(\d{9})$/);
if (m) {
const d = m[1];
return `+41 ${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// +33 (France)
m = digits.match(/^\+33(\d{9})$/);
if (m) {
const d = m[1];
return `+33 ${d.slice(0, 1)} ${d.slice(1, 3)} ${d.slice(3, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// 0XX XXX XX XX (fixe ou mobile CH, 10 chiffres commençant par 0)
m = digits.match(/^0(\d{9})$/);
if (m) {
const d = m[1];
return `0${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`;
}
// Numéro court interne (5 chiffres) : 78999, 68999, 88999, etc.
m = digits.match(/^(\d{5})$/);
if (m) {
return m[1]; // tel quel (déjà court et lisible)
}
// Fallback : retour brut
return digits;
}
/**
* Extrait le numéro de téléphone d'une chaîne contact.
* Accepte les préfixes : +41, +33, 07x, 02x, 03x (CH), 01-09 FR.
* Retourne un objet { name, phone } où phone est déjà formaté.
*/
function extractContactNameAndPhone(raw) {
if (!raw) return { name: null, phone: null };
const contacts = extractContacts(raw);
if (contacts.length === 0) return { name: null, phone: null };
// Pour compat avec l'ancien usage qui ne prend qu'1 contact
return contacts[0];
}
/**
* Extrait TOUS les contacts d'une chaîne (potentiellement plusieurs séparés
* par "ou", "/", des retours à la ligne, etc.).
* Retourne un tableau [{ name, phone }, { name, phone }, ...]
* Format d'entrée typique :
* "Nom1, Prénom1 +41000000001"
* "Nom1, Prénom1 +41000000001 ou Nom2, Prénom2 +41000000002"
* "Nom1, Prénom1 +41...\nNom2, Prénom2 +41..."
*/
function extractContacts(raw) {
if (!raw) return [];
let s = String(raw).trim();
// Virer les labels parasites (Nom utilisateur, etc.) qui traînent
s = s.replace(/\b(Nom utilisateur|Utilisateur)\s*:\s*[^\n]+/gi, "");
// Séparer sur " ou ", " / ", retours à la ligne
// Mais attention : "Nom, Prénom" contient une virgule qu'on ne doit pas découper
const parts = s.split(/\s+ou\s+|\n+|\s*\/\s*(?=[A-ZÉÈÀÂÎÔÛÇ])/i)
.map(p => p.trim())
.filter(Boolean);
const results = [];
for (const part of parts) {
const { name, phone } = splitOneContact(part);
if (name || phone) results.push({ name, phone });
}
return results;
}
/**
* Split UN seul bloc "Nom Prénom +41... [autres tels] [commentaires]" en
* { name, phone }.
*
* Stratégie robuste (v4.1.8) :
* - On cherche TOUS les numéros de téléphone (long ou court).
* - Le nom = ce qui précède le PREMIER numéro.
* - Le champ phone concatène les numéros trouvés (séparés par " / ").
* - Ce qui suit les numéros (commentaires "S'annoncer à la réception...",
* "téléphone à l'utilisateur") est JETÉ : ça ne fait pas partie du contact.
*
* Pattern numéro (inchangé, connu pour marcher) :
* Long : +41 / +33 / 0X suivi de 8+ caractères de [chiffres espaces . -]
* Court: 5 chiffres isolés (entre espaces, parenthèses, ou début/fin)
*/
function splitOneContact(raw) {
if (!raw) return { name: null, phone: null };
const rxLong = /(\+41\s?\d[\d\s.\-]{8,}|\+33\s?\d[\d\s.\-]{8,}|0\d[\d\s.\-]{8,})/g;
const rxShort = /(?:^|[\s(])(\d{5})(?=[\s)]|$)/g;
// Trouver toutes les positions de match pour LONG et SHORT
const matches = [];
let mm;
while ((mm = rxLong.exec(raw)) !== null) {
matches.push({ start: mm.index, end: mm.index + mm[1].length, tel: mm[1] });
}
while ((mm = rxShort.exec(raw)) !== null) {
// Ne pas prendre un short qui chevauche un long déjà trouvé
const shortTel = mm[1];
const shortStart = mm.index + mm[0].indexOf(shortTel);
const shortEnd = shortStart + shortTel.length;
const overlaps = matches.some(x => shortStart < x.end && shortEnd > x.start);
if (!overlaps) {
matches.push({ start: shortStart, end: shortEnd, tel: shortTel });
}
}
matches.sort((a, b) => a.start - b.start);
let name = raw;
let phone = null;
if (matches.length > 0) {
// Nom = ce qui précède le 1er numéro
name = raw.substring(0, matches[0].start).trim();
// Tels formatés, joints par " / "
const tels = matches.map(x => formatPhone(x.tel)).filter(Boolean);
phone = tels.length > 0 ? tels.join(" / ") : null;
}
name = cleanContactName(name);
return { name, phone };
}
/**
* Nettoie le nom du contact :
* - retire tout ce qui est dans des parenthèses (...)
* - retire les éventuels "Nom utilisateur :" ou libellés
* - retire les virgules en trop en fin
* - v4.1.8 : tronque les commentaires parasites après le nom
* (ex: "Dupont, Jean S'annoncer à la réception" → "Dupont, Jean")
* - Conserve juste "Nom, Prénom" (ou "Nom Prénom" si pas de virgule)
*/
function cleanContactName(raw) {
if (!raw) return null;
let s = String(raw);
// Retirer parenthèses COMPLÈTES et leur contenu : (RH), (support)...
s = s.replace(/\s*\([^)]*\)\s*/g, " ");
// Retirer parenthèses non fermées en fin : "Bento, Joao (" → "Bento, Joao"
s = s.replace(/\s*\([^)]*$/g, " ");
// Retirer parenthèses non ouvertes en début : ")Bento" → "Bento"
s = s.replace(/^[^(]*\)\s*/g, "");
// Retirer tout caractère parenthèse isolé restant
s = s.replace(/[()]/g, " ");
// Retirer labels type "Nom utilisateur :", "Utilisateur :", "Bénéficiaire :"
s = s.replace(/\b(Nom utilisateur|Utilisateur|B[ée]n[ée]ficiaire)\s*:\s*[^\n,]*/gi, "");
// Espaces multiples → un seul
s = s.replace(/\s{2,}/g, " ").trim();
// Ponctuation en bord
s = s.replace(/^[\s,;:.\-]+|[\s,;:.\-]+$/g, "").trim();
if (!s) return null;
// v4.1.8 : tronquer les commentaires parasites qui suivent le nom.
// On considère qu'un nom de personne ne dépasse pas 4 mots (Nom, Prénom,
// et éventuellement particule "Da Silva" ou second prénom).
// Si après les 4 premiers mots on a encore des mots, ce sont des parasites
// (ex: "Barbosa Oliveira, Bruno S'annoncer à la réception" → on coupe
// après "Bruno"). Le signal de fin de nom : un mot commençant par une
// minuscule (les noms/prénoms commencent par une majuscule).
// On garde quand même le 1er mot (parfois un mot comme "de", "von",
// "van" est lowercase).
const words = s.split(/\s+/);
const keep = [];
for (let i = 0; i < words.length; i++) {
const w = words[i];
if (i === 0) { keep.push(w); continue; }
// Particules courantes : de / da / du / van / von / le / la
if (/^(de|da|du|van|von|le|la|del|di|der)$/i.test(w)) { keep.push(w); continue; }
// Si on a déjà au moins 2 mots et que ce mot commence par une minuscule,
// c'est un commentaire qui commence → on arrête.
if (keep.length >= 2 && /^[a-zéèêàâîôûç]/.test(w)) break;
// Limite dure : 4 mots maximum (Barbosa Oliveira, Bruno Dupont OK)
if (keep.length >= 4) break;
keep.push(w);
}
s = keep.join(" ");
// Ponctuation de nouveau en fin au cas où
s = s.replace(/[\s,;:.\-]+$/, "").trim();
return s || null;
}
/**
* Split un lieu du type "Lausanne/Rue Caroline 9 bis" en
* { ville: "Lausanne", adresse: "Rue Caroline 9 bis" }
* Si format inconnu, retourne { ville: null, adresse: raw }.
*/
function splitLieu(raw) {
if (!raw) return { ville: null, adresse: null };
let s = String(raw).trim();
// Retirer un / final (avec ou sans espaces)
s = s.replace(/\s*\/\s*$/, "").trim();
if (!s) return { ville: null, adresse: null };
const idx = s.indexOf("/");
let ville, adresse;
if (idx < 0) {
ville = null;
adresse = s;
} else {
ville = s.substring(0, idx).trim();
adresse = s.substring(idx + 1).trim();
}
// Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue,
// Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd)
if (adresse) {
adresse = adresse.replace(
/\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi,
(match) => {
// Conserver la casse existante si déjà majuscule, sinon capitaliser
if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match;
return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase();
}
);
}
return { ville: ville || null, adresse: adresse || null };
}
/**
* Extrait la "signature planificateur" de la description d'action.
* Formats acceptés : "ECM 16.04", "JKF 17.04", "AWR 13/04/26", "ECM 16.04.2026".
* Parcourt d'abord les lignes depuis la fin (si la signature est sur sa ligne),
* sinon cherche à la fin de la description entière.
* Retourne null si rien trouvé.
*/
/**
* Normalise une date trouvée dans une signature :
* - "27/03" → "27.03"
* - "27.03" → "27.03"
* - "10/04/26" → "10.04" (on retire l'année)
* - "13/04/2026" → "13.04"
*/
function normalizeSignatureDate(date) {
if (!date) return "";
// Prendre les 2 premiers blocs de chiffres (JJ et MM) et les joindre avec "."
const parts = String(date).split(/[./]/);
if (parts.length < 2) return date;
const dd = parts[0].padStart(2, "0");
const mm = parts[1].padStart(2, "0");
return `${dd}.${mm}`;
}
function extractPlanifSignature(actionText) {
if (!actionText) return null;
// Formater le texte d'abord pour avoir des lignes séparées
const text = formatActionTextMultiline(String(actionText)).trim();
// 1. Dernière ligne non vide : regarder si c'est une signature (avec ou sans date)
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
if (lines.length > 0) {
const last = lines[lines.length - 1];
// 1a. Lettres (majuscules OU minuscules) + date
// Ex: "FRD 07/04", "csh 27.03", "AWR 13/04/26", "JKF 17.04"
const mFull = last.match(/^([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)$/);
if (mFull) {
return `${mFull[1].toUpperCase()} ${normalizeSignatureDate(mFull[2])}`;
}
// 1b. Juste les lettres seules (JKF, NDV) sur leur propre ligne
const mSolo = last.match(/^([A-Za-z]{2,4})$/);
if (mSolo) return mSolo[1].toUpperCase();
}
// 2. Sinon chercher la dernière signature "lettres + date" collée en fin
let lastMatch = null;
let m;
const rxGlobal = /([A-Za-z]{2,4})\s+(\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)/g;
while ((m = rxGlobal.exec(text)) !== null) {
lastMatch = { sigs: m[1], date: m[2], pos: m.index };
}
if (lastMatch && lastMatch.pos >= text.length - 100) {
return `${lastMatch.sigs.toUpperCase()} ${normalizeSignatureDate(lastMatch.date)}`;
}
return null;
}
// 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;
try {
await navigator.clipboard.writeText(ref);
btn.classList.add("copied");
btn.textContent = "✓";
setTimeout(() => { btn.classList.remove("copied"); btn.textContent = "📋"; }, 1200);
} catch {
alert("Référence : " + ref);
}
}
// ─── Rendu incrémental (v3.1) ───────────────────────────────────────────────
// Met à jour UNE ligne d'intervention dans le DOM (après qu'un fetch fiche
// ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par
// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans
// attendre que tous les workers aient fini ni re-rendre toute la vue.
//
// Doit rester en phase avec la structure DOM construite par
// buildInterventionRow (classes iv-ref-header, iv-status-check,
// intervention-copy, intervention-dot, timeline-slot...).
const ALL_COLOR_CLASSES = [
"color-livraison", "color-installation", "color-recup",
"color-remplacement", "color-incident", "color-rollout",
"color-reservation", "color-autre"
];
/**
* (Re)génère les blocs Lieu et Contact(s) dans le conteneur .iv-right.
* Supprime d'abord les anciens blocs (.iv-lieu-block + .iv-contact-line),
* puis insère les nouveaux AVANT le bloc .iv-bottom-line (si présent) pour
* conserver l'ordre d'affichage. Utilisé à la création ET lors de la
* mise à jour après fetch de la fiche.
*/
function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw) {
// Supprime les anciens blocs lieu/contact
rightCol.querySelectorAll(".iv-lieu-block, .iv-contact-line").forEach(el => el.remove());
const contacts = extractContacts(contactRaw);
const { ville, adresse } = splitLieu(lieuRaw);
// Point d'insertion : avant .iv-bottom-line (catégorie + signature), sinon à la fin
const anchor = rightCol.querySelector(".iv-bottom-line");
const insert = (el) => {
if (anchor) rightCol.insertBefore(el, anchor);
else rightCol.appendChild(el);
};
// ── Lieu : ville (MAJUSCULES GRAS) puis adresse (italique noir) ──────────
if (ville || adresse) {
const lieuBlock = document.createElement("div");
lieuBlock.className = "iv-lieu-block";
if (ville) {
const villeEl = document.createElement("div");
villeEl.className = "iv-lieu-ville";
villeEl.textContent = ville.toUpperCase();
lieuBlock.appendChild(villeEl);
}
if (adresse) {
const addrEl = document.createElement("div");
addrEl.className = "iv-lieu-adresse";
addrEl.textContent = adresse;
lieuBlock.appendChild(addrEl);
}
insert(lieuBlock);
}
// ── Contact(s) + téléphone — un par ligne si plusieurs ──────────────────
for (const c of contacts) {
if (!c.name && !c.phone) continue;
const contactEl = document.createElement("div");
contactEl.className = "iv-contact-line";
if (c.name) {
const nameSpan = document.createElement("span");
nameSpan.className = "iv-contact";
nameSpan.textContent = c.name;
contactEl.appendChild(nameSpan);
}
if (c.phone) {
if (c.name) {
const sep = document.createElement("span");
sep.className = "iv-sep";
sep.textContent = " | ";
contactEl.appendChild(sep);
}
const phoneSpan = document.createElement("span");
phoneSpan.className = "iv-phone";
phoneSpan.textContent = c.phone;
contactEl.appendChild(phoneSpan);
}
insert(contactEl);
}
}
function updateInterventionRow(iv) {
// Réservations : pas concerné (pas de fetch fiche pour elles)
if (iv.type === "AL-Reservation") return;
const row = document.querySelector(
`.intervention-v2[data-action-id="${iv.actionId}"]`
);
if (!row) return;
// Classes de statut sur la ligne
const sc = getStatusClass(iv);
row.classList.remove("status-closed", "status-resolved");
if (sc) row.classList.add(sc);
// Classe de couleur sur la ligne (la pastille hérite via CSS)
const colorKey = deriveColorKey(iv);
row.classList.remove(...ALL_COLOR_CLASSES);
row.classList.add("color-" + colorKey);
// Ref (le titre gros en haut de la ligne)
const refEl = row.querySelector(".iv-ref-header");
if (refEl) {
if (iv.ref) {
refEl.textContent = iv.ref;
refEl.classList.remove("no-ref");
} else {
refEl.textContent = "—";
refEl.classList.add("no-ref");
}
}
// Check ✓ : ajouter/retirer selon statut
let checkEl = row.querySelector(".iv-status-check");
if (sc && !checkEl) {
checkEl = document.createElement("div");
checkEl.className = "iv-status-check";
checkEl.textContent = "✓";
// Insérer après la ref (avant le bouton copier s'il existe)
const copy = row.querySelector(".intervention-copy");
if (copy) row.insertBefore(checkEl, copy);
else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling);
else row.appendChild(checkEl);
} else if (!sc && checkEl) {
checkEl.remove();
}
// Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas
let copyBtn = row.querySelector(".intervention-copy");
if (iv.ref && !copyBtn) {
copyBtn = document.createElement("button");
copyBtn.className = "intervention-copy";
copyBtn.type = "button";
copyBtn.title = "Copier la référence";
copyBtn.innerHTML = "📋";
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
copyRef(iv.ref, copyBtn);
});
row.appendChild(copyBtn);
}
// Catégorie affichée en bas (dépend de la ref pour Incident, etc.)
const catEl = row.querySelector(".iv-category");
if (catEl) catEl.textContent = deriveShortTitle(iv);
// v4.1.8 : signature planificateur (XXX JJ.MM). Si le texte fiche (complet)
// est arrivé, il peut maintenant fournir une signature que le xhr2 tronqué
// n'avait pas. On met à jour le span .iv-signature en conséquence.
const bottomEl = row.querySelector(".iv-bottom-line");
if (bottomEl) {
let sigEl = bottomEl.querySelector(".iv-signature");
const sig = extractPlanifSignature(iv.ficheActionText || iv.bulleDescription);
if (sig) {
if (!sigEl) {
sigEl = document.createElement("span");
sigEl.className = "iv-signature";
bottomEl.appendChild(sigEl);
}
sigEl.textContent = sig;
} else if (sigEl) {
sigEl.remove();
}
}
// 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 || {};
const contactRaw = info.contact || iv.bulleContact || null;
const lieuRaw = info.lieu || iv.bulleLieu || null;
renderLieuContactBlocks(rightCol, lieuRaw, contactRaw);
}
// Segment timeline correspondant : même couleur + même classe statut
const card = row.closest(".card");
if (card && row.dataset.ivIdx !== undefined) {
const slot = card.querySelector(
`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`
);
if (slot) {
slot.classList.remove("status-closed", "status-resolved", ...ALL_COLOR_CLASSES);
slot.classList.add("color-" + colorKey);
if (sc) slot.classList.add(sc);
// Maj du dataset pour le popover (titre + ref)
slot.dataset.title = deriveShortTitle(iv);
if (iv.ref) slot.dataset.ref = iv.ref;
}
}
}
// ============================================================================
// Tooltip
// ============================================================================
const tooltipEl = () => document.getElementById("tooltip");
// v4.1.10 : état persistant de la bulle
// - pinned : une fois épinglée (double Ctrl), la bulle reste à sa position,
// ne suit plus la souris, et ne se ferme ni au mouseleave ni au
// mouseleave suivant. On peut sélectionner le texte dedans.
// Clic hors bulle (ailleurs que sur une autre intervention) ou
// nouveau double-Ctrl → désépingle.
// - hoveredInBulle : si la souris entre DANS la bulle elle-même, la bulle
// reste visible même si elle n'est pas épinglée. Elle ne
// disparaît que quand la souris sort à la fois de la carte ET
// de la bulle.
let bulleState = {
pinned: false,
hoveredInBulle: false,
hoveredInRow: false,
hideTimer: null
};
function showTooltip(e, iv, rowEl) {
const el = tooltipEl();
el.innerHTML = buildTooltipHTML(iv);
el.classList.remove("hidden", "pinned");
el.classList.add("visible");
// Reset pin en changeant d'iv
if (bulleState.pinned && state.currentTooltipIv !== iv) {
bulleState.pinned = false;
}
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
bulleState.hoveredInRow = true;
// v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la
// carte (row) et pas du curseur. Elle ne bouge plus pendant le survol.
positionTooltipAnchored(rowEl || (e && e.currentTarget));
// 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(opts = {}) {
// Si la bulle est épinglée, on ignore (sauf force: true = unpin explicite)
if (bulleState.pinned && !opts.force) return;
bulleState.hoveredInRow = false;
// Petit délai : laisse le temps à la souris d'ENTRER dans la bulle elle-même
// (si l'user veut sélectionner du texte). On annule la fermeture si
// hoveredInBulle passe à true entre-temps.
if (bulleState.hideTimer) clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = setTimeout(() => {
if (bulleState.hoveredInBulle || bulleState.hoveredInRow) return;
if (bulleState.pinned) return;
const el = tooltipEl();
el.classList.remove("visible", "pinned");
el.classList.add("hidden");
state.currentTooltipIv = null;
}, 120);
}
function moveTooltip(e) {
// v4.1.12 : la bulle est FIXE (positionnée une fois au mouseenter). Cette
// fonction est conservée pour compat mais ne fait plus rien.
}
// v4.1.12 : positionnement fixe de la bulle, ancrée par rapport à la ligne
// (rowEl). Par défaut à droite de la ligne, avec fallback à gauche si pas
// assez de place, et ajustement vertical pour rester dans la fenêtre.
function positionTooltipAnchored(rowEl) {
const el = tooltipEl();
if (!rowEl || !el) return;
const pad = 14;
const rowRect = rowEl.getBoundingClientRect();
// Dimensions de la bulle : rendre visible puis mesurer
const tipRect = el.getBoundingClientRect();
// Position X : à droite de la ligne par défaut
let x = rowRect.right + pad;
if (x + tipRect.width > window.innerWidth - 8) {
// Pas assez de place à droite → à gauche
x = rowRect.left - tipRect.width - pad;
}
if (x < 4) x = 4;
// Position Y : aligné en haut de la ligne
let y = rowRect.top;
if (y + tipRect.height > window.innerHeight - 8) {
y = window.innerHeight - tipRect.height - 8;
}
if (y < 4) y = 4;
el.style.left = x + "px";
el.style.top = y + "px";
}
// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned"
// qui change le curseur (text) et autorise la sélection.
function pinTooltip() {
if (!state.currentTooltipIv) return;
bulleState.pinned = true;
const el = tooltipEl();
el.classList.add("pinned");
}
// v4.1.14 : recharger UNIQUEMENT cette intervention. Reset les flags de
// fetch pour forcer la récupération xhr2 + fiche + timeline, puis appeler
// fetchAndUpdateIntervention qui met à jour la carte ET (si la bulle est
// toujours ouverte sur cette iv) son contenu.
// Pendant ce temps, seul le bouton ↻ de la bulle tourne — pas les boutons
// Actualiser / Tout recharger de la topbar.
let singleReloadCounter = 0;
async function reloadSingleIntervention(iv, btnEl) {
if (!iv || iv.type === "AL-Reservation") return;
// Empêcher double-clic en cours
if (iv._reloading) return;
iv._reloading = true;
// Reset flags pour forcer un refetch complet
iv.xhr2Fetched = false;
iv.ficheFetched = false;
iv.ficheActionText = null;
iv.ficheFetchError = null;
// Marquer le bouton ↻ comme en cours
if (btnEl) btnEl.classList.add("spinning");
singleReloadCounter++;
try {
// Utiliser le token courant pour que l'abort au changement de date
// stoppe aussi ce reload
await fetchAndUpdateIntervention(iv, currentRefreshToken);
// Si la bulle est toujours ouverte sur cette iv, régénérer son HTML
const tip = tooltipEl();
if (tip.classList.contains("visible") && state.currentTooltipIv === iv) {
tip.innerHTML = buildTooltipHTML(iv);
}
// Sauvegarder le cache pour cette date
const cached = await readCache(state.currentDate);
if (cached && cached.techs) {
// Remettre l'iv à jour dans le cache
for (const tech of cached.techs) {
for (let i = 0; i < (tech.interventions || []).length; i++) {
if (tech.interventions[i].actionId === iv.actionId) {
tech.interventions[i] = iv;
}
}
}
await writeCache(state.currentDate, { techs: cached.techs });
}
} catch (err) {
console.warn("[reloadSingle] erreur iv", iv.actionId, err);
} finally {
iv._reloading = false;
singleReloadCounter = Math.max(0, singleReloadCounter - 1);
if (btnEl) btnEl.classList.remove("spinning");
}
}
function unpinTooltip() {
bulleState.pinned = false;
const el = tooltipEl();
el.classList.remove("pinned");
// v4.1.13 : test immédiat si la souris est toujours dans la bulle ou sur
// la ligne. Si ni l'un ni l'autre, on ferme tout de suite (sans timer).
if (!bulleState.hoveredInBulle && !bulleState.hoveredInRow) {
el.classList.remove("visible");
el.classList.add("hidden");
state.currentTooltipIv = null;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
}
// Sinon : la bulle reste visible, et c'est le mouseleave qui la fermera
// normalement quand la souris sortira.
}
// v4.1.10 : interactions bulle (double-Ctrl pour pin/unpin, hover dans la
// bulle pour persistance, clic hors pour unpin).
function bindTooltipInteractions() {
const el = tooltipEl();
if (!el) return;
// Hover sur la bulle elle-même : empêche la fermeture
el.addEventListener("mouseenter", () => {
bulleState.hoveredInBulle = true;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
});
el.addEventListener("mouseleave", () => {
bulleState.hoveredInBulle = false;
if (!bulleState.hoveredInRow && !bulleState.pinned) {
hideTooltip();
}
});
// Double-Ctrl : pin/unpin
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
let lastCtrlTs = 0;
document.addEventListener("keydown", (e) => {
if (e.key !== "Control") return;
// Ignorer si la touche est répétée (hold)
if (e.repeat) return;
const now = performance.now();
if (now - lastCtrlTs < 400) {
// Double-Ctrl détecté
lastCtrlTs = 0;
if (bulleState.pinned) {
unpinTooltip();
} else if (state.currentTooltipIv) {
pinTooltip();
}
} else {
lastCtrlTs = now;
}
});
// v4.1.13 : clic sur le bouton 📌 ou ↻ (bouton d'action de la bulle)
el.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
e.stopPropagation();
e.preventDefault();
const action = btn.dataset.action;
if (action === "pin") {
if (bulleState.pinned) {
unpinTooltip();
} else if (state.currentTooltipIv) {
pinTooltip();
}
} else if (action === "reload") {
// v4.1.14 : recharger uniquement l'intervention actuellement affichée
if (state.currentTooltipIv) {
reloadSingleIntervention(state.currentTooltipIv, btn);
}
}
});
// Clic hors bulle : unpin si épinglé.
// Attention : ne pas déclencher sur clic DANS la bulle (elle contient du
// texte sélectionnable), ni sur clic sur une intervention (qui ouvre la
// fiche — le user n'attend pas que la bulle reste épinglée dans ce cas
// mais le comportement "ouvrir la fiche" reste prioritaire).
document.addEventListener("mousedown", (e) => {
if (!bulleState.pinned) return;
// Clic dans la bulle → on laisse (sélection de texte)
if (el.contains(e.target)) return;
// Dans tous les autres cas (y compris clic sur une autre intervention),
// on désépingle. Si c'était un clic sur intervention, le handler
// d'ouverture de la fiche s'exécutera ensuite normalement.
unpinTooltip();
});
}
function buildTooltipHTML(iv) {
const i = iv.infobulle || {};
const rows = [];
// Cas spécial : réservation (créneau bloqué par un coordinateur)
if (iv.type === "AL-Reservation") {
rows.push(`<dt>Type</dt><dd><span class="status-pill other" style="background:var(--c-reservation);color:#fff">Réservation</span></dd>`);
if (iv.startTime && iv.endTime) {
rows.push(row("Horaire", `${iv.startTime}${iv.endTime}`));
}
if (iv.reservationLabel) rows.push(row("Sujet", iv.reservationLabel));
if (iv.reservationCreator) rows.push(row("Par", iv.reservationCreator));
return `<dl>${rows.join("")}</dl>`;
}
// Statut en premier (si connu)
if (iv.status) {
let cls = "other";
if (isClosedStatus(iv.status)) cls = "closed";
else if (isResolvedStatus(iv.status)) cls = "resolved";
else if (/en cours|ex[ée]cution/i.test(iv.status)) cls = "ongoing";
rows.push(`<dt>Statut</dt><dd><span class="status-pill ${cls}">${escapeHtml(iv.status)}</span></dd>`);
}
if (iv.startTime && iv.endTime) {
rows.push(row("Horaire", `${iv.startTime}${iv.endTime}`));
}
// ─── Texte d'action : fiche (complet) en priorité, sinon xhr2 (tronqué) ──
// v4.1.8 : un seul bloc "Action" qui s'enrichit automatiquement. Au début,
// le xhr2 tronqué s'affiche ; dès que le fetch timeline est revenu,
// iv.ficheActionText remplace le texte dans le même bloc.
const actionText = iv.ficheActionText ||
(iv.bulleDescription ? formatActionTextMultiline(iv.bulleDescription) : null);
if (actionText) {
const htmlAction = escapeHtml(actionText).replace(/\n/g, "<br>");
rows.push(`<dt>Action</dt><dd class="description">${htmlAction}</dd>`);
} else {
// Si pas de description (même pas de xhr2), afficher les infos structurées qu'on a
const hasAction = !!(i.date || i.heure || i.lieu || i.contact || i.service ||
i.probleme || i.aFaire || i.materiel);
if (i.date || i.heure) {
const dh = [i.date, i.heure].filter(Boolean).join(" · ");
if (dh) rows.push(row("Quand", dh));
}
const contact = i.contact || iv.bulleContact;
if (contact) rows.push(row("Contact", contact));
const lieu = i.lieu || iv.bulleLieu;
if (lieu) rows.push(row("Lieu", lieu));
if (i.service) rows.push(row("Service", i.service));
if (i.probleme) rows.push(row("Problème", i.probleme));
if (i.aFaire) rows.push(row("À faire", i.aFaire));
if (!hasAction && !contact && !lieu) {
if (iv.ficheFetched) {
rows.push(`<dt>Info</dt><dd style="color:var(--text-faint)">Aucun détail pour cette intervention.</dd>`);
} else {
rows.push(`<dt>Info</dt><dd style="color:var(--text-faint)">Chargement des détails…</dd>`);
}
}
}
// Deadline (si connue et différente)
if (iv.deadline && !i.date) rows.push(row("Deadline", iv.deadline));
if (iv.ref) {
rows.push(`<hr>`);
rows.push(row("Référence", iv.ref));
}
if (iv.ghost) {
rows.push(`<hr>`);
rows.push(`<dt>⚠</dt><dd>Intervention disparue d'EasyVista (clôturée, déplacée ou annulée)</dd>`);
} else if (iv.formLink) {
rows.push(`<hr>`);
rows.push(`<dt></dt><dd style="color:var(--text-faint);font-size:11px">Cliquer pour ouvrir la fiche</dd>`);
}
if (rows.length === 0) {
return `<div class="tooltip-actions">
<div class="tooltip-actionbtn" data-action="reload" title="Recharger uniquement cette intervention">
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="tooltip-actionbtn tooltip-pinbtn" data-action="pin" title="Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer.">📌</div>
</div><dl><dt>Info</dt><dd>Aucun détail disponible</dd></dl>`;
}
// v4.1.13/14 : boutons d'action en haut à droite (recharger + épingler)
return `<div class="tooltip-actions">
<div class="tooltip-actionbtn" data-action="reload" title="Recharger uniquement cette intervention">
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="tooltip-actionbtn tooltip-pinbtn" data-action="pin" title="Épingler la bulle (ou double-Ctrl). Cliquer à nouveau pour libérer.">📌</div>
</div><dl>${rows.join("")}</dl>`;
}
/**
* Met en forme un texte d'action EasyVista en ajoutant des retours à la ligne
* avant chaque étiquette connue ("Date :", "Lieu :", "Contact :", etc.).
* Transforme :
* "Date : 20.04 Heure : MatinLieu : Ville1/Rue1 1 bisContact : Nom..."
* En :
* "Date : 20.04 Heure : Matin
* Lieu : Ville1/Rue1 1 bis
* Contact : Nom..."
*/
function formatActionTextMultiline(text) {
if (!text) return "";
const newlineLabels = [
"Lieu", "Contact",
"Service", "Étage", "Bureau",
"Nom utilisateur",
"Problème", "A faire", "À faire",
"Matériel", "Materiel",
"Bénéficiaire", "Beneficiaire"
];
let result = String(text);
for (const label of newlineLabels) {
const rx = new RegExp(`([^\\n])(${escapeRegex(label)}\\s*:\\s*)`, "g");
result = result.replace(rx, "$1\n$2");
}
// Isoler la signature planificateur finale ("ECM 16.04", "csh 27.03", etc.)
// qui se trouve typiquement en fin sans préfixe de label.
// On utilise un look-behind pour ne PAS manger la lettre précédente
// (et donc ne pas couper le "F" de "FRD 07/04").
result = result.replace(/(?<=[^\n])(\s*)([A-Za-z]{2,4}\s+\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?)\s*$/, "\n$2");
// Nettoyer
result = result.replace(/\n{2,}/g, "\n").trim();
return result;
}
function escapeRegex(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function row(label, value) {
return `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function highlightIntervention(cardEl, ivIdx, on) {
const row = cardEl.querySelector(`.intervention[data-iv-idx="${ivIdx}"]`);
const slot = cardEl.querySelector(`.timeline-slot[data-iv-idx="${ivIdx}"]`);
if (row) row.classList.toggle("highlight", on);
if (slot) slot.classList.toggle("highlight", on);
}
// ============================================================================
// Helpers temps
// ============================================================================
function timeToMinutes(hhmm) {
if (!hhmm) return null;
const m = hhmm.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
}
function minutesToTime(mins) {
const h = Math.floor(mins / 60);
const m = mins % 60;
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
}
// ============================================================================
// Écrans d'erreur
// ============================================================================
function showLoading() {
document.getElementById("loading").classList.remove("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
document.getElementById("cards").innerHTML = "";
}
function showError(msg) {
document.getElementById("loading").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("cards").innerHTML = "";
const box = document.getElementById("error-box");
box.textContent = msg;
box.classList.remove("hidden");
}
function showSessionNeeded() {
document.getElementById("loading").classList.add("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("stats").classList.add("hidden");
document.getElementById("cards").innerHTML = "";
document.getElementById("session-needed").classList.remove("hidden");
}
function hideSessionNeeded() {
document.getElementById("session-needed").classList.add("hidden");
}
// v4.1.12 : bannière non bloquante "session expirée". Affichée quand le
// fetch détecte une session morte EN COURS DE ROUTE (pas au démarrage).
// L'utilisateur voit toujours les données déjà chargées, mais est prévenu
// que les mises à jour sont arrêtées.
function showSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) b.classList.remove("hidden");
}
function hideSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) b.classList.add("hidden");
}