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,
"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.",
"permissions": [
"activeTab",
+12
View File
@@ -259,6 +259,18 @@ html, body {
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 {
display: inline-block;
animation: spin 0.8s linear infinite;
+3
View File
@@ -22,6 +22,9 @@
<button id="refresh-btn" class="btn" title="Rafraîchir maintenant">
<span id="refresh-icon"></span> Rafraîchir
</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é">
Vider cache
</button>
+140 -44
View File
@@ -47,8 +47,11 @@ const LS_THEME = "planning_theme";
const CACHE_PREFIX = "planning_cache_"; // + YYYY-MM-DD
const CACHE_DAYS = 7;
// Concurrence du fetch en parallèle (fiches + timelines)
const FETCH_CONCURRENCY = 12;
// Concurrence du fetch en parallèle (fiches + timelines).
// 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
@@ -152,6 +155,24 @@ let state = {
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
// ============================================================================
@@ -226,6 +247,10 @@ function toggleTheme() {
function bindTopbar() {
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
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("nav-prev").addEventListener("click", () => navigateDate(-1));
@@ -422,8 +447,16 @@ async function refreshPlanning(opts = {}) {
await refreshSessionAndLoad();
return;
}
// Bouton Rafraîchir manuel : on force le refetch des fiches
await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true });
// Rafraîchissement manuel (clic bouton) : on démarre un nouveau jeton et
// 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);
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;
async function worker() {
while (idx < toFetch.length) {
if (isRefreshAborted()) return;
const i = idx++;
await fetchAndUpdateIntervention(toFetch[i]);
}
@@ -806,6 +843,13 @@ async function refreshStatuses(techs, isoDate) {
for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker());
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
for (const tech of techs) {
tech.interventions = tech.interventions.filter(iv => {
@@ -832,14 +876,29 @@ async function refreshStatuses(techs, isoDate) {
async function fetchAndUpdateIntervention(iv) {
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 +
// extraire target/checksum qui servent à :
// - 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)
const ficheResp = await sendMessage({
type: "fetchFiche",
formLink: iv.formLink
});
//
// Retry léger : une erreur réseau ponctuelle (timeout, 5xx) ne doit pas
// 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) {
iv.ficheFetched = true;
@@ -872,6 +931,12 @@ async function fetchAndUpdateIntervention(iv) {
}
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)
const timelineTarget = iv.ficheTarget;
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) {
state.currentData = data;
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)
function updateInterventionInDom(iv) {
const row = document.querySelector(`.intervention[data-action-id="${iv.actionId}"]`);
// ─── Rendu incrémental (v3.1) ───────────────────────────────────────────────
// Met à jour UNE ligne d'intervention dans le DOM (après qu'un fetch fiche
// 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;
// Statut visuel
row.classList.remove("status-closed", "status-resolved");
// Classes de statut sur la ligne
const sc = getStatusClass(iv);
row.classList.remove("status-closed", "status-resolved");
if (sc) row.classList.add(sc);
// Ref (S260xxx) : mise à jour si on l'a trouvée dans la fiche
const refEl = row.querySelector(".intervention-refhdr");
// Classe de couleur sur la ligne (la pastille hérite via CSS)
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 (iv.ref) {
refEl.textContent = iv.ref;
@@ -2345,28 +2444,22 @@ function updateInterventionInDom(iv) {
}
}
// Titre (catégorie)
const title = row.querySelector(".intervention-title");
if (title) title.textContent = deriveShortTitle(iv);
// Meta
const meta = row.querySelector(".intervention-meta");
if (meta) meta.textContent = shortMeta(iv);
// Check ✓ : ajouter ou retirer
let statusEl = row.querySelector(".intervention-status");
if (sc && !statusEl) {
statusEl = document.createElement("div");
statusEl.className = "intervention-status";
statusEl.textContent = "✓";
// Check ✓ : ajouter/retirer selon statut
let checkEl = row.querySelector(".iv-status-check");
if (sc && !checkEl) {
checkEl = document.createElement("div");
checkEl.className = "iv-status-check";
checkEl.textContent = "✓";
// Insérer après la ref (avant le bouton copier s'il existe)
const copy = row.querySelector(".intervention-copy");
if (copy) row.insertBefore(statusEl, copy);
else row.appendChild(statusEl);
} else if (!sc && statusEl) {
statusEl.remove();
if (copy) row.insertBefore(checkEl, copy);
else if (refEl && refEl.nextSibling) row.insertBefore(checkEl, refEl.nextSibling);
else row.appendChild(checkEl);
} 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");
if (iv.ref && !copyBtn) {
copyBtn = document.createElement("button");
@@ -2381,20 +2474,23 @@ function updateInterventionInDom(iv) {
row.appendChild(copyBtn);
}
// Mettre à jour la classe couleur (pour la pastille)
const colorKey = deriveColorKey(iv);
row.classList.remove("color-livraison", "color-recup", "color-remplacement", "color-autre");
row.classList.add("color-" + colorKey);
// Catégorie affichée en bas (dépend de la ref pour Incident, etc.)
const catEl = row.querySelector(".iv-category");
if (catEl) catEl.textContent = deriveShortTitle(iv);
// Mettre à jour le bloc timeline correspondant
// Segment timeline correspondant : même couleur + même classe statut
const card = row.closest(".card");
if (card) {
const slot = card.querySelector(`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`);
if (card && row.dataset.ivIdx !== undefined) {
const slot = card.querySelector(
`.timeline-slot[data-iv-idx="${row.dataset.ivIdx}"]`
);
if (slot) {
slot.classList.remove("status-closed", "status-resolved",
"color-livraison", "color-recup", "color-remplacement", "color-autre");
slot.classList.remove("status-closed", "status-resolved", ...ALL_COLOR_CLASSES);
slot.classList.add("color-" + colorKey);
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;
}
}
}