Compare commits
1 Commits
v2026.5.38
...
v2026.5.39
| Author | SHA1 | Date | |
|---|---|---|---|
| a5dc0b3365 |
@@ -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
|
||||
|
||||
|
||||
+21
-4
@@ -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 }));
|
||||
});
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+367
-22
@@ -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 */
|
||||
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 <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);
|
||||
}
|
||||
|
||||
@@ -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 = '<span class="settings-ico">⊞</span> Vue : '
|
||||
+ (currentView === "horizontal" ? "Horizontale" : "Classique");
|
||||
viewBtn.title = "Changer la disposition du planning";
|
||||
if (currentView === "horizontal") {
|
||||
viewBtn.innerHTML = '<span class="settings-ico">⊞</span> Passer en vue Classique';
|
||||
} 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) => {
|
||||
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/<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) {
|
||||
@@ -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) {
|
||||
<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>` : ""}
|
||||
<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"><b>${s.pompiers}</b> pompier${s.pompiers > 1 ? "s" : ""}</span>
|
||||
<span class="global-stat-sep">·</span>
|
||||
@@ -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 = `<span class="day-period-label">${period === "morning" ? "Matin" : "Après-midi"}</span>`;
|
||||
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 = `<span class="day-period-label">${period === "morning" ? "Matin" : "Après-midi"}</span>`;
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user