Compare commits

..

15 Commits

Author SHA1 Message Date
Quentin Rouiller 763e63d9c6 v5.0.15 — Absences partielles affichées comme rows (gris foncé) 2026-04-21 16:24:24 +02:00
Quentin Rouiller bea236ca88 v5.0.14 — Affichage timeline pour absences partielles seules 2026-04-21 16:13:04 +02:00
Quentin Rouiller d6ab8d59e0 v5.0.13 — Cache + retry 2026-04-21 16:04:00 +02:00
Quentin Rouiller 909ddb8301 v5.0.12 — Stabilité 2026-04-21 15:49:08 +02:00
Quentin Rouiller 6794360887 v5.0.11 — Détection contexte réseau (interne/externe via SSO) 2026-04-21 15:44:14 +02:00
Quentin Rouiller 7ba28d3bac v5.0.10 — Stabilité session EV 2026-04-21 15:32:44 +02:00
Quentin Rouiller e17f604d9e v5.0.9 — Surveillance timeout session EasyVista (compteur tick 1s, alertes 5min/2min) 2026-04-21 15:19:06 +02:00
Quentin Rouiller 9d701701e6 v5.0.8 — Correctifs 2026-04-21 12:53:22 +02:00
Quentin Rouiller 77c68dbe83 v5.0.7 — Correctifs 2026-04-21 12:50:36 +02:00
Quentin Rouiller d4fc8ff250 v5.0.6 — Correctifs 2026-04-21 12:46:58 +02:00
Quentin Rouiller 3996e3fb4f v5.0.5 — Correctifs admin/UX 2026-04-21 12:42:50 +02:00
Quentin Rouiller 86f52029f5 v5.0.4 — Améliorations admin/UX 2026-04-21 12:40:08 +02:00
Quentin Rouiller 984f326b39 v5.0.3 — Ajustements admin et stabilité 2026-04-20 14:03:34 +02:00
Quentin Rouiller 6d3058028f v5.0.1 — Refonte topbar : horloge HH:MM + compteur session EV + admin caché (5 clics titre) 2026-04-20 13:21:16 +02:00
Quentin Rouiller c59abbed23 v4.3.3 — Soft unpin popup + nettoyage tooltip persistance 2026-04-20 09:13:20 +02:00
9 changed files with 175 additions and 1142 deletions
-39
View File
@@ -1,39 +0,0 @@
# 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
@@ -1,179 +0,0 @@
# 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
@@ -1,21 +0,0 @@
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.
+84 -156
View File
@@ -1,182 +1,110 @@
# Planification — Extension EasyVista Canton de Vaud # Planning techniciens — Vue claire (v4.1.2)
Extension Chrome / Firefox pour visualiser de manière claire et rapide le planning des techniciens IT du Canton de Vaud dans EasyVista. Extension Chrome/Brave/Edge pour afficher le planning techniciens EasyVista
(`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) dans une vue plus lisible.
## Aperçu rapide ## Nouveautés v4.1.2
- **Auteur** : Quentin Rouiller (QRO) - **Vraies infos contact/lieu dans les cartes** : les attributs attr1/attr2 du
- **Cible** : techniciens IT Canton de Vaud, EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) XML contiennent les infos saisies à la *planification*, qui ne sont pas
- **Démarrage projet** : jeudi 16 avril 2026 toujours à jour (le tech a pu corriger le contact/lieu avant intervention).
- **Version actuelle** : `v2026.5.37` Désormais, pour chaque intervention, on fetch AUSSI le xhr2 en arrière-plan
- **Manifest** : V3 (Chrome/Edge/Firefox) (en plus de la fiche), ce qui apporte les **vraies** infos validées. La
- **Format** : `.zip` (Chromium) + `.xpi` signé (Firefox) 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.
## Fonctionnalités principales ## Nouveautés v4.1
### Vue planning - **Fetch des fiches séquentiel (1 par 1)** au lieu de 5 workers en parallèle.
- Affichage des interventions et réservations groupées par technicien Le serveur EasyVista sérialise les requêtes de toute façon, donc le parallélisme
- Horaires, contact, lieu, catégorie, statut visibles d'un coup d'œil n'apporte rien. Et surtout : quand tu changes de date pendant le fetch, l'abort
- 8 techniciens hardcodés (équipe IT canton) est **instantané** car il n'y a qu'une seule requête en vol au maximum.
- Cache local pour réduire les requêtes serveur - **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.
### Modes d'affichage ## Nouveautés v4
- **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`)
### Tooltips et popups **Chargement ~50× plus rapide.** Le nombre de requêtes au serveur EasyVista passe
- Tooltips au survol (hover) sur chaque intervention de ~100 par chargement à **1 seule requête** pour l'affichage principal.
- 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)
### Classification des absences (depuis v2026.5.27) Concrètement, en v3 un chargement initial faisait :
- **Maladie/Accident** : indigo `#4338ca` - 1 fetch XML planning (`calendar_block`)
- **Congé / Congés** : cyan `#06b6d4` (suffixe `s` adaptatif) - ~40 fetches `planning_xhr_2.php` pour les lieux/contacts
- **Pompier** : rouge `#b03030` - ~40 fetches de fiches HTML pour les catégories/refs/statuts
- Badge + barre gauche colorée + dégradé fond - jusqu'à ~40 fetches de l'API timeline
- Absence récurrente Pillonel vendredi : cyan (depuis v2026.5.30)
### User et session Total : ~120 requêtes, 10+ Mo, 8 à 15 secondes selon la charge serveur.
- 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
### Admin et configuration En v4, on a découvert que le XML initial `calendar_block` contient **déjà**
- Mode admin caché : bouton ⚙ Paramètres dans popup user-badge (depuis v2026.5.25, remplace les 5 clics secrets sur le titre) dans ses attributs `attr1`/`attr2`/`attr3` le contact, le lieu et la catégorie
- Configuration persistée dans `localStorage` (`admin_config`) complète de chaque intervention, et la ref dans le textContent du nœud.
- Catégories interventions personnalisables (livraison/recup/remplacement/incident/rollout/reservation/autre) Toutes ces infos qu'on allait chercher ailleurs étaient en fait dans la toute
première réponse, ignorées par le code.
## Versionning — historique et conventions 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.
L'extension a connu **3 systèmes de versionning successifs** : **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.
| Période | Format | Exemple | **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.
| 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` |
### Pourquoi le passage à `YYYY.M.PATCH` ? Toute l'interface utilisateur est **strictement identique** à la v3 — on n'a
changé que ce qu'il y a sous le capot.
À partir de la **v2026.5.16** (21 avril 2026), l'extension est passée au versionning par année : ## Hérité des versions précédentes
- 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
⚠️ **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. - Navigation par date : ◀ ▶ et sélecteur
- Détection automatique des interventions closes (✓ vert, fond vert)
## Versions notables - Cache persistant 7 jours
- Ghosts : les interventions disparues d'EasyVista restent visibles dans la vue
### `v2026.5.37` (latest, 25 avril 2026) — Refonte vue horizontale - Refresh auto 12h et 15h
- Topbar supprimée en vue horizontale, tout passe en sidebar - Annulation coopérative (bouton "Arrêter")
- User-badge + titre + bouton "Aujourd'hui" + date/heure + sélecteur + flèches + stats dans sidebar - Thème clair/sombre
- 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 ## Installation
### Firefox 1. Décompresser le zip
Télécharger le `.xpi` signé depuis le serveur de mises à jour interne, ou drag-and-drop dans `about:addons`. 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`
### Chrome / Edge Si tu avais déjà la v3 installée, tu peux la supprimer avant — les caches des
Mode développeur : décompresser le ZIP et charger en tant qu'extension non empaquetée. deux versions sont compatibles (même format).
## Développement ## Utilisation
```bash 1. Se connecter à EasyVista dans un onglet (`itsma.etat-de-vaud.ch` ou `itsma.vd.ch`)
git clone https://gitea.netaplaid.ch/FroSteel/Planification.git 2. Cliquer sur l'icône de l'extension (depuis n'importe quel onglet)
cd Planification 3. La vue claire s'ouvre dans un nouvel onglet
# Pour packager une nouvelle version : ## Comment ça marche techniquement
# 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
```
## Licence - `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`.
[MIT License](LICENSE) — Copyright (c) 2026 Quentin Rouiller ## Limitations connues
## Auteur - 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)
**Quentin Rouiller** (QRO) - Les 8 IDs des techs sont en dur dans le code (si quelqu'un quitte/arrive dans
Canton de Vaud — Service IT 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)
+30 -100
View File
@@ -157,44 +157,19 @@ async function fetchXhr2(origin, phpsessid, actionId) {
async function fetchFicheHtml(origin, phpsessid, formLink) { async function fetchFicheHtml(origin, phpsessid, formLink) {
const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`; const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`;
console.log("[bg] fetchFicheHtml →", url.substring(0, 120)); console.log("[bg] fetchFicheHtml →", url.substring(0, 120));
const r = await evFetch(url, origin);
// v2026.5.16 : juste après une reconnexion SSO, EasyVista retourne parfois if (!r.ok) {
// une page intermédiaire tronquée (~8 Ko au lieu de ~250 Ko), le temps que const err = new Error("HTTP " + r.status);
// les cookies SSO/Kerberos se propagent. On fait jusqu'à 3 tentatives avec err.kind = classifyHttpStatus(r.status);
// 1.5s entre chaque si on détecte une taille suspecte. err.status = r.status;
const MAX_RETRIES = 3; throw err;
const RETRY_DELAY_MS = 1500;
const MIN_VALID_SIZE = 20000; // < 20 Ko = probablement page intermédiaire
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const r = await evFetch(url, origin);
if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
err.status = r.status;
throw err;
}
const html = await r.text();
console.log(`[bg] fiche status = ${r.status} | taille = ${html.length}${attempt > 1 ? ` (tentative ${attempt}/${MAX_RETRIES})` : ""}`);
// Si réponse clairement une redirection courte → login expiré, inutile de retry
if (html.length < 500) {
console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html));
return html;
}
// Si taille suspecte (< 20 Ko), probable page intermédiaire SSO : retry
if (html.length < MIN_VALID_SIZE && attempt < MAX_RETRIES) {
console.warn(`[bg] ⚠ fiche anormalement petite (${html.length} octets), retry dans ${RETRY_DELAY_MS} ms...`);
await new Promise(res => setTimeout(res, RETRY_DELAY_MS));
continue;
}
// Sinon : on retourne ce qu'on a
return html;
} }
// Ne devrait pas arriver (la boucle fait return avant) const html = await r.text();
throw new Error("fetchFicheHtml: max retries reached"); console.log("[bg] fiche status =", r.status, "| taille =", html.length);
if (html.length < 500) {
console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html));
}
return html;
} }
// v4.1.7 : API timeline EasyVista. Renvoie le JSON des actions d'une fiche, // v4.1.7 : API timeline EasyVista. Renvoie le JSON des actions d'une fiche,
@@ -400,67 +375,6 @@ function originForContext(context) {
: "https://itsma.vd.ch"; : "https://itsma.vd.ch";
} }
/**
* v2026.5.16 : surveille un onglet ouvert pour détecter si le Windows SSO
* a échoué et rediriger vers la bonne page.
*
* Quand la session portail Canton est expirée, EasyVista redirige vers
* https://portail.etat-de-vaud.ch/iamlogin/?spEntityID=...
* (page de login manuel moche). On préfère rediriger vers
* https://portail.etat-de-vaud.ch/iam/accueil/
* qui déclenche le Windows Kerberos SSO automatique.
*
* @param {number} tabId - ID de l'onglet à surveiller
*/
function watchReconnectTabForIamLogin(tabId) {
let redirected = false;
const timeoutMs = 60000; // surveille max 60s
const listener = (updatedTabId, changeInfo, tab) => {
if (updatedTabId !== tabId) return;
if (redirected) return;
const url = changeInfo.url || (tab && tab.url) || "";
if (!url) return;
// Détecter la page de login manuel
// Patterns : portail.etat-de-vaud.ch/iamlogin/ ou www.portail.vd.ch/iamlogin/
if (/\/iamlogin\//i.test(url) && /portail\./i.test(url)) {
redirected = true;
// Choisir le domaine de redirection :
// - si on voit portail.etat-de-vaud.ch → rester sur interne
// - si on voit www.portail.vd.ch → rester sur externe
let targetUrl;
if (/portail\.etat-de-vaud\.ch/i.test(url)) {
targetUrl = "https://portail.etat-de-vaud.ch/iam/accueil/";
} else {
targetUrl = "https://www.portail.vd.ch/iam/accueil/";
}
console.log(`[bg] watchReconnectTab : iamlogin détecté, redirection vers ${targetUrl}`);
chrome.tabs.update(tabId, { url: targetUrl }).catch(e => {
console.warn("[bg] watchReconnectTab : update failed", e);
});
}
};
chrome.tabs.onUpdated.addListener(listener);
// Stop la surveillance après 60s pour ne pas accumuler des listeners morts
setTimeout(() => {
try {
chrome.tabs.onUpdated.removeListener(listener);
} catch (e) {}
}, timeoutMs);
// Si l'onglet est fermé, stop aussi
const closeListener = (closedTabId) => {
if (closedTabId === tabId) {
try { chrome.tabs.onUpdated.removeListener(listener); } catch (e) {}
try { chrome.tabs.onRemoved.removeListener(closeListener); } catch (e) {}
}
};
chrome.tabs.onRemoved.addListener(closeListener);
}
// ============================================================================ // ============================================================================
// v4.2 : récupération de l'utilisateur connecté // v4.2 : récupération de l'utilisateur connecté
// ============================================================================ // ============================================================================
@@ -784,8 +698,24 @@ async function deletePlanningItem(origin, phpsessid, actionId, kind) {
console.log(`[bg] → SUCCÈS confirmé par XML <...>true</...> avec function_name=${fn}`); console.log(`[bg] → SUCCÈS confirmé par XML <...>true</...> avec function_name=${fn}`);
return { status: r.status, functionName: fn, body: trimmed }; return { status: r.status, functionName: fn, body: trimmed };
} }
console.log(`[bg] → réponse ressemble à une erreur, on tente le prochain nom`);
lastBody = body; // Détection d'échec : <X>false</X>, erreurs, html, redirect, etc.
const looksLikeError = /^<\w+>false<\/\w+>\s*$/i.test(trimmed)
|| lower.includes("error")
|| lower.includes("erreur")
|| lower.includes("unknown function")
|| lower.includes("fonction inconnue")
|| lower.includes("<html")
|| lower.includes("window.location.href");
if (looksLikeError) {
console.log(`[bg] → réponse ressemble à une erreur, on tente le prochain nom`);
lastBody = body;
continue;
}
// Pas d'erreur évidente mais pas de succès explicite non plus
// (ex: réponse vide ou "1" ou "ok"). On considère comme succès.
console.log(`[bg] → suppression probablement OK (body neutre) avec function_name=${fn}`);
return { status: r.status, functionName: fn, body: trimmed.substring(0, 200) };
} catch (err) { } catch (err) {
if (err.message === "session_expired") throw err; if (err.message === "session_expired") throw err;
console.warn(`[bg] erreur avec ${fn}:`, err); console.warn(`[bg] erreur avec ${fn}:`, err);
+4 -27
View File
@@ -1,26 +1,9 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Planification", "name": "Planification",
"version": "2026.5.20", "version": "5.0.15",
"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.", "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.",
"browser_specific_settings": { "permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"],
"gecko": {
"id": "planification@vd.ch",
"strict_min_version": "140.0",
"data_collection_permissions": {
"required": [
"none"
]
}
}
},
"permissions": [
"activeTab",
"scripting",
"storage",
"tabs",
"alarms"
],
"host_permissions": [ "host_permissions": [
"https://itsma.etat-de-vaud.ch/*", "https://itsma.etat-de-vaud.ch/*",
"https://itsma.vd.ch/*" "https://itsma.vd.ch/*"
@@ -29,9 +12,7 @@
"default_title": "Ouvrir la Planification" "default_title": "Ouvrir la Planification"
}, },
"background": { "background": {
"scripts": [ "service_worker": "background.js"
"background.js"
]
}, },
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
@@ -40,11 +21,7 @@
}, },
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": [ "resources": ["viewer.html", "viewer.js", "viewer.css"],
"viewer.html",
"viewer.js",
"viewer.css"
],
"matches": [ "matches": [
"https://itsma.etat-de-vaud.ch/*", "https://itsma.etat-de-vaud.ch/*",
"https://itsma.vd.ch/*" "https://itsma.vd.ch/*"
-76
View File
@@ -320,57 +320,8 @@ html, body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
flex-wrap: nowrap;
} }
/* v2026.5.17 : faux input date custom avec nom du jour */
.date-custom-wrapper {
position: relative;
display: inline-flex;
align-items: center;
}
.date-custom {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 10px 5px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-muted);
color: var(--text);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
user-select: none;
transition: border-color 0.15s, background 0.15s;
}
.date-custom:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
}
.date-custom:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.date-custom-icon {
font-size: 13px;
opacity: 0.7;
}
.date-input-hidden {
position: absolute;
top: 100%;
left: 0;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
/* v2026.5.17 : masquer l'ancien date-picker-day s'il traîne (compat) */
.date-picker-day { display: none; }
.btn-nav { .btn-nav {
padding: 6px 10px; padding: 6px 10px;
font-size: 13px; font-size: 13px;
@@ -1015,12 +966,6 @@ html, body {
opacity: 0; opacity: 0;
transition: opacity 0.1s, background 0.1s, color 0.1s; transition: opacity 0.1s, background 0.1s, color 0.1s;
font-family: inherit; font-family: inherit;
/* v2026.5.17 : figer largeur/hauteur pour que le changement 📋 → ✓ pendant
la copie ne fasse pas bouger le titre centré dans la grid */
min-width: 28px;
min-height: 22px;
text-align: center;
box-sizing: border-box;
} }
.intervention-v2:hover .intervention-copy { opacity: 1; } .intervention-v2:hover .intervention-copy { opacity: 1; }
.intervention-copy:hover { .intervention-copy:hover {
@@ -1992,32 +1937,11 @@ body.modal-open {
/* ───────────────────────────────────────────────────────────────────────── /* ─────────────────────────────────────────────────────────────────────────
v5.0.0 : horloge au milieu de la topbar (HH:MM, pas de secondes) v5.0.0 : horloge au milieu de la topbar (HH:MM, pas de secondes)
───────────────────────────────────────────────────────────────────────── */ ───────────────────────────────────────────────────────────────────────── */
/* v2026.5.16 : app-clock contient maintenant 2 lignes empilées :
- app-clock-date : "Mardi 21 avril 2026" (petit)
- app-clock-time : "12:34" (grand) */
.app-clock { .app-clock {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1.1;
color: var(--text);
pointer-events: none;
user-select: none;
white-space: nowrap;
}
.app-clock-date {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.3px;
text-transform: capitalize;
}
.app-clock-time {
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
+1 -8
View File
@@ -16,14 +16,7 @@
<h1 id="app-title">Planification</h1> <h1 id="app-title">Planification</h1>
<div class="date-nav"> <div class="date-nav">
<button id="nav-prev" class="btn btn-nav" title="Jour précédent" aria-label="Jour précédent"></button> <button id="nav-prev" class="btn btn-nav" title="Jour précédent" aria-label="Jour précédent"></button>
<!-- v2026.5.17 : input date custom qui affiche "Vendredi 24.04.2026" --> <input type="date" id="date-picker" class="date-input">
<div class="date-custom-wrapper">
<div id="date-custom" class="date-custom" role="button" tabindex="0" title="Choisir une date">
<span id="date-custom-label"></span>
<span class="date-custom-icon">📅</span>
</div>
<input type="date" id="date-picker" class="date-input-hidden">
</div>
<button id="nav-next" class="btn btn-nav" title="Jour suivant" aria-label="Jour suivant"></button> <button id="nav-next" class="btn btn-nav" title="Jour suivant" aria-label="Jour suivant"></button>
<button id="nav-today" class="btn btn-today" title="Aujourd'hui">Auj.</button> <button id="nav-today" class="btn btn-today" title="Aujourd'hui">Auj.</button>
</div> </div>
+56 -536
View File
@@ -242,12 +242,10 @@ async function init() {
initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar
initAdminMenu(); // v5.0.0 : menu admin caché (5 clics sur titre) initAdminMenu(); // v5.0.0 : menu admin caché (5 clics sur titre)
initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s) initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s)
initDateCustomPicker(); // v2026.5.17 : faux input date avec jour
// Initialiser la date = aujourd'hui // Initialiser la date = aujourd'hui
state.currentDate = todayISO(); state.currentDate = todayISO();
document.getElementById("date-picker").value = state.currentDate; document.getElementById("date-picker").value = state.currentDate;
updateDatePickerDayLabel(state.currentDate); // v2026.5.16 : label "Mardi"
// v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant) // v5.0.11 : détecter le contexte réseau en arrière-plan (non bloquant)
detectNetworkContextAsync(); detectNetworkContextAsync();
@@ -401,21 +399,7 @@ function toggleUserNamePopup() {
return; return;
} }
if (!state.currentUser || !state.currentUser.name) return; if (!state.currentUser || !state.currentUser.name) return;
popup.textContent = state.currentUser.name;
// v2026.5.17 : afficher aussi le temps restant de la session (MM:SS) avec
// une couleur qui dépend du seuil (vert/jaune/rouge).
popup.innerHTML = "";
const nameEl = document.createElement("div");
nameEl.className = "user-name-popup-name";
nameEl.textContent = state.currentUser.name;
popup.appendChild(nameEl);
const sessEl = document.createElement("div");
sessEl.className = "user-name-popup-session";
sessEl.id = "user-name-popup-session";
_renderUserPopupSessionLine(sessEl);
popup.appendChild(sessEl);
popup.classList.remove("hidden"); popup.classList.remove("hidden");
badge.classList.add("open"); badge.classList.add("open");
// Positionne juste en dessous de la pastille // Positionne juste en dessous de la pastille
@@ -430,38 +414,6 @@ function hideUserNamePopup() {
if (badge) badge.classList.remove("open"); if (badge) badge.classList.remove("open");
} }
// v2026.5.17 : remplit la ligne "Session : MM:SS" avec couleur selon seuil.
// Recalcule à chaque appel — appelée aussi par le tick session pour rafraîchir.
function _renderUserPopupSessionLine(el) {
if (!el) return;
const remainingMs = _getSessionRemainingMs();
if (remainingMs == null) {
el.textContent = "Session : —";
el.className = "user-name-popup-session";
return;
}
const mins = Math.floor(remainingMs / 60000);
const secs = Math.floor((remainingMs % 60000) / 1000);
const txt = `Session : ${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
el.textContent = txt;
el.className = "user-name-popup-session";
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) {
el.classList.add("session-critical");
} else if (remainingMs <= SESSION_WARN_THRESHOLD_MS) {
el.classList.add("session-warn");
} else {
el.classList.add("session-ok");
}
}
// v2026.5.17 : récupère en ms le temps restant avant expiration de la session.
// Retourne null si on ne connaît pas encore (pas de session ouverte).
function _getSessionRemainingMs() {
if (!state.sessionExpireAt) return null;
const remaining = state.sessionExpireAt - Date.now();
return remaining > 0 ? remaining : 0;
}
// ============================================================================ // ============================================================================
// Thème clair/sombre // Thème clair/sombre
// ============================================================================ // ============================================================================
@@ -565,65 +517,18 @@ function bindTopbar() {
} }
} }
}); });
// v2026.5.20 : nouveau comportement de la touche Échap
// - Appui court : ferme uniquement le popup SOUS la souris (normal ou
// minimisé). Si la souris n'est sur aucun popup, ne fait rien.
// Ferme aussi le popup user-badge et la grande bulle anchored.
// - Maintenu ≥ 3 secondes : ferme TOUS les popups flottants, mais garde
// les pastilles dock (popups "réduits" en bas).
let _escHoldTimer = null;
let _escHoldTriggered = false;
const ESC_HOLD_MS = 3000;
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return; if (e.key === "Escape") {
// keydown peut se répéter si la touche est maintenue ; on ignore les répétitions. hideUserNamePopup();
if (e.repeat) return; // v4.2.4 : Échap ferme aussi la grande bulle anchored
// Armer le timer "maintenu 3s" const tip = tooltipEl();
_escHoldTriggered = false; if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
if (_escHoldTimer) clearTimeout(_escHoldTimer); hideTooltip({ force: true });
_escHoldTimer = setTimeout(() => { }
_escHoldTriggered = true; // v4.3.0 : Échap ferme TOUS les popups épinglés (le user veut tout fermer)
_escHoldTimer = null; if (typeof closeAllPinnedPopups === "function") {
// Fermer TOUS les popups flottants (normaux + minimisés) mais pas les dockés closeAllPinnedPopups();
document.querySelectorAll(".pinned-popup:not(.pinned-popup-reduced)").forEach(p => {
try { p.remove(); } catch (err) {}
});
// Nettoyer la liste
for (let i = pinnedPopups.length - 1; i >= 0; i--) {
if (!document.body.contains(pinnedPopups[i].el)) {
pinnedPopups.splice(i, 1);
}
} }
_ensureDockCloseAllBtn();
}, ESC_HOLD_MS);
});
document.addEventListener("keyup", (e) => {
if (e.key !== "Escape") return;
if (_escHoldTimer) {
clearTimeout(_escHoldTimer);
_escHoldTimer = null;
}
if (_escHoldTriggered) {
// On a déjà fait l'action "maintenu", ne rien faire de plus
_escHoldTriggered = false;
return;
}
// Appui court : fermer le popup sous la souris si applicable
hideUserNamePopup();
const tip = tooltipEl();
if (tip && tip.dataset.mode === "anchored" && tip.classList.contains("visible")) {
hideTooltip({ force: true });
}
// Quel popup est sous la souris ? Utiliser :hover pour détecter
const hovered = document.querySelector(".pinned-popup:hover");
if (hovered && !hovered.classList.contains("pinned-popup-reduced")) {
// Retirer aussi de pinnedPopups
const idx = pinnedPopups.findIndex(p => p.el === hovered);
if (idx >= 0) pinnedPopups.splice(idx, 1);
hovered.remove();
_ensureDockCloseAllBtn();
} }
}); });
@@ -894,37 +799,11 @@ function initAppFooter() {
function initAppClock() { function initAppClock() {
const el = document.getElementById("app-clock"); const el = document.getElementById("app-clock");
if (!el) return; if (!el) return;
const dateEl = document.getElementById("app-clock-date");
const timeEl = document.getElementById("app-clock-time");
// v2026.5.16 : format "Mardi 21 avril 2026"
const JOURS = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
const MOIS = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre"
];
let lastDateStr = "";
const tick = () => { const tick = () => {
const d = new Date(); const d = new Date();
const h = String(d.getHours()).padStart(2, "0"); const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0"); const m = String(d.getMinutes()).padStart(2, "0");
const timeStr = `${h}:${m}`; el.textContent = `${h}:${m}`;
if (timeEl) timeEl.textContent = timeStr;
else el.textContent = timeStr; // fallback si ancien markup
// Date complète : actualisée seulement si elle a changé (évite reflow inutile)
if (dateEl) {
const jour = JOURS[d.getDay()];
const num = d.getDate();
const mois = MOIS[d.getMonth()];
const annee = d.getFullYear();
const dateStr = `${jour} ${num} ${mois} ${annee}`;
if (dateStr !== lastDateStr) {
dateEl.textContent = dateStr;
lastDateStr = dateStr;
}
}
// v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now" // v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now"
updateNowLine(); updateNowLine();
}; };
@@ -933,53 +812,6 @@ function initAppClock() {
setInterval(tick, 30 * 1000); setInterval(tick, 30 * 1000);
} }
// v2026.5.17 : met à jour le faux input date custom (ex: "Vendredi 24.04.2026")
// Remplace l'ancien updateDatePickerDayLabel. L'input date natif reste présent
// mais caché, et son onChange continue de déclencher le chargement.
const DAY_NAMES_FULL = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
function updateDatePickerDayLabel(isoDate) {
const el = document.getElementById("date-custom-label");
if (!el) return;
if (!isoDate) { el.textContent = ""; return; }
try {
const d = isoToDate(isoDate);
const day = DAY_NAMES_FULL[d.getDay()];
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
el.textContent = `${day} ${dd}.${mm}.${yyyy}`;
} catch (e) {
el.textContent = "";
}
}
// v2026.5.17 : brancher le faux input date — clic dessus ouvre le vrai input
// caché pour choisir une date.
function initDateCustomPicker() {
const custom = document.getElementById("date-custom");
const picker = document.getElementById("date-picker");
if (!custom || !picker) return;
const openPicker = () => {
try {
if (typeof picker.showPicker === "function") {
picker.showPicker();
} else {
picker.focus();
picker.click();
}
} catch (e) {
picker.focus();
}
};
custom.addEventListener("click", openPicker);
custom.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openPicker();
}
});
}
// v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible // v5.0.0 : ligne verticale rouge "heure actuelle" sur la timeline, visible
// UNIQUEMENT quand on affiche aujourd'hui. Appelée à chaque tick de l'horloge // UNIQUEMENT quand on affiche aujourd'hui. Appelée à chaque tick de l'horloge
// + après chaque render (cf renderFromData). // + après chaque render (cf renderFromData).
@@ -1169,103 +1001,6 @@ function updateSessionIndicator() {
} }
}; };
} }
// v2026.5.17 : si le popup user-badge est ouvert, rafraîchir la ligne "Session : MM:SS"
const sessLineInPopup = document.getElementById("user-name-popup-session");
if (sessLineInPopup) _renderUserPopupSessionLine(sessLineInPopup);
// v2026.5.17 : popup d'alerte "glissante" depuis le haut gauche
// - à 5 min : alerte standard (si pas encore affichée ni "plus tard")
// - à 2 min : alerte urgente (si pas encore affichée)
_handleSessionSlideAlerts(remainingMs);
}
/**
* v2026.5.17 : gère les 2 alertes popup glissant depuis le haut gauche.
* - Première alerte à 5 min (SESSION_WARN_THRESHOLD_MS). Reste affichée jusqu'à
* action manuelle (Prolonger ou Plus tard).
* - Si "Plus tard", une 2e alerte plus urgente réapparait à 2 min
* (SESSION_CRITICAL_THRESHOLD_MS).
*/
function _handleSessionSlideAlerts(remainingMs) {
if (remainingMs == null) return;
// Alerte à 5 min
if (remainingMs <= SESSION_WARN_THRESHOLD_MS
&& remainingMs > SESSION_CRITICAL_THRESHOLD_MS
&& !state._slideAlert5minShown) {
state._slideAlert5minShown = true;
_showSessionSlideAlert({ urgent: false });
}
// Alerte à 2 min (si déjà "Plus tard" sur l'alerte 5 min OU alerte 5 min jamais affichée)
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS
&& !state._slideAlert2minShown) {
state._slideAlert2minShown = true;
// Cacher éventuellement l'ancienne alerte pour ré-afficher la nouvelle
_hideSessionSlideAlert();
_showSessionSlideAlert({ urgent: true });
}
}
function _showSessionSlideAlert({ urgent }) {
// Retirer l'ancienne si elle existe
_hideSessionSlideAlert();
const el = document.createElement("div");
el.id = "session-slide-alert";
el.className = "session-slide-alert" + (urgent ? " urgent" : "");
const title = urgent ? "⚠ Session expire dans 2 minutes !" : "⏱ Session expire dans 5 minutes";
el.innerHTML = `
<div class="session-slide-alert-title">${title}</div>
<div class="session-slide-alert-actions">
<button type="button" class="session-slide-alert-extend">🔄 Prolonger</button>
<button type="button" class="session-slide-alert-later">Plus tard</button>
</div>
`;
document.body.appendChild(el);
// Déclenche l'animation de slide-in (petite tempo pour que la transition parte)
requestAnimationFrame(() => el.classList.add("visible"));
// Action "Prolonger"
el.querySelector(".session-slide-alert-extend").addEventListener("click", async () => {
const extendBtn = el.querySelector(".session-slide-alert-extend");
extendBtn.disabled = true;
extendBtn.textContent = "…";
try {
const resp = await sendMessage({ type: "extendSession" });
if (resp && resp.ok && typeof resp.remainingMs === "number") {
state.sessionExpireAt = Date.now() + resp.remainingMs;
state.sessionPingDone = false;
state._criticalModalShown = false;
// Reset des flags d'alerte pour le prochain cycle
state._slideAlert5minShown = false;
state._slideAlert2minShown = false;
showToast("Session prolongée", "30 minutes de plus");
updateSessionIndicator();
_hideSessionSlideAlert();
} else {
throw new Error((resp && resp.error) || "erreur inconnue");
}
} catch (err) {
extendBtn.disabled = false;
extendBtn.textContent = "🔄 Prolonger";
}
});
// Action "Plus tard"
el.querySelector(".session-slide-alert-later").addEventListener("click", () => {
_hideSessionSlideAlert();
// Si c'est l'alerte 5 min qu'on dismissa, l'alerte 2 min reviendra
// automatiquement (state._slideAlert2minShown toujours false).
});
}
function _hideSessionSlideAlert() {
const el = document.getElementById("session-slide-alert");
if (!el) return;
el.classList.remove("visible");
setTimeout(() => { try { el.remove(); } catch (e) {} }, 250);
} }
/** /**
@@ -2411,19 +2146,15 @@ async function writeCache(isoDate, data) {
// ============================================================================ // ============================================================================
async function loadForDate(isoDate, opts = {}) { async function loadForDate(isoDate, opts = {}) {
// v4.3.1 : changer de date fermait tous les popups épinglés. // v4.3.1 : changer de date ferme tous les popups épinglés. Ils réfèrent à
// v2026.5.17 : les popups épinglés restent maintenant ouverts entre dates, // des interventions du jour courant, ils n'ont aucun sens sur un autre jour.
// avec les données qu'ils avaient au moment de l'épinglage.
// v2026.5.18 : au changement de date, on réduit tous les popups épinglés
// dans la taskbar du bas (l'user peut les re-agrandir au clic).
const previousDate = state.currentDate; const previousDate = state.currentDate;
if (previousDate && previousDate !== isoDate) { if (previousDate && previousDate !== isoDate && typeof closeAllPinnedPopups === "function") {
_reduceAllPinnedPopups(); closeAllPinnedPopups();
} }
state.currentDate = isoDate; state.currentDate = isoDate;
document.getElementById("date-picker").value = isoDate; document.getElementById("date-picker").value = isoDate;
updateDatePickerDayLabel(isoDate); // v2026.5.16 : label "Mardi" à côté
if (!state.session) { if (!state.session) {
// v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session" // v4.2.5 : afficher le cache + bannière, plutôt que l'écran "session"
@@ -5635,10 +5366,7 @@ function splitOneContact(raw) {
// Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en // Prénom 41XXXXXXXXX → extraire 41XXXXXXXXX puis reformater en
// +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour // +41 XX XXX XX XX). On exige exactement 11 chiffres collés pour
// éviter de matcher des codes postaux ou autres nombres. // éviter de matcher des codes postaux ou autres nombres.
// v2026.5.16 : ne PAS matcher si le numéro est précédé d'une lettre ou const rxLong = /(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g;
// d'un underscore (identifiants style XXXX_NNNNNNNN, ABC123456,
// SERIAL_0123456789). On ajoute un lookbehind négatif (?<![A-Za-z_]).
const rxLong = /(?<![A-Za-z_])(\+41\s?\d(?:[\d\s.\-]*\d)?|\+33\s?\d(?:[\d\s.\-]*\d)?|0\d(?:[\d\s.\-]*\d)?|(?<!\d)41\d{9}(?!\d)|(?<!\d)33\d{9}(?!\d))/g;
// SHORT : numéro interne court (5 chiffres). // SHORT : numéro interne court (5 chiffres).
// - v4.1.20 : accepte "12345Texte" (pas de séparateur après) // - v4.1.20 : accepte "12345Texte" (pas de séparateur après)
// - v4.2.3 : accepte aussi les formats AVEC ESPACES au sein du numéro, // - v4.2.3 : accepte aussi les formats AVEC ESPACES au sein du numéro,
@@ -5687,26 +5415,6 @@ function splitOneContact(raw) {
} }
name = cleanContactName(name); name = cleanContactName(name);
// v2026.5.16 : dernier garde-fou — rejeter les "noms" qui ressemblent
// à des fragments de description technique plutôt qu'à des vrais contacts.
// Exemples rejetés :
// - "1x" (quantité isolée)
// - "1x pc" (quantité + type matériel)
// - "pc XNNNNNN" (type + numéro de série)
// - "XXXX_NNNNNNNN" (identifiant matériel)
// Critères d'un vrai nom : contient au moins un mot qui commence par une
// majuscule ET n'est pas juste un identifiant technique.
if (name) {
const looksLikeIdentifier = /^[A-Z]{2,}[_\-]\d+$/.test(name); // XXXX_NNNNNNNN
const startsWithQuantity = /^\d+x(\s|$)/i.test(name); // "1x" ou "1x pc"
const noCapitalWord = !/\b[A-ZÉÈÀÂÎÔÛÇ][a-zéèàâîôûç]+/.test(name); // aucun mot "Xxxxx"
const hasOnlyTechTokens = /^(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout)(\s+(\d+x|pc|mac|t[ée]l[ée]phone|ecran|docking|rollout|[A-Z]\d+))*\s*$/i.test(name);
if (looksLikeIdentifier || startsWithQuantity || hasOnlyTechTokens || (noCapitalWord && !phone)) {
name = null;
}
}
return { name, phone }; return { name, phone };
} }
@@ -5805,34 +5513,22 @@ function splitLieu(raw) {
// Retirer un / final (avec ou sans espaces) // Retirer un / final (avec ou sans espaces)
s = s.replace(/\s*\/\s*$/, "").trim(); s = s.replace(/\s*\/\s*$/, "").trim();
if (!s) return { ville: null, adresse: null }; if (!s) return { ville: null, adresse: null };
const idx = s.indexOf("/");
// v2026.5.16 : le format EasyVista peut avoir jusqu'à 3 parties séparées
// par "/" : VILLE / ADRESSE / PRÉCISIONS (étage, bureau, indications).
// Exemple : "LAUSANNE / Av. de Beaulieu 19 / 4eme en face de l'ascenseur"
// On ne garde que VILLE + ADRESSE. Les précisions (3e partie et suivantes)
// sont strippées — elles alourdissent la carte et sont disponibles dans
// le tooltip détaillé.
const parts = s.split("/").map(p => p.trim()).filter(Boolean);
let ville, adresse; let ville, adresse;
if (parts.length === 0) { if (idx < 0) {
return { ville: null, adresse: null };
} else if (parts.length === 1) {
// Pas de slash : tout est l'adresse
ville = null; ville = null;
adresse = parts[0]; adresse = s;
} else { } else {
// 2+ parties : ville = 1ère, adresse = 2e, on ignore le reste ville = s.substring(0, idx).trim();
ville = parts[0]; adresse = s.substring(idx + 1).trim();
adresse = parts[1];
} }
// Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue, // Capitaliser la 1ère lettre des mots de voie (Rue, Chemin, Route, Avenue,
// Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd) // Boulevard, Place, Quai, Impasse + abréviations Av., Ch., Rte, Bd)
if (adresse) { if (adresse) {
adresse = adresse.replace( adresse = adresse.replace(
/\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi, /\b(rue|chemin|route|avenue|boulevard|place|quai|impasse|ruelle|allée|allee|passage|sentier|av\.?|ch\.?|rte\.?|bd\.?)\b/gi,
(match) => { (match) => {
// Conserver la casse existante si déjà majuscule, sinon capitaliser
if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match; if (/^[A-ZÉÈÀÂÎÔÛÇ]/.test(match)) return match;
return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase(); return match.charAt(0).toUpperCase() + match.slice(1).toLowerCase();
} }
@@ -6141,11 +5837,6 @@ let bulleState = {
}; };
function showTooltip(e, iv, rowEl) { 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
// l'ouverture d'un nouveau tooltip par-dessus ce qu'on est en train de bouger.
if (state._popupDragging) return;
// v4.1.15 : si la bulle est épinglée sur une autre iv, on NE REMPLACE PAS // v4.1.15 : si la bulle est épinglée sur une autre iv, on NE REMPLACE PAS
// son contenu (l'user veut garder la fiche épinglée même en survolant // son contenu (l'user veut garder la fiche épinglée même en survolant
// d'autres cartes). // d'autres cartes).
@@ -6222,8 +5913,7 @@ function hideTooltip(opts = {}) {
state.currentTooltipIv = null; state.currentTooltipIv = null;
currentTooltipPos = null; currentTooltipPos = null;
tooltipPositionMode = null; // re-détecter à la prochaine ouverture tooltipPositionMode = null; // re-détecter à la prochaine ouverture
}, 1000); // v2026.5.17 : délai 1s au lieu de 120ms pour laisser le temps }, 120);
// à l'user d'atteindre le popup depuis la carte
} }
// v4.2 : détecte si l'utilisateur a une sélection de texte active dans la bulle. // v4.2 : détecte si l'utilisateur a une sélection de texte active dans la bulle.
@@ -6357,51 +6047,9 @@ function positionTooltipAnchored(rowEl) {
} }
if (y < 4) y = 4; if (y < 4) y = 4;
// v2026.5.17 : éviter le chevauchement avec les popups épinglés existants.
// On teste la position candidate, et si elle chevauche un popup épinglé,
// on essaie d'autres candidats (gauche de la carte, au-dessous, au-dessus).
const tipW = tipRect.width || 320;
const tipH = tipRect.height || 200;
const pinnedRects = _getPinnedPopupsViewportRects();
if (pinnedRects.length) {
const candidates = [
{ x, y, label: "right" },
{ x: rowRect.left - tipW - pad, y: rowRect.top, label: "left" },
{ x: rowRect.left, y: rowRect.bottom + pad, label: "below" },
{ x: rowRect.left, y: rowRect.top - tipH - pad, label: "above" }
];
for (const c of candidates) {
// Borne dans le viewport
if (c.x < 4) c.x = 4;
if (c.x + tipW > window.innerWidth - 8) c.x = window.innerWidth - tipW - 8;
if (c.y < 4) c.y = 4;
if (c.y + tipH > window.innerHeight - 8) c.y = window.innerHeight - tipH - 8;
const testRect = { left: c.x, top: c.y, right: c.x + tipW, bottom: c.y + tipH };
const overlaps = pinnedRects.some(pr => _rectsOverlap(testRect, pr));
if (!overlaps) {
x = c.x; y = c.y;
break;
}
}
}
setTooltipViewportPosition(x, y); setTooltipViewportPosition(x, y);
} }
/**
* v2026.5.17 : retourne les rectangles (en coords viewport) de tous les popups
* actuellement épinglés et visibles (non réduits). Utilisé pour anti-chevauchement.
*/
function _getPinnedPopupsViewportRects() {
const rects = [];
document.querySelectorAll(".pinned-popup").forEach(p => {
if (p.classList.contains("pinned-popup-reduced")) return; // docké, pas à l'écran
const r = p.getBoundingClientRect();
if (r.width > 0 && r.height > 0) rects.push(r);
});
return rects;
}
// ============================================================================ // ============================================================================
// v4.3.0 : système de popups épinglés détachés // v4.3.0 : système de popups épinglés détachés
// ============================================================================ // ============================================================================
@@ -6446,41 +6094,31 @@ function _rectsOverlap(a, b) {
function _findFreePopupPosition(rowEl, w, h) { function _findFreePopupPosition(rowEl, w, h) {
const pad = 14; const pad = 14;
const rowRect = rowEl.getBoundingClientRect(); const rowRect = rowEl.getBoundingClientRect();
// v2026.5.20 : utiliser la safe area (en dessous topbar, au-dessus dock) const viewportW = window.innerWidth;
const safe = _getPopupSafeArea(); const viewportH = window.innerHeight;
// 4 candidats d'abord, autour de la row source (en coords viewport) // 4 candidats, en coords viewport
const candidates = [ const candidates = [
{ x: rowRect.right + pad, y: rowRect.top, name: "droite" }, // Droite
{ x: rowRect.left - w - pad, y: rowRect.top, name: "gauche" }, { x: rowRect.right + pad, y: rowRect.top, name: "droite" },
{ x: rowRect.left, y: rowRect.bottom + pad, name: "dessous" }, // Gauche
{ x: rowRect.left, y: rowRect.top - h - pad, name: "dessus" } { 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" }
]; ];
// v2026.5.20 : ajouter une grille de positions de fallback couvrant toute // Pour chaque candidat, clamper dans le viewport (marge 8px) et convertir
// la safe area (pas de 60px × 60px) — garantit qu'on trouve ~toujours une // en coord document, puis tester le chevauchement
// place, sauf si vraiment trop de popups actifs.
const availW = safe.right - safe.left;
const availH = safe.bottom - safe.top;
if (availW > w + 20 && availH > h + 20) {
for (let y = safe.top; y + h <= safe.bottom; y += 60) {
for (let x = safe.left; x + w <= safe.right; x += 60) {
candidates.push({ x, y, name: "grid" });
}
}
}
// Tester chaque candidat dans l'ordre
for (const c of candidates) { for (const c of candidates) {
let x = c.x, y = c.y; let x = c.x, y = c.y;
// Clamp dans la safe area // Clamp horizontal dans le viewport
if (x < safe.left) x = safe.left; if (x < 4) x = 4;
if (x + w > safe.right) x = safe.right - w; if (x + w > viewportW - 8) x = viewportW - 8 - w;
if (x < safe.left) continue; // popup plus large que safe area // Clamp vertical dans le viewport
if (y < safe.top) y = safe.top; if (y < 4) y = 4;
if (y + h > safe.bottom) y = safe.bottom - h; if (y + h > viewportH - 8) y = viewportH - 8 - h;
if (y < safe.top) continue;
// Si, après clamp, la popup chevaucherait la ligne source elle-même, // 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). // on ignore ce candidat (on préfère une direction qui la laisse visible).
const rowRectClamped = { const rowRectClamped = {
@@ -6488,12 +6126,9 @@ function _findFreePopupPosition(rowEl, w, h) {
right: rowRect.right, bottom: rowRect.bottom right: rowRect.right, bottom: rowRect.bottom
}; };
const candRect = { left: x, top: y, right: x + w, bottom: y + h }; const candRect = { left: x, top: y, right: x + w, bottom: y + h };
// v2026.5.20 : pour les 4 candidats principaux, on refuse de chevaucher if (_rectsOverlap(candRect, rowRectClamped)) continue;
// la row ; pour les candidats "grid" de fallback, on l'accepte
// (on veut une place à tout prix).
if (c.name !== "grid" && _rectsOverlap(candRect, rowRectClamped)) continue;
// Test chevauchement avec les popups déjà épinglés (coords document) // Test chevauchement avec les popups déjà épinglés
const docRect = { const docRect = {
left: _viewportToDocumentX(x), left: _viewportToDocumentX(x),
top: _viewportToDocumentY(y), top: _viewportToDocumentY(y),
@@ -6502,14 +6137,13 @@ function _findFreePopupPosition(rowEl, w, h) {
}; };
let overlapsOther = false; let overlapsOther = false;
for (const p of pinnedPopups) { for (const p of pinnedPopups) {
// Ne pas comparer avec un popup qui est dans le dock (réduit)
if (p.el && p.el.classList && p.el.classList.contains("pinned-popup-reduced")) continue;
if (_rectsOverlap(docRect, p.rect)) { if (_rectsOverlap(docRect, p.rect)) {
overlapsOther = true; overlapsOther = true;
break; break;
} }
} }
if (!overlapsOther) { if (!overlapsOther) {
// Position libre trouvée
return { return {
viewportX: x, viewportY: y, viewportX: x, viewportY: y,
docX: docRect.left, docY: docRect.top, docX: docRect.left, docY: docRect.top,
@@ -6517,30 +6151,6 @@ function _findFreePopupPosition(rowEl, w, h) {
}; };
} }
} }
// v2026.5.20 : ultime fallback — accepter de chevaucher mais décaler
// un peu par rapport au 1er popup épinglé existant. Évite complètement
// le "Pas de place" injuste.
if (pinnedPopups.length > 0) {
const last = pinnedPopups[pinnedPopups.length - 1];
let x = (last.rect.left - (window.scrollX || 0)) + 30;
let y = (last.rect.top - (window.scrollY || 0)) + 30;
if (x + w > safe.right) x = safe.right - w;
if (y + h > safe.bottom) y = safe.bottom - h;
if (x < safe.left) x = safe.left;
if (y < safe.top) y = safe.top;
const docRect = {
left: _viewportToDocumentX(x),
top: _viewportToDocumentY(y),
right: _viewportToDocumentX(x + w),
bottom: _viewportToDocumentY(y + h)
};
return {
viewportX: x, viewportY: y,
docX: docRect.left, docY: docRect.top,
rect: docRect
};
}
return null; return null;
} }
@@ -6554,34 +6164,6 @@ function pinTooltip() {
if (!srcEl) return; if (!srcEl) return;
const iv = state.currentTooltipIv; const iv = state.currentTooltipIv;
// v2026.5.21 : unicité actionId + date. Si un popup pour la même ref
// ET la même date est déjà épinglé, on le supprime et on re-crée un nouveau
// (user a choisi ce comportement : "tu supprime le popup actuellement
// épinglé et tu répingle la nouvelle fenêtre").
const currentDate = state.currentDate || "";
const existingKey = (iv.actionId || "") + "|" + currentDate;
for (let i = pinnedPopups.length - 1; i >= 0; i--) {
const p = pinnedPopups[i];
if (!p || !p.el) continue;
const aid = p.el.dataset.actionId || "";
const d = p.el.dataset.originDate || "";
if (aid + "|" + d === existingKey) {
// Retirer l'ancien (popup + pastille dock éventuelle)
if (p.el._linkedPill) {
try { p.el._linkedPill.remove(); } catch (e) {}
}
try { p.el.remove(); } catch (e) {}
pinnedPopups.splice(i, 1);
}
}
// Nettoyer un éventuel dock devenu vide
const dockEl = document.getElementById("pinned-popups-dock");
if (dockEl && dockEl.querySelectorAll(".pinned-popup-dock-pill").length === 0) {
dockEl.classList.remove("visible");
const closeAllBtn = document.getElementById("pinned-popups-close-all");
if (closeAllBtn) closeAllBtn.remove();
}
// Chercher la ligne source (row iv-v2) // Chercher la ligne source (row iv-v2)
let rowEl = null; let rowEl = null;
if (iv.actionId) { if (iv.actionId) {
@@ -6598,79 +6180,17 @@ function pinTooltip() {
popup.dataset.actionId = iv.actionId || ""; popup.dataset.actionId = iv.actionId || "";
popup.innerHTML = srcEl.innerHTML; popup.innerHTML = srcEl.innerHTML;
// v2026.5.18 : mémoriser la ref et la couleur pour le dock (pastille avec // Ajouter un bouton × de fermeture (en plus du 📌)
// couleur de catégorie + texte ref) const closeBtn = document.createElement("button");
popup.dataset.ref = iv.ref || ""; closeBtn.type = "button";
popup.dataset.colorKey = (typeof deriveColorKey === "function" ? deriveColorKey(iv) : "autre") || "autre"; closeBtn.className = "pinned-popup-close";
closeBtn.innerHTML = "×";
// v2026.5.19 : mémoriser aussi la date pour l'afficher sur la pastille dock closeBtn.title = "Désépingler (reste visible tant que la souris est dessus)";
popup.dataset.originDate = state.currentDate || ""; closeBtn.addEventListener("click", (e) => {
// v2026.5.17 : masquer l'icône 📌 du contenu cloné (redondante car le
// popup a sa propre topbar avec le bouton "désépingler" 📍 explicite)
const oldPin = popup.querySelector('.tooltip-pinbtn[data-action="pin"]');
if (oldPin) oldPin.remove();
// v2026.5.17 : topbar avec 3 boutons pour un popup épinglé :
// v2026.5.18 : swap des actions — _ réduit dans le dock, ▭ minimise flottant
// _ = Réduire (docké dans la taskbar du bas)
// ▭ = Minimiser (popup reste flottant mais compact, juste la ref)
// 📍 = Désépingler (l'icône d'épingle "plantée" ; clic = retire l'épingle)
const topbar = document.createElement("div");
topbar.className = "pinned-popup-topbar";
// Bouton Réduire (icône _ )
const reduceBtn = document.createElement("button");
reduceBtn.type = "button";
reduceBtn.className = "pinned-popup-btn pinned-popup-reduce";
reduceBtn.innerHTML = "_";
reduceBtn.title = "Réduire (docké en bas de l'écran)";
reduceBtn.addEventListener("click", (e) => {
e.stopPropagation();
_reducePinnedPopup(popup);
});
topbar.appendChild(reduceBtn);
// Bouton Minimiser (icône ▭ )
const minBtn = document.createElement("button");
minBtn.type = "button";
minBtn.className = "pinned-popup-btn pinned-popup-minimize";
minBtn.innerHTML = "▭";
minBtn.title = "Minimiser (reste flottant mais compact)";
minBtn.addEventListener("click", (e) => {
e.stopPropagation();
_minimizePinnedPopup(popup);
});
topbar.appendChild(minBtn);
// v2026.5.19 : Bouton Actualiser (même icône SVG que le tooltip standard)
// Re-fetch la fiche de l'intervention pour mettre à jour les infos (statut,
// commentaires, action text) sans recharger le planning entier.
const refreshBtn = document.createElement("button");
refreshBtn.type = "button";
refreshBtn.className = "pinned-popup-btn pinned-popup-refresh";
refreshBtn.innerHTML = '<svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M2 8a6 6 0 1 0 1.76-4.24M2 3v3h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
refreshBtn.title = "Actualiser les informations de cette intervention";
refreshBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (refreshBtn.classList.contains("spinning")) return;
refreshBtn.classList.add("spinning");
try {
await _refreshPinnedPopupIv(popup, iv);
} finally {
setTimeout(() => refreshBtn.classList.remove("spinning"), 300);
}
});
topbar.appendChild(refreshBtn);
// Bouton Désépingler (icône épingle plantée)
const unpinBtn = document.createElement("button");
unpinBtn.type = "button";
unpinBtn.className = "pinned-popup-btn pinned-popup-unpin";
unpinBtn.innerHTML = "📍";
unpinBtn.title = "Désépingler (se ferme quand la souris sort)";
unpinBtn.addEventListener("click", (e) => {
e.stopPropagation(); 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); _softUnpinPopup(popup);
}); });
popup.appendChild(closeBtn); popup.appendChild(closeBtn);