v2026.5.38 — Attribution auteur + nettoyage + observabilité (LOG unifié, handlers globaux d'erreur, toggle logs verbeux dans admin)

This commit is contained in:
Quentin Rouiller
2026-04-25 22:55:00 +02:00
parent aabda3ba7e
commit 957b754bdc
9 changed files with 1007 additions and 191 deletions
+40
View File
@@ -0,0 +1,40 @@
# OS
.DS_Store
Thumbs.db
desktop.ini
# Editors
.vscode/
.idea/
*.swp
*.swo
*~
# Backups
*.bak
*.bak-*
*.orig
*.old
# Build artifacts (les ZIP/XPI livrés ne sont pas dans le repo, ils sont buildés à la demande)
dist/
*.zip
*.xpi
*.crx
# Node (si jamais utilisé pour build)
node_modules/
package-lock.json
npm-debug.log*
# Logs
*.log
rebuild.log
# Dossiers de travail temporaires
extracted/
temp/
tmp/
# Tests
test-output/
+219
View File
@@ -0,0 +1,219 @@
# CHANGELOG — Extension Planification EasyVista Canton de Vaud
> Ce changelog documente l'évolution de l'extension Chrome/Firefox "Planification"
> développée par Quentin Rouiller pour les techniciens DGNSI (Canton de Vaud).
>
> Les versions documentées ci-dessous sont celles dont les détails sont connus.
> Pour les versions plus anciennes, Claude Code se basera sur l'analyse du code
> source pour déterminer un message de commit pertinent.
---
## v2026.5.38 — Attribution auteur + nettoyage code
**Branche** : current
### Attribution auteur
- Ajout en-têtes copyright dans tous les fichiers source
(viewer.js, viewer.html, viewer.css, background.js)
- Ajout `@author Quentin Rouiller` sur les fonctions principales
(loadForDate, buildCard, buildTooltipHTML, pinTooltip, _softUnpinPopup,
positionTooltipAnchored, _applyViewMode, _moveElementsToSidebar,
_restoreElementsToTopbar, fetchAndShowCurrentUser, _maybeRetryFetchUser,
initAppClock, initAppFooter, bindTimelinePopover,
openPersistentTimelinePopup, showTooltip, _findFreePopupPosition,
_clampPopupInSafeArea, findEasyVistaSession, fetchPlanningXml,
fetchCurrentUser, detectNetworkContext)
- Ajout signature "Développé par Quentin Rouiller" 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
### Nettoyage et optimisation
- Retrait fonction vide `initAdminMenu()` (inutile depuis v2026.5.25,
l'admin passe par le bouton ⚙ Paramètres du popup user-badge)
- Retrait classe CSS orpheline `.date-picker-day` (déjà remplacée par
`.date-custom` en v2026.5.17)
- Retrait anciens styles CSS `.intervention` (layout v1, jamais générés
depuis le passage à `.intervention-v2`)
- Retrait commentaire orphelin `.intervention-v2.is-ghost` (classe
retirée en v4.3.3)
- Retrait 14× `console.log("[viewMode]")` debug verbose (gardé
uniquement les `console.warn` utiles pour erreurs)
- Retrait 5× `console.log("[bg]")` debug verbose dans
fetchPlanningXml / fetchFicheHtml / fetchSessionTimeRemaining /
extendSessionKeepAlive (gardé warnings + logs critiques)
- Remplacement `extendBtn.onclick` par `addEventListener("click", ...)`
pour plus de cohérence
### Builds
- `dist/chromium/` et `dist/firefox/` prêts à charger en mode dev
- `planification-v2026.5.38-chromium.zip` (~144 Ko)
- `planification-v2026.5.38-firefox.xpi` (~144 Ko, à signer sur AMO)
## v2026.5.37 — Refonte vue horizontale (sidebar complète)
- Topbar en haut supprimée en vue horizontale
- User-badge + titre déplacés tout en haut de la sidebar
- Bouton "Aujourd'hui" pleine largeur avec icône ↺
- Date + heure centrés sous le bouton
- Séparateur visuel
- Sélecteur de date pleine largeur
- Flèches ◀ ▶ côte à côte (wrapper #sidebar-arrows)
- Stats empilées
- Synchronisé à HH:MM
- Espace vide intentionnel
- Boutons du bas vers le haut (margin-top: auto sur Absence)
- Barre de rafraîchissement en overlay top-left
- Banderole pompier masquée en vue horizontale (badge + barre rouge à gauche conservés)
## v2026.5.36 — Sidebar verticale en vue horizontale
- Création wrapper flex-row #horizontal-wrapper contenant [sidebar] + [main]
- Sidebar 200px (170px sur <1400px), sticky, bg-muted
- Déplacement physique des éléments via JS (ELEMENTS_TO_RELOCATE)
- Mémorisation parents d'origine (data-orig-parent + data-orig-index)
- Restauration propre en vue classique
- Zone nom tech : 140px → 120px
## v2026.5.35 — Fix popup épinglé position vue horizontale + stats gauche
- Fix popup épinglé qui partait en haut à gauche en vue horizontale
- Cause : rows .intervention-v2 cachées (display: none) → getBoundingClientRect (0,0,0,0)
- Solution : priorité 1 tooltip visible, priorité 2 segment timeline, fallback srcEl
- Stats globales en colonne verticale 200px à gauche en vue horizontale
- Position sticky, fond bg-muted, séparateurs · masqués
- Zone nom tech 200px → 140px (vue horizontale)
## v2026.5.34 — Bouton 📌 restauré + badge user cliquable
- HTML : badge user toujours visible avec "?" par défaut (retiré class hidden)
- _softUnpinPopup refait en 8 étapes loggées
- Popup reste visible après désépinglage (plus de suppression auto au mouseleave)
- Restauration du bouton 📌 dans .tooltip-actions
- Handler click ré-attaché : clic 📌 = ré-épingle, clic ↻ = recharge
- _ensureSoftUnpinnedCleanupHandler : handler global clic hors popup
- _maybeRetryFetchUser : relance opportuniste après succès planning et reconnexion session
- Logs abondants : [currentUser], [softUnpin], [positionTooltip], [persistentTimeline], [showTooltip]
- Fonction positionTooltipAnchored unifiée (4 candidats droite/gauche/dessous/dessus)
- popup._linkedIv stocké pour ré-épinglage
## v2026.5.33 — Interactions vue horizontale différenciées
- Hover segment timeline en vue horizontale → grande popup directement (openPersistentTimelinePopup)
- Clic segment timeline en vue horizontale → ouvre fiche EasyVista
- Popup absence en vue horizontale : hover uniquement sur badge .card-tech-badge (pas sur carte entière)
- Vue classique : comportement inchangé
## v2026.5.32 — Vue horizontale togglable
- Bouton ⊞ "Vue" dans popup user-badge (à côté ⚙ Paramètres)
- Toggle Vue classique ↔ Vue horizontale persisté localStorage "view_mode"
- HTML class "view-classic" ou "view-horizontal" sur <html>
- Chaque tech = 1 ligne horizontale compacte en mode horizontal
- Card header devient barre latérale gauche fixe 200px
- Interventions détaillées masquées (display: none)
- Timeline horizontale pleine largeur
- Stats rapides .tech-row-stats ajoutés au header (nb interv, Xm · Ya)
## v2026.5.31 — Sarcelle pour absence récurrente (REJETÉ par utilisateur)
- Couleur Pillonel vendredi : sarcelle foncée #0f766e / soft #ccfbf1
- Variables --c-recurring, --c-recurring-soft
- Layout 4 colonnes forcées + scroll interne cartes (REJETÉ : "scroll en continu")
## v2026.5.30 — Absence récurrente cyan + mode compact 24"
- Absence récurrente Pillonel vendredi en cyan
- Mode compact @media (max-width: 1920px) avec grid-template-columns: repeat(4, 1fr)
## v2026.5.29 — Contraste++ + footer
- Contrastes encore plus forts (text-muted #d0d5de dark, #2e3642 light)
- Footer QRO/version : 13px badge avec fond bg-muted + bordure
- Fix highlight row : selector .intervention-v2[data-iv-idx]
- Scroll-into-view automatique au hover segment timeline
## v2026.5.28 — Ajustements visuels absences
- Retrait pastille ronde (.tech-name-dot supprimée) — barre gauche + badge suffisent
- "Maladie" → "Maladie/Accident"
- Contraste textes secondaires +30%
- Popups épinglés width fixe 520px (ne rétrécit plus au resize fenêtre)
- _clampPopupInSafeArea ne rétrécit plus si popup > zone dispo
## v2026.5.27 — Classification absences (Maladie/Congé/Pompier)
- Topbar une ligne : "Jeudi 23.04.26 • 21:55" (gros point •, même taille 22px)
- Fermeture auto popups non-épinglés au survol autre popup/carte
- Texte +20% topbar/stats/boutons
- Icône thème ☀/🌙 plus contrastée (bordure 1.5px, fond bg-muted, ombre)
- Classification absences (ABSENCE_LABELS) + absenceCategory : "maladie"|"conge"|"pompier"|null
- Couleurs : Maladie #4338ca indigo foncé, Congé #06b6d4 cyan, Pompier #b03030 rouge
- Badge + barre gauche + dégradé fond pour catégorie
- Libellé "Absent du DD.MM au DD.MM — Maladie/Accident"
- Suffixe `s` adaptatif (Congé/Congés)
## v2026.5.26 — Badge user inconnu cliquable + retry
- En cas d'échec fetch user, afficher rond gris "?" cliquable
- Bouton ⚙ Paramètres accessible même quand user inconnu
- Retry automatique 60s (max 10 essais = 10 min)
- Reset compteur au succès
## v2026.5.25 — Bouton Paramètres dans popup user-badge
- Remplace les 5 clics sur le titre pour ouvrir admin
- Bouton ⚙ Paramètres explicite dans le popup user-badge
## v2026.5.16-v2026.5.24 — Évolutions diverses (à compléter)
- v2026.5.17 : popup user-badge avec ligne session (MM:SS), couleur selon seuil
- v2026.5.18 : dock pastilles popups épinglés avec couleur catégorie
- v2026.5.19 : drag popup épinglé
- v2026.5.20 : safe area popups (topbar + dock)
- v2026.5.22 : régénération tooltip hover après softUnpin
- v2026.5.23 : reset bulleState.pinned + iv._reloading
---
## Versions antérieures (v5.x et v4.x)
> Ces versions sont à analyser par Claude Code à partir des fichiers source.
> Indices clés à chercher dans le viewer.js :
>
> - Présence de `pinTooltip` → version >= v4.x
> - Présence de `_softUnpinPopup` → version >= v4.3.3
> - Présence de `initSessionTimer` → version >= v5.0.9
> - Présence de `initAppClock` → version >= v5.0.0
> - Présence de `_applyViewMode` → version >= v2026.5.32
> - Présence de `bindTimelinePopover` → version >= v4.2.3
> - Présence de `openPersistentTimelinePopup` → version >= v4.2.3
> - Commentaires `// vX.Y.Z` au-dessus des fonctions = version d'introduction
### v5.0.0 — Refonte topbar (horloge, menu admin)
- initAppClock : horloge HH:MM au milieu topbar
- initAdminMenu : menu admin caché (5 clics sur titre)
- initSessionTimer : compteur de session EV (tick 1s)
### v4.x — Fonctions tooltip avancées
- v4.1.12 : moveTooltip devenu no-op (popup statique)
- v4.1.15 : pendant épinglage, ne pas remplacer contenu sur hover autre iv
- v4.2.3 : grande popup timeline persistante (clic), suit-souris (hover)
- v4.2.3 : bindTimelinePopover, showTimelinePopover, moveTimelineTooltip
- v4.2.4 : setTooltipViewportPosition (détection auto fixed/abs)
- v4.2.9 : pied de page discret QRO/version
- v4.2.9 : initModalScrollLock (bloquer scroll arrière modal)
- v4.3.0 : tooltip live libéré après épinglage (réutilisable autres survols)
- v4.3.3 : _softUnpinPopup (désépinglage mou)
### v3.x et antérieures — Versions de base
- À analyser par Claude Code
---
## Notes techniques persistantes (toutes versions)
- 8 techs hardcodés : "76272,83725,66635,92235,90070,40944,72485,86874"
- Pillonel Olivier (ID 40944) absent tous les vendredis (hardcodé)
- Group ID EasyVista : 191
- Domaines cibles : itsma.etat-de-vaud.ch (interne), itsma.vd.ch (externe)
- SSO : Canton ForgeRock OpenAM
- ABSENCE_LABELS = /^(cong[ée]s|maladie|pompier)$/i
- ADMIN_CONFIG_KEY = "admin_config"
- VIEW_MODE_KEY = "view_mode" (depuis v2026.5.32)
- DAY_NAMES_FULL = ["Dimanche", "Lundi", ..., "Samedi"]
- GUIDs forms EV : S={C99ECD05-3D48-4C62-ABF0-66292053AED6} demande, I={07ED9C68-6172-48EA-8A58-90912B0A283E} incident
- Couleurs catégories : livraison #2563eb, recup #16a34a, remplacement #ea580c, incident #8b5cf6, rollout #92400e, reservation #f59e0b, autre #6b7280
## Auteur
**Quentin Rouiller** (QRO)
Technicien DGNSI — Canton de Vaud
Email pour commits Git : `quentin.rouiller@ikmail.com`
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Quentin Rouiller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+166 -84
View File
@@ -1,110 +1,192 @@
# Planning techniciens — Vue claire (v4.1.2)
# Planification — Extension EasyVista Canton de Vaud
Extension Chrome/Brave/Edge pour afficher le planning techniciens EasyVista
(`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) dans une vue plus lisible.
Extension Chrome / Firefox pour visualiser de manière claire et rapide le planning des techniciens DGNSI (Canton de Vaud) dans EasyVista.
## Nouveautés v4.1.2
## Aperçu rapide
- **Vraies infos contact/lieu dans les cartes** : les attributs attr1/attr2 du
XML contiennent les infos saisies à la *planification*, qui ne sont pas
toujours à jour (le tech a pu corriger le contact/lieu avant intervention).
Désormais, pour chaque intervention, on fetch AUSSI le xhr2 en arrière-plan
(en plus de la fiche), ce qui apporte les **vraies** infos validées. La
carte se met à jour automatiquement quand elles arrivent.
- **Clic ouverture restauré** : retour à la logique v4 (fetch fiche à la volée
+ extraction checksum + construction URL avec sender adéquat). Le checksum
est pré-rempli pendant le fetch arrière-plan, donc au clic l'ouverture est
instantanée dans la plupart des cas.
- **Auteur** : Quentin Rouiller (QRO)
- **Cible** : techniciens DGNSI (Canton de Vaud), EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`)
- **Démarrage projet** : jeudi 16 avril 2026
- **Version actuelle** : `v2026.5.37`
- **Manifest** : V3 (Chrome/Edge/Firefox)
- **Format** : `.zip` (Chromium) + `.xpi` signé (Firefox)
## Nouveautés v4.1
## Fonctionnalités principales
- **Fetch des fiches séquentiel (1 par 1)** au lieu de 5 workers en parallèle.
Le serveur EasyVista sérialise les requêtes de toute façon, donc le parallélisme
n'apporte rien. Et surtout : quand tu changes de date pendant le fetch, l'abort
est **instantané** car il n'y a qu'une seule requête en vol au maximum.
- **Cache incrémental** : le cache est sauvé toutes les 5 fiches pendant le fetch,
pas juste à la fin. Si tu changes de date avant que tout soit fini, les statuts
déjà récupérés sont conservés.
### Vue planning
- Affichage des interventions et réservations groupées par technicien
- Horaires, contact, lieu, catégorie, statut visibles d'un coup d'œil
- 8 techniciens hardcodés (équipe IT canton)
- Cache local pour réduire les requêtes serveur
## Nouveautés v4
### Modes d'affichage
- **Vue classique** (depuis v1.0.0) : cards en grille, mode compact écran 24" (depuis v2026.5.30)
- **Vue horizontale** (depuis v2026.5.32) : timeline par tech, sidebar verticale (depuis v2026.5.36)
- Toggle Vue classique ↔ Vue horizontale via bouton ⊞ dans popup user-badge
- Persistance localStorage (`view_mode`)
**Chargement ~50× plus rapide.** Le nombre de requêtes au serveur EasyVista passe
de ~100 par chargement à **1 seule requête** pour l'affichage principal.
### Tooltips et popups
- Tooltips au survol (hover) sur chaque intervention
- Popups épinglables (📌) pour garder ouvert (depuis v4.1.3)
- Popups timeline persistantes au clic (depuis v4.2.3)
- Drag-and-drop des popups épinglés (depuis v2026.5.19)
- Safe area : popups jamais cachés sous topbar/dock (depuis v2026.5.20)
- Position auto adaptative (4 candidats : droite/gauche/dessous/dessus)
Concrètement, en v3 un chargement initial faisait :
- 1 fetch XML planning (`calendar_block`)
- ~40 fetches `planning_xhr_2.php` pour les lieux/contacts
- ~40 fetches de fiches HTML pour les catégories/refs/statuts
- jusqu'à ~40 fetches de l'API timeline
### Classification des absences (depuis v2026.5.27)
- **Maladie/Accident** : indigo `#4338ca`
- **Congé / Congés** : cyan `#06b6d4` (suffixe `s` adaptatif)
- **Pompier** : rouge `#b03030`
- Badge + barre gauche colorée + dégradé fond
- Absence récurrente Pillonel vendredi : cyan (depuis v2026.5.30)
Total : ~120 requêtes, 10+ Mo, 8 à 15 secondes selon la charge serveur.
### User et session
- Badge user avec photo/initiales en topbar
- Badge cliquable (depuis v2026.5.26) : popup avec ⚙ Paramètres + ⊞ Vue + compteur session MM:SS
- Retry automatique en cas d'échec fetch user (60s, max 10 essais)
- Compteur de session EasyVista (tick 1s, depuis v5.0.0)
- Reconnexion automatique
En v4, on a découvert que le XML initial `calendar_block` contient **déjà**
dans ses attributs `attr1`/`attr2`/`attr3` le contact, le lieu et la catégorie
complète de chaque intervention, et la ref dans le textContent du nœud.
Toutes ces infos qu'on allait chercher ailleurs étaient en fait dans la toute
première réponse, ignorées par le code.
### Admin et configuration
- Mode admin caché : bouton ⚙ Paramètres dans popup user-badge (depuis v2026.5.25, remplace les 5 clics secrets sur le titre)
- Configuration persistée dans `localStorage` (`admin_config`)
- Catégories interventions personnalisables (livraison/recup/remplacement/incident/rollout/reservation/autre)
Résultat : le premier rendu complet arrive en **moins d'une seconde**. Les
fiches individuelles ne sont plus fetchées qu'en arrière-plan, uniquement
pour le statut "Clôturé/Résolu" et le commentaire technicien.
## Versionning — historique et conventions
**Lazy-load au survol.** Le texte détaillé d'une intervention (Problème, À faire,
Matériel, TFS ancien/nouveau poste...) n'est chargé qu'au premier survol de la
ligne, seulement pour l'intervention survolée. Imperceptible pour l'utilisateur,
énorme pour le serveur.
L'extension a connu **3 systèmes de versionning successifs** :
**Concurrence réduite.** Le pic de requêtes parallèles passe de 15 à 5 workers,
pour ménager le serveur EasyVista qui a tendance à saturer sous les rafales.
| Période | Format | Exemple |
|---|---|---|
| 16-17 avril 2026 | Versions de base | `1.0.0`, `2.0.0`, `3.0.0` |
| 18-20 avril 2026 | SemVer classique | `4.1.3`, `4.2.8`, `5.0.12` |
| 21 avril 2026 → maintenant | **`ANNÉE.MAJEURE.PATCH`** | `2026.5.16``2026.5.37` |
Toute l'interface utilisateur est **strictement identique** à la v3 — on n'a
changé que ce qu'il y a sous le capot.
### Format actuel : `ANNÉE.MAJEURE.PATCH`
## Hérité des versions précédentes
À partir de la **v2026.5.16** (21 avril 2026), l'extension utilise le schéma suivant :
- Navigation par date : ◀ ▶ et sélecteur
- Détection automatique des interventions closes (✓ vert, fond vert)
- Cache persistant 7 jours
- Ghosts : les interventions disparues d'EasyVista restent visibles dans la vue
- Refresh auto 12h et 15h
- Annulation coopérative (bouton "Arrêter")
- Thème clair/sombre
| Position | Sens | Quand ça change |
|---|---|---|
| `2026` | **Année** | À chaque nouvelle année calendaire |
| `5` | **Majeure** | À chaque **gros changement / ajout important** (refonte, nouvelle feature majeure, bump volontaire) |
| `37` | **Patch** | À **chaque livraison** dans la majeure courante (corrections, ajustements, petites features) |
Exemples :
- `2026.5.16``2026.5.17` : petite correction ou ajustement (patch)
- `2026.5.37``2026.6.0` : refonte majeure (par exemple nouvelle vue, nouvelle architecture)
- `2026.x.y``2027.0.0` : passage à la nouvelle année
Le numéro de **majeure** n'est **pas** un mois et **pas** un chiffre lié au calendrier — c'est un compteur de versions importantes propre au projet (la `5` actuelle continue le `5.x` qui précédait, repris tel quel lors du passage au format annuel).
⚠️ **Important** : `v2026.5.16` succède chronologiquement à `v5.0.12`, malgré le numéro qui semble plus petit. Le préfixe `2026` indique l'année.
## Versions notables
### `v2026.5.37` (latest, 25 avril 2026) — Refonte vue horizontale
- Topbar supprimée en vue horizontale, tout passe en sidebar
- User-badge + titre + bouton "Aujourd'hui" + date/heure + sélecteur + flèches + stats dans sidebar
- Banderole pompier masquée (badge + barre rouge gauche conservés)
### `v2026.5.36` — Sidebar verticale
- Wrapper flex-row `#horizontal-wrapper` [sidebar 200px] + [main]
- Déplacement physique des éléments via `ELEMENTS_TO_RELOCATE`
- Restauration propre en vue classique
### `v2026.5.32` — Vue horizontale togglable
- Bouton ⊞ "Vue" dans popup user-badge
- Chaque tech = 1 ligne horizontale compacte
- localStorage `view_mode`
### `v2026.5.27` — Classification absences
- ABSENCE_LABELS : `^(cong[ée]s|maladie|pompier)$`
- Couleurs catégories
- Topbar une ligne : "Jeudi 23.04.26 • 21:55"
### `v4.2.3` — Grande popup timeline persistante
- Clic segment timeline = popup persistante
- Hover = popup qui suit la souris
### `v4.1.3` — Tooltips épinglables
- Introduction de `pinTooltip`
### `v1.0.0` (16 avril 2026) — Initiale
- Premier viewer EasyVista pour le canton
Voir [CHANGELOG.md](CHANGELOG.md) pour l'historique complet (40 versions taggées).
## Architecture technique
```
manifest.json # Manifest V3 (Chrome) + gecko_settings (Firefox)
background.js # Worker fond : fetch planning XML, gestion session, fetch fiches
viewer.html # Interface principale
viewer.js # Logique (~9000 lignes) — voir détail ci-dessous
viewer.css # Styles + thèmes clair/sombre
icons/ # icon16, icon48, icon128
```
### `viewer.js` — fonctions clés
| Fonction | Introduite | Rôle |
|---|---|---|
| `loadForDate` | v1.0.0 | Fetch + parse planning pour une date donnée |
| `buildTooltipHTML` | v1.0.0 | Construction HTML du tooltip d'intervention |
| `pinTooltip` | v4.1.3 | Épingler un tooltip (le rendre permanent) |
| `bindTimelinePopover` | v4.2.3 | Lier popover timeline aux segments |
| `showTimelinePopover` | v4.2.3 | Afficher popover persistante |
| `openPersistentTimelinePopup` | v4.2.3 | Grande popup détaillée |
| `setTooltipViewportPosition` | v4.2.4 | Détection auto fixed/abs |
| `_softUnpinPopup` | v4.3.3 | Désépinglage mou (popup reste visible) |
| `initAppClock` | v5.0.0 | Horloge HH:MM topbar |
| `initSessionTimer` | v5.0.0 | Compteur session EV (tick 1s) |
| `initAdminMenu` | v5.0.0 | Menu admin (5 clics titre) |
| `_applyViewMode` | v2026.5.32 | Toggle vue classique/horizontale |
| `_maybeRetryFetchUser` | v2026.5.34 | Relance opportuniste fetch user |
| `positionTooltipAnchored` | v2026.5.34 | Positionnement unifié (4 candidats) |
### Constantes persistantes (toutes versions)
- 8 techs hardcodés : `76272,83725,66635,92235,90070,40944,72485,86874`
- Pillonel Olivier (ID 40944) : absent tous les vendredis (hardcodé)
- Group ID EasyVista : `191`
- GUIDs forms EV :
- Demande : `S={C99ECD05-3D48-4C62-ABF0-66292053AED6}`
- Incident : `I={07ED9C68-6172-48EA-8A58-90912B0A283E}`
- SSO : Canton ForgeRock OpenAM
- Storage keys : `admin_config`, `view_mode` (depuis v2026.5.32)
- Domaines : `itsma.etat-de-vaud.ch` (interne), `itsma.vd.ch` (externe SSO)
## Installation
1. Décompresser le zip
2. Ouvrir Chrome, `chrome://extensions/`
3. Activer **Mode développeur** (en haut à droite)
4. **Charger l'extension non empaquetée** → sélectionner le dossier `planning-extension-v4`
### Firefox
Télécharger le `.xpi` signé depuis le serveur de mises à jour interne, ou drag-and-drop dans `about:addons`.
Si tu avais déjà la v3 installée, tu peux la supprimer avant — les caches des
deux versions sont compatibles (même format).
### Chrome / Edge
Mode développeur : décompresser le ZIP et charger en tant qu'extension non empaquetée.
## Utilisation
## Développement
1. Se connecter à EasyVista dans un onglet (`itsma.etat-de-vaud.ch` ou `itsma.vd.ch`)
2. Cliquer sur l'icône de l'extension (depuis n'importe quel onglet)
3. La vue claire s'ouvre dans un nouvel onglet
```bash
git clone https://gitea.netaplaid.ch/FroSteel/Planification.git
cd Planification
## Comment ça marche techniquement
# Pour packager une nouvelle version :
# 1. modifier le code
# 2. bump version dans manifest.json
# 3. zip + xpi
git add -A
git commit -m "Version YYYY.M.PATCH — description"
git tag vYYYY.M.PATCH
git push origin main
git push --tags
```
- `background.js` fait les fetches en arrière-plan (via le cookie de session EasyVista).
- L'extension détecte automatiquement le `PHPSESSID` depuis un onglet EasyVista ouvert.
- **v4 : le XML `planning_xhr.php?div=calendar_block` suffit à afficher tout
l'essentiel.** Les champs `attr1`/`attr2`/`attr3` contiennent contact, lieu
et catégorie. Le `textContent` du nœud contient la ref (S260.../I260...).
- Les fiches individuelles (`index.php?formEvent=...`) ne sont fetchées que pour
obtenir le statut Clôturé/Résolu et le commentaire technicien.
- Le texte d'action détaillé (Problème/À faire/Matériel/...) est récupéré en
lazy-load via `planning_xhr_2.php?id=ACTIONID` au premier survol.
- Le cache est stocké dans `chrome.storage.local` (local à ta machine).
- Aucune donnée n'est envoyée ailleurs que vers `itsma.etat-de-vaud.ch` et `itsma.vd.ch`.
## Licence
## Limitations connues
[MIT License](LICENSE) — Copyright (c) 2026 Quentin Rouiller
- Nécessite un onglet EasyVista ouvert (même en arrière-plan) pour fonctionner
- Fonctionne uniquement sur l'intranet cantonal (les fetches échoueront en externe)
- Les 8 IDs des techs sont en dur dans le code (si quelqu'un quitte/arrive dans
l'équipe, il faut mettre à jour `viewer.js` ligne ~22)
- Le statut "Clôturé/Résolu" met quelques secondes à apparaître après le
chargement initial (fetch des fiches en arrière-plan, concurrence 5)
## Auteur
**Quentin Rouiller** (QRO)
Technicien DGNSI — Canton de Vaud
+131 -15
View File
@@ -1,3 +1,16 @@
/**
* Planification — Extension navigateur EasyVista (Canton de Vaud / DGNSI)
*
* Service worker (Manifest V3) : récupération session EV, fetch planning XML,
* fetch fiches détaillées, gestion cache.
*
* Copyright (c) 2026 Quentin Rouiller
* Licensed under the MIT License — see LICENSE file in the project root.
*
* @author Quentin Rouiller
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
*/
// background.js — Service worker (Manifest V3) — v4
//
// Rôles :
@@ -14,6 +27,94 @@
// directement ref/contact/lieu/catégorie dans ses attributs attr1/attr2/attr3,
// donc on n'a plus besoin ni de xhr2 en masse, ni de l'API timeline.
// ============================================================================
// v2026.5.38 : Observabilité — logger unifié + handlers globaux SW
// ============================================================================
// Le service worker MV3 est endormi/relancé fréquemment, donc important d'avoir
// un format de log compact et reproductible. Les handlers `error` et
// `unhandledrejection` sur `self` capturent ce qui passe à travers les
// try/catch (ex: une promise oubliée dans un setTimeout).
// ============================================================================
// Clef chrome.storage.local pour le mode debug — toggle depuis le panel admin
// du viewer. Le viewer envoie un message {type:"setDebugLogs"} qu'on traite
// plus bas pour sync, et on lit aussi au boot.
const DEBUG_LOGS_KEY = "debug_logs";
const LOG = (() => {
let _version = "?";
try {
if (chrome && chrome.runtime && chrome.runtime.getManifest) {
_version = chrome.runtime.getManifest().version || "?";
}
} catch (e) {}
let _debug = false;
// Lecture initiale (asynchrone, mais on s'en fout : on commence muet
// et qd la valeur arrive on update). Le SW peut être tué/relancé donc
// on relit à chaque démarrage.
try {
chrome.storage.local.get([DEBUG_LOGS_KEY]).then(obj => {
_debug = !!obj[DEBUG_LOGS_KEY];
}).catch(() => {});
} catch (e) {}
const _stamp = () => new Date().toISOString().substring(11, 23);
const _format = (level, prefix, msg, ctx) => {
const head = `[${_stamp()}][v${_version}][bg/${prefix}][${level}]`;
if (ctx !== undefined) return [head, msg, ctx];
return [head, msg];
};
return {
info: (prefix, msg, ctx) => {
if (!_debug) return;
console.log(..._format("INFO", prefix, msg, ctx));
},
warn: (prefix, msg, ctx) => console.warn (..._format("WARN", prefix, msg, ctx)),
error:(prefix, msg, ctx) => console.error(..._format("ERROR", prefix, msg, ctx)),
exception: (prefix, msg, err, extra) => {
const ctx = {
name: err && err.name,
message: err && err.message,
stack: err && err.stack,
extra: extra
};
console.error(..._format("ERROR", prefix, msg, ctx));
},
setDebug: (on) => {
_debug = !!on;
try { chrome.storage.local.set({ [DEBUG_LOGS_KEY]: _debug }); } catch (e) {}
console.log(..._format("INFO", "logger", `mode debug = ${_debug ? "ON" : "OFF"}`));
},
isDebug: () => _debug,
version: () => _version
};
})();
// Si le viewer toggle pendant qu'on est endormi/relancé, on capte le
// changement chrome.storage et on update le flag local.
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && changes[DEBUG_LOGS_KEY]) {
const newVal = !!changes[DEBUG_LOGS_KEY].newValue;
LOG.setDebug(newVal);
}
});
self.addEventListener("error", (event) => {
LOG.exception("global", "uncaught error in service worker",
event.error || new Error(event.message || "unknown"),
{ filename: event.filename, lineno: event.lineno, colno: event.colno });
});
self.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
LOG.exception("global", "unhandled promise rejection in service worker",
reason instanceof Error ? reason : new Error(String(reason)));
});
LOG.info("boot", "service worker démarré", { version: LOG.version() });
// Domaines EasyVista reconnus (interne d'abord, externe en fallback)
const EV_ORIGINS = [
"https://itsma.etat-de-vaud.ch",
@@ -40,6 +141,11 @@ chrome.action.onClicked.addListener(async () => {
// Trouver l'onglet EasyVista actif et en extraire le PHPSESSID
// ============================================================================
/**
* Trouve l'onglet EasyVista ouvert et récupère phpsessid + origin.
*
* @author Quentin Rouiller
*/
async function findEasyVistaSession() {
// Chercher tous les onglets sur un domaine EasyVista
for (const origin of EV_ORIGINS) {
@@ -65,6 +171,8 @@ 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 Quentin Rouiller
*/
async function fetchPlanningXml(origin, phpsessid, unixDate) {
const techIds = "76272,83725,66635,92235,90070,40944,72485,86874";
@@ -84,9 +192,9 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
`&mail_title=mail` +
`&day_start_hour=8` +
`&day_end_hour=19`;
console.log("[bg] fetchPlanningXml →", url.substring(0, 140));
// v2026.5.38 : on retire les logs verbose à chaque fetch (URL/status/taille).
// En cas de souci, le throw plus bas porte assez d'info pour debug.
const r = await evFetch(url, origin);
console.log("[bg] status =", r.status);
if (!r.ok) {
// v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon
// écran (session expirée vs EV inaccessible).
@@ -95,9 +203,7 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
err.status = r.status;
throw err;
}
const xml = await r.text();
console.log("[bg] taille XML =", xml.length);
return xml;
return await r.text();
}
/**
@@ -156,7 +262,6 @@ async function fetchXhr2(origin, phpsessid, actionId) {
async function fetchFicheHtml(origin, phpsessid, formLink) {
const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`;
console.log("[bg] fetchFicheHtml →", url.substring(0, 120));
// v2026.5.16 : juste après une reconnexion SSO, EasyVista retourne parfois
// une page intermédiaire tronquée (~8 Ko au lieu de ~250 Ko), le temps que
@@ -175,7 +280,11 @@ async function fetchFicheHtml(origin, phpsessid, formLink) {
throw err;
}
const html = await r.text();
console.log(`[bg] fiche status = ${r.status} | taille = ${html.length}${attempt > 1 ? ` (tentative ${attempt}/${MAX_RETRIES})` : ""}`);
// v2026.5.38 : on log seulement les retries (utile en cas de pb SSO),
// pas chaque tentative normale qui réussit du premier coup.
if (attempt > 1) {
console.log(`[bg] fiche tentative ${attempt}/${MAX_RETRIES} (taille = ${html.length})`);
}
// Si réponse clairement une redirection courte → login expiré, inutile de retry
if (html.length < 500) {
@@ -273,7 +382,7 @@ async function fetchSessionTimeRemaining(origin, phpsessid) {
const url = `${origin}/timeout_ajax.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ `&__AJAX_TIMEOUT_FCT__=session_time`;
console.log("[bg] fetchSessionTimeRemaining →", url.substring(0, 120));
// v2026.5.38 : log retiré (appelé toutes les minutes, polluait la console).
const r = await evFetch(url, origin);
if (!r.ok) {
throw new Error("HTTP " + r.status);
@@ -288,9 +397,7 @@ async function fetchSessionTimeRemaining(origin, phpsessid) {
}
throw new Error("invalid_response");
}
const ms = parseInt(body, 10);
console.log(`[bg] session_time = ${ms} ms = ${Math.round(ms/60000)} min`);
return ms;
return parseInt(body, 10);
}
/**
@@ -302,7 +409,7 @@ async function extendSessionKeepAlive(origin, phpsessid) {
const url = `${origin}/timeout_ajax.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ `&__AJAX_TIMEOUT_FCT__=keep_connection`;
console.log("[bg] extendSessionKeepAlive →", url.substring(0, 120));
// v2026.5.38 : log retiré (déclenché par bouton "prolonger", l'UI affiche déjà un toast).
const r = await evFetch(url, origin);
if (!r.ok) {
throw new Error("HTTP " + r.status);
@@ -312,9 +419,7 @@ async function extendSessionKeepAlive(origin, phpsessid) {
if (looksLikeLoginPage(body)) throw new Error("session_expired");
throw new Error("invalid_response");
}
const ms = parseInt(body, 10);
console.log(`[bg] keep_connection → session prolongée à ${ms} ms`);
return ms;
return parseInt(body, 10);
}
// ============================================================================
@@ -337,6 +442,8 @@ async function extendSessionKeepAlive(origin, phpsessid) {
*
* @param {boolean} force - si true, ignore le cache et refait le test
* @returns {Promise<"internal"|"external">}
*
* @author Quentin Rouiller
*/
async function detectNetworkContext(force = false) {
const CACHE_KEY = "network_context_v2"; // v5.0.12 : nouvelle clé pour invalider le cache fautif v5.0.11
@@ -475,6 +582,8 @@ function watchReconnectTabForIamLogin(tabId) {
* - champ "Bienvenue Nom Prénom"
* Retourne { name: "Nom Prénom" | null, login: "..." | null } ou null si
* tout a échoué.
*
* @author Quentin Rouiller
*/
async function fetchCurrentUser(origin, phpsessid) {
const url = `${origin}/index.php?PHPSESSID=${encodeURIComponent(phpsessid)}`;
@@ -1213,6 +1322,13 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return;
}
// v2026.5.38 : toggle debug logs (depuis le panel admin du viewer)
if (msg.type === "setDebugLogs") {
LOG.setDebug(!!msg.on);
sendResponse({ ok: true, debug: LOG.isDebug() });
return;
}
sendResponse({ ok: false, error: "unknown_message" });
} catch (err) {
console.error("background error:", err);
+14 -4
View File
@@ -1,9 +1,15 @@
{
"manifest_version": 3,
"name": "Planification",
"version": "2026.5.37",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Regroupe interventions et réservations par tech, affiche horaires, contact, lieu, catégorie et statut en un coup d'œil.",
"permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"],
"version": "2026.5.38",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.",
"permissions": [
"activeTab",
"scripting",
"storage",
"tabs",
"alarms"
],
"host_permissions": [
"https://itsma.etat-de-vaud.ch/*",
"https://itsma.vd.ch/*"
@@ -21,7 +27,11 @@
},
"web_accessible_resources": [
{
"resources": ["viewer.html", "viewer.js", "viewer.css"],
"resources": [
"viewer.html",
"viewer.js",
"viewer.css"
],
"matches": [
"https://itsma.etat-de-vaud.ch/*",
"https://itsma.vd.ch/*"
+31 -33
View File
@@ -1,3 +1,13 @@
/**
* Planification — Extension navigateur EasyVista (Canton de Vaud / DGNSI)
*
* Copyright (c) 2026 Quentin Rouiller
* Licensed under the MIT License — see LICENSE file in the project root.
*
* @author Quentin Rouiller
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
*/
/* ==========================================================================
Thème clair (défaut)
========================================================================== */
@@ -368,8 +378,8 @@ html, body {
pointer-events: none;
}
/* v2026.5.17 : masquer l'ancien date-picker-day s'il traîne (compat) */
.date-picker-day { display: none; }
/* v2026.5.38 : ancienne classe .date-picker-day retirée (orpheline depuis le
nouveau picker .date-custom de la v2026.5.17). */
.btn-nav {
padding: 6px 10px;
@@ -838,10 +848,6 @@ html, body {
opacity: 0.7;
}
.intervention.highlight {
background: var(--bg-hover);
}
/* v2026.5.29 : highlight visible sur les rows .intervention-v2 quand on
survole le segment timeline correspondant (ou que l'user survole la row) */
.intervention-v2.highlight {
@@ -966,10 +972,6 @@ html, body {
to { transform: rotate(360deg); }
}
/* .intervention-v2.is-ghost : retirée en v4.3.3 — on ne barre plus les
cartes. La gestion des tickets disparus se fait via _disappearStatus
(vert ✓/✓✓) ou _disappearRemove (retrait total). */
/* Ligne 1 : REF en titre centré gros gras */
.iv-ref-header {
grid-area: ref;
@@ -1212,28 +1214,8 @@ html, body {
font-style: italic;
}
/* ──────────────────────────────────────────────────────────────────────────
Anciens styles .intervention (v1) — gardés pour ne pas casser le reste
────────────────────────────────────────────────────────────────────────── */
.intervention {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px 8px 10px;
border-top: 1px solid var(--border);
cursor: default;
transition: background 0.08s;
position: relative;
}
.intervention:first-child { border-top: none; }
.intervention:hover { background: var(--bg-hover); }
.intervention-dot {
flex-shrink: 0;
width: 4px;
align-self: stretch;
margin: 2px 4px 2px 0;
border-radius: 2px;
}
/* v2026.5.38 : anciens styles .intervention (layout v1) supprimés — le HTML
ne génère plus que .intervention-v2 depuis longtemps. */
/* ==========================================================================
Tooltip
@@ -2554,7 +2536,6 @@ header.topbar::before {
.app-clock-date { display: none; }
.topbar-left { flex-wrap: wrap; }
.date-nav { margin-top: 4px; }
.date-picker-day { min-width: 46px; font-size: 12px; }
.topbar-right { flex-wrap: wrap; justify-content: flex-end; }
}
@@ -4032,3 +4013,20 @@ html.view-horizontal #progress-bar {
html.view-horizontal .card-status-note.pompier {
display: none !important;
}
/* ==========================================================================
v2026.5.38 : Signature auteur en bas du popup user-badge
Style cohérent avec le footer "QRO/vX.Y.Z" en bas à droite : petit, gris
atténué, séparé par une fine ligne supérieure.
========================================================================== */
.user-name-popup-author {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-faint);
text-align: center;
font-style: italic;
letter-spacing: 0.2px;
user-select: none;
}
+9
View File
@@ -1,4 +1,13 @@
<!doctype html>
<!--
Planification — Extension navigateur EasyVista (Canton de Vaud / DGNSI)
Copyright (c) 2026 Quentin Rouiller
Licensed under the MIT License — see LICENSE file in the project root.
@author Quentin Rouiller
@repository https://gitea.netaplaid.ch/FroSteel/Planification
-->
<html lang="fr">
<head>
<meta charset="utf-8">
+376 -55
View File
@@ -1,3 +1,17 @@
/**
* Planification Extension navigateur EasyVista (Canton de Vaud / DGNSI)
*
* Vue claire et rapide du planning des techniciens : interventions,
* réservations, absences, statut pompier, etc. Regroupé par tech avec
* timelines visuelles et popups détaillés.
*
* Copyright (c) 2026 Quentin Rouiller
* Licensed under the MIT License see LICENSE file in the project root.
*
* @author Quentin Rouiller
* @repository https://gitea.netaplaid.ch/FroSteel/Planification
*/
// ============================================================================
// viewer.js — vue claire du planning techniciens
// ============================================================================
@@ -16,6 +30,121 @@
// pas perdu.
// ============================================================================
// ============================================================================
// v2026.5.38 : Observabilité — logger unifié + handlers globaux
// ============================================================================
// Toutes les erreurs runtime (uncaught + unhandled rejection) sont attrapées
// par les listeners ci-dessous et loggées avec un préfix clair, un timestamp,
// et la stack trace si dispo. L'idée : qd l'user lance F12 et copie la console,
// on a tout pour reproduire et corriger.
//
// Format des logs : [PREFIX][LEVEL] message {context}
// - LEVEL : INFO / WARN / ERROR
// - PREFIX : module qui logue (load, currentUser, session, viewMode, ...)
// - message : un truc lisible humain
// - context : objet sérialisable avec les vars utiles au debug
// ============================================================================
// Clef localStorage pour le mode debug — exposé dans le panel admin.
// Quand activé : LOG.info() devient visible. Sinon : muet.
// LOG.warn et LOG.error sont TOUJOURS visibles, peu importe le flag.
const DEBUG_LOGS_KEY = "debug_logs";
const LOG = (() => {
// On capture la version du manifest une fois, pour la mettre dans les logs
// d'erreur (utile qd l'user nous envoie un screenshot console).
let _version = "?";
try {
if (chrome && chrome.runtime && chrome.runtime.getManifest) {
_version = chrome.runtime.getManifest().version || "?";
}
} catch (e) {
// chrome.runtime peut ne pas être dispo dans certains contextes
}
// État debug — relu depuis localStorage. Peut être toggle à la volée
// depuis le panel admin sans reload.
let _debug = false;
try {
_debug = localStorage.getItem(DEBUG_LOGS_KEY) === "1";
} catch (e) {
// localStorage peut être bloqué (mode privé) — on reste en mode normal
}
const _stamp = () => new Date().toISOString().substring(11, 23); // HH:MM:SS.mmm
const _format = (level, prefix, msg, ctx) => {
const head = `[${_stamp()}][v${_version}][${prefix}][${level}]`;
if (ctx !== undefined) return [head, msg, ctx];
return [head, msg];
};
return {
// info() : visible UNIQUEMENT si debug activé. Pour les étapes verbose.
info: (prefix, msg, ctx) => {
if (!_debug) return;
console.log(..._format("INFO", prefix, msg, ctx));
},
// warn / error : toujours visibles
warn: (prefix, msg, ctx) => console.warn (..._format("WARN", prefix, msg, ctx)),
error:(prefix, msg, ctx) => console.error(..._format("ERROR", prefix, msg, ctx)),
// Erreur "interne" — affiche stack + serialize l'objet erreur
exception: (prefix, msg, err, extra) => {
const ctx = {
name: err && err.name,
message: err && err.message,
stack: err && err.stack,
extra: extra
};
console.error(..._format("ERROR", prefix, msg, ctx));
},
// Toggle à la volée depuis le panel admin
setDebug: (on) => {
_debug = !!on;
try { localStorage.setItem(DEBUG_LOGS_KEY, _debug ? "1" : "0"); } catch (e) {}
// Synchroniser avec le service worker (qui a son propre flag)
try {
chrome.runtime.sendMessage({ type: "setDebugLogs", on: _debug }, () => {
// on ignore lastError volontairement : le SW peut être en sommeil
if (chrome.runtime.lastError) { /* ok */ }
});
} catch (e) {}
console.log(..._format("INFO", "logger", `mode debug = ${_debug ? "ON" : "OFF"}`));
},
isDebug: () => _debug,
version: () => _version
};
})();
// Global error handler : attrape les exceptions qui passent à travers tous les
// try/catch. L'user voit un toast, on logue tout en console.
window.addEventListener("error", (event) => {
LOG.exception("global", "uncaught error",
event.error || new Error(event.message || "unknown"),
{ filename: event.filename, lineno: event.lineno, colno: event.colno });
// Toast non-bloquant — on suppose que showToast est dispo (init en haut)
try {
if (typeof showToast === "function") {
showToast("Erreur inattendue", "Voir la console (F12) pour le détail");
}
} catch (e) { /* showToast peut ne pas être init au tout début */ }
});
// Promesses rejetées sans .catch() — typiquement des async/await sans try
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
LOG.exception("global", "unhandled promise rejection",
reason instanceof Error ? reason : new Error(String(reason)),
{ promise: "unhandled" });
try {
if (typeof showToast === "function") {
showToast("Erreur asynchrone", "Voir la console (F12) pour le détail");
}
} catch (e) { /* idem */ }
});
LOG.info("boot", "viewer.js chargé", { version: LOG.version() });
// ============================================================================
// Configuration
// ============================================================================
@@ -241,7 +370,6 @@ async function init() {
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar
_applyViewMode(); // v2026.5.32 : appliquer la vue sauvegardée
initAdminMenu(); // v5.0.0 : menu admin caché (5 clics sur titre)
initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s)
initDateCustomPicker(); // v2026.5.17 : faux input date avec jour
@@ -341,6 +469,11 @@ let _currentUserRetryCount = 0;
const _CURRENT_USER_MAX_RETRIES = 10;
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 Quentin Rouiller
*/
async function fetchAndShowCurrentUser() {
const attemptId = _currentUserRetryCount + 1;
console.log(`[currentUser] tentative ${attemptId}/${_CURRENT_USER_MAX_RETRIES + 1} de fetchCurrentUser`);
@@ -425,6 +558,8 @@ async function fetchAndShowCurrentUser() {
* - un retry est déjà en cours (évite les doublons)
*
* @param {string} reason - contexte pour les logs (ex: "after_load_success")
*
* @author Quentin Rouiller
*/
function _maybeRetryFetchUser(reason) {
if (state.currentUser && state.currentUser.name) {
@@ -547,6 +682,15 @@ function toggleUserNamePopup() {
});
popup.appendChild(settingsBtn);
// v2026.5.38 : signature auteur en bas du popup user-badge
// Style : même look que le footer "QRO/vX.Y.Z" en bas à droite — petit,
// 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 Quentin Rouiller";
authorLine.title = "Auteur de l'extension";
popup.appendChild(authorLine);
popup.classList.remove("hidden");
badge.classList.add("open");
// Positionne juste en dessous de la pastille
@@ -1006,6 +1150,11 @@ function initModalScrollLock() {
// v4.2.9 : pied de page discret "QRO / vX.X.X" en bas à droite.
// La version est lue depuis le manifest (source unique de vérité).
/**
* Pied de page discret bas-droite avec QRO et numéro de version.
*
* @author Quentin Rouiller
*/
function initAppFooter() {
if (document.querySelector(".app-footer")) return;
let version = "";
@@ -1013,7 +1162,9 @@ function initAppFooter() {
const manifest = chrome && chrome.runtime && chrome.runtime.getManifest
? chrome.runtime.getManifest() : null;
if (manifest && manifest.version) version = "v" + manifest.version;
} catch (e) {}
} catch (e) {
LOG.warn("footer", "getManifest indispo (mode dégradé sans version)", { err: e && e.message });
}
const el = document.createElement("div");
el.className = "app-footer";
el.textContent = `QRO${version ? " / " + version : ""}`;
@@ -1036,7 +1187,10 @@ function _getCurrentView() {
function _setCurrentView(mode) {
try {
localStorage.setItem(VIEW_MODE_KEY, mode === "horizontal" ? "horizontal" : "classic");
} catch (e) {}
} catch (e) {
// localStorage peut être bloqué (mode privé Firefox, quota plein)
LOG.warn("viewMode", "localStorage indispo, vue non persistée", { err: e && e.message });
}
_applyViewMode();
}
@@ -1066,11 +1220,14 @@ function _toggleView() {
* On mémorise les parents d'origine sur chaque élément (data-orig-parent)
* pour restaurer proprement en vue classique.
*
* Logs [viewMode] pour debug.
* v2026.5.38 : on ne log plus que les warnings (parent absent etc.) les
* logs verbeux étape par étape ont été retirés, ils polluaient la console
* à chaque toggle.
*
* @author Quentin Rouiller
*/
function _applyViewMode() {
const mode = _getCurrentView();
console.log(`[viewMode] application de la vue : ${mode}`);
// Mettre à jour la classe sur <html> pour les règles CSS
document.documentElement.classList.remove("view-classic", "view-horizontal");
@@ -1102,12 +1259,13 @@ function _applyViewMode() {
_restoreElementsToTopbar(ELEMENTS_TO_RELOCATE);
}
console.log(`[viewMode] application terminée (mode=${mode})`);
}
/**
* 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 Quentin Rouiller
*/
function _moveElementsToSidebar(ids) {
// Créer (ou retrouver) le wrapper qui contiendra sidebar + main côte à côte
@@ -1121,7 +1279,6 @@ function _moveElementsToSidebar(ids) {
if (mainEl && mainEl.parentNode) {
mainEl.parentNode.insertBefore(wrapper, mainEl);
wrapper.appendChild(mainEl);
console.log("[viewMode] wrapper créé, <main> encapsulé");
} else {
console.warn("[viewMode] <main> introuvable, wrapper ajouté à <body>");
document.body.appendChild(wrapper);
@@ -1135,7 +1292,6 @@ function _moveElementsToSidebar(ids) {
sidebar.id = "horizontal-sidebar";
sidebar.className = "horizontal-sidebar";
wrapper.insertBefore(sidebar, wrapper.firstChild);
console.log("[viewMode] sidebar créée et insérée en tête du wrapper");
} else if (sidebar.parentNode !== wrapper) {
// Sidebar existe ailleurs, on la remet dans le wrapper
wrapper.insertBefore(sidebar, wrapper.firstChild);
@@ -1151,7 +1307,6 @@ function _moveElementsToSidebar(ids) {
if (dateNav) {
_memorizeOriginalParent(dateNav);
sidebar.appendChild(dateNav);
console.log("[viewMode] déplacé dans sidebar : .date-nav");
// Créer le wrapper des flèches si pas déjà fait, et y mettre prev + next
let arrowsWrap = document.getElementById("sidebar-arrows");
@@ -1159,7 +1314,6 @@ function _moveElementsToSidebar(ids) {
arrowsWrap = document.createElement("div");
arrowsWrap.id = "sidebar-arrows";
sidebar.appendChild(arrowsWrap);
console.log("[viewMode] wrapper flèches #sidebar-arrows créé");
}
const prevBtn = document.getElementById("nav-prev");
const nextBtn = document.getElementById("nav-next");
@@ -1178,18 +1332,18 @@ function _moveElementsToSidebar(ids) {
if (id === "date-nav") continue; // déjà traité
const el = document.getElementById(id);
if (!el) {
console.log(`[viewMode] élément #${id} introuvable (optionnel) — skip`);
continue;
}
_memorizeOriginalParent(el);
sidebar.appendChild(el);
console.log(`[viewMode] déplacé dans sidebar : #${id}`);
}
}
/**
* 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 Quentin Rouiller
*/
function _restoreElementsToTopbar(ids) {
// v2026.5.37 : d'abord remettre les flèches dans .date-nav (avant de
@@ -1216,7 +1370,6 @@ function _restoreElementsToTopbar(ids) {
const arrowsWrap = document.getElementById("sidebar-arrows");
if (arrowsWrap) {
arrowsWrap.remove();
console.log("[viewMode] wrapper #sidebar-arrows supprimé");
}
// Restaurer .date-nav à son parent d'origine
@@ -1233,7 +1386,6 @@ function _restoreElementsToTopbar(ids) {
const sidebar = document.getElementById("horizontal-sidebar");
if (sidebar && sidebar.children.length === 0) {
sidebar.remove();
console.log("[viewMode] sidebar supprimée (vide)");
}
// Sortir <main> du wrapper et supprimer le wrapper
@@ -1242,10 +1394,8 @@ function _restoreElementsToTopbar(ids) {
const mainEl = wrapper.querySelector("#main");
if (mainEl && wrapper.parentNode) {
wrapper.parentNode.insertBefore(mainEl, wrapper);
console.log("[viewMode] <main> restauré hors du wrapper");
}
wrapper.remove();
console.log("[viewMode] wrapper supprimé");
}
}
@@ -1271,7 +1421,6 @@ function _memorizeOriginalParent(el) {
function _restoreToOriginalParent(el, label) {
const orig = el.dataset.origParent;
if (!orig) {
console.log(`[viewMode] ${label} n'a pas de parent d'origine mémorisé — skip`);
return;
}
let parent = null;
@@ -1292,13 +1441,17 @@ function _restoreToOriginalParent(el, label) {
} else {
parent.appendChild(el);
}
console.log(`[viewMode] restauré à ${orig} : ${label}`);
}
// v5.0.0 : horloge HH:MM au milieu de la topbar. Mise à jour toutes les 30s
// (les secondes ne sont pas affichées donc pas besoin d'un tick plus rapide).
// v2026.5.27 : date courte "Jeudi 23.04.26" sur la même ligne que l'heure,
// séparées par un gros point "•", même taille que l'heure.
/**
* Horloge HH:MM au milieu de la topbar, mise à jour chaque minute (depuis v5.0.0).
*
* @author Quentin Rouiller
*/
function initAppClock() {
const el = document.getElementById("app-clock");
if (!el) return;
@@ -1406,14 +1559,8 @@ function updateNowLine() {
}
// v5.0.0 : menu admin caché via 5 clics sur le titre "Planification".
// v2026.5.25 : SUPPRIMÉ — l'accès au panneau admin se fait désormais via le
// v2026.5.38 : initAdminMenu() retiré — l'accès admin passe maintenant par le
// bouton "⚙ Paramètres" du popup user-badge (clic sur les initiales).
function initAdminMenu() {
const title = document.getElementById("app-title");
if (!title) return;
title.style.cursor = "default";
// Plus de handler de clic : les 5 clics n'ouvrent plus rien.
}
// ============================================================================
// v5.0.9 : Surveillance du timeout de session EasyVista
@@ -1515,7 +1662,7 @@ function updateSessionIndicator() {
state.sessionExpireAt = Date.now() + resp.remainingMs;
updateSessionIndicator();
}
}).catch(() => {});
}).catch(e => LOG.warn("session", "getSessionRemaining a échoué (silencieux)", { err: e && e.message }));
// En attendant, on continue avec l'estimation locale
}
@@ -1542,7 +1689,9 @@ function updateSessionIndicator() {
`;
const extendBtn = el.querySelector(".session-extend-btn");
if (extendBtn) {
extendBtn.onclick = async () => {
// v2026.5.38 : addEventListener plutôt que .onclick — meilleur tracage
// si plus tard on veut detach le handler proprement.
extendBtn.addEventListener("click", async () => {
extendBtn.disabled = true;
extendBtn.textContent = "…";
try {
@@ -1563,7 +1712,7 @@ function updateSessionIndicator() {
handleSessionExpired();
}
}
};
});
}
// v2026.5.17 : si le popup user-badge est ouvert, rafraîchir la ligne "Session : MM:SS"
@@ -2230,8 +2379,8 @@ function renderAdminSectionDiagnostics(container, cfg, saveFn) {
h.className = "admin-section-title";
container.appendChild(h);
const version = (chrome && chrome.runtime && chrome.runtime.getManifest)
? chrome.runtime.getManifest().version : "?";
// v2026.5.38 : on passe par LOG.version() qui a déjà gardé une copie au boot
const version = LOG.version();
const info = document.createElement("div");
info.className = "admin-diag-grid";
@@ -2244,6 +2393,27 @@ function renderAdminSectionDiagnostics(container, cfg, saveFn) {
`;
container.appendChild(info);
// v2026.5.38 : toggle "Logs verbeux (debug)" — par défaut OFF.
// Quand activé : LOG.info() devient visible dans la console (étapes
// détaillées loadForDate, fetch, render, etc.). LOG.warn et LOG.error
// restent toujours visibles peu importe ce flag.
const debugRow = document.createElement("label");
debugRow.className = "admin-debug-row";
debugRow.style.cssText = "display:flex; align-items:center; gap:10px; margin-top:16px; padding:10px; background:var(--bg-muted); border-radius:6px; cursor:pointer;";
const debugCheckbox = document.createElement("input");
debugCheckbox.type = "checkbox";
debugCheckbox.checked = LOG.isDebug();
const debugText = document.createElement("div");
debugText.style.cssText = "flex:1;";
debugText.innerHTML = `<strong>Logs verbeux (debug)</strong><div style="font-size:12px; color:var(--text-faint); margin-top:2px;">Affiche les étapes détaillées dans la console (F12). À activer si tu veux signaler un bug avec un max de contexte.</div>`;
debugRow.appendChild(debugCheckbox);
debugRow.appendChild(debugText);
debugCheckbox.addEventListener("change", () => {
LOG.setDebug(debugCheckbox.checked);
showToast("Debug logs", debugCheckbox.checked ? "ACTIVÉ — voir console F12" : "désactivé");
});
container.appendChild(debugRow);
// Bouton reset
const resetBtn = document.createElement("button");
resetBtn.type = "button";
@@ -2797,15 +2967,46 @@ function isoToUnixDate(iso) {
// Messages → background
// ============================================================================
// v2026.5.38 : timeout sur sendMessage. En MV3 le service worker peut être
// endormi/relancé : si un handler async oublie d'appeller sendResponse, le
// port reste ouvert et notre Promise pend pour toujours. On force un timeout
// (defaut 15s) qui rejette proprement avec un message clair.
const _SENDMESSAGE_TIMEOUT_MS = 15000;
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 || {});
});
let settled = false;
const t0 = performance.now();
const timer = setTimeout(() => {
if (settled) return;
settled = true;
const dur = Math.round(performance.now() - t0);
LOG.error("sendMessage", `timeout après ${dur}ms (background ne répond pas)`, { type: msg && msg.type });
reject(new Error(`sendMessage timeout (${msg && msg.type || "?"})`));
}, _SENDMESSAGE_TIMEOUT_MS);
try {
chrome.runtime.sendMessage(msg, (response) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (chrome.runtime.lastError) {
const dur = Math.round(performance.now() - t0);
LOG.warn("sendMessage", "lastError", { type: msg && msg.type, err: chrome.runtime.lastError.message, durMs: dur });
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve(response || {});
});
} catch (e) {
// chrome.runtime.sendMessage peut throw direct si extension contexte invalide
if (settled) return;
settled = true;
clearTimeout(timer);
LOG.exception("sendMessage", "throw synchrone", e, { type: msg && msg.type });
reject(e);
}
});
}
@@ -2819,16 +3020,68 @@ async function readCache(isoDate) {
return obj[key] || null;
}
// v2026.5.38 : on attrape spécifiquement les QUOTA_BYTES dépassés. Quand le
// cache local explose (~10 Mo), Chrome rejette le set avec un message qui
// contient "QUOTA". On essaie alors de purger les vieilles entrées et on
// re-tente une fois.
async function writeCache(isoDate, data) {
const key = CACHE_PREFIX + isoDate;
await chrome.storage.local.set({ [key]: { ...data, savedAt: Date.now() } });
const payload = { ...data, savedAt: Date.now() };
try {
await chrome.storage.local.set({ [key]: payload });
} catch (e) {
const msg = (e && e.message) || String(e);
if (msg.toUpperCase().includes("QUOTA")) {
LOG.warn("cache", "quota dépassé, tentative de purge des vieilles entrées", { key });
try {
await _purgeOldCacheEntries();
await chrome.storage.local.set({ [key]: payload });
LOG.info("cache", "écriture OK après purge", { key });
return;
} catch (e2) {
LOG.exception("cache", "écriture échouée même après purge", e2, { key });
if (typeof showToast === "function") {
showToast("Cache plein", "Les données ne sont pas mises en cache");
}
return;
}
}
LOG.exception("cache", "écriture échouée", e, { key });
}
}
// Purge les entrées de cache plus vielles que 7 jours (best-effort).
async function _purgeOldCacheEntries() {
const all = await chrome.storage.local.get(null);
const now = Date.now();
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const toRemove = [];
for (const k of Object.keys(all)) {
if (!k.startsWith(CACHE_PREFIX)) continue;
const v = all[k];
if (v && typeof v.savedAt === "number" && (now - v.savedAt) > SEVEN_DAYS_MS) {
toRemove.push(k);
}
}
if (toRemove.length > 0) {
await chrome.storage.local.remove(toRemove);
LOG.info("cache", `purgé ${toRemove.length} entrée(s) > 7 jours`, { keys: toRemove });
}
}
// ============================================================================
// Flux principal : charger une date
// ============================================================================
/**
* Charge le planning pour la date donnée (fetch XML EasyVista + cache).
*
* @author Quentin Rouiller
*/
async function loadForDate(isoDate, opts = {}) {
LOG.info("load", `début pour ${isoDate}`, { opts, hasSession: !!state.session });
const _t0 = performance.now();
// v4.3.1 : changer de date fermait tous les popups épinglés.
// v2026.5.17 : les popups épinglés restent maintenant ouverts entre dates,
// avec les données qu'ils avaient au moment de l'épinglage.
@@ -2965,7 +3218,8 @@ async function loadForDate(isoDate, opts = {}) {
source: "fresh",
lastRefreshKind: activeRefreshButton
});
writeCache(isoDate, { techs: merged.techs }).catch(() => {});
writeCache(isoDate, { techs: merged.techs })
.catch(e => LOG.warn("cache", "writeCache fail (disappear-analysis)", { err: e && e.message }));
}
})
.catch(err => console.error("[disappear-analysis]", err));
@@ -3885,7 +4139,8 @@ async function refreshStatuses(techs, isoDate, opts = {}) {
// Si annulé : on laisse les résultats partiels dans le DOM et on sauve
// quand même ce qu'on a déjà récupéré (cache incrémental).
if (isRefreshAborted(myToken)) {
try { await writeCache(isoDate, { techs }); } catch {}
try { await writeCache(isoDate, { techs }); }
catch (e) { LOG.warn("cache", "writeCache fail (after abort)", { err: e && e.message }); }
return;
}
@@ -4360,7 +4615,11 @@ function parseTimelineJsonForAction(jsonText, actionId) {
let extra;
try {
extra = JSON.parse(extraRaw);
} catch {
} catch (e) {
// v2026.5.38 : on logue le contenu (tronqué) pour pouvoir reproduire
// qd le serveur EV renvoie du JSON cassé (ça arrive après MAJ EV).
LOG.warn("timeline", "JSON.parse failed sur extraRaw",
{ snippet: extraRaw.substring(0, 120), err: e && e.message });
continue;
}
@@ -4700,21 +4959,48 @@ function showAbortToast() {
}
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");
// v2026.5.38 : on wrappe tout le rendu dans un try/catch global. Si l'une
// des étapes plante (ex: tech sans interventions, données XML cassées),
// on logue tout et on affiche un message à l'user au lieu de laisser un
// écran blanc.
const _tStart = performance.now();
LOG.info("render", "renderFromData début",
{ date: data && data.targetDate, source: data && data.source, techsCount: data && data.techs && data.techs.length });
try {
state.currentData = data;
// v4.3.0 : détecter les conflits d'horaire entre interventions d'un même
// tech (même heure de début OU chevauchement).
detectOverlaps(data.techs);
// Null checks défensifs sur les éléments UI critiques
const elLoading = document.getElementById("loading");
const elErrorBox = document.getElementById("error-box");
const elSessionNeeded = document.getElementById("session-needed");
const elCards = document.getElementById("cards");
if (!elLoading || !elErrorBox || !elSessionNeeded || !elCards) {
LOG.error("render", "éléments DOM manquants",
{ loading: !!elLoading, errorBox: !!elErrorBox, sessionNeeded: !!elSessionNeeded, cards: !!elCards });
return;
}
elLoading.classList.add("hidden");
elErrorBox.classList.add("hidden");
elSessionNeeded.classList.add("hidden");
elCards.classList.remove("hidden");
// Calculer les stats
const stats = computeStats(data.techs, data.targetDate);
renderCaptureInfo(data, stats);
renderStats(stats);
renderCards(data);
// v4.3.0 : détecter les conflits d'horaire entre interventions d'un même
// tech (même heure de début OU chevauchement).
detectOverlaps(data.techs);
// Calculer les stats
const stats = computeStats(data.techs, data.targetDate);
renderCaptureInfo(data, stats);
renderStats(stats);
renderCards(data);
LOG.info("render", "renderFromData OK", { ms: Math.round(performance.now() - _tStart) });
} catch (err) {
LOG.exception("render", "renderFromData a planté",
err, { date: data && data.targetDate, techsCount: data && data.techs && data.techs.length });
if (typeof showError === "function") {
showError("Erreur d'affichage : " + (err.message || "voir console F12"));
}
}
}
// v4.3.0 : détection des conflits d'horaire entre interventions d'un même tech.
@@ -4943,6 +5229,11 @@ function isPillonelAbsentFriday(tech, isoDate) {
return d.getDay() === 5;
}
/**
* Construit la card HTML d'un technicien (header, timeline, interventions, absences).
*
* @author Quentin Rouiller
*/
function buildCard(tech, isoDate) {
const card = document.createElement("section");
card.className = "card";
@@ -5358,6 +5649,11 @@ function getStatusClass(iv) {
return null;
}
/**
* Lie les segments timeline à un popover persistant au clic (depuis v4.2.3).
*
* @author Quentin Rouiller
*/
function bindTimelinePopover(el) {
// v2026.5.33 : en vue horizontale, les interactions sont différentes :
// - hover : ouvre directement la GRANDE popup (pas la petite)
@@ -5512,6 +5808,11 @@ function findIvByActionId(actionId) {
//
// v2026.5.34 : utilise positionTooltipAnchored() unifié au lieu de recalculer
// sa propre position. Plus de code dupliqué.
/**
* Ouvre une grande popup détaillée pour un segment timeline.
*
* @author Quentin Rouiller
*/
function openPersistentTimelinePopup(el) {
if (!el) {
console.warn("[persistentTimeline] segment el null — abandon");
@@ -6739,6 +7040,11 @@ let bulleState = {
hideTimer: null
};
/**
* Affiche un tooltip au survol d'une intervention/réservation.
*
* @author Quentin Rouiller
*/
function showTooltip(e, iv, rowEl) {
// v2026.5.19 : pendant qu'un popup épinglé est en cours de drag, on ignore
// les mouseenter sur les cartes — sinon en survolant une carte on déclenche
@@ -6973,6 +7279,8 @@ 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 Quentin Rouiller
*/
function positionTooltipAnchored(sourceEl, opts) {
opts = opts || {};
@@ -7110,6 +7418,8 @@ function _rectsOverlap(a, b) {
* Cherche une position libre pour un popup de dimensions {w, h} près de la
* 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 Quentin Rouiller
*/
function _findFreePopupPosition(rowEl, w, h) {
const pad = 14;
@@ -7215,6 +7525,8 @@ 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 Quentin Rouiller
*/
function pinTooltip() {
if (!state.currentTooltipIv) return;
@@ -7567,6 +7879,8 @@ async function _refreshPinnedPopupIv(popup, iv) {
* -épingler, ou cliquer ailleurs pour s'en débarrasser normalement.
*
* @param {HTMLElement} el - le popup à désépingler
*
* @author Quentin Rouiller
*/
function _softUnpinPopup(el) {
if (!el) {
@@ -8048,6 +8362,8 @@ function _getPopupSafeArea() {
* v2026.5.20 : contraint un popup flottant (en coords document via style.left/top)
* dans la safe area. Appelé à l'épinglage, pendant le drag, et quand le dock
* apparaît/disparaît.
*
* @author Quentin Rouiller
*/
function _clampPopupInSafeArea(popup) {
if (!popup) return;
@@ -8840,7 +9156,7 @@ function bindTooltipInteractions() {
btn.classList.remove("copied");
btn.textContent = original;
}, 1200);
}).catch(() => {});
}).catch(e => LOG.warn("clipboard", "copie ref échouée (permission ?)", { ref, err: e && e.message }));
}
} else if (action === "delete-item") {
// v5.0.0 : supprimer absence/réservation (depuis tooltip)
@@ -8866,6 +9182,11 @@ function bindTooltipInteractions() {
});
}
/**
* Construit le HTML du tooltip détaillé pour une intervention/réservation.
*
* @author Quentin Rouiller
*/
function buildTooltipHTML(iv) {
if (!iv) return '<dl><dt>Info</dt><dd>—</dd></dl>';
const i = iv.infobulle || {};