/**
* 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 FroSteel
* Licensed under the MIT License — see LICENSE file in the project root.
*
* @author FroSteel
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
*/
// ============================================================================
// viewer.js — vue claire du planning techniciens
// ============================================================================
// 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) => {
LOG.exception("global", "uncaught error",
event.error || new Error(event.message || "unknown"),
{ filename: event.filename, lineno: event.lineno, colno: event.colno });
// Toast non-bloquant — on suppose que showToast est dispo (init en haut)
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() });
// v2026.5.45 — Refonte topbar + personnalisation Apparence + onboarding équipe.
//
// Topbar (vue classique) :
// - Sélecteur de date du planning au CENTRE absolu, ne bouge plus quand le
// bouton "Arrêter" apparaît à droite pendant un rafraîchissement.
// - Bouton "Aujourd'hui" en toutes lettres + horloge contextuelle discrète.
//
// Personnalisation (Paramètres → Apparence) :
// - Couleur de la topbar : 12 presets cliquables + picker custom + champ hex.
// La couleur du texte s'adapte automatiquement (clair/foncé) selon la
// luminance choisie pour rester lisible.
// - Police de l'application : 28 choix (Arial, Helvetica, Verdana, Tahoma,
// Trebuchet, Calibri, Segoe UI, Times New Roman, Georgia, Cambria,
// Garamond, Palatino, Courier, Consolas, Comic Sans, Impact, …) appliquée
// à TOUTE la page (topbar, cards, popups, panel admin).
//
// Vue horizontale :
// - Bloc Aujourd'hui + horloge + date sélectionnée empilés en sidebar dans
// le même cadre, comme la vue classique.
// - Barre verte verticale à droite des mini-cards quand le ticket est
// officiellement clôturé / résolu (✓✓), avec décalage du ✓✓ pour ne pas
// chevaucher la barre.
// - Quand l'utilisateur a choisi une couleur de topbar, la sidebar prend
// aussi la même couleur, avec icônes / boutons / séparateurs / theme-toggle
// translucides cohérents (color-mix).
//
// Stats globales :
// - Nouveau compteur "X faits / Y clos" entre "(matin · après-midi)" et
// "tech. dispo". En vue classique, "//" remplace "·" entre clos et dispo.
// En sidebar horizontale, c'est une vraie barre horizontale de séparation.
//
// Onboarding équipe :
// - Quand aucun technicien n'est sélectionné (1ʳᵉ install ou cfg vide),
// une carte centrée propre s'affiche avec icône, titre, description et
// bouton "Ouvrir paramètres" qui pointe directement sur la section Équipe
// du panel admin. Centrée verticalement et horizontalement dans la zone
// disponible (entre la sidebar et le bord droit en horizontal).
//
// Bugfix divers :
// - Absences récurrentes affichées au switch de groupe puis retour (n'étaient
// plus cochées dans la table même si la donnée existait toujours).
// - Barre de progression / bannière session expirée : suivent la hauteur
// dynamique de la topbar via --topbar-height (ne se cachent plus dessous
// en scrollant).
// - Flèche " ↗" retirée des références cliquables dans le tooltip.
LOG.warn("planification",
`🩺 Planification v${LOG.version()} — Mode ${LOG.isDebug() ? "DIAGNOSTIC" : "PROD"}.`);
// on n'analyse que les ghosts du tech FroSteel pour se concentrer.
// Les ghosts des autres techs sont loggés comme "skipped" et restent visibles.
const DIAGNOSTIC_TECH_FILTER_ID = "86874"; // FroSteel
// v2026.5.40 r22 : pattern simple et fiable pour fermer le tooltip
// - mouseleave row → timer 500ms (démarré dans hideTooltip)
// - mouseenter popup → clearTimeout (annule la fermeture en cours)
// - mouseleave popup → hideTooltip (démarre un nouveau timer 500ms)
// - mouseenter row → showTooltip (annule la fermeture aussi)
// Pas de flags hoveredInBulle/hoveredInRow, pas de watchdog mousemove,
// pas de elementFromPoint : juste des events natifs qui sont fiables.
// Source : pattern recommandé dans Tippy.js, jQuery PowerTip, MDN Popover API.
// ============================================================================
// 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 = {};
let RECURRING_ABSENCES = {};
async function _initTeamFromConfig() {
try {
const cfg = await loadAdminConfig();
TEAM = cfg.team || {};
RECURRING_ABSENCES = cfg.recurringAbsences || {};
// R12v : 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();
}
} catch (e) {
console.warn("[boot] _initTeamFromConfig err", e);
}
}
// R12v : 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];
// Clés de stockage
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
// 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, // résultat parsé (techs, stats, ...)
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,
// 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
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
// ─── Annulation coopérative d'un refresh manuel (v3.1) ──────────────────────
// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent
// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si
// l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement.
//
// v3.2 : on ajoute une "abortPromise" par refresh. loadForDate race cette
// promesse avec son Promise.all, donc dès qu'on clique Arrêter, loadForDate
// sort immédiatement (masque le bouton, fait un toast), même si les fetches
// en cours continuent silencieusement. Le changement de token les rend
// inoffensifs (ils ne peuvent plus écrire le cache ni updater le DOM).
let currentRefreshToken = 0;
let abortedToken = -1;
let abortResolvers = new Map(); // token → resolve fn of the abort promise
function startNewRefresh() {
currentRefreshToken++;
return currentRefreshToken;
}
function makeAbortPromise(myToken) {
return new Promise(resolve => {
abortResolvers.set(myToken, resolve);
});
}
function abortCurrentRefresh() {
abortedToken = currentRefreshToken;
// Réveiller tous les loadForDate en attente (normalement un seul)
for (const [token, resolve] of abortResolvers) {
if (token <= currentRefreshToken) {
resolve("aborted");
abortResolvers.delete(token);
}
}
// 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
if (state.currentData && state.currentData.techs) {
for (const tech of state.currentData.techs) {
for (const iv of tech.interventions || []) {
if (iv._disappearChecking) {
iv._disappearChecking = 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 (abortedToken === currentRefreshToken) return true;
if (typeof myToken === "number" && myToken < currentRefreshToken) return true;
return false;
}
function cleanupAbortResolver(myToken) {
abortResolvers.delete(myToken);
}
// ============================================================================
// Boot
// ============================================================================
document.addEventListener("DOMContentLoaded", init);
async function init() {
await initTheme();
// 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();
// 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
// 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) {
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;
}
console.log("[viewer] réseau détecté :", resp.context, "→", resp.origin);
}
} catch (e) {
console.warn("[viewer] détection réseau échouée", e);
}
}
async function refreshSessionAndLoad() {
const resp = await sendMessage({ type: "getSession" });
if (!resp.ok || !resp.session) {
// v4.2.5 : si un cache existe pour le jour demandé, on l'affiche avec
// une bannière "session expirée" sticky au-dessus. Sinon écran plein.
const cached = await readCache(state.currentDate);
if (cached) {
renderFromData({
techs: cached.techs,
targetDate: state.currentDate,
captureTime: cached.savedAt || null,
source: "cache"
});
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 FroSteel
*/
async function fetchAndShowCurrentUser() {
const attemptId = _currentUserRetryCount + 1;
console.log(`[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.
console.warn("[currentUser] badge DOM introuvable — abandon");
return;
}
let success = false;
let errorReason = null;
try {
const resp = await sendMessage({ type: "fetchCurrentUser" });
console.log("[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);
console.log(`[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;
success = true;
_currentUserRetryCount = 0; // reset compteur au succès
}
}
} catch (err) {
errorReason = "exception: " + String(err);
console.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.
console.warn(`[currentUser] échec (raison: ${errorReason}) — badge reste en état "?"`);
// 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++;
console.log(`[currentUser] retry programmé : ${_currentUserRetryCount}/${_CURRENT_USER_MAX_RETRIES} dans ${_CURRENT_USER_RETRY_DELAY_MS / 1000}s`);
setTimeout(() => {
fetchAndShowCurrentUser();
}, _CURRENT_USER_RETRY_DELAY_MS);
} else {
console.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 FroSteel
*/
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;
}
console.log(`[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.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 "FRS/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 FroSteel";
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;
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;
}
// ============================================================================
// 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) {
console.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
const absenceBtn = document.getElementById("absence-btn");
if (absenceBtn) absenceBtn.addEventListener("click", showAbsenceModal);
const douchetteBtn = document.getElementById("douchette-btn");
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 = `
Vider le cache
Le cache stocke les données des interventions pour éviter de
re-télécharger à chaque ouverture. Que voulez-vous supprimer ?
`;
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(CACHE_PREFIX + state.currentDate);
await loadForDate(state.currentDate, { forceRefetch: true });
return;
}
if (action === "clear-all") {
close();
// Supprimer toutes les clés CACHE_PREFIX*
const all = await chrome.storage.local.get(null);
const toRemove = Object.keys(all).filter(k => k.startsWith(CACHE_PREFIX));
if (toRemove.length) {
await chrome.storage.local.remove(toRemove);
}
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.
*/
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) { console.error("[alert-modal]", e); }
}
});
actions.appendChild(b);
if (i === 0) setTimeout(() => b.focus(), 50);
});
card.appendChild(actions);
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 "FRS / 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 FRS et numéro de version.
*
* @author FroSteel
*/
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 = `FRS${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 FroSteel
*/
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)
"absence-btn",
"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);
}
// libellé complet "Aujourd'hui" partout (vue classique + horizontale).
const navToday = document.getElementById("nav-today");
if (navToday) {
navToday.textContent = "Aujourd'hui";
}
}
/**
* 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 FroSteel
*/
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 {
console.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 FroSteel
*/
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) {
console.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 FroSteel
*/
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 à chaque tick de l'horloge
// + après chaque render (cf renderFromData).
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);
});
}
// ============================================================================
// Surveillance du timeout de session EasyVista
// ============================================================================
/**
* 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 uniquement en reconnexion, pour détecter
* dès que l'user s'est reconnecté dans l'onglet EasyVista ouvert.
*/
function initSessionTimer() {
setInterval(() => {
updateSessionIndicator();
}, 1000);
// Polling actif UNIQUEMENT pendant une reconnexion pour détecter le nouveau
// PHPSESSID dès qu'il apparaît dans un onglet EV. Rien d'envoyé au serveur
// en dehors de ça.
setInterval(async () => {
if (!state.reconnecting) return;
try {
const resp = await sendMessage({ type: "getSession" });
if (resp && resp.ok && resp.session && resp.session.phpsessid) {
const oldPhpsessid = state.session ? state.session.phpsessid : null;
if (resp.session.phpsessid !== oldPhpsessid) {
console.log("[session] nouvelle session détectée après reconnexion :", resp.session.phpsessid);
// v5.0.11 : annuler le timeout de reconnexion puisque ça a marché
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;
hideReconnectingBanner();
hideSessionExpiredBanner();
hideReconnectFailedBanner();
markSessionActivity();
showToast("Reconnecté", "Session EasyVista renouvelée");
// v2026.5.34 : relancer fetchUser tout de suite (au lieu d'attendre
// le retry de 60s) — la session vient d'être renouvelée, c'est le
// meilleur moment pour récupérer le user.
_maybeRetryFetchUser("session_reconnected");
// Recharger le planning à la date courante sans perdre la position
await loadForDate(state.currentDate);
}
}
} catch (e) {
// Silencieux, on réessayera au prochain tick
}
}, 2000);
}
/**
* 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"
*/
function updateSessionIndicator() {
const el = document.getElementById("app-session");
if (!el) return;
if (state.sessionExpired || state.reconnecting) {
el.classList.add("hidden");
return;
}
if (!state.sessionExpireAt) {
el.classList.add("hidden");
return;
}
const remainingMs = state.sessionExpireAt - Date.now();
if (remainingMs <= 0) {
handleSessionExpired();
return;
}
if (remainingMs > SESSION_WARN_THRESHOLD_MS) {
el.classList.add("hidden");
return;
}
// Zone d'alerte (< 5 min)
// v5.0.9 : avant d'afficher l'alerte, on fait UN ping de confirmation
// pour vérifier que le serveur est bien d'accord (compteur local parfois
// désynchronisé si plusieurs requêtes EV ont rafraîchi sans qu'on update
// notre horloge). Une seule fois par cycle.
if (!state.sessionPingDone) {
state.sessionPingDone = true;
sendMessage({ type: "getSessionRemaining" }).then(resp => {
if (resp && resp.ok && typeof resp.remainingMs === "number") {
state.sessionExpireAt = Date.now() + resp.remainingMs;
updateSessionIndicator();
}
}).catch(e => LOG.warn("session", "getSessionRemaining a échoué (silencieux)", { err: e && e.message }));
// En attendant, on continue avec l'estimation locale
}
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");
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) {
el.classList.add("session-critical");
if (!state._criticalModalShown) {
state._criticalModalShown = true;
showSessionCriticalModal();
}
} else {
el.classList.add("session-warn");
state._criticalModalShown = false;
}
el.innerHTML = `
⏱${timeStr}
`;
const extendBtn = el.querySelector(".session-extend-btn");
if (extendBtn) {
// v2026.5.38 : addEventListener plutôt que .onclick — meilleur tracage
// si plus tard on veut detach le handler proprement.
extendBtn.addEventListener("click", async () => {
extendBtn.disabled = true;
extendBtn.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;
state._criticalModalShown = false;
showToast("Session prolongée", "30 minutes de plus");
updateSessionIndicator();
} else {
throw new Error((resp && resp.error) || "erreur inconnue");
}
} catch (err) {
extendBtn.disabled = false;
extendBtn.textContent = "🔄 Prolonger";
if (err.message === "session_expired" || err.message === "no_session") {
handleSessionExpired();
}
}
});
}
// v2026.5.17 : si le popup user-badge est ouvert, rafraîchir la ligne "Session : MM:SS"
const sessLineInPopup = document.getElementById("user-name-popup-session");
if (sessLineInPopup) _renderUserPopupSessionLine(sessLineInPopup);
// v2026.5.17 : popup d'alerte "glissante" depuis le haut gauche
// - à 5 min : alerte standard (si pas encore affichée ni "plus tard")
// - à 2 min : alerte urgente (si pas encore affichée)
_handleSessionSlideAlerts(remainingMs);
}
/**
* v2026.5.17 : gère les 2 alertes popup glissant depuis le haut gauche.
* - Première alerte à 5 min (SESSION_WARN_THRESHOLD_MS). Reste affichée jusqu'à
* action manuelle (Prolonger ou Plus tard).
* - Si "Plus tard", une 2e alerte plus urgente réapparait à 2 min
* (SESSION_CRITICAL_THRESHOLD_MS).
*/
function _handleSessionSlideAlerts(remainingMs) {
if (remainingMs == null) return;
// Alerte à 5 min
if (remainingMs <= SESSION_WARN_THRESHOLD_MS
&& remainingMs > SESSION_CRITICAL_THRESHOLD_MS
&& !state._slideAlert5minShown) {
state._slideAlert5minShown = true;
_showSessionSlideAlert({ urgent: false });
}
// Alerte à 2 min (si déjà "Plus tard" sur l'alerte 5 min OU alerte 5 min jamais affichée)
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS
&& !state._slideAlert2minShown) {
state._slideAlert2minShown = true;
// Cacher éventuellement l'ancienne alerte pour ré-afficher la nouvelle
_hideSessionSlideAlert();
_showSessionSlideAlert({ urgent: true });
}
}
function _showSessionSlideAlert({ urgent }) {
// Retirer l'ancienne si elle existe
_hideSessionSlideAlert();
const el = document.createElement("div");
el.id = "session-slide-alert";
el.className = "session-slide-alert" + (urgent ? " urgent" : "");
const title = urgent ? "⚠ Session expire dans 2 minutes !" : "⏱ Session expire dans 5 minutes";
el.innerHTML = `
${title}
`;
document.body.appendChild(el);
// Déclenche l'animation de slide-in (petite tempo pour que la transition parte)
requestAnimationFrame(() => el.classList.add("visible"));
// Action "Prolonger" — v2026.5.40 r12 :
// - succès → fermer l'alerte + toast bas-droite "Session prolongée"
// - échec → fermer l'alerte + toast erreur + ouvrir la bannière /
// écran de reconnexion EasyVista
el.querySelector(".session-slide-alert-extend").addEventListener("click", async () => {
const extendBtn = el.querySelector(".session-slide-alert-extend");
extendBtn.disabled = true;
extendBtn.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;
state._criticalModalShown = false;
state._slideAlert5minShown = false;
state._slideAlert2minShown = false;
showToast("Session prolongée", "30 minutes de plus");
updateSessionIndicator();
_hideSessionSlideAlert();
} else {
throw new Error((resp && resp.error) || "erreur inconnue");
}
} catch (err) {
// Échec : on ferme cette mini-alerte, on affiche un toast d'erreur,
// puis on bascule sur la bannière "session expirée" (laquelle propose
// un bouton pour ouvrir EasyVista et se reconnecter).
_hideSessionSlideAlert();
showToast("Échec prolongation", "Reconnectez-vous à EasyVista");
LOG.warn("session", "extendSession a échoué", { err: err && err.message });
if (typeof handleSessionExpired === "function") {
handleSessionExpired();
}
}
});
// Action "Plus tard"
el.querySelector(".session-slide-alert-later").addEventListener("click", () => {
_hideSessionSlideAlert();
// Si c'est l'alerte 5 min qu'on dismissa, l'alerte 2 min reviendra
// automatiquement (state._slideAlert2minShown toujours false).
});
}
function _hideSessionSlideAlert() {
const el = document.getElementById("session-slide-alert");
if (!el) return;
el.classList.remove("visible");
setTimeout(() => { try { el.remove(); } catch (e) {} }, 250);
}
/**
* 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;
state._criticalModalShown = false;
console.warn("[session] session EV expirée");
showSessionExpiredBanner();
const el = document.getElementById("app-session");
if (el) el.classList.add("hidden");
}
/**
* Modal auto quand < 2 min : alerte visuelle forte.
*/
function showSessionCriticalModal() {
showAlertModal({
title: "⚠️ Session EasyVista expire bientôt",
message: "Votre session EasyVista expire dans moins de 2 minutes. Cliquez sur « Prolonger » pour éviter d'être déconnecté.",
buttons: [
{ label: "Ignorer", variant: "secondary", action: () => {} },
{
label: "🔄 Prolonger maintenant",
variant: "primary",
action: async () => {
try {
const resp = await sendMessage({ type: "extendSession" });
if (resp && resp.ok && typeof resp.remainingMs === "number") {
state.sessionExpireAt = Date.now() + resp.remainingMs;
state.sessionPingDone = false;
state._criticalModalShown = false;
showToast("Session prolongée", "30 minutes de plus");
updateSessionIndicator();
}
} catch (e) {
handleSessionExpired();
}
}
}
]
});
}
/**
* 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";
}
console.log("[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) {
console.warn("[session] reconnexion timeout 90s → bannière échec");
state.reconnecting = false;
hideReconnectingBanner();
showReconnectFailedBanner();
}
}, RECONNECT_TIMEOUT_MS);
} catch (err) {
console.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
};
}
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) {
console.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) {
console.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: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics },
{ id: "about", label: "À propos", render: renderAdminSectionAbout }
];
// 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) {
console.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