// ============================================================================
// viewer.js — vue claire du planning techniciens (v2)
// ============================================================================
// Ce fichier fait tourner la page viewer.html. Il :
// 1. Récupère le HTML capturé (via background.js → chrome.storage.local)
// 2. Parse ce HTML pour extraire les techs + événements du jour
// 3. Construit une vue claire (une carte par tech, triées pompier > actifs > absents)
// 4. Gère les interactions : survol (tooltip enrichi), clic copie (ref dans presse-papier),
// bouton rafraîchir, toggle de thème.
// 5. Met en cache le parsing dans sessionStorage pour un rechargement instantané
// (le cache se vide automatiquement à la fermeture du navigateur).
//
// Pas de fetch de fiches détaillées : v2 affiche uniquement ce qui est dans
// la page du planning. La détection de statut clos viendra en v3.
// ============================================================================
// Configuration
// ============================================================================
// Équipe : ID EasyVista → nom affiché. Copié depuis la page du planning.
// Si la composition de l'équipe change, modifier cette map.
const TEAM = {
"76272": "Ciuppa, Mathieu",
"83725": "De Almeida Martins, Solange",
"66635": "Makonda, Yannick",
"92235": "Mamouni, Anas",
"90070": "Paisana, David",
"40944": "Pillonel, Olivier",
"72485": "Rosset, Pascal",
"86874": "Rouiller, Quentin"
};
// Règles fixes d'absence (personnes absentes récurrentes).
// Format : id tech → [liste de jours JS, 0=dim, 1=lun, ..., 5=ven, 6=sam]
const RECURRING_ABSENCES = {
"40944": [5] // Pillonel absent tous les vendredis
};
// Clés localStorage et sessionStorage
const LS_THEME = "planning_theme"; // "light" | "dark"
const SS_CACHE = "planning_cache_v2"; // dernier parsing JSON
// ============================================================================
// Mapping de catégorie → titre court affiché
// ============================================================================
// Les interventions EasyVista ont une catégorie longue type :
// "Demande de service/Place de travail/Poste de travail/Z - Options/Remplacement de matériel"
// On extrait un titre court lisible pour l'humain.
const CATEGORY_TO_TITLE = [
// [regex, titre court, clé de couleur CSS]
[/Arriv[ée]e\s+ou\s+mutation/i, "Livraison", "livraison"],
[/Accessoire\s+pour\s+PC/i, "Livraison", "livraison"],
[/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\s+de\s+mat[ée]riel/i, "Remplacement", "remplacement"],
];
/**
* Dérive un titre court à partir de la catégorie d'une intervention.
*/
function deriveShortTitle(iv) {
const raw = (iv.infobulle && iv.infobulle._raw) || "";
const catLine = raw.split(/\n+/).map(l => l.trim())
.find(l => /^(Demande de service|Incident|Changement|Probl[èe]me)\//.test(l));
if (!catLine) return "Autres";
for (const [regex, title] of CATEGORY_TO_TITLE) {
if (regex.test(catLine)) return title;
}
return "Autres";
}
/**
* Renvoie la clé de couleur ("livraison", "recup", "remplacement", "autre")
* pour appliquer en classe CSS.
*/
function deriveColorKey(iv) {
const raw = (iv.infobulle && iv.infobulle._raw) || "";
const catLine = raw.split(/\n+/).map(l => l.trim())
.find(l => /^(Demande de service|Incident|Changement|Probl[èe]me)\//.test(l));
if (!catLine) return "autre";
for (const [regex, , colorKey] of CATEGORY_TO_TITLE) {
if (regex.test(catLine)) return colorKey;
}
return "autre";
}
// ============================================================================
// État global
// ============================================================================
let currentData = null; // { techs, stats, captureTime }
// ============================================================================
// Boot
// ============================================================================
document.addEventListener("DOMContentLoaded", init);
async function init() {
initTheme();
bindTopbar();
bindTooltipHandlers();
// Récupérer le HTML capturé depuis le background
const stored = await chrome.storage.local.get([
"planningHtml", "planningError", "planningCapturedAt", "planningUrl"
]);
if (stored.planningError) {
showError(stored.planningError);
return;
}
if (!stored.planningHtml) {
// Essayer le cache de session
const cached = loadFromSessionCache();
if (cached) {
render(cached);
return;
}
showError(
"Pas de planning à afficher pour le moment. " +
"Va sur la page du planning dans itsma.vd.ch puis clique sur l'icône de l'extension."
);
return;
}
try {
const parsed = parsePlanning(stored.planningHtml);
parsed.captureTime = stored.planningCapturedAt || Date.now();
saveToSessionCache(parsed);
render(parsed);
} catch (err) {
console.error("Erreur parsing:", err);
showError(
"Impossible de parser le planning. " +
"Détail technique : " + (err?.message || String(err))
);
}
}
// ============================================================================
// Thème clair/sombre
// ============================================================================
function initTheme() {
const saved = localStorage.getItem(LS_THEME);
const theme = (saved === "light" || saved === "dark") ? saved : detectDefaultTheme();
applyTheme(theme);
}
function detectDefaultTheme() {
// Respecter la préférence système
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
const icon = document.getElementById("theme-icon");
if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙";
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme") || "light";
const next = current === "dark" ? "light" : "dark";
applyTheme(next);
localStorage.setItem(LS_THEME, next);
}
// ============================================================================
// Topbar (rafraîchir + thème)
// ============================================================================
function bindTopbar() {
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
document.getElementById("refresh-btn").addEventListener("click", refreshFromPlanning);
}
async function refreshFromPlanning() {
// On cherche un onglet itsma.vd.ch ouvert. Sinon on propose d'y aller.
// Le fonctionnement "normal" c'est : l'utilisateur clique sur l'icône depuis
// la page du planning, pas depuis ici. Ici on lui rappelle.
const tabs = await chrome.tabs.query({ url: "https://itsma.vd.ch/*" });
if (tabs.length === 0) {
alert(
"Ouvre d'abord l'onglet du planning sur itsma.vd.ch, " +
"puis reclique sur l'icône de l'extension (et non sur ce bouton)."
);
return;
}
// Réactiver l'onglet et demander à l'utilisateur de cliquer sur l'icône
await chrome.tabs.update(tabs[0].id, { active: true });
alert(
"Le planning a été rouvert. Clique maintenant sur l'icône de l'extension " +
"pour recapturer le planning."
);
}
// ============================================================================
// Cache sessionStorage
// ============================================================================
function saveToSessionCache(data) {
try {
sessionStorage.setItem(SS_CACHE, JSON.stringify(data));
} catch (e) {
console.warn("Cache session impossible (quota ?) :", e);
}
}
function loadFromSessionCache() {
try {
const raw = sessionStorage.getItem(SS_CACHE);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
// ============================================================================
// Parsing du HTML du planning
// ============================================================================
/**
* Parse le HTML complet de la page EasyVista et retourne une structure :
* {
* techs: [
* {
* id: "76272",
* name: "Ciuppa, Mathieu",
* interventions: [
* {
* playerIdx: 0,
* ref: "S260414_00100",
* label: "...",
* type: "intervention" | "absence",
* startTime: "08:00",
* endTime: "12:00",
* startDate: "17/04/2026",
* endDate: "17/04/2026",
* isPompier: boolean,
* infobulle: {
* horaire, ref, type, contact, beneficiaire, lieu, service,
* probleme, aFaire, materiel, deadline, description
* }
* }
* ]
* }
* ],
* targetDate: "17/04/2026",
* stats: { total, pompiers, absents }
* }
*/
function parsePlanning(html) {
// On bosse sur un DOM temporaire pour les sélecteurs, mais aussi sur le HTML
// brut pour attraper les données JS inline (g_arr_player[...])
const doc = new DOMParser().parseFromString(html, "text/html");
// --- 1. Repérer la date cible du planning ---
const targetDate = extractTargetDate(doc, html);
// --- 2. Extraire les définitions d'événements ---
// Format : g_arr_player[N] = new action_player("event_id", "label", ...);
// Puis ensuite : g_arr_player[N].assign_informations(tech_id, "title", type, ...)
// Puis : g_arr_player[N].assign_date_time_informations(...)
const players = extractPlayers(html);
// --- 3. Construire la map tech → interventions ---
const techMap = new Map();
for (const id of Object.keys(TEAM)) {
techMap.set(id, {
id,
name: TEAM[id],
interventions: []
});
}
for (const player of players) {
if (!player.techId || !techMap.has(player.techId)) continue;
// Ne garder que les interventions qui concernent le jour cible
if (!intersectsDate(player, targetDate)) continue;
techMap.get(player.techId).interventions.push(player);
}
// --- 4. Tri des interventions de chaque tech par heure ---
for (const tech of techMap.values()) {
tech.interventions.sort((a, b) => {
return (a.startTime || "").localeCompare(b.startTime || "");
});
}
// --- 5. Calculs statistiques et tri final ---
const techs = [...techMap.values()];
const stats = computeStats(techs, targetDate);
techs.sort((a, b) => {
const sa = sortKey(a, targetDate);
const sb = sortKey(b, targetDate);
return sa - sb;
});
return { targetDate, techs, stats };
}
function extractTargetDate(doc, html) {
// La date du planning est généralement dans le
ou dans un
/
// à côté du calendrier. On cherche un pattern JJ/MM/AAAA.
const title = doc.title || "";
let m = title.match(/(\d{2}\/\d{2}\/\d{4})/);
if (m) return m[1];
// Chercher dans le HTML brut
m = html.match(/planning[^<]*?(\d{2}\/\d{2}\/\d{4})/i);
if (m) return m[1];
// Chercher des patterns assign_date_time_informations pour déduire la date majoritaire
const dates = [];
const re = /assign_date_time_informations\s*\(\s*"(\d{2}\/\d{2}\/\d{4})"/g;
let match;
while ((match = re.exec(html)) !== null) {
dates.push(match[1]);
}
if (dates.length) {
// Prendre la date la plus fréquente
const counts = {};
dates.forEach(d => counts[d] = (counts[d] || 0) + 1);
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
}
// Fallback : aujourd'hui
return todayAsDDMMYYYY();
}
function todayAsDDMMYYYY() {
const d = new Date();
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
return `${dd}/${mm}/${d.getFullYear()}`;
}
/**
* Extraction des g_arr_player du HTML brut.
* Structure réelle EasyVista :
* - new action_player("", "