From f54ccd28d2f7ad79184bfb11d0560a41c5b7abbe Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Tue, 21 Apr 2026 11:00:00 +0200 Subject: [PATCH] =?UTF-8?q?Version=202026.5.17=20=E2=80=94=20Popup=20user-?= =?UTF-8?q?badge=20avec=20ligne=20session=20(MM:SS)=20-=20Couleur=20selon?= =?UTF-8?q?=20seuil=20[code=20interpol=C3=A9]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 8 +- manifest.json | 2 +- viewer.css | 57 +++++++++++-- viewer.html | 9 ++- viewer.js | 218 ++++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 274 insertions(+), 20 deletions(-) diff --git a/background.js b/background.js index 5079a86..e5776f6 100644 --- a/background.js +++ b/background.js @@ -193,12 +193,8 @@ async function fetchFicheHtml(origin, phpsessid, formLink) { // Sinon : on retourne ce qu'on a return html; } - const html = await r.text(); - console.log("[bg] fiche status =", r.status, "| taille =", html.length); - if (html.length < 500) { - console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html)); - } - return html; + // Ne devrait pas arriver (la boucle fait return avant) + throw new Error("fetchFicheHtml: max retries reached"); } // v4.1.7 : API timeline EasyVista. Renvoie le JSON des actions d'une fiche, diff --git a/manifest.json b/manifest.json index 8667fb4..aa549ff 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "2026.5.16", + "version": "2026.5.17", "description": "Vue claire et rapide du planning des techniciens EasyVista. Regroupe interventions et réservations par tech, affiche horaires, contact, lieu, catégorie et statut en un coup d'œil.", "browser_specific_settings": { "gecko": { diff --git a/viewer.css b/viewer.css index cdb7dc4..38e9c89 100644 --- a/viewer.css +++ b/viewer.css @@ -323,6 +323,54 @@ html, body { flex-wrap: nowrap; } +/* v2026.5.17 : faux input date custom avec nom du jour */ +.date-custom-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} +.date-custom { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 10px 5px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-muted); + color: var(--text); + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + user-select: none; + transition: border-color 0.15s, background 0.15s; +} +.date-custom:hover { + border-color: var(--border-strong); + background: var(--bg-hover); +} +.date-custom:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} +.date-custom-icon { + font-size: 13px; + opacity: 0.7; +} +.date-input-hidden { + position: absolute; + top: 100%; + left: 0; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +/* v2026.5.17 : masquer l'ancien date-picker-day s'il traîne (compat) */ +.date-picker-day { display: none; } + .btn-nav { padding: 6px 10px; font-size: 13px; @@ -690,12 +738,9 @@ html, body { .timeline-slot.status-resolved { background: var(--c-resolved); } .timeline-slot.kind-absence { - background: repeating-linear-gradient( - 45deg, - var(--text-faint) 0 6px, - var(--bg-muted) 6px 12px - ); - opacity: 0.6; + /* v5.0.15 : uni gris-noir au lieu de rayé, plus lisible */ + background: #2a2f36; + border-right: 1px solid var(--bg-elevated); } .timeline-slot:hover, diff --git a/viewer.html b/viewer.html index 968f53e..985b243 100644 --- a/viewer.html +++ b/viewer.html @@ -16,7 +16,14 @@

Planification

- + +
+
+ + 📅 +
+ +
diff --git a/viewer.js b/viewer.js index 550b49d..f490274 100644 --- a/viewer.js +++ b/viewer.js @@ -933,6 +933,53 @@ function initAppClock() { setInterval(tick, 30 * 1000); } +// v2026.5.17 : met à jour le faux input date custom (ex: "Vendredi 24.04.2026") +// Remplace l'ancien updateDatePickerDayLabel. L'input date natif reste présent +// mais caché, et son onChange continue de déclencher le chargement. +const DAY_NAMES_FULL = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; +function updateDatePickerDayLabel(isoDate) { + const el = document.getElementById("date-custom-label"); + if (!el) return; + if (!isoDate) { el.textContent = ""; return; } + try { + const d = isoToDate(isoDate); + const day = DAY_NAMES_FULL[d.getDay()]; + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const yyyy = d.getFullYear(); + el.textContent = `${day} ${dd}.${mm}.${yyyy}`; + } catch (e) { + el.textContent = ""; + } +} + +// v2026.5.17 : brancher le faux input date — clic dessus ouvre le vrai input +// caché pour choisir une date. +function initDateCustomPicker() { + const custom = document.getElementById("date-custom"); + const picker = document.getElementById("date-picker"); + if (!custom || !picker) return; + const openPicker = () => { + try { + if (typeof picker.showPicker === "function") { + picker.showPicker(); + } else { + picker.focus(); + picker.click(); + } + } catch (e) { + picker.focus(); + } + }; + custom.addEventListener("click", openPicker); + custom.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openPicker(); + } + }); +} + // v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible // UNIQUEMENT quand on affiche aujourd'hui. Appelée à chaque tick de l'horloge // + après chaque render (cf renderFromData). @@ -1122,6 +1169,103 @@ function updateSessionIndicator() { } }; } + + // v2026.5.17 : si le popup user-badge est ouvert, rafraîchir la ligne "Session : MM:SS" + const sessLineInPopup = document.getElementById("user-name-popup-session"); + if (sessLineInPopup) _renderUserPopupSessionLine(sessLineInPopup); + + // v2026.5.17 : popup d'alerte "glissante" depuis le haut gauche + // - à 5 min : alerte standard (si pas encore affichée ni "plus tard") + // - à 2 min : alerte urgente (si pas encore affichée) + _handleSessionSlideAlerts(remainingMs); +} + +/** + * v2026.5.17 : gère les 2 alertes popup glissant depuis le haut gauche. + * - Première alerte à 5 min (SESSION_WARN_THRESHOLD_MS). Reste affichée jusqu'à + * action manuelle (Prolonger ou Plus tard). + * - Si "Plus tard", une 2e alerte plus urgente réapparait à 2 min + * (SESSION_CRITICAL_THRESHOLD_MS). + */ +function _handleSessionSlideAlerts(remainingMs) { + if (remainingMs == null) return; + + // Alerte à 5 min + if (remainingMs <= SESSION_WARN_THRESHOLD_MS + && remainingMs > SESSION_CRITICAL_THRESHOLD_MS + && !state._slideAlert5minShown) { + state._slideAlert5minShown = true; + _showSessionSlideAlert({ urgent: false }); + } + + // Alerte à 2 min (si déjà "Plus tard" sur l'alerte 5 min OU alerte 5 min jamais affichée) + if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS + && !state._slideAlert2minShown) { + state._slideAlert2minShown = true; + // Cacher éventuellement l'ancienne alerte pour ré-afficher la nouvelle + _hideSessionSlideAlert(); + _showSessionSlideAlert({ urgent: true }); + } +} + +function _showSessionSlideAlert({ urgent }) { + // Retirer l'ancienne si elle existe + _hideSessionSlideAlert(); + + const el = document.createElement("div"); + el.id = "session-slide-alert"; + el.className = "session-slide-alert" + (urgent ? " urgent" : ""); + const title = urgent ? "⚠ Session expire dans 2 minutes !" : "⏱ Session expire dans 5 minutes"; + el.innerHTML = ` +
${title}
+
+ + +
+ `; + document.body.appendChild(el); + // Déclenche l'animation de slide-in (petite tempo pour que la transition parte) + requestAnimationFrame(() => el.classList.add("visible")); + + // Action "Prolonger" + el.querySelector(".session-slide-alert-extend").addEventListener("click", async () => { + const extendBtn = el.querySelector(".session-slide-alert-extend"); + extendBtn.disabled = true; + extendBtn.textContent = "…"; + try { + const resp = await sendMessage({ type: "extendSession" }); + if (resp && resp.ok && typeof resp.remainingMs === "number") { + state.sessionExpireAt = Date.now() + resp.remainingMs; + state.sessionPingDone = false; + state._criticalModalShown = false; + // Reset des flags d'alerte pour le prochain cycle + state._slideAlert5minShown = false; + state._slideAlert2minShown = false; + showToast("Session prolongée", "30 minutes de plus"); + updateSessionIndicator(); + _hideSessionSlideAlert(); + } else { + throw new Error((resp && resp.error) || "erreur inconnue"); + } + } catch (err) { + extendBtn.disabled = false; + extendBtn.textContent = "🔄 Prolonger"; + } + }); + + // Action "Plus tard" + el.querySelector(".session-slide-alert-later").addEventListener("click", () => { + _hideSessionSlideAlert(); + // Si c'est l'alerte 5 min qu'on dismissa, l'alerte 2 min reviendra + // automatiquement (state._slideAlert2minShown toujours false). + }); +} + +function _hideSessionSlideAlert() { + const el = document.getElementById("session-slide-alert"); + if (!el) return; + el.classList.remove("visible"); + setTimeout(() => { try { el.remove(); } catch (e) {} }, 250); } /** @@ -2267,15 +2411,19 @@ async function writeCache(isoDate, data) { // ============================================================================ async function loadForDate(isoDate, opts = {}) { - // v4.3.1 : changer de date ferme tous les popups épinglés. Ils réfèrent à - // des interventions du jour courant, ils n'ont aucun sens sur un autre jour. + // v4.3.1 : changer de date fermait tous les popups épinglés. + // v2026.5.17 : les popups épinglés restent maintenant ouverts entre dates, + // avec les données qu'ils avaient au moment de l'épinglage. + // v2026.5.18 : au changement de date, on réduit tous les popups épinglés + // dans la taskbar du bas (l'user peut les re-agrandir au clic). const previousDate = state.currentDate; - if (previousDate && previousDate !== isoDate && typeof closeAllPinnedPopups === "function") { - closeAllPinnedPopups(); + if (previousDate && previousDate !== isoDate) { + _reduceAllPinnedPopups(); } state.currentDate = isoDate; document.getElementById("date-picker").value = isoDate; + updateDatePickerDayLabel(isoDate); // v2026.5.16 : label "Mardi" à côté if (!state.session) { // v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session" @@ -4310,6 +4458,12 @@ function compareTechs(a, b, targetDate) { return aLast.localeCompare(bLast, "fr"); } +// v5.0.13 : un tech est considéré "absent toute la journée" uniquement si une +// absence couvre RÉELLEMENT du matin au soir (ou quasi), pas juste s'il a des +// absences (éventuellement partielles). Avant, une absence matin 08-12 seule +// faisait passer le tech en "absent toute la journée" car il n'avait QUE des +// absences. Maintenant on check explicitement que l'absence couvre ≥ 90% de +// la plage 08:00-18:00. function isTechAbsent(tech, isoDate) { const recurring = RECURRING_ABSENCES[tech.id]; if (recurring) { @@ -4317,7 +4471,26 @@ function isTechAbsent(tech, isoDate) { if (recurring.includes(day)) return true; } if (tech.interventions.length === 0) return false; - return tech.interventions.every(iv => iv.type === "AL-Absence" && !iv.isPompier); + // Parmi les absences (hors pompier), est-ce qu'une seule couvre la journée ? + const fullDayAbsences = tech.interventions.filter(iv => { + if (iv.type !== "AL-Absence" || iv.isPompier) return false; + const startMin = timeToMinutes(iv.startTime); + const endMin = timeToMinutes(iv.endTime); + if (startMin == null || endMin == null) { + // Si on n'a pas d'horaires, on considère que c'est toute la journée + // (cas des absences multi-jours sans horaires précis) + return true; + } + // Absence couvre toute la journée si son créneau déborde largement + // la plage affichée (≥ 90%). Une demi-journée (4h) sur 10h = 40% → ne + // passera pas, donc on ne marquera pas le tech comme absent toute la journée. + const DAY_LEN_MIN = 10 * 60; // 08:00 → 18:00 = 10h + const clampedStart = Math.max(startMin, 8 * 60); + const clampedEnd = Math.min(endMin, 18 * 60); + const coveredMin = Math.max(0, clampedEnd - clampedStart); + return coveredMin >= 0.9 * DAY_LEN_MIN; + }); + return fullDayAbsences.length > 0; } // ============================================================================ @@ -4445,7 +4618,21 @@ function buildCard(tech, isoDate) { return card; } - if (realInterventions.length === 0 && !isPompier) { + // v5.0.14 : si le tech n'a aucune intervention mais a des absences + // partielles (demi-journée) ou pompier, on veut quand même afficher la + // timeline avec les blocs absence visibles. Sans ça, une absence 08-12 + // seule n'apparaissait jamais sur la carte (affichait juste "Pas + // d'intervention planifiée"). + const hasPartialAbsences = absenceBlocks.some(ab => { + if (ab.isPompier) return false; + const s = timeToMinutes(ab.startTime); + const e = timeToMinutes(ab.endTime); + if (s === null || e === null) return false; + // Absence qui couvre PAS toute la journée → c'est partiel + return !(s <= DAY_START && e >= DAY_END); + }); + + if (realInterventions.length === 0 && !isPompier && !hasPartialAbsences) { if (isPillonelFriday) { const note = document.createElement("div"); note.className = "tech-absence-recurring"; @@ -4495,6 +4682,25 @@ function buildCard(tech, isoDate) { body.appendChild(buildInterventionRow(iv, card)); } + // v5.0.15 : afficher aussi les absences partielles (demi-journée) comme + // des rows, avec le même style que les réservations mais en gris foncé. + // Les absences qui couvrent toute la journée sont déjà traitées plus haut + // (carte "Absent toute la journée") et ne doivent pas être dupliquées ici. + if (!isAbsent) { + const partialAbsences = absenceBlocks.filter(ab => { + if (ab.isPompier) return false; + const s = timeToMinutes(ab.startTime); + const e = timeToMinutes(ab.endTime); + if (s === null || e === null) return false; + return !(s <= DAY_START && e >= DAY_END); + }); + // Trier par heure de début + partialAbsences.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || "")); + for (const ab of partialAbsences) { + body.appendChild(buildInterventionRow(ab, card)); + } + } + card.appendChild(body); return card; }