Version 3.1.0 — Améliorations affichage

This commit is contained in:
2026-04-17 11:00:00 +02:00
parent 8ab62e92d2
commit 94877cb816
4 changed files with 156 additions and 45 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Planning Techniciens — Vue claire", "name": "Planning Techniciens — Vue claire",
"version": "3.0.0", "version": "3.1.0",
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch) avec navigation par date, détection automatique des interventions closes et cache 7 jours.", "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch) avec navigation par date, détection automatique des interventions closes et cache 7 jours.",
"permissions": [ "permissions": [
"activeTab", "activeTab",
+12
View File
@@ -259,6 +259,18 @@ html, body {
opacity: 0.9; opacity: 0.9;
} }
/* Bouton "Arrêter" (apparaît pendant un refresh manuel) */
.btn-abort {
background: var(--danger-soft);
color: var(--danger);
border-color: var(--danger);
}
.btn-abort:hover {
background: var(--danger);
color: white;
border-color: var(--danger);
}
#refresh-icon.spinning { #refresh-icon.spinning {
display: inline-block; display: inline-block;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
+3
View File
@@ -22,6 +22,9 @@
<button id="refresh-btn" class="btn" title="Rafraîchir maintenant"> <button id="refresh-btn" class="btn" title="Rafraîchir maintenant">
<span id="refresh-icon"></span> Rafraîchir <span id="refresh-icon"></span> Rafraîchir
</button> </button>
<button id="abort-btn" class="btn btn-abort hidden" title="Arrêter le rafraîchissement en cours">
✕ Arrêter
</button>
<button id="clear-cache-btn" class="btn btn-subtle" title="Vider le cache du jour affiché"> <button id="clear-cache-btn" class="btn btn-subtle" title="Vider le cache du jour affiché">
Vider cache Vider cache
</button> </button>
+140 -44
View File
@@ -47,8 +47,11 @@ const LS_THEME = "planning_theme";
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
const CACHE_DAYS = 7; const CACHE_DAYS = 7;
// Concurrence du fetch en parallèle (fiches + timelines) // Concurrence du fetch en parallèle (fiches + timelines).
const FETCH_CONCURRENCY = 12; // Avant v3.1 : 12. Monté à 30 pour afficher les refs plus vite sur les jours
// chargés (~34 interventions → 2 vagues au lieu de 3). Si le serveur sature,
// redescendre à 20.
const FETCH_CONCURRENCY = 30;
// ============================================================================ // ============================================================================
// Mapping de catégorie → titre court + couleur // Mapping de catégorie → titre court + couleur
@@ -152,6 +155,24 @@ let state = {
loading: false loading: false
}; };
// ─── Annulation coopérative d'un refresh manuel (v3.1) ──────────────────────
// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent
// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si
// l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement.
let currentRefreshToken = 0;
let abortedToken = -1;
function startNewRefresh() {
currentRefreshToken++;
return currentRefreshToken;
}
function abortCurrentRefresh() {
abortedToken = currentRefreshToken;
}
function isRefreshAborted() {
return abortedToken === currentRefreshToken;
}
// ============================================================================ // ============================================================================
// Boot // Boot
// ============================================================================ // ============================================================================
@@ -226,6 +247,10 @@ function toggleTheme() {
function bindTopbar() { function bindTopbar() {
document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning()); document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning());
document.getElementById("abort-btn").addEventListener("click", () => {
abortCurrentRefresh();
showAbortButton(false);
});
document.getElementById("clear-cache-btn").addEventListener("click", onClearCache); document.getElementById("clear-cache-btn").addEventListener("click", onClearCache);
document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1)); document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1));
@@ -422,8 +447,16 @@ async function refreshPlanning(opts = {}) {
await refreshSessionAndLoad(); await refreshSessionAndLoad();
return; return;
} }
// Bouton Rafraîchir manuel : on force le refetch des fiches // Rafraîchissement manuel (clic bouton) : on démarre un nouveau jeton et
await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true }); // on fait apparaître le bouton "Arrêter". Les refresh auto (12h/15h) et
// les navigations de date n'ont pas ce bouton (ils ne passent pas ici).
startNewRefresh();
showAbortButton(true);
try {
await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true });
} finally {
showAbortButton(false);
}
} }
// ============================================================================ // ============================================================================
@@ -793,10 +826,14 @@ async function refreshStatuses(techs, isoDate) {
setRefreshing(true); setRefreshing(true);
try { try {
// Fetcher avec concurrence = FETCH_CONCURRENCY (12) // Fetcher avec concurrence = FETCH_CONCURRENCY (30)
// Chaque worker vérifie isRefreshAborted() AVANT de prendre la prochaine
// intervention : si l'utilisateur a cliqué "Arrêter", les workers
// s'arrêtent proprement dans ~100ms.
let idx = 0; let idx = 0;
async function worker() { async function worker() {
while (idx < toFetch.length) { while (idx < toFetch.length) {
if (isRefreshAborted()) return;
const i = idx++; const i = idx++;
await fetchAndUpdateIntervention(toFetch[i]); await fetchAndUpdateIntervention(toFetch[i]);
} }
@@ -806,6 +843,13 @@ async function refreshStatuses(techs, isoDate) {
for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker()); for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker());
await Promise.all(workers); await Promise.all(workers);
// Si annulé : on laisse les refs déjà arrivées s'afficher (le rendu
// incrémental les a mises dans le DOM), on skip juste le re-render
// final et le nettoyage ghosts/cache.
if (isRefreshAborted()) {
return;
}
// Résoudre le sort des ghosts // Résoudre le sort des ghosts
for (const tech of techs) { for (const tech of techs) {
tech.interventions = tech.interventions.filter(iv => { tech.interventions = tech.interventions.filter(iv => {
@@ -832,14 +876,29 @@ async function refreshStatuses(techs, isoDate) {
async function fetchAndUpdateIntervention(iv) { async function fetchAndUpdateIntervention(iv) {
try { try {
// Bail-out coopératif : si l'utilisateur a cliqué sur "Arrêter",
// on ne fetch pas cette intervention.
if (isRefreshAborted()) {
iv.ficheFetched = true;
iv.ficheFetchError = "aborted";
return;
}
// Fetch de la fiche (HTML) pour récupérer statut + commentaire tech + // Fetch de la fiche (HTML) pour récupérer statut + commentaire tech +
// extraire target/checksum qui servent à : // extraire target/checksum qui servent à :
// - l'API timeline (texte validé de l'action, si xhr2 n'avait pas été assez) // - l'API timeline (texte validé de l'action, si xhr2 n'avait pas été assez)
// - construire une URL d'ouverture qui marche (clic sur intervention) // - construire une URL d'ouverture qui marche (clic sur intervention)
const ficheResp = await sendMessage({ //
type: "fetchFiche", // Retry léger : une erreur réseau ponctuelle (timeout, 5xx) ne doit pas
formLink: iv.formLink // perdre la ligne. 1 seul retry après 400ms. Session expirée n'est PAS
}); // retryée (ça ne passera pas mieux la 2e fois).
let ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
if (!ficheResp.ok && ficheResp.error !== "session_expired" && !isRefreshAborted()) {
await new Promise(r => setTimeout(r, 400));
if (!isRefreshAborted()) {
ficheResp = await sendMessage({ type: "fetchFiche", formLink: iv.formLink });
}
}
if (!ficheResp.ok) { if (!ficheResp.ok) {
iv.ficheFetched = true; iv.ficheFetched = true;
@@ -872,6 +931,12 @@ async function fetchAndUpdateIntervention(iv) {
} }
iv.ficheFetched = true; iv.ficheFetched = true;
// ─── RENDU INCRÉMENTAL (v3.1) ─────────────────────────────────────────
// La ref (RFC_NUMBER) et le statut sont déjà connus : on met à jour la
// ligne correspondante DANS LE DOM immédiatement, sans attendre que les
// autres workers aient fini. Pas de re-rendu global.
updateInterventionRow(iv);
// Pour l'API timeline, on utilise le MÊME target + checksum (celui de la fiche) // Pour l'API timeline, on utilise le MÊME target + checksum (celui de la fiche)
const timelineTarget = iv.ficheTarget; const timelineTarget = iv.ficheTarget;
const timelineChecksum = iv.ficheChecksum; const timelineChecksum = iv.ficheChecksum;
@@ -1209,6 +1274,16 @@ function clearCheckMark() {
} }
} }
// Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh
// manuel (clic utilisateur), pas pendant les chargements normaux ni les
// refresh auto 12h/15h.
function showAbortButton(on) {
const btn = document.getElementById("abort-btn");
if (!btn) return;
if (on) btn.classList.remove("hidden");
else btn.classList.add("hidden");
}
function renderFromData(data) { function renderFromData(data) {
state.currentData = data; state.currentData = data;
document.getElementById("loading").classList.add("hidden"); document.getElementById("loading").classList.add("hidden");
@@ -2323,18 +2398,42 @@ async function copyRef(ref, btn) {
} }
} }
// Met à jour dans le DOM la ligne correspondant à une intervention (après fetch) // ─── Rendu incrémental (v3.1) ───────────────────────────────────────────────
function updateInterventionInDom(iv) { // Met à jour UNE ligne d'intervention dans le DOM (après qu'un fetch fiche
const row = document.querySelector(`.intervention[data-action-id="${iv.actionId}"]`); // ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par
// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans
// attendre que tous les workers aient fini ni re-rendre toute la vue.
//
// Doit rester en phase avec la structure DOM construite par
// buildInterventionRow (classes iv-ref-header, iv-status-check,
// intervention-copy, intervention-dot, timeline-slot...).
const ALL_COLOR_CLASSES = [
"color-livraison", "color-installation", "color-recup",
"color-remplacement", "color-incident", "color-rollout",
"color-reservation", "color-autre"
];
function updateInterventionRow(iv) {
// Réservations : pas concerné (pas de fetch fiche pour elles)
if (iv.type === "AL-Reservation") return;
const row = document.querySelector(
`.intervention-v2[data-action-id="${iv.actionId}"]`
);
if (!row) return; if (!row) return;
// Statut visuel // Classes de statut sur la ligne
row.classList.remove("status-closed", "status-resolved");
const sc = getStatusClass(iv); const sc = getStatusClass(iv);
row.classList.remove("status-closed", "status-resolved");
if (sc) row.classList.add(sc); if (sc) row.classList.add(sc);
// Ref (S260xxx) : mise à jour si on l'a trouvée dans la fiche // Classe de couleur sur la ligne (la pastille hérite via CSS)
const refEl = row.querySelector(".intervention-refhdr"); const colorKey = deriveColorKey(iv);
row.classList.remove(...ALL_COLOR_CLASSES);
row.classList.add("color-" + colorKey);
// Ref (le titre gros en haut de la ligne)
const refEl = row.querySelector(".iv-ref-header");
if (refEl) { if (refEl) {
if (iv.ref) { if (iv.ref) {
refEl.textContent = iv.ref; refEl.textContent = iv.ref;
@@ -2345,28 +2444,22 @@ function updateInterventionInDom(iv) {
} }
} }
// Titre (catégorie) // Check ✓ : ajouter/retirer selon statut
const title = row.querySelector(".intervention-title"); let checkEl = row.querySelector(".iv-status-check");
if (title) title.textContent = deriveShortTitle(iv); if (sc && !checkEl) {
checkEl = document.createElement("div");
// Meta checkEl.className = "iv-status-check";
const meta = row.querySelector(".intervention-meta"); checkEl.textContent = "✓";
if (meta) meta.textContent = shortMeta(iv); // Insérer après la ref (avant le bouton copier s'il existe)
// Check ✓ : ajouter ou retirer
let statusEl = row.querySelector(".intervention-status");
if (sc && !statusEl) {
statusEl = document.createElement("div");
statusEl.className = "intervention-status";
statusEl.textContent = "✓";
const copy = row.querySelector(".intervention-copy"); const copy = row.querySelector(".intervention-copy");
if (copy) row.insertBefore(statusEl, copy); if (copy) row.insertBefore(checkEl, copy);
else row.appendChild(statusEl); else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling);
} else if (!sc && statusEl) { else row.appendChild(checkEl);
statusEl.remove(); } else if (!sc && checkEl) {
checkEl.remove();
} }
// Bouton copier : ajouter si on a maintenant une ref // Bouton 📋 copier : ajouter si on a maintenant une ref et qu'il n'existe pas
let copyBtn = row.querySelector(".intervention-copy"); let copyBtn = row.querySelector(".intervention-copy");
if (iv.ref && !copyBtn) { if (iv.ref && !copyBtn) {
copyBtn = document.createElement("button"); copyBtn = document.createElement("button");
@@ -2381,20 +2474,23 @@ function updateInterventionInDom(iv) {
row.appendChild(copyBtn); row.appendChild(copyBtn);
} }
// Mettre à jour la classe couleur (pour la pastille) // Catégorie affichée en bas (dépend de la ref pour Incident, etc.)
const colorKey = deriveColorKey(iv); const catEl = row.querySelector(".iv-category");
row.classList.remove("color-livraison", "color-recup", "color-remplacement", "color-autre"); if (catEl) catEl.textContent = deriveShortTitle(iv);
row.classList.add("color-" + colorKey);
// Mettre à jour le bloc timeline correspondant // Segment timeline correspondant : même couleur + même classe statut
const card = row.closest(".card"); const card = row.closest(".card");
if (card) { if (card && row.dataset.ivIdx !== undefined) {
const slot = card.querySelector(`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`); const slot = card.querySelector(
`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`
);
if (slot) { if (slot) {
slot.classList.remove("status-closed", "status-resolved", slot.classList.remove("status-closed", "status-resolved", ...ALL_COLOR_CLASSES);
"color-livraison", "color-recup", "color-remplacement", "color-autre");
slot.classList.add("color-" + colorKey); slot.classList.add("color-" + colorKey);
if (sc) slot.classList.add(sc); if (sc) slot.classList.add(sc);
// Maj du dataset pour le popover (titre + ref)
slot.dataset.title = deriveShortTitle(iv);
if (iv.ref) slot.dataset.ref = iv.ref;
} }
} }
} }