diff --git a/background.js b/background.js index 0cccf61..3dd483d 100644 --- a/background.js +++ b/background.js @@ -1,11 +1,10 @@ // 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 +// Au clic sur l'icône : +// 1. Vérifier qu'on est bien sur itsma.vd.ch (sinon message d'erreur) +// 2. Injecter un script dans la page qui récupère le HTML complet +// 3. Stocker dans chrome.storage.local (persistant, sert de "dernière capture") +// 4. Ouvrir viewer.html chrome.action.onClicked.addListener(async (tab) => { try { @@ -19,7 +18,6 @@ chrome.action.onClicked.addListener(async (tab) => { return; } - // Injecter un script dans l'onglet pour extraire le HTML const results = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: extractPlanningFromPage @@ -30,13 +28,12 @@ chrome.action.onClicked.addListener(async (tab) => { 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." + "Assure-toi d'être 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, @@ -54,12 +51,8 @@ chrome.action.onClicked.addListener(async (tab) => { } }); -// 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 }; + return { html: document.documentElement.outerHTML }; } async function openViewer() { diff --git a/manifest.json b/manifest.json index f4cb770..69e60df 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "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.", + "version": "2.0.0", + "description": "Réaffiche le planning du jour (itsma.vd.ch) avec pompier, absents, tooltips enrichis et thème clair/sombre.", "permissions": [ "activeTab", "scripting", diff --git a/viewer.css b/viewer.css index 867ecf0..24908d8 100644 --- a/viewer.css +++ b/viewer.css @@ -1,360 +1,601 @@ -/* viewer.css — Style de la vue claire du planning */ +/* ========================================================================== + Thème clair (défaut) + ========================================================================== */ +:root { + --bg: #f4f5f7; + --bg-elevated: #ffffff; + --bg-muted: #f0f1f3; + --bg-hover: #f7f8fa; + --border: #e2e4e8; + --border-strong: #cfd3da; + --text: #1a1f2b; + --text-muted: #5b6573; + --text-faint: #8892a0; + --accent: #0f4f8b; + --accent-soft: #e1ecf7; + --danger: #b03030; + --danger-soft: #fbe6e6; + --warn: #b87a00; + --warn-soft: #fff2d6; + --ok: #2e7b4a; + --ok-soft: #dff0e4; -* { - box-sizing: border-box; + /* Palette par type d'intervention (clair & lisible) */ + --c-livraison: #2563eb; /* bleu */ + --c-livraison-soft: #dbeafe; + --c-recup: #16a34a; /* vert */ + --c-recup-soft: #dcfce7; + --c-remplacement: #ea580c; /* orange */ + --c-remplacement-soft: #fed7aa; + --c-autre: #6b7280; /* gris */ + --c-autre-soft: #e5e7eb; + + --shadow: 0 1px 3px rgba(20, 30, 50, 0.06), 0 1px 2px rgba(20, 30, 50, 0.04); + --shadow-hover: 0 2px 8px rgba(20, 30, 50, 0.08); + --radius: 8px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace; +} + +[data-theme="dark"] { + --bg: #16181d; + --bg-elevated: #21242b; + --bg-muted: #1c1f25; + --bg-hover: #2a2e36; + --border: #2e333c; + --border-strong: #414754; + --text: #e6e8ec; + --text-muted: #9ba2ad; + --text-faint: #6a727e; + --accent: #5ea8e8; + --accent-soft: #223348; + --danger: #e87878; + --danger-soft: #3b2626; + --warn: #d9a753; + --warn-soft: #3a2e1a; + --ok: #78c59a; + --ok-soft: #1f3a2b; + + /* Palette sombre — tons plus doux mais toujours distincts */ + --c-livraison: #60a5fa; + --c-livraison-soft: #1e3a5f; + --c-recup: #4ade80; + --c-recup-soft: #14432a; + --c-remplacement: #fb923c; + --c-remplacement-soft: #4a2512; + --c-autre: #9ca3af; + --c-autre-soft: #2a2e36; + + --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-hover: 0 2px 10px rgba(0, 0, 0, 0.4); +} + +/* ========================================================================== + Base + ========================================================================== */ +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #f5f7fa; - color: #1f2937; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14px; line-height: 1.5; - min-height: 100vh; - padding-bottom: 40px; } -.hidden { - display: none !important; -} +.hidden { display: none !important; } -/* === Top bar === */ +/* ========================================================================== + Topbar + ========================================================================== */ .topbar { - background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%); - color: white; - padding: 20px 32px; + position: sticky; + top: 0; + z-index: 10; 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; + padding: 10px 20px; + background: var(--bg-elevated); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.topbar-left { + display: flex; + align-items: baseline; + gap: 14px; } .topbar h1 { - font-size: 24px; + margin: 0; + font-size: 18px; font-weight: 600; + color: var(--text); } -.subtitle { - font-size: 13px; - opacity: 0.85; - margin-top: 4px; +.capture-info { + font-size: 12px; + color: var(--text-muted); } .topbar-right { display: flex; - gap: 12px; + gap: 8px; } -.topbar button { - background: rgba(255,255,255,0.15); - color: white; - border: 1px solid rgba(255,255,255,0.3); - padding: 8px 16px; +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-muted); + color: var(--text); + border: 1px solid var(--border); border-radius: 6px; + font-size: 13px; + font-family: inherit; cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: background 0.2s; + transition: background 0.1s, border-color 0.1s; } -.topbar button:hover { - background: rgba(255,255,255,0.25); +.btn:hover { + background: var(--bg-hover); + border-color: var(--border-strong); } -.topbar button:disabled { - opacity: 0.5; - cursor: not-allowed; +.btn:active { + transform: translateY(1px); } -/* === Error zone === */ -.error-zone { - margin: 24px 32px; - padding: 16px 20px; - background: #fef2f2; - border: 1px solid #fca5a5; - color: #991b1b; - border-radius: 8px; +.btn-icon { + padding: 6px 10px; + font-size: 15px; } -/* === 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; +/* ========================================================================== + État initial + ========================================================================== */ +.loading { + padding: 40px 20px; text-align: center; - color: #9ca3af; - font-size: 16px; + color: var(--text-muted); + font-size: 14px; } -.tech-grid { +.error-box { + margin: 20px; + padding: 14px 18px; + background: var(--danger-soft); + color: var(--danger); + border: 1px solid var(--danger); + border-radius: var(--radius); + font-size: 14px; + line-height: 1.55; +} + +.stats { + padding: 10px 20px 0 20px; + color: var(--text-muted); + font-size: 12px; +} + +/* ========================================================================== + Grille de cartes + ========================================================================== */ +.cards { display: grid; - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); - gap: 20px; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; + padding: 14px 20px 40px 20px; } -.tech-card { - background: white; - border-radius: 10px; - box-shadow: 0 1px 3px rgba(0,0,0,0.05); +.card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); 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; +.card-header { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--bg-muted); display: flex; justify-content: space-between; align-items: center; + gap: 10px; } -.tech-card.tech-pompier .tech-header { - background: #fef2f2; -} - -.tech-card.tech-absent .tech-header { - background: #fef3c7; -} - -.tech-name { +.card-tech-name { font-weight: 600; - font-size: 16px; - color: #111827; + font-size: 14px; + color: var(--text); } -.tech-badge { +.card-tech-badge { font-size: 11px; - padding: 3px 8px; - border-radius: 12px; - font-weight: 600; + padding: 2px 8px; + border-radius: 10px; text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; } -.tech-badge.badge-pompier { - background: #dc2626; - color: white; +.badge-pompier { + background: var(--danger-soft); + color: var(--danger); } -.tech-badge.badge-absent { - background: #f59e0b; - color: white; +.badge-absent { + background: var(--bg-muted); + color: var(--text-faint); + border: 1px solid var(--border); } -.tech-badge.badge-count { - background: #e5e7eb; - color: #4b5563; +.badge-count { + background: var(--accent-soft); + color: var(--accent); } -.tech-interventions { +.card-body { padding: 8px 0; flex: 1; } -.tech-empty { - padding: 24px; - text-align: center; - color: #9ca3af; +.card-empty { + padding: 14px; + color: var(--text-faint); + font-size: 13px; font-style: italic; - font-size: 14px; + text-align: center; } -/* === Intervention items === */ -.intervention { - padding: 10px 18px; - border-bottom: 1px solid #f3f4f6; - cursor: pointer; - transition: background 0.15s; +/* Cartes pompier : liseré rouge discret */ +.card.is-pompier { + border-left: 3px solid var(--danger); +} + +/* Cartes absent : teinte neutre */ +.card.is-absent { + opacity: 0.85; +} + +.card.is-absent .card-header { + background: var(--bg); +} + +/* ========================================================================== + Frise de temps + ========================================================================== */ +.timeline { + padding: 12px 14px 6px 14px; + background: var(--bg-muted); + border-bottom: 1px solid var(--border); position: relative; } -.intervention:last-child { - border-bottom: none; +/* Fond rouge discret quand la carte est "pompier" */ +.timeline-pompier { + background: var(--danger-soft); } -.intervention:hover { - background: #eff6ff; -} - -.interv-header { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 8px; -} - -.interv-time { +.timeline-pompier::before { + content: "En pompier toute la journée"; + position: absolute; + top: 2px; + right: 14px; + font-size: 10px; + color: var(--danger); font-weight: 600; - color: #1e40af; - font-size: 14px; - font-variant-numeric: tabular-nums; - white-space: nowrap; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.7; } -.interv-ref { - font-size: 12px; - color: #6b7280; - font-family: ui-monospace, "SF Mono", Consolas, monospace; +.timeline-bar { + position: relative; + height: 20px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; } -.interv-summary { - font-size: 13px; - color: #374151; +/* Trous (zones libres) : fond diagonal discret + vert léger au survol */ +.timeline-hole { + position: absolute; + top: 0; + bottom: 0; + background: repeating-linear-gradient( + 45deg, + transparent 0 4px, + rgba(0, 0, 0, 0.035) 4px 8px + ); + cursor: help; + transition: background 0.1s; +} +[data-theme="dark"] .timeline-hole { + background: repeating-linear-gradient( + 45deg, + transparent 0 4px, + rgba(255, 255, 255, 0.04) 4px 8px + ); +} +.timeline-hole:hover { + background: var(--ok-soft); +} + +/* Blocs occupés : couleurs selon type */ +.timeline-slot { + position: absolute; + top: 0; + bottom: 0; + cursor: help; + transition: filter 0.1s; + border-right: 1px solid var(--bg-elevated); +} + +.timeline-slot.color-livraison { background: var(--c-livraison); } +.timeline-slot.color-recup { background: var(--c-recup); } +.timeline-slot.color-remplacement { background: var(--c-remplacement); } +.timeline-slot.color-autre { background: var(--c-autre); } + +.timeline-slot.kind-absence { + background: repeating-linear-gradient( + 45deg, + var(--text-faint) 0 6px, + var(--bg-muted) 6px 12px + ); + opacity: 0.6; +} + +.timeline-slot:hover, +.timeline-slot.highlight { + filter: brightness(1.12); + outline: 2px solid var(--text); + outline-offset: -2px; + z-index: 2; +} + +/* Ligne de midi : marqueur vertical discret */ +.timeline-noon { + position: absolute; + top: -2px; + bottom: -2px; + width: 1px; + background: var(--border-strong); + z-index: 1; + pointer-events: none; +} + +.timeline-scale { + position: relative; + height: 14px; margin-top: 4px; - line-height: 1.4; } -.interv-contact { +.timeline-tick { + position: absolute; + transform: translateX(-50%); + font-size: 10px; + color: var(--text-faint); + font-family: var(--mono); +} + +/* Stats matin / après-midi / total */ +.card-stats { + display: flex; + gap: 16px; + padding: 6px 14px 8px 14px; + font-size: 12px; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + background: var(--bg-muted); +} +.card-stats .stat-chunk b { + color: var(--text); + font-weight: 600; +} +.card-stats .stat-chunk.total { + margin-left: auto; + color: var(--text); +} + +/* Note de statut pompier/absent en haut de carte */ +.card-status-note { + padding: 8px 14px; + font-size: 12px; font-weight: 500; + text-align: center; } - -.interv-location { - color: #6b7280; +.card-status-note.pompier { + background: var(--danger-soft); + color: var(--danger); + border-bottom: 1px solid var(--border); } - -.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; +.card-status-note.absent { + background: var(--bg); + color: var(--text-muted); + border-bottom: 1px solid var(--border); font-style: italic; } -.interv-details.error { - color: #991b1b; - background: #fef2f2; -} - -.interv-open-link { - display: inline-block; - margin-top: 8px; +.card-empty.subtle { font-size: 12px; - color: #2563eb; - text-decoration: none; + opacity: 0.7; } -.interv-open-link:hover { - text-decoration: underline; +/* Highlight réciproque */ +.intervention.highlight { + background: var(--bg-hover); } -/* === 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; +/* ========================================================================== + Interventions (lignes dans la carte) + ========================================================================== */ +.intervention { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px 8px 10px; + border-top: 1px solid var(--border); + cursor: default; + transition: background 0.08s; } -.pompier-info { - padding: 12px 18px; - font-size: 14px; - color: #7f1d1d; - background: #fee2e2; - border-left: 3px solid #dc2626; - font-weight: 500; +.intervention:first-child { + border-top: none; } -/* === Tooltip flottant === */ +.intervention:hover { + background: var(--bg-hover); +} + +/* Pastille colorée à gauche, rappel visuel du type */ +.intervention-dot { + flex-shrink: 0; + width: 4px; + align-self: stretch; + margin: 2px 4px 2px 0; + border-radius: 2px; +} +.intervention.color-livraison .intervention-dot { background: var(--c-livraison); } +.intervention.color-recup .intervention-dot { background: var(--c-recup); } +.intervention.color-remplacement .intervention-dot { background: var(--c-remplacement); } +.intervention.color-autre .intervention-dot { background: var(--c-autre); } + +.intervention-time { + flex-shrink: 0; + font-family: var(--mono); + font-size: 12px; + color: var(--text-muted); + min-width: 86px; +} + +.intervention-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.intervention-title { + font-size: 13px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.intervention-meta { + font-size: 12px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.intervention-ref { + font-family: var(--mono); + font-size: 11px; + color: var(--text-faint); +} + +.intervention-copy { + flex-shrink: 0; + padding: 4px 8px; + background: transparent; + color: var(--text-faint); + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + opacity: 0; + transition: opacity 0.1s, background 0.1s, color 0.1s; + font-family: inherit; +} + +.intervention:hover .intervention-copy { + opacity: 1; +} + +.intervention-copy:hover { + background: var(--bg-muted); + color: var(--text); + border-color: var(--border); +} + +.intervention-copy.copied { + color: var(--ok); + background: var(--ok-soft); + opacity: 1; +} + +/* Intervention de type pompier */ +.intervention.is-pompier-line .intervention-time { + color: var(--danger); + font-weight: 600; +} + +/* ========================================================================== + Tooltip au survol + ========================================================================== */ .tooltip { position: fixed; - background: #1f2937; - color: white; + z-index: 100; + max-width: 420px; padding: 12px 14px; - border-radius: 6px; + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border-strong); + border-radius: 8px; + box-shadow: var(--shadow-hover); 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); + opacity: 0; + transition: opacity 0.1s; } -/* === Responsive === */ -@media (max-width: 768px) { - .topbar { - padding: 16px 20px; - } - .summary, #main-content { - margin: 20px 16px; - } - .tech-grid { - grid-template-columns: 1fr; - } +.tooltip.visible { + opacity: 1; +} + +.tooltip dl { + margin: 0; + display: grid; + grid-template-columns: auto 1fr; + column-gap: 10px; + row-gap: 4px; +} + +.tooltip dt { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.tooltip dd { + margin: 0; + color: var(--text); + font-size: 13px; + word-break: break-word; +} + +.tooltip dd.description { + white-space: pre-wrap; +} + +.tooltip hr { + border: none; + border-top: 1px solid var(--border); + margin: 8px 0; + grid-column: 1 / -1; } diff --git a/viewer.html b/viewer.html index 64a3d26..f805e2e 100644 --- a/viewer.html +++ b/viewer.html @@ -1,49 +1,34 @@ - + - - Planning Techniciens — Vue claire + + Planning techniciens
-

📅 Planning du

-
Chargement…
+

Planning techniciens

+
- -
- - - - -
-
Le planning va s'afficher ici.
+
+ +
Chargement…
+ +
- - + diff --git a/viewer.js b/viewer.js index 654e8a1..ab6b493 100644 --- a/viewer.js +++ b/viewer.js @@ -1,681 +1,1361 @@ -// viewer.js — Logique de la vue claire +// ============================================================================ +// 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). // -// É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() +// 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 -// ========================================================================== +// ============================================================================ -// 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" - } +// É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" }; -// 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(); +// 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 +}; -// ========================================================================== -// Init -// ========================================================================== +// Clés localStorage et sessionStorage +const LS_THEME = "planning_theme"; // "light" | "dark" +const SS_CACHE = "planning_cache_v2"; // dernier parsing JSON -document.addEventListener("DOMContentLoaded", async () => { - document.getElementById("btn-refresh").addEventListener("click", refresh); - document.getElementById("btn-preload").addEventListener("click", preloadAll); - await loadFromStorage(); -}); +// ============================================================================ +// 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. -async function loadFromStorage() { - const data = await chrome.storage.local.get([ - "planningHtml", "planningUrl", "planningCapturedAt", "planningError" +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 (data.planningError) { - showError(data.planningError); + if (stored.planningError) { + showError(stored.planningError); return; } - if (!data.planningHtml) { + if (!stored.planningHtml) { + // Essayer le cache de session + const cached = loadFromSessionCache(); + if (cached) { + render(cached); + return; + } 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." + "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(data.planningHtml, data.planningUrl); - render(parsed, data.planningCapturedAt); + const parsed = parsePlanning(stored.planningHtml); + parsed.captureTime = stored.planningCapturedAt || Date.now(); + saveToSessionCache(parsed); + render(parsed); } catch (err) { - console.error(err); - showError("Erreur lors du parsing du planning : " + err.message); + console.error("Erreur parsing:", err); + showError( + "Impossible de parser le planning. " + + "Détail technique : " + (err?.message || String(err)) + ); } } -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." +// ============================================================================ +// 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." ); } -// ========================================================================== -// Parsing -// ========================================================================== +// ============================================================================ +// Cache sessionStorage +// ============================================================================ -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"); +function saveToSessionCache(data) { + try { + sessionStorage.setItem(SS_CACHE, JSON.stringify(data)); + } catch (e) { + console.warn("Cache session impossible (quota ?) :", e); + } +} - // --- 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 }; - } +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: [] + }); } - // --- 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; + 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; - // 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]; - } - } + techMap.get(player.techId).interventions.push(player); } - // --- 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. 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 || ""); + }); } - // --- 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