Version 4.3.0 — Tooltip live libéré après épinglage

This commit is contained in:
2026-04-19 18:00:00 +02:00
parent 565075933e
commit f6d549d522
3 changed files with 426 additions and 17 deletions
+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") {