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.
This commit is contained in:
@@ -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