Compare commits

..

16 Commits

Author SHA1 Message Date
FroSteel 565075933e Version 4.2.8 — Corrections cumulées 4.2.4-8 2026-04-19 15:00:00 +02:00
FroSteel 7f78493859 Version 4.2.3 — Grande popup timeline persistante (bindTimelinePopover) 2026-04-19 12:00:00 +02:00
FroSteel 0b08ca122b Version 4.2.1 — Démarrage série 4.2 2026-04-19 09:00:00 +02:00
FroSteel 87f561ae10 Version 4.1.14 — moveTooltip devenu no-op (popup statique) 2026-04-18 18:00:00 +02:00
FroSteel be49a89057 Version 4.1.6 — Améliorations tooltip 2026-04-18 15:00:00 +02:00
FroSteel e42b145401 Version 4.1.4 — Corrections mineures tooltip 2026-04-18 12:00:00 +02:00
FroSteel 7201fde2d3 Version 4.1.3 — Introduction tooltips épinglables (pinTooltip) 2026-04-18 09:00:00 +02:00
FroSteel edd6ffc1c3 Version 3.3.0 — Corrections + raffinements
(manifest.json corrigé : était resté à 3.2.0 par oubli)
2026-04-17 18:00:00 +02:00
FroSteel 23244fc4db Version 3.2.0 — Stabilisation 3.2 2026-04-17 16:00:00 +02:00
FroSteel f52095dc4d Version 3.2.0 (pre-release) — Travail en cours sur la 3.2 2026-04-17 14:00:00 +02:00
FroSteel 94877cb816 Version 3.1.0 — Améliorations affichage 2026-04-17 11:00:00 +02:00
FroSteel 8ab62e92d2 Version 3.0.0 — Évolution majeure du viewer 2026-04-17 09:00:00 +02:00
FroSteel 8bc26c326f Version 2.0.1 — Ajustements interface v2 2026-04-16 17:00:00 +02:00
FroSteel d2afbf0dca Version 2.0.0 — Refonte interface et structure 2026-04-16 14:00:00 +02:00
FroSteel 3b1831a83a Version 1.0.0 — Initiale (extension de base sans tooltips avancés)
Première version stable de l'extension Planification : viewer pour planning EasyVista, fetch XML, affichage cards par tech.
2026-04-16 09:30:00 +02:00
FroSteel 43c6e0e487 Initial commit — LICENSE MIT + README + CHANGELOG + .gitignore 2026-04-16 09:00:00 +02:00
7 changed files with 490 additions and 850 deletions
+39
View File
@@ -0,0 +1,39 @@
# 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)
*.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/
+179
View File
@@ -0,0 +1,179 @@
# 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 IT du 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.37 — Refonte vue horizontale (sidebar complète)
**Branche** : current
- 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)
Canton de Vaud — Service IT
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.
+156 -84
View File
@@ -1,110 +1,182 @@
# 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 IT du 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 IT 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 + mois + 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.
### Pourquoi le passage à `YYYY.M.PATCH` ?
## Hérité des versions précédentes
À partir de la **v2026.5.16** (21 avril 2026), l'extension est passée au versionning par année :
- Plus lisible pour les utilisateurs (l'année indique immédiatement la fraîcheur)
- Plus de débat sur ce qui constitue un "majeur" vs "mineur"
- Bump du `PATCH` à chaque livraison
- 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
⚠️ **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)
Canton de Vaud — Service IT
+14 -4
View File
@@ -1,9 +1,15 @@
{
"manifest_version": 3,
"name": "Planification",
"version": "4.3.3",
"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": "4.2.8",
"description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch). v4.2.8 : liste de techniciens dans les modals Absence/Douchette entièrement visible sans scroll. Inclut v4.2.7 (URL exacte douchette).",
"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/*"
+6 -171
View File
@@ -262,7 +262,7 @@ html, body {
background: rgba(255,255,255,0.15);
}
/* Barre de progression pendant le rafraichissement — v4.1.12 : texte
/* Barre de progression pendant le rafraîchissement — v4.1.12 : texte
toujours lisible, que la zone verte l'ait atteint ou non (utilise
mix-blend-mode:difference pour inverser la couleur du texte là où la
barre verte est dessous). */
@@ -438,7 +438,7 @@ html, body {
opacity: 0.9;
}
/* Bouton "Arrêter" (apparaît pdt un refresh manuel) */
/* Bouton "Arrêter" (apparaît pendant un refresh manuel) */
.btn-abort {
background: var(--danger-soft);
color: var(--danger);
@@ -909,9 +909,10 @@ 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). */
.intervention-v2.is-ghost {
opacity: 0.5;
text-decoration: line-through;
}
/* Ligne 1 : REF en titre centré gros gras */
.iv-ref-header {
@@ -1750,169 +1751,3 @@ html, body {
.modal-actions.horizontal .btn {
flex: 1;
}
/* ─────────────────────────────────────────────────────────────────────────
v4.2.9 : blocage du scroll arrière quand une modal est ouverte.
La classe body.modal-open est ajoutée/retirée automatiquement par
initModalScrollLock() dans viewer.js dès qu'un .modal-overlay existe.
───────────────────────────────────────────────────────────────────────── */
body.modal-open {
overflow: hidden;
}
/* v4.2.9 : pied de page discret en bas à droite — affiche auteur + date + version */
.app-footer {
position: fixed;
right: 8px;
bottom: 4px;
font-size: 10px;
color: var(--text-faint, #8892a0);
opacity: 0.55;
pointer-events: none; /* ne capture pas les clics */
user-select: none;
font-variant-numeric: tabular-nums;
letter-spacing: 0.2px;
z-index: 1; /* sous les modals (qui sont à 10000) */
}
.app-footer:hover {
opacity: 0.85;
}
/* ─────────────────────────────────────────────────────────────────────────
v4.3.0 : conflit d'horaire entre 2 interventions d'un même tech.
Les heures s'affichent en rouge + icône ⚠ à côté.
───────────────────────────────────────────────────────────────────────── */
.iv-time-vertical.iv-time-overlap .iv-time-start,
.iv-time-vertical.iv-time-overlap .iv-time-end,
.iv-time-vertical.iv-time-overlap .iv-time-arrow {
color: var(--danger, #b03030) !important;
font-weight: 700;
}
.iv-time-overlap-warn {
color: var(--danger, #b03030);
font-size: 14px;
font-weight: 700;
line-height: 1;
margin-top: 2px;
cursor: help;
text-align: center;
}
/* ─────────────────────────────────────────────────────────────────────────
v4.3.0 : popups épinglés détachés
Ancrés au contenu (position:absolute coord document) → scrollent avec
la page. Persistent jusqu'à fermeture explicite.
───────────────────────────────────────────────────────────────────────── */
.tooltip.pinned-popup {
position: absolute !important; /* override le fixed du .tooltip */
/* v4.3.3 corr : les popups épinglées doivent passer DERRIÈRE la topbar
quand on scrolle (topbar sticky z-index 10). Donc on met 5 : au-dessus
du contenu normal, mais sous la topbar / bannières / modals. */
z-index: 5 !important;
opacity: 1 !important;
pointer-events: auto !important;
/* Bordure plus visible pour distinguer du tooltip live */
border: 2px solid var(--accent, #0f4f8b);
box-shadow: 0 8px 24px rgba(0,0,0,0.18);
/* Pas de contain: layout (hérité) car ça limite le rendu ; on laisse */
animation: pinned-popup-in 0.15s ease-out;
/* Le padding-top est augmenté pour accueillir la barre de drag. */
padding-top: 28px !important;
}
@keyframes pinned-popup-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
/* v4.3.3 : animation de sortie (symétrique à l'apparition) quand on
désépingle. Appliquée par la classe .unpinning. */
.tooltip.pinned-popup.unpinning,
.tooltip.soft-unpinned.unpinning {
animation: pinned-popup-out 0.18s ease-in forwards !important;
}
@keyframes pinned-popup-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.94); }
}
/* v4.3.3 corr : quand une popup est désépinglée "mou", elle perd son look
"épinglé" et redevient un tooltip normal visuellement, tout en gardant
sa position absolute (pour ne pas sauter). */
.tooltip.soft-unpinned {
position: absolute !important;
z-index: 5 !important;
opacity: 1 !important;
pointer-events: auto !important;
/* Pas de bordure bleue, pas de padding-top (plus de dragbar), juste les
styles de base du tooltip (hérités de .tooltip). */
border: 1px solid var(--border-strong) !important;
box-shadow: var(--shadow-hover) !important;
padding-top: 12px !important;
animation: none !important;
}
/* v4.3.3 : Barre de drag en haut de la popup épinglée, permet de la
déplacer (le contenu lui-même garde la sélection de texte possible). */
.pinned-popup-dragbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
to bottom,
var(--bg-muted, rgba(128,128,128,0.08)) 0%,
transparent 100%
);
border-bottom: 1px solid var(--border, rgba(128,128,128,0.15));
border-radius: 6px 6px 0 0;
cursor: grab;
user-select: none;
-webkit-user-select: none;
}
.pinned-popup-dragbar:active,
.pinned-popup.dragging .pinned-popup-dragbar {
cursor: grabbing;
}
/* Petite grippe visuelle au milieu pour signaler que c'est déplaçable */
.pinned-popup-dragbar::before {
content: "";
width: 32px;
height: 3px;
border-radius: 3px;
background: var(--border-strong, rgba(128,128,128,0.35));
}
/* Pendant le drag, on fige l'animation pour éviter les tremblements */
.pinned-popup.dragging {
animation: none !important;
transition: none !important;
cursor: grabbing !important;
box-shadow: 0 12px 32px rgba(0,0,0,0.28);
}
/* Bouton × de fermeture du popup épinglé */
.pinned-popup-close {
position: absolute;
top: 3px;
right: 6px;
width: 22px;
height: 22px;
padding: 0;
line-height: 1;
font-size: 18px;
font-weight: 400;
color: var(--text-muted, #888);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.1s, color 0.1s;
z-index: 2; /* au-dessus de la dragbar */
}
.pinned-popup-close:hover {
background: var(--danger-soft, #fbe6e6);
color: var(--danger, #b03030);
}
+75 -591
View File
@@ -1,19 +1,24 @@
// ============================================================================
// viewer.js — vue claire du planning techniciens
// viewer.js v4.1 — vue claire du planning techniciens
// ============================================================================
// Idée de base : on récupère tout depuis le XML EasyVista (calendar_block) en
// 1 seule requête. attr1/attr2/attr3 + textContent contiennent déjà ref,
// contact, lieu, catégorie, formLink, deadline. Plus besoin de faire 74
// requêtes xhr2 au chargement comme la v3. Le texte complet de l'action
// (Problème / À faire / Matériel) est lazy-load au hover, seulement si
// l'user survole la ligne.
// Différences clés avec v3 :
// 1. Une SEULE requête initiale (calendar_block) pour TOUT récupérer :
// ref, contact, lieu, catégorie, formLink, deadline — tout est déjà dans
// les attributs attr1/attr2/attr3/textContent du XML EasyVista.
// 2. Suppression du fetch xhr2 en masse au chargement (74 requêtes éliminées)
// 3. Suppression du fetch timeline (plus nécessaire)
// 4. Lazy-load du texte d'action détaillé : on fetch xhr2 UNIQUEMENT sur hover,
// et seulement pour l'intervention survolée (pour enrichir le tooltip avec
// Problème/À faire/Matériel/etc.)
// 5. Rendu utilisateur IDENTIQUE à v3 (même UI, mêmes infos au tooltip).
//
// Fetch des fiches : séquentiel (1 par 1) au lieu d'en paralléliser. Le
// serveur EasyVista sérialise de toute façon, et ça rend l'abort instantané
// si l'user change de date en cours.
// Le cache est écrit toutes les 5 fiches (incrémental), pas juste à la fin.
// Comme ça si l'user change de date au milieu, ce qu'on a déjà fetché est
// pas perdu.
// Différences v4 → v4.1 :
// - Fetch des fiches SÉQUENTIEL (1 par 1) au lieu de 5 workers en parallèle.
// Raison : le serveur EasyVista sérialise de toute façon, et le séquentiel
// rend l'abort instantané quand l'user change de date.
// - Cache INCRÉMENTAL : écrit toutes les 5 fiches pendant le fetch, pas juste
// à la fin. Si l'user change de date en cours, les statuts déjà récupérés
// ne sont pas perdus.
// ============================================================================
// ============================================================================
@@ -209,8 +214,6 @@ async function init() {
initTheme();
bindTopbar();
bindTooltipInteractions();
initModalScrollLock(); // v4.2.9 : bloquer le scroll arrière quand modal
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
@@ -220,7 +223,7 @@ async function init() {
// désormais soit manuels (boutons Actualiser / Tout recharger), soit au
// premier chargement si aucun cache n'existe pour la date.
// Charger la sesson puis le planning
// Charger la session puis le planning
await refreshSessionAndLoad();
}
@@ -380,7 +383,7 @@ function toggleTheme() {
function bindTopbar() {
document.getElementById("theme-toggle").addEventListener("click", toggleTheme);
// v4.1.10 : 2 boutons de rafraichissement.
// v4.1.10 : 2 boutons de rafraîchissement.
// - refresh-btn (Total) : force le re-fetch de toutes les fiches (même celles
// déjà enrichies), utile pour voir les statuts évoluer.
// - refresh-partial-btn (Partiel) : re-fetch juste le XML planning pour
@@ -437,7 +440,7 @@ function bindTopbar() {
hideUserNamePopup();
}
}
// v4.2.4 : clic ailleurs ferme aussi la grande bulle d'interventoin
// v4.2.4 : clic ailleurs ferme aussi la grande bulle d'intervention
// quand elle est ouverte via clic timeline (mode "anchored"). Clic sur
// la bulle elle-même ou sur une timeline-slot ne ferme pas.
const tip = tooltipEl();
@@ -455,10 +458,6 @@ function bindTopbar() {
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
hideTooltip({ force: true });
}
// v4.3.0 : Échap ferme TOUS les popups épinglés (le user veut tout fermer)
if (typeof closeAllPinnedPopups === "function") {
closeAllPinnedPopups();
}
}
});
@@ -676,44 +675,6 @@ function showAlertModal(opts) {
document.addEventListener("keydown", escHandler);
}
// ============================================================================
// v4.2.9 : blocage du scroll en arrière-plan quand un modal est ouvert
// ============================================================================
//
// Un MutationObserver surveille l'apparition/disparition de tout élément
// .modal-overlay dans le body. Dès qu'il y en a au moins un, on ajoute la
// classe `modal-open` sur body → CSS bloque le scroll. Quand le dernier
// modal disparaît, la classe est retirée.
//
// Centralisé ici pour que TOUS les modals (existants et futurs) en profitent
// sans modification individuelle.
function initModalScrollLock() {
const updateLock = () => {
const hasModal = document.querySelector(".modal-overlay") !== null;
document.body.classList.toggle("modal-open", hasModal);
};
const observer = new MutationObserver(updateLock);
observer.observe(document.body, { childList: true, subtree: false });
updateLock(); // au cas où un modal serait déjà là au boot
}
// 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é).
function initAppFooter() {
if (document.querySelector(".app-footer")) return;
let version = "";
try {
const manifest = chrome && chrome.runtime && chrome.runtime.getManifest
? chrome.runtime.getManifest() : null;
if (manifest && manifest.version) version = "v" + manifest.version;
} catch (e) {}
const el = document.createElement("div");
el.className = "app-footer";
el.textContent = `QRO${version ? " / " + version : ""}`;
document.body.appendChild(el);
}
// ============================================================================
// v4.2.6 : Modals Absence et Douchette
// ============================================================================
@@ -1212,13 +1173,6 @@ async function writeCache(isoDate, data) {
// ============================================================================
async function loadForDate(isoDate, opts = {}) {
// v4.3.1 : changer de date ferme tous les popups épinglés. Ils réfèrent à
// des interventions du jour courant, ils n'ont aucun sens sur un autre jour.
const previousDate = state.currentDate;
if (previousDate && previousDate !== isoDate && typeof closeAllPinnedPopups === "function") {
closeAllPinnedPopups();
}
state.currentDate = isoDate;
document.getElementById("date-picker").value = isoDate;
@@ -1364,20 +1318,11 @@ async function loadForDate(isoDate, opts = {}) {
)
);
// v4.3.2 : avant de lancer le fetch des fiches (lourd, ~460 KB chacune),
// on fait d'abord un batch xhr2 (léger, ~2-5 KB chacun) pour récupérer
// les vraies infos contact/lieu de toutes les interventions en parallèle.
// Comme ça les cartes s'enrichissent en 1-3 secondes au lieu d'attendre
// que l'utilisateur les survole une par une.
if (!isRefreshAborted(myToken)) {
await prefetchAllXhr2(merged.techs, myToken, opts.doStatusRefresh);
}
if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted(myToken)) {
const tFiches = performance.now();
const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length;
console.log(`[load] début fetch des ${nFiches} fiches (statuts)…`);
// forceAll : uniquement si refresh manuel (bouton "rafraichir").
// forceAll : uniquement si refresh manuel (bouton "Rafraîchir").
// À la navigation normale entre dates, on ne refetch que les iv non
// encore enrichies (ficheFetched=false) — ça reprend là où on s'était
// arrêté si un refresh précédent a été interrompu par un changement de
@@ -1466,9 +1411,9 @@ async function fetchPlanningForDate(isoDate) {
}
// Safeguard (v3.1) : le serveur EasyVista répond parfois 200 avec un
// corps vide — typiquement quand la sesson vient d'être invalidée, ou
// corps vide — typiquement quand la session vient d'être invalidée, ou
// quand il soupçonne du scraping (trop de requêtes parallèles). Dans
// les deux cas, on traite ça comme une sesson expirée : inutile de
// les deux cas, on traite ça comme une session expirée : inutile de
// parser (ça ferait "Document is empty") ni de retry en boucle.
if (!resp.xml || resp.xml.length < 20) {
console.warn("[viewer] XML planning vide — session probablement invalide");
@@ -1716,7 +1661,7 @@ function mergeCacheAndFresh(cached, fresh) {
// xhr2Fetched.
// - Règle générale : fresh wins sur les champs live, cache wins sur les
// champs enrichis qui ne sont pas dans le fresh.
// - Une interventoin en cache mais plus en fresh → marquée "ghost"
// - Une intervention en cache mais plus en fresh → marquée "ghost"
if (!cached || !cached.techs) {
return { techs: fresh.techs };
@@ -2035,17 +1980,6 @@ async function analyzeDisappearedInterventions(techs, ghostsToAnalyze, myToken)
* et iv._disappearRemove (true si à retirer).
*/
async function analyzeOneDisappearedIv(tech, iv) {
// v4.3.0 : court-circuit pour les réservations (AL-Reservation). Elles n'ont
// pas de notion de "terminé par tech" ni de statut clos/résolu à afficher
// (pas de fiche à ouvrir). Quand une réservation disparaît du planning,
// elle est juste retirée — inutile de re-fetcher sa fiche.
if (iv.type === "AL-Reservation") {
iv._disappearChecking = false;
iv._disappearStatus = "cancelled";
iv._disappearRemove = true;
return;
}
// Étape 1 : re-fetch la fiche
const resp = await sendMessage({
type: "fetchFiche",
@@ -2173,7 +2107,7 @@ async function refreshStatuses(techs, isoDate, opts = {}) {
if (statusClosed && iv.ficheFetched) continue;
// v4.1.7 : pause/reprise par date. Sans forceAll (= chargement normal
// au retour sur une date), on skip les iv déjà enrichies (ficheFetched)
// pour ne pas refetcher inutilement. Un clic sur "rafraichir" active
// pour ne pas refetcher inutilement. Un clic sur "Rafraîchir" active
// forceAll, ce qui refetche les non-closes même si déjà enrichies (pour
// voir passer les statuts "En cours" → "Exécution" → "Clôturé").
if (!forceAll && iv.ficheFetched) continue;
@@ -2186,8 +2120,8 @@ async function refreshStatuses(techs, isoDate, opts = {}) {
setRefreshing(true);
// v4.1.7 : barre de progression visible uniquement si on est en train de
// rafraichir la date actuellement affichée. Si l'user change de date
// pdt le refresh, isRefreshAborted() deviendra true et on sortira.
// rafraîchir la date actuellement affichée. Si l'user change de date
// pendant le refresh, isRefreshAborted() deviendra true et on sortira.
const showBar = (state.currentDate === isoDate);
if (showBar) {
updateProgressBar(0, toFetch.length);
@@ -2219,7 +2153,7 @@ async function refreshStatuses(techs, isoDate, opts = {}) {
updateProgressBar(i + 1, toFetch.length);
}
// Sauvegarde périodique du cache pdt le fetch
// Sauvegarde périodique du cache pendant le fetch
if (sinceLastCacheWrite >= CACHE_WRITE_EVERY) {
try {
await writeCache(isoDate, { techs });
@@ -2275,7 +2209,7 @@ async function fetchAndUpdateIntervention(iv, myToken) {
return;
}
// v4.1.2 : pour chaque interventoin on fait xhr2 PUIS fiche.
// v4.1.2 : pour chaque intervention on fait xhr2 PUIS fiche.
// - xhr2 : récupère les VRAIES infos contact/lieu (attr1/attr2 du XML
// sont parfois erronées si le tech a corrigé après planif).
// On met à jour la carte tout de suite avec les vraies infos.
@@ -2355,7 +2289,7 @@ async function fetchAndUpdateIntervention(iv, myToken) {
// ─── Étape 3 : API timeline → texte complet de l'action ─────────────
// Le HTML brut de la fiche ne contient PAS les valeurs d'action (elles
// sont injectées côté client par Angular via un apel REST). On appelle
// sont injectées côté client par Angular via un appel REST). On appelle
// donc le même endpoint REST qu'Angular pour récupérer la description
// complète, match par ACTION_ID === iv.actionId (fiable, numérique).
//
@@ -2449,61 +2383,6 @@ async function fetchAndUpdateIntervention(iv, myToken) {
* (venant du texte d'action validé par le tech) sont plus fiables que
* attr1/attr2 (planification initiale parfois erronée).
*/
// v4.3.2 : pré-fetch de tous les xhr2 en parallèle (batch).
// Objectif : avoir les VRAIES infos contact/lieu pour toutes les interventions
// AVANT que l'utilisateur se mette à les survoler. Comme le xhr2 est léger
// (2-5 KB), on peut en faire plusieurs en parallèle sans écrouler EasyVista.
//
// Params :
// techs : liste des techs avec leurs interventions
// myToken : jeton d'annulation (si l'user change de date, on s'arrête)
// forceAll : si true, re-fait le xhr2 même pour les inter déjà xhr2Fetched
// (utilisé par "Tout recharger")
async function prefetchAllXhr2(techs, myToken, forceAll) {
if (!techs) return;
// Lister les iv qui ont besoin d'un xhr2
const needed = [];
for (const tech of techs) {
for (const iv of tech.interventions || []) {
if (iv.type !== "AL-Intervention") continue;
if (!iv.actionId || iv.ghost) continue;
if (iv.xhr2Fetching) continue;
if (iv.xhr2Fetched && !forceAll) continue;
needed.push(iv);
}
}
if (needed.length === 0) return;
console.log(`[load] pré-fetch xhr2 batch : ${needed.length} interventoin(s)…`);
const t0 = performance.now();
// Si forceAll, reset le flag pour que ensureBulleDescription re-fetch
if (forceAll) {
for (const iv of needed) iv.xhr2Fetched = false;
}
// Batch en parallèle avec concurrency limitée (6) — assez rapide, pas trop
// aggressif sur EasyVista.
const concurrency = 6;
const queue = [...needed];
const workers = [];
for (let w = 0; w < concurrency; w++) {
workers.push((async () => {
while (queue.length > 0) {
if (isRefreshAborted(myToken)) return;
const iv = queue.shift();
try {
await ensureBulleDescription(iv);
} catch (err) {
console.warn("[prefetch xhr2] iv", iv.actionId, err);
}
}
})());
}
await Promise.all(workers);
console.log(`[load] pré-fetch xhr2 fini en ${Math.round(performance.now() - t0)} ms`);
}
async function ensureBulleDescription(iv) {
// Déjà chargé : rien à faire
if (iv.xhr2Fetched) return true;
@@ -2942,7 +2821,7 @@ function setRefreshing(on) {
refreshCounter++;
if (targetIcon) targetIcon.classList.add("spinning");
clearCheckMark();
// Afficher "rafraichissement en cours…" si on n'a pas déjà les données
// Afficher "Rafraîchissement en cours…" si on n'a pas déjà les données
updateCaptureInfoText();
} else {
refreshCounter = Math.max(0, refreshCounter - 1);
@@ -2955,7 +2834,7 @@ function setRefreshing(on) {
}
}
// Force le rafraichissement du texte "MAJ HH:MM" ou "rafraichissement en cours…"
// Force le rafraîchissement du texte "MAJ HH:MM" ou "Rafraîchissement en cours…"
// selon refreshCounter.
function updateCaptureInfoText() {
if (state.currentData) {
@@ -3023,7 +2902,7 @@ function updateProgressBar(done, total) {
label.textContent = `${prefix}${done} / ${total}`;
}
// Affiche/masque le bouton "Arrêter". N'est montré que pdt un refresh
// Affiche/masque le bouton "Arrêter". N'est montré que pendant un refresh
// manuel (clic utilisateur), pas pendant les chargements normaux ni les
// refresh auto 12h/15h.
function showAbortButton(on) {
@@ -3039,7 +2918,7 @@ function showAbortButton(on) {
/**
* Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent
* encore prendre 1-2 secondes avant de se terminer (on ne peut pas vriament
* encore prendre 1-2 secondes avant de se terminer (on ne peut pas vraiment
* annuler un fetch() en cours), mais du point de vue de l'interface tout
* est arrêté : plus de mise à jour, plus de cache, plus rien.
*/
@@ -3054,10 +2933,6 @@ function renderFromData(data) {
document.getElementById("session-needed").classList.add("hidden");
document.getElementById("cards").classList.remove("hidden");
// 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);
@@ -3065,62 +2940,12 @@ function renderFromData(data) {
renderCards(data);
}
// v4.3.0 : détection des conflits d'horaire entre interventions d'un même tech.
// Marque iv._hasOverlap = true pour chaque intervention en conflit avec une
// autre (même heure de début OU chevauchement de créneaux).
// Les absences récurrentes, tickets fantômes à retirer, et réservations
// sont ignorés (pas de conflit pertinent pour eux).
function detectOverlaps(techs) {
if (!techs) return;
for (const tech of techs) {
const ivs = (tech.interventions || []).filter(iv =>
iv && iv.startTime && iv.endTime &&
!iv._disappearRemove &&
iv.type !== "AL-Reservation" &&
// v4.3.2 : le pompier est une absence "tolérée" qui chevauche par
// nature les heures de travail (garde volontaire) — on l'exclut des
// conflits. En revanche les congés/maladies/formations restent
// détectés car une inter planifiée pdt une absence, c'est un vrai pb.
!iv.isPompier
);
// Reset flag sur toutes les inters du tech (y compris celles ignorées)
for (const iv of (tech.interventions || [])) {
iv._hasOverlap = false;
}
// Convertir HH:MM en minutes pour comparaison rapide
const toMin = (hhmm) => {
if (!hhmm) return null;
const parts = hhmm.split(":");
if (parts.length < 2) return null;
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
if (isNaN(h) || isNaN(m)) return null;
return h * 60 + m;
};
// Comparer chaque paire
for (let i = 0; i < ivs.length; i++) {
for (let j = i + 1; j < ivs.length; j++) {
const a = ivs[i], b = ivs[j];
const aStart = toMin(a.startTime), aEnd = toMin(a.endTime);
const bStart = toMin(b.startTime), bEnd = toMin(b.endTime);
if (aStart === null || aEnd === null || bStart === null || bEnd === null) continue;
// Chevauchement = a commence avant que b finisse ET b commence avant que a finisse.
// Inclut aussi le cas "même heure de début" (aStart === bStart).
if (aStart < bEnd && bStart < aEnd) {
a._hasOverlap = true;
b._hasOverlap = true;
}
}
}
}
}
function renderCaptureInfo(data, stats) {
const info = document.getElementById("capture-info");
if (refreshCounter > 0) {
// v4.1.20 : message différencié selon le type de refresh actif
// - partial (Actualiser) → "Actualisation en cours…"
// - total (Tout recharger) → "rafraichissement en cours…"
// - total (Tout recharger) → "Rafraîchissement en cours…"
if (activeRefreshButton === "partial") {
info.textContent = "Actualisation en cours…";
} else {
@@ -3558,7 +3383,7 @@ function bindTimelinePopover(el) {
// - Ctrl+clic (Cmd+clic sur Mac) : ouvre dans un onglet en arrière-plan
const kind = el.dataset.kind;
const ivIdxStr = el.dataset.ivIdx;
// Seulement sur les segments avec une interventoin (pas les "hole" libres
// Seulement sur les segments avec une intervention (pas les "hole" libres
// ni certaines absences sans ivIdx)
if (ivIdxStr === undefined) return;
@@ -3622,7 +3447,7 @@ function openInterventionFromTimeline(el, opts) {
if (!row) return;
const actionId = row.dataset.actionId;
if (!actionId) return;
// recupere l'iv depuis state
// cupère l'iv depuis state
const iv = findIvByActionId(actionId);
if (!iv) return;
openInterventionInNewTab(iv, opts || {});
@@ -3739,7 +3564,7 @@ function showTimelinePopover(e, el) {
}
// ============================================================================
// Ligne d'interventoin
// Ligne d'intervention
// ============================================================================
function buildInterventionRow(iv, cardEl) {
@@ -3747,9 +3572,7 @@ function buildInterventionRow(iv, cardEl) {
row.className = "intervention-v2";
row.dataset.actionId = iv.actionId;
if (iv.isPompier) row.classList.add("is-pompier-line");
// v4.3.3 : on ne marque plus les ghosts visuellement (classe is-ghost
// retirée). Les tickets disparus sont soit retirés (_disappearRemove),
// soit affichés en vert (_disappearStatus). Plus de barrage.
if (iv.ghost) row.classList.add("is-ghost");
// v4.2.5 : indicateur "en cours d'analyse" (ticket disparu, on re-fetch
// la fiche pour décider de le garder en vert ou le retirer).
if (iv._disappearChecking) row.classList.add("_checking");
@@ -3840,10 +3663,6 @@ function buildInterventionRow(iv, cardEl) {
// ─── Ligne 2 gauche : heures VERTICALES (début / ↓ / fin) ─────────────────
const timeEl = document.createElement("div");
timeEl.className = "iv-time-vertical";
// v4.3.0 : marquer rouge + icône ⚠ si conflit horaire détecté
if (iv._hasOverlap) {
timeEl.classList.add("iv-time-overlap");
}
if (iv.startTime && iv.endTime) {
const s = document.createElement("div");
s.className = "iv-time-start";
@@ -3857,14 +3676,6 @@ function buildInterventionRow(iv, cardEl) {
timeEl.appendChild(s);
timeEl.appendChild(sep);
timeEl.appendChild(e);
// v4.3.0 : icône d'alerte à côté des heures si conflit
if (iv._hasOverlap) {
const warn = document.createElement("div");
warn.className = "iv-time-overlap-warn";
warn.textContent = "⚠";
warn.title = "Conflit d'horaire avec une autre intervention";
timeEl.appendChild(warn);
}
} else {
timeEl.textContent = "—";
}
@@ -4545,7 +4356,7 @@ async function copyRef(ref, btn) {
}
// ─── Rendu incrémental (v3.1) ───────────────────────────────────────────────
// Met à jour UNE ligne d'interventoin dans le DOM (après qu'un fetch fiche
// Met à jour UNE ligne d'intervention dans le DOM (après qu'un fetch fiche
// ait enrichi l'objet iv avec sa ref, son statut, etc.). Appelée par
// fetchAndUpdateIntervention pour afficher la ref dès qu'elle arrive, sans
// attendre que tous les workers aient fini ni re-rendre toute la vue.
@@ -4793,7 +4604,7 @@ function showTooltip(e, iv, rowEl) {
}
bulleState.hoveredInRow = true;
// v4.1.12 : positionner la bulle UNE SEULE FOIS au mouseenter, près de la
// carte (row) et pas du curseur. Elle ne bouge plus pdt le survol.
// carte (row) et pas du curseur. Elle ne bouge plus pendant le survol.
// v4.1.15 : si pinned, NE PAS repositionner (la bulle doit rester fixe).
if (!bulleState.pinned) {
positionTooltipAnchored(rowEl || (e && e.currentTarget));
@@ -4810,7 +4621,7 @@ function showTooltip(e, iv, rowEl) {
if (!ok) return;
const tip = tooltipEl();
if (!tip.classList.contains("visible")) return;
// Vérifie qu'on affiche toujours la même interventoin (pas un autre hover
// Vérifie qu'on affiche toujours la même intervention (pas un autre hover
// intervenu entretemps)
if (state.currentTooltipIv === iv) {
tip.innerHTML = buildTooltipHTML(iv);
@@ -4867,19 +4678,24 @@ function hasTextSelectionInTooltip() {
}
function moveTooltip(e) {
// Historique : avant on suivait la souris. Maintenant la bulle est fixe
// (placée une seule fois au mouseenter). Cette fonction est là juste pour
// pas casser les appels existants.
// v4.1.12 : la bulle est FIXE (positionnée une fois au mouseenter). Cette
// fonction est conservée pour compat mais ne fait plus rien.
}
// ============================================================================
// Positionnement du tooltip
// ============================================================================
// On positionne avec style.left/top en coords VIEWPORT (comme position:fixed).
// Si un ancêtre casse position:fixed (transform, filter, backdrop-filter ou
// contain), on détecte ça empiriquement au 1er placement via
// getBoundingClientRect — et on bascule en "abs" : mêmes coords mais on
// compense le scroll manuellement pour garder la bulle stable à l'écran.
// v4.2.4 : Positionnement du tooltip — refonte complète
//
// Stratégie :
// 1. On positionne TOUJOURS avec style.left/top en coordonnées VIEWPORT
// (comme un élément position:fixed).
// 2. Au 1er positionnement, on mesure si `position: fixed` marche vraiment
// sur ce tooltip (grâce à getBoundingClientRect). Si un ancêtre le
// casse (transform / filter / backdrop-filter / contain), le tooltip
// tombe en position:absolute calculée depuis le containing block.
// 3. Si `position: fixed` est cassée, on active un listener scroll qui
// recalcule la position pour qu'elle reste STABLE à l'écran (on traite
// alors style.left/top comme des coordonnées document et on ajoute
// window.scrollX/Y pour compenser).
// ============================================================================
// Position stockée : targetLeft / targetTop = coordonnées VIEWPORT désirées
@@ -4983,338 +4799,13 @@ function positionTooltipAnchored(rowEl) {
setTooltipViewportPosition(x, y);
}
// ============================================================================
// v4.3.0 : système de popups épinglés détachés
// ============================================================================
//
// Au lieu d'épingler le tooltip unique (qui empêchait d'afficher d'autres
// infos au survol), on clone son contenu en un popup indépendant :
// - Ancré DANS le contenu de la page (position: absolute + coordonnées
// document) → scrolle avec le contenu, pas avec le viewport.
// - Peut coexister avec d'autres popups épinglés (jusqu'à ce qu'il n'y
// ait plus de place disponible).
// - Persiste jusqu'à fermeture explicite (bouton ×, Échap, ou Ctrl×2 si 1 seul).
//
// Le tooltip live (#tooltip) garde son rôle initial : il se ferme au mouseleave.
const pinnedPopups = []; // [{el, iv, rect}]
/**
* Ancre la popup au contenu : ajoute le scrollY actuel au top viewport pour
* obtenir une position absolute document, qui scrolle avec le contenu.
*/
function _viewportToDocumentY(y) {
return y + (window.scrollY || window.pageYOffset || 0);
}
function _viewportToDocumentX(x) {
return x + (window.scrollX || window.pageXOffset || 0);
}
/**
* Teste si un rectangle {left, top, right, bottom} (en coords document)
* chevauche avec un popup déjà épinglé.
*/
function _rectsOverlap(a, b) {
return !(a.right <= b.left || a.left >= b.right ||
a.bottom <= b.top || a.top >= b.bottom);
}
/**
* 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.
*/
function _findFreePopupPosition(rowEl, w, h) {
const pad = 14;
const rowRect = rowEl.getBoundingClientRect();
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
// 4 candidats, en coords viewport
const candidates = [
// Droite
{ x: rowRect.right + pad, y: rowRect.top, name: "droite" },
// Gauche
{ x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" },
// Dessous
{ x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" },
// Dessus
{ x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" }
];
// Pour chaque candidat, clamper dans le viewport (marge 8px) et convertir
// en coord document, puis tester le chevauchement
for (const c of candidates) {
let x = c.x, y = c.y;
// Clamp horizontal dans le viewport
if (x < 4) x = 4;
if (x + w > viewportW - 8) x = viewportW - 8 - w;
// Clamp vertical dans le viewport
if (y < 4) y = 4;
if (y + h > viewportH - 8) y = viewportH - 8 - h;
// Si, après clamp, la popup chevaucherait la ligne source elle-même,
// on ignore ce candidat (on préfère une direction qui la laisse visible).
const rowRectClamped = {
left: rowRect.left, top: rowRect.top,
right: rowRect.right, bottom: rowRect.bottom
};
const candRect = { left: x, top: y, right: x + w, bottom: y + h };
if (_rectsOverlap(candRect, rowRectClamped)) continue;
// Test chevauchement avec les popups déjà épinglés
const docRect = {
left: _viewportToDocumentX(x),
top: _viewportToDocumentY(y),
right: _viewportToDocumentX(x + w),
bottom: _viewportToDocumentY(y + h)
};
let overlapsOther = false;
for (const p of pinnedPopups) {
if (_rectsOverlap(docRect, p.rect)) {
overlapsOther = true;
break;
}
}
if (!overlapsOther) {
// Position libre trouvée
return {
viewportX: x, viewportY: y,
docX: docRect.left, docY: docRect.top,
rect: docRect
};
}
}
return null;
}
/**
* v4.3.0 : épingle la bulle courante en la clonant dans un popup détaché
* ancré au contenu. Le tooltip live redevient disponible.
*/
// v4.1.10 : pin/unpin la bulle. Quand pin, on ajoute la classe CSS "pinned"
// qui change le curseur (text) et autorise la sélection.
function pinTooltip() {
if (!state.currentTooltipIv) return;
const srcEl = tooltipEl();
if (!srcEl) return;
const iv = state.currentTooltipIv;
// Chercher la ligne source (row iv-v2)
let rowEl = null;
if (iv.actionId) {
rowEl = document.querySelector(`.intervention-v2[data-action-id="${iv.actionId}"]`);
}
if (!rowEl) {
// Fallback : utiliser la position actuelle du tooltip live
rowEl = srcEl;
}
// Cloner le contenu du tooltip actuel en popup détaché
const popup = document.createElement("div");
popup.className = "tooltip pinned-popup visible";
popup.dataset.actionId = iv.actionId || "";
popup.innerHTML = srcEl.innerHTML;
// Ajouter un bouton × de fermeture (en plus du 📌)
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "pinned-popup-close";
closeBtn.innerHTML = "×";
closeBtn.title = "Désépingler (reste visible tant que la souris est dessus)";
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
// Désépinglage "mou" : on marque la popup comme non épinglée mais on la
// laisse visible tant que la souris est dessus. Elle disparaît quand la
// souris sort.
_softUnpinPopup(popup);
});
popup.appendChild(closeBtn);
// v4.3.3 : barre de drag en haut, pour déplacer la popup à la souris.
// Ancrée en haut à 22px de haut ; le padding-top de la popup est augmenté
// côté CSS pour ne pas que le contenu soit caché derrière.
const dragbar = document.createElement("div");
dragbar.className = "pinned-popup-dragbar";
dragbar.title = "Glissez pour déplacer";
popup.appendChild(dragbar);
_attachPopupDragHandler(popup, dragbar);
// v4.3.0 : le popup contient un clone du tooltip live, qui inclut le
// bouton 📌. Dans un popup déjà épinglé, ce bouton devient "désépingler".
// On intercepte le clic ici, avant qu'il remonte.
popup.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === "pin") {
e.stopPropagation();
e.preventDefault();
_softUnpinPopup(popup);
}
// Les autres actions (reload, copy-ref, etc.) ne sont pas gérées ici ;
// on pourrait les ajouter plus tard si besoin.
});
// Placer en (0,0) temporairement pour mesurer la taille
popup.style.position = "absolute";
popup.style.left = "-9999px";
popup.style.top = "-9999px";
popup.style.visibility = "hidden";
document.body.appendChild(popup);
// Mesurer après rendu
const pRect = popup.getBoundingClientRect();
const w = pRect.width;
const h = pRect.height;
// Chercher une position libre
const pos = _findFreePopupPosition(rowEl, w, h);
if (!pos) {
// Pas de place : retirer et afficher un toast
popup.remove();
showToast("Pas de place", "Fermez une popup épinglée");
return;
}
// Appliquer la position (coords document = position: absolute)
popup.style.left = pos.docX + "px";
popup.style.top = pos.docY + "px";
popup.style.visibility = "visible";
// Enregistrer dans la liste
pinnedPopups.push({ el: popup, iv: iv, rect: pos.rect });
// v4.3.0 : libérer le tooltip live (il redevient utilisable pour d'autres survols)
bulleState.pinned = false;
bulleState.hoveredInRow = false;
bulleState.hoveredInBulle = false;
srcEl.classList.remove("visible", "pinned");
srcEl.classList.add("hidden");
if (srcEl.dataset) delete srcEl.dataset.mode;
state.currentTooltipIv = null;
currentTooltipPos = null;
tooltipPositionMode = null;
if (bulleState.hideTimer) {
clearTimeout(bulleState.hideTimer);
bulleState.hideTimer = null;
}
}
/** Ferme un popup épinglé donné. */
function _closePinnedPopup(el) {
const idx = pinnedPopups.findIndex(p => p.el === el);
if (idx >= 0) pinnedPopups.splice(idx, 1);
el.remove();
}
/**
* Désépinglage "mou" : la popup n'est plus considérée épinglée (elle n'est
* plus dans pinnedPopups, donc le comptage pour Ctrl×2 etc. ignore) mais on
* la laisse visible. Elle disparait quand la souris sort.
*/
function _softUnpinPopup(el) {
// Retirer de la liste (pour le comptage Ctrl×2) mais garder le DOM
const idx = pinnedPopups.findIndex(p => p.el === el);
if (idx >= 0) pinnedPopups.splice(idx, 1);
// v4.3.3 corr : basculer visuellement en tooltip normal (retirer tous les
// attributs visuels du mode épinglé : bordure bleue, dragbar, bouton ×,
// padding-top, etc.). La classe .soft-unpinned fait ça côté CSS.
// On retire .pinned-popup pour que les règles visuelles lourdes
// disparaissent, tout en gardant la popup au même endroit (position
// absolute conservée).
el.classList.remove("pinned-popup");
el.classList.add("soft-unpinned");
// Icône 📌 → 📍 pour le clin d'œil (même si elle va bientôt disparaitre)
const pinBtn = el.querySelector('[data-action="pin"]');
if (pinBtn) pinBtn.textContent = "📍";
// Supprimer les éléments propres au mode épinglé : barre de drag et ×
const dragbar = el.querySelector(".pinned-popup-dragbar");
if (dragbar) dragbar.remove();
const closeBtn = el.querySelector(".pinned-popup-close");
if (closeBtn) closeBtn.remove();
// Helper qui joue l'animation de sortie puis supprime le DOM
const animateAndRemove = () => {
el.classList.add("unpinning");
setTimeout(() => el.remove(), 180);
};
if (!el.matches(":hover")) {
animateAndRemove();
return;
}
// Souris dessus : on ne supprime pas tout de suite. On attend mouseleave
// et à ce moment on joue l'animation de sortie et on supprime.
el.addEventListener("mouseleave", animateAndRemove, { once: true });
}
/** Ferme toutes les popups épinglées (appelé par Échap ou changement de date). */
function closeAllPinnedPopups() {
for (const p of pinnedPopups.slice()) {
p.el.remove();
}
pinnedPopups.length = 0;
// Fermer aussi les popups en état soft-unpinned qui trainent encore
document.querySelectorAll(".pinned-popup.soft-unpinned").forEach(el => el.remove());
}
/**
* v4.3.3 : permet de déplacer une popup épinglée à la souris via sa barre
* de drag. Met à jour les coords document (position absolute) et le rect
* mémorisé dans pinnedPopups pour que les nouvelles popups évitent bien
* la nouvelle position.
*/
function _attachPopupDragHandler(popup, dragbar) {
let dragging = false;
let startMouseX = 0, startMouseY = 0;
let startLeft = 0, startTop = 0;
const onMouseMove = (e) => {
if (!dragging) return;
const dx = e.clientX - startMouseX;
const dy = e.clientY - startMouseY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
// Clamper dans le document (pas sortir trop à gauche/haut)
if (newLeft < 4) newLeft = 4;
if (newTop < 4) newTop = 4;
popup.style.left = newLeft + "px";
popup.style.top = newTop + "px";
};
const onMouseUp = () => {
if (!dragging) return;
dragging = false;
popup.classList.remove("dragging");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
// Mettre à jour le rect mémorisé pour la détection de chevauchement
const entry = pinnedPopups.find(p => p.el === popup);
if (entry) {
const l = parseFloat(popup.style.left) || 0;
const t = parseFloat(popup.style.top) || 0;
const w = popup.offsetWidth;
const h = popup.offsetHeight;
entry.rect = { left: l, top: t, right: l + w, bottom: t + h };
}
};
dragbar.addEventListener("mousedown", (e) => {
// Seulement bouton gauche
if (e.button !== 0) return;
e.preventDefault();
dragging = true;
startMouseX = e.clientX;
startMouseY = e.clientY;
startLeft = parseFloat(popup.style.left) || 0;
startTop = parseFloat(popup.style.top) || 0;
popup.classList.add("dragging");
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
bulleState.pinned = true;
const el = tooltipEl();
el.classList.add("pinned");
}
// v4.1.14/19 : recharger UNIQUEMENT cette intervention. Fetch DIRECT sans
@@ -5517,29 +5008,22 @@ function bindTooltipInteractions() {
}
});
// Double-Ctrl : v4.3.0
// - Si 0 popup épinglé ET un tooltip live visible : épingler
// - Si EXACTEMENT 1 popup épinglé ET souris pas dessus : le fermer
// - Si 2+ popups épinglés : ne fait rien (ambigu, user doit utiliser Échap)
// Double-Ctrl : pin/unpin
// On détecte 2 keydown Control dans une fenêtre de 400 ms.
let lastCtrlTs = 0;
document.addEventListener("keydown", (e) => {
if (e.key !== "Control") return;
// Ignorer si la touche est répétée (hold)
if (e.repeat) return;
const now = performance.now();
if (now - lastCtrlTs < 400) {
// Double-Ctrl détecté
lastCtrlTs = 0;
if (pinnedPopups.length === 0) {
// Aucun popup épinglé : épingler le tooltip live s'il y en a un
if (state.currentTooltipIv) pinTooltip();
} else if (pinnedPopups.length === 1) {
// 1 popup épinglé : le fermer si la souris n'est pas dessus
const p = pinnedPopups[0];
if (!p.el.matches(":hover")) {
_closePinnedPopup(p.el);
if (bulleState.pinned) {
unpinTooltip();
} else if (state.currentTooltipIv) {
pinTooltip();
}
}
// 2+ popups : rien faire (Échap pour tout fermer)
} else {
lastCtrlTs = now;
}
@@ -5553,9 +5037,9 @@ function bindTooltipInteractions() {
e.preventDefault();
const action = btn.dataset.action;
if (action === "pin") {
// v4.3.0 : toujours épingler (le tooltip live clone son contenu en popup
// détaché). Pour désépingler, l'user utilise × sur le popup, ou Échap.
if (state.currentTooltipIv) {
if (bulleState.pinned) {
unpinTooltip();
} else if (state.currentTooltipIv) {
pinTooltip();
}
} else if (action === "reload") {
@@ -5582,14 +5066,14 @@ function bindTooltipInteractions() {
// Clic hors bulle : unpin si épinglé.
// Attention : ne pas déclencher sur clic DANS la bulle (elle contient du
// texte sélectionnable), ni sur clic sur une interventoin (qui ouvre la
// texte sélectionnable), ni sur clic sur une intervention (qui ouvre la
// fiche — le user n'attend pas que la bulle reste épinglée dans ce cas
// mais le comportement "ouvrir la fiche" reste prioritaire).
document.addEventListener("mousedown", (e) => {
if (!bulleState.pinned) return;
// Clic dans la bulle → on laisse (sélection de texte)
if (el.contains(e.target)) return;
// Dans tous les autres cas (y compris clic sur une autre interventoin),
// Dans tous les autres cas (y compris clic sur une autre intervention),
// on désépingle. Si c'était un clic sur intervention, le handler
// d'ouverture de la fiche s'exécutera ensuite normalement.
unpinTooltip();