Compare commits

..
Author SHA1 Message Date
FroSteel a37b90aa13 v2026.5.50 : texte des actions rétabli (format EasyVista du 29.09), catégories Roll Out et Déménagement
- Timeline et fiche : la cellule JSON qui porte ACTION_ID est repérée par son
  contenu, la description par le nom de sa colonne (EasyVista est passé de 14 à
  13 cellules par action et le texte des actions ne s'affichait plus).
- Format EasyVista non reconnu : avertissement unique dans la console ; fiche ou
  historique non chargé journalisé.
- Réservations : catégories Roll Out et Déménagement, propres à la Planification
  (EasyVista reste « Evènements spéciaux ») ; catégorie inconnue conservée à
  l'édition ; proposée à la saisie du n° d'une intervention Roll Out.
- firefox-updates.json : 2026.5.50, xpi signé par AMO.
2026-09-29 13:18:02 +02:00
FroSteel a1c7f45b03 docs: prenom de l'auteur (Quentin) retabli a la place du pseudonyme
Le prenom seul reste visible dans les projets ; le nom de famille n'y figure jamais.
2026-09-14 00:26:34 +02:00
FroSteel cd106484d5 refactor: reconnaissance de l'auteur par empreintes, sans nom en clair 2026-09-13 21:05:00 +02:00
FroSteel e846e90bb8 docs: exemples d'adresses coherents apres anonymisation 2026-09-13 16:45:10 +02:00
12 changed files with 226 additions and 94 deletions
+27 -5
View File
@@ -1,7 +1,7 @@
# CHANGELOG — Extension Planification EasyVista Canton de Vaud
> Ce changelog documente l'évolution de l'extension Chrome/Firefox "Planification"
> développée par FroSteel pour les coordinateurs DGNSI (Canton de Vaud).
> développée par Quentin pour les coordinateurs DGNSI (Canton de Vaud).
>
> Les versions documentées ci-dessous sont celles dont les détails sont connus.
> Pour les versions plus anciennes, l'analyse du code source permet de
@@ -9,6 +9,28 @@
---
## v2026.5.50 — Texte des actions rétabli (format EasyVista du 29.09.2026), catégories Roll Out et Déménagement
> Correctif urgent : depuis le 29 septembre 2026, le texte complet des actions ne s'affichait plus. EasyVista renvoie désormais les actions avec une cellule de moins, et l'extension les lisait par position. Elle les retrouve maintenant par leur contenu. Cette version ajoute aussi deux catégories de réservation.
### Texte des actions
- **Texte complet des actions rétabli** dans les bulles et les fiches, avec l'ancien comme avec le nouveau format d'EasyVista.
- **Plus robuste aux prochains changements** : la description est repérée par le nom de sa colonne, et l'identifiant de l'action par son contenu, plus par leur position.
- **Changement de format signalé** : si EasyVista modifie encore la structure au point qu'aucune action ne soit lisible, un avertissement apparaît dans la console (une seule fois, sans aucune donnée personnelle). Les fiches ou historiques qui ne se chargent pas sont aussi signalés dans la console.
### Réservations
- **Deux nouvelles catégories : Roll Out et Déménagement.** Elles sont propres à la Planification : dans EasyVista, la réservation reste « Evènements spéciaux ». Elles sont partagées entre tous les postes par le dossier partagé, comme le n°, la ville et la rue.
- **Catégorie proposée à la saisie du n°** : le n° d'une intervention Roll Out propose Roll Out.
- **Catégorie inconnue conservée** : une catégorie posée par une version plus récente n'est plus effacée quand on modifie la réservation.
### Déploiement
- **Mettre à jour tous les postes le même jour.** Un poste resté en 2026.5.49 qui modifie une réservation Roll Out ou Déménagement effacerait cette catégorie sur tous les postes.
---
## v2026.5.49 — Sécurité hors session, onglet Email clarifié, lecture au défilement, affichage et synchronisation fiabilisés
> Cette version renforce la **sécurité** quand aucune session EasyVista n'est active, clarifie l'**onglet Email** (déplacement et annulation bien séparés, aperçu de la copie), améliore la **lecture du planning au défilement** (le nom du technicien, son compteur et la frise horaire restent visibles), et corrige en profondeur l'**affichage de la reconnexion** ainsi que plusieurs comportements de la synchronisation et des réservations.
@@ -122,7 +144,7 @@
- **Règle des heures fixe** en haut (reste collée au défilement), **lignes verticales continues** sur toute la hauteur du tableau : trait net à chaque heure, pointillé discret aux demi-heures, césure rayée à midi ; les traits passent derrière les bulles et sautent les techniciens absents.
- **Ligne rouge « maintenant »** continue sur toute la hauteur (jour courant), reposée instantanément à chaque rendu ; elle saute aussi les absents.
- **Chevauchements** : les éléments qui se recouvrent s'empilent en sous-lignes de même hauteur — la zone du technicien s'agrandit seulement dans ce cas ; tolérance de 10 minutes pour ne pas empiler de simples enchaînements.
- **Cartes** : fond neutre, le bord gauche porte la couleur de catégorie et sert de **poignée** (cliquer = fenêtre de modification, glisser = déplacer ; présente sur toutes les cartes, déplacement bloqué pour les faites/clôturées, réservations et absences). Numéro d'intervention en tête, ville + adresse groupées ; cartes étroites : texte compact (ville + fin d'adresse, « Rue de l'Exemple 5 » → « Université 5 »), jamais vides tant qu'il reste de la place.
- **Cartes** : fond neutre, le bord gauche porte la couleur de catégorie et sert de **poignée** (cliquer = fenêtre de modification, glisser = déplacer ; présente sur toutes les cartes, déplacement bloqué pour les faites/clôturées, réservations et absences). Numéro d'intervention en tête, ville + adresse groupées ; cartes étroites : texte compact (ville + fin d'adresse, « Rue de l'Exemple 5 » → « Exemple 5 »), jamais vides tant qu'il reste de la place.
- **Réservations** : affichent sous l'intitulé le lieu renseigné à la création, sinon le nom de la personne qui a réservé.
- **Zones libres** : survol = « Libre HH:MM–HH:MM » (y compris sur les journées totalement vides), clic droit = Réservation/Absence à l'heure du curseur, dépôt du drag précis (pas de 30 min) avec marqueur vert sur toute la bande.
- Alignement règle/cartes mesuré au pixel réel (indépendant du zoom et de la taille de texte).
@@ -540,7 +562,7 @@ Une intervention terminée par le tech mais dont la fiche était passée en stat
### Attribution auteur
- Ajout en-têtes copyright dans tous les fichiers source
(viewer.js, viewer.html, viewer.css, background.js)
- Ajout `@author FroSteel` sur les fonctions principales
- Ajout `@author Quentin` sur les fonctions principales
(loadForDate, buildCard, buildTooltipHTML, pinTooltip, _softUnpinPopup,
positionTooltipAnchored, _applyViewMode, _moveElementsToSidebar,
_restoreElementsToTopbar, fetchAndShowCurrentUser, _maybeRetryFetchUser,
@@ -548,7 +570,7 @@ Une intervention terminée par le tech mais dont la fiche était passée en stat
openPersistentTimelinePopup, showTooltip, _findFreePopupPosition,
_clampPopupInSafeArea, findEasyVistaSession, fetchPlanningXml,
fetchCurrentUser, detectNetworkContext)
- Ajout signature "Développé par FroSteel" en bas du popup
- Ajout signature "Développé par Quentin" en bas du popup
user-badge (style cohérent avec footer version : 11px, italique,
gris atténué, séparateur fin)
- Mise à jour `description` du manifest pour mentionner DGNSI
@@ -740,6 +762,6 @@ Une intervention terminée par le tech mais dont la fiche était passée en stat
## Auteur
**FroSteel** (FRS)
**Quentin** (FRS)
Technicien DGNSI — Canton de Vaud
Contact : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact)
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 FroSteel
Copyright (c) 2026 Quentin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+12 -5
View File
@@ -6,10 +6,10 @@ Extension Chrome / Firefox pour visualiser de manière claire et rapide le plann
## Aperçu rapide
- **Auteur** : FroSteel (FRS), Technicien DGNSI — Canton de Vaud
- **Auteur** : Quentin (FRS), Technicien DGNSI — Canton de Vaud
- **Public cible** : coordinateurs DGNSI qui pilotent dans EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) le planning de l'équipe technicienne
- **Démarrage projet** : jeudi 16 avril 2026
- **Version actuelle** : [`v2026.5.49`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.49) (latest)
- **Version actuelle** : [`v2026.5.50`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.50) (latest)
- **Contact** : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact) ou [ouvrir une issue](https://gitea.netaplaid.ch/FroSteel/Planification/issues/new)
- **Manifest** : V3 (Chrome/Edge/Firefox 140+)
- **Format** : `.zip` (Chromium) + `.xpi` signé Mozilla (Firefox)
@@ -121,7 +121,14 @@ Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au ca
## Versions notables
### `v2026.5.49` (latest, 1 septembre 2026) — Sécurité hors session, onglet Email clarifié, lecture au défilement, affichage fiabilisé
### `v2026.5.50` (latest, 29 septembre 2026) — Texte des actions rétabli, catégories Roll Out et Déménagement
- Correctif : depuis le 29 septembre 2026, EasyVista renvoie les actions avec une cellule de moins et le texte complet des actions ne s'affichait plus ; l'extension retrouve désormais ces informations par leur contenu et par le nom des colonnes, pour tenir aux prochains changements de format
- Si EasyVista change encore de format, un avertissement clair apparaît dans la console (une seule fois)
- Réservations : deux nouvelles catégories, Roll Out et Déménagement, propres à la Planification (dans EasyVista, la réservation reste « Evènements spéciaux ») ; partagées entre postes comme les autres informations de réservation
- Mettre à jour tous les postes le même jour : un poste resté en 2026.5.49 effacerait ces catégories en modifiant la réservation
### `v2026.5.49` (1 septembre 2026) — Sécurité hors session, onglet Email clarifié, lecture au défilement, affichage fiabilisé
- Sans session EasyVista active : plus d'écriture dans le dossier partagé (cache local seul) ni de modification (déplacement, suppression, absence, réservation bloqués proprement)
- Onglet Email séparé en deux sections (déplacement / annulation), copie (CC) commune avec aperçu en direct des adresses retenues, variables en colonnes
@@ -263,10 +270,10 @@ de cette version dans `firefox-updates.json`.
## Licence
[MIT License](LICENSE) — Copyright (c) 2026 FroSteel
[MIT License](LICENSE) — Copyright (c) 2026 Quentin
## Auteur
**FroSteel** (FRS)
**Quentin** (FRS)
Technicien DGNSI — Canton de Vaud
Contact : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact)
+5
View File
@@ -2,6 +2,11 @@
"addons": {
"[email protected]": {
"updates": [
{
"version": "2026.5.50",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.50/planification-v2026.5.50-firefox.xpi",
"update_hash": "sha256:314ae3cba7e31d1f6eb89c419a666d49d156cd78a83c116de03f5e48b8a094df"
},
{
"version": "2026.5.49",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.49/planification-v2026.5.49-firefox.xpi",
+1 -1
View File
@@ -1,5 +1,5 @@
# Planification - native messaging host // 2026.1.4 - FRS
# Developpe par FroSteel (FRS) - DGNSI, Canton de Vaud.
# Developpe par Quentin (FRS) - DGNSI, Canton de Vaud.
# Licence MIT - voir le fichier LICENSE.
#
# Pont entre l'extension et un dossier reseau partage pour le cache du planning.
+1 -1
View File
@@ -1,5 +1,5 @@
# Planification - gestionnaire graphique du helper de sync. // 2026.1.5 - FRS
# Developpe par FroSteel (FRS) - DGNSI, Canton de Vaud.
# Developpe par Quentin (FRS) - DGNSI, Canton de Vaud.
# Licence MIT - voir le fichier LICENSE.
#
# Fenetre avec : Installer / Mettre a jour, Reparer, Desinstaller.
+1 -1
View File
@@ -1,5 +1,5 @@
# Planification - icone barre des taches (best-effort, optionnel) // 2026.1.4 - FRS
# Developpe par FroSteel (FRS) - DGNSI, Canton de Vaud.
# Developpe par Quentin (FRS) - DGNSI, Canton de Vaud.
# Licence MIT - voir le fichier LICENSE.
#
# Indique que le helper de sync est installe. Clic / menu -> fenetre du journal
+8 -8
View File
@@ -4,13 +4,13 @@
* Service worker (Manifest V3) : récupération session EV, fetch planning XML,
* fetch fiches détaillées, gestion cache.
*
* Copyright (c) 2026 FroSteel
* Copyright (c) 2026 Quentin
* Licensed under the MIT License — see LICENSE file in the project root.
*
* @author FroSteel
* @author Quentin
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
*
* Développé par FroSteel (FRS) — DGNSI, Canton de Vaud. Licence MIT.
* Développé par Quentin (FRS) — DGNSI, Canton de Vaud. Licence MIT.
*/
// background.js — Service worker (Manifest V3) — v4
@@ -262,7 +262,7 @@ chrome.action.onClicked.addListener(async () => {
* dans l'URL des onglets historiques après un relog. Sinon, fallback sur
* l'ancienne logique URL (compat sans la permission).
*
* @author FroSteel
* @author Quentin
*/
async function _hasCookiesPermission() {
return new Promise(resolve => {
@@ -423,7 +423,7 @@ async function findEasyVistaSession() {
* Ce n'est PAS le HTML de la page Planning — le serveur ne rend pas les données
* dans le HTML, elles arrivent via cet endpoint AJAX.
*
* @author FroSteel
* @author Quentin
*/
async function fetchPlanningXml(origin, phpsessid, unixDate) {
// v2026.5.41 : groupId vient de admin_config (défaut SI-CSS).
@@ -781,7 +781,7 @@ async function extendSessionKeepAlive(origin, phpsessid) {
* @param {boolean} force - si true, ignore le cache et refait le test
* @returns {Promise<"internal"|"external">}
*
* @author FroSteel
* @author Quentin
*/
async function detectNetworkContext(force = false) {
const CACHE_KEY = "network_context_v2"; // v5.0.12 : nouvelle clé pour invalider le cache fautif v5.0.11
@@ -921,7 +921,7 @@ function watchReconnectTabForIamLogin(tabId) {
* Retourne { name: "Nom Prénom" | null, login: "..." | null } ou null si
* tout a échoué.
*
* @author FroSteel
* @author Quentin
*/
async function fetchCurrentUser(origin, phpsessid) {
const url = `${origin}/index.php?PHPSESSID=${encodeURIComponent(phpsessid)}`;
@@ -1306,7 +1306,7 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
* Retourne { groups: [{id, name}, ...] }. Liste vide si le fetch échoue ou
* si EV renvoie une page de login / redirection JS.
*
* @author FroSteel
* @author Quentin
*/
async function detectGroupsFromEV(origin, phpsessid) {
const url = `${origin}/index.php?eventName=HelpDesk_PlanningItem&PHPSESSID=${encodeURIComponent(phpsessid)}`;
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "Planification",
"version": "2026.5.49",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par FroSteel — DGNSI, Canton de Vaud.",
"version": "2026.5.50",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin — DGNSI, Canton de Vaud.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl/El4I9gxcZkp5bnheg4ZBCKxckRLt9xQ5x+i6phEPYlydl6NapWlkbOyycUaeTZcpoEh/tKQnpMpoEc5jY8zCoRTwh/vfejVBZE6ANDG/dc3iix/1Z8OhRI4zMaRYQ+DQTjS5Q6l6wkcwzbQB85HZNhke/k1qIW6RCuBjeJYmES5WVDwzfCNVvaF6uQvdOCpLjrA3+LI6vUBEwaMg1hM42mC3dU0z9p7qzAby/dH3XpZD9HTFXLJL9/1zryN9rXNDpvZwYWnJ0hLTu5tfmURkvgKOwquX1KJy9vupmhUALRtS08Fplx/e3kUH3LD+coS1ExLwoOZ+1ORBUE9JCB8wIDAQAB",
"permissions": [
"activeTab",
+2 -2
View File
@@ -1,10 +1,10 @@
/**
* Planification — Extension navigateur EasyVista (Canton de Vaud / DGNSI)
*
* Copyright (c) 2026 FroSteel
* Copyright (c) 2026 Quentin
* Licensed under the MIT License — see LICENSE file in the project root.
*
* @author FroSteel
* @author Quentin
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
*/
+2 -2
View File
@@ -2,10 +2,10 @@
<!--
Planification — Extension navigateur EasyVista (Canton de Vaud / DGNSI)
Copyright (c) 2026 FroSteel
Copyright (c) 2026 Quentin
Licensed under the MIT License — see LICENSE file in the project root.
@author FroSteel
@author Quentin
@repository https://gitea.netaplaid.ch/FroSteel/Planification
-->
<html lang="fr">
+164 -66
View File
@@ -5,13 +5,13 @@
* réservations, absences, statut pompier, etc. Regroupé par tech avec
* timelines visuelles et popups détaillés.
*
* Copyright (c) 2026 FroSteel
* Copyright (c) 2026 Quentin
* Licensed under the MIT License — see LICENSE file in the project root.
*
* @author FroSteel
* @author Quentin
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
*
* Développé par FroSteel (FRS) — DGNSI, Canton de Vaud. Licence MIT.
* Développé par Quentin (FRS) — DGNSI, Canton de Vaud. Licence MIT.
*/
// ============================================================================
@@ -543,15 +543,23 @@ let _myActionsRows = null;
let _myActionsLoading = false;
let _myActionsDiag = ""; // diagnostic du dernier fetch (affiché dans l'onglet)
// v2026.5.46 — impersonation (RÉSERVÉE à FroSteel) : permet de tester
// v2026.5.46 — impersonation (RÉSERVÉE à Quentin) : permet de tester
// la vue coordinateur en "se faisant passer pour" une autre personne. Chargé
// depuis admin_config (cfg.impersonate = { enabled, name, initials, memberId }).
let _impersonate = null;
// Vrai uniquement si l'utilisateur connecté EST FroSteel (garde-fou
// Vrai uniquement si l'utilisateur connecté EST Quentin (garde-fou
// pour n'afficher l'onglet Test qu'à lui).
function _isQuentin() {
// Empreintes FNV-1a (32 bits) du nom et du prenom de l'auteur : jamais en clair dans le code.
const _EMPREINTES_AUTEUR = [0x716d54e7, 0xa6e31b47];
function _empreinteMot(mot) {
let h = 0x811c9dc5;
for (const c of mot) { h ^= c.codePointAt(0); h = Math.imul(h, 0x01000193) >>> 0; }
return h;
}
function _estAuteur() {
const n = (state && state.currentUser && state.currentUser.name) || "";
return /exemple/i.test(n) && /FroSteel/i.test(n);
const mots = new Set(n.toLowerCase().split(/[^a-zà-ÿ]+/).filter(Boolean).map(_empreinteMot));
return _EMPREINTES_AUTEUR.every((e) => mots.has(e));
}
// Identité effective utilisée pour retrouver "l'action liée à la personne"
// (Partie A « Mes actions »). Si impersonation active ET FRS → identité simulée,
@@ -563,7 +571,7 @@ function _effectiveIdentity() {
memberId: (state && state.currentUser && state.currentUser.memberId) || "",
impersonated: false
};
if (_isQuentin() && _impersonate && _impersonate.enabled &&
if (_estAuteur() && _impersonate && _impersonate.enabled &&
(_impersonate.name || _impersonate.initials || _impersonate.memberId)) {
return {
name: _impersonate.name || "",
@@ -1176,7 +1184,7 @@ const _CURRENT_USER_RETRY_DELAY_MS = 60 * 1000;
/**
* Récupère le nom de l'utilisateur EasyVista connecté et l'affiche dans le badge.
*
* @author FroSteel
* @author Quentin
*/
async function fetchAndShowCurrentUser() {
const attemptId = _currentUserRetryCount + 1;
@@ -1278,7 +1286,7 @@ async function fetchAndShowCurrentUser() {
*
* @param {string} reason - contexte pour les logs (ex: "after_load_success")
*
* @author FroSteel
* @author Quentin
*/
function _maybeRetryFetchUser(reason) {
if (state.currentUser && state.currentUser.name) {
@@ -1432,7 +1440,7 @@ function toggleUserNamePopup() {
// gris atténué, séparé par une fine ligne supérieure.
const authorLine = document.createElement("div");
authorLine.className = "user-name-popup-author";
authorLine.textContent = "Développé par FroSteel";
authorLine.textContent = "Développé par Quentin";
authorLine.title = "Auteur de l'extension";
popup.appendChild(authorLine);
@@ -2143,7 +2151,7 @@ function initModalScrollLock() {
/**
* Pied de page discret bas-droite avec FRS et numéro de version.
*
* @author FroSteel
* @author Quentin
*/
function initAppFooter() {
if (document.querySelector(".app-footer")) return;
@@ -2214,7 +2222,7 @@ function _toggleView() {
* logs verbeux étape par étape ont été retirés, ils polluaient la console
* à chaque toggle.
*
* @author FroSteel
* @author Quentin
*/
function _applyViewMode() {
const mode = _getCurrentView();
@@ -2291,7 +2299,7 @@ function _applyViewMode() {
* v2026.5.36 : crée ou retrouve la sidebar, et y déplace les éléments listés.
* Les éléments sont déplacés dans l'ordre du tableau.
*
* @author FroSteel
* @author Quentin
*/
function _moveElementsToSidebar(ids) {
// Créer (ou retrouver) le wrapper qui contiendra sidebar + main côte à côte
@@ -2369,7 +2377,7 @@ function _moveElementsToSidebar(ids) {
* v2026.5.36 : restaure chaque élément à son parent d'origine (mémorisé dans
* data-orig-parent). Utilisé quand on revient en vue classique.
*
* @author FroSteel
* @author Quentin
*/
function _restoreElementsToTopbar(ids) {
// v2026.5.37 : d'abord remettre les flèches dans .date-nav (avant de
@@ -2476,7 +2484,7 @@ function _restoreToOriginalParent(el, label) {
/**
* Horloge HH:MM au milieu de la topbar, mise à jour chaque minute (depuis v5.0.0).
*
* @author FroSteel
* @author Quentin
*/
function initAppClock() {
const el = document.getElementById("app-clock");
@@ -3110,10 +3118,10 @@ async function showAdminPanel(initialSection) {
{ id: "diagnostics",label: "Diagnostics", render: renderAdminSectionDiagnostics },
{ id: "about", label: "À propos", render: renderAdminSectionAbout }
];
// v2026.5.46 — onglet "Test (FRS)" RÉSERVÉ à FroSteel : permet de
// v2026.5.46 — onglet "Test (FRS)" RÉSERVÉ à Quentin : permet de
// se faire passer pour une autre personne (coordinateur) pour tester « Mes
// actions » sans être réellement coordinateur. Caché pour tout autre utilisateur.
if (_isQuentin()) {
if (_estAuteur()) {
sections.push({ id: "qrotest", label: "🧪 Test (FRS)", render: renderAdminSectionImpersonate });
}
@@ -4888,7 +4896,7 @@ function renderAdminSectionImpersonate(container, cfg, saveFn) {
const intro = document.createElement("p");
intro.style.cssText = "font-size:13px; color:var(--text-muted); margin:0 0 6px;";
intro.textContent = "Réservé à FroSteel. Permet de tester la vue « Mes actions » en te faisant passer pour une autre personne (ex. un coordinateur). N'affecte que l'affichage local de l'extension — aucune action côté EasyVista.";
intro.textContent = "Réservé à Quentin. Permet de tester la vue « Mes actions » en te faisant passer pour une autre personne (ex. un coordinateur). N'affecte que l'affichage local de l'extension — aucune action côté EasyVista.";
container.appendChild(intro);
const who = document.createElement("p");
@@ -5456,7 +5464,7 @@ function renderAdminSectionAbout(container, cfg, saveFn) {
</div>
<div class="admin-about-row">
<div class="admin-about-label">Auteur</div>
<div class="admin-about-value"><strong>FroSteel</strong></div>
<div class="admin-about-value"><strong>Quentin</strong></div>
</div>
<div class="admin-about-row">
<div class="admin-about-label">Affiliation</div>
@@ -6616,7 +6624,13 @@ async function submitAbsence(opts) {
// ville, rue, catégorie) au créneau, propagées par le merge et affichées dans
// la bulle. Les réservations créées directement dans EV restent inchangées.
const RESERVATION_TYPE_GUID = "{E7432422-55CB-4DB9-8A26-619D036E2155}"; // Evènements spéciaux
const RESERVATION_CATEGORIES = ["Installation", "Remplacement", "Récupération", "Incident"];
// v2026.5.50 — « Roll Out » et « Déménagement » : catégories propres à la
// Planification (méta du carnet, partagée par le dossier) ; côté EasyVista, la
// réservation reste « Evènements spéciaux ».
const RESERVATION_CATEGORIES = ["Installation", "Remplacement", "Récupération", "Incident", "Roll Out", "Déménagement"];
// Celles que seule la Planification repère (texte de l'action) : la fiche EV,
// qui ne connaît que le catalogue, ne doit pas les remplacer.
const CATEGORIES_PROPRES_PLANIFICATION = ["Roll Out", "Déménagement"];
// v2026.5.46 — GUID du type "Pompier" (utilisé par le popup pompier de la
// modal Réservation). Doit rester aligné avec HOLIDAY_TYPES.
const POMPIER_TYPE_GUID = "{60D70502-063D-45AD-9415-25C1C556105F}";
@@ -6630,7 +6644,8 @@ function _resTypeColorKey(cat) {
case "récupération":
case "recuperation": return "recup";
case "incident": return "incident";
default: return null;
case "roll out": return "rollout";
default: return null; // dont « Déménagement » : couleur par défaut des réservations
}
}
@@ -6645,7 +6660,11 @@ function _lookupInterventionInfo(num) {
if (String(iv.ref || "").trim() === n || String(iv.actionId || "").trim() === n) {
const cat = (iv.categoryLine || "").toLowerCase();
let categorie = "";
if (/install/.test(cat)) categorie = "Installation";
// Une réf I… (incident) ne reçoit pas les catégories propres à la Planification.
const refIncident = /^I\d/.test(iv.ref || "");
if (!refIncident && isRollOut(iv)) categorie = "Roll Out";
else if (!refIncident && /d[ée]m[ée]nag/.test(cat)) categorie = "Déménagement";
else if (/install/.test(cat)) categorie = "Installation";
else if (/remplac/.test(cat)) categorie = "Remplacement";
else if (/r[ée]cup/.test(cat)) categorie = "Récupération";
else if (/incident/.test(cat)) categorie = "Incident";
@@ -6764,7 +6783,13 @@ async function _fetchReservationInfo(num) {
const titM = html.match(/"dbFieldName"\s*:\s*"TITLE_FR"[^}]*?"value"\s*:\s*"([^"]{2,300})"/);
if (titM) titreFiche = (decodeJsonString ? decodeJsonString(titM[1]) : titM[1]).trim();
const titreCourt = deriveShortTitle({ ref: n, categoryLine: titreFiche });
const categorie = RESERVATION_CATEGORIES.includes(titreCourt) ? titreCourt : "";
let categorie = RESERVATION_CATEGORIES.includes(titreCourt) ? titreCourt : "";
// Catégories propres à la Planification, repérées dans le titre de la fiche
// (deriveShortTitle ne les connaît pas sans le texte de l'action).
if (!categorie && titreCourt !== "Incident") {
if (/roll\s*-?\s*out/i.test(titreFiche)) categorie = "Roll Out";
else if (/d[ée]m[ée]nag/i.test(titreFiche)) categorie = "Déménagement";
}
let ville = "", rue = "";
const locM = html.match(/"dbFieldName"\s*:\s*"[^"]*(?:LOCATION|LOCALISATION|ADDRESS|ADRESSE)[^"]*"[^}]*?"value"\s*:\s*"([^"]{3,160})"/i)
|| html.match(/Localisation[^>]{0,40}>\s*([^<]{3,160})/i);
@@ -6820,11 +6845,18 @@ function _wireResNumberLookup(numField, cibles) {
if (num === lastNum) return;
lastNum = num;
LOG.info("reservation", `n° saisi : ${num} → recherche infos (planning + EV)`);
apply(_lookupInterventionInfo(num)); // instantané (cache planning)
const infoPlanning = _lookupInterventionInfo(num);
apply(infoPlanning); // instantané (cache planning)
// Roll Out / Déménagement trouvé par le planning pour CE n° : la fiche EV ne
// le voit pas et ne doit pas le remplacer par une catégorie du catalogue.
const categoriePropre = infoPlanning && CATEGORIES_PROPRES_PLANIFICATION.includes(infoPlanning.categorie)
? infoPlanning.categorie : "";
const seq = ++seqRef; // fetch EV réel en arrière-plan
if (loadingBar) loadingBar.classList.remove("hidden");
_fetchReservationInfo(num).then(info => {
if (seq === seqRef) { apply(info); if (loadingBar) loadingBar.classList.add("hidden"); }
if (seq !== seqRef) return;
apply(info && categoriePropre ? { ...info, categorie: categoriePropre } : info);
if (loadingBar) loadingBar.classList.add("hidden");
});
};
numField.addEventListener("change", trigger);
@@ -7056,6 +7088,12 @@ function showReservationEditModal(iv) {
const catSelect = document.createElement("select"); catSelect.className = "modal-form-select";
const emptyOpt = document.createElement("option"); emptyOpt.value = ""; emptyOpt.textContent = "—"; catSelect.appendChild(emptyOpt);
RESERVATION_CATEGORIES.forEach(c => { const o = document.createElement("option"); o.value = c; o.textContent = c; if (c === iv.resCategorie) o.selected = true; catSelect.appendChild(o); });
// Catégorie inconnue de cette version (posée par une version plus récente,
// reçue du dossier) : gardée telle quelle, sinon l'enregistrer l'effacerait
// sur tous les postes (l'édition la plus récente gagne à la fusion).
if (iv.resCategorie && !RESERVATION_CATEGORIES.includes(iv.resCategorie)) {
const o = document.createElement("option"); o.value = iv.resCategorie; o.textContent = iv.resCategorie; o.selected = true; catSelect.appendChild(o);
}
catGroup.appendChild(catSelect); grid.appendChild(catGroup);
const villeField = _mkField("Ville / village", iv.resVille, "");
const rueField = _mkField("Rue / chemin / route", iv.resRue, "");
@@ -7660,7 +7698,7 @@ function sendMessage(msg) {
// Filets supplémentaires à l'ingestion : correspondance par réf, et signature
// composite (tech|type|dates|libellé) pour les résa/absences sans requestId.
//
// Auteur : FroSteel (FRS).
// Auteur : Quentin (FRS).
// ============================================================================
const TOMBSTONE_TTL_MS = 7 * 24 * 3600 * 1000; // rétention tombstone (convergence postes)
@@ -7904,7 +7942,7 @@ function ivFreshness(iv) {
* 3. existence : max des événements des deux côtés, puis règle vie/mort —
* une VIE strictement postérieure à la mort lève les tombstones
* (ré-apparition EV autoritaire), sinon la mort est conservée/propagée.
* @author FroSteel
* @author Quentin
*/
function ivMerge(a, b) {
if (!a) return b;
@@ -8197,7 +8235,7 @@ function _ivAbsenceSig(iv, techId) {
*
* @returns {{day: object, toAnalyze: object[]}} le store mis à jour + la
* liste des enregistrements à passer à l'analyse de disparition.
* @author FroSteel
* @author Quentin
*/
function storeApplyFresh(day, freshTechs, isoDate) {
const now = Date.now();
@@ -8854,7 +8892,7 @@ function _legacyDayToV2(isoDate, v1) {
* Convertit tout l'ancien dossier (racine v1) vers le store v2 : liste les
* jours, lit chaque fichier v1, convertit, fusionne au store local (dayMerge)
* et sauvegarde — le push différé propage ensuite vers le sous-dossier v2.
* @author FroSteel
* @author Quentin
*/
async function importLegacyFolderCache(rootPath, onProgress) {
if (state.networkContext !== "internal") return { ok: false, error: "réseau externe — dossier partagé injoignable" };
@@ -8958,7 +8996,7 @@ function scheduleFolderPush(isoDate) {
* v2026.5.46 — le rendu après tirage du dossier passe par storeProject
* (mêmes filtres que tout rendu : tombstones, dock, déplacées récentes) → fini
* le flash d'une iv parquée/déplacée qui réapparaissait via la sync.
* @author FroSteel
* @author Quentin
*/
// v2026.5.49 — blocage ferme lié à la connexion : les opérations sensibles
// (lecture/écriture du dossier PARTAGÉ, déplacement, suppression) exigent une
@@ -9080,7 +9118,7 @@ async function reconcileCacheForDate(isoDate) {
* 6. phase B : fiches complètes + analyse des disparues en SÉQUENTIEL ;
* 7. sauvegarde finale + convergence dossier en arrière-plan.
*
* @author FroSteel
* @author Quentin
*/
async function loadForDate(isoDate, opts = {}) {
LOG.info("load", `début pour ${isoDate}`, { opts, hasSession: !!state.session });
@@ -9793,10 +9831,10 @@ const RX_LOGIN_COMMENTAIRE = /(?:^|\n|<br\s*\/?>)\s*([a-z0-9_]{3,12})\s*:\s+(\S[
/**
* Extrait toutes les actions d'une fiche en parsant les blocs "rows" du HTML.
* Une action a 14 values. Indices stables :
* Une action a 13 values (14 jusqu'au 28.09.2026). Indices stables :
* [2] = Intervenant (ex: "Nom, Prénom" ou "EZV_WS_REST_USER")
* [4] = Type d'action (ex: "AL-Intervention", "Ajout d'informations")
* [13] = JSON stringifié contenant ACTION_ID, AM_DONE_BY_ID, …
* dernière cellule = JSON stringifié contenant ACTION_ID, AM_DONE_BY_ID, …
*
* v2026.5.45 — la position des dates et de la description varie selon le
* layout EV (anciennement [8]/[9]/[11], aujourd'hui [6]/[7]/[9] sur les
@@ -9842,26 +9880,30 @@ function parseAllActionsFromFicheHtml(html) {
// Une "vraie" action a 14 valeurs. On se contente de 12 minimum
// pour avoir au moins la description.
//
// v2026.5.44 on extrait aussi actionId / amDoneById depuis values[13]
// qui est un JSON stringifié contenant {NAME, ACTION_ID, AM_DONE_BY_ID, …}.
// v2026.5.44 on extrait aussi actionId / amDoneById depuis la cellule JSON
// stringifié qui contient {NAME, ACTION_ID, AM_DONE_BY_ID, …}.
// Permet de filtrer la "bonne action" par ACTION_ID au lieu de matcher
// par nom de tech (le matching par nom échouait par exemple sur les
// actions où l'intervenant est noté "EZV_WS_REST_USER" ou un format
// qui ne correspond pas exactement au tech.name de la config).
let actionId = null;
let amDoneById = null;
if (values.length >= 14 && values[13]) {
// v2026.5.50 — la cellule JSON est repérée par son contenu (la dernière qui
// mentionne ACTION_ID) : EV a retiré la cellule vide qui la précédait
// (29.09.2026), elle est passée de values[13] à values[12].
const valeurJson = [...values].reverse().find(v => v && v.includes("ACTION_ID"));
if (valeurJson) {
// v2026.5.46 — BUG : dans le HTML, les guillemets du JSON imbriqué sont
// encodés " (pas des " littéraux). Le regex cherchait des " littéraux
// → ACTION_ID et surtout AM_DONE_BY_ID étaient TOUJOURS null → le matching
// d'action par id (le plus fiable, indépendant du nom) ne marchait JAMAIS.
// On DÉCODE values[13] d'abord pour que le regex matche.
const raw13 = decodeUnicodeEscapes(values[13]);
// On DÉCODE la cellule d'abord pour que le regex matche.
const jsonDecode = decodeUnicodeEscapes(valeurJson);
// Parsing tolérant via regex (on accepte avec ou sans guillemets autour
// des valeurs numériques) — robuste aux formats JSON avec ou sans
// échappements résiduels.
const mAid = raw13.match(/"ACTION_ID"\s*:\s*"?(\d+)"?/);
const mDid = raw13.match(/"AM_DONE_BY_ID"\s*:\s*"?(\d*)"?/);
const mAid = jsonDecode.match(/"ACTION_ID"\s*:\s*"?(\d+)"?/);
const mDid = jsonDecode.match(/"AM_DONE_BY_ID"\s*:\s*"?(\d*)"?/);
if (mAid) actionId = mAid[1];
if (mDid && mDid[1]) amDoneById = mDid[1];
}
@@ -10148,7 +10190,7 @@ function actionBelongsToTech(action, techName, techId) {
* 3-4) actions de la fiche : du tech ? même jour ? commentaire « login: » ?
* 4bis) action de PLANIFICATION via xhr2 (la fiche ne la contient pas).
*
* @author FroSteel
* @author Quentin
*/
async function analyzeMissingIv(iv) {
const techName = iv.techName || "";
@@ -10725,6 +10767,9 @@ async function fetchAndUpdateIntervention(iv, myToken, opts = {}) {
if (!ficheResp.ok) {
iv.ficheFetched = true;
iv.ficheFetchError = ficheResp.error || "fetch_failed";
if (ficheResp.error !== "session_expired") {
LOG.warn("seq", `iv ${iv.actionId} (ref=${iv.ref}) fiche non chargée : ${iv.ficheFetchError}`);
}
if (ficheResp.error === "session_expired") {
state.session = null;
// session expirée → + bannière + abort de tout le refresh.
@@ -10830,6 +10875,8 @@ async function fetchAndUpdateIntervention(iv, myToken, opts = {}) {
} else if (tlResp && tlResp.error === "session_expired") {
state.session = null;
showSessionExpiredBanner();
} else {
LOG.warn("timeline", `iv ${iv.actionId} : timeline non chargée (${(tlResp && tlResp.error) || "échec"})`);
}
} catch (err) {
LOG.warn("timeline", "erreur iv", iv.actionId, err);
@@ -11161,12 +11208,16 @@ function parseFicheHtml(html) {
* { rows: [
* {value:"..."}, // [0..10] statut, groupe, dates, etc.
* {value:"Date : ... Heure : ... Lieu : ..."}, // [11] DESCRIPTION
* {value:""},
* {value:"{\"ACTION_ID\":\"57700033\",...}"} // [13] JSON stringifié
* {value:"{\"ACTION_ID\":\"57700033\",...}"} // [12] JSON stringifié
* ] }
* ] }}}
*
* On cherche l'action dont rows[13].ACTION_ID === actionId ; si trouvée, on
* v2026.5.50 — jusqu'au 28.09.2026, EV intercalait une cellule vide en [12] et
* le JSON était en [13] (14 cellules) ; depuis, 13 cellules. La cellule JSON est
* repérée par son contenu (la dernière qui mentionne "ACTION_ID"), valable pour
* les deux formats.
*
* On cherche l'action dont le JSON porte ACTION_ID === actionId ; si trouvée, on
* retourne rows[11] nettoyé (br→\n, entités décodées) ; sinon null.
*/
function parseTimelineJsonForAction(jsonText, actionId) {
@@ -11182,15 +11233,23 @@ function parseTimelineJsonForAction(jsonText, actionId) {
const values = data?.data?.data?.values;
if (!Array.isArray(values)) return null;
// Colonne de la description repérée par son nom (« Commentaire ou
// Description ») : une cellule ajoutée ou retirée avant elle ne la décale plus.
const colonnes = data?.data?.data?.columns;
const idxColonneDescription = _indexColonneTimeline(colonnes, /description/i);
const targetId = String(actionId);
let actionsLisibles = 0;
for (const entry of values) {
const rows = entry?.rows;
if (!Array.isArray(rows) || rows.length < 14) continue;
if (!Array.isArray(rows)) continue;
// rows[13] = JSON stringifié qui contient ACTION_ID
const extraRaw = rows[13]?.value;
if (!extraRaw || typeof extraRaw !== "string") continue;
// JSON stringifié qui contient ACTION_ID : la dernière cellule qui le mentionne.
const celluleJson = [...rows].reverse().find(
c => typeof c?.value === "string" && c.value.includes('"ACTION_ID"'));
const extraRaw = celluleJson?.value;
if (!extraRaw) continue;
actionsLisibles++;
let extra;
try {
@@ -11205,15 +11264,54 @@ function parseTimelineJsonForAction(jsonText, actionId) {
if (String(extra.ACTION_ID) !== targetId) continue;
// Trouvé : extraire la description (rows[11]) et la nettoyer.
const rawDesc = rows[11]?.value || extra["AM_ACTION.DESCRIPTION"] || "";
// Trouvé. Description : la colonne nommée, sinon la position historique 11,
// jamais la cellule JSON elle-même ; en dernier recours, le champ du JSON.
const celluleDesc = rows[idxColonneDescription >= 0 ? idxColonneDescription : 11];
const texteCellule = celluleDesc && celluleDesc !== celluleJson &&
typeof celluleDesc.value === "string" ? celluleDesc.value : "";
const rawDesc = texteCellule || extra["AM_ACTION.DESCRIPTION"] || "";
const cleaned = cleanHtmlBlock(rawDesc);
return cleaned || null;
}
// Des actions, mais aucune lisible : EV a changé le format de la timeline.
if (values.length > 0 && actionsLisibles === 0) {
_signalerFormatEvInconnu("timeline", {
nbActions: values.length,
nbCellules: values.map(e => (Array.isArray(e?.rows) ? e.rows.length : null)),
colonnes: Array.isArray(colonnes) ? colonnes.map(_nomColonneTimeline) : "absentes"
});
}
return null;
}
const _CHAMPS_NOM_COLONNE_TIMELINE = ["dbFieldName", "name", "label", "title"];
/** Nom lisible d'une colonne de la timeline EV (le premier champ de nom renseigné). */
function _nomColonneTimeline(colonne) {
const champ = _CHAMPS_NOM_COLONNE_TIMELINE.find(k => typeof colonne?.[k] === "string" && colonne[k]);
return champ ? colonne[champ] : "?";
}
/** Index de la première colonne de la timeline EV dont un nom répond au motif ; -1 sinon. */
function _indexColonneTimeline(colonnes, motif) {
if (!Array.isArray(colonnes)) return -1;
return colonnes.findIndex(c => _CHAMPS_NOM_COLONNE_TIMELINE.some(
k => typeof c?.[k] === "string" && motif.test(c[k])));
}
/**
* Signale UNE fois par session et par source une réponse EV dont le format n'est
* plus reconnu (mise à jour d'EasyVista) : structure seulement (nombres de
* cellules, noms de colonnes), jamais les valeurs.
*/
const _formatsEvInconnusSignales = new Set();
function _signalerFormatEvInconnu(source, structure) {
if (_formatsEvInconnusSignales.has(source)) return;
_formatsEvInconnusSignales.add(source);
LOG.warn(source, "format EasyVista non reconnu : texte des actions indisponible", structure);
}
/**
* Nettoie un bloc HTML pour obtenir du texte brut lisible.
@@ -12209,7 +12307,7 @@ function _recurringAbsenceLabel(isoDate) {
// v2026.5.46 — menu contextuel (clic droit) sur le nom d'un technicien :
// ouvre directement Réservation / Absence / Pompier / Sync douchette avec CE
// tech pré-sélectionné (la liste/sélecteur de tech est masquée dans la modal).
// Les boutons « Ajouter » du haut restent inchangés. Auteur : FroSteel.
// Les boutons « Ajouter » du haut restent inchangés. Auteur : Quentin.
let _techCtxMenuEl = null;
function _closeTechContextMenu() {
if (_techCtxMenuEl) { _techCtxMenuEl.remove(); _techCtxMenuEl = null; }
@@ -12317,7 +12415,7 @@ function _showQuickAddMenu(ev, tech, startHHMM, endHHMM, disabledReason) {
/**
* Construit la card HTML d'un technicien (header, timeline, interventions, absences).
*
* @author FroSteel
* @author Quentin
*/
function buildCard(tech, isoDate) {
const card = document.createElement("section");
@@ -13008,7 +13106,7 @@ async function _initDayBoundsFromConfig() {
// v2026.5.46 — abrège une adresse pour les cartes étroites du mode
// exact : retire le TYPE de voie et sa particule (« Rue de l'Exemple 5 »
// devient « Université 5 », « Av. de l'Exemple 14 » devient « Gare 14 »).
// devient « Exemple 5 », « Av. de l'Exemple 14 » devient « Exemple 14 »).
// Si tout disparaît (« Grand-Rue » seule), on garde l'adresse d'origine.
function _shortAddress(adresse) {
const a = String(adresse || "").trim();
@@ -14057,7 +14155,7 @@ function getStatusClass(iv) {
/**
* Lie les segments timeline à un popover persistant au clic (depuis v4.2.3).
*
* @author FroSteel
* @author Quentin
*/
function bindTimelinePopover(el) {
// v2026.5.40 : comportement UNIFIÉ vue classique + vue horizontale.
@@ -14191,7 +14289,7 @@ function findIvByActionId(actionId) {
/**
* Ouvre une grande popup détaillée pour un segment timeline.
*
* @author FroSteel
* @author Quentin
*/
function openPersistentTimelinePopup(el) {
if (!el) {
@@ -17547,10 +17645,10 @@ function cleanContactName(raw) {
// v2026.5.49 — retirer les « ? » (et le caractère de remplacement Unicode
// U+FFFD, souvent rendu « ? ») collés au nom : c'est un encodage cassé côté
// EasyVista, jamais un vrai caractère d'un nom de personne. Ex : "?Exemple"
// → "Exemple", "Exemple?, FroSteel" → "FroSteel".
// → "Exemple", "Exemple?, Quentin" → "Quentin".
s = s.replace(/[?\uFFFD]/g, " ");
// ... et recoller l'espace laisse devant une virgule/point-virgule
// ("Exemple?, FroSteel" -> "Exemple , FroSteel" -> "FroSteel").
// ("Exemple?, Quentin" -> "Exemple , Quentin" -> "Quentin").
s = s.replace(/\s+([,;:])/g, "$1");
// Espaces multiples → un seul
s = s.replace(/\s{2,}/g, " ").trim();
@@ -18076,7 +18174,7 @@ function _cancelPendingShowTooltip() {
/**
* Affiche un tooltip au survol d'une intervention/réservation.
*
* @author FroSteel
* @author Quentin
*/
function showTooltip(e, iv, rowEl, posOpts) {
if (_techCtxMenuEl) return; // v2026.5.46 — menu contextuel ouvert : pas de popup par-dessus
@@ -18375,7 +18473,7 @@ function reapplyTooltipPosition() {
* @param {HTMLElement} sourceEl - l'élément déclencheur (row, card, segment)
* @param {object} opts - options { anchorBelow: true pour préférer dessous }
*
* @author FroSteel
* @author Quentin
*/
function positionTooltipAnchored(sourceEl, opts) {
opts = opts || {};
@@ -18555,7 +18653,7 @@ function _rectsOverlap(a, b) {
* empêche un simple `style.zIndex = "9"` de prendre effet. Il FAUT utiliser
* `setProperty(..., 'important')` pour battre le `!important` du CSS.
*
* @author FroSteel
* @author Quentin
*/
function _bringPinnedPopupToFront(popup) {
if (!popup) return;
@@ -18617,7 +18715,7 @@ function _applyRememberedPopupSize(popup, iv, date) {
* ligne source `rowEl`. Essaie dans l'ordre : droite, gauche, dessous, dessus.
* Retourne {x, y} en coordonnées document, ou null si aucune position libre.
*
* @author FroSteel
* @author Quentin
*/
function _findFreePopupPosition(rowEl, w, h) {
const pad = 14;
@@ -18731,7 +18829,7 @@ function _findFreePopupPosition(rowEl, w, h) {
* v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché
* ancré au contenu. Le tooltip live redevient disponible.
*
* @author FroSteel
* @author Quentin
*/
function pinTooltip() {
if (!state.currentTooltipIv) return;
@@ -19225,7 +19323,7 @@ async function _refreshPinnedPopupIv(popup, iv) {
*
* @param {HTMLElement} el - le popup à désépingler
*
* @author FroSteel
* @author Quentin
*/
function _softUnpinPopup(el) {
if (!el) {
@@ -19848,7 +19946,7 @@ function _getPopupSafeArea() {
* dans la safe area. Appelé à l'épinglage, pendant le drag, et quand le dock
* apparaît/disparaît.
*
* @author FroSteel
* @author Quentin
*/
function _clampPopupInSafeArea(popup) {
if (!popup) return;
@@ -20731,7 +20829,7 @@ function bindTooltipInteractions() {
/**
* Construit le HTML du tooltip détaillé pour une intervention/réservation.
*
* @author FroSteel
* @author Quentin
*/
function buildTooltipHTML(iv) {
if (!iv) return '<dl><dt>Info</dt><dd>—</dd></dl>';