Compare commits

...

1 Commits

Author SHA1 Message Date
FroSteel f6d549d522 Version 4.3.0 — Tooltip live libéré après épinglage 2026-04-19 18:00:00 +02:00
3 changed files with 426 additions and 17 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "Planification",
"version": "4.2.8",
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.8 : liste de techniciens dans les modals Absence/Douchette entièrement visible sans scroll. Inclut v4.2.7 (URL exacte douchette).",
"version": "4.3.0",
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.3.0 : (1) conflits horaires entre interventions d'un même tech affichés en rouge + ⚠. (2) Réservations disparues retirées directement (pas de re-fetch inutile). (3) Popups épinglés détachés : plusieurs peuvent coexister, ancrés au contenu (scrollent avec la page), auto-positionnés sans se marcher dessus (toast si pas de place), Échap pour tout fermer, Ctrl×2 pour fermer si un seul épinglé. Inclut v4.2.9.",
"permissions": [
"activeTab",
"scripting",
+91
View File
@@ -1751,3 +1751,94 @@ html, body {
.modal-actions.horizontal .btn {
flex: 1;
}
/* ─────────────────────────────────────────────────────────────────────────
v4.2.9 : blocage du scroll arrière quand une modal est ouverte.
La classe body.modal-open est ajoutée/retirée automatiquement par
initModalScrollLock() dans viewer.js dès qu'un .modal-overlay existe.
───────────────────────────────────────────────────────────────────────── */
body.modal-open {
overflow: hidden;
}
/* v4.2.9 : pied de page discret en bas à droite — affiche auteur + date + version */
.app-footer {
position: fixed;
right: 8px;
bottom: 4px;
font-size: 10px;
color: var(--text-faint, #8892a0);
opacity: 0.55;
pointer-events: none; /* ne capture pas les clics */
user-select: none;
font-variant-numeric: tabular-nums;
letter-spacing: 0.2px;
z-index: 1; /* sous les modals (qui sont à 10000) */
}
.app-footer:hover {
opacity: 0.85;
}
/* ─────────────────────────────────────────────────────────────────────────
v4.3.0 : conflit d'horaire entre 2 interventions d'un même tech.
Les heures s'affichent en rouge + icône ⚠ à côté.
───────────────────────────────────────────────────────────────────────── */
.iv-time-vertical.iv-time-overlap .iv-time-start,
.iv-time-vertical.iv-time-overlap .iv-time-end,
.iv-time-vertical.iv-time-overlap .iv-time-arrow {
color: var(--danger, #b03030) !important;
font-weight: 700;
}
.iv-time-overlap-warn {
color: var(--danger, #b03030);
font-size: 14px;
font-weight: 700;
line-height: 1;
margin-top: 2px;
cursor: help;
text-align: center;
}
/* ─────────────────────────────────────────────────────────────────────────
v4.3.0 : popups épinglés détachés
Ancrés au contenu (position:absolute coord document) → scrollent avec
la page. Persistent jusqu'à fermeture explicite.
───────────────────────────────────────────────────────────────────────── */
.tooltip.pinned-popup {
position: absolute !important; /* override le fixed du .tooltip */
z-index: 500; /* au-dessus du contenu, sous les modals (10000) */
opacity: 1 !important;
pointer-events: auto !important;
/* Bordure plus visible pour distinguer du tooltip live */
border: 2px solid var(--accent, #0f4f8b);
box-shadow: 0 8px 24px rgba(0,0,0,0.18);
/* Pas de contain: layout (hérité) car ça limite le rendu ; on laisse */
animation: pinned-popup-in 0.15s ease-out;
}
@keyframes pinned-popup-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
/* Bouton × de fermeture du popup épinglé */
.pinned-popup-close {
position: absolute;
top: 4px;
right: 6px;
width: 22px;
height: 22px;
padding: 0;
line-height: 1;
font-size: 18px;
font-weight: 400;
color: var(--text-muted, #888);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.pinned-popup-close:hover {
background: var(--danger-soft, #fbe6e6);
color: var(--danger, #b03030);
}
+333 -15
View File
@@ -214,6 +214,8 @@ async function init() {
initTheme();
bindTopbar();
bindTooltipInteractions();
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
@@ -458,6 +460,10 @@ function bindTopbar() {
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
hideTooltip({ force: true });
}
// v4.3.0 : Échap ferme TOUS les popups épinglés (le user veut tout fermer)
if (typeof closeAllPinnedPopups === "function") {
closeAllPinnedPopups();
}
}
});
@@ -675,6 +681,45 @@ function showAlertModal(opts) {
document.addEventListener("keydown", escHandler);
}
// ============================================================================
// v4.2.9 : blocage du scroll en arrière-plan quand un modal est ouvert
// ============================================================================
//
// Un MutationObserver surveille l'apparition/disparition de tout élément
// .modal-overlay dans le body. Dès qu'il y en a au moins un, on ajoute la
// classe `modal-open` sur body → CSS bloque le scroll. Quand le dernier
// modal disparaît, la classe est retirée.
//
// Centralisé ici pour que TOUS les modals (existants et futurs) en profitent
// sans modification individuelle.
function initModalScrollLock() {
const updateLock = () => {
const hasModal = document.querySelector(".modal-overlay") !== null;
document.body.classList.toggle("modal-open", hasModal);
};
const observer = new MutationObserver(updateLock);
observer.observe(document.body, { childList: true, subtree: false });
updateLock(); // au cas où un modal serait déjà là au boot
}
// v4.2.9 : pied de page discret "QRO / Mois Année / vX.X.X" en bas à droite.
// La version est lue depuis le manifest (source unique de vérité).
function initAppFooter() {
if (document.querySelector(".app-footer")) return;
let version = "";
try {
const manifest = chrome && chrome.runtime && chrome.runtime.getManifest
? chrome.runtime.getManifest() : null;
if (manifest && manifest.version) version = "v" + manifest.version;
} catch (e) {}
const dateStr = "Avril 26"; // mois/année de release de cette itération
const el = document.createElement("div");
el.className = "app-footer";
el.textContent = `QRO / ${dateStr}${version ? " / " + version : ""}`;
document.body.appendChild(el);
}
// ============================================================================
// v4.2.6 : Modals Absence et Douchette
// ============================================================================
@@ -1980,6 +2025,17 @@ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken)
* et iv._disappearRemove (true si à retirer).
*/
async function analyzeOneDisappearedIv(tech, iv) {
// v4.3.0 : court-circuit pour les réservations (AL-Reservation). Elles n'ont
// pas de notion de "terminé par tech" ni de statut clos/résolu à afficher
// (pas de fiche à ouvrir). Quand une réservation disparaît du planning,
// elle est juste retirée — inutile de re-fetcher sa fiche.
if (iv.type === "AL-Reservation") {
iv._disappearChecking = false;
iv._disappearStatus = "cancelled";
iv._disappearRemove = true;
return;
}
// Étape 1 : re-fetch la fiche
const resp = await sendMessage({
type: "fetchFiche",
@@ -2933,6 +2989,10 @@ function renderFromData(data) {
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("cards").classList.remove("hidden");
// v4.3.0 : détecter les conflits d'horaire entre interventions d'un même
// tech (même heure de début OU chevauchement).
detectOverlaps(data.techs);
// Calculer les stats
const stats = computeStats(data.techs, data.targetDate);
renderCaptureInfo(data, stats);
@@ -2940,6 +3000,51 @@ function renderFromData(data) {
renderCards(data);
}
// v4.3.0 : détection des conflits d'horaire entre interventions d'un même tech.
// Marque iv._hasOverlap = true pour chaque intervention en conflit avec une
// autre (même heure de début OU chevauchement de créneaux).
// Les absences récurrentes, tickets fantômes à retirer, et réservations
// sont ignorés (pas de conflit pertinent pour eux).
function detectOverlaps(techs) {
if (!techs) return;
for (const tech of techs) {
const ivs = (tech.interventions || []).filter(iv =>
iv && iv.startTime && iv.endTime &&
!iv._disappearRemove &&
iv.type !== "AL-Reservation"
);
// Reset flag sur toutes les inters du tech (y compris celles ignorées)
for (const iv of (tech.interventions || [])) {
iv._hasOverlap = false;
}
// Convertir HH:MM en minutes pour comparaison rapide
const toMin = (hhmm) => {
if (!hhmm) return null;
const parts = hhmm.split(":");
if (parts.length < 2) return null;
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
if (isNaN(h) || isNaN(m)) return null;
return h * 60 + m;
};
// Comparer chaque paire
for (let i = 0; i < ivs.length; i++) {
for (let j = i + 1; j < ivs.length; j++) {
const a = ivs[i], b = ivs[j];
const aStart = toMin(a.startTime), aEnd = toMin(a.endTime);
const bStart = toMin(b.startTime), bEnd = toMin(b.endTime);
if (aStart === null || aEnd === null || bStart === null || bEnd === null) continue;
// Chevauchement = a commence avant que b finisse ET b commence avant que a finisse.
// Inclut aussi le cas "même heure de début" (aStart === bStart).
if (aStart < bEnd && bStart < aEnd) {
a._hasOverlap = true;
b._hasOverlap = true;
}
}
}
}
}
function renderCaptureInfo(data, stats) {
const info = document.getElementById("capture-info");
if (refreshCounter > 0) {
@@ -3663,6 +3768,10 @@ function buildInterventionRow(iv, cardEl) {
// ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ─────────────────
const timeEl = document.createElement("div");
timeEl.className = "iv-time-vertical";
// v4.3.0 : marquer rouge + icône ⚠ si conflit horaire détecté
if (iv._hasOverlap) {
timeEl.classList.add("iv-time-overlap");
}
if (iv.startTime && iv.endTime) {
const s = document.createElement("div");
s.className = "iv-time-start";
@@ -3676,6 +3785,14 @@ function buildInterventionRow(iv, cardEl) {
timeEl.appendChild(s);
timeEl.appendChild(sep);
timeEl.appendChild(e);
// v4.3.0 : icône d'alerte à côté des heures si conflit
if (iv._hasOverlap) {
const warn = document.createElement("div");
warn.className = "iv-time-overlap-warn";
warn.textContent = "⚠";
warn.title = "Conflit d'horaire avec une autre intervention";
timeEl.appendChild(warn);
}
} else {
timeEl.textContent = "—";
}
@@ -4799,13 +4916,207 @@ function positionTooltipAnchored(rowEl) {
setTooltipViewportPosition(x, y);
}
// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned"
// qui change le curseur (text) et autorise la sélection.
// ============================================================================
// v4.3.0 : système de popups épinglés détachés
// ============================================================================
//
// Au lieu d'épingler le tooltip unique (qui empêchait d'afficher d'autres
// infos au survol), on clone son contenu en un popup indépendant :
// - Ancré DANS le contenu de la page (position: absolute + coordonnées
// document) → scrolle avec le contenu, pas avec le viewport.
// - Peut coexister avec d'autres popups épinglés (jusqu'à ce qu'il n'y
// ait plus de place disponible).
// - Persiste jusqu'à fermeture explicite (bouton ×, Échap, ou Ctrl×2 si 1 seul).
//
// Le tooltip live (#tooltip) garde son rôle initial : il se ferme au mouseleave.
const pinnedPopups = []; // [{el, iv, rect}]
/**
* Ancre la popup au contenu : ajoute le scrollY actuel au top viewport pour
* obtenir une position absolute document, qui scrolle avec le contenu.
*/
function _viewportToDocumentY(y) {
return y + (window.scrollY || window.pageYOffset || 0);
}
function _viewportToDocumentX(x) {
return x + (window.scrollX || window.pageXOffset || 0);
}
/**
* Teste si un rectangle {left, top, right, bottom} (en coords document)
* chevauche avec un popup déjà épinglé.
*/
function _rectsOverlap(a, b) {
return !(a.right <= b.left || a.left >= b.right ||
a.bottom <= b.top || a.top >= b.bottom);
}
/**
* Cherche une position libre pour un popup de dimensions {w, h} près de la
* ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus.
* Retourne {x, y} en coordonnées document, ou null si aucune position libre.
*/
function _findFreePopupPosition(rowEl, w, h) {
const pad = 14;
const rowRect = rowEl.getBoundingClientRect();
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
// 4 candidats, en coords viewport
const candidates = [
// Droite
{ x: rowRect.right + pad, y: rowRect.top, name: "droite" },
// Gauche
{ x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" },
// Dessous
{ x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" },
// Dessus
{ x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" }
];
// Pour chaque candidat, clamper dans le viewport (marge 8px) et convertir
// en coord document, puis tester le chevauchement
for (const c of candidates) {
let x = c.x, y = c.y;
// Clamp horizontal dans le viewport
if (x < 4) x = 4;
if (x + w > viewportW - 8) x = viewportW - 8 - w;
// Clamp vertical dans le viewport
if (y < 4) y = 4;
if (y + h > viewportH - 8) y = viewportH - 8 - h;
// Si, après clamp, la popup chevaucherait la ligne source elle-même,
// on ignore ce candidat (on préfère une direction qui la laisse visible).
const rowRectClamped = {
left: rowRect.left, top: rowRect.top,
right: rowRect.right, bottom: rowRect.bottom
};
const candRect = { left: x, top: y, right: x + w, bottom: y + h };
if (_rectsOverlap(candRect, rowRectClamped)) continue;
// Test chevauchement avec les popups déjà épinglés
const docRect = {
left: _viewportToDocumentX(x),
top: _viewportToDocumentY(y),
right: _viewportToDocumentX(x + w),
bottom: _viewportToDocumentY(y + h)
};
let overlapsOther = false;
for (const p of pinnedPopups) {
if (_rectsOverlap(docRect, p.rect)) {
overlapsOther = true;
break;
}
}
if (!overlapsOther) {
// Position libre trouvée
return {
viewportX: x, viewportY: y,
docX: docRect.left, docY: docRect.top,
rect: docRect
};
}
}
return null;
}
/**
* v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché
* ancré au contenu. Le tooltip live redevient disponible.
*/
function pinTooltip() {
if (!state.currentTooltipIv) return;
bulleState.pinned = true;
const el = tooltipEl();
el.classList.add("pinned");
const srcEl = tooltipEl();
if (!srcEl) return;
const iv = state.currentTooltipIv;
// Chercher la ligne source (row iv-v2)
let rowEl = null;
if (iv.actionId) {
rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`);
}
if (!rowEl) {
// Fallback : utiliser la position actuelle du tooltip live
rowEl = srcEl;
}
// Cloner le contenu du tooltip actuel en popup détaché
const popup = document.createElement("div");
popup.className = "tooltip pinned-popup visible";
popup.dataset.actionId = iv.actionId || "";
popup.innerHTML = srcEl.innerHTML;
// Ajouter un bouton × de fermeture (en plus du 📌)
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "pinned-popup-close";
closeBtn.innerHTML = "×";
closeBtn.title = "Fermer";
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
_closePinnedPopup(popup);
});
popup.appendChild(closeBtn);
// Placer en (0,0) temporairement pour mesurer la taille
popup.style.position = "absolute";
popup.style.left = "-9999px";
popup.style.top = "-9999px";
popup.style.visibility = "hidden";
document.body.appendChild(popup);
// Mesurer après rendu
const pRect = popup.getBoundingClientRect();
const w = pRect.width;
const h = pRect.height;
// Chercher une position libre
const pos = _findFreePopupPosition(rowEl, w, h);
if (!pos) {
// Pas de place : retirer et afficher un toast
popup.remove();
showToast("Pas de place", "Fermez une popup épinglée");
return;
}
// Appliquer la position (coords document = position: absolute)
popup.style.left = pos.docX + "px";
popup.style.top = pos.docY + "px";
popup.style.visibility = "visible";
// Enregistrer dans la liste
pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect });
// v4.3.0 : libérer le tooltip live (il redevient utilisable pour d'autres survols)
bulleState.pinned = false;
bulleState.hoveredInRow = false;
bulleState.hoveredInBulle = false;
srcEl.classList.remove("visible", "pinned");
srcEl.classList.add("hidden");
if (srcEl.dataset) delete srcEl.dataset.mode;
state.currentTooltipIv = null;
currentTooltipPos = null;
tooltipPositionMode = null;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
}
/** Ferme un popup épinglé donné. */
function _closePinnedPopup(el) {
const idx = pinnedPopups.findIndex(p => p.el === el);
if (idx >= 0) pinnedPopups.splice(idx, 1);
el.remove();
}
/** Ferme tous les popups épinglés. */
function closeAllPinnedPopups() {
for (const p of pinnedPopups.slice()) {
p.el.remove();
}
pinnedPopups.length = 0;
}
// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans
@@ -5008,22 +5319,29 @@ function bindTooltipInteractions() {
}
});
// Double-Ctrl : pin/unpin
// Double-Ctrl : v4.3.0
// - Si 0 popup épinglé ET un tooltip live visible : épingler
// - Si EXACTEMENT 1 popup épinglé ET souris pas dessus : le fermer
// - Si 2+ popups épinglés : ne fait rien (ambigu, user doit utiliser Échap)
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
let lastCtrlTs = 0;
document.addEventListener("keydown", (e) => {
if (e.key !== "Control") return;
// Ignorer si la touche est répétée (hold)
if (e.repeat) return;
const now = performance.now();
if (now - lastCtrlTs < 400) {
// Double-Ctrl détecté
lastCtrlTs = 0;
if (bulleState.pinned) {
unpinTooltip();
} else if (state.currentTooltipIv) {
pinTooltip();
if (pinnedPopups.length === 0) {
// Aucun popup épinglé : épingler le tooltip live s'il y en a un
if (state.currentTooltipIv) pinTooltip();
} else if (pinnedPopups.length === 1) {
// 1 popup épinglé : le fermer si la souris n'est pas dessus
const p = pinnedPopups[0];
if (!p.el.matches(":hover")) {
_closePinnedPopup(p.el);
}
}
// 2+ popups : rien faire (Échap pour tout fermer)
} else {
lastCtrlTs = now;
}
@@ -5037,9 +5355,9 @@ function bindTooltipInteractions() {
e.preventDefault();
const action = btn.dataset.action;
if (action === "pin") {
if (bulleState.pinned) {
unpinTooltip();
} else if (state.currentTooltipIv) {
// v4.3.0 : toujours épingler (le tooltip live clone son contenu en popup
// détaché). Pour désépingler, l'user utilise × sur le popup, ou Échap.
if (state.currentTooltipIv) {
pinTooltip();
}
} else if (action === "reload") {