// ============================================================================
// viewer.js v3 — vue claire du planning techniciens
// ============================================================================
// Différences clés avec v2 :
// 1. Fetch direct EasyVista (plus besoin de capturer la page manuellement)
// 2. Parsing XML (planning_xhr.php?div=calendar_block) au lieu de HTML
// 3. Fetch des fiches individuelles pour détecter les statuts Clôturé/Résolu
// 4. Cache persistant 7 jours par date (chrome.storage.local)
// 5. Navigation ◀ / date picker / ▶
// 6. Refresh auto 12h / 15h
//
// Les fetches se font dans le service worker (background.js) pour éviter
// les problèmes de CORS : viewer.js envoie des messages, background fait les
// requêtes et renvoie les données.
// ============================================================================
// ============================================================================
// Configuration
// ============================================================================
// Équipe : ID EasyVista → nom affiché
const TEAM = {
"76272": "Ciuppa, Mathieu",
"83725": "De Almeida Martins, Solange",
"66635": "Makonda, Yannick",
"92235": "Mamouni, Anas",
"90070": "Paisana, David",
"40944": "Pillonel, Olivier",
"72485": "Rosset, Pascal",
"86874": "Rouiller, Quentin"
};
// Absences récurrentes (id tech → [jour JS, 0=dim..6=sam])
const RECURRING_ABSENCES = {
"40944": [5] // Pillonel absent tous les vendredis
};
// Statuts EasyVista qui déclenchent l'affichage "clos"
const CLOSED_STATUS = ["Clôturé", "Cloture", "Clôture"];
const RESOLVED_STATUS = ["Résolu", "Resolu"];
// Statuts qui indiquent qu'une intervention a été supprimée/annulée
// → si présente dans le cache mais disparue du planning : on retire
const CANCELLED_STATUS = ["Annulé", "Annule", "Supprimé", "Supprime"];
// Clés de stockage
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;
// ============================================================================
// Mapping de catégorie → titre court + couleur
// ============================================================================
const CATEGORY_TO_TITLE = [
// Arrivées / nouvelles installations → Installation (bleu)
[/Arriv[ée]e\s+ou\s+mutation/i, "Installation", "installation"],
[/Accessoire\s+pour\s+PC/i, "Installation", "installation"],
[/Nouveau\s+Poste\s+Windows/i, "Installation", "installation"],
[/Nouveau\s+Poste\s+macOS/i, "Installation", "installation"],
// Récupération / départ (vert)
[/D[ée]part\s+d[\u2018\u2019']un\s+utilisateur/i, "Récupération", "recup"],
[/Reprise\s+du\s+mat[ée]riel/i, "Récupération", "recup"],
// Remplacement (orange)
[/Remplacement\s+de\s+mat[ée]riel/i, "Remplacement", "remplacement"],
];
/**
* Détecte si le texte de l'action commence par "Roll Out".
*/
function isRollOut(iv) {
const texts = [
iv.bulleDescription,
iv.actionText,
iv.infobulle && iv.infobulle.aFaire,
iv.label
];
for (const t of texts) {
if (!t) continue;
if (/^\s*[«"']?\s*roll[\s\-]*out/i.test(String(t))) return true;
if (/(?:^|\bA faire\s*:\s*)roll[\s\-]*out/i.test(String(t))) return true;
}
return false;
}
/**
* Détecte si le texte de l'action mentionne une récupération de matériel.
* Accepté : "RÉCUPÉRATION DE MATÉRIEL" ou "Récupération" au début de l'action,
* ou dans "A faire : Récupération ...".
*/
function isRecupAction(iv) {
const texts = [
iv.bulleDescription,
iv.actionText,
iv.infobulle && iv.infobulle.aFaire,
iv.label
];
for (const t of texts) {
if (!t) continue;
const s = String(t);
if (/^\s*r[ée]cup[ée]ration/i.test(s)) return true;
if (/\bA\s+faire\s*:\s*r[ée]cup[ée]ration/i.test(s)) return true;
}
return false;
}
/**
* Dérive un titre court et une clé de couleur à partir d'une intervention.
* Priorité :
* 1. Si la ref commence par I260 → "Incident" (violet)
* 2. Si l'action commence par "Roll Out" → "Roll Out" (brun)
* 3. Si l'action mentionne récupération → "Récupération" (vert)
* 4. Sinon, mapping par catégorie (fiche)
* 5. Sinon, "Autres" (gris)
*/
function deriveShortTitle(iv) {
if (iv.type === "AL-Reservation") return "Réservation";
if (iv.ref && /^I2\d/.test(iv.ref)) return "Incident";
if (isRollOut(iv)) return "Roll Out";
if (isRecupAction(iv)) return "Récupération";
const cat = iv.categoryLine || "";
if (!cat) return "Autres";
for (const [regex, title] of CATEGORY_TO_TITLE) {
if (regex.test(cat)) return title;
}
return "Autres";
}
function deriveColorKey(iv) {
if (iv.type === "AL-Reservation") return "reservation";
if (iv.ref && /^I2\d/.test(iv.ref)) return "incident";
if (isRollOut(iv)) return "rollout";
if (isRecupAction(iv)) return "recup";
const cat = iv.categoryLine || "";
if (!cat) return "autre";
for (const [regex, , colorKey] of CATEGORY_TO_TITLE) {
if (regex.test(cat)) return colorKey;
}
return "autre";
}
// ============================================================================
// État global
// ============================================================================
let state = {
session: null, // { phpsessid, origin, tabId }
currentDate: null, // "YYYY-MM-DD" affiché
currentData: null, // résultat parsé (techs, stats, ...)
loading: false
};
// ============================================================================
// Boot
// ============================================================================
document.addEventListener("DOMContentLoaded", init);
async function init() {
initTheme();
bindTopbar();
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
document.getElementById("date-picker").value = state.currentDate;
// Écouter les messages d'auto-refresh du service worker
chrome.runtime.onMessage.addListener((msg) => {
if (msg && msg.type === "autoRefresh") {
console.log("Auto-refresh 12h/15h déclenché");
refreshPlanning({ keepStatuses: true });
}
});
// Charger la session puis le planning
await refreshSessionAndLoad();
}
async function refreshSessionAndLoad() {
const resp = await sendMessage({ type: "getSession" });
if (!resp.ok || !resp.session) {
showSessionNeeded();
return;
}
state.session = resp.session;
hideSessionNeeded();
await loadForDate(state.currentDate);
}
// ============================================================================
// Thème clair/sombre
// ============================================================================
function initTheme() {
const saved = localStorage.getItem(LS_THEME);
const theme = (saved === "light" || saved === "dark") ? saved : detectDefaultTheme();
applyTheme(theme);
}
function detectDefaultTheme() {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
const icon = document.getElementById("theme-icon");
if (icon) icon.textContent = theme === "dark" ? "☀️" : "🌙";
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme") || "light";
const next = current === "dark" ? "light" : "dark";
applyTheme(next);
localStorage.setItem(LS_THEME, next);
}
// ============================================================================
// Topbar handlers
// ============================================================================
function bindTopbar() {
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning());
document.getElementById("clear-cache-btn").addEventListener("click", onClearCache);
document.getElementById("nav-prev").addEventListener("click", () => navigateDate(-1));
document.getElementById("nav-next").addEventListener("click", () => navigateDate(+1));
document.getElementById("nav-today").addEventListener("click", () => loadForDate(todayISO()));
document.getElementById("date-picker").addEventListener("change", (e) => {
if (e.target.value) loadForDate(e.target.value);
});
document.getElementById("open-ev-btn").addEventListener("click", openEasyVista);
}
async function openEasyVista() {
// Ouvrir sur le domaine externe (accessible depuis l'extérieur).
// Le domaine interne (itsma.etat-de-vaud.ch) n'est accessible que depuis le réseau VD.
// Une fois connecté, l'extension détectera automatiquement le PHPSESSID quel que
// soit le domaine où tu es connecté.
await chrome.tabs.create({ url: "https://itsma.vd.ch/" });
}
// Navigation ±1 jour en sautant les week-ends
function navigateDate(direction) {
const d = isoToDate(state.currentDate);
d.setDate(d.getDate() + direction);
// Sauter les week-ends
while (d.getDay() === 0 || d.getDay() === 6) {
d.setDate(d.getDate() + direction);
}
loadForDate(dateToISO(d));
}
async function onClearCache() {
if (!confirm(`Vider le cache du ${formatDateDM(state.currentDate)} ?`)) return;
await chrome.storage.local.remove(CACHE_PREFIX + state.currentDate);
await loadForDate(state.currentDate, { forceRefetch: true });
}
// ============================================================================
// Date helpers
// ============================================================================
function todayISO() {
const d = new Date();
return dateToISO(d);
}
function dateToISO(d) {
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
function isoToDate(iso) {
const [y, m, d] = iso.split("-").map(n => parseInt(n, 10));
return new Date(y, m - 1, d);
}
function isoToDDMMYYYY(iso) {
const [y, m, d] = iso.split("-");
return `${d}/${m}/${y}`;
}
function formatDateDM(iso) {
const [, m, d] = iso.split("-");
return `${d}/${m}`;
}
function isoToUnixDate(iso) {
// Renvoie le timestamp Unix à midi local du jour (pour que le serveur comprenne bien le jour demandé)
const d = isoToDate(iso);
d.setHours(12, 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
// ============================================================================
// Messages → background
// ============================================================================
function sendMessage(msg) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(msg, (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(response || {});
});
});
}
// ============================================================================
// Cache (chrome.storage.local)
// ============================================================================
async function readCache(isoDate) {
const key = CACHE_PREFIX + isoDate;
const obj = await chrome.storage.local.get(key);
return obj[key] || null;
}
async function writeCache(isoDate, data) {
const key = CACHE_PREFIX + isoDate;
await chrome.storage.local.set({ [key]: { ...data, savedAt: Date.now() } });
}
// ============================================================================
// Flux principal : charger une date
// ============================================================================
async function loadForDate(isoDate, opts = {}) {
state.currentDate = isoDate;
document.getElementById("date-picker").value = isoDate;
if (!state.session) {
showSessionNeeded();
return;
}
// 1. Afficher immédiatement depuis le cache si disponible
const cached = await readCache(isoDate);
if (cached && !opts.forceRefetch) {
renderFromData({
techs: cached.techs,
targetDate: isoDate,
captureTime: cached.savedAt || null,
source: "cache"
});
// Si cache présent ET pas de refresh explicite demandé, on s'arrête là.
// Pas de fetch XML, pas de fetch xhr2, pas de fetch fiches.
// Le cache d'un jour précédent reste affiché jusqu'au prochain refresh manuel.
if (!opts.doStatusRefresh) {
return;
}
} else {
showLoading();
}
// 2. Fetch frais du planning (XML calendar_block — rapide, ~40 ko)
const fresh = await fetchPlanningForDate(isoDate);
if (!fresh) return;
// 3. Fusionner cache + frais
const merged = mergeCacheAndFresh(cached, fresh);
// 4. Afficher immédiatement avec ce qu'on a
renderFromData({
techs: merged.techs,
targetDate: isoDate,
captureTime: Date.now(),
source: "fresh"
});
// 5. PHASE BULLES (xhr_2) : fetch planning_xhr_2.php pour chaque intervention
const bulleNeeded = [];
for (const tech of merged.techs) {
for (const iv of tech.interventions) {
if (iv.type !== "AL-Intervention") continue;
if (iv.infobulle && iv.bulleContact) continue;
bulleNeeded.push(iv);
}
}
if (bulleNeeded.length > 0) {
console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`);
await fetchBullesForInterventions(bulleNeeded);
renderFromData({
techs: merged.techs,
targetDate: isoDate,
captureTime: Date.now(),
source: "fresh+bulles"
});
}
// 6. Sauvegarder dans le cache
await writeCache(isoDate, { techs: merged.techs });
// 7. Fetch fiches en arrière-plan (pour statut + target/checksum clic)
const needFetch = merged.techs.some(tech =>
tech.interventions.some(iv =>
iv.type === "AL-Intervention" && !iv.ficheTarget
)
);
if (opts.doStatusRefresh || needFetch) {
await refreshStatuses(merged.techs, isoDate);
}
showRefreshDone();
}
async function refreshPlanning(opts = {}) {
if (!state.session) {
await refreshSessionAndLoad();
return;
}
// Bouton Rafraîchir manuel : on force le refetch des fiches
await loadForDate(state.currentDate, { ...opts, doStatusRefresh: true });
}
// ============================================================================
// Fetch du planning (via background)
// ============================================================================
async function fetchPlanningForDate(isoDate) {
setRefreshing(true);
try {
const unixDate = isoToUnixDate(isoDate);
const resp = await sendMessage({
type: "fetchPlanning",
session: state.session,
unixDate: unixDate
});
if (!resp.ok) {
if (resp.error === "no_session" || resp.error === "session_expired") {
state.session = null;
showSessionNeeded();
} else {
showError("Erreur de fetch : " + (resp.error || "inconnue"));
}
return null;
}
// Parser le HTML complet du planning (contient TOUT : ref, catégorie,
// contact, lieu, description, formLinks, request_id + checksum)
const techs = parsePlanningXml(resp.xml, isoDate);
return { techs };
} catch (err) {
showError("Erreur inattendue : " + (err.message || err));
return null;
} finally {
setRefreshing(false);
}
}
// ============================================================================
// Parsing du XML du planning
// ============================================================================
/**
* Parse le XML retourné par planning_xhr.php?div=calendar_block.
* Contient les interventions (actions) par technicien, avec :
* - action_id, done_by_id, action_label (parfois juste "AL-Intervention"),
* - start_time / end_time, start_date / end_date,
* - formLink (eventName=formEvent&target=ACTIONID&checksum=...) pour ouvrir l'action,
* - request_id (ID de la fiche SD_REQUEST, utilisé pour ouvrir la fiche).
*/
function parsePlanningXml(xml, isoDate) {
const doc = new DOMParser().parseFromString(xml, "text/xml");
const parserError = doc.querySelector("parsererror");
if (parserError) {
console.warn("Parser error:", parserError.textContent);
}
const actionNodes = doc.querySelectorAll("action");
const byTechId = new Map();
for (const id of Object.keys(TEAM)) {
byTechId.set(id, { id, name: TEAM[id], interventions: [] });
}
for (const node of actionNodes) {
const iv = actionNodeToIntervention(node);
if (!iv) continue;
if (!byTechId.has(iv.techId)) continue;
if (!actionCoversDate(iv, isoDate)) continue;
byTechId.get(iv.techId).interventions.push(iv);
}
for (const tech of byTechId.values()) {
tech.interventions.sort((a, b) =>
(a.startTime || "").localeCompare(b.startTime || "")
);
}
return [...byTechId.values()];
}
function actionNodeToIntervention(node) {
const get = name => node.getAttribute(name) || "";
const actionId = get("action_id");
if (!actionId) return null;
const actionType = get("action_type");
const techId = get("done_by_id");
const label = get("action_label");
const cssClass = get("Css_Class");
const startDate = get("start_date");
const endDate = get("end_date");
const startTime = get("start_time");
const endTime = get("end_time");
const currentDate = get("current_date");
const formLink = get("formLink");
const deadline = get("max_resolution_date") || get("max_intervention_date");
const requestId = get("request_id");
// Extraire la ref S260/I260 du label si présente
const refMatch = label.match(/\b([SI]2\d{5}_\d{5})\b/);
const ref = refMatch ? refMatch[1] : null;
// Détection du type "Réservation" : un coordinateur a bloqué un créneau.
// Dans le XML, action_type = "AL-Absence" pour ce genre de créneau, mais
// action_label contient le vrai pattern :
// action_label = "Xxxxx / Créé par : Nom, Prénom"
// Ex: "Ecrans / Créé par : Nom20, Prénom20"
// "Rollout / Créé par : Nom24, Prénom24"
// "Congés / Créé par : ..." → pas une réservation, c'est une absence
// "Maladie / Créé par : ..." → idem
// "Pompier / Créé par : ..." → idem
let effectiveType = actionType;
let reservationLabel = null;
let reservationCreator = null;
const reservationMatch = label.match(/^([^/]+?)\s*\/\s*Créé par\s*:\s*(.+)$/i);
if (reservationMatch) {
const label1 = reservationMatch[1].trim();
const creator = reservationMatch[2].trim();
// Les "absences" connues (Congés/Maladie/Pompier) restent des absences
if (/^(cong[ée]s|maladie|pompier)$/i.test(label1)) {
effectiveType = "AL-Absence";
} else {
// Tout autre label (Ecrans, Rollout, ...) → Réservation
effectiveType = "AL-Reservation";
reservationLabel = label1;
reservationCreator = creator;
}
}
return {
actionId: actionId,
requestId: requestId,
techId: techId,
label: label,
type: effectiveType, // "AL-Intervention" | "AL-Absence" | "AL-Reservation"
originalType: actionType, // type brut (pour debug)
reservationLabel: reservationLabel, // "Ecrans", "Rollout", etc.
reservationCreator: reservationCreator, // "Nom, Prénom" du coordinateur
cssClass: cssClass,
isPompier: /pompier/i.test(label) || /pompier/i.test(actionType),
ref: ref,
startDate: startDate,
endDate: endDate,
startTime: startTime,
endTime: endTime,
currentDate: currentDate,
formLink: formLink,
deadline: deadline,
bulleContact: null,
bulleLieu: null,
bulleDescription: null,
infobulle: null,
status: null,
categoryLine: null,
commentaireTech: null,
ficheTarget: null,
ficheChecksum: null,
ficheFetched: false,
ficheFetchError: null,
ghost: false
};
}
/**
* Parse le body de planning_xhr_2.php?id=ACTIONID (ou similaire).
* Format observé :
* @@DESCRIPTION_S@@...texte complet de l'action...@@DESCRIPTION_E@@
* @@LABEL_S@@AL-Intervention@@LABEL_E@@
* @@LAST_S@@Nom, Prénom@@LAST_E@@
* @@PLANNED_TIME_S@@@@PLANNED_TIME_E@@
* @@PLANNED_CHANGE_S@@@@PLANNED_CHANGE_E@@
*/
function parseXhr2Body(body) {
if (!body || typeof body !== "string") return null;
const out = { description: null, label: null, last: null };
const rxD = /@@DESCRIPTION_S@@([\s\S]*?)@@DESCRIPTION_E@@/;
const rxL = /@@LABEL_S@@([\s\S]*?)@@LABEL_E@@/;
const rxLa = /@@LAST_S@@([\s\S]*?)@@LAST_E@@/;
const md = body.match(rxD);
const ml = body.match(rxL);
const mla = body.match(rxLa);
if (md) out.description = md[1].trim();
if (ml) out.label = ml[1].trim();
if (mla) out.last = mla[1].trim();
return out;
}
/**
* Fetch planning_xhr_2.php pour chaque intervention en parallèle (12 workers)
* et renseigne bulleContact / bulleLieu / bulleDescription / infobulle.
*/
async function fetchBullesForInterventions(interventions) {
if (!interventions || interventions.length === 0) return { ok: 0, fail: 0 };
setRefreshing(true);
let idx = 0;
let ok = 0, fail = 0;
async function worker() {
while (idx < interventions.length) {
const i = idx++;
const iv = interventions[i];
try {
const resp = await sendMessage({ type: "fetchXhr2", actionId: iv.actionId });
if (!resp || !resp.ok) { fail++; continue; }
const parsed = parseXhr2Body(resp.body);
if (!parsed) { fail++; continue; }
if (parsed.description) {
iv.bulleDescription = parsed.description;
const infob = parseActionText(parsed.description);
if (infob) {
iv.infobulle = infob;
if (infob.contact) iv.bulleContact = infob.contact;
if (infob.lieu) iv.bulleLieu = infob.lieu;
}
}
if (parsed.label) iv.label = parsed.label;
iv.xhr2Fetched = true;
ok++;
} catch (err) {
fail++;
console.warn("[xhr2] erreur iv", iv.actionId, err);
}
}
}
const workers = [];
const nWorkers = Math.min(FETCH_CONCURRENCY, interventions.length);
for (let w = 0; w < nWorkers; w++) workers.push(worker());
await Promise.all(workers);
console.log(`[xhr2] ${ok} OK, ${fail} échecs sur ${interventions.length}`);
setRefreshing(false);
return { ok, fail };
}
function actionCoversDate(iv, isoDate) {
if (!iv.startDate || !iv.endDate) return true; // manque info → on garde
const target = isoToDDMMYYYY(isoDate);
return ddmmyyyyLE(iv.startDate, target) && ddmmyyyyLE(target, iv.endDate);
}
function ddmmyyyyLE(a, b) {
// Compare deux dates JJ/MM/AAAA
const toNum = s => {
const m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
return m ? parseInt(m[3] + m[2] + m[1], 10) : 0;
};
return toNum(a) <= toNum(b);
}
// ============================================================================
// Fusion cache ↔ fresh
// ============================================================================
function mergeCacheAndFresh(cached, fresh) {
// fresh.techs : liste des techs avec interventions d'aujourd'hui (depuis EasyVista)
// cached.techs : dernière liste sauvegardée pour ce jour (avec statuts)
//
// Règles :
// - Chaque intervention fresh APPORTE : actionId, type, startTime, endTime, formLink...
// - Le cache APPORTE : ref, categoryLine, status, infobulle (contact/lieu/...),
// commentaireTech, actionText, ficheFetched
// - Pour les CHAMPS ENRICHIS : cache wins (sauf si fresh en a de meilleurs)
// - Une intervention en cache mais plus en fresh → marquée "ghost"
if (!cached || !cached.techs) {
return { techs: fresh.techs };
}
// Indexer le cache par actionId
const cachedByAction = new Map();
for (const tech of cached.techs) {
for (const iv of tech.interventions || []) {
cachedByAction.set(iv.actionId, iv);
}
}
const resultTechs = fresh.techs.map(t => ({ ...t, interventions: [] }));
const freshActionIds = new Set();
for (const tech of fresh.techs) {
const outTech = resultTechs.find(t => t.id === tech.id);
for (const iv of tech.interventions) {
freshActionIds.add(iv.actionId);
const cachedIv = cachedByAction.get(iv.actionId);
if (cachedIv) {
// On part du cache (qui a les champs enrichis), puis on remplace
// les champs "live" depuis le fresh (horaires, type, formLink).
const merged = {
...cachedIv,
// Champs live venant du fresh (le planning peut avoir bougé)
techId: iv.techId || cachedIv.techId,
type: iv.type || cachedIv.type,
label: iv.label || cachedIv.label,
cssClass: iv.cssClass || cachedIv.cssClass,
isPompier: iv.isPompier,
startDate: iv.startDate || cachedIv.startDate,
endDate: iv.endDate || cachedIv.endDate,
startTime: iv.startTime || cachedIv.startTime,
endTime: iv.endTime || cachedIv.endTime,
currentDate: iv.currentDate || cachedIv.currentDate,
formLink: iv.formLink || cachedIv.formLink,
deadline: iv.deadline || cachedIv.deadline,
requestId: iv.requestId || cachedIv.requestId,
// Ref : on privilégie celle qu'on a (fresh ou cached)
ref: cachedIv.ref || iv.ref,
// Bulle (HTML planning) : fresh est plus à jour
bulleContact: iv.bulleContact || cachedIv.bulleContact,
bulleLieu: iv.bulleLieu || cachedIv.bulleLieu,
bulleDescription: iv.bulleDescription || cachedIv.bulleDescription,
// ghost : on retire (cette intervention est bien là dans le fresh)
ghost: false
};
outTech.interventions.push(merged);
} else {
outTech.interventions.push(iv);
}
}
}
// Ajouter les interventions qui sont en cache mais plus en fresh
for (const tech of cached.techs) {
const outTech = resultTechs.find(t => t.id === tech.id);
if (!outTech) continue;
for (const iv of tech.interventions || []) {
if (!freshActionIds.has(iv.actionId)) {
const ghost = { ...iv, ghost: true };
outTech.interventions.push(ghost);
}
}
// Retrier
outTech.interventions.sort((a, b) =>
(a.startTime || "").localeCompare(b.startTime || "")
);
}
return { techs: resultTechs };
}
// ============================================================================
// Fetch des fiches individuelles (pour obtenir le statut et les détails)
// ============================================================================
async function refreshStatuses(techs, isoDate) {
// Construire la liste des interventions à fetcher, dans l'ordre de priorité :
// 1. Interventions du (des) pompier(s) en premier
// 2. Puis les autres techs par ordre alphabétique du nom de famille
// 3. (Les absents n'ont pas d'interventions à fetcher)
const sortedTechs = [...techs].sort((a, b) => compareTechs(a, b, isoDate));
const toFetch = [];
for (const tech of sortedTechs) {
for (const iv of tech.interventions) {
if (iv.type !== "AL-Intervention") continue;
if (!iv.formLink) continue;
// On skip si :
// - Déjà clos / résolu ET ficheTarget déjà connu (statut + requestId OK)
// - Sinon on garde (pour avoir statut frais OU ficheTarget pour clic)
const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status);
if (statusClosed && iv.ficheTarget) continue;
toFetch.push(iv);
}
}
if (toFetch.length === 0) return;
setRefreshing(true);
try {
// Fetcher avec concurrence = FETCH_CONCURRENCY (12)
let idx = 0;
async function worker() {
while (idx < toFetch.length) {
const i = idx++;
await fetchAndUpdateIntervention(toFetch[i]);
}
}
const workers = [];
for (let w = 0; w < FETCH_CONCURRENCY; w++) workers.push(worker());
await Promise.all(workers);
// Résoudre le sort des ghosts
for (const tech of techs) {
tech.interventions = tech.interventions.filter(iv => {
if (!iv.ghost) return true;
if (CANCELLED_STATUS.includes(iv.status)) return false;
return true;
});
}
// Sauvegarder le résultat enrichi dans le cache
await writeCache(isoDate, { techs });
// Re-rendre pour afficher les mises à jour (un seul rendu à la fin)
renderFromData({
techs,
targetDate: isoDate,
captureTime: Date.now(),
source: "fresh+statuses"
});
} finally {
setRefreshing(false);
}
}
async function fetchAndUpdateIntervention(iv) {
try {
// 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
});
if (!ficheResp.ok) {
iv.ficheFetched = true;
iv.ficheFetchError = ficheResp.error || "fetch_failed";
if (ficheResp.error === "session_expired") {
state.session = null;
}
return;
}
const fiche = parseFicheHtml(ficheResp.html);
iv.status = fiche.status;
iv.categoryLine = fiche.categoryLine || iv.categoryLine;
if (fiche.rfc && !iv.ref) {
iv.ref = fiche.rfc;
}
iv.commentaireTech = fiche.commentaireTech;
// Extraire le checksum CORRECT pour ouvrir la fiche :
// - Le target de la FICHE = iv.requestId (vient du XML)
// - Il faut trouver le checksum qui est accolé à ce target dans le HTML
// (pattern : target=REQUEST_ID&checksum=XXX...)
if (iv.requestId) {
const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`);
const ckm = ficheResp.html.match(rx);
if (ckm) {
iv.ficheTarget = iv.requestId;
iv.ficheChecksum = ckm[1];
}
}
iv.ficheFetched = true;
// Pour l'API timeline, on utilise le MÊME target + checksum (celui de la fiche)
const timelineTarget = iv.ficheTarget;
const timelineChecksum = iv.ficheChecksum;
// Étape timeline API : on veut le texte COMPLET de l'action.
// planning_xhr_2.php tronque souvent à ~300 chars, mais l'API timeline
// retourne le texte intégral. On la fetch à chaque fois que possible.
const needsTimelineValidation = !iv.actionText;
if (needsTimelineValidation && timelineTarget && timelineChecksum) {
const tlResp = await sendMessage({
type: "fetchTimeline",
target: timelineTarget,
checksum: timelineChecksum
});
if (tlResp && tlResp.ok) {
const actionDetails = parseTimelineJson(tlResp.body, iv.actionId);
if (actionDetails && actionDetails.text) {
iv.actionText = actionDetails.text;
iv.actionDone = actionDetails.doneById;
// Le texte de timeline est plus complet que bulleDescription :
// on remplace bulleDescription par actionText pour le tooltip.
iv.bulleDescription = actionDetails.text;
const infob = parseActionText(actionDetails.text);
if (infob) {
iv.infobulle = infob;
if (infob.contact) iv.bulleContact = infob.contact;
if (infob.lieu) iv.bulleLieu = infob.lieu;
}
}
}
}
} catch (err) {
iv.ficheFetched = true;
iv.ficheFetchError = String(err);
console.warn("fetchAndUpdate error:", err);
}
}
function isClosedStatus(s) {
return !!s && CLOSED_STATUS.some(x => s.includes(x));
}
function isResolvedStatus(s) {
return !!s && RESOLVED_STATUS.some(x => s.includes(x));
}
function isCancelledStatus(s) {
return !!s && CANCELLED_STATUS.some(x => s.includes(x));
}
// ============================================================================
// Parsing d'une fiche individuelle (HTML)
// ============================================================================
function parseFicheHtml(html) {
const out = {
status: null,
rfc: null,
categoryLine: null,
commentaireTech: null
};
// STATUS_FR (valeur parfois encodée en \u00XX)
let m = html.match(/"dbFieldName"\s*:\s*"STATUS_FR"[^}]*?"value"\s*:\s*"([^"]{2,30})"/);
if (m) out.status = decodeJsonString(m[1]);
// RFC_NUMBER
m = html.match(/"dbFieldName"\s*:\s*"RFC_NUMBER"[^}]*?"value"\s*:\s*"([^"]{5,30})"/);
if (m) out.rfc = m[1];
// TITLE_FR contient la catégorie complète
m = html.match(/"dbFieldName"\s*:\s*"TITLE_FR"[^}]*?"value"\s*:\s*"([^"]{5,300})"/);
if (m) out.categoryLine = decodeJsonString(m[1]);
// Commentaire tech à la fin de DESCRIPTION : "
techN: ..."
m = html.match(/"dbFieldName"\s*:\s*"DESCRIPTION"[^}]*?"value"\s*:\s*"((?:[^"\\]|\\.)+)"/);
if (m) {
const desc = decodeJsonString(m[1]);
const ctm = desc.match(/
\s*
\s*([a-z][a-z0-9]{2,14})\s*:\s*([^<]{3,500})/i);
if (ctm) {
out.commentaireTech = ctm[1] + ": " + ctm[2].trim();
}
}
return out;
}
function decodeJsonString(s) {
return s
.replace(/\\r/g, "")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\\//g, "/")
.replace(/\\"/g, '"')
.replace(/\\\\/g, "\\")
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
try { return String.fromCharCode(parseInt(hex, 16)); }
catch { return _; }
});
}
// ============================================================================
// Parse de la réponse JSON de /api/v1/.../timeline
// Extrait le texte de l'action correspondant à un actionId donné.
// ============================================================================
function parseTimelineJson(body, actionId) {
let json;
try {
json = JSON.parse(body);
} catch {
return null;
}
const values = json && json.data && json.data.data && json.data.data.values;
if (!Array.isArray(values)) return null;
// Chaque élément de values a :
// - rows: [{value}, {value}, ...] (la ligne du tableau)
// - dans la colonne d'index 11 : le texte de l'action (ce qu'on veut)
// - dans la colonne d'index 13 : un objet JSON stringifié avec ACTION_ID, AM_DONE_BY_ID, etc.
//
// L'ordre des colonnes peut varier. On ne se fie pas à des index magiques :
// - on cherche la colonne avec ACTION_ID==actionId pour identifier la bonne ligne
// - dans cette ligne, on prend la colonne qui ressemble à une description
// (contient "
" ou plusieurs ":" typiques de "Date :", "Lieu :", etc.)
for (const row of values) {
const cells = Array.isArray(row && row.rows) ? row.rows : [];
// Chercher la colonne "data" qui est un JSON avec ACTION_ID
let meta = null;
for (const c of cells) {
const v = c && c.value;
if (typeof v === "string" && v.startsWith('{"') && v.includes("ACTION_ID")) {
try {
meta = JSON.parse(v);
break;
} catch { /* ignore */ }
}
}
if (!meta) continue;
if (String(meta.ACTION_ID) !== String(actionId)) continue;
// On a trouvé notre action. Chercher la cellule texte (la plus longue contenant
)
let best = "";
for (const c of cells) {
const v = c && c.value;
if (typeof v !== "string") continue;
if (v.startsWith('{"')) continue; // c'est un JSON meta, pas le texte
if (v.length < 20) continue;
if (v.length > best.length) best = v;
}
// Décoder les entités (
→ \n, &/</>/ , \uXXXX)
const text = decodeActionText(best);
return {
text: text,
doneById: meta.AM_DONE_BY_ID || null,
actionLabel: meta.NAME || null
};
}
return null;
}
function decodeActionText(s) {
if (!s) return "";
// \uXXXX échappés en JSON (déjà décodés par JSON.parse normalement,
// mais au cas où on reçoit un fragment non parsé)
let out = s.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
try { return String.fromCharCode(parseInt(hex, 16)); }
catch { return _; }
});
// Tags
→ retour à la ligne
out = out.replace(/
/gi, "\n");
// Autres tags HTML : on les enlève
out = out.replace(/<[^>]+>/g, "");
// Entités HTML
out = out
.replace(/ /g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'");
return out.trim();
}
/**
* Parse le texte d'une action au format :
* Date : lundi 20.04 Heure : matin
* Lieu : Ville1/Rue1 1
* Service : Service1/...
* Contact : Nom1, Prénom1 +41000000001
* ...
*
* → renvoie un objet { date, heure, lieu, service, contact, etage, bureau,
* probleme, aFaire, tfsAncien, tfsNouveau, materiel, dateProposee, autres }
*/
function parseActionText(text) {
if (!text) return null;
const out = { _raw: text };
// Pré-filtrer les lignes "Date proposée par ..." : on NE prend PAS ce champ
// nulle part (ni en infobulle.dateProposee, ni dans autres).
const lines = text.split(/\n+/)
.map(l => l.trim())
.filter(Boolean)
.filter(l => !/^\s*date\s+propos[ée]e\s+par\b/i.test(l));
const labelMap = {
"date": "date",
"heure": "heure",
"lieu": "lieu",
"service": "service",
"contact": "contact",
"bénéficiaire": "beneficiaire",
"beneficiaire": "beneficiaire",
"étage": "etage",
"etage": "etage",
"bureau": "bureau",
"problème": "probleme",
"probleme": "probleme",
"a faire": "aFaire",
"à faire": "aFaire",
"matériel": "materiel",
"materiel": "materiel",
"tfs ancien poste": "tfsAncien",
"tfs nouveau poste": "tfsNouveau"
};
const autres = [];
for (const line of lines) {
// Si la ligne CONTIENT "Date proposée par ..." à l'intérieur (pas juste au
// début), on coupe cette partie-là avant de parser le reste.
// Ex: "...Matériel : xxx Date proposée par contact : oui" → on garde la
// partie Matériel mais on jette "Date proposée..."
let cleanLine = line.replace(/\bdate\s+propos[ée]e\s+par\s+(?:le\s+|la\s+)?contact\s*[:?]\s*\S+.*$/i, "").trim();
if (!cleanLine) continue;
// "Date : lundi 20.04 Heure : matin" → split en plusieurs paires
const markers = [];
const rx = /(Date|Heure|Lieu|Service|Contact|B[ée]n[ée]ficiaire|[ÉE]tage|Bureau|Probl[èe]me|A\s*faire|À\s*faire|Mat[ée]riel|TFS\s+ancien\s+poste|TFS\s+nouveau\s+poste)\s*:\s*/gi;
let m;
while ((m = rx.exec(cleanLine)) !== null) {
markers.push({ label: m[1], valueStart: m.index + m[0].length });
}
if (markers.length === 0) {
autres.push(cleanLine);
continue;
}
for (let i = 0; i < markers.length; i++) {
const mk = markers[i];
let val;
if (i + 1 < markers.length) {
const nextStart = cleanLine.indexOf(markers[i + 1].label, mk.valueStart);
val = cleanLine.substring(mk.valueStart, nextStart).trim();
} else {
val = cleanLine.substring(mk.valueStart).trim();
}
const keyNorm = mk.label.toLowerCase().replace(/\s+/g, " ");
const outKey = labelMap[keyNorm];
if (outKey && val) {
out[outKey] = out[outKey] ? out[outKey] + " / " + val : val;
}
}
}
if (autres.length) out.autres = autres.join("\n");
return out;
}
// ============================================================================
// Rendu général
// ============================================================================
// Compteur de fetches en cours. La flèche tourne tant que ce compteur > 0.
// On le maintient manuellement au lieu d'un booléen pour gérer correctement
// les appels imbriqués (loadForDate + refreshStatuses en parallèle).
let refreshCounter = 0;
// Timer pour effacer le ✓ vert après 5 s
let refreshDoneTimer = null;
function setRefreshing(on) {
const icon = document.getElementById("refresh-icon");
if (on) {
refreshCounter++;
if (icon) icon.classList.add("spinning");
clearCheckMark();
// Afficher "Rafraîchissement en cours…" si on n'a pas déjà les données
// (on ne veut pas écraser l'heure du cache si on est juste en train
// de re-fetch en arrière-plan)
updateCaptureInfoText();
} else {
refreshCounter = Math.max(0, refreshCounter - 1);
if (refreshCounter === 0 && icon) {
icon.classList.remove("spinning");
}
updateCaptureInfoText();
}
}
// Force le rafraîchissement du texte "MAJ HH:MM" ou "Rafraîchissement en cours…"
// selon refreshCounter.
function updateCaptureInfoText() {
if (state.currentData) {
renderCaptureInfo(state.currentData);
}
}
/**
* Appelé quand TOUS les fetches (y compris les fetches fiches en
* arrière-plan) sont terminés. Affiche un ✓ vert à côté de l'heure MAJ
* pendant 5 secondes.
*/
function showRefreshDone() {
const check = document.getElementById("refresh-check");
if (!check) return;
check.classList.remove("hidden");
check.classList.add("visible");
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
refreshDoneTimer = setTimeout(() => {
check.classList.remove("visible");
setTimeout(() => check.classList.add("hidden"), 300); // après transition
}, 5000);
}
function clearCheckMark() {
const check = document.getElementById("refresh-check");
if (check) {
check.classList.remove("visible");
check.classList.add("hidden");
}
if (refreshDoneTimer) {
clearTimeout(refreshDoneTimer);
refreshDoneTimer = null;
}
}
function renderFromData(data) {
state.currentData = data;
document.getElementById("loading").classList.add("hidden");
document.getElementById("error-box").classList.add("hidden");
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("cards").classList.remove("hidden");
// Calculer les stats
const stats = computeStats(data.techs, data.targetDate);
renderCaptureInfo(data, stats);
renderStats(stats);
renderCards(data);
}
function renderCaptureInfo(data, stats) {
const info = document.getElementById("capture-info");
if (refreshCounter > 0) {
info.textContent = "Rafraîchissement en cours…";
info.classList.add("refreshing");
return;
}
info.classList.remove("refreshing");
const parts = [];
if (data.captureTime) {
const d = new Date(data.captureTime);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
// Comparer la date du cache avec aujourd'hui :
// - si c'est aujourd'hui → juste l'heure
// - sinon → date + heure (format "17.04 14:32")
const today = new Date();
const isSameDay = d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const prefix = data.source === "cache" ? "Cache de " : "MAJ ";
if (isSameDay) {
parts.push(`${prefix}${hh}:${mm}`);
} else {
const dd = String(d.getDate()).padStart(2, "0");
const mo = String(d.getMonth() + 1).padStart(2, "0");
const prefixDate = data.source === "cache" ? "Cache du " : "MAJ ";
parts.push(`${prefixDate}${dd}.${mo} ${hh}:${mm}`);
}
}
info.textContent = parts.join(" · ");
}
function computeStats(techs, targetDate) {
let pompiers = 0, absents = 0;
let totalInterventions = 0, morning = 0, afternoon = 0;
let closed = 0, resolved = 0;
for (const tech of techs) {
const isPompier = tech.interventions.some(iv => iv.isPompier);
const isAbsent = isTechAbsent(tech, targetDate);
if (isPompier) pompiers++;
if (isAbsent) absents++;
const real = tech.interventions.filter(iv =>
iv.type !== "AL-Absence" && !iv.isPompier
);
for (const iv of real) {
totalInterventions++;
const s = timeToMinutes(iv.startTime);
if (s !== null && s < 12 * 60) morning++;
else if (s !== null) afternoon++;
if (isClosedStatus(iv.status)) closed++;
else if (isResolvedStatus(iv.status)) resolved++;
}
}
return { totalTechs: techs.length, pompiers, absents, totalInterventions, morning, afternoon, closed, resolved };
}
function renderStats(s) {
const el = document.getElementById("stats");
el.innerHTML = `
${s.totalInterventions} intervention${s.totalInterventions > 1 ? "s" : ""}
(${s.morning} matin · ${s.afternoon} après-midi)
${(s.closed + s.resolved > 0) ? `·${s.closed + s.resolved} clos` : ""}
·
${s.totalTechs} techs
·
${s.pompiers} pompier${s.pompiers > 1 ? "s" : ""}
·
${s.absents} absent${s.absents > 1 ? "s" : ""}
`;
el.classList.remove("hidden");
}
function renderCards(data) {
const container = document.getElementById("cards");
container.innerHTML = "";
// Tri : pompier(s) > actifs alphabétique nom de famille > absents alphabétique
const sorted = [...data.techs].sort((a, b) => compareTechs(a, b, data.targetDate));
for (const tech of sorted) {
container.appendChild(buildCard(tech, data.targetDate));
}
}
function compareTechs(a, b, targetDate) {
const aP = a.interventions.some(iv => iv.isPompier);
const bP = b.interventions.some(iv => iv.isPompier);
if (aP && !bP) return -1;
if (bP && !aP) return 1;
const aAbs = isTechAbsent(a, targetDate);
const bAbs = isTechAbsent(b, targetDate);
if (aAbs && !bAbs) return 1;
if (bAbs && !aAbs) return -1;
// Sinon : alphabétique sur le nom de famille
// Les noms sont stockés au format "Nom, Prénom"
const aLast = (a.name || "").split(",")[0].trim();
const bLast = (b.name || "").split(",")[0].trim();
return aLast.localeCompare(bLast, "fr");
}
function isTechAbsent(tech, isoDate) {
const recurring = RECURRING_ABSENCES[tech.id];
if (recurring) {
const day = isoToDate(isoDate).getDay();
if (recurring.includes(day)) return true;
}
if (tech.interventions.length === 0) return false;
return tech.interventions.every(iv => iv.type === "AL-Absence" && !iv.isPompier);
}
// ============================================================================
// Construction d'une carte
// ============================================================================
function buildCard(tech, isoDate) {
const card = document.createElement("section");
card.className = "card";
card.dataset.techId = tech.id;
const isPompier = tech.interventions.some(iv => iv.isPompier);
const isAbsent = isTechAbsent(tech, isoDate);
if (isPompier) card.classList.add("is-pompier");
if (isAbsent) card.classList.add("is-absent");
const realInterventions = tech.interventions.filter(iv =>
iv.type !== "AL-Absence" && !iv.isPompier
);
const absenceBlocks = tech.interventions.filter(iv => iv.type === "AL-Absence");
const pompierBlocks = tech.interventions.filter(iv => iv.isPompier);
const morning = realInterventions.filter(iv => {
const s = timeToMinutes(iv.startTime);
return s !== null && s < 12 * 60;
}).length;
const afternoon = realInterventions.length - morning;
// --- Header ---
const header = document.createElement("div");
header.className = "card-header";
const nameEl = document.createElement("div");
nameEl.className = "card-tech-name";
nameEl.textContent = tech.name;
header.appendChild(nameEl);
if (isPompier || isAbsent) {
const badge = document.createElement("div");
badge.className = "card-tech-badge";
if (isPompier) {
badge.classList.add("badge-pompier");
badge.textContent = "Pompier";
} else {
badge.classList.add("badge-absent");
badge.textContent = "Absent";
}
header.appendChild(badge);
}
card.appendChild(header);
// --- Body ---
const body = document.createElement("div");
body.className = "card-body";
// Note statut
if (isPompier && pompierBlocks.length) {
const note = document.createElement("div");
note.className = "card-status-note pompier";
const pb = pompierBlocks[0];
if (pb.startDate && pb.endDate && pb.startDate !== pb.endDate) {
note.textContent = `En pompier du ${pb.startDate.substring(0, 5)} au ${pb.endDate.substring(0, 5)}`;
} else {
note.textContent = "En pompier aujourd'hui";
}
body.appendChild(note);
} else if (isAbsent && absenceBlocks.length) {
const note = document.createElement("div");
note.className = "card-status-note absent";
const ab = absenceBlocks[0];
if (ab.startDate && ab.endDate && ab.startDate !== ab.endDate) {
note.textContent = `Absent du ${ab.startDate.substring(0, 5)} au ${ab.endDate.substring(0, 5)}`;
} else {
note.textContent = "Absent toute la journée";
}
body.appendChild(note);
}
// Absent sans interv → on stop là
if (isAbsent && realInterventions.length === 0) {
card.appendChild(body);
return card;
}
if (realInterventions.length === 0 && !isPompier) {
const empty = document.createElement("div");
empty.className = "card-empty";
empty.textContent = "Pas d'intervention planifiée";
body.appendChild(empty);
card.appendChild(body);
return card;
}
// Timeline
body.appendChild(buildTimeline(realInterventions, pompierBlocks, absenceBlocks, card, isPompier, isAbsent));
// Stats de carte
if (realInterventions.length > 0) {
const stats = document.createElement("div");
stats.className = "card-stats";
stats.innerHTML = `