diff --git a/background.js b/background.js new file mode 100644 index 0000000..0cccf61 --- /dev/null +++ b/background.js @@ -0,0 +1,68 @@ +// background.js — Service worker (Manifest V3) +// +// Quand l'utilisateur clique sur l'icône de l'extension : +// 1. On vérifie qu'il est bien sur itsma.vd.ch +// 2. On injecte un script dans l'onglet qui récupère le HTML complet +// de la page + tous les liens vers les fiches d'intervention +// 3. On stocke ça dans chrome.storage.local +// 4. On ouvre un nouvel onglet avec viewer.html + +chrome.action.onClicked.addListener(async (tab) => { + try { + if (!tab.url || !tab.url.startsWith("https://itsma.vd.ch/")) { + await chrome.storage.local.set({ + planningError: + "Cette extension ne fonctionne que sur https://itsma.vd.ch/. " + + "Va d'abord sur la page du planning, puis reclique sur l'icône." + }); + await openViewer(); + return; + } + + // Injecter un script dans l'onglet pour extraire le HTML + const results = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: extractPlanningFromPage + }); + + const data = results[0]?.result; + if (!data || !data.html) { + await chrome.storage.local.set({ + planningError: + "Impossible de lire le contenu de la page. " + + "Assure-toi d'être bien sur la page du planning des techniciens." + }); + await openViewer(); + return; + } + + // Stocker le HTML capturé + l'URL de base pour les requêtes futures + await chrome.storage.local.set({ + planningHtml: data.html, + planningUrl: tab.url, + planningCapturedAt: Date.now(), + planningError: null + }); + + await openViewer(); + } catch (err) { + console.error("Erreur extension:", err); + await chrome.storage.local.set({ + planningError: "Erreur inattendue : " + (err?.message || String(err)) + }); + await openViewer(); + } +}); + +// Fonction injectée dans la page du planning +// (elle s'exécute dans le contexte de itsma.vd.ch, pas dans le service worker) +function extractPlanningFromPage() { + // Récupérer tout le HTML de la page + const html = document.documentElement.outerHTML; + return { html: html }; +} + +async function openViewer() { + const viewerUrl = chrome.runtime.getURL("viewer.html"); + await chrome.tabs.create({ url: viewerUrl }); +} diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..87db8b0 Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..b4fff8c Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..8e50109 Binary files /dev/null and b/icons/icon48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..f4cb770 --- /dev/null +++ b/manifest.json @@ -0,0 +1,37 @@ +{ + "manifest_version": 3, + "name": "Planning Techniciens — Vue claire", + "version": "1.0.0", + "description": "Réaffiche le planning du jour (itsma.vd.ch) avec pompier, absents et détails d'interventions à la demande.", + "permissions": [ + "activeTab", + "scripting", + "storage" + ], + "host_permissions": [ + "https://itsma.vd.ch/*" + ], + "action": { + "default_title": "Ouvrir la vue claire du planning" + }, + "background": { + "service_worker": "background.js" + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "web_accessible_resources": [ + { + "resources": [ + "viewer.html", + "viewer.js", + "viewer.css" + ], + "matches": [ + "https://itsma.vd.ch/*" + ] + } + ] +} diff --git a/viewer.css b/viewer.css new file mode 100644 index 0000000..867ecf0 --- /dev/null +++ b/viewer.css @@ -0,0 +1,360 @@ +/* viewer.css — Style de la vue claire du planning */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f5f7fa; + color: #1f2937; + line-height: 1.5; + min-height: 100vh; + padding-bottom: 40px; +} + +.hidden { + display: none !important; +} + +/* === Top bar === */ +.topbar { + background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%); + color: white; + padding: 20px 32px; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + flex-wrap: wrap; + gap: 16px; +} + +.topbar h1 { + font-size: 24px; + font-weight: 600; +} + +.subtitle { + font-size: 13px; + opacity: 0.85; + margin-top: 4px; +} + +.topbar-right { + display: flex; + gap: 12px; +} + +.topbar button { + background: rgba(255,255,255,0.15); + color: white; + border: 1px solid rgba(255,255,255,0.3); + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background 0.2s; +} + +.topbar button:hover { + background: rgba(255,255,255,0.25); +} + +.topbar button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* === Error zone === */ +.error-zone { + margin: 24px 32px; + padding: 16px 20px; + background: #fef2f2; + border: 1px solid #fca5a5; + color: #991b1b; + border-radius: 8px; +} + +/* === Summary cards (pompier, absents, stats) === */ +.summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 16px; + margin: 24px 32px; +} + +.summary-card { + background: white; + padding: 18px 22px; + border-radius: 10px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + border-left: 4px solid #e5e7eb; +} + +.summary-card.summary-pompier { + border-left-color: #dc2626; +} + +.summary-card.summary-absents { + border-left-color: #f59e0b; +} + +.summary-card.summary-stats { + border-left-color: #10b981; +} + +.summary-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; + font-weight: 600; + margin-bottom: 6px; +} + +.summary-value { + font-size: 20px; + font-weight: 600; + color: #111827; +} + +.summary-value .muted { + font-weight: 400; + color: #6b7280; + font-size: 14px; +} + +/* === Main content : cartes techniciens === */ +#main-content { + margin: 0 32px; +} + +.placeholder { + background: white; + padding: 48px 32px; + border-radius: 10px; + text-align: center; + color: #9ca3af; + font-size: 16px; +} + +.tech-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 20px; +} + +.tech-card { + background: white; + border-radius: 10px; + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.tech-card.tech-absent { + opacity: 0.7; + background: #fafafa; +} + +.tech-card.tech-pompier { + box-shadow: 0 0 0 2px #dc2626, 0 2px 6px rgba(220,38,38,0.2); +} + +.tech-header { + padding: 14px 18px; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; +} + +.tech-card.tech-pompier .tech-header { + background: #fef2f2; +} + +.tech-card.tech-absent .tech-header { + background: #fef3c7; +} + +.tech-name { + font-weight: 600; + font-size: 16px; + color: #111827; +} + +.tech-badge { + font-size: 11px; + padding: 3px 8px; + border-radius: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.tech-badge.badge-pompier { + background: #dc2626; + color: white; +} + +.tech-badge.badge-absent { + background: #f59e0b; + color: white; +} + +.tech-badge.badge-count { + background: #e5e7eb; + color: #4b5563; +} + +.tech-interventions { + padding: 8px 0; + flex: 1; +} + +.tech-empty { + padding: 24px; + text-align: center; + color: #9ca3af; + font-style: italic; + font-size: 14px; +} + +/* === Intervention items === */ +.intervention { + padding: 10px 18px; + border-bottom: 1px solid #f3f4f6; + cursor: pointer; + transition: background 0.15s; + position: relative; +} + +.intervention:last-child { + border-bottom: none; +} + +.intervention:hover { + background: #eff6ff; +} + +.interv-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} + +.interv-time { + font-weight: 600; + color: #1e40af; + font-size: 14px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.interv-ref { + font-size: 12px; + color: #6b7280; + font-family: ui-monospace, "SF Mono", Consolas, monospace; +} + +.interv-summary { + font-size: 13px; + color: #374151; + margin-top: 4px; + line-height: 1.4; +} + +.interv-contact { + font-weight: 500; +} + +.interv-location { + color: #6b7280; +} + +.interv-details { + margin-top: 12px; + padding: 12px; + background: #f9fafb; + border-radius: 6px; + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; + max-height: 400px; + overflow-y: auto; +} + +.interv-details.loading { + color: #9ca3af; + font-style: italic; +} + +.interv-details.error { + color: #991b1b; + background: #fef2f2; +} + +.interv-open-link { + display: inline-block; + margin-top: 8px; + font-size: 12px; + color: #2563eb; + text-decoration: none; +} + +.interv-open-link:hover { + text-decoration: underline; +} + +/* === Absence item (affichée dans la carte du tech absent) === */ +.absence-info { + padding: 12px 18px; + font-size: 14px; + color: #78350f; + background: #fef3c7; + border-left: 3px solid #f59e0b; +} + +.pompier-info { + padding: 12px 18px; + font-size: 14px; + color: #7f1d1d; + background: #fee2e2; + border-left: 3px solid #dc2626; + font-weight: 500; +} + +/* === Tooltip flottant === */ +.tooltip { + position: fixed; + background: #1f2937; + color: white; + padding: 12px 14px; + border-radius: 6px; + font-size: 13px; + line-height: 1.5; + max-width: 400px; + z-index: 1000; + pointer-events: none; + white-space: pre-wrap; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); +} + +/* === Responsive === */ +@media (max-width: 768px) { + .topbar { + padding: 16px 20px; + } + .summary, #main-content { + margin: 20px 16px; + } + .tech-grid { + grid-template-columns: 1fr; + } +} diff --git a/viewer.html b/viewer.html new file mode 100644 index 0000000..64a3d26 --- /dev/null +++ b/viewer.html @@ -0,0 +1,50 @@ + + + + + Planning Techniciens — Vue claire + + + +
+
+

📅 Planning du

+
Chargement…
+
+
+ + +
+
+ + + + + +
+
Le planning va s'afficher ici.
+
+ + + + + + + diff --git a/viewer.js b/viewer.js new file mode 100644 index 0000000..654e8a1 --- /dev/null +++ b/viewer.js @@ -0,0 +1,681 @@ +// viewer.js — Logique de la vue claire +// +// Étapes : +// 1. Lire le HTML capturé depuis chrome.storage.local +// 2. Parser les techniciens (emp_XXXXX dans le DOM) +// 3. Parser les événements (g_arr_player[N] dans le JS inline) +// 4. Calculer : pompier du jour, absents, interventions par tech +// 5. Afficher tout ça +// 6. Au survol / clic : charger la fiche détaillée via fetch() + +// ========================================================================== +// Configuration +// ========================================================================== + +// Règles fixes (techs avec horaires particuliers) +const RULES = { + // Pillonel, Olivier (ID 40944) est absent tous les vendredis + "40944": { + alwaysAbsentOn: [5], // 5 = vendredi (JS: 0=dim, 1=lun, ..., 5=ven, 6=sam) + reason: "Absent fixe le vendredi" + } +}; + +// Cache des fiches détaillées (persiste pour la session) +const detailsCache = new Map(); +// Fetch en cours (pour éviter de lancer 2x la même requête) +const detailsPromises = new Map(); + +// ========================================================================== +// Init +// ========================================================================== + +document.addEventListener("DOMContentLoaded", async () => { + document.getElementById("btn-refresh").addEventListener("click", refresh); + document.getElementById("btn-preload").addEventListener("click", preloadAll); + await loadFromStorage(); +}); + +async function loadFromStorage() { + const data = await chrome.storage.local.get([ + "planningHtml", "planningUrl", "planningCapturedAt", "planningError" + ]); + + if (data.planningError) { + showError(data.planningError); + return; + } + + if (!data.planningHtml) { + showError( + "Aucune donnée de planning disponible. " + + "Va sur la page du planning des techniciens sur itsma.vd.ch puis clique sur l'icône de l'extension." + ); + return; + } + + try { + const parsed = parsePlanning(data.planningHtml, data.planningUrl); + render(parsed, data.planningCapturedAt); + } catch (err) { + console.error(err); + showError("Erreur lors du parsing du planning : " + err.message); + } +} + +async function refresh() { + document.getElementById("subtitle").textContent = "Retour sur EasyVista pour actualiser…"; + // Inviter l'utilisateur à revenir sur l'onglet EasyVista et re-cliquer sur l'icône + // (on ne peut pas re-capturer automatiquement depuis le viewer) + showError( + "Pour actualiser : va sur l'onglet EasyVista, recharge le planning (F5), " + + "puis clique à nouveau sur l'icône de l'extension." + ); +} + +// ========================================================================== +// Parsing +// ========================================================================== + +function parsePlanning(html, sourceUrl) { + // Parser le HTML dans un DOM isolé pour pouvoir utiliser les sélecteurs CSS + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + // --- 1. Extraire les techniciens depuis
+ const techs = {}; // id -> { name, id } + for (const el of doc.querySelectorAll('div.support_list[id^="emp_"]')) { + const id = el.id.replace("emp_", ""); + // Le nom est le texte direct du div (après le checkbox) + const rawText = el.textContent.trim(); + // Nettoyer : enlever les \u00a0 ( ) + const name = rawText.replace(/\u00a0/g, " ").trim(); + if (name && /^\d+$/.test(id)) { + techs[id] = { id, name }; + } + } + + // --- 2. Extraire les événements depuis le JS inline (g_arr_player[N]) + // On parse les scripts + const events = {}; + for (const script of doc.querySelectorAll("script")) { + const src = script.textContent; + if (!src.includes("g_arr_player")) continue; + + // Pattern 1 : new action_player("event_id", "label", ...) + const pLabel = /g_arr_player\[(\d+)\]\s*=\s*new action_player\("(\d+)",\s*"([^"]*)"/g; + let m; + while ((m = pLabel.exec(src)) !== null) { + const idx = m[1]; + events[idx] = events[idx] || {}; + events[idx].eventId = m[2]; + events[idx].label = decodeText(m[3]); + } + + // Pattern 2 : assign_informations(tech_id, "title", "type", ...) + const pInfo = /g_arr_player\[(\d+)\]\.assign_informations\((\d+),\s*"([^"]*)",\s*"([^"]*)"/g; + while ((m = pInfo.exec(src)) !== null) { + const idx = m[1]; + events[idx] = events[idx] || {}; + events[idx].techId = m[2]; + events[idx].title = decodeText(m[3]); + events[idx].type = m[4]; + } + + // Pattern 3 : assign_date_time_informations (plusieurs arguments) + const pTime = /g_arr_player\[(\d+)\]\.assign_date_time_informations\(([^)]+)\)/g; + while ((m = pTime.exec(src)) !== null) { + const idx = m[1]; + const args = m[2]; + const parts = [...args.matchAll(/"([^"]*)"/g)].map(x => x[1]); + if (parts.length >= 10) { + events[idx] = events[idx] || {}; + events[idx].dateStart = parts[0]; + events[idx].dateEnd = parts[4]; + events[idx].timeStart = parts[8]; + events[idx].timeEnd = parts[9]; + } + } + } + + // --- 3. Extraire les liens vers les fiches détaillées depuis les AffBulle + // Les contenant target=XXXXXXXX donnent l'URL de la fiche + const eventLinks = {}; // eventId -> full URL + for (const a of doc.querySelectorAll('a[href*="target="]')) { + const href = a.getAttribute("href"); + const m = /target=(\d+)/.exec(href); + if (m) { + // Construire l'URL absolue à partir de sourceUrl + try { + const absoluteUrl = new URL(href, sourceUrl || "https://itsma.vd.ch/").href; + eventLinks[m[1]] = absoluteUrl; + } catch (e) { + // ignore + } + } + } + + // --- 4. Extraire les infobulles rapides pour chaque event + // Format : AffBulle(this, '...texte échappé...') + const eventBulles = {}; // eventId -> texte décodé + // Recherche dans le HTML brut pour les AffBulle (ils ne sont pas dans