diff --git a/background.js b/background.js index dc2b16a..1796ab7 100644 --- a/background.js +++ b/background.js @@ -157,19 +157,44 @@ async function fetchXhr2(origin, phpsessid, actionId) { async function fetchFicheHtml(origin, phpsessid, formLink) { const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`; console.log("[bg] fetchFicheHtml →", url.substring(0, 120)); - const r = await evFetch(url, origin); - if (!r.ok) { - const err = new Error("HTTP " + r.status); - err.kind = classifyHttpStatus(r.status); - err.status = r.status; - throw err; + + // v2026.5.16 : juste après une reconnexion SSO, EasyVista retourne parfois + // une page intermédiaire tronquée (~8 Ko au lieu de ~250 Ko), le temps que + // les cookies SSO/Kerberos se propagent. On fait jusqu'à 3 tentatives avec + // 1.5s entre chaque si on détecte une taille suspecte. + const MAX_RETRIES = 3; + const RETRY_DELAY_MS = 1500; + const MIN_VALID_SIZE = 20000; // < 20 Ko = probablement page intermédiaire + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + const r = await evFetch(url, origin); + if (!r.ok) { + const err = new Error("HTTP " + r.status); + err.kind = classifyHttpStatus(r.status); + err.status = r.status; + throw err; + } + const html = await r.text(); + console.log(`[bg] fiche status = ${r.status} | taille = ${html.length}${attempt > 1 ? ` (tentative ${attempt}/${MAX_RETRIES})` : ""}`); + + // Si réponse clairement une redirection courte → login expiré, inutile de retry + if (html.length < 500) { + console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html)); + return html; + } + + // Si taille suspecte (< 20 Ko), probable page intermédiaire SSO : retry + if (html.length < MIN_VALID_SIZE && attempt < MAX_RETRIES) { + console.warn(`[bg] ⚠ fiche anormalement petite (${html.length} octets), retry dans ${RETRY_DELAY_MS} ms...`); + await new Promise(res => setTimeout(res, RETRY_DELAY_MS)); + continue; + } + + // 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, @@ -375,6 +400,67 @@ function originForContext(context) { : "https://itsma.vd.ch"; } +/** + * v2026.5.16 : surveille un onglet ouvert pour détecter si le Windows SSO + * a échoué et rediriger vers la bonne page. + * + * Quand la session portail Canton est expirée, EasyVista redirige vers + * https://portail.etat-de-vaud.ch/iamlogin/?spEntityID=... + * (page de login manuel moche). On préfère rediriger vers + * https://portail.etat-de-vaud.ch/iam/accueil/ + * qui déclenche le Windows Kerberos SSO automatique. + * + * @param {number} tabId - ID de l'onglet à surveiller + */ +function watchReconnectTabForIamLogin(tabId) { + let redirected = false; + const timeoutMs = 60000; // surveille max 60s + + const listener = (updatedTabId, changeInfo, tab) => { + if (updatedTabId !== tabId) return; + if (redirected) return; + const url = changeInfo.url || (tab && tab.url) || ""; + if (!url) return; + + // Détecter la page de login manuel + // Patterns : portail.etat-de-vaud.ch/iamlogin/ ou www.portail.vd.ch/iamlogin/ + if (/\/iamlogin\//i.test(url) && /portail\./i.test(url)) { + redirected = true; + // Choisir le domaine de redirection : + // - si on voit portail.etat-de-vaud.ch → rester sur interne + // - si on voit www.portail.vd.ch → rester sur externe + let targetUrl; + if (/portail\.etat-de-vaud\.ch/i.test(url)) { + targetUrl = "https://portail.etat-de-vaud.ch/iam/accueil/"; + } else { + targetUrl = "https://www.portail.vd.ch/iam/accueil/"; + } + console.log(`[bg] watchReconnectTab : iamlogin détecté, redirection vers ${targetUrl}`); + chrome.tabs.update(tabId, { url: targetUrl }).catch(e => { + console.warn("[bg] watchReconnectTab : update failed", e); + }); + } + }; + + chrome.tabs.onUpdated.addListener(listener); + + // Stop la surveillance après 60s pour ne pas accumuler des listeners morts + setTimeout(() => { + try { + chrome.tabs.onUpdated.removeListener(listener); + } catch (e) {} + }, timeoutMs); + + // Si l'onglet est fermé, stop aussi + const closeListener = (closedTabId) => { + if (closedTabId === tabId) { + try { chrome.tabs.onUpdated.removeListener(listener); } catch (e) {} + try { chrome.tabs.onRemoved.removeListener(closeListener); } catch (e) {} + } + }; + chrome.tabs.onRemoved.addListener(closeListener); +} + // ============================================================================ // v4.2 : récupération de l'utilisateur connecté // ============================================================================ @@ -1098,6 +1184,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { url: `${origin}/`, // racine → EV redirige vers SSO si besoin active: true }); + // v2026.5.16 : surveiller cet onglet — si on tombe sur la page de + // login manuel portail.etat-de-vaud.ch/iamlogin/, rediriger vers + // portail.etat-de-vaud.ch/iam/accueil/ qui déclenche le Windows + // SSO Kerberos automatiquement. + watchReconnectTabForIamLogin(tab.id); sendResponse({ ok: true, tabId: tab.id, origin }); } catch (err) { sendResponse({ ok: false, error: err.message || String(err) }); diff --git a/manifest.json b/manifest.json index 1328d98..21663d0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planification", - "version": "5.0.15", + "version": "2026.5.16", "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.", "permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"], "host_permissions": [ diff --git a/viewer.css b/viewer.css index bc51b2f..0f2adb2 100644 --- a/viewer.css +++ b/viewer.css @@ -320,6 +320,19 @@ html, body { display: flex; align-items: center; gap: 4px; + flex-wrap: nowrap; +} + +/* v2026.5.16 : nom court du jour (Mardi, Lundi, ...) à gauche du date-picker */ +.date-picker-day { + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + padding: 0 6px 0 2px; + min-width: 58px; + text-align: right; + white-space: nowrap; + user-select: none; } .btn-nav { @@ -1937,18 +1950,36 @@ body.modal-open { /* ───────────────────────────────────────────────────────────────────────── v5.0.0 : horloge au milieu de la topbar (HH:MM, pas de secondes) ───────────────────────────────────────────────────────────────────────── */ +/* v2026.5.16 : app-clock contient maintenant 2 lignes empilées : + - app-clock-date : "Mardi 21 avril 2026" (petit) + - app-clock-time : "12:34" (grand) */ .app-clock { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + line-height: 1.1; + color: var(--text); + pointer-events: none; + user-select: none; + white-space: nowrap; +} +.app-clock-date { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + letter-spacing: 0.3px; + text-transform: capitalize; +} +.app-clock-time { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; - color: var(--text); letter-spacing: 1px; - pointer-events: none; - user-select: none; } .topbar { position: sticky; /* déja défini plus haut */ } /* topbar doit être en position: relative parent pour que .app-clock absolute @@ -2407,3 +2438,63 @@ header.topbar::before { .banner-reconnect-failed .banner-btn-primary:hover { background: #f8d7da; } + +/* ========================================================================== + v2026.5.16 : responsive topbar + ========================================================================== */ + +/* Breakpoint medium : entre 1000 et 1300px, on compacte un peu */ +@media (max-width: 1300px) { + .app-clock-date { font-size: 11px; } + .app-clock-time { font-size: 20px; } + .topbar-right .btn-action .btn-action-label, + .topbar-right .btn-refresh .btn-refresh-label { + font-size: 12px; + } +} + +/* Breakpoint small : moins de 1000px, on masque les labels de boutons action + et on réduit encore l'horloge. Les icônes restent, titres restent. */ +@media (max-width: 1000px) { + .topbar { padding: 8px 14px; gap: 8px; } + .topbar h1 { font-size: 16px; } + .app-clock { font-size: smaller; } + .app-clock-date { font-size: 10px; } + .app-clock-time { font-size: 18px; } + .btn-action .btn-action-label, + .btn-refresh .btn-refresh-label { + display: none; + } + .btn-action, .btn-refresh { + padding: 6px 10px; + } + .capture-info { display: none; } +} + +/* Breakpoint très petit : moins de 720px, on cache la date complète (garde + juste l'heure) et on autorise le wrap total */ +@media (max-width: 720px) { + .topbar { + flex-wrap: wrap; + padding: 6px 10px; + } + .app-clock { + position: static; + transform: none; + margin: 0 auto; + } + .app-clock-date { display: none; } + .topbar-left { flex-wrap: wrap; } + .date-nav { margin-top: 4px; } + .date-picker-day { min-width: 46px; font-size: 12px; } + .topbar-right { flex-wrap: wrap; justify-content: flex-end; } +} + +/* Breakpoint minuscule : masque aussi les labels de refresh, boutons deviennent + vraiment iconifiés */ +@media (max-width: 520px) { + .app-clock-time { font-size: 16px; } + .topbar h1 { font-size: 14px; } + .btn-today { padding: 4px 6px; font-size: 11px; } + .btn-nav { min-width: 26px; padding: 4px 6px; } +} diff --git a/viewer.html b/viewer.html index 968f53e..4865569 100644 --- a/viewer.html +++ b/viewer.html @@ -16,6 +16,8 @@

Planification

+ + @@ -23,8 +25,11 @@
- -
+ +
+
+
+
diff --git a/viewer.js b/viewer.js index 083713a..1574930 100644 --- a/viewer.js +++ b/viewer.js @@ -246,6 +246,7 @@ async function init() { // Initialiser la date = aujourd'hui state.currentDate = todayISO(); document.getElementById("date-picker").value = state.currentDate; + updateDatePickerDayLabel(state.currentDate); // v2026.5.16 : label "Mardi" // v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant) detectNetworkContextAsync(); @@ -799,11 +800,37 @@ function initAppFooter() { function initAppClock() { const el = document.getElementById("app-clock"); if (!el) return; + const dateEl = document.getElementById("app-clock-date"); + const timeEl = document.getElementById("app-clock-time"); + + // v2026.5.16 : format "Mardi 21 avril 2026" + const JOURS = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; + const MOIS = [ + "janvier", "février", "mars", "avril", "mai", "juin", + "juillet", "août", "septembre", "octobre", "novembre", "décembre" + ]; + + let lastDateStr = ""; const tick = () => { const d = new Date(); const h = String(d.getHours()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0"); - el.textContent = `${h}:${m}`; + const timeStr = `${h}:${m}`; + if (timeEl) timeEl.textContent = timeStr; + else el.textContent = timeStr; // fallback si ancien markup + + // Date complète : actualisée seulement si elle a changé (évite reflow inutile) + if (dateEl) { + const jour = JOURS[d.getDay()]; + const num = d.getDate(); + const mois = MOIS[d.getMonth()]; + const annee = d.getFullYear(); + const dateStr = `${jour} ${num} ${mois} ${annee}`; + if (dateStr !== lastDateStr) { + dateEl.textContent = dateStr; + lastDateStr = dateStr; + } + } // v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now" updateNowLine(); }; @@ -812,6 +839,21 @@ function initAppClock() { setInterval(tick, 30 * 1000); } +// v2026.5.16 : met à jour le label court du jour affiché à gauche du +// date-picker (ex: "Mardi", "Lundi"). Appelé à chaque changement de date. +const DAY_NAMES_FULL = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"]; +function updateDatePickerDayLabel(isoDate) { + const el = document.getElementById("date-picker-day"); + if (!el) return; + if (!isoDate) { el.textContent = ""; return; } + try { + const d = isoToDate(isoDate); + el.textContent = DAY_NAMES_FULL[d.getDay()]; + } catch (e) { + el.textContent = ""; + } +} + // 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). @@ -2155,6 +2197,7 @@ async function loadForDate(isoDate, opts = {}) { 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" @@ -5366,7 +5409,10 @@ function splitOneContact(raw) { // Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en // +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour // éviter de matcher des codes postaux ou autres nombres. - const rxLong = /(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(? p.trim()).filter(Boolean); + let ville, adresse; - if (idx < 0) { + if (parts.length === 0) { + return { ville: null, adresse: null }; + } else if (parts.length === 1) { + // Pas de slash : tout est l'adresse ville = null; - adresse = s; + adresse = parts[0]; } else { - ville = s.substring(0, idx).trim(); - adresse = s.substring(idx + 1).trim(); + // 2+ parties : ville = 1ère, adresse = 2e, on ignore le reste + ville = parts[0]; + adresse = parts[1]; } + // Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue, // Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd) if (adresse) { adresse = adresse.replace( /\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi, (match) => { - // Conserver la casse existante si déjà majuscule, sinon capitaliser if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match; return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase(); }