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;
}