/** * Planification — Extension navigateur EasyVista (Canton de Vaud / DGNSI) * * Vue claire et rapide du planning des techniciens : interventions, * réservations, absences, statut pompier, etc. Regroupé par tech avec * timelines visuelles et popups détaillés. * * Copyright (c) 2026 Quentin Rouiller * Licensed under the MIT License — see LICENSE file in the project root. * * @author Quentin Rouiller * @repository https://gitea.netaplaid.ch/FroSteel/Planification * * Développé par Quentin Rouiller (QRO) — DGNSI, Canton de Vaud. Licence MIT. */ // ============================================================================ // viewer.js — vue claire du planning techniciens // ============================================================================ // v2026.5.46 — bug « navigation rapide entre dates » : rendus périmés // refusés (verrous storeSyncFolder + pipeline 3bis + filet renderFromData). // v2026.5.46 — bug « tech retiré encore affiché » : la projection filtre // l'annuaire du jour par l'équipe configurée actuelle (TEAM). // v2026.5.46 — vue horizontale « échelle exacte » : lignes d'heures et // ligne « maintenant » bornées avant le bloc des absents journée entière ; // isTechAbsent évalue la plage configurée (DAY_START/DAY_END). // v2026.5.46 — bug « navigation rapide » (suite) : jeton d'annulation pris // dès l'entrée de loadForDate + gardes après les await pré-pipeline. // v2026.5.46 — même famille : garde du chemin de boot sans session // (refreshSessionAndLoad) contre une navigation pendant la lecture du store. // v2026.5.46 — retours de revue : propriété du pipeline (_loadPipelineToken, // « Arrêter » ciblé, 3bis avorté sans effet), re-rendu des réglages permis, // double plage pour « absent toute la journée », _dayRange() unique, // segments calculés une fois, filtre TEAM en hasOwnProperty. // v2026.5.46 — retours de revue (2e passe) : cycle de vie du bouton // « Arrêter » lié à la propriété du pipeline (plus de bouton fantôme, clic // sans pipeline sans effet), zones absentes adjacentes fusionnées (plus de // tronçons dans les interstices), dayLen dans _dayRange(). // v2026.5.46 — bug « réservations multiples le même jour » : le créneau // (heures) entre dans ivKey/_ivAbsenceSig des réservations/absences — deux // créneaux d'un même tech/jour ne fusionnent plus (un seul s'affichait quand // EV partageait le request_id). + modal Modifier réservation : le N° // d'intervention auto-remplit ville/rue/catégorie (helper _wireResNumberLookup // partagé avec la création). // v2026.5.46 — passe /simplify : _fusionnerPlages() remplace 4 copies de // la fusion d'intervalles, _jourAffiche() nomme le prédicat « jour affiché » // (3 sites), tolérance des interstices dérivée du gap CSS réel, dayE // destructuré partout où dayStart+dayLen le re-dérivait. // v2026.5.46 — bug « boucle de conflit du cache dossier » : le CAS de // storeSyncFolder utilisait le savedAt renvoyé par « stat » (que l'hôte sert // depuis _index.json, parfois périmé) comme référence d'écriture, alors que // l'hôte compare le CAS au savedAt RELU dans le fichier. Index en retard = // conflit permanent, écriture jamais aboutie, index jamais rafraîchi (deadlock). // On prend désormais la référence CAS dans le fichier réellement lu et fusionné // (folder.savedAt), pas dans stat : l'écriture aboutit et guérit l'index. // v2026.5.46 — deux correctifs d'affichage (vue horizontale) : // 1. fond des cartes d'absence : en échelle exacte la carte est haute et le // dégradé « couleur → neutre à 40 % » laissait le bas vide → en vue // horizontale la couleur douce remplit toute la carte (CSS). // 2. plus de DOUBLON absence récurrente + vraie absence : si le tech a une // vraie absence EV ce jour (congé/maladie), l'absence récurrente configurée // ne s'affiche plus (fond/badge/note suivent la vraie absence). // v2026.5.47 — passage au numéro de version 2026.5.47 (la 2026.5.46 est publiée ; // le travail non poussé se fait désormais sous 2026.5.47). Pas de pull. // v2026.5.47 — vue horizontale : la barre de progression « Actualisation // X/Y » ne recouvre plus le haut de la sidebar (badge/titre/zone de reconnexion) ; // elle se cale au-dessus de la zone de CONTENU, à droite de la sidebar // (--sidebar-w mesuré en JS, robuste aux breakpoints/zoom). // v2026.5.47b — la barre passe SOUS la banderole de session/reconnexion // quand il y en a une (top = --stack-top, qui intègre la hauteur des banderoles), // au lieu de la recouvrir ; top:0 quand il n'y a pas de banderole. // v2026.5.47 — onglet Synchro : bouton « Tester la communication » qui fait // l'aller-retour extension → hôte (version) → dossier (stat réel) et affiche un // diagnostic lisible. Va de pair avec l'hôte natif v2026.1.8 (verrou dédié sur // _index.json contre les « lost update » multi-postes ; icône tray ré-affichée // au redémarrage d'explorer via TaskbarCreated). // v2026.5.46 — refonte complète de la couche données : store unifié par // jour (planv2_*), fusion unique vie/mort, verdict unique par intervention, // projection rendu centralisée, sync dossier v2. Cf. section « COUCHE // DONNÉES v2 » plus bas pour le modèle complet. // // v2026.5.46 — vue horizontale « échelle de temps exacte » (option // Apparence, exclusive avec « Afficher les pauses ») : cartes posées en // position réelle sur une bande continue, cf. _buildExactRow. // // v2026.5.46 — fiabilisation de la détection de session EasyVista // (faux « déconnecté ») : cookie overwrite ignoré + vérification serveur // avant bannière, expiration sur preuve uniquement, cookie sans onglet EV, // contre-vérification des statuts HTTP côté background. // // v2026.5.46 — mode exact : poignée de déplacement à gauche des cartes // (remplace le bloc d'heures), règle des heures alignée par MESURE runtime // (_alignExactRuler), vraie césure midi (style timeline-noon). // // v2026.5.46 — mode exact : cartes étroites jamais vides (ville + fin // d'adresse compactes), tolérance d'empilement 10 min (les enchaînements // pile ne s'empilent plus), ligne « maintenant » UNIQUE et continue. // // v2026.5.46 — mode exact : sous-lignes en FLUX réel (.iv-exact-lane), // bande à hauteur auto — un empilement n'est plus jamais rogné. // // v2026.5.46 — mode exact : position/ancres/hauteur des cartes // verrouillées (!important) — la règle « position: relative » des cartes // closed/resolved (mode flux, plus spécifique) cassait posé + taille. // // v2026.5.46 — la méta réservation (n° DS/ville/rue/catégorie, locale au // poste, EV ne la stocke pas) remonte enfin au dossier partagé et aux autres // postes : ivMerge la complète depuis l'autre copie (n'était jamais recopiée // → écrasée par l'iv EV re-fetchée) + l'édition « infos seules » déclenche // bien storeSave/push (avant : mutation mémoire sans persistance). // // v2026.5.46 — (1) ouverture de fiche des interventions faites/clôturées // réparée (formLink retiré au dossier → régénération via la réf) ; (2) mode // exact : cartes à fond neutre, seul le bord gauche est coloré et sert de // poignée (clic = modifier, glisser = déplacer). // // v2026.5.46 — mode exact : bord gauche à points colorés sur TOUTES les // cartes (drag bloqué pour faites/clôturées/réservations/absences), bulles // moins hautes (sous-ligne 60→48 px) ; croix native du champ recherche masquée. // // v2026.5.46 — mode exact : lignes d'heures verticales CONTINUES qui // traversent tout le tableau (calque global, _layoutExactGrid) ; règle des // heures réellement fixe en haut au scroll (top:0, la topbar est masquée). // // v2026.5.46 — mode exact : fines lignes aux demi-heures (h30) ; les // traits passent DERRIÈRE les bulles d'intervention (z-index sous les cartes). // // v2026.5.46 — mode exact : vide sous les bulles supprimé (bande centrée, // hauteur ↑), heures pleines vs demi-heures (pointillé) différenciées, aucune // ligne sur les techs absents, adresse abrégée tenue plus longtemps. // // v2026.5.46 — mode exact : bulles un chouïa plus hautes (texte qui // respire) et espace haut/bas resserré ; léger contour conservé. // // v2026.5.46 — mode exact : bulles encore agrandies (remplissent leur // zone) et ensemble resserré (marges de bande et entre sous-lignes réduites). // // v2026.5.46 — mode exact : bulles encore un peu plus grandes + n° // d'intervention détaché du bloc ville+adresse (qui restent groupés). // // v2026.5.46 — mode exact : bulles encore agrandies + plus d'espace sous // le n° ; bandelette pompier refixée en haut (le centrage la décentrait). // // v2026.5.46 — mode exact : bulles encore agrandies ; pompier — bandelette // figée en haut (taille inchangée) + bulles centrées dans l'espace restant. // // v2026.5.46 — contact : deux numéros de téléphone séparés par un espace // n'étaient plus collés à l'affichage (re-découpe du bloc capturé). // // v2026.5.46 — alerte doublons de réservation RETIRÉE à la // demande : le marquage ROUGE des conflits (detectOverlaps, pré-existant) // reste seul en place, comme avant. // // v2026.5.46 — vue par heure : les réservations affichent le lieu (meta) // sinon le créateur ; catégorie « Autre » en fuchsia sur toutes les vues. // // v2026.5.46 — « Réservation » en tête de bulle comme le n° d'intervention // (gabarit + espace dessous) ; « Autre » passe du fuchsia au vert lime. // // v2026.5.46 — « Autre » : lime pas retenu → rose framboise. // // v2026.5.46 — vue par heure : les journées totalement VIDES ont enfin // leurs trous (survol « Libre », clic droit, drop précis) — la bande est // construite même sans aucune intervention. // // v2026.5.46 — « Autre » en violet foncé ; la ligne du temps saute les // techs absents (helper _exactAbsentSegments partagé avec les lignes d'heures). // // v2026.5.46 — la ligne du temps n'a plus ni point ni trait sur la règle // des heures : elle démarre au haut des cartes. // // v2026.5.46 — la ligne du temps est REPOSÉE immédiatement après chaque // rendu/resize/bascule (avant : détruite par le rendu, elle ne revenait qu'au // tick d'horloge de 30 s → souvent absente). // // Idée de base : on récupère tout depuis le XML EasyVista (calendar_block) en // 1 seule requête. attr1/attr2/attr3 + textContent contiennent déjà ref, // contact, lieu, catégorie, formLink, deadline. Plus besoin de faire 74 // requêtes xhr2 au chargement comme la v3. Le texte complet de l'action // (Problème / À faire / Matériel) est lazy-load au hover, seulement si // l'user survole la ligne. // // Fetch des fiches : séquentiel (1 par 1) au lieu d'en paralléliser. Le // serveur EasyVista sérialise de toute façon, et ça rend l'abort instantané // si l'user change de date en cours. // Le cache est écrit toutes les 5 fiches (incrémental), pas juste à la fin. // Comme ça si l'user change de date au milieu, ce qu'on a déjà fetché est // pas perdu. // ============================================================================ // ============================================================================ // v2026.5.38 : Observabilité — logger unifié + handlers globaux // ============================================================================ // Toutes les erreurs runtime (uncaught + unhandled rejection) sont attrapées // par les listeners ci-dessous et loggées avec un préfix clair, un timestamp, // et la stack trace si dispo. L'idée : qd l'user lance F12 et copie la console, // on a tout pour reproduire et corriger. // // Format des logs : [PREFIX][LEVEL] message {context} // - LEVEL : INFO / WARN / ERROR // - PREFIX : module qui logue (load, currentUser, session, viewMode, ...) // - message : un truc lisible humain // - context : objet sérialisable avec les vars utiles au debug // ============================================================================ // Clef localStorage pour le mode debug — exposé dans le panel admin. // Quand activé : LOG.info() devient visible. Sinon : muet. // LOG.warn et LOG.error sont TOUJOURS visibles, peu importe le flag. const DEBUG_LOGS_KEY = "debug_logs"; const LOG = (() => { // On capture la version du manifest une fois, pour la mettre dans les logs // d'erreur (utile qd l'user nous envoie un screenshot console). let _version = "?"; try { if (chrome && chrome.runtime && chrome.runtime.getManifest) { _version = chrome.runtime.getManifest().version || "?"; } } catch (e) { // chrome.runtime peut ne pas être dispo dans certains contextes } // État debug — relu depuis localStorage. Peut être toggle à la volée // depuis le panel admin sans reload. let _debug = false; try { _debug = localStorage.getItem(DEBUG_LOGS_KEY) === "1"; } catch (e) { // localStorage peut être bloqué (mode privé) — on reste en mode normal } const _stamp = () => new Date().toISOString().substring(11, 23); // HH:MM:SS.mmm const _format = (level, prefix, msg, ctx) => { const head = `[${_stamp()}][v${_version}][${prefix}][${level}]`; if (ctx !== undefined) return [head, msg, ctx]; return [head, msg]; }; return { // info() : visible UNIQUEMENT si debug activé. Pour les étapes verbose. info: (prefix, msg, ctx) => { if (!_debug) return; console.log(..._format("INFO", prefix, msg, ctx)); }, // warn / error : toujours visibles warn: (prefix, msg, ctx) => console.warn (..._format("WARN", prefix, msg, ctx)), error:(prefix, msg, ctx) => console.error(..._format("ERROR", prefix, msg, ctx)), // Erreur "interne" — affiche stack + serialize l'objet erreur exception: (prefix, msg, err, extra) => { const ctx = { name: err && err.name, message: err && err.message, stack: err && err.stack, extra: extra }; console.error(..._format("ERROR", prefix, msg, ctx)); }, // Toggle à la volée depuis le panel admin setDebug: (on) => { _debug = !!on; try { localStorage.setItem(DEBUG_LOGS_KEY, _debug ? "1" : "0"); } catch (e) {} // Synchroniser avec le service worker (qui a son propre flag) try { chrome.runtime.sendMessage({ type: "setDebugLogs", on: _debug }, () => { // on ignore lastError volontairement : le SW peut être en sommeil if (chrome.runtime.lastError) { /* ok */ } }); } catch (e) {} console.log(..._format("INFO", "logger", `mode debug = ${_debug ? "ON" : "OFF"}`)); }, isDebug: () => _debug, version: () => _version }; })(); // Global error handler : attrape les exceptions qui passent à travers tous les // try/catch. L'user voit un toast, on logue tout en console. window.addEventListener("error", (event) => { // Bruit bénin de Chrome lors d'un redimensionnement rapide de fenêtre : // un callback de ResizeObserver déclenche un layout qui programme un nouveau // resize au tick suivant. Le navigateur gère la situation tout seul (les // notifications restantes seront livrées au tick suivant) — il n'y a rien // à corriger côté JS, mais Chrome signale quand même l'event en boucle. // On filtre pour éviter de spammer les logs et le toast utilisateur. const msg = (event.message || "") + ""; if (msg.includes("ResizeObserver loop")) return; LOG.exception("global", "uncaught error", event.error || new Error(event.message || "unknown"), { filename: event.filename, lineno: event.lineno, colno: event.colno }); try { if (typeof showToast === "function") { showToast("Erreur inattendue", "Voir la console (F12) pour le détail"); } } catch (e) { /* showToast peut ne pas être init au tout début */ } }); // Promesses rejetées sans .catch() — typiquement des async/await sans try window.addEventListener("unhandledrejection", (event) => { const reason = event.reason; LOG.exception("global", "unhandled promise rejection", reason instanceof Error ? reason : new Error(String(reason)), { promise: "unhandled" }); try { if (typeof showToast === "function") { showToast("Erreur asynchrone", "Voir la console (F12) pour le détail"); } } catch (e) { /* idem */ } }); LOG.info("boot", "viewer.js chargé", { version: LOG.version() }); LOG.warn("planification", `Planification v${LOG.version()} — Mode ${LOG.isDebug() ? "DEBUG" : "PROD"}.`); // ============================================================================ // Configuration // ============================================================================ // v2026.5.41 : plus aucune équipe / absence récurrente codée en dur. // L'utilisateur configure tout depuis Paramètres → Équipe : // - cfg.team = { id: name } — techniciens à afficher // - cfg.recurringAbsences = { id: [days] } — jours d'absence récurrente // (chrome.storage.local["admin_config"], persiste entre les mises à jour) // // TEAM et RECURRING_ABSENCES sont rechargées au boot depuis admin_config par // _initTeamFromConfig() (appelée tôt dans init()). Tant qu'elles ne sont pas // chargées, elles restent vides → aucun fetch tenté. let TEAM = {}; // v2026.5.46 — vue horizontale « échelle de temps exacte » (option // Apparence). Miroir de cfg.horizontalExactMode, lu au boot et au toggle. let HORIZONTAL_EXACT_MODE = false; let RECURRING_ABSENCES = {}; // idx commun dérivé des bornes "startMin-endMin", posé sur le // .gap-placeholder (rect carte-body / mini-cards) ET sur le // .timeline-hole correspondant. Pas de matching fragile — si les bornes // sont identiques, les 2 partagent le même attribut. highlightGap // toggle .gap-hover-active sur tous les [data-gap-idx="N"] dans la // card, exactement comme highlightIntervention pour [data-iv-idx="N"]. function highlightGap(cardEl, gapIdx, on) { if (!cardEl || gapIdx == null) return; cardEl.querySelectorAll(`[data-gap-idx="${gapIdx}"]`).forEach(el => { el.classList.toggle("gap-hover-active", on); }); } function _bindGapPlaceholder(phEl, cardEl) { if (!cardEl || !phEl) return; const startMin = Number(phEl.dataset.startMin); const endMin = Number(phEl.dataset.endMin); if (!Number.isFinite(startMin) || !Number.isFinite(endMin)) return; const idx = startMin + "-" + endMin; phEl.dataset.gapIdx = idx; phEl.addEventListener("mouseenter", () => highlightGap(cardEl, idx, true)); phEl.addEventListener("mouseleave", () => highlightGap(cardEl, idx, false)); } // Détection post-rendu d'une mini-card iv tronquée → masque les // gap-placeholder-mini de la MÊME ligne pour libérer la place. Le // flex CSS ne suffit pas car le navigateur ne sait pas que le // contenu de l'iv déborde son flex-basis (overflow:hidden masque le // débordement). On le détecte explicitement via scrollWidth > clientWidth. function _trimMiniGapsForFit() { document.querySelectorAll(".iv-mini-block-cards").forEach(blockCards => { // Reset : remettre tous les gaps non-full visibles d'abord blockCards.querySelectorAll(".gap-placeholder-mini:not(.gap-placeholder-full)").forEach(g => { g.style.removeProperty("display"); }); // Détection : au moins une iv tronque-t-elle son contenu ? let needsTrim = false; blockCards.querySelectorAll(".iv-mini-card").forEach(iv => { const txt = iv.querySelector(".iv-mini-card-text"); if (txt && txt.scrollWidth > txt.clientWidth + 1) needsTrim = true; const tCol = iv.querySelector(".iv-mini-time-vertical"); if (tCol && tCol.scrollWidth > tCol.clientWidth + 1) needsTrim = true; }); if (needsTrim) { blockCards.querySelectorAll(".gap-placeholder-mini:not(.gap-placeholder-full)").forEach(g => { g.style.display = "none"; }); } }); } // Re-check au resize de la fenêtre (la largeur dispo change → certaines // iv passent ou non en troncature). let _trimRO = null; function _attachMiniGapsResizeWatcher() { if (typeof ResizeObserver === "undefined") return; if (_trimRO) return; let scheduled = false; _trimRO = new ResizeObserver(() => { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; _trimMiniGapsForFit(); // v2026.5.46 — recale la règle des heures du mode exact (resize, // bandeaux qui apparaissent/disparaissent, zoom, sidebar). if (typeof _alignExactRuler === "function") _alignExactRuler(); if (typeof _layoutExactGrid === "function") _layoutExactGrid(); if (typeof updateNowLine === "function") updateNowLine(); }); }); _trimRO.observe(document.body); } async function _initTeamFromConfig() { try { const cfg = await loadAdminConfig(); TEAM = cfg.team || {}; RECURRING_ABSENCES = cfg.recurringAbsences || {}; // v2026.5.46 — mode « échelle de temps exacte » (vue horizontale). HORIZONTAL_EXACT_MODE = !!cfg.horizontalExactMode; document.documentElement.classList.toggle("horizontal-exact", HORIZONTAL_EXACT_MODE); // applique l'option "Afficher les pauses" au boot. // v2026.5.46 — exclusive avec le mode exact : si les deux traînent // dans la config, le mode exact gagne à l'affichage. document.documentElement.classList.toggle("show-iv-gaps", !!cfg.showInterventionGaps && !HORIZONTAL_EXACT_MODE); // recharger les statuts personnalisables depuis la config. if (Array.isArray(cfg.closedStatus) && cfg.closedStatus.length > 0) { CLOSED_STATUS = cfg.closedStatus.slice(); } if (Array.isArray(cfg.resolvedStatus) && cfg.resolvedStatus.length > 0) { RESOLVED_STATUS = cfg.resolvedStatus.slice(); } if (Array.isArray(cfg.cancelledStatus) && cfg.cancelledStatus.length > 0) { CANCELLED_STATUS = cfg.cancelledStatus.slice(); } KEEP_DIAG_GHOSTS = !!cfg.keepDisappearedGhosts; COORDINATOR_MODE = !!cfg.coordinatorMode; _impersonate = cfg.impersonate || null; } catch (e) { LOG.warn("boot", "_initTeamFromConfig err", e); } } // statuts EasyVista éditables depuis admin_config (Paramètres → EV). // Les valeurs par défaut servent de filet de sécurité au 1er install et // pour les versions où la config n'aurait pas encore ces clés. // Clôturé et Terminé par défaut (les 2 statuts qui signifient // "finalisé officiellement" dans EV). N'affecte QUE les nouveaux installs // (config vide) — si une config existe déjà avec d'autres valeurs, // loadAdminConfig la garde intacte (cf. fusion `...stored`). const DEFAULT_CLOSED_STATUS = ["Clôturé", "Terminé"]; const DEFAULT_RESOLVED_STATUS = []; const DEFAULT_CANCELLED_STATUS = ["Annulé", "Supprimé"]; let CLOSED_STATUS = [...DEFAULT_CLOSED_STATUS]; let RESOLVED_STATUS = [...DEFAULT_RESOLVED_STATUS]; let CANCELLED_STATUS = [...DEFAULT_CANCELLED_STATUS]; // v2026.5.45 — flag diagnostic, lu depuis admin_config au boot et mis à // jour quand on toggle la case "Garder les disparitions". Quand true : on // ne retire JAMAIS les ghosts (KEEP forcé), peu importe le verdict. let KEEP_DIAG_GHOSTS = false; // v2026.5.46 — mode coordinateur : affiche le badge "Mes actions" dans la // barre de stats (nb d'interventions où l'utilisateur a une action). Off par défaut. let COORDINATOR_MODE = false; // Mode coordinateur — "Mes actions" : fetch + parse de la page EV // General_MyActionsItem PAR L'EXTENSION (aucune fenêtre EV ouverte). // _myActionsCount : nombre (badge "A faire : N"). null = pas encore chargé. // _myActionsRows : lignes parsées [{numero}]. null = pas encore chargé. let _myActionsCount = null; let _myActionsRows = null; let _myActionsLoading = false; let _myActionsDiag = ""; // diagnostic du dernier fetch (affiché dans l'onglet) // v2026.5.46 — impersonation (RÉSERVÉE à Quentin Rouiller) : permet de tester // la vue coordinateur en "se faisant passer pour" une autre personne. Chargé // depuis admin_config (cfg.impersonate = { enabled, name, initials, memberId }). let _impersonate = null; // Vrai uniquement si l'utilisateur connecté EST Quentin Rouiller (garde-fou // pour n'afficher l'onglet Test qu'à lui). function _isQuentin() { const n = (state && state.currentUser && state.currentUser.name) || ""; return /rouiller/i.test(n) && /quentin/i.test(n); } // Identité effective utilisée pour retrouver "l'action liée à la personne" // (Partie A « Mes actions »). Si impersonation active ET QRO → identité simulée, // sinon l'utilisateur réel. function _effectiveIdentity() { const real = { name: (state && state.currentUser && state.currentUser.name) || "", initials: (state && state.currentUser && state.currentUser.initials) || "", memberId: (state && state.currentUser && state.currentUser.memberId) || "", impersonated: false }; if (_isQuentin() && _impersonate && _impersonate.enabled && (_impersonate.name || _impersonate.initials || _impersonate.memberId)) { return { name: _impersonate.name || "", initials: _impersonate.initials || "", memberId: _impersonate.memberId || "", impersonated: true }; } return real; } // Clés de stockage // v2026.5.46 — store unifié v2. L'ancien préfixe "planning_cache_" est // abandonné (format incompatible) : les vieilles clés sont purgées au boot // (cf. storePurgeLegacy) et l'enrichissement se reconstruit au fil de l'eau. const STORE_PREFIX = "planv2_"; // + YYYY-MM-DD → entrée jour schéma 2 const LEGACY_CACHE_PREFIX = "planning_cache_"; // ancien format (purgé au boot) // v2026.5.46 — meta des réservations créées depuis l'extension (n° DS, // ville, rue, catégorie). EasyVista ne stocke PAS ces infos : on les garde // localement et on les réapplique à CHAQUE parse, pour qu'elles survivent aux // refresh EV et au recyclage d'actionId des réservations. // Structure : { "||": { numero, ville, rue, categorie } } const RESERVATION_META_KEY = "reservation_meta"; let _resMetaStore = {}; function _resMetaKey(techId, isoDate, hhmm) { return `${isoDate}|${techId}|${(hhmm || "").slice(0, 5)}`; } async function loadResMetaStore() { try { const r = await chrome.storage.local.get(RESERVATION_META_KEY); _resMetaStore = r[RESERVATION_META_KEY] || {}; } catch (e) { _resMetaStore = {}; } } function _resMetaGet(techId, isoDate, hhmm) { return _resMetaStore[_resMetaKey(techId, isoDate, hhmm)] || null; } async function _resMetaSet(techIds, isoDate, hhmm, meta) { // La clé date doit être au format du XML calendar_block (JJ/MM/AAAA = iv.startDate), // pas en ISO, sinon le lookup au parse ne matche jamais. const dkey = (typeof isoToDDMMYYYY === "function") ? isoToDDMMYYYY(isoDate) : isoDate; for (const techId of (techIds || [])) { _resMetaStore[_resMetaKey(String(techId), dkey, hhmm)] = { numero: meta.numero || "", ville: meta.ville || "", rue: meta.rue || "", categorie: meta.categorie || "" }; } try { await chrome.storage.local.set({ [RESERVATION_META_KEY]: _resMetaStore }); } catch (e) { LOG.warn("reservation", "écriture meta persistante échouée", { err: e && e.message }); } } // v2026.5.46 — set par clé "brute" : la date est déjà au format XML // (JJ/MM/AAAA = iv.startDate). Utilisé pour éditer une réservation déjà affichée. async function _resMetaSetRaw(techId, ddmmyyyyDate, hhmm, meta) { _resMetaStore[_resMetaKey(String(techId), ddmmyyyyDate, hhmm)] = { numero: meta.numero || "", ville: meta.ville || "", rue: meta.rue || "", categorie: meta.categorie || "" }; try { await chrome.storage.local.set({ [RESERVATION_META_KEY]: _resMetaStore }); } catch (e) { LOG.warn("reservation", "écriture meta (édition) échouée", { err: e && e.message }); } } // v2026.5.46 — assignations pompier : liste de périodes // { techId, name, start, end } (dates ISO YYYY-MM-DD). Le « pompier du mois » // affiché = l'assignation couvrant la date affichée. Règle : un seul pompier // par jour → on bloque tout chevauchement avec un AUTRE technicien. const POMPIER_KEY = "pompier_assignments"; let _pompierAssignments = []; async function loadPompierAssignments() { try { const r = await chrome.storage.local.get(POMPIER_KEY); _pompierAssignments = Array.isArray(r[POMPIER_KEY]) ? r[POMPIER_KEY] : []; } catch (e) { _pompierAssignments = []; } } // Assignation couvrant une date ISO (comparaison lexicale OK sur YYYY-MM-DD). function _pompierForDate(isoDate) { if (!isoDate) return null; return _pompierAssignments.find(a => a.start <= isoDate && isoDate <= a.end) || null; } // Périodes en conflit avec [start,end] portées par un AUTRE technicien. function _pompierConflicts(start, end, techId) { return _pompierAssignments.filter(a => String(a.techId) !== String(techId) && a.start <= end && start <= a.end); } async function _pompierAddAssignment(techId, name, start, end) { // Remplacement propre : on retire les périodes du MÊME tech qui chevauchent. _pompierAssignments = _pompierAssignments.filter(a => !(String(a.techId) === String(techId) && a.start <= end && start <= a.end)); _pompierAssignments.push({ techId: String(techId), name: name || "", start, end }); try { await chrome.storage.local.set({ [POMPIER_KEY]: _pompierAssignments }); } catch (e) { LOG.warn("pompier", "écriture assignation pompier échouée", { err: e && e.message }); } } // v2026.5.46 — retire une assignation exacte (après déplacement de dates). async function _pompierRemoveExact(techId, start, end) { const before = _pompierAssignments.length; _pompierAssignments = _pompierAssignments.filter(a => !(String(a.techId) === String(techId) && a.start === start && a.end === end)); if (_pompierAssignments.length !== before) { try { await chrome.storage.local.set({ [POMPIER_KEY]: _pompierAssignments }); } catch (e) {} } } // v2026.5.46 — collecte les pompiers depuis 2 sources et fusionne : // (1) le store local d'assignations (créées via l'extension), // (2) le CACHE du planning : toute absence type Pompier réellement présente dans // EasyVista, sur les jours déjà chargés. Chaque absence porte sa période // début→fin (JJ/MM/AAAA dans le cache → converti en ISO). // Dédupliqué par tech|début|fin. Renvoie une liste {techId, name, start, end} (ISO). async function _collectPompiers() { const _toIso = (s) => { const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(s || ""); return m ? `${m[3]}-${m[2]}-${m[1]}` : (s || ""); }; const map = new Map(); // actionId vient du cache (nécessaire pour supprimer l'absence lors d'une // modification de dates). Le store local n'en a pas → complété par le cache. const add = (techId, name, start, end, actionId) => { if (!techId || !start || !end) return; const k = `${techId}|${start}|${end}`; const ex = map.get(k); if (!ex) map.set(k, { techId: String(techId), name: name || "", start, end, actionId: actionId || null }); else if (!ex.actionId && actionId) ex.actionId = actionId; }; for (const a of _pompierAssignments) add(a.techId, a.name, a.start, a.end, null); try { // v2026.5.46 — scan des jours du store v2 (liste plate d'ivs) const all = await chrome.storage.local.get(null); for (const key of Object.keys(all)) { if (key.indexOf(STORE_PREFIX) !== 0) continue; const entry = all[key]; if (!entry || !Array.isArray(entry.ivs)) continue; for (const iv of entry.ivs) { if (!iv || !iv.isPompier || ivIsDead(iv)) continue; const tid = iv.techId; add(tid, (TEAM && TEAM[tid]) || "Pompier", _toIso(iv.startDate), _toIso(iv.endDate || iv.startDate), iv.actionId); } } } catch (e) { LOG.warn("pompier", "scan store pompiers échoué", { err: e && e.message }); } return [...map.values()]; } // 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.type === "AL-Absence") return "absence"; // v5.0.15 : couleur noire/gris foncé 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, // projection rendue (techs[] — objets iv PARTAGÉS avec le store) currentDay: null, // v2026.5.46 — store du jour affiché (LA vérité, cf. couche données v2) loading: false, // v5.0.9 : timestamp (ms) auquel la session EV va expirer. sessionExpireAt: null, // v5.0.9 : true pendant une reconnexion en cours reconnecting: false, // v5.0.9 : true si la session est expirée (bannière rouge affichée) sessionExpired: false, // Cookie PHPSESSID présent mais fetchUser retourne null (page login SSO // intermédiaire). On garde le polling actif tant que la vraie session // (avec user identifié) n'est pas arrivée. sessionPending: false, // v5.0.9 : true si on a déjà fait le ping de confirmation < 5 min sessionPingDone: false, // v5.0.10 : dernière origine EV connue comme fonctionnelle (itsma.vd.ch // ou itsma.etat-de-vaud.ch selon qu'on est en externe ou interne). // Conservée même quand state.session est null, pour savoir où rediriger // lors de la reconnexion. lastKnownOrigin: null, // v5.0.11 : contexte réseau détecté ("internal" ou "external" ou null). // Détecté automatiquement au démarrage par un HEAD test sur l'URL interne. networkContext: null, // v5.0.11 : timer (setTimeout id) pour le timeout de reconnexion 90 sec reconnectTimeoutId: null }; // v5.0.9 : constantes session const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 min // v2026.5.46 — calibrage serveur du compteur de session const SESSION_SYNC_INTERVAL_MS = 90 * 1000; // recalage périodique const SESSION_SYNC_MIN_GAP_MS = 60 * 1000; // throttle entre deux recalages const SESSION_WARN_THRESHOLD_MS = 5 * 60 * 1000; // 5 min → affichage compteur const SESSION_CRITICAL_THRESHOLD_MS = 2 * 60 * 1000; // 2 min → rouge + modal // v5.0.11 : timeout de la reconnexion. Si l'user n'est pas reconnecté // dans ce délai, on bascule en état "Reconnexion échouée" avec choix du réseau. const RECONNECT_TIMEOUT_MS = 90 * 1000; // 90 sec // Tracking des iv déplacées vers une autre date. // EasyVista a un cache serveur qui peut continuer à retourner l'iv pour la // date d'origine pendant quelques secondes après le drag&drop. Pour éviter // que l'iv ré-apparaisse en boucle sur l'ancien jour pendant ce délai, on // la marque ici et on la filtre du fresh tant que la fenêtre est active. // Map const _recentlyMovedAway = new Map(); const _RECENT_MOVE_TTL_MS = 60 * 1000; function _registerRecentMove(actionId, fromDate, toDate) { if (!actionId || !fromDate || !toDate || fromDate === toDate) return; _recentlyMovedAway.set(String(actionId), { fromDate, toDate, ts: Date.now() }); } // v2026.5.46 — l'iv vient-elle d'être déplacée HORS de cette date ? // Utilisé comme garde d'INGESTION (storeApplyFresh n'ingère pas sa fausse // présence resservie par le cache serveur EV) et comme filtre d'AFFICHAGE // (storeProject). Purge automatique de la fenêtre TTL au passage ; arriver // sur la date CIBLE annule la fenêtre (l'iv y est légitime). function _recentlyMovedAwayCovers(actionId, currentDate) { if (!_recentlyMovedAway.size || actionId === undefined || actionId === null) return false; const now = Date.now(); for (const [k, e] of _recentlyMovedAway) { if (now - e.ts > _RECENT_MOVE_TTL_MS) _recentlyMovedAway.delete(k); else if (e.toDate === currentDate) _recentlyMovedAway.delete(k); } const e = _recentlyMovedAway.get(String(actionId)); return !!(e && e.fromDate === currentDate); } // ─── 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 abortCurrentRefresh() { // v2026.5.46 — « Arrêter » vise le pipeline EN COURS (propriétaire du // bouton) et LUI SEUL : sans pipeline actif il n'y a rien à annuler, et une // navigation encore pré-pipeline (jeton pris à l'entrée) survit toujours — // elle affichera son jour. if (_loadPipelineToken !== null) abortedToken = _loadPipelineToken; // Réveiller tous les loadForDate en attente (normalement un seul) for (const [token, resolve] of abortResolvers) { if (token <= currentRefreshToken) { resolve("aborted"); abortResolvers.delete(token); } } // tuer toutes les requêtes EV en vol côté background pour que // rien ne continue en arrière-plan. fire-and-forget. try { sendMessage({ type: "abortAllFetches" }).catch(() => {}); } catch (e) { /* ignore */ } // retirer l'animation "en cours" sur toutes les iv en analyse (le verdict, // lui, reste le dernier connu — l'analyse sera retentée au prochain refresh) if (state.currentData && state.currentData.techs) { for (const tech of state.currentData.techs) { for (const iv of tech.interventions || []) { if (iv._analyzing) { iv._analyzing = false; try { updateInterventionRow(iv); } catch (e) { /* ignore */ } } } } } } // 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 (typeof myToken !== "number") return abortedToken === currentRefreshToken; if (abortedToken === myToken) return true; // « Arrêter » a visé CE flux if (myToken < currentRefreshToken) return true; // un flux plus récent existe return false; } function cleanupAbortResolver(myToken) { abortResolvers.delete(myToken); } // ============================================================================ // Boot // ============================================================================ document.addEventListener("DOMContentLoaded", init); async function init() { // v2026.5.46 — onglet dédié "Mes actions" : viewer.html?view=myactions // affiche la page-liste (rendue par l'extension) au lieu du planning. try { if (new URLSearchParams(location.search).get("view") === "myactions") { await initMyActionsPage(); return; } } catch (e) { /* pas de URLSearchParams → boot planning normal */ } await initTheme(); // v2026.5.46 — hygiène du store au boot : purge unique des clés de // l'ANCIEN format (planning_cache_*, libère le quota) + purge des jours v2 // plus vieux que cfg.cacheDays (le réglage est enfin appliqué réellement, // plus seulement quand le quota déborde). Non bloquant. storePurgeLegacy().then(() => storePurgeOldDays()).catch(() => {}); // v2026.5.46 — charger la meta persistante des réservations (n°/adresse/type). await loadResMetaStore(); // v2026.5.46 — charger les assignations pompier (affichées dans les stats). await loadPompierAssignments(); // v2026.5.39 : appliquer le zoom texte enregistré dès le boot, avant que // le DOM ne soit affiché (sinon "flash" à la taille par défaut). _initTextZoomFromConfig(); // appliquer la couleur + police de la topbar enregistrées. _initTopbarStyleFromConfig(); // exposer la hauteur réelle de la topbar (sticky) en variable CSS. _initTopbarHeightVar(); // surveille les redim. → re-trim les gaps mini si une iv tronque _attachMiniGapsResizeWatcher(); // v2026.5.45 (issue #3) : si on vient de faire une mise à jour depuis // une version qui avait le bug structurel d'écrasement des absences // récurrentes, prévenir l'utilisateur via toast. Une seule fois. _checkInstallEventAndNotify(); // v2026.5.45 (issue #4) : écouter les changements de cookie EZV // broadcastés par background.js (si la permission "cookies" est accordée). _attachCookieChangeListener(); // v2026.5.39 : lire les heures de la journée depuis admin_config (8-18 défaut). await _initDayBoundsFromConfig(); // v2026.5.41 : charger l'équipe et les absences récurrentes depuis admin_config. // Avant ce point, TEAM = {} → aucun fetch ne tournera tant que la config // n'est pas chargée. Si l'utilisateur n'a rien configuré, le fetch retournera // l'erreur "no_team_configured" qui invite à ouvrir les paramètres. await _initTeamFromConfig(); bindTopbar(); bindTooltipInteractions(); initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal initAppFooter(); // v4.2.9 : pied de page discret bas-droite initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar _applyViewMode(); // v2026.5.32 : appliquer la vue sauvegardée initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s) initDateCustomPicker(); // v2026.5.17 : faux input date avec jour _ivDockInit(); // dock latéral d'iv mises de côté // Initialiser la date = aujourd'hui state.currentDate = todayISO(); document.getElementById("date-picker").value = state.currentDate; updateDatePickerDayLabel(state.currentDate); // v2026.5.16 : label "Mardi" // v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant) detectNetworkContextAsync(); // Charger la sesson puis le planning await refreshSessionAndLoad(); } /** * v5.0.11 : détecte si on est en interne (bureau VPN) ou externe (télétravail), * de manière asynchrone au démarrage. Résultat utilisé pour choisir le bon * domaine lors de la reconnexion. */ async function detectNetworkContextAsync(force = false) { try { const resp = await sendMessage({ type: "detectNetwork", force }); if (resp && resp.ok) { const wasInternal = state.networkContext === "internal"; state.networkContext = resp.context; // Si on n'a pas encore de lastKnownOrigin, on prend celui du contexte détecté if (!state.lastKnownOrigin) { state.lastKnownOrigin = resp.origin; } LOG.info("viewer", `réseau détecté : ${resp.context} → ${resp.origin}`); // v2026.5.46 — passage (ou confirmation) en INTERNE → on (re)lance la // synchro dossier pour la date affichée, ce qui relance le host natif s'il // était éteint. En externe, storeSyncFolder ne fait rien. if (resp.context === "internal" && !wasInternal && state.currentDate) { reconcileCacheForDate(state.currentDate).catch(() => {}); } } } catch (e) { LOG.warn("viewer", "détection réseau échouée", e); } } async function refreshSessionAndLoad() { const resp = await sendMessage({ type: "getSession" }); if (!resp.ok || !resp.session) { // si un store existe pour le jour demandé, on l'affiche avec une // bannière "session expirée" sticky au-dessus. Sinon écran plein. const dateAuLancement = state.currentDate; const day = await storeLoad(dateAuLancement); // v2026.5.46 — l'utilisateur a pu naviguer pendant la lecture du store // (boot sans session) : si la date affichée a changé entre-temps, on ne // touche ni l'état ni l'écran (sinon les données du jour lu seraient // affichées sous l'étiquette du nouveau jour). if (state.currentDate !== dateAuLancement) return; if (day) { state.currentDay = day; renderFromData({ techs: storeProject(day, state.currentDate), targetDate: state.currentDate, captureTime: day.savedAt || null, source: "store" }); showSessionExpiredBanner(); } else { showSessionNeeded(); } return; } state.session = resp.session; // v5.0.10 : mémoriser l'origine courante pour la reconnexion si besoin if (resp.session && resp.session.origin) { state.lastKnownOrigin = resp.session.origin; } hideSessionNeeded(); hideEvUnreachable(); hideSessionExpiredBanner(); hideEvUnreachableBanner(); state.sessionExpired = false; state.reconnecting = false; fetchAndShowCurrentUser(); // v5.0.9 : à chaque démarrage/reconnexion, on suppose que la session vient // d'être rafraîchie à 30 min. updateSessionIndicator va masquer le compteur. markSessionActivity(); await loadForDate(state.currentDate); } /** * v5.0.9 : doit être appelée à chaque requête EasyVista réussie. Reset le * timer local à 30 min (la session serveur a été renouvelée implicitement). */ function markSessionActivity() { state.sessionExpireAt = Date.now() + SESSION_DURATION_MS; state.sessionPingDone = false; // reset le flag de ping updateSessionIndicator(); } // v4.2 : fetche l'utilisateur EasyVista connecté (via background.js) et // l'affiche dans la topbar. // v2026.5.26 : en cas d'échec, affiche un rond gris "?" + retry 60s (max 10 essais). // v2026.5.34 : le badge est maintenant TOUJOURS visible (état "?" par défaut // dans le HTML). Cette fonction met à jour le contenu (initiales // quand succès, "?" quand échec). Logs abondants pour debug. // // État initial (HTML) : // État succès : initiales calculées + couleur dérivée du nom // État échec : "?" + couleur grise (classe user-badge-unknown) // // Retry : 10 tentatives espacées de 60s (10 min max), arrêt au 1er succès. let _currentUserRetryCount = 0; const _CURRENT_USER_MAX_RETRIES = 10; const _CURRENT_USER_RETRY_DELAY_MS = 60 * 1000; /** * Récupère le nom de l'utilisateur EasyVista connecté et l'affiche dans le badge. * * @author Quentin Rouiller */ async function fetchAndShowCurrentUser() { const attemptId = _currentUserRetryCount + 1; LOG.info("currentUser", `tentative ${attemptId}/${_CURRENT_USER_MAX_RETRIES + 1} de fetchCurrentUser`); const badge = document.getElementById("user-badge"); if (!badge) { // Fallback défensif : pas de badge dans le DOM ? On log et on abandonne. LOG.warn("currentUser", "badge DOM introuvable — abandon"); return; } let success = false; let errorReason = null; try { const resp = await sendMessage({ type: "fetchCurrentUser" }); LOG.info("currentUser", "réponse reçue :", resp ? JSON.stringify(resp).substring(0, 200) : "(null)"); if (!resp) { errorReason = "response_null"; } else if (!resp.ok) { errorReason = resp.error || "ok_false"; } else if (!resp.user) { errorReason = "user_null"; } else { const fullName = resp.user.name || resp.user.login || null; if (!fullName) { errorReason = "name_empty"; } else { // Succès : mise à jour du badge const initials = computeUserInitials(fullName); LOG.info("currentUser", `SUCCÈS : "${fullName}" → initiales "${initials}"`); badge.textContent = initials; badge.title = fullName; badge.style.setProperty("--user-badge-color", colorFromName(fullName)); badge.classList.remove("user-badge-unknown"); // On retire aussi "hidden" au cas où (compat ancienne version) badge.classList.remove("hidden"); state.currentUser = resp.user; const wasPending = state.sessionPending; state.sessionPending = false; // session définitive → polling peut s'arrêter success = true; _currentUserRetryCount = 0; // reset compteur au succès // Si on était en attente d'auth définitive (page login SSO), le // planning n'a pas encore été chargé avec la vraie session — on // relance maintenant qu'on a un user valide. if (wasPending && typeof loadForDate === "function") { loadForDate(state.currentDate, { forceRefetch: true }).catch(() => {}); } } } } catch (err) { errorReason = "exception: " + String(err); LOG.warn("currentUser", "exception durant sendMessage :", err); } if (success) return; // Échec : on laisse le badge en état "inconnu" (déjà le cas par défaut) // et on schedule un retry. LOG.warn("currentUser", `échec (raison: ${errorReason}) — badge reste en état "?"`); // user_null = page de login SSO intermédiaire → on a un cookie PHPSESSID // mais pas la vraie session définitive. On flag pour que le polling 2s // continue à surveiller le PHPSESSID (qui changera quand l'auth termine). if (errorReason === "user_null") { state.sessionPending = true; } // Défense : s'assurer que le badge est bien en état inconnu (au cas où // une mise à jour partielle a eu lieu puis échoué). badge.textContent = "?"; badge.title = "Utilisateur — cliquer pour accéder aux paramètres"; badge.style.setProperty("--user-badge-color", "#6b7280"); badge.classList.add("user-badge-unknown"); badge.classList.remove("hidden"); // Schedule retry si pas trop d'essais if (_currentUserRetryCount < _CURRENT_USER_MAX_RETRIES) { _currentUserRetryCount++; LOG.info("currentUser", `retry programmé : ${_currentUserRetryCount}/${_CURRENT_USER_MAX_RETRIES} dans ${_CURRENT_USER_RETRY_DELAY_MS / 1000}s`); setTimeout(() => { fetchAndShowCurrentUser(); }, _CURRENT_USER_RETRY_DELAY_MS); } else { LOG.warn("currentUser", "max retries atteint, arrêt du retry automatique. Le badge reste cliquable (Paramètres accessible)."); } } /** * v2026.5.34 : déclenche un fetchAndShowCurrentUser() SI le user n'est pas * encore connu (badge en état "?"). Appelée après chaque succès de planning * pour profiter d'une session EV valide sans attendre le retry de 60s. * * Sans effet si : * - state.currentUser est déjà renseigné (pas besoin de re-fetcher) * - un retry est déjà en cours (évite les doublons) * * @param {string} reason - contexte pour les logs (ex: "after_load_success") * * @author Quentin Rouiller */ function _maybeRetryFetchUser(reason) { if (state.currentUser && state.currentUser.name) { // User déjà connu, rien à faire return; } const badge = document.getElementById("user-badge"); if (badge && !badge.classList.contains("user-badge-unknown")) { // Badge n'est pas en état inconnu → user probablement connu par un autre chemin return; } LOG.info("currentUser", `relance opportuniste (raison: ${reason}) — user encore inconnu`); // Reset le compteur puisqu'on a un nouveau contexte (session fraîche) _currentUserRetryCount = 0; fetchAndShowCurrentUser(); } // v4.2.3 : calcule les initiales depuis un nom au format "Nom, Prénom" ou // "Nom Prénom" ou "Prénom Nom". On prend la 1re lettre majuscule de chaque // mot/segment significatif, limité à 2 caractères. function computeUserInitials(fullName) { if (!fullName) return "?"; // Format "Nom, Prénom" → prendre initiale avant virgule et après let parts; if (fullName.includes(",")) { parts = fullName.split(",").map(s => s.trim()).filter(Boolean); } else { parts = fullName.split(/\s+/).filter(Boolean); } const letters = parts .map(p => p.charAt(0)) .filter(c => /[A-Za-zÀ-ÿ]/.test(c)) .slice(0, 2) .join("") .toUpperCase(); return letters || (fullName.charAt(0).toUpperCase() || "?"); } // v4.2.3 : couleur déterministe à partir du nom. Palette neutre et sobre // (tons tamisés), compatible avec les thèmes clair et sombre de l'extension. function colorFromName(name) { // Hash simple (djb2) pour dériver un index stable let h = 5381; for (let i = 0; i < name.length; i++) { h = ((h << 5) + h + name.charCodeAt(i)) & 0xffffffff; } const palette = [ "#5b6372", // gris bleuté "#6b7280", // gris neutre "#4a5568", // ardoise "#3b5a72", // bleu profond tamisé "#4f6a5e", // vert sauge sombre "#6b5a4f", // brun taupe "#5d4a6b", // prune sombre "#6a5a3a", // kaki bronze "#3a5a5e", // sarcelle sombre "#6c5c67" // mauve grisé ]; return palette[Math.abs(h) % palette.length]; } // v4.2.3 : affiche/masque la popup nom complet sous la pastille function toggleUserNamePopup() { const badge = document.getElementById("user-badge"); const popup = document.getElementById("user-name-popup"); if (!badge || !popup) return; if (!popup.classList.contains("hidden")) { hideUserNamePopup(); return; } // v2026.5.17 : afficher aussi le temps restant de la session (MM:SS) avec // une couleur qui dépend du seuil (vert/jaune/rouge). // v2026.5.26 : si user inconnu, afficher "Utilisateur inconnu" + retry + réglages popup.innerHTML = ""; const nameEl = document.createElement("div"); nameEl.className = "user-name-popup-name"; if (state.currentUser && state.currentUser.name) { nameEl.textContent = state.currentUser.name; } else { nameEl.textContent = "Utilisateur inconnu"; nameEl.style.fontStyle = "italic"; nameEl.style.color = "var(--text-muted)"; } popup.appendChild(nameEl); const sessEl = document.createElement("div"); sessEl.className = "user-name-popup-session"; sessEl.id = "user-name-popup-session"; _renderUserPopupSessionLine(sessEl); popup.appendChild(sessEl); // v2026.5.46 — le popup devient le panneau de contrôle de la session : // ligne « Connexion » (domaine actif) + bouton Prolonger toujours accessible // (avant : uniquement dans le chip d'alerte < 5 min). const connEl = document.createElement("div"); connEl.className = "user-name-popup-conn"; connEl.id = "user-name-popup-conn"; _renderUserPopupConnLine(connEl); popup.appendChild(connEl); const extendPopupBtn = document.createElement("button"); extendPopupBtn.type = "button"; extendPopupBtn.className = "user-name-popup-settings"; const _extendHtml = '🔄 Prolonger la session (30 min)'; extendPopupBtn.innerHTML = _extendHtml; extendPopupBtn.title = "Remet le compteur de session EasyVista à 30 minutes"; extendPopupBtn.addEventListener("click", () => _sessionExtend(extendPopupBtn, _extendHtml)); popup.appendChild(extendPopupBtn); // v2026.5.25 : bouton Paramètres (remplace les 5 clics sur le titre) // v2026.5.32 : bouton "Vue" pour basculer Vue classique ↔ Vue horizontale // v2026.5.39 r7 : on affiche la vue de DESTINATION (pas la vue actuelle), // et le logo s'adapte pour visualiser comment seront affichées les // interventions après clic. // - en classique : on propose Horizontale + logo "rangées" (≡) // - en horizontal : on propose Classique + logo "grille" (⊞) const viewBtn = document.createElement("button"); viewBtn.type = "button"; viewBtn.className = "user-name-popup-settings"; const currentView = _getCurrentView(); if (currentView === "horizontal") { viewBtn.innerHTML = ' Passer en vue Classique'; } else { viewBtn.innerHTML = ' Passer en vue Horizontale'; } viewBtn.title = "Bascule entre vue classique (cards en grille) et vue horizontale (1 ligne par tech)"; viewBtn.addEventListener("click", (e) => { e.stopPropagation(); hideUserNamePopup(); _toggleView(); }); popup.appendChild(viewBtn); const settingsBtn = document.createElement("button"); settingsBtn.type = "button"; settingsBtn.className = "user-name-popup-settings"; settingsBtn.innerHTML = ' Paramètres'; settingsBtn.title = "Ouvrir les paramètres d'administration"; settingsBtn.addEventListener("click", (e) => { e.stopPropagation(); hideUserNamePopup(); if (typeof showAdminPanel === "function") { showAdminPanel(); } }); popup.appendChild(settingsBtn); // v2026.5.38 : signature auteur en bas du popup user-badge // Style : même look que le footer "QRO/vX.Y.Z" en bas à droite — petit, // gris atténué, séparé par une fine ligne supérieure. const authorLine = document.createElement("div"); authorLine.className = "user-name-popup-author"; authorLine.textContent = "Développé par Quentin Rouiller"; authorLine.title = "Auteur de l'extension"; popup.appendChild(authorLine); popup.classList.remove("hidden"); badge.classList.add("open"); // Positionne juste en dessous de la pastille const r = badge.getBoundingClientRect(); popup.style.left = Math.max(8, r.left) + "px"; popup.style.top = (r.bottom + 6) + "px"; } function hideUserNamePopup() { const popup = document.getElementById("user-name-popup"); const badge = document.getElementById("user-badge"); if (popup) popup.classList.add("hidden"); if (badge) badge.classList.remove("open"); } // v2026.5.17 : remplit la ligne "Session : MM:SS" avec couleur selon seuil. // Recalcule à chaque appel — appelée aussi par le tick session pour rafraîchir. function _renderUserPopupSessionLine(el) { if (!el) return; // v2026.5.46 — état expiré affiché explicitement dans le popup. if (state.sessionExpired) { el.textContent = "Session : expirée"; el.className = "user-name-popup-session session-critical"; return; } const remainingMs = _getSessionRemainingMs(); if (remainingMs == null) { el.textContent = "Session : —"; el.className = "user-name-popup-session"; return; } const mins = Math.floor(remainingMs / 60000); const secs = Math.floor((remainingMs % 60000) / 1000); const txt = `Session : ${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; el.textContent = txt; el.className = "user-name-popup-session"; if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) { el.classList.add("session-critical"); } else if (remainingMs <= SESSION_WARN_THRESHOLD_MS) { el.classList.add("session-warn"); } else { el.classList.add("session-ok"); } } // v2026.5.17 : récupère en ms le temps restant avant expiration de la session. // Retourne null si on ne connaît pas encore (pas de session ouverte). function _getSessionRemainingMs() { if (!state.sessionExpireAt) return null; const remaining = state.sessionExpireAt - Date.now(); return remaining > 0 ? remaining : 0; } // v2026.5.46 — calibrage du compteur local sur le SERVEUR (timeout_ajax // session_time, le même endpoint qu'EV utilise pour sa propre modal de // timeout : il ne prolonge pas la session). Throttle interne 60 s sauf force. let _sessionSyncLast = 0; let _sessionSyncInFlight = false; async function _sessionServerSync(opts) { const force = !!(opts && opts.force); if (_sessionSyncInFlight) return false; if (!force && Date.now() - _sessionSyncLast < SESSION_SYNC_MIN_GAP_MS) return false; if (state.sessionExpired || state.reconnecting) return false; _sessionSyncInFlight = true; try { const resp = await sendMessage({ type: "getSessionRemaining" }); _sessionSyncLast = Date.now(); if (resp && resp.ok && typeof resp.remainingMs === "number") { const drift = state.sessionExpireAt ? (state.sessionExpireAt - (Date.now() + resp.remainingMs)) : null; state.sessionExpireAt = Date.now() + resp.remainingMs; if (drift !== null && Math.abs(drift) > 30000) { LOG.info("session", "compteur recalé sur le serveur (dérive " + Math.round(drift / 1000) + "s)"); } updateSessionIndicator(); return true; } if (resp && resp.error === "session_expired") handleSessionExpired(); return false; } catch (e) { return false; } finally { _sessionSyncInFlight = false; } } // v2026.5.46 — le compteur local atteint 0 : VERIFIER au serveur avant de // declarer la session expiree (un onglet EV ouvert peut la maintenir vivante // alors que notre hypothese locale 30 min est depassee). // v2026.5.46 — l'expiration n'est déclarée QUE SUR PREUVE : réponse // serveur « session_expired » (la sonde a reçu une page de login), temps // restant nul, ou « no_session » (plus AUCUN PHPSESSID — ni onglet EV ni // cookie : l'extension ne peut de toute façon plus rien fetcher). Une erreur // TRANSITOIRE (réseau, HTTP 5xx, réponse invalide, messaging) ne bascule // PLUS en « session expirée » : nouvel essai au plus tôt dans 30 s, le chip // reste à 00:00 entre-temps (cf. updateSessionIndicator). let _sessionExpireVerifying = false; let _sessionExpireNextTryAt = 0; async function _sessionExpireVerify() { if (_sessionExpireVerifying || state.sessionExpired) return; if (Date.now() < _sessionExpireNextTryAt) return; _sessionExpireVerifying = true; let verdict = "unknown"; // alive | dead | unknown let remainingMs = 0; try { const resp = await sendMessage({ type: "getSessionRemaining" }); if (resp && resp.ok && typeof resp.remainingMs === "number") { if (resp.remainingMs > 1000) { verdict = "alive"; remainingMs = resp.remainingMs; } else verdict = "dead"; } else if (resp && (resp.error === "session_expired" || resp.error === "no_session")) { verdict = "dead"; } } catch (e) { /* messaging KO → verdict unknown */ } _sessionExpireVerifying = false; if (verdict === "alive") { state.sessionExpireAt = Date.now() + remainingMs; LOG.warn("session", "compteur local a 0 mais session serveur vivante (" + Math.round(remainingMs / 1000) + "s) → recalé, pas d'expiration"); updateSessionIndicator(); return; } if (verdict === "unknown") { _sessionExpireNextTryAt = Date.now() + 30000; LOG.warn("session", "compteur local a 0, vérification serveur impossible (transitoire) → nouvel essai dans 30 s, pas d'expiration"); return; } handleSessionExpired(); } // v2026.5.46 — prolongation de session factorisée (chip topbar + popup // badge) : désactive le bouton, appelle keep_connection, recale le compteur. async function _sessionExtend(btn, restoreHtml) { if (btn) { btn.disabled = true; btn.textContent = "…"; } try { const resp = await sendMessage({ type: "extendSession" }); if (resp && resp.ok && typeof resp.remainingMs === "number") { state.sessionExpireAt = Date.now() + resp.remainingMs; state.sessionPingDone = false; showToast("Session prolongée", "30 minutes de plus"); } else { throw new Error((resp && resp.error) || "erreur inconnue"); } } catch (err) { if (err.message === "session_expired" || err.message === "no_session") { handleSessionExpired(); return; } showToast("Prolongation impossible", err.message || "erreur"); } finally { if (btn) { btn.disabled = false; if (restoreHtml) btn.innerHTML = restoreHtml; } updateSessionIndicator(); } } // v2026.5.46 — ligne « Connexion » du popup badge : domaine EV actif. function _renderUserPopupConnLine(el) { if (!el) return; const o = state.lastKnownOrigin || ""; let txt = "Connexion : —"; if (state.sessionExpired) txt = "Connexion : session à rétablir"; else if (o.includes("etat-de-vaud")) txt = "Connexion : EasyVista interne (bureau)"; else if (o.includes("itsma.vd.ch")) txt = "Connexion : EasyVista externe (télétravail)"; el.textContent = txt; } // ============================================================================ // Thème clair/sombre // ============================================================================ // v2026.5.41 : thème unifié sur admin_config.theme (chrome.storage.local). // Le toggle topbar écrit dans la même clé que le select Apparence du panel, // pour que les deux soient toujours en accord. async function initTheme() { let pref = "auto"; try { const cfg = await loadAdminConfig(); if (cfg.theme === "light" || cfg.theme === "dark" || cfg.theme === "auto") { pref = cfg.theme; } } catch (e) {} _applyTheme(pref); _watchOsThemeChanges(); } function detectDefaultTheme() { if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { return "dark"; } return "light"; } async function toggleTheme() { // L'effectif courant (light ou dark), même si pref="auto" const currentAttr = document.documentElement.getAttribute("data-theme"); const effective = (currentAttr === "light" || currentAttr === "dark") ? currentAttr : detectDefaultTheme(); const next = effective === "dark" ? "light" : "dark"; _applyTheme(next); try { const cfg = await loadAdminConfig(); cfg.theme = next; await saveAdminConfig(cfg); } catch (e) { LOG.warn("theme", "toggle save err", e); } } // ============================================================================ // Topbar handlers // ============================================================================ function bindTopbar() { document.getElementById("theme-toggle").addEventListener("click", toggleTheme); // v4.1.10 : 2 boutons de rafraichissement. // - 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); // v4.2.6 : boutons Absence et Douchette // v2026.5.46 — bouton principal "Ajouter" → menu Réservation/Absence/Pompier. const addMenuBtn = document.getElementById("add-menu-btn"); const addMenu = document.getElementById("add-menu"); if (addMenuBtn && addMenu) { const addMenuWrap = document.getElementById("add-menu-wrap"); // v2026.5.46 — rétablit le menu dans son conteneur (dé-portalise) + reset. const _restoreAddMenu = () => { addMenu.classList.remove("add-menu--right"); addMenu.style.left = ""; addMenu.style.top = ""; if (addMenuWrap && addMenu.parentNode !== addMenuWrap) addMenuWrap.appendChild(addMenu); }; const closeAddMenu = () => { addMenu.classList.add("hidden"); addMenuBtn.setAttribute("aria-expanded", "false"); _restoreAddMenu(); }; addMenuBtn.addEventListener("click", (e) => { e.stopPropagation(); const willOpen = addMenu.classList.contains("hidden"); addMenuBtn.setAttribute("aria-expanded", willOpen ? "true" : "false"); // v2026.5.46 — en vue horizontale (bouton dans la sidebar gauche), // on PORTALISE le menu dans et on l'ouvre à DROITE du bouton, sinon // il est rogné/derrière les cartes (contexte d'empilement de la sidebar). const horiz = document.documentElement.classList.contains("view-horizontal"); if (willOpen && horiz) { if (addMenu.parentNode !== document.body) document.body.appendChild(addMenu); const r = addMenuBtn.getBoundingClientRect(); addMenu.classList.add("add-menu--right"); addMenu.style.left = Math.round(r.right + 10) + "px"; addMenu.style.top = Math.round(r.top) + "px"; addMenu.classList.remove("hidden"); } else if (willOpen) { _restoreAddMenu(); addMenu.classList.remove("hidden"); } else { closeAddMenu(); } }); // Clic ailleurs → fermer le menu (exclut le wrap ET le menu lui-même, // car portalisé dans en vue horizontale). document.addEventListener("click", (e) => { if (!addMenu.classList.contains("hidden") && !e.target.closest("#add-menu-wrap") && !e.target.closest("#add-menu")) closeAddMenu(); }); const wire = (id, fn) => { const b = document.getElementById(id); if (b) b.addEventListener("click", () => { closeAddMenu(); fn(); }); }; wire("add-menu-reservation", showReservationModal); wire("add-menu-absence", showAbsenceModal); wire("add-menu-pompier", showPompierModal); } const douchetteBtn = document.getElementById("douchette-btn"); // v2026.5.46 — CORRECTIF : ne PAS passer le handler direct, sinon l'Event // arrive comme preselectTechId → hideList truthy → la liste des techniciens // était masquée dans la modal douchette. if (douchetteBtn) douchetteBtn.addEventListener("click", () => showDouchetteModal()); document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1)); document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1)); document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO())); // clic sur le badge rouge "Auj. JJ.MM" → revient à aujourd'hui. const todayBadgeEl = document.getElementById("today-badge"); if (todayBadgeEl) { todayBadgeEl.addEventListener("click", () => loadForDate(todayISO())); } document.getElementById("date-picker").addEventListener("change", (e) => { if (e.target.value) loadForDate(e.target.value); }); // v4.2.3 : clic sur la pastille d'initiales → toggle popup nom complet const userBadge = document.getElementById("user-badge"); if (userBadge) { userBadge.addEventListener("click", (e) => { e.stopPropagation(); toggleUserNamePopup(); }); } // Clic ailleurs ou touche Escape ferme la popup user document.addEventListener("click", (e) => { const popup = document.getElementById("user-name-popup"); if (popup && !popup.classList.contains("hidden")) { // Ne pas fermer si le clic est dans la popup elle-même ou sur le badge if (!e.target.closest("#user-name-popup") && !e.target.closest("#user-badge")) { hideUserNamePopup(); } } // v4.2.4 : clic ailleurs ferme aussi la grande bulle d'interventoin // quand elle est ouverte via clic timeline (mode "anchored"). Clic sur // la bulle elle-même ou sur une timeline-slot ne ferme pas. const tip = tooltipEl(); if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) { if (!e.target.closest("#tooltip") && !e.target.closest(".timeline-slot")) { hideTooltip({ force: true }); } } }); // v2026.5.20 : nouveau comportement de la touche Échap // - Appui court : ferme uniquement le popup SOUS la souris (normal ou // minimisé). Si la souris n'est sur aucun popup, ne fait rien. // Ferme aussi le popup user-badge et la grande bulle anchored. // - Maintenu ≥ 3 secondes : ferme TOUS les popups flottants, mais garde // les pastilles dock (popups "réduits" en bas). let _escHoldTimer = null; let _escHoldTriggered = false; const ESC_HOLD_MS = 3000; document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; // keydown peut se répéter si la touche est maintenue ; on ignore les répétitions. if (e.repeat) return; // Armer le timer "maintenu 3s" _escHoldTriggered = false; if (_escHoldTimer) clearTimeout(_escHoldTimer); _escHoldTimer = setTimeout(() => { _escHoldTriggered = true; _escHoldTimer = null; // Fermer TOUS les popups flottants (normaux + minimisés) mais pas les dockés document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => { try { p.remove(); } catch (err) {} }); // Nettoyer la liste for (let i = pinnedPopups.length - 1; i >= 0; i--) { if (!document.body.contains(pinnedPopups[i].el)) { pinnedPopups.splice(i, 1); } } _ensureDockCloseAllBtn(); }, ESC_HOLD_MS); }); document.addEventListener("keyup", (e) => { if (e.key !== "Escape") return; if (_escHoldTimer) { clearTimeout(_escHoldTimer); _escHoldTimer = null; } if (_escHoldTriggered) { // On a déjà fait l'action "maintenu", ne rien faire de plus _escHoldTriggered = false; return; } // Appui court : fermer le popup sous la souris si applicable hideUserNamePopup(); const tip = tooltipEl(); if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) { hideTooltip({ force: true }); } // Quel popup est sous la souris ? Utiliser :hover pour détecter const hovered = document.querySelector(".pinned-popup:hover"); if (hovered && !hovered.classList.contains("pinned-popup-reduced")) { // Retirer aussi de pinnedPopups const idx = pinnedPopups.findIndex(p => p.el === hovered); if (idx >= 0) pinnedPopups.splice(idx, 1); hovered.remove(); _ensureDockCloseAllBtn(); } }); // v5.0.10 : clic "Ouvrir EasyVista" sur l'écran plein → déclenche la // reconnexion SSO + l'auto-reload du viewer dès que la nouvelle session // est détectée (au lieu d'ouvrir juste un onglet). document.getElementById("open-ev-btn").addEventListener("click", () => { triggerReconnect(); }); // v4.2 : écran "EasyVista inaccessible" const openEvBtn2 = document.getElementById("open-ev-btn-2"); if (openEvBtn2) openEvBtn2.addEventListener("click", openEasyVista); const retryBtn = document.getElementById("retry-btn"); if (retryBtn) retryBtn.addEventListener("click", async () => { hideEvUnreachable(); document.getElementById("loading").classList.remove("hidden"); await refreshSessionAndLoad(); }); // 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); // v4.2.5 : bindings bannière "EasyVista inaccessible" const evRetryBtn = document.getElementById("ev-unreachable-banner-retry"); if (evRetryBtn) evRetryBtn.addEventListener("click", async () => { hideEvUnreachableBanner(); await refreshSessionAndLoad(); }); const evOpenBtn = document.getElementById("ev-unreachable-banner-open"); if (evOpenBtn) evOpenBtn.addEventListener("click", openEasyVista); const evCloseBtn = document.getElementById("ev-unreachable-banner-close"); if (evCloseBtn) evCloseBtn.addEventListener("click", hideEvUnreachableBanner); } async function openEasyVista() { // v5.0.10 : ouvrir sur le domaine le plus approprié : // - lastKnownOrigin si on a déjà eu une session fonctionnelle (respecte // interne vs externe selon le réseau) // - session.origin si on a encore la session // - itsma.vd.ch en fallback (domaine externe accessible de partout, // même depuis le réseau VD il redirige vers l'interne transparent) const origin = state.lastKnownOrigin || (state.session && state.session.origin) || "https://itsma.vd.ch"; await chrome.tabs.create({ url: origin + "/" }); } // 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() { // v4.1.20 : modal central avec 2 choix (jour / tout) + annuler showClearCacheModal(); } // v4.1.20 : modal central de confirmation pour vider le cache. L'arrière-plan // est flouté, l'utilisateur a deux choix explicites + Annuler. function showClearCacheModal() { // Ne pas ouvrir 2x si déjà affiché if (document.getElementById("clear-cache-modal")) return; const dateTxt = formatDateDM(state.currentDate); const overlay = document.createElement("div"); overlay.id = "clear-cache-modal"; overlay.className = "modal-overlay"; overlay.innerHTML = ` `; document.body.appendChild(overlay); const close = () => { overlay.remove(); }; overlay.addEventListener("click", async (e) => { const action = e.target.closest("[data-action]")?.dataset.action; if (!action) { // Clic sur le fond (pas sur la carte) → fermer if (e.target === overlay) close(); return; } if (action === "cancel") { close(); return; } if (action === "clear-day") { close(); await chrome.storage.local.remove(STORE_PREFIX + state.currentDate); state.currentDay = null; await loadForDate(state.currentDate, { forceRefetch: true }); return; } if (action === "clear-all") { close(); // Supprimer toutes les clés du store v2 (+ vieux planning_cache_* s'il en reste) const all = await chrome.storage.local.get(null); const toRemove = Object.keys(all).filter(k => k.startsWith(STORE_PREFIX) || k.startsWith(LEGACY_CACHE_PREFIX)); if (toRemove.length) { await chrome.storage.local.remove(toRemove); } state.currentDay = null; await loadForDate(state.currentDate, { forceRefetch: true }); return; } }); // Échap ferme la modale const escHandler = (e) => { if (e.key === "Escape") { close(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); } // ============================================================================ // v4.2.5 : modal d'alerte générique (session expirée / EV inaccessible / // erreur d'ouverture). Remplace les alert() natives par une vraie popup // avec flou autour, titre, message et boutons personnalisables. // ============================================================================ /** * Affiche un modal d'alerte. * @param {Object} opts * @param {string} opts.title - Titre * @param {string} opts.message - Message (HTML autorisé si opts.html=true) * @param {boolean} [opts.html=false] - Si true, message interprété comme HTML * @param {Array<{label:string, variant:"primary"|"secondary"|"danger", action:(()=>void|Promise)}>} opts.buttons * Boutons (en bas du modal). Le 1er = focus par défaut. */ // ============================================================================ // notification dans le titre de l'onglet (tab title flip) // ============================================================================ // Quand la session EasyVista va expirer, on alterne le titre de l'onglet // entre "Planification" et "Planification" (toggle 700 ms) — visible // dans la barre des onglets du navigateur même quand le tab n'est pas // au premier plan, comme Gmail/Slack qui ajoutent "(3) " devant. let _expirationTitleInterval = null; let _expirationTitleTimeout = null; let _expirationTitleOriginal = null; function _setExpirationTitleNotice(active, durationMs) { if (_expirationTitleInterval) { clearInterval(_expirationTitleInterval); _expirationTitleInterval = null; } if (_expirationTitleTimeout) { clearTimeout(_expirationTitleTimeout); _expirationTitleTimeout = null; } // Restaure l'original si on en avait sauvegardé un. if (!active) { if (_expirationTitleOriginal != null) { try { document.title = _expirationTitleOriginal; } catch (e) {} _expirationTitleOriginal = null; } return; } _expirationTitleOriginal = document.title || "Planification"; let on = true; const tick = () => { try { // remplacement complet du titre par un message d'alerte — // le mot change drastiquement entre les deux états, l'œil capte // beaucoup mieux le mouvement dans la barre d'onglets. document.title = on ? "🚨 SESSION EXPIRE 🚨" : _expirationTitleOriginal; } catch (e) {} on = !on; }; tick(); _expirationTitleInterval = setInterval(tick, 700); if (durationMs && durationMs > 0) { _expirationTitleTimeout = setTimeout(() => { _setExpirationTitleNotice(false); }, durationMs); } } function showAlertModal(opts) { // Si un alert modal est déjà affiché, l'enlever d'abord const existing = document.getElementById("alert-modal"); if (existing) existing.remove(); const overlay = document.createElement("div"); overlay.id = "alert-modal"; overlay.className = "modal-overlay"; const card = document.createElement("div"); card.className = "modal-card"; card.setAttribute("role", "dialog"); card.setAttribute("aria-labelledby", "alert-modal-title"); const h = document.createElement("h2"); h.id = "alert-modal-title"; h.className = "modal-title"; h.textContent = opts.title || ""; card.appendChild(h); const p = document.createElement("p"); p.className = "modal-message"; if (opts.html) { p.innerHTML = opts.message || ""; } else { p.textContent = opts.message || ""; } card.appendChild(p); const actions = document.createElement("div"); actions.className = "modal-actions"; (opts.buttons || []).forEach((btn, i) => { const b = document.createElement("button"); b.type = "button"; b.className = "btn"; if (btn.variant === "primary") b.classList.add("btn-modal-primary"); else if (btn.variant === "danger") b.classList.add("btn-modal-danger-strong"); else b.classList.add("btn-modal-cancel"); b.textContent = btn.label; b.addEventListener("click", async () => { overlay.remove(); if (typeof btn.action === "function") { try { await btn.action(); } catch (e) { LOG.exception("alert-modal", "action de bouton en échec", e); } } }); actions.appendChild(b); if (i === 0) setTimeout(() => b.focus(), 50); }); card.appendChild(actions); // opt-in pour injecter le sous-bandeau "Activer multi-onglets" // dans la modal — utilisé par les modals d'erreur fiche EZV pour que // l'utilisateur puisse activer la lecture du cookie sans naviguer // vers les paramètres. if (opts.injectCookiesPrompt && typeof _injectCookiesPromptInBanner === "function") { try { _injectCookiesPromptInBanner(card); } catch (e) {} } overlay.appendChild(card); document.body.appendChild(overlay); // Clic sur le fond (flou) → fermer overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); // Échap ferme la modale const escHandler = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); } // ============================================================================ // v4.2.9 : blocage du scroll en arrière-plan quand un modal est ouvert // ============================================================================ // // Un MutationObserver surveille l'apparition/disparition de tout élément // .modal-overlay dans le body. Dès qu'il y en a au moins un, on ajoute la // classe `modal-open` sur body → CSS bloque le scroll. Quand le dernier // modal disparaît, la classe est retirée. // // Centralisé ici pour que TOUS les modals (existants et futurs) en profitent // sans modification individuelle. function initModalScrollLock() { const updateLock = () => { const hasModal = document.querySelector(".modal-overlay") !== null; document.body.classList.toggle("modal-open", hasModal); }; const observer = new MutationObserver(updateLock); observer.observe(document.body, { childList: true, subtree: false }); updateLock(); // au cas où un modal serait déjà là au boot } // v4.2.9 : pied de page discret "QRO / vX.X.X" en bas à droite. // La version est lue depuis le manifest (source unique de vérité). /** * Pied de page discret bas-droite avec QRO et numéro de version. * * @author Quentin Rouiller */ function initAppFooter() { if (document.querySelector(".app-footer")) return; let version = ""; try { const manifest = chrome && chrome.runtime && chrome.runtime.getManifest ? chrome.runtime.getManifest() : null; if (manifest && manifest.version) version = "v" + manifest.version; } catch (e) { LOG.warn("footer", "getManifest indispo (mode dégradé sans version)", { err: e && e.message }); } const el = document.createElement("div"); el.className = "app-footer"; el.textContent = `QRO${version ? " / " + version : ""}`; document.body.appendChild(el); } // v2026.5.32 : bascule entre Vue classique (cards) et Vue horizontale (rows) // Persisté dans localStorage (clé : "view_mode"). Défaut : "classic". const VIEW_MODE_KEY = "view_mode"; function _getCurrentView() { try { const v = localStorage.getItem(VIEW_MODE_KEY); return v === "horizontal" ? "horizontal" : "classic"; } catch (e) { return "classic"; } } function _setCurrentView(mode) { try { localStorage.setItem(VIEW_MODE_KEY, mode === "horizontal" ? "horizontal" : "classic"); } catch (e) { // localStorage peut être bloqué (mode privé Firefox, quota plein) LOG.warn("viewMode", "localStorage indispo, vue non persistée", { err: e && e.message }); } _applyViewMode(); } function _toggleView() { const current = _getCurrentView(); const next = current === "horizontal" ? "classic" : "horizontal"; _setCurrentView(next); } /** * v2026.5.36 : applique le mode de vue (classique/horizontal) en déplaçant * physiquement les éléments de la topbar vers/depuis une sidebar verticale * à gauche de l'écran. * * En vue horizontale : * - Sidebar gauche verticale contenant (haut → bas) : * · Navigation date (prev / date / next / aujourd'hui) * · Horloge + date (compacte, une par ligne) * · Info de synchro * · Stats globales (interventions/techs/absents) * · Boutons actions (Absence, Douchette, Actualiser, Tout recharger, Vider cache) * - Topbar réduite à : user-badge + titre + theme-toggle * * En vue classique : * - Tout est remis dans la topbar comme avant (topbar-left / topbar-right) * * On mémorise les parents d'origine sur chaque élément (data-orig-parent) * pour restaurer proprement en vue classique. * * v2026.5.38 : on ne log plus que les warnings (parent absent etc.) — les * logs verbeux étape par étape ont été retirés, ils polluaient la console * à chaque toggle. * * @author Quentin Rouiller */ function _applyViewMode() { const mode = _getCurrentView(); // Mettre à jour la classe sur pour les règles CSS document.documentElement.classList.remove("view-classic", "view-horizontal"); document.documentElement.classList.add("view-" + mode); // Liste des IDs à déplacer entre topbar (classique) et sidebar (horizontal) // Ordre = ordre visuel dans la sidebar en mode horizontal (haut → bas) // v2026.5.37 : user-badge, app-title, theme-toggle déplacés aussi → la topbar // devient vide en vue horizontale. // on déplace today-block comme un tout (qui contient nav-today + // app-clock). Ainsi, en sidebar, le bouton "Aujourd'hui" reste au-dessus // de la date+heure du jour, dans le même cadre, comme en vue classique. const ELEMENTS_TO_RELOCATE = [ "user-badge", // tout en haut de la sidebar "app-title", // juste après user-badge "today-block", // nav-today + app-clock, en bloc "date-nav", // (pas un id mais une classe, traité à part) "capture-info", "stats", // conteneur stats globales (généré dynamiquement) "topline-coord", // v2026.5.46 — bouton « Mes actions » coordinateur "add-menu-wrap", // v2026.5.46 — bouton "Ajouter" + son menu "search-zone", // v2026.5.46 — barre de recherche, entre "Ajouter" et "Actualiser" "douchette-btn", "refresh-partial-btn", "refresh-btn", "abort-btn", "clear-cache-btn", "theme-toggle" // tout en bas de la sidebar ]; if (mode === "horizontal") { _moveElementsToSidebar(ELEMENTS_TO_RELOCATE); } else { _restoreElementsToTopbar(ELEMENTS_TO_RELOCATE); // v2026.5.46 — garantir que la ligne stats+recherche est correctement // reconstituée en classique : stats ET search-zone DANS #topline, dans le // bon ordre (stats puis search). Le restore générique pouvait inverser // l'ordre / les sortir de #topline → la grille « stats à gauche / barre au // milieu » ne s'appliquait plus (ils s'empilaient). const _tl = document.getElementById("topline"); const _st = document.getElementById("stats"); const _sz = document.getElementById("search-zone"); if (_tl && _st && _sz) { _tl.appendChild(_st); // stats en 1er (col 1) _tl.appendChild(_sz); // puis la recherche (col 2) // v2026.5.46 — le bouton coordinateur DOIT être ré-appendé EN DERNIER. // Sinon il reste 1er dans le DOM : la grille (auto-flow) place alors l'item // de colonne 3 en premier et POUSSE stats+recherche sur une 2e rangée // (la barre de recherche « tombait » sous la ligne). Ordre DOM = colonnes. const _co = document.getElementById("topline-coord"); if (_co) _tl.appendChild(_co); // col 3, à droite } } // recalage de l'empilement collant (topbar / bandeaux / ligne) if (typeof _srSyncStickyOffsets === "function") _srSyncStickyOffsets(); // libellé complet "Aujourd'hui" partout (vue classique + horizontale). const navToday = document.getElementById("nav-today"); if (navToday) { navToday.textContent = "Aujourd'hui"; } // v2026.5.46 — en (re)passant en horizontal, la règle des heures du // mode exact devient visible : la caler sur la géométrie réelle. if (typeof _alignExactRuler === "function") { requestAnimationFrame(() => { _alignExactRuler(); if (typeof _layoutExactGrid === "function") _layoutExactGrid(); if (typeof updateNowLine === "function") updateNowLine(); }); } } /** * v2026.5.36 : crée ou retrouve la sidebar, et y déplace les éléments listés. * Les éléments sont déplacés dans l'ordre du tableau. * * @author Quentin Rouiller */ function _moveElementsToSidebar(ids) { // Créer (ou retrouver) le wrapper qui contiendra sidebar + main côte à côte let wrapper = document.getElementById("horizontal-wrapper"); const mainEl = document.getElementById("main"); if (!wrapper) { wrapper = document.createElement("div"); wrapper.id = "horizontal-wrapper"; wrapper.className = "horizontal-wrapper"; // Placer le wrapper à la place de
, puis mettre
DEDANS if (mainEl && mainEl.parentNode) { mainEl.parentNode.insertBefore(wrapper, mainEl); wrapper.appendChild(mainEl); } else { LOG.warn("viewMode", "
introuvable, wrapper ajouté à "); document.body.appendChild(wrapper); } } // Créer la sidebar si elle n'existe pas, et la mettre EN PREMIER dans le wrapper let sidebar = document.getElementById("horizontal-sidebar"); if (!sidebar) { sidebar = document.createElement("aside"); sidebar.id = "horizontal-sidebar"; sidebar.className = "horizontal-sidebar"; wrapper.insertBefore(sidebar, wrapper.firstChild); } else if (sidebar.parentNode !== wrapper) { // Sidebar existe ailleurs, on la remet dans le wrapper wrapper.insertBefore(sidebar, wrapper.firstChild); } // Traitement spécial : .date-nav (classe, pas id) // v2026.5.37 : en vue horizontale, on décompose .date-nav pour : // - Mettre btn-today en haut // - Intercaler app-clock entre btn-today et date-custom // - Grouper les 2 flèches dans un wrapper #sidebar-arrows pour qu'elles // soient côte à côte et même largeur const dateNav = document.querySelector(".date-nav"); if (dateNav) { _memorizeOriginalParent(dateNav); sidebar.appendChild(dateNav); // Créer le wrapper des flèches si pas déjà fait, et y mettre prev + next let arrowsWrap = document.getElementById("sidebar-arrows"); if (!arrowsWrap) { arrowsWrap = document.createElement("div"); arrowsWrap.id = "sidebar-arrows"; sidebar.appendChild(arrowsWrap); } const prevBtn = document.getElementById("nav-prev"); const nextBtn = document.getElementById("nav-next"); if (prevBtn && !arrowsWrap.contains(prevBtn)) { _memorizeOriginalParent(prevBtn); arrowsWrap.appendChild(prevBtn); } if (nextBtn && !arrowsWrap.contains(nextBtn)) { _memorizeOriginalParent(nextBtn); arrowsWrap.appendChild(nextBtn); } } // Pour chaque ID listé, trouver et déplacer for (const id of ids) { if (id === "date-nav") continue; // déjà traité const el = document.getElementById(id); if (!el) { continue; } _memorizeOriginalParent(el); sidebar.appendChild(el); } } /** * v2026.5.36 : restaure chaque élément à son parent d'origine (mémorisé dans * data-orig-parent). Utilisé quand on revient en vue classique. * * @author Quentin Rouiller */ function _restoreElementsToTopbar(ids) { // v2026.5.37 : d'abord remettre les flèches dans .date-nav (avant de // restaurer .date-nav à son parent d'origine), puis supprimer le wrapper // #sidebar-arrows. const dateNav = document.querySelector(".date-nav"); const prevBtn = document.getElementById("nav-prev"); const nextBtn = document.getElementById("nav-next"); if (dateNav && prevBtn && !dateNav.contains(prevBtn)) { // Remettre prev en premier dans date-nav dateNav.insertBefore(prevBtn, dateNav.firstChild); } if (dateNav && nextBtn && !dateNav.contains(nextBtn)) { // Remettre next après date-custom-wrapper (position d'origine) : // l'ordre d'origine est [prev, date-custom-wrapper, next, today]. const dateCustomWrap = dateNav.querySelector(".date-custom-wrapper"); if (dateCustomWrap && dateCustomWrap.nextSibling) { dateNav.insertBefore(nextBtn, dateCustomWrap.nextSibling); } else { dateNav.appendChild(nextBtn); } } // Supprimer le wrapper des flèches (normalement vide maintenant) const arrowsWrap = document.getElementById("sidebar-arrows"); if (arrowsWrap) { arrowsWrap.remove(); } // Restaurer .date-nav à son parent d'origine if (dateNav) _restoreToOriginalParent(dateNav, ".date-nav"); for (const id of ids) { if (id === "date-nav") continue; const el = document.getElementById(id); if (!el) continue; _restoreToOriginalParent(el, "#" + id); } // Supprimer la sidebar si elle existe et est vide const sidebar = document.getElementById("horizontal-sidebar"); if (sidebar && sidebar.children.length === 0) { sidebar.remove(); } // Sortir
du wrapper et supprimer le wrapper const wrapper = document.getElementById("horizontal-wrapper"); if (wrapper) { const mainEl = wrapper.querySelector("#main"); if (mainEl && wrapper.parentNode) { wrapper.parentNode.insertBefore(mainEl, wrapper); } wrapper.remove(); } } function _memorizeOriginalParent(el) { if (el.dataset.origParent) return; // déjà mémorisé const parent = el.parentNode; if (!parent || !parent.id) { // Parent sans id : on mémorise par selector (parent tag + classes) let selector = parent.tagName.toLowerCase(); if (parent.className) { const cls = parent.className.split(/\s+/)[0]; if (cls) selector += "." + cls; } el.dataset.origParent = "SEL:" + selector; } else { el.dataset.origParent = "ID:" + parent.id; } // Mémoriser aussi l'index d'origine pour restaurer l'ordre const siblings = Array.from(parent.children); el.dataset.origIndex = String(siblings.indexOf(el)); } function _restoreToOriginalParent(el, label) { const orig = el.dataset.origParent; if (!orig) { return; } let parent = null; if (orig.startsWith("ID:")) { parent = document.getElementById(orig.substring(3)); } else if (orig.startsWith("SEL:")) { parent = document.querySelector(orig.substring(4)); } if (!parent) { LOG.warn("viewMode", `parent d'origine pour ${label} introuvable (orig=${orig})`); return; } // Insérer à l'index d'origine si possible const idx = parseInt(el.dataset.origIndex || "0", 10); const refChild = parent.children[idx] || null; if (refChild && refChild !== el) { parent.insertBefore(el, refChild); } else { parent.appendChild(el); } } // v5.0.0 : horloge HH:MM au milieu de la topbar. Mise à jour toutes les 30s // (les secondes ne sont pas affichées donc pas besoin d'un tick plus rapide). // v2026.5.27 : date courte "Jeudi 23.04.26" sur la même ligne que l'heure, // séparées par un gros point "•", même taille que l'heure. /** * Horloge HH:MM au milieu de la topbar, mise à jour chaque minute (depuis v5.0.0). * * @author Quentin Rouiller */ function initAppClock() { const el = document.getElementById("app-clock"); if (!el) return; const dateEl = document.getElementById("app-clock-date"); const timeEl = document.getElementById("app-clock-time"); const JOURS = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; let lastDateStr = ""; const tick = () => { const d = new Date(); const h = String(d.getHours()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0"); const timeStr = `${h}:${m}`; if (timeEl) timeEl.textContent = timeStr; else el.textContent = timeStr; // fallback si ancien markup // Date courte : "Jeudi 23.04.26" if (dateEl) { const jour = JOURS[d.getDay()]; const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yy = String(d.getFullYear()).slice(-2); const dateStr = `${jour} ${dd}.${mm}.${yy}`; if (dateStr !== lastDateStr) { dateEl.textContent = dateStr; lastDateStr = dateStr; } } // v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now" updateNowLine(); }; tick(); // Tick toutes les 30s : ça garantit une MAJ rapide au changement de min setInterval(tick, 30 * 1000); } // v2026.5.17 : met à jour le faux input date custom (ex: "Vendredi 24.04.2026") // Remplace l'ancien updateDatePickerDayLabel. L'input date natif reste présent // mais caché, et son onChange continue de déclencher le chargement. const DAY_NAMES_FULL = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; function updateDatePickerDayLabel(isoDate) { const el = document.getElementById("date-custom-label"); const dateCustom = document.getElementById("date-custom"); if (!el) return; if (!isoDate) { el.textContent = ""; if (dateCustom) dateCustom.classList.remove("is-today"); return; } try { const d = isoToDate(isoDate); const day = DAY_NAMES_FULL[d.getDay()]; const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yyyy = d.getFullYear(); // libellé "Vendredi 24.04.2026" — couleur fixe désormais (la // personnalisation se fait sur toute la topbar via les paramètres). el.textContent = `${day} ${dd}.${mm}.${yyyy}`; const isToday = (isoDate === todayISO()); if (dateCustom) dateCustom.classList.toggle("is-today", isToday); } catch (e) { el.textContent = ""; } } // v2026.5.17 : brancher le faux input date — clic dessus ouvre le vrai input // caché pour choisir une date. function initDateCustomPicker() { const custom = document.getElementById("date-custom"); const picker = document.getElementById("date-picker"); if (!custom || !picker) return; const openPicker = () => { try { if (typeof picker.showPicker === "function") { picker.showPicker(); } else { picker.focus(); picker.click(); } } catch (e) { picker.focus(); } }; custom.addEventListener("click", openPicker); custom.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openPicker(); } }); } // v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible // UNIQUEMENT quand on affiche aujourd'hui. Appelée au tick de l'horloge (30 s) // ET, juste après chaque rendu/resize/bascule de vue (le rendu // détruit la ligne avec #cards — le commentaire historique « cf renderFromData » // décrivait un appel qui n'existait plus). function updateNowLine() { const isToday = state.currentDate === todayISO(); // Retirer toutes les lignes existantes d'abord document.querySelectorAll(".timeline-now-line").forEach(el => el.remove()); if (!isToday) return; // Calculer la position en % sur la timeline (DAY_START à DAY_END) const now = new Date(); const nowMin = now.getHours() * 60 + now.getMinutes(); if (nowMin < DAY_START || nowMin > DAY_END) return; // hors plage affichée const pct = ((nowMin - DAY_START) / DAY_LEN) * 100; // Ajouter une ligne sur chaque barre timeline visible document.querySelectorAll(".timeline-bar").forEach(bar => { const line = document.createElement("div"); line.className = "timeline-now-line"; line.style.left = pct + "%"; bar.appendChild(line); }); // v2026.5.46 — mode exact. : UNE seule ligne continue, calée sur // la largeur réelle des bandes (alignement garanti). : saute les // absents. : plus de tête ni de trait sur la règle des heures — la // ligne démarre au haut des cartes. document.querySelectorAll(".exact-now-global, .exact-now-head").forEach(el => el.remove()); const isExactHoriz = (typeof HORIZONTAL_EXACT_MODE !== "undefined") && HORIZONTAL_EXACT_MODE && document.documentElement.classList.contains("view-horizontal"); if (isExactHoriz) { const container = document.getElementById("cards"); const firstRow = container ? container.querySelector(".iv-exact-row") : null; if (container && firstRow) { const contRect = container.getBoundingClientRect(); const rowRect = firstRow.getBoundingClientRect(); if (rowRect.width > 0 && contRect.width > 0) { const x = (rowRect.left - contRect.left) + (pct / 100) * rowRect.width; const allCards = container.querySelectorAll(".card"); const lastCard = allCards.length ? allCards[allCards.length - 1] : null; const bottomPx = lastCard ? (lastCard.offsetTop + lastCard.offsetHeight) : container.scrollHeight; // v2026.5.46 — la ligne du temps SAUTE les techs absents // (mêmes segments que les lignes d'heures). // v2026.5.46 — elle démarre AU HAUT DES CARTES : plus de point // ni de trait sur la règle des heures. const topPx = allCards.length ? allCards[0].offsetTop : 0; const segs = (typeof _exactAbsentSegments === "function") ? _exactAbsentSegments(container, topPx, Math.max(topPx, bottomPx)) : [[topPx, Math.max(topPx, bottomPx)]]; for (const [s, e] of segs) { if (e - s <= 0) continue; const line = document.createElement("div"); line.className = "exact-now-global"; line.style.left = Math.round(x) + "px"; line.style.top = s + "px"; line.style.height = (e - s) + "px"; container.appendChild(line); } // v2026.5.46 — plus de tête sur la règle (retirée à la demande). } } } } // ============================================================================ // Surveillance du timeout de session EasyVista // ============================================================================ /** * Vérifie si une nouvelle session EV est apparue (cookie PHPSESSID différent). * Cas couverts : * 1. Reconnexion volontaire (clic « Me reconnecter ») : state.reconnecting = true * 2. Reconnexion externe (user rouvre EV / SSO refait silencieusement) : * state.sessionExpired = true, mais l'user n'a jamais cliqué sur le bouton * * Sans le cas 2, l'user devait recharger l'extension pour que le viewer voie * qu'il était à nouveau connecté (il restait bloqué sur la bannière « Session * expirée » tant qu'il ne cliquait pas). */ async function _checkForRestoredSession() { if (!state.reconnecting && !state.sessionExpired && !state.sessionPending) return; try { const resp = await sendMessage({ type: "getSession" }); if (!resp || !resp.ok || !resp.session || !resp.session.phpsessid) return; const newId = resp.session.phpsessid; const oldPhpsessid = state.session ? state.session.phpsessid : null; if (newId === oldPhpsessid) return; // v2026.1.6 — QRO : anti-boucle « session SSO intermédiaire ». Un PHPSESSID // existe dans le cookie mais l'auth SSO n'a pas abouti → page de login, // planning vide → fetchPlanningForDate remet state.session = null → au tick // suivant ce MÊME PHPSESSID est revu comme « nouveau » (oldPhpsessid=null) // → relance toutes les 2 s à l'infini. On mémorise donc le PHPSESSID qui a // échoué et on ne le re-tente qu'après 30 s (un PHPSESSID DIFFÉRENT, lui, // est tenté immédiatement → vraie reconnexion détectée tout de suite). if (newId === state._failedRestoreId && (Date.now() - (state._failedRestoreAt || 0)) < 30000) return; LOG.info("session", "nouvelle session détectée :", newId); if (state.reconnectTimeoutId) { clearTimeout(state.reconnectTimeoutId); state.reconnectTimeoutId = null; } state.session = resp.session; if (resp.session.origin) state.lastKnownOrigin = resp.session.origin; state.reconnecting = false; state.sessionExpired = false; markSessionActivity(); _maybeRetryFetchUser("session_reconnected"); await loadForDate(state.currentDate); // Bilan : si après le chargement la session est restée invalide (SSO // intermédiaire), on marque ce PHPSESSID pour le throttler ; sinon on // confirme la reconnexion (et on retire les bannières) — pas de faux // « Reconnecté » à chaque tour. if (!state.session) { state._failedRestoreId = newId; state._failedRestoreAt = Date.now(); } else { state._failedRestoreId = null; hideReconnectingBanner(); hideSessionExpiredBanner(); hideReconnectFailedBanner(); showToast("Reconnecté", "Session EasyVista renouvelée"); } } catch (e) { // Silencieux : on réessayera au prochain tick / au prochain focus. } } /** * Initialise le tick du compteur de session (toutes les secondes). * Pas de requête réseau : décompte purement local depuis state.sessionExpireAt. * En parallèle, un polling 2s actif dès qu'on est en reconnexion OU en session * expirée, pour détecter automatiquement le retour de session, peu importe * comment l'user s'est reconnecté (bouton bandeau, ouverture manuelle EV, ou * SSO/IAM silencieux depuis l'extérieur). */ function initSessionTimer() { setInterval(() => { updateSessionIndicator(); }, 1000); setInterval(_checkForRestoredSession, 2000); // v2026.5.46 — recalage périodique du compteur sur le serveur (l'onglet // visible uniquement ; au retour de visibilité un recalage immédiat suit). setInterval(() => { if (!document.hidden) _sessionServerSync(); }, SESSION_SYNC_INTERVAL_MS); // calibrage initial : remplace l'hypothèse « 30 min » du boot par la vraie // valeur serveur dès que l'interface est posée. setTimeout(() => { _sessionServerSync({ force: true }); }, 5000); // Au retour de visibilité (l'user revient sur l'onglet du viewer) ou au // focus de la fenêtre, check immédiat — sans attendre le tick de 2s. Évite // de regarder une bannière « expirée » périmée pendant 2s alors qu'on est // déjà reconnecté. document.addEventListener("visibilitychange", () => { if (!document.hidden) { _checkForRestoredSession(); _sessionServerSync(); } }); window.addEventListener("focus", () => { _checkForRestoredSession(); _sessionServerSync(); }); } /** * Met à jour l'affichage du compteur session dans la topbar. * Règles : * - Session expirée ou reconnexion → compteur caché (bannière gère l'affichage) * - > 5 min restantes → compteur invisible * - 2-5 min → jaune, bouton "Prolonger" visible * - < 2 min → rouge pulse + modal automatique (une seule fois) * - <= 0 → déclenche l'état "expirée" */ // v2026.5.46 — vrai si un bandeau de session (expirée / EV inaccessible / // reconnexion / reconnexion échouée) est actuellement visible. function _anySessionBannerVisible() { try { return (typeof _SESSION_BANNER_IDS !== "undefined" ? _SESSION_BANNER_IDS : []) .some(id => { const b = document.getElementById(id); return b && !b.classList.contains("hidden"); }); } catch (e) { return false; } } // v2026.5.46 — REFONTE PROPRE : une SEULE notification de session, le chip // #app-session dans la topbar. Mis à jour chaque seconde (cf. initSessionTimer). // Règles : // - masqué si > 5 min restantes, ou pas de session connue ; // - masqué si session expirée / reconnexion en cours / un bandeau de session // est déjà affiché (jamais de doublon avec la banderole / page de reco) ; // - sinon : compteur LIVE MM:SS (jaune < 5 min, rouge < 2 min) + « Prolonger ». // Plus d'alertes glissantes ni de modal automatique (qui restaient collées et // masquaient le décompte). function updateSessionIndicator() { // v2026.5.46 — le popup badge (s'il est ouvert) tique CHAQUE seconde, // quel que soit l'état (avant : figé au-dessus de 5 min). const _popupSessLine = document.getElementById("user-name-popup-session"); if (_popupSessLine) _renderUserPopupSessionLine(_popupSessLine); const _popupConnLine = document.getElementById("user-name-popup-conn"); if (_popupConnLine) _renderUserPopupConnLine(_popupConnLine); const el = document.getElementById("app-session"); if (!el) return; if (state.sessionExpired || state.reconnecting || _anySessionBannerVisible() || !state.sessionExpireAt) { el.classList.add("hidden"); return; } const remainingMs = state.sessionExpireAt - Date.now(); if (remainingMs <= 0) { _sessionExpireVerify(); // v2026.5.46 — pendant la vérification (ou son backoff 30 s après // une erreur transitoire) : chip honnête à 00:00 rouge avec « Prolonger » // (un keep_connection réussi recale tout le compteur). el.classList.remove("hidden", "session-warn"); el.classList.add("session-critical"); const tEl = el.querySelector(".session-time"); if (tEl) { tEl.textContent = "00:00"; } else { el.innerHTML = ` 00:00 `; const bEl = el.querySelector(".session-extend-btn"); if (bEl) bEl.addEventListener("click", () => _sessionExtend(bEl, "🔄 Prolonger")); } return; } if (remainingMs > SESSION_WARN_THRESHOLD_MS) { el.classList.add("hidden"); return; } // Zone d'alerte (< 5 min) : un seul ping de confirmation serveur (le compteur // local peut être désynchronisé si EV a rafraîchi sans qu'on mette à jour // notre horloge). On NE se rappelle PAS récursivement (le tick 1s suffit). if (!state.sessionPingDone) { state.sessionPingDone = true; _sessionServerSync({ force: true }); } const mm = Math.floor(remainingMs / 60000); const ss = Math.floor((remainingMs % 60000) / 1000); const timeStr = `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; el.classList.remove("hidden", "session-warn", "session-critical"); el.classList.add(remainingMs <= SESSION_CRITICAL_THRESHOLD_MS ? "session-critical" : "session-warn"); el.innerHTML = ` ${timeStr} `; const extendBtn = el.querySelector(".session-extend-btn"); if (extendBtn) extendBtn.addEventListener("click", () => _sessionExtend(extendBtn, "🔄 Prolonger")); } /** * Appelée quand le compteur atteint 0 ou quand une requête EV échoue en * session expirée. Affiche la bannière "Session expirée" avec bouton "Me * reconnecter". */ function handleSessionExpired() { if (state.sessionExpired) return; state.sessionExpired = true; state.sessionExpireAt = null; LOG.warn("session", "session EV expirée"); showSessionExpiredBanner(); const el = document.getElementById("app-session"); if (el) el.classList.add("hidden"); } /** * Appelé au clic "Me reconnecter" dans la bannière. Ouvre EasyVista dans un * nouvel onglet (déclenche Windows SSO Kerberos automatique). Le polling * dans initSessionTimer détectera la nouvelle session et rechargera le viewer. * * v5.0.10 : utilise l'origine dynamique (interne ou externe selon le réseau). * v5.0.11 : détecte le contexte réseau avant d'ouvrir (si pas déjà connu) + * timeout 90s : si pas reconnecté après ce délai, propose choix manuel. * * @param {string} [forcedOrigin] - origine à forcer (pour le choix manuel * dans le fallback après timeout). Si absent : détection auto. */ async function triggerReconnect(forcedOrigin) { state.reconnecting = true; hideSessionExpiredBanner(); hideReconnectFailedBanner(); showReconnectingBanner(); // Annuler tout timeout précédent if (state.reconnectTimeoutId) { clearTimeout(state.reconnectTimeoutId); state.reconnectTimeoutId = null; } try { let origin = forcedOrigin; if (!origin) { // v5.0.11 : re-détecter le réseau à chaque expiration pour gérer le // cas où on a changé de contexte (bureau → TT) pendant la session. await detectNetworkContextAsync(true); origin = state.lastKnownOrigin || (state.session && state.session.origin) || "https://itsma.vd.ch"; } LOG.info("session", "triggerReconnect → ouverture de", origin); await sendMessage({ type: "openEasyVistaLogin", origin }); // Démarrer le timeout 90s : si pas reconnecté, basculer en mode "Échec" state.reconnectTimeoutId = setTimeout(() => { if (state.reconnecting && !state.session) { LOG.warn("session", "reconnexion timeout 90s → bannière échec"); state.reconnecting = false; hideReconnectingBanner(); showReconnectFailedBanner(); } }, RECONNECT_TIMEOUT_MS); } catch (err) { LOG.warn("session", "openEasyVistaLogin failed:", err); state.reconnecting = false; hideReconnectingBanner(); showSessionExpiredBanner(); } } /** * v5.0.11 : l'user clique "Annuler" pendant la reconnexion. On arrête le * polling/timeout et on revient à l'état "Session expirée" normal. */ function cancelReconnect() { if (state.reconnectTimeoutId) { clearTimeout(state.reconnectTimeoutId); state.reconnectTimeoutId = null; } state.reconnecting = false; hideReconnectingBanner(); hideReconnectFailedBanner(); showSessionExpiredBanner(); } // v5.0.0 : stockage des paramètres admin dans chrome.storage.local. // Clé unique : "admin_config". Contient la config éditable (équipe, // absences récurrentes, statuts etc.). Au 1er lancement : initialisée // avec les valeurs hardcodées actuelles. const ADMIN_CONFIG_KEY = "admin_config"; function getDefaultAdminConfig() { return { team: { ...TEAM }, // Clone pour ne pas modifier le hardcode recurringAbsences: { ...RECURRING_ABSENCES }, // idem groupId: "191", evOrigins: ["https://itsma.etat-de-vaud.ch", "https://itsma.vd.ch"], closedStatus: [...DEFAULT_CLOSED_STATUS], resolvedStatus: [...DEFAULT_RESOLVED_STATUS], cancelledStatus: [...DEFAULT_CANCELLED_STATUS], dayStart: 8, dayEnd: 18, cacheDays: 7, // v2026.5.45 — diagnostic disparitions : si true, on NE retire JAMAIS // les ghosts (verdict CANCELLED/REMOVE → on log seulement). Sert à valider // les verdicts cas par cas avant de basculer en prod. Décorrélé de // LOG.isDebug() (qui ne contrôle plus que la verbosité console). keepDisappearedGhosts: false }; } async function loadAdminConfig() { try { const stored = await chrome.storage.local.get(ADMIN_CONFIG_KEY); if (stored && stored[ADMIN_CONFIG_KEY]) { // Fusion avec les defaults (pour rajouter d'éventuelles nouvelles clés) return { ...getDefaultAdminConfig(), ...stored[ADMIN_CONFIG_KEY] }; } } catch (e) { LOG.warn("admin", "loadAdminConfig err", e); } return getDefaultAdminConfig(); } async function saveAdminConfig(cfg) { try { await chrome.storage.local.set({ [ADMIN_CONFIG_KEY]: cfg }); return true; } catch (e) { LOG.error("admin", "saveAdminConfig err", e); return false; } } // v5.0.0 : affiche le panel admin plein écran. // showAdminPanel accepte un id de section initiale ("appearance", // "team", "easyvista", "diagnostics", "about"). Défaut : "appearance". async function showAdminPanel(initialSection) { // Ferme un éventuel panel existant const existing = document.getElementById("admin-panel"); if (existing) existing.remove(); // Charge la config actuelle const cfg = await loadAdminConfig(); // Overlay plein écran const overlay = document.createElement("div"); overlay.id = "admin-panel"; overlay.className = "modal-overlay admin-overlay"; const card = document.createElement("div"); card.className = "admin-panel-card"; // En-tête const header = document.createElement("div"); header.className = "admin-header"; const title = document.createElement("h2"); title.textContent = "⚙ Administration"; title.className = "admin-title"; const closeBtn = document.createElement("button"); closeBtn.type = "button"; closeBtn.className = "admin-close-btn"; closeBtn.textContent = "×"; closeBtn.title = "Fermer (Échap)"; closeBtn.addEventListener("click", () => overlay.remove()); header.appendChild(title); header.appendChild(closeBtn); card.appendChild(header); // Navigation latérale (onglets) const body = document.createElement("div"); body.className = "admin-body"; const sidebar = document.createElement("nav"); sidebar.className = "admin-sidebar"; const content = document.createElement("div"); content.className = "admin-content"; // v2026.5.39 : Apparence en 1re. À propos en dernière (après Diagnostics). const sections = [ { id: "appearance", label: "Apparence", render: renderAdminSectionAppearance }, { id: "team", label: "Équipe", render: renderAdminSectionTeam }, { id: "easyvista", label: "EasyVista", render: renderAdminSectionEV }, { id: "sync", label: "Synchronisation",render: renderAdminSectionSync }, { id: "data", label: "Données", render: renderAdminSectionData }, { id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics }, { id: "about", label: "À propos", render: renderAdminSectionAbout } ]; // v2026.5.46 — onglet "Test (QRO)" RÉSERVÉ à Quentin Rouiller : permet de // se faire passer pour une autre personne (coordinateur) pour tester « Mes // actions » sans être réellement coordinateur. Caché pour tout autre utilisateur. if (_isQuentin()) { sections.push({ id: "qrotest", label: "🧪 Test (QRO)", render: renderAdminSectionImpersonate }); } // utilise la section demandée si valide, sinon "appearance" par défaut. let currentSection = sections.some(s => s.id === initialSection) ? initialSection : "appearance"; const navButtons = {}; for (const section of sections) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "admin-nav-btn"; btn.textContent = section.label; btn.dataset.section = section.id; if (section.id === currentSection) btn.classList.add("active"); btn.addEventListener("click", () => { currentSection = section.id; for (const k in navButtons) navButtons[k].classList.remove("active"); btn.classList.add("active"); content.innerHTML = ""; section.render(content, cfg, () => saveAndReload(cfg)); }); navButtons[section.id] = btn; sidebar.appendChild(btn); } body.appendChild(sidebar); body.appendChild(content); card.appendChild(body); overlay.appendChild(card); document.body.appendChild(overlay); // rendu initial = la section demandée (par défaut "appearance"). const initialSec = sections.find(s => s.id === currentSection) || sections[0]; initialSec.render(content, cfg, () => saveAndReload(cfg)); // Échap ferme le panel const escHandler = (e) => { if (e.key === "Escape") { overlay.remove(); document.removeEventListener("keydown", escHandler); } }; document.addEventListener("keydown", escHandler); async function saveAndReload(updatedCfg) { const ok = await saveAdminConfig(updatedCfg); if (ok) { // v2026.5.41 : application immédiate sans demander de recharger. // 1) repeupler TEAM / RECURRING_ABSENCES en mémoire depuis la nouvelle config. // 2) refetcher le planning du jour courant. // 3) le toast reste visible au-dessus du panel grâce au z-index 11000. await _initTeamFromConfig(); showToast("Config enregistrée", "Mise à jour du planning…"); try { await loadForDate(state.currentDate); } catch (e) { LOG.warn("admin", "reload planning err", e); } } else { showAlertModal({ title: "Erreur", message: "Impossible d'enregistrer la configuration.", buttons: [{ label: "OK", variant: "secondary", action: () => {} }] }); } } } // v5.0.0 : section "Équipe" du panel admin. // v5.0.1 : affiche la liste complète du groupe EasyVista (20+ personnes), // avec case à cocher "inclure dans la planification" pour chacune. function renderAdminSectionTeam(container, cfg, saveFn) { const h = document.createElement("h3"); h.textContent = "Équipe"; h.className = "admin-section-title"; container.appendChild(h); const desc = document.createElement("p"); desc.className = "admin-section-desc"; desc.textContent = "Sélectionnez les personnes qui doivent apparaître dans la planification. Les IDs viennent d'EasyVista (bouton Détecter) ou peuvent être saisis manuellement."; container.appendChild(desc); // ---- v2026.5.41 : Sélecteur de groupe EasyVista (SI-CSS / SI-EXT / …) ---- // Placé en tête de la section Équipe : c'est le groupe qui détermine quels // techniciens sont listés. Détection live depuis le