From e92b0c444459b39e7900f1dbf9f3bd504e522898 Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Sun, 26 Apr 2026 18:10:00 +0200 Subject: [PATCH] =?UTF-8?q?v2026.5.39=20=E2=80=94=20S=C3=A9paration=20Mati?= =?UTF-8?q?n=20/=20Apr=C3=A8s-midi=20+=20Apparence=20(th=C3=A8me,=20taille?= =?UTF-8?q?=20du=20texte,=20dur=C3=A9e=20du=20cache,=20heures=20de=20la=20?= =?UTF-8?q?journ=C3=A9e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 39 +++++ background.js | 25 ++- manifest.json | 2 +- viewer.css | 391 ++++++++++++++++++++++++++++++++++++++--- viewer.js | 475 ++++++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 874 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65860ff..dcacbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,45 @@ --- +## v2026.5.39 — Séparation Matin / Après-midi + Apparence (thème, zoom, cache) +**Branche** : current + +### Séparation matin / après-midi +- Séparateur visuel "MATIN" / "APRÈS-MIDI" entre les interventions + dans la vue classique : pill grise neutre, ligne 3px épaisse. +- Affiché aussi entre les absences partielles (demi-journée). +- Si une période est vide, son séparateur n'est pas affiché. +- Caché en vue horizontale (les rows sont masquées de toute façon). + +### Timeline — coupure midi très visible +- Bande verticale composée d'un trait massif central (couleur --text) + + stripes diagonales en arrière-plan (effet "césure"). 6 px de large + (7 px en vue horizontale). Visible immédiatement, pas de label superflu. + +### Vue horizontale (sidebar) +- Boutons (Absence, Douchette, Actualiser, Tout recharger, Vider cache, + Thème) maintenant **vraiment** poussés en bas via `min-height: 100vh` + sur la sidebar. +- Bouton "Aujourd'hui" : style cohérent avec les flèches ◀ ▶ (même + padding, font-size, hauteur), texte centré, libellé complet + "Aujourd'hui" (au lieu de "Auj."). +- Espace visuel entre `Actualisé à HH:MM` et le bouton Absence (fine + bordure top + padding). + +### Vue classique (topbar) +- Ordre verrouillé via CSS `order` : badge user → titre → date-nav → + capture-info → refresh-check. Évite les déplacements au retour de + vue horizontale. + +### Section Apparence (admin) — refondue + en première position +- **Thème** : sélecteur Auto / Clair / Sombre (s'enregistre direct). +- **Durée du cache (jours)** : configurable, défaut 7 jours, range 1-365. + Lue par viewer.js (purge auto) ET background.js (au boot). +- **Taille du texte** : 5 niveaux (-20%, -10%, 100%, +10%, +20%) via CSS + `zoom` sur body. Persisté dans admin_config.textZoom et appliqué dès + le boot. +- Section "Apparence" est maintenant **la première** dans le panel admin. + ## v2026.5.38 — Attribution auteur + nettoyage code **Branche** : current diff --git a/background.js b/background.js index f5a02e1..2259984 100644 --- a/background.js +++ b/background.js @@ -1380,13 +1380,30 @@ async function cleanupOldCaches(daysToKeep) { return toRemove.length; } +// v2026.5.39 : on lit admin_config pour récupérer cacheDays. Si pas dispo, +// fallback sur 7 jours. +async function _getCacheDays() { + try { + const o = await chrome.storage.local.get("admin_config"); + const cfg = o && o.admin_config; + if (cfg && typeof cfg.cacheDays === "number" && cfg.cacheDays > 0) { + return cfg.cacheDays; + } + } catch (e) { + LOG.warn("cache", "lecture admin_config échouée, fallback 7 jours", { err: e && e.message }); + } + return 7; +} + // Au démarrage, nettoyer les anciennes alarmes et les anciens caches -chrome.runtime.onInstalled.addListener(() => { +chrome.runtime.onInstalled.addListener(async () => { clearLegacyRefreshAlarms(); - cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); + const days = await _getCacheDays(); + cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onInstalled", { err: err && err.message })); }); -chrome.runtime.onStartup.addListener(() => { +chrome.runtime.onStartup.addListener(async () => { clearLegacyRefreshAlarms(); - cleanupOldCaches(7).catch(err => console.warn("cleanup:", err)); + const days = await _getCacheDays(); + cleanupOldCaches(days).catch(err => LOG.warn("cleanup", "échec onStartup", { err: err && err.message })); }); diff --git a/manifest.json b/manifest.json index 2f39863..44d1c20 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "2026.5.38", + "version": "2026.5.39", "description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.", "permissions": [ "activeTab", diff --git a/viewer.css b/viewer.css index d966736..710efff 100644 --- a/viewer.css +++ b/viewer.css @@ -144,6 +144,17 @@ html, body { min-width: 0; } +/* v2026.5.39 r2 : on verrouille l'ordre des élements de la topbar gauche en + vue classique. Le badge user reste à l'extrême gauche, le titre suit, puis + la nav date et enfin la capture-info. Ce verrou évite que des composants + restaurés depuis la sidebar (vue horizontale → classique) finissent dans + le mauvais ordre. */ +html.view-classic .topbar-left #user-badge { order: 1; } +html.view-classic .topbar-left #app-title { order: 2; } +html.view-classic .topbar-left .date-nav { order: 3; } +html.view-classic .topbar-left .capture-info { order: 4; } +html.view-classic .topbar-left #refresh-check { order: 5; } + .topbar h1 { margin: 0; font-size: 18px; @@ -761,14 +772,40 @@ html, body { z-index: 2; } +/* v2026.5.39 r3 : séparateur "midi" — vraie coupure visuelle. + La timeline-bar a overflow:hidden donc les chevrons qui dépassaient en + haut/bas étaient coupés. On reste DANS la bar mais on rend le trait + massif et fortement contrasté + un effet "encoche" via box-shadow latéral + (pour bien détacher du fond), et on superpose une bande à stripes + diagonales sur 6px de large pour signaler la pause de midi. + Z-index élevé pour passer au-dessus des segments. */ .timeline-noon { position: absolute; - top: -2px; - bottom: -2px; - width: 1px; - background: var(--border-strong); - z-index: 1; + top: 0; + bottom: 0; + width: 6px; + z-index: 3; pointer-events: none; + transform: translateX(-3px); /* centrer la bande sur 12h */ + background: + /* trait central massif : */ + linear-gradient(to right, + transparent 0%, transparent 30%, + var(--text) 30%, var(--text) 70%, + transparent 70%, transparent 100%), + /* fond stripes diagonales pour signaler "césure" : */ + repeating-linear-gradient( + 45deg, + var(--bg-muted) 0 3px, + var(--text-muted) 3px 4px + ); + opacity: 0.95; +} +/* En vue horizontale, timeline-bar est plus haute (22px au lieu de 20px), + on garde le même trait mais légèrement plus large pour la visibilité. */ +html.view-horizontal .timeline-noon { + width: 7px; + transform: translateX(-3.5px); } .timeline-scale { @@ -3849,9 +3886,10 @@ html.view-horizontal body > header.topbar { } /* 2. Sidebar : structure verticale avec section fixe en haut (user+titre+date) - et section "boutons" en bas poussée via margin-top: auto. */ + et section "boutons" en bas poussée via margin-top: auto. + v2026.5.39 r8 : max-height retiré (était en conflit avec min-height + compensé par --zoom-inv quand le user dézoom le texte). */ html.view-horizontal .horizontal-sidebar { - max-height: 100vh !important; padding-top: 12px !important; } @@ -3879,23 +3917,14 @@ html.view-horizontal .horizontal-sidebar #app-title { html.view-horizontal .horizontal-sidebar .date-nav { display: contents; } -/* Le bouton Aujourd'hui devient prominent */ +/* Bouton Aujourd'hui — v2026.5.39 r5 : MÊME style que Absence/Douchette + en sidebar. Hérite de la règle générique button.btn (padding/font-size). + On force juste centrage du texte. */ html.view-horizontal .horizontal-sidebar .btn-today { - order: 1; /* tout en haut après titre */ + order: 1; /* tout en haut après titre */ width: 100% !important; + justify-content: center !important; /* centrage du label */ text-align: center !important; - padding: 8px 12px !important; - font-size: 13px !important; - font-weight: 600 !important; - background: var(--bg) !important; - border: 1px solid var(--border) !important; - border-radius: 6px !important; - color: var(--text) !important; - justify-content: center !important; -} -html.view-horizontal .horizontal-sidebar .btn-today::before { - content: "↺ "; - margin-right: 4px; } /* 5. App-clock (date + heure) centré sous le bouton "Aujourd'hui" */ @@ -3957,10 +3986,24 @@ html.view-horizontal .horizontal-sidebar .capture-info { } /* 10. Boutons poussés en bas via margin-top: auto sur le premier d'entre eux - (Absence, qui a order:7) */ + (Absence, qui a order:7). + v2026.5.39 r2 : ajout d'une fine bordure top + padding pour visualiser + clairement la séparation entre la zone "info" (capture-info, stats) et + la zone "actions" (boutons). margin-top: auto pousse en bas. */ html.view-horizontal .horizontal-sidebar #absence-btn { order: 7; margin-top: auto !important; /* pousse tout ce qui suit en bas */ + padding-top: 8px !important; /* respiration au-dessus du bouton */ + position: relative; +} +html.view-horizontal .horizontal-sidebar #absence-btn::before { + content: ""; + position: absolute; + top: 0; + left: 4px; + right: 4px; + height: 1px; + background: var(--border); } html.view-horizontal .horizontal-sidebar #douchette-btn { order: 8; } html.view-horizontal .horizontal-sidebar #refresh-partial-btn { order: 9; } @@ -3982,11 +4025,39 @@ html.view-horizontal .horizontal-sidebar #theme-toggle { justify-content: center !important; } -/* 12. Sidebar doit être flex column pour que margin-top:auto fonctionne */ +/* 12. Sidebar doit être flex column pour que margin-top:auto fonctionne. + v2026.5.39 r3 : on force min-height: 100vh. + v2026.5.39 r5 : tout est centré horizontalement (text-align + align-items). + v2026.5.39 r8 : on compense le body.zoom via --zoom-inv. À 75% de zoom, + 100vh ne couvre que 75% de la viewport effective ; on multiplie donc par + l'inverse du zoom pour que la sidebar atteigne TOUJOURS le bas de l'écran. */ html.view-horizontal .horizontal-sidebar { display: flex !important; flex-direction: column !important; + align-items: center !important; /* centrage horizontal des enfants */ + text-align: center !important; /* centrage des textes */ gap: 6px !important; + min-height: calc(100vh * var(--zoom-inv, 1)) !important; + box-sizing: border-box !important; +} +/* Tous les enfants directs reçoivent text-align: center par héritage, + mais on force aussi sur les boutons (qui ont parfois justify-content: + flex-start) et les blocs de stats. */ +html.view-horizontal .horizontal-sidebar > * { + text-align: center !important; +} +html.view-horizontal .horizontal-sidebar button.btn { + justify-content: center !important; +} +html.view-horizontal .horizontal-sidebar #stats .global-stat { + text-align: center !important; +} +html.view-horizontal .horizontal-sidebar .app-clock { + align-items: center !important; + text-align: center !important; +} +html.view-horizontal .horizontal-sidebar .date-custom { + justify-content: center !important; } /* 13. Barre de rafraîchissement en vue horizontale : overlay par-dessus @@ -4030,3 +4101,277 @@ html.view-horizontal .card-status-note.pompier { letter-spacing: 0.2px; user-select: none; } + +/* ========================================================================== + v2026.5.39 : Séparateur Matin / Après-midi entre les interventions + Affiché entre les rows .intervention-v2 dans la vue classique. La ligne + est marquée (pas un simple text), avec un label dans une "pill" centrée + sur une fine barre horizontale qui traverse la card. + Caché automatiquement en vue horizontale (les rows .intervention-v2 sont + masquées dans cette vue, donc le séparateur le serait visuellement seul). + ========================================================================== */ +.day-period-sep { + display: flex; + align-items: center; + gap: 10px; + margin: 14px 0 8px 0; + padding: 0 6px; + position: relative; + user-select: none; +} +/* v2026.5.39 r2 : ligne plus épaisse (3px), continue, sans dégradé. */ +.day-period-sep::before, +.day-period-sep::after { + content: ""; + flex: 1 1 auto; + height: 3px; + background: var(--border-strong); + border-radius: 2px; +} +/* v2026.5.39 r2 : style neutre — pas de couleur ambre/bleu. */ +.day-period-sep .day-period-label { + flex: 0 0 auto; + font-size: 12px; + font-weight: 700; + letter-spacing: 1.4px; + text-transform: uppercase; + color: var(--text-muted); + background: var(--bg-muted); + border: 1px solid var(--border-strong); + border-radius: 14px; + padding: 4px 14px; + white-space: nowrap; +} + +/* Premier séparateur : pas de marge top excessive (juste après les stats) */ +.card-stats + .day-period-sep { + margin-top: 8px; +} + +/* Vue horizontale : la liste détaillée est masquée donc le séparateur aussi */ +html.view-horizontal .day-period-sep { + display: none !important; +} + +/* ========================================================================== + v2026.5.39 : Admin — section Apparence (rows label/control + select + + groupe boutons zoom). + ========================================================================== */ +.admin-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} +.admin-row:last-child { + border-bottom: none; +} +.admin-row-label { + flex: 1 1 auto; + min-width: 0; +} +.admin-row-label strong { + font-size: 14px; + color: var(--text); +} +.admin-row-desc { + font-size: 12px; + color: var(--text-faint); + margin-top: 4px; + line-height: 1.4; +} +.admin-row-control { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 6px; +} +.admin-select { + padding: 6px 10px; + font-size: 13px; + background: var(--bg-elevated); + color: var(--text); + border: 1px solid var(--border-strong); + border-radius: 4px; + cursor: pointer; + min-width: 200px; +} +.admin-input-num { + width: 80px !important; + text-align: center; +} + +.admin-zoom-group { + display: flex; + gap: 4px; +} +.btn-zoom { + min-width: 56px; + padding: 6px 10px; + font-size: 12px; + font-weight: 500; + background: var(--bg-elevated); + color: var(--text-muted); + border: 1px solid var(--border-strong); + border-radius: 4px; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} +.btn-zoom:hover { + background: var(--bg-hover); + color: var(--text); +} +.btn-zoom.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +/* ========================================================================== + v2026.5.39 r5 : zoom texte via variable --text-scale (0.8 à 1.2). + Modifie uniquement les font-size, pas le layout. Stockée par JS dans + admin_config.textZoom et appliquée sur au boot. + On utilise un selecteur `*` ciblant tous les éléments qui ont + `font-size` défini en `px` ou `rem` — nope c'est impossible. Donc on + applique sur :root un font-size inheritable, et on bump les tailles + spécifiques via calc(). Pour simplicité on cible juste body+composants + principaux. Les éléments restants (avec font-size en px hardcodé) seront + inchangés, mais comme la majorité des textes hérite de body, ça suffit + en pratique. + ========================================================================== */ +/* v2026.5.39 r7 : zoom GLOBAL sur le body — fait scaler TOUS les textes + visibles (interventions, popups, absences, réservations, tooltips, cards, + sidebar, etc.). Application via body.style.zoom = "" depuis JS. + On garde aussi --text-scale pour la date/heure (qui restent à la même + taille entre elles, même règle calc()). */ +:root { + --text-scale: 1; + --zoom-factor: 1; + --zoom-inv: 1; +} +.app-clock-time, +.app-clock-date { font-size: calc(18px * var(--text-scale)) !important; } + +/* v2026.5.39 r12 : le panel admin suit le zoom comme tout le reste. + Pas d'effet yo-yo car le zoom n'est plus appliqué pendant le drag du + slider (seulement au release) — voir _applyTextZoom dans viewer.js. */ + +/* Slider taille texte dans Apparence — v2026.5.39 r6 : 5 dots sur la piste */ +.admin-zoom-slider-wrap { + display: flex; + align-items: center; + gap: 12px; +} +.admin-zoom-slider { + width: 220px; + height: 26px; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + background: transparent; +} +.admin-zoom-slider::-webkit-slider-runnable-track { + height: 4px; + background: + radial-gradient(circle at 0% 50%, var(--text-muted) 0 4px, transparent 5px), + radial-gradient(circle at 25% 50%, var(--text-muted) 0 4px, transparent 5px), + radial-gradient(circle at 50% 50%, var(--text-muted) 0 4px, transparent 5px), + radial-gradient(circle at 75% 50%, var(--text-muted) 0 4px, transparent 5px), + radial-gradient(circle at 100% 50%, var(--text-muted) 0 4px, transparent 5px), + var(--border-strong); + border-radius: 2px; +} +.admin-zoom-slider::-moz-range-track { + height: 4px; + background: + radial-gradient(circle at 0% 50%, var(--text-muted) 0 4px, transparent 5px), + radial-gradient(circle at 25% 50%, var(--text-muted) 0 4px, transparent 5px), + radial-gradient(circle at 50% 50%, var(--text-muted) 0 4px, transparent 5px), + radial-gradient(circle at 75% 50%, var(--text-muted) 0 4px, transparent 5px), + radial-gradient(circle at 100% 50%, var(--text-muted) 0 4px, transparent 5px), + var(--border-strong); + border-radius: 2px; +} +.admin-zoom-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg-elevated); + box-shadow: 0 1px 3px rgba(0,0,0,0.25); + cursor: pointer; + margin-top: -7px; +} +.admin-zoom-slider::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg-elevated); + box-shadow: 0 1px 3px rgba(0,0,0,0.25); + cursor: pointer; +} +.admin-zoom-value { + min-width: 48px; + text-align: right; + font-size: 13px; + font-weight: 600; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +/* ========================================================================== + v2026.5.39 r6 : section "À propos" du panel admin + ========================================================================== */ +.admin-about-card { + display: flex; + flex-direction: column; + gap: 0; + background: var(--bg-muted); + border: 1px solid var(--border); + border-radius: 6px; + padding: 16px 20px; + margin-top: 8px; +} +.admin-about-row { + display: flex; + gap: 16px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.admin-about-row:last-child { + border-bottom: none; +} +.admin-about-label { + flex: 0 0 130px; + font-size: 13px; + color: var(--text-muted); + font-weight: 500; +} +.admin-about-value { + flex: 1 1 auto; + font-size: 13px; + color: var(--text); + word-break: break-word; +} +.admin-about-value a { + color: var(--accent); + text-decoration: none; +} +.admin-about-value a:hover { + text-decoration: underline; +} +.admin-about-desc { + margin: 12px 0; + padding: 12px 14px; + background: var(--bg-elevated); + border-left: 3px solid var(--accent); + border-radius: 0 4px 4px 0; + font-size: 13px; + line-height: 1.55; + color: var(--text); +} diff --git a/viewer.js b/viewer.js index c6f0d91..a0fc8f4 100644 --- a/viewer.js +++ b/viewer.js @@ -364,6 +364,11 @@ document.addEventListener("DOMContentLoaded", init); async function init() { 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(); + // v2026.5.39 : lire les heures de la journée depuis admin_config (8-18 défaut). + await _initDayBoundsFromConfig(); bindTopbar(); bindTooltipInteractions(); initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal @@ -654,13 +659,21 @@ function toggleUserNamePopup() { // 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(); - viewBtn.innerHTML = ' Vue : ' - + (currentView === "horizontal" ? "Horizontale" : "Classique"); - viewBtn.title = "Changer la disposition du planning"; + 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(); @@ -1259,6 +1272,13 @@ function _applyViewMode() { _restoreElementsToTopbar(ELEMENTS_TO_RELOCATE); } + // v2026.5.39 r2 : label complet "Aujourd'hui" en sidebar (plus large), "Auj." + // en topbar classique (plus compact). On gère ça ici car on n'utilise pas + // de pseudo-element CSS (problèmes avec le reflow). + const navToday = document.getElementById("nav-today"); + if (navToday) { + navToday.textContent = (mode === "horizontal") ? "Aujourd'hui" : "Auj."; + } } /** @@ -2018,15 +2038,17 @@ async function showAdminPanel() { 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: "appearance", label: "Apparence", render: renderAdminSectionAppearance }, { id: "statuses", label: "Statuts", render: renderAdminSectionStatuses }, - { id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics } + { id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics }, + { id: "about", label: "À propos", render: renderAdminSectionAbout } ]; - let currentSection = "team"; + let currentSection = "appearance"; const navButtons = {}; for (const section of sections) { @@ -2335,23 +2357,327 @@ function renderAdminSectionEV(container, cfg, saveFn) { container.appendChild(pre); } +// v2026.5.39 : section Apparence — thème, taille du texte, cache. +// Chaque champ s'enregistre direct (pas besoin de bouton "Enregistrer" +// global), pour que le user voit l'effet immédiatement. function renderAdminSectionAppearance(container, cfg, saveFn) { const h = document.createElement("h3"); h.textContent = "Apparence"; h.className = "admin-section-title"; container.appendChild(h); - const desc = document.createElement("p"); - desc.className = "admin-section-desc"; - desc.textContent = "Section à venir dans v5.0.x. Heures journée, durée cache, thème."; - container.appendChild(desc); - const pre = document.createElement("pre"); - pre.className = "admin-readonly"; - pre.textContent = JSON.stringify({ - dayStart: cfg.dayStart, - dayEnd: cfg.dayEnd, - cacheDays: cfg.cacheDays - }, null, 2); - container.appendChild(pre); + + // ---- Champ Thème (1er) ---- + const themeRow = _makeAdminRow( + "Thème", + "Clair, sombre ou suit l'OS." + ); + const themeSelect = document.createElement("select"); + themeSelect.className = "admin-select"; + for (const opt of [ + { val: "auto", label: "Automatique (selon l'OS)" }, + { val: "light", label: "Clair" }, + { val: "dark", label: "Sombre" } + ]) { + const o = document.createElement("option"); + o.value = opt.val; + o.textContent = opt.label; + if ((cfg.theme || "auto") === opt.val) o.selected = true; + themeSelect.appendChild(o); + } + themeSelect.addEventListener("change", async () => { + cfg.theme = themeSelect.value; + _applyTheme(cfg.theme); + await saveAdminConfig(cfg); + }); + themeRow.querySelector(".admin-row-control").appendChild(themeSelect); + container.appendChild(themeRow); + + // ---- Champ Cache (2e) ---- + const cacheRow = _makeAdminRow( + "Durée du cache (jours)", + "Au-delà, les anciens caches sont purgés. Défaut : 7 jours." + ); + // v2026.5.39 : tooltip survol → emplacement physique du cache (selon + // navigateur + OS). Aide les techs DGNSI à inspecter / nettoyer si besoin. + cacheRow.title = _getCacheLocationHint(); + const cacheInput = document.createElement("input"); + cacheInput.type = "number"; + cacheInput.min = "1"; + cacheInput.max = "365"; + cacheInput.step = "1"; + cacheInput.className = "admin-input admin-input-num"; + cacheInput.value = String(cfg.cacheDays || 7); + cacheInput.addEventListener("change", async () => { + let v = parseInt(cacheInput.value, 10); + if (isNaN(v) || v < 1) v = 1; + if (v > 365) v = 365; + cacheInput.value = String(v); + cfg.cacheDays = v; + await saveAdminConfig(cfg); + }); + cacheRow.querySelector(".admin-row-control").appendChild(cacheInput); + container.appendChild(cacheRow); + + // ---- Champ Taille du texte (3e) — slider horizontal ---- + // 5 crans : -2 (-20%), -1 (-10%), 0 (100% défaut), +1 (+10%), +2 (+20%) + const zoomRow = _makeAdminRow( + "Taille du texte", + "Glisse le curseur pour changer la taille. 100% = défaut. Persisté entre sessions." + ); + const zoomWrap = document.createElement("div"); + zoomWrap.className = "admin-zoom-slider-wrap"; + // v2026.5.39 r9 : déclaration EN PREMIER pour pouvoir l'utiliser dans la + // boucle datalist (sinon TDZ ReferenceError → la section ne s'affichait plus) + const _zoomLabel = (lv) => { + const pct = { "-2": 70, "-1": 85, "0": 100, "1": 110, "2": 120 }[String(lv)] || 100; + return pct + "%"; + }; + const zoomSlider = document.createElement("input"); + zoomSlider.type = "range"; + zoomSlider.min = "-2"; + zoomSlider.max = "2"; + zoomSlider.step = "1"; + zoomSlider.className = "admin-zoom-slider"; + zoomSlider.setAttribute("list", "zoom-ticks"); + const currentZoom = (typeof cfg.textZoom === "number") ? cfg.textZoom : 0; + zoomSlider.value = String(currentZoom); + // datalist (5 ticks) + const ticks = document.createElement("datalist"); + ticks.id = "zoom-ticks"; + for (const v of [-2, -1, 0, 1, 2]) { + const opt = document.createElement("option"); + opt.value = String(v); + opt.label = _zoomLabel(v); + ticks.appendChild(opt); + } + zoomWrap.appendChild(ticks); + const zoomVal = document.createElement("span"); + zoomVal.className = "admin-zoom-value"; + zoomVal.textContent = _zoomLabel(currentZoom); + // v2026.5.39 r11 : on N'APPLIQUE PAS le zoom pendant le drag (event "input"), + // sinon l'UI fait du yo-yo et la position de la souris vs le curseur dérive. + // Pendant le drag : on met juste à jour le label % pour que l'user voie où + // il va atterrir. Au release ("change"), on applique le zoom + on save. + zoomSlider.addEventListener("input", () => { + const lv = parseInt(zoomSlider.value, 10) || 0; + zoomVal.textContent = _zoomLabel(lv); + }); + zoomSlider.addEventListener("change", async () => { + const lv = parseInt(zoomSlider.value, 10) || 0; + _applyTextZoom(lv); + cfg.textZoom = lv; + await saveAdminConfig(cfg); + }); + zoomWrap.appendChild(zoomSlider); + zoomWrap.appendChild(zoomVal); + zoomRow.querySelector(".admin-row-control").appendChild(zoomWrap); + container.appendChild(zoomRow); + + // ---- Heures de la journée (4e) ---- + const hoursRow = _makeAdminRow( + "Heures de la journée", + "Plage horaire affichée sur la timeline. Défaut : 8h - 18h." + ); + const hoursWrap = document.createElement("div"); + hoursWrap.style.cssText = "display:flex; align-items:center; gap:6px;"; + const hStart = document.createElement("input"); + hStart.type = "number"; hStart.min = "0"; hStart.max = "23"; hStart.step = "1"; + hStart.className = "admin-input admin-input-num"; + hStart.value = String(cfg.dayStart || 8); + const sep = document.createElement("span"); + sep.textContent = "h →"; + sep.style.cssText = "color: var(--text-muted); font-size: 13px;"; + const hEnd = document.createElement("input"); + hEnd.type = "number"; hEnd.min = "1"; hEnd.max = "24"; hEnd.step = "1"; + hEnd.className = "admin-input admin-input-num"; + hEnd.value = String(cfg.dayEnd || 18); + const hSuffix = document.createElement("span"); + hSuffix.textContent = "h"; + hSuffix.style.cssText = "color: var(--text-muted); font-size: 13px;"; + const _saveHours = async () => { + let s = parseInt(hStart.value, 10); + let e = parseInt(hEnd.value, 10); + if (isNaN(s) || s < 0) s = 0; + if (s > 23) s = 23; + if (isNaN(e) || e <= s) e = s + 1; + if (e > 24) e = 24; + hStart.value = String(s); + hEnd.value = String(e); + cfg.dayStart = s; + cfg.dayEnd = e; + await saveAdminConfig(cfg); + }; + hStart.addEventListener("change", _saveHours); + hEnd.addEventListener("change", _saveHours); + hoursWrap.appendChild(hStart); + hoursWrap.appendChild(sep); + hoursWrap.appendChild(hEnd); + hoursWrap.appendChild(hSuffix); + hoursRow.querySelector(".admin-row-control").appendChild(hoursWrap); + container.appendChild(hoursRow); +} + +// Helper pour créer une ligne label/desc + zone contrôle (utilisée par +// plusieurs sections admin). +function _makeAdminRow(labelText, descText) { + const row = document.createElement("div"); + row.className = "admin-row"; + const lbl = document.createElement("div"); + lbl.className = "admin-row-label"; + const strong = document.createElement("strong"); + strong.textContent = labelText; + lbl.appendChild(strong); + if (descText) { + const d = document.createElement("div"); + d.className = "admin-row-desc"; + d.textContent = descText; + lbl.appendChild(d); + } + const ctrl = document.createElement("div"); + ctrl.className = "admin-row-control"; + row.appendChild(lbl); + row.appendChild(ctrl); + return row; +} + +// v2026.5.39 r7 : zoom GLOBAL via body.style.zoom — fait scaler TOUS les +// textes visibles. Couvre tooltip, intervention rows, absences, réservations, +// "En pompier", etc. +// Niveaux : -2 = 75% (-25%), -1 = 90%, 0 = 100%, +1 = 110%, +2 = 120%. +// v2026.5.39 r8 : on expose aussi --zoom-factor (et --zoom-inv) pour que +// le CSS compense les calculs vh sur la sidebar (sinon à 75% la sidebar +// ne couvre que 75vh de la viewport effective). +const TEXT_ZOOM_PCT = { "-2": 70, "-1": 85, "0": 100, "1": 110, "2": 120 }; +function _applyTextZoom(level) { + const lv = Math.max(-2, Math.min(2, parseInt(level, 10) || 0)); + const pct = TEXT_ZOOM_PCT[String(lv)] || 100; + const factor = pct / 100; + const html = document.documentElement; + html.style.setProperty("--text-scale", String(factor)); + html.style.setProperty("--zoom-factor", String(factor)); + html.style.setProperty("--zoom-inv", String(1 / factor)); + if (document.body) { + document.body.style.zoom = pct + "%"; + } +} + +// Lit cfg.textZoom et l'applique dès que la config est chargée. Idempotent. +async function _initTextZoomFromConfig() { + try { + const cfg = await loadAdminConfig(); + if (typeof cfg.textZoom === "number") { + _applyTextZoom(cfg.textZoom); + } + } catch (e) { + LOG.warn("textZoom", "init failed", { err: e && e.message }); + } +} + +// v2026.5.39 : génère un texte décrivant l'emplacement physique du cache +// chrome.storage.local selon le navigateur + l'OS détecté. Sert de tooltip +// (title=) sur le champ "Durée du cache" dans le panel admin. +function _getCacheLocationHint() { + const ua = (navigator.userAgent || "").toLowerCase(); + const isFirefox = ua.includes("firefox") || ua.includes("gecko/"); + const isMac = /mac|darwin/.test(ua); + const isWin = /windows|win64|win32/.test(ua); + const isLinux = /linux/.test(ua) && !isMac && !isWin; + + let extId = "?"; + try { extId = chrome.runtime.id || "?"; } catch (e) {} + + const lines = []; + lines.push("Emplacement physique du cache :"); + lines.push(""); + if (isFirefox) { + if (isMac) { + lines.push("~/Library/Application Support/Firefox/Profiles//"); + lines.push(" storage/default/moz-extension+++" + extId + "/idb/"); + } else if (isWin) { + lines.push("%APPDATA%\\Mozilla\\Firefox\\Profiles\\\\"); + lines.push(" storage\\default\\moz-extension+++" + extId + "\\idb\\"); + } else if (isLinux) { + lines.push("~/.mozilla/firefox//storage/default/"); + lines.push(" moz-extension+++" + extId + "/idb/"); + } else { + lines.push("(profil Firefox) / storage / default / moz-extension+++" + extId + " / idb /"); + } + } else { + // Chrome / Edge / Brave (Chromium-based) + if (isMac) { + lines.push("~/Library/Application Support/Google/Chrome/Default/"); + lines.push(" Local Extension Settings/" + extId + "/"); + } else if (isWin) { + lines.push("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\"); + lines.push(" Local Extension Settings\\" + extId + "\\"); + } else if (isLinux) { + lines.push("~/.config/google-chrome/Default/"); + lines.push(" Local Extension Settings/" + extId + "/"); + } else { + lines.push("(profil Chrome) / Local Extension Settings / " + extId + " /"); + } + } + lines.push(""); + lines.push("Extension ID : " + extId); + return lines.join("\n"); +} + +// v2026.5.39 r6 : section "À propos" — version, description courte, auteur. +function renderAdminSectionAbout(container, cfg, saveFn) { + const h = document.createElement("h3"); + h.textContent = "À propos"; + h.className = "admin-section-title"; + container.appendChild(h); + + const card = document.createElement("div"); + card.className = "admin-about-card"; + + const v = LOG.version(); + card.innerHTML = ` +
+
Extension
+
Planification
+
+
+
Version
+
v${escapeHtml(v)}
+
+
+
Auteur
+
Quentin Rouiller
+
+
+
Affiliation
+
Technicien DGNSI — Canton de Vaud
+
+
+ Extension Chrome / Firefox qui offre une vue claire et rapide du planning + des techniciens DGNSI dans EasyVista. Regroupe interventions, réservations + et absences par technicien avec timeline visuelle, popups détaillés + épinglables, et 2 modes d'affichage (vue cards classique ou vue horizontale + compacte). +
+
+
Licence
+
MIT
+
+ + `; + container.appendChild(card); +} + +// Helper applique un thème (utilisé par le sélecteur Apparence). +// Si "auto", on retire data-theme pour laisser le CSS detecter prefers-color-scheme. +function _applyTheme(theme) { + const html = document.documentElement; + if (theme === "light" || theme === "dark") { + html.setAttribute("data-theme", theme); + } else { + html.removeAttribute("data-theme"); + } } function renderAdminSectionStatuses(container, cfg, saveFn) { @@ -3050,22 +3376,24 @@ async function writeCache(isoDate, data) { } } -// Purge les entrées de cache plus vielles que 7 jours (best-effort). +// Purge les entrées de cache plus vieilles que cfg.cacheDays (défaut 7 j). async function _purgeOldCacheEntries() { + const cfg = await loadAdminConfig(); + const days = (typeof cfg.cacheDays === "number" && cfg.cacheDays > 0) ? cfg.cacheDays : 7; const all = await chrome.storage.local.get(null); const now = Date.now(); - const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + const maxAgeMs = days * 24 * 60 * 60 * 1000; const toRemove = []; for (const k of Object.keys(all)) { if (!k.startsWith(CACHE_PREFIX)) continue; const v = all[k]; - if (v && typeof v.savedAt === "number" && (now - v.savedAt) > SEVEN_DAYS_MS) { + if (v && typeof v.savedAt === "number" && (now - v.savedAt) > maxAgeMs) { toRemove.push(k); } } if (toRemove.length > 0) { await chrome.storage.local.remove(toRemove); - LOG.info("cache", `purgé ${toRemove.length} entrée(s) > 7 jours`, { keys: toRemove }); + LOG.info("cache", `purgé ${toRemove.length} entrée(s) > ${days}j`, { keys: toRemove }); } } @@ -5109,7 +5437,7 @@ function renderCaptureInfo(data, stats) { } function computeStats(techs, targetDate) { - let pompiers = 0, absents = 0; + let pompiers = 0, absents = 0, available = 0; let totalInterventions = 0, morning = 0, afternoon = 0; let closed = 0, resolved = 0; for (const tech of techs) { @@ -5117,6 +5445,21 @@ function computeStats(techs, targetDate) { const isAbsent = isTechAbsent(tech, targetDate); if (isPompier) pompiers++; if (isAbsent) absents++; + + // v2026.5.39 : tech disponible = pas absent/malade ET pas réservé toute + // la journée. Pompier compte comme disponible (le user peut quand même + // intervenir entre 2 départs). + const reservedAllDay = tech.interventions.some(iv => { + if (iv.type !== "AL-Reservation") return false; + const s = timeToMinutes(iv.startTime); + const e = timeToMinutes(iv.endTime); + if (s === null || e === null) return false; + return s <= DAY_START && e >= DAY_END; + }); + if (!isAbsent && !reservedAllDay) { + available++; + } + const real = tech.interventions.filter(iv => iv.type !== "AL-Absence" && !iv.isPompier ); @@ -5129,7 +5472,7 @@ function computeStats(techs, targetDate) { else if (isResolvedStatus(iv.status)) resolved++; } } - return { totalTechs: techs.length, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved }; + return { totalTechs: techs.length, available, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved }; } function renderStats(s) { @@ -5139,7 +5482,7 @@ function renderStats(s) { (${s.morning} matin · ${s.afternoon} après-midi) ${(s.closed + s.resolved > 0) ? `·${s.closed + s.resolved} clos` : ""} · - ${s.totalTechs} techs + ${s.available} tech. dispo · ${s.pompiers} pompier${s.pompiers > 1 ? "s" : ""} · @@ -5486,8 +5829,22 @@ function buildCard(tech, isoDate) { body.appendChild(stats); } - // Liste interventions + // v2026.5.39 : séparateur Matin / Après-midi entre les interventions. + // Les interv sont déjà triées par startTime (cf. parseXML), donc on insère + // un séparateur dès qu'on bascule de "matin" (<12h) à "après-midi" (>=12h). + // Si une période est vide, son séparateur n'est pas affiché. + // (Caché en vue horizontale via CSS, vu que les rows sont masquées.) + let _lastPeriod = null; for (const iv of realInterventions) { + const sMin = timeToMinutes(iv.startTime); + const period = (sMin !== null && sMin < 12 * 60) ? "morning" : "afternoon"; + if (period !== _lastPeriod) { + const sep = document.createElement("div"); + sep.className = "day-period-sep day-period-" + period; + sep.innerHTML = `${period === "morning" ? "Matin" : "Après-midi"}`; + body.appendChild(sep); + _lastPeriod = period; + } body.appendChild(buildInterventionRow(iv, card)); } @@ -5505,7 +5862,18 @@ function buildCard(tech, isoDate) { }); // Trier par heure de début partialAbsences.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || "")); + // v2026.5.39 : on continue la logique de séparation matin/après-midi. + // Si une absence partielle change de période, on insère un séparateur. for (const ab of partialAbsences) { + const sMin = timeToMinutes(ab.startTime); + const period = (sMin !== null && sMin < 12 * 60) ? "morning" : "afternoon"; + if (period !== _lastPeriod) { + const sep = document.createElement("div"); + sep.className = "day-period-sep day-period-" + period; + sep.innerHTML = `${period === "morning" ? "Matin" : "Après-midi"}`; + body.appendChild(sep); + _lastPeriod = period; + } body.appendChild(buildInterventionRow(ab, card)); } } @@ -5520,9 +5888,24 @@ function buildCard(tech, isoDate) { // v5.0.0 : constantes timeline globales (avant : locales à buildTimeline), // pour que updateNowLine puisse les utiliser aussi. -const DAY_START = 8 * 60; // 08:00 en minutes -const DAY_END = 18 * 60; // 18:00 en minutes -const DAY_LEN = DAY_END - DAY_START; +// v2026.5.39 : DAY_START/END deviennent let pour pouvoir être actualisées +// depuis admin_config (cfg.dayStart, cfg.dayEnd). Lecture faite au boot +// dans init(). Défaut 8h-18h. +let DAY_START = 8 * 60; // 08:00 en minutes +let DAY_END = 18 * 60; // 18:00 en minutes +let DAY_LEN = DAY_END - DAY_START; +async function _initDayBoundsFromConfig() { + try { + const cfg = await loadAdminConfig(); + const s = (typeof cfg.dayStart === "number" && cfg.dayStart >= 0 && cfg.dayStart <= 23) ? cfg.dayStart : 8; + const e = (typeof cfg.dayEnd === "number" && cfg.dayEnd > s && cfg.dayEnd <= 24) ? cfg.dayEnd : 18; + DAY_START = s * 60; + DAY_END = e * 60; + DAY_LEN = DAY_END - DAY_START; + } catch (err) { + LOG.warn("dayBounds", "init failed, fallback 8h-18h", { err: err && err.message }); + } +} function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) { @@ -5618,9 +6001,14 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, bar.appendChild(el); } + // v2026.5.39 : séparateur midi plus marqué (avant : ligne 1px discrète). + // On dessine maintenant une bande de 4px + un petit label "12h" au-dessus + // pour bien visualiser la pause de midi sur la timeline (utile surtout en + // vue horizontale où la timeline est la seule représentation). const noon = document.createElement("div"); noon.className = "timeline-noon"; noon.style.left = (((12 * 60) - DAY_START) / DAY_LEN) * 100 + "%"; + noon.title = "Midi"; bar.appendChild(noon); wrap.appendChild(bar); @@ -7040,12 +7428,37 @@ let bulleState = { hideTimer: null }; +// v2026.5.39 : délai d'apparition tooltip (500ms). Permet à l'user de +// passer rapidement la souris sur plusieurs cartes sans déclencher des +// popups intempestifs. +const TOOLTIP_SHOW_DELAY_MS = 500; +let _showTooltipTimer = null; +function _cancelPendingShowTooltip() { + if (_showTooltipTimer) { + clearTimeout(_showTooltipTimer); + _showTooltipTimer = null; + } +} + /** * Affiche un tooltip au survol d'une intervention/réservation. * * @author Quentin Rouiller */ function showTooltip(e, iv, rowEl) { + // v2026.5.39 : on planifie l'affichage à +500ms. Si la souris quitte + // l'élément avant, hideTooltip annule le timer (via _cancelPendingShowTooltip). + // Si on rappelle showTooltip pour un autre élément avant, on remplace le timer. + _cancelPendingShowTooltip(); + // Capturer les valeurs sérialisables maintenant (l'event peut être recyclé) + const _ev = { clientX: e.clientX, clientY: e.clientY }; + _showTooltipTimer = setTimeout(() => { + _showTooltipTimer = null; + _showTooltipImpl(_ev, iv, rowEl); + }, TOOLTIP_SHOW_DELAY_MS); +} + +function _showTooltipImpl(e, iv, rowEl) { // v2026.5.19 : pendant qu'un popup épinglé est en cours de drag, on ignore // les mouseenter sur les cartes — sinon en survolant une carte on déclenche // l'ouverture d'un nouveau tooltip par-dessus ce qu'on est en train de bouger. @@ -7121,6 +7534,8 @@ function showTooltip(e, iv, rowEl) { } function hideTooltip(opts = {}) { + // v2026.5.39 : annuler aussi un éventuel show planifié (pas encore exécuté) + _cancelPendingShowTooltip(); // Si la bulle est épinglée, on ignore (sauf force: true = unpin explicite) if (bulleState.pinned && !opts.force) return; bulleState.hoveredInRow = false; @@ -7144,7 +7559,7 @@ function hideTooltip(opts = {}) { state.currentTooltipIv = null; currentTooltipPos = null; tooltipPositionMode = null; // re-détecter à la prochaine ouverture - }, 1000); // v2026.5.17 : délai 1s au lieu de 120ms pour laisser le temps + }, 500); // v2026.5.39 : 500ms (au lieu de 1s) — assez pour entrer dans la bulle sans rester trop longtemps en hover // à l'user d'atteindre le popup depuis la carte }