Compare commits

...

1 Commits

Author SHA1 Message Date
FroSteel a5dc0b3365 v2026.5.39 — Séparation matin/après-midi + Apparence (thème, taille, cache, heures) + À propos
Séparation matin / après-midi
- Pill "MATIN" / "APRÈS-MIDI" entre interventions (vue classique), grise
  neutre, ligne 3px épaisse. Affiché aussi entre les absences partielles.
- Si une période est vide, son séparateur n'apparaît pas.

Timeline — coupure midi très visible
- Bande verticale composée d'un trait massif + stripes diagonales (effet
  césure). Visible immédiatement, sans label superflu.

Vue horizontale (sidebar)
- Tout centré horizontalement (align-items + text-align)
- min-height: calc(100vh * --zoom-inv) — sidebar atteint toujours le bas
  de l'écran, même quand le user dézoom le texte
- Bouton "Aujourd'hui" : style identique aux autres boutons (Absence,
  Douchette...), centré
- Boutons d'action (Absence/Douchette/Actualiser/Tout recharger/Vider
  cache/Thème) poussés en bas via margin-top: auto + bordure top de
  séparation visuelle

Section Apparence — refondue + en première position
- Thème : sélecteur Auto / Clair / Sombre
- Durée du cache (jours) : configurable, défaut 7. Lue par viewer (purge
  auto en cas de quota) ET background (au boot). Tooltip au survol qui
  montre l'emplacement physique du cache (adapté browser + OS)
- Taille du texte : slider horizontal avec 5 dots, 5 paliers (-30%, -15%,
  100%, +10%, +20%). Zoom appliqué uniquement au release (pas pendant le
  drag) pour éviter l'effet yo-yo. Couvre TOUS les textes visibles
  (interventions, popups, absences, réservations, "En pompier du...",
  date+heure de la même taille, etc.)
- Heures de la journée : 2 inputs Début/Fin, défaut 8h-18h. Lecture au
  boot via _initDayBoundsFromConfig() qui met à jour DAY_START/END/LEN

Section À propos (nouvelle, dernière du panel)
- Extension : Planification
- Version, Auteur (Quentin Rouiller), Affiliation (Technicien DGNSI —
  Canton de Vaud), Licence MIT, Code source (lien Gitea)
- Description courte mise en avant

Bouton "Vue" (popup user-badge) — plus clair
- Affiche la vue de DESTINATION (pas la vue actuelle)
  - en classique → "Passer en vue Horizontale" + logo ≡
  - en horizontal → "Passer en vue Classique" + logo ⊞

Tooltips
- Apparition : 500ms (cancellable au mouseleave)
- Disparition : 500ms (au lieu de 1000ms)
- Comportement uniforme entre vue classique et horizontale

Stats
- "X tech. dispo" (nouveau) : disponibles = pas absent + pas réservé
  toute la journée. Pompier compte comme disponible.
2026-04-26 02:20:00 +02:00
5 changed files with 874 additions and 58 deletions
+39
View File
@@ -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 ## v2026.5.38 — Attribution auteur + nettoyage code
**Branche** : current **Branche** : current
+21 -4
View File
@@ -1380,13 +1380,30 @@ async function cleanupOldCaches(daysToKeep) {
return toRemove.length; 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 // Au démarrage, nettoyer les anciennes alarmes et les anciens caches
chrome.runtime.onInstalled.addListener(() => { chrome.runtime.onInstalled.addListener(async () => {
clearLegacyRefreshAlarms(); 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(); 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 }));
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Planification", "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.", "description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.",
"permissions": [ "permissions": [
"activeTab", "activeTab",
+368 -23
View File
@@ -144,6 +144,17 @@ html, body {
min-width: 0; 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 { .topbar h1 {
margin: 0; margin: 0;
font-size: 18px; font-size: 18px;
@@ -761,14 +772,40 @@ html, body {
z-index: 2; 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 { .timeline-noon {
position: absolute; position: absolute;
top: -2px; top: 0;
bottom: -2px; bottom: 0;
width: 1px; width: 6px;
background: var(--border-strong); z-index: 3;
z-index: 1;
pointer-events: none; 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 { .timeline-scale {
@@ -3849,9 +3886,10 @@ html.view-horizontal body > header.topbar {
} }
/* 2. Sidebar : structure verticale avec section fixe en haut (user+titre+date) /* 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 { html.view-horizontal .horizontal-sidebar {
max-height: 100vh !important;
padding-top: 12px !important; padding-top: 12px !important;
} }
@@ -3879,23 +3917,14 @@ html.view-horizontal .horizontal-sidebar #app-title {
html.view-horizontal .horizontal-sidebar .date-nav { html.view-horizontal .horizontal-sidebar .date-nav {
display: contents; 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 { 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; width: 100% !important;
justify-content: center !important; /* centrage du label */
text-align: center !important; 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" */ /* 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 /* 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 { html.view-horizontal .horizontal-sidebar #absence-btn {
order: 7; order: 7;
margin-top: auto !important; /* pousse tout ce qui suit en bas */ 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 #douchette-btn { order: 8; }
html.view-horizontal .horizontal-sidebar #refresh-partial-btn { order: 9; } 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; 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 { html.view-horizontal .horizontal-sidebar {
display: flex !important; display: flex !important;
flex-direction: column !important; flex-direction: column !important;
align-items: center !important; /* centrage horizontal des enfants */
text-align: center !important; /* centrage des textes */
gap: 6px !important; 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 /* 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; letter-spacing: 0.2px;
user-select: none; 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 <html> 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 = "<pct>" 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);
}
+445 -30
View File
@@ -364,6 +364,11 @@ document.addEventListener("DOMContentLoaded", init);
async function init() { async function init() {
initTheme(); 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(); bindTopbar();
bindTooltipInteractions(); bindTooltipInteractions();
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal 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.25 : bouton Paramètres (remplace les 5 clics sur le titre)
// v2026.5.32 : bouton "Vue" pour basculer Vue classique ↔ Vue horizontale // 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"); const viewBtn = document.createElement("button");
viewBtn.type = "button"; viewBtn.type = "button";
viewBtn.className = "user-name-popup-settings"; viewBtn.className = "user-name-popup-settings";
const currentView = _getCurrentView(); const currentView = _getCurrentView();
viewBtn.innerHTML = '<span class="settings-ico">⊞</span> Vue : ' if (currentView === "horizontal") {
+ (currentView === "horizontal" ? "Horizontale" : "Classique"); viewBtn.innerHTML = '<span class="settings-ico">⊞</span> Passer en vue Classique';
viewBtn.title = "Changer la disposition du planning"; } else {
viewBtn.innerHTML = '<span class="settings-ico">≡</span> Passer en vue Horizontale';
}
viewBtn.title = "Bascule entre vue classique (cards en grille) et vue horizontale (1 ligne par tech)";
viewBtn.addEventListener("click", (e) => { viewBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
hideUserNamePopup(); hideUserNamePopup();
@@ -1259,6 +1272,13 @@ function _applyViewMode() {
_restoreElementsToTopbar(ELEMENTS_TO_RELOCATE); _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"); const content = document.createElement("div");
content.className = "admin-content"; content.className = "admin-content";
// v2026.5.39 : Apparence en 1re. À propos en dernière (après Diagnostics).
const sections = [ const sections = [
{ id: "appearance", label: "Apparence", render: renderAdminSectionAppearance },
{ id: "team", label: "Équipe", render: renderAdminSectionTeam }, { id: "team", label: "Équipe", render: renderAdminSectionTeam },
{ id: "easyvista", label: "EasyVista", render: renderAdminSectionEV }, { id: "easyvista", label: "EasyVista", render: renderAdminSectionEV },
{ id: "appearance", label: "Apparence", render: renderAdminSectionAppearance },
{ id: "statuses", label: "Statuts", render: renderAdminSectionStatuses }, { 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 = {}; const navButtons = {};
for (const section of sections) { for (const section of sections) {
@@ -2335,23 +2357,327 @@ function renderAdminSectionEV(container, cfg, saveFn) {
container.appendChild(pre); 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) { function renderAdminSectionAppearance(container, cfg, saveFn) {
const h = document.createElement("h3"); const h = document.createElement("h3");
h.textContent = "Apparence"; h.textContent = "Apparence";
h.className = "admin-section-title"; h.className = "admin-section-title";
container.appendChild(h); container.appendChild(h);
const desc = document.createElement("p");
desc.className = "admin-section-desc"; // ---- Champ Thème (1er) ----
desc.textContent = "Section à venir dans v5.0.x. Heures journée, durée cache, thème."; const themeRow = _makeAdminRow(
container.appendChild(desc); "Thème",
const pre = document.createElement("pre"); "Clair, sombre ou suit l'OS."
pre.className = "admin-readonly"; );
pre.textContent = JSON.stringify({ const themeSelect = document.createElement("select");
dayStart: cfg.dayStart, themeSelect.className = "admin-select";
dayEnd: cfg.dayEnd, for (const opt of [
cacheDays: cfg.cacheDays { val: "auto", label: "Automatique (selon l'OS)" },
}, null, 2); { val: "light", label: "Clair" },
container.appendChild(pre); { 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/<profil>/");
lines.push(" storage/default/moz-extension+++" + extId + "/idb/");
} else if (isWin) {
lines.push("%APPDATA%\\Mozilla\\Firefox\\Profiles\\<profil>\\");
lines.push(" storage\\default\\moz-extension+++" + extId + "\\idb\\");
} else if (isLinux) {
lines.push("~/.mozilla/firefox/<profil>/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 = `
<div class="admin-about-row">
<div class="admin-about-label">Extension</div>
<div class="admin-about-value"><strong>Planification</strong></div>
</div>
<div class="admin-about-row">
<div class="admin-about-label">Version</div>
<div class="admin-about-value">v${escapeHtml(v)}</div>
</div>
<div class="admin-about-row">
<div class="admin-about-label">Auteur</div>
<div class="admin-about-value"><strong>Quentin Rouiller</strong></div>
</div>
<div class="admin-about-row">
<div class="admin-about-label">Affiliation</div>
<div class="admin-about-value">Technicien DGNSI Canton de Vaud</div>
</div>
<div class="admin-about-desc">
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).
</div>
<div class="admin-about-row">
<div class="admin-about-label">Licence</div>
<div class="admin-about-value">MIT</div>
</div>
<div class="admin-about-row">
<div class="admin-about-label">Code source</div>
<div class="admin-about-value"><a href="https://gitea.netaplaid.ch/FroSteel/Planification" target="_blank" rel="noopener">gitea.netaplaid.ch/FroSteel/Planification</a></div>
</div>
`;
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) { 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() { 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 all = await chrome.storage.local.get(null);
const now = Date.now(); const now = Date.now();
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const maxAgeMs = days * 24 * 60 * 60 * 1000;
const toRemove = []; const toRemove = [];
for (const k of Object.keys(all)) { for (const k of Object.keys(all)) {
if (!k.startsWith(CACHE_PREFIX)) continue; if (!k.startsWith(CACHE_PREFIX)) continue;
const v = all[k]; 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); toRemove.push(k);
} }
} }
if (toRemove.length > 0) { if (toRemove.length > 0) {
await chrome.storage.local.remove(toRemove); 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) { function computeStats(techs, targetDate) {
let pompiers = 0, absents = 0; let pompiers = 0, absents = 0, available = 0;
let totalInterventions = 0, morning = 0, afternoon = 0; let totalInterventions = 0, morning = 0, afternoon = 0;
let closed = 0, resolved = 0; let closed = 0, resolved = 0;
for (const tech of techs) { for (const tech of techs) {
@@ -5117,6 +5445,21 @@ function computeStats(techs, targetDate) {
const isAbsent = isTechAbsent(tech, targetDate); const isAbsent = isTechAbsent(tech, targetDate);
if (isPompier) pompiers++; if (isPompier) pompiers++;
if (isAbsent) absents++; 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 => const real = tech.interventions.filter(iv =>
iv.type !== "AL-Absence" && !iv.isPompier iv.type !== "AL-Absence" && !iv.isPompier
); );
@@ -5129,7 +5472,7 @@ function computeStats(techs, targetDate) {
else if (isResolvedStatus(iv.status)) resolved++; 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) { function renderStats(s) {
@@ -5139,7 +5482,7 @@ function renderStats(s) {
<span class="global-stat global-stat-sub">(${s.morning} matin · ${s.afternoon} après-midi)</span> <span class="global-stat global-stat-sub">(${s.morning} matin · ${s.afternoon} après-midi)</span>
${(s.closed + s.resolved > 0) ? `<span class="global-stat-sep">·</span><span class="global-stat"><b>${s.closed + s.resolved}</b> clos</span>` : ""} ${(s.closed + s.resolved > 0) ? `<span class="global-stat-sep">·</span><span class="global-stat"><b>${s.closed + s.resolved}</b> clos</span>` : ""}
<span class="global-stat-sep">·</span> <span class="global-stat-sep">·</span>
<span class="global-stat"><b>${s.totalTechs}</b> techs</span> <span class="global-stat" title="Techs réellement disponibles aujourd'hui (hors absents et réservés toute la journée — les pompiers comptent)"><b>${s.available}</b> tech. dispo</span>
<span class="global-stat-sep">·</span> <span class="global-stat-sep">·</span>
<span class="global-stat"><b>${s.pompiers}</b> pompier${s.pompiers > 1 ? "s" : ""}</span> <span class="global-stat"><b>${s.pompiers}</b> pompier${s.pompiers > 1 ? "s" : ""}</span>
<span class="global-stat-sep">·</span> <span class="global-stat-sep">·</span>
@@ -5486,8 +5829,22 @@ function buildCard(tech, isoDate) {
body.appendChild(stats); 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) { 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 = `<span class="day-period-label">${period === "morning" ? "Matin" : "Après-midi"}</span>`;
body.appendChild(sep);
_lastPeriod = period;
}
body.appendChild(buildInterventionRow(iv, card)); body.appendChild(buildInterventionRow(iv, card));
} }
@@ -5505,7 +5862,18 @@ function buildCard(tech, isoDate) {
}); });
// Trier par heure de début // Trier par heure de début
partialAbsences.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || "")); 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) { 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 = `<span class="day-period-label">${period === "morning" ? "Matin" : "Après-midi"}</span>`;
body.appendChild(sep);
_lastPeriod = period;
}
body.appendChild(buildInterventionRow(ab, card)); body.appendChild(buildInterventionRow(ab, card));
} }
} }
@@ -5520,9 +5888,24 @@ function buildCard(tech, isoDate) {
// v5.0.0 : constantes timeline globales (avant : locales à buildTimeline), // v5.0.0 : constantes timeline globales (avant : locales à buildTimeline),
// pour que updateNowLine puisse les utiliser aussi. // pour que updateNowLine puisse les utiliser aussi.
const DAY_START = 8 * 60; // 08:00 en minutes // v2026.5.39 : DAY_START/END deviennent let pour pouvoir être actualisées
const DAY_END = 18 * 60; // 18:00 en minutes // depuis admin_config (cfg.dayStart, cfg.dayEnd). Lecture faite au boot
const DAY_LEN = DAY_END - DAY_START; // 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) { function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl, isPompier, isAbsent) {
@@ -5618,9 +6001,14 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl,
bar.appendChild(el); 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"); const noon = document.createElement("div");
noon.className = "timeline-noon"; noon.className = "timeline-noon";
noon.style.left = (((12 * 60) - DAY_START) / DAY_LEN) * 100 + "%"; noon.style.left = (((12 * 60) - DAY_START) / DAY_LEN) * 100 + "%";
noon.title = "Midi";
bar.appendChild(noon); bar.appendChild(noon);
wrap.appendChild(bar); wrap.appendChild(bar);
@@ -7040,12 +7428,37 @@ let bulleState = {
hideTimer: null 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. * Affiche un tooltip au survol d'une intervention/réservation.
* *
* @author Quentin Rouiller * @author Quentin Rouiller
*/ */
function showTooltip(e, iv, rowEl) { 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 // 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 // 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. // 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 = {}) { 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) // Si la bulle est épinglée, on ignore (sauf force: true = unpin explicite)
if (bulleState.pinned && !opts.force) return; if (bulleState.pinned && !opts.force) return;
bulleState.hoveredInRow = false; bulleState.hoveredInRow = false;
@@ -7144,7 +7559,7 @@ function hideTooltip(opts = {}) {
state.currentTooltipIv = null; state.currentTooltipIv = null;
currentTooltipPos = null; currentTooltipPos = null;
tooltipPositionMode = null; // re-détecter à la prochaine ouverture 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 // à l'user d'atteindre le popup depuis la carte
} }