Compare commits

..

6 Commits

Author SHA1 Message Date
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 123 additions and 1480 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)
- **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)
- **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.
## Fonctionnalités principales
## Nouveautés v4.1
### 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
- **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.
### 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`)
## Nouveautés v4
### 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)
**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.
### 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)
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
### 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
Total : ~120 requêtes, 10+ Mo, 8 à 15 secondes selon la charge serveur.
### 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)
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.
## 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 |
|---|---|---|
| 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` |
**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.
### 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 :
- 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
## Hérité des versions précédentes
⚠️ **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)
- 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
## Installation
### Firefox
Télécharger le `.xpi` signé depuis le serveur de mises à jour interne, ou drag-and-drop dans `about:addons`.
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`
### Chrome / Edge
Mode développeur : décompresser le ZIP et charger en tant qu'extension non empaquetée.
Si tu avais déjà la v3 installée, tu peux la supprimer avant — les caches des
deux versions sont compatibles (même format).
## Développement
## Utilisation
```bash
git clone https://gitea.netaplaid.ch/FroSteel/Planification.git
cd Planification
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
# 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
```
## Comment ça marche techniquement
## 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
**Quentin Rouiller** (QRO)
Canton de Vaud — Service IT
- 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)
+11 -298
View File
@@ -85,7 +85,7 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
`&day_start_hour=8` +
`&day_end_hour=19`;
console.log("[bg] fetchPlanningXml →", url.substring(0, 140));
const r = await evFetch(url, origin);
const r = await fetch(url, { credentials: "include" });
console.log("[bg] status =", r.status);
if (!r.ok) {
// v4.2 : classifier l'erreur HTTP pour que le viewer affiche le bon
@@ -100,32 +100,6 @@ async function fetchPlanningXml(origin, phpsessid, unixDate) {
return xml;
}
/**
* v5.0.9 : wrapper autour de fetch() qui ajoute systématiquement les
* headers de sécurité attendus par EasyVista (Referer, Sec-Fetch-Site,
* X-Requested-With). Sans ces headers, EV renvoie soit un <script> de
* redirection (CSRF check), soit une page de login, même avec une session
* valide.
*
* Observé dans les captures réseau du navigateur :
* Referer: https://itsma.etat-de-vaud.ch/index.php?eventName=HelpDesk_PlanningItem
* Sec-Fetch-Site: same-origin
* X-Requested-With: XMLHttpRequest (parfois)
*
* @param {string} url - URL complète à fetcher
* @param {string} origin - origine EasyVista (pour construire le Referer)
* @param {object} [opts] - options fetch (method, body, headers supplémentaires)
*/
async function evFetch(url, origin, opts = {}) {
const defaultHeaders = {
"Referer": `${origin}/index.php?eventName=HelpDesk_PlanningItem`,
"X-Requested-With": "XMLHttpRequest"
};
const headers = Object.assign({}, defaultHeaders, opts.headers || {});
const fetchOpts = Object.assign({ credentials: "include" }, opts, { headers });
return await fetch(url, fetchOpts);
}
/**
* v4.2 : classifie un statut HTTP comme "session_expired" ou "ev_unreachable".
* - 401, 403, 404 → session_expired (EV renvoie souvent 404 au lieu de rediriger
@@ -144,7 +118,7 @@ function classifyHttpStatus(status) {
*/
async function fetchXhr2(origin, phpsessid, actionId) {
const url = `${origin}/planning_xhr_2.php?PHPSESSID=${encodeURIComponent(phpsessid)}&id=${encodeURIComponent(actionId)}`;
const r = await evFetch(url, origin);
const r = await fetch(url, { credentials: "include" });
if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
@@ -157,47 +131,15 @@ async function fetchXhr2(origin, phpsessid, actionId) {
async function fetchFicheHtml(origin, phpsessid, formLink) {
const url = `${origin}/index.php?${formLink}&PHPSESSID=${encodeURIComponent(phpsessid)}`;
console.log("[bg] fetchFicheHtml →", url.substring(0, 120));
// v2026.5.16 : juste après une reconnexion SSO, EasyVista retourne parfois
// une page intermédiaire tronquée (~8 Ko au lieu de ~250 Ko), le temps que
// les cookies SSO/Kerberos se propagent. On fait jusqu'à 3 tentatives avec
// 1.5s entre chaque si on détecte une taille suspecte.
const MAX_RETRIES = 3;
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;
const r = await fetch(url, { credentials: "include" });
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);
if (html.length < 500) {
console.warn("[bg] ⚠ fiche très courte, contenu =", JSON.stringify(html));
}
return html;
}
@@ -221,7 +163,7 @@ async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) {
`&checksum=${encodeURIComponent(formChecksum)}` +
`&type=todo&sectionId=1&navigator=&nbRecord=0` +
`&PHPSESSID=${encodeURIComponent(phpsessid)}`;
const r = await evFetch(url, origin);
const r = await fetch(url, { credentials: "include" });
if (!r.ok) {
const err = new Error("HTTP " + r.status);
err.kind = classifyHttpStatus(r.status);
@@ -235,173 +177,9 @@ async function fetchTimelineApi(origin, phpsessid, guid, formId, formChecksum) {
// Détection "session invalide"
// ============================================================================
/**
* v5.0.9 : détecte plusieurs patterns de session invalide :
* 1. Page de login classique EasyVista (customer_login, my.policy)
* 2. Script de redirection court : <script>window.location.href = "..."</script>
* (protection CSRF ou session expirée)
* 3. URL de logout : index.php?...&logout=1
* 4. Redirection vers le portail SSO : portail.etat-de-vaud.ch/sso/
* 5. Réponse JSON avec "isLogged": false
*/
function looksLikeLoginPage(text) {
const t = (text || "").substring(0, 3000);
if (!t) return false;
// Pattern 1 : page de login EV classique
if (/customer_login|my\.policy/i.test(t)) return true;
// Pattern 2 : script de redirection (< 500 chars = probablement juste ça)
if (t.length < 500 && /<script[^>]*>\s*window\.location\.href\s*=/i.test(t)) return true;
// Pattern 3 : URL de logout
if (/[?&]logout=1/i.test(t)) return true;
// Pattern 4 : redirection vers portail SSO
if (/portail\.etat-de-vaud\.ch\/sso\//i.test(t)) return true;
// Pattern 5 : JSON isLogged:false
if (/"isLogged"\s*:\s*false/i.test(t)) return true;
return false;
}
// ============================================================================
// v5.0.9 : surveillance du timeout de session EasyVista
// ============================================================================
/**
* GET /timeout_ajax.php?__AJAX_TIMEOUT_FCT__=session_time
*
* Retourne le nombre de millisecondes restantes avant expiration de la
* session EasyVista (0 à 1 800 000 = 30 min max).
*
* Attention : cette requête EST authentifiée et prolonge probablement la
* session (comme toute requête PHP authentifiée). À utiliser avec parcimonie.
*/
async function fetchSessionTimeRemaining(origin, phpsessid) {
const url = `${origin}/timeout_ajax.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ `&__AJAX_TIMEOUT_FCT__=session_time`;
console.log("[bg] fetchSessionTimeRemaining →", url.substring(0, 120));
const r = await evFetch(url, origin);
if (!r.ok) {
throw new Error("HTTP " + r.status);
}
const body = (await r.text()).trim();
// Vérifier que c'est bien un nombre (sinon = session morte probable)
if (!/^\d+$/.test(body)) {
console.warn("[bg] réponse session_time anormale :", body.substring(0, 200));
// Si c'est une page de login/redirect → session expirée
if (looksLikeLoginPage(body)) {
throw new Error("session_expired");
}
throw new Error("invalid_response");
}
const ms = parseInt(body, 10);
console.log(`[bg] session_time = ${ms} ms = ${Math.round(ms/60000)} min`);
return ms;
}
/**
* GET /timeout_ajax.php?__AJAX_TIMEOUT_FCT__=keep_connection
*
* Prolonge la session à 30 min. Retourne 1800000.
*/
async function extendSessionKeepAlive(origin, phpsessid) {
const url = `${origin}/timeout_ajax.php`
+ `?PHPSESSID=${encodeURIComponent(phpsessid)}`
+ `&__AJAX_TIMEOUT_FCT__=keep_connection`;
console.log("[bg] extendSessionKeepAlive →", url.substring(0, 120));
const r = await evFetch(url, origin);
if (!r.ok) {
throw new Error("HTTP " + r.status);
}
const body = (await r.text()).trim();
if (!/^\d+$/.test(body)) {
if (looksLikeLoginPage(body)) throw new Error("session_expired");
throw new Error("invalid_response");
}
const ms = parseInt(body, 10);
console.log(`[bg] keep_connection → session prolongée à ${ms} ms`);
return ms;
}
// ============================================================================
// v5.0.11 : détection automatique du contexte réseau (interne / externe)
// ============================================================================
/**
* Détecte si on est sur le réseau interne (itsma.etat-de-vaud.ch accessible)
* ou externe (seul itsma.vd.ch accessible). Fait un fetch avec mode "no-cors"
* sur l'URL interne : si ça répond (même redirection SSO), on est interne.
* Si ça échoue (DNS unreachable, timeout), on est externe.
*
* v5.0.11 (fix) : mode "no-cors" pour éviter que l'erreur CORS de la
* redirection SSO (itsma.etat-de-vaud.ch → portail.etat-de-vaud.ch/sso/)
* soit interprétée comme un échec de connectivité. Au bureau, la redirection
* SSO passe → le fetch termine → on sait qu'on est interne.
*
* Le résultat est mis en cache dans chrome.storage.local pendant 1h pour
* éviter de refaire le test à chaque démarrage.
*
* @param {boolean} force - si true, ignore le cache et refait le test
* @returns {Promise<"internal"|"external">}
*/
async function detectNetworkContext(force = false) {
const CACHE_KEY = "network_context_v2"; // v5.0.12 : nouvelle clé pour invalider le cache fautif v5.0.11
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
if (!force) {
try {
const data = await chrome.storage.local.get([CACHE_KEY]);
const cached = data[CACHE_KEY];
if (cached && cached.detectedAt && (Date.now() - cached.detectedAt) < CACHE_MAX_AGE_MS) {
console.log("[bg] detectNetworkContext : cache hit =", cached.networkContext);
return cached.networkContext;
}
} catch (e) {}
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2500);
let context = "external";
try {
console.log("[bg] detectNetworkContext : test de itsma.etat-de-vaud.ch (mode no-cors)...");
// v5.0.11 (fix) : no-cors évite l'erreur CORS de la redirection SSO.
// Si le fetch termine sans throw, le serveur est joignable = interne.
// Si timeout ou DNS fail, throw → externe.
await fetch("https://itsma.etat-de-vaud.ch/", {
method: "GET",
mode: "no-cors",
signal: controller.signal,
credentials: "omit",
cache: "no-store"
});
clearTimeout(timer);
console.log("[bg] detectNetworkContext : interne accessible");
context = "internal";
} catch (err) {
clearTimeout(timer);
// AbortError (timeout) ou TypeError (DNS unreachable / réseau coupé)
console.log("[bg] detectNetworkContext : interne inaccessible → externe (" + err.name + ": " + err.message + ")");
context = "external";
}
// Mettre en cache
try {
await chrome.storage.local.set({
[CACHE_KEY]: {
networkContext: context,
detectedAt: Date.now()
}
});
} catch (e) {}
return context;
}
/**
* Retourne l'origine EV à utiliser selon le contexte réseau détecté.
*/
function originForContext(context) {
return context === "internal"
? "https://itsma.etat-de-vaud.ch"
: "https://itsma.vd.ch";
// La page de login EasyVista contient cette chaîne
return /customer_login|my\.policy/i.test((text || "").substring(0, 3000));
}
// ============================================================================
@@ -1081,71 +859,6 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return;
}
if (msg.type === "getSessionRemaining") {
// v5.0.9 : récupère le temps restant avant expiration de la session EV
const session = await findEasyVistaSession();
if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const remainingMs = await fetchSessionTimeRemaining(session.origin, session.phpsessid);
sendResponse({ ok: true, remainingMs, phpsessid: session.phpsessid });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return;
}
if (msg.type === "extendSession") {
// v5.0.9 : prolonge la session EV à 30 min via keep_connection
const session = await findEasyVistaSession();
if (!session) {
sendResponse({ ok: false, error: "no_session" });
return;
}
try {
const remainingMs = await extendSessionKeepAlive(session.origin, session.phpsessid);
sendResponse({ ok: true, remainingMs });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return;
}
if (msg.type === "openEasyVistaLogin") {
// v5.0.9 : ouvre EasyVista dans un nouvel onglet pour provoquer
// le SSO Windows automatique (reconnexion transparente).
// v5.0.11 : URL simplifiée (racine domaine au lieu de eventName=...),
// et utilise le contexte réseau détecté si l'origine n'est pas fournie.
let origin = msg.origin;
if (!origin) {
const context = await detectNetworkContext();
origin = originForContext(context);
}
try {
const tab = await chrome.tabs.create({
url: `${origin}/`, // racine → EV redirige vers SSO si besoin
active: true
});
sendResponse({ ok: true, tabId: tab.id, origin });
} catch (err) {
sendResponse({ ok: false, error: err.message || String(err) });
}
return;
}
if (msg.type === "detectNetwork") {
// v5.0.11 : détecte si on est en interne ou externe.
const context = await detectNetworkContext(!!msg.force);
sendResponse({
ok: true,
context, // "internal" | "external"
origin: originForContext(context) // URL correspondante
});
return;
}
if (msg.type === "cleanupOldCaches") {
const removed = await cleanupOldCaches(msg.daysToKeep || 7);
sendResponse({ ok: true, removed });
+4 -27
View File
@@ -1,26 +1,9 @@
{
"manifest_version": 3,
"name": "Planification",
"version": "2026.5.16",
"version": "5.0.6",
"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": {
"gecko": {
"id": "planification@vd.ch",
"strict_min_version": "140.0",
"data_collection_permissions": {
"required": [
"none"
]
}
}
},
"permissions": [
"activeTab",
"scripting",
"storage",
"tabs",
"alarms"
],
"permissions": ["activeTab", "scripting", "storage", "tabs", "alarms"],
"host_permissions": [
"https://itsma.etat-de-vaud.ch/*",
"https://itsma.vd.ch/*"
@@ -29,9 +12,7 @@
"default_title": "Ouvrir la Planification"
},
"background": {
"scripts": [
"background.js"
]
"service_worker": "background.js"
},
"icons": {
"16": "icons/icon16.png",
@@ -40,11 +21,7 @@
},
"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/*"
-174
View File
@@ -320,7 +320,6 @@ html, body {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.btn-nav {
@@ -2218,176 +2217,3 @@ header.topbar::before {
font-size: 13px;
cursor: pointer;
}
/* ==========================================================================
v5.0.9 : Compteur de session EasyVista (topbar)
========================================================================== */
.app-session {
position: absolute;
top: 50%;
left: calc(50% + 60px); /* à droite de l'horloge (~60px de décalage) */
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
border-radius: 14px;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
z-index: 9;
background: rgba(0, 0, 0, 0.05);
transition: background 0.3s, color 0.3s;
}
.app-session.hidden {
display: none;
}
.app-session .session-icon {
font-size: 14px;
}
.app-session .session-time {
font-weight: 600;
}
.app-session .session-extend-btn {
margin-left: 4px;
padding: 3px 8px;
font-size: 11px;
border-radius: 10px;
border: 1px solid currentColor;
background: transparent;
color: inherit;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.app-session .session-extend-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.app-session .session-extend-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* État warning (2-5 min) : jaune */
.app-session.session-warn {
background: #f5c518;
color: #2a2100;
}
.app-session.session-warn .session-extend-btn {
border-color: #2a2100;
}
/* État critical (< 2 min) : rouge + pulse */
.app-session.session-critical {
background: #e74c3c;
color: #fff;
animation: session-pulse 1s infinite;
}
.app-session.session-critical .session-extend-btn {
border-color: #fff;
background: rgba(255, 255, 255, 0.15);
font-weight: 600;
}
.app-session.session-critical .session-extend-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
@keyframes session-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.5); }
50% { box-shadow: 0 0 0 6px rgba(231, 76, 60, 0); }
}
/* Bouton "Me reconnecter" dans la bannière session expirée */
.session-expired-reconnect-btn {
margin-left: 12px;
padding: 6px 14px;
border-radius: 4px;
background: #fff;
color: #c0392b;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.session-expired-reconnect-btn:hover {
background: #f8d7da;
}
/* Bannière "Reconnexion en cours" */
.banner-reconnecting {
background: #3498db;
color: #fff;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 500;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.banner-reconnecting.hidden {
display: none;
}
.banner-reconnecting .banner-spinner {
font-size: 16px;
animation: spin-slow 2s linear infinite;
}
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ==========================================================================
v5.0.11 : bannières reconnect avec boutons Annuler + choix réseau
========================================================================== */
.banner-reconnecting .banner-cancel-btn,
.banner-reconnect-failed .banner-cancel-btn {
margin-left: auto;
padding: 5px 12px;
background: transparent;
color: inherit;
border: 1px solid currentColor;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.banner-reconnecting .banner-cancel-btn:hover,
.banner-reconnect-failed .banner-cancel-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.banner-reconnect-failed {
background: #e67e22;
color: #fff;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
font-weight: 500;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.banner-reconnect-failed.hidden {
display: none;
}
.banner-reconnect-failed .banner-icon {
font-size: 18px;
}
.banner-reconnect-failed .banner-btn-primary {
padding: 6px 14px;
border-radius: 4px;
background: #fff;
color: #c0392b;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.banner-reconnect-failed .banner-btn-primary:hover {
background: #f8d7da;
}
-2
View File
@@ -25,8 +25,6 @@
</div>
<!-- v5.0.0 : horloge au milieu, format HH:MM, mise à jour toutes les min -->
<div id="app-clock" class="app-clock" title="Heure actuelle"></div>
<!-- v5.0.9 : compteur de session EasyVista (visible < 5 min restantes) -->
<div id="app-session" class="app-session hidden"></div>
<div class="topbar-right">
<!-- v4.2.6 : bouton créer une absence pour un ou plusieurs techs -->
<button id="absence-btn" class="btn btn-action" title="Créer une absence pour un ou plusieurs techniciens">
+24 -584
View File
@@ -131,7 +131,6 @@ function deriveShortTitle(iv) {
function deriveColorKey(iv) {
if (iv.type === "AL-Reservation") return "reservation";
if (iv.type === "AL-Absence") return "absence"; // v5.0.15 : couleur noire/gris foncé
if (iv.ref && /^I\d/.test(iv.ref)) return "incident";
if (isRollOut(iv)) return "rollout";
if (isRecupAction(iv)) return "recup";
@@ -151,36 +150,9 @@ let state = {
session: null, // { phpsessid, origin, tabId }
currentDate: null, // "YYYY-MM-DD" affiché
currentData: null, // résultat parsé (techs, stats, ...)
loading: false,
// v5.0.9 : timestamp (ms) auquel la session EV va expirer.
sessionExpireAt: null,
// v5.0.9 : true pendant une reconnexion en cours
reconnecting: false,
// v5.0.9 : true si la session est expirée (bannière rouge affichée)
sessionExpired: false,
// v5.0.9 : true si on a déjà fait le ping de confirmation < 5 min
sessionPingDone: false,
// v5.0.10 : dernière origine EV connue comme fonctionnelle (itsma.vd.ch
// ou itsma.etat-de-vaud.ch selon qu'on est en externe ou interne).
// Conservée même quand state.session est null, pour savoir où rediriger
// lors de la reconnexion.
lastKnownOrigin: null,
// v5.0.11 : contexte réseau détecté ("internal" ou "external" ou null).
// Détecté automatiquement au démarrage par un HEAD test sur l'URL interne.
networkContext: null,
// v5.0.11 : timer (setTimeout id) pour le timeout de reconnexion 90 sec
reconnectTimeoutId: null
loading: false
};
// v5.0.9 : constantes session
const SESSION_DURATION_MS = 30 * 60 * 1000; // 30 min
const SESSION_WARN_THRESHOLD_MS = 5 * 60 * 1000; // 5 min → affichage compteur
const SESSION_CRITICAL_THRESHOLD_MS = 2 * 60 * 1000; // 2 min → rouge + modal
// v5.0.11 : timeout de la reconnexion. Si l'user n'est pas reconnecté
// dans ce délai, on bascule en état "Reconnexion échouée" avec choix du réseau.
const RECONNECT_TIMEOUT_MS = 90 * 1000; // 90 sec
// ─── Annulation coopérative d'un refresh manuel (v3.1) ──────────────────────
// Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent
// isRefreshAborted() avant chaque fetch : si le jeton a changé ou si
@@ -241,42 +213,19 @@ async function init() {
initAppFooter(); // v4.2.9 : pied de page discret bas-droite
initAppClock(); // v5.0.0 : horloge HH:MM au milieu topbar
initAdminMenu(); // v5.0.0 : menu admin caché (5 clics sur titre)
initSessionTimer(); // v5.0.9 : compteur de session EV (tick 1s)
initDateCustomPicker(); // v2026.5.17 : faux input date avec jour
// Initialiser la date = aujourd'hui
state.currentDate = todayISO();
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)
detectNetworkContextAsync();
// v4.2 : l'auto-refresh 12h/15h a été supprimé. Les rafraîchissements sont
// 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
await refreshSessionAndLoad();
}
/**
* v5.0.11 : détecte si on est en interne (bureau VPN) ou externe (télétravail),
* de manière asynchrone au démarrage. Résultat utilisé pour choisir le bon
* domaine lors de la reconnexion.
*/
async function detectNetworkContextAsync(force = false) {
try {
const resp = await sendMessage({ type: "detectNetwork", force });
if (resp && resp.ok) {
state.networkContext = resp.context;
// Si on n'a pas encore de lastKnownOrigin, on prend celui du contexte détecté
if (!state.lastKnownOrigin) {
state.lastKnownOrigin = resp.origin;
}
console.log("[viewer] réseau détecté :", resp.context, "→", resp.origin);
}
} catch (e) {
console.warn("[viewer] détection réseau échouée", e);
}
}
async function refreshSessionAndLoad() {
const resp = await sendMessage({ type: "getSession" });
if (!resp.ok || !resp.session) {
@@ -297,33 +246,14 @@ async function refreshSessionAndLoad() {
return;
}
state.session = resp.session;
// v5.0.10 : mémoriser l'origine courante pour la reconnexion si besoin
if (resp.session && resp.session.origin) {
state.lastKnownOrigin = resp.session.origin;
}
hideSessionNeeded();
hideEvUnreachable();
hideSessionExpiredBanner();
hideEvUnreachableBanner();
state.sessionExpired = false;
state.reconnecting = false;
fetchAndShowCurrentUser();
// v5.0.9 : à chaque démarrage/reconnexion, on suppose que la session vient
// d'être rafraîchie à 30 min. updateSessionIndicator va masquer le compteur.
markSessionActivity();
await loadForDate(state.currentDate);
}
/**
* v5.0.9 : doit être appelée à chaque requête EasyVista réussie. Reset le
* timer local à 30 min (la session serveur a été renouvelée implicitement).
*/
function markSessionActivity() {
state.sessionExpireAt = Date.now() + SESSION_DURATION_MS;
state.sessionPingDone = false; // reset le flag de ping
updateSessionIndicator();
}
// v4.2 : fetche l'utilisateur EasyVista connecté (via background.js) et
// l'affiche dans la topbar. En cas d'échec ou si aucun nom n'est trouvé,
// le badge reste caché.
@@ -401,21 +331,7 @@ function toggleUserNamePopup() {
return;
}
if (!state.currentUser || !state.currentUser.name) return;
// 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.textContent = state.currentUser.name;
popup.classList.remove("hidden");
badge.classList.add("open");
// Positionne juste en dessous de la pastille
@@ -430,38 +346,6 @@ function hideUserNamePopup() {
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
// ============================================================================
@@ -565,74 +449,22 @@ 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) => {
if (e.key !== "Escape") return;
// keydown peut se répéter si la touche est maintenue ; on ignore les répétitions.
if (e.repeat) return;
// Armer le timer "maintenu 3s"
_escHoldTriggered = false;
if (_escHoldTimer) clearTimeout(_escHoldTimer);
_escHoldTimer = setTimeout(() => {
_escHoldTriggered = true;
_escHoldTimer = null;
// Fermer TOUS les popups flottants (normaux + minimisés) mais pas les dockés
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);
}
if (e.key === "Escape") {
hideUserNamePopup();
// v4.2.4 : Échap ferme aussi la grande bulle anchored
const tip = tooltipEl();
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();
}
_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();
}
});
// v5.0.10 : clic "Ouvrir EasyVista" sur l'écran plein → déclenche la
// reconnexion SSO + l'auto-reload du viewer dès que la nouvelle session
// est détectée (au lieu d'ouvrir juste un onglet).
document.getElementById("open-ev-btn").addEventListener("click", () => {
triggerReconnect();
});
document.getElementById("open-ev-btn").addEventListener("click", openEasyVista);
// v4.2 : écran "EasyVista inaccessible"
const openEvBtn2 = document.getElementById("open-ev-btn-2");
@@ -663,16 +495,11 @@ function bindTopbar() {
}
async function openEasyVista() {
// v5.0.10 : ouvrir sur le domaine le plus approprié :
// - lastKnownOrigin si on a déjà eu une session fonctionnelle (respecte
// interne vs externe selon le réseau)
// - session.origin si on a encore la session
// - itsma.vd.ch en fallback (domaine externe accessible de partout,
// même depuis le réseau VD il redirige vers l'interne transparent)
const origin = state.lastKnownOrigin
|| (state.session && state.session.origin)
|| "https://itsma.vd.ch";
await chrome.tabs.create({ url: origin + "/" });
// Ouvrir sur le domaine externe (accessible depuis l'extérieur).
// Le domaine interne (itsma.etat-de-vaud.ch) n'est accessible que depuis le réseau VD.
// Une fois connecté, l'extension détectera automatiquement le PHPSESSID quel que
// soit le domaine où tu es connecté.
await chrome.tabs.create({ url: "https://itsma.vd.ch/" });
}
// Navigation ±1 jour en sautant les week-ends
@@ -894,37 +721,11 @@ function initAppFooter() {
function initAppClock() {
const el = document.getElementById("app-clock");
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 d = new Date();
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
const timeStr = `${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;
}
}
el.textContent = `${h}:${m}`;
// v5.0.0 : profite du tick pour mettre à jour la ligne rouge "now"
updateNowLine();
};
@@ -976,271 +777,6 @@ function initAdminMenu() {
title.style.cursor = "default";
}
// ============================================================================
// v5.0.9 : Surveillance du timeout de session EasyVista
// ============================================================================
/**
* Initialise le tick du compteur de session (toutes les secondes).
* Pas de requête réseau : décompte purement local depuis state.sessionExpireAt.
* En parallèle, un polling 2s actif uniquement en reconnexion, pour détecter
* dès que l'user s'est reconnecté dans l'onglet EasyVista ouvert.
*/
function initSessionTimer() {
setInterval(() => {
updateSessionIndicator();
}, 1000);
// Polling actif UNIQUEMENT pendant une reconnexion pour détecter le nouveau
// PHPSESSID dès qu'il apparaît dans un onglet EV. Rien d'envoyé au serveur
// en dehors de ça.
setInterval(async () => {
if (!state.reconnecting) return;
try {
const resp = await sendMessage({ type: "getSession" });
if (resp && resp.ok && resp.session && resp.session.phpsessid) {
const oldPhpsessid = state.session ? state.session.phpsessid : null;
if (resp.session.phpsessid !== oldPhpsessid) {
console.log("[session] nouvelle session détectée après reconnexion :", resp.session.phpsessid);
// v5.0.11 : annuler le timeout de reconnexion puisque ça a marché
if (state.reconnectTimeoutId) {
clearTimeout(state.reconnectTimeoutId);
state.reconnectTimeoutId = null;
}
state.session = resp.session;
if (resp.session.origin) state.lastKnownOrigin = resp.session.origin;
state.reconnecting = false;
state.sessionExpired = false;
hideReconnectingBanner();
hideSessionExpiredBanner();
hideReconnectFailedBanner();
markSessionActivity();
showToast("Reconnecté", "Session EasyVista renouvelée");
// Recharger le planning à la date courante sans perdre la position
await loadForDate(state.currentDate);
}
}
} catch (e) {
// Silencieux, on réessayera au prochain tick
}
}, 2000);
}
/**
* Met à jour l'affichage du compteur session dans la topbar.
* Règles :
* - Session expirée ou reconnexion compteur caché (bannière gère l'affichage)
* - > 5 min restantes compteur invisible
* - 2-5 min jaune, bouton "Prolonger" visible
* - < 2 min rouge pulse + modal automatique (une seule fois)
* - <= 0 déclenche l'état "expirée"
*/
function updateSessionIndicator() {
const el = document.getElementById("app-session");
if (!el) return;
if (state.sessionExpired || state.reconnecting) {
el.classList.add("hidden");
return;
}
if (!state.sessionExpireAt) {
el.classList.add("hidden");
return;
}
const remainingMs = state.sessionExpireAt - Date.now();
if (remainingMs <= 0) {
handleSessionExpired();
return;
}
if (remainingMs > SESSION_WARN_THRESHOLD_MS) {
el.classList.add("hidden");
return;
}
// Zone d'alerte (< 5 min)
// v5.0.9 : avant d'afficher l'alerte, on fait UN ping de confirmation
// pour vérifier que le serveur est bien d'accord (compteur local parfois
// désynchronisé si plusieurs requêtes EV ont rafraîchi sans qu'on update
// notre horloge). Une seule fois par cycle.
if (!state.sessionPingDone) {
state.sessionPingDone = true;
sendMessage({ type: "getSessionRemaining" }).then(resp => {
if (resp && resp.ok && typeof resp.remainingMs === "number") {
state.sessionExpireAt = Date.now() + resp.remainingMs;
updateSessionIndicator();
}
}).catch(() => {});
// En attendant, on continue avec l'estimation locale
}
const mm = Math.floor(remainingMs / 60000);
const ss = Math.floor((remainingMs % 60000) / 1000);
const timeStr = `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
el.classList.remove("hidden", "session-warn", "session-critical");
if (remainingMs <= SESSION_CRITICAL_THRESHOLD_MS) {
el.classList.add("session-critical");
if (!state._criticalModalShown) {
state._criticalModalShown = true;
showSessionCriticalModal();
}
} else {
el.classList.add("session-warn");
state._criticalModalShown = false;
}
el.innerHTML = `
<span class="session-icon"></span>
<span class="session-time">${timeStr}</span>
<button type="button" class="session-extend-btn" title="Prolonger la session de 30 min">🔄 Prolonger</button>
`;
const extendBtn = el.querySelector(".session-extend-btn");
if (extendBtn) {
extendBtn.onclick = async () => {
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;
showToast("Session prolongée", "30 minutes de plus");
updateSessionIndicator();
} else {
throw new Error((resp && resp.error) || "erreur inconnue");
}
} catch (err) {
extendBtn.disabled = false;
extendBtn.textContent = "🔄 Prolonger";
if (err.message === "session_expired" || err.message === "no_session") {
handleSessionExpired();
}
}
};
}
}
/**
* Appelée quand le compteur atteint 0 ou quand une requête EV échoue en
* session expirée. Affiche la bannière "Session expirée" avec bouton "Me
* reconnecter".
*/
function handleSessionExpired() {
if (state.sessionExpired) return;
state.sessionExpired = true;
state.sessionExpireAt = null;
state._criticalModalShown = false;
console.warn("[session] session EV expirée");
showSessionExpiredBanner();
const el = document.getElementById("app-session");
if (el) el.classList.add("hidden");
}
/**
* Modal auto quand < 2 min : alerte visuelle forte.
*/
function showSessionCriticalModal() {
showAlertModal({
title: "⚠️ Session EasyVista expire bientôt",
message: "Votre session EasyVista expire dans moins de 2 minutes. Cliquez sur « Prolonger » pour éviter d'être déconnecté.",
buttons: [
{ label: "Ignorer", variant: "secondary", action: () => {} },
{
label: "🔄 Prolonger maintenant",
variant: "primary",
action: async () => {
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;
showToast("Session prolongée", "30 minutes de plus");
updateSessionIndicator();
}
} catch (e) {
handleSessionExpired();
}
}
}
]
});
}
/**
* Appelé au clic "Me reconnecter" dans la bannière. Ouvre EasyVista dans un
* nouvel onglet (déclenche Windows SSO Kerberos automatique). Le polling
* dans initSessionTimer détectera la nouvelle session et rechargera le viewer.
*
* v5.0.10 : utilise l'origine dynamique (interne ou externe selon le réseau).
* v5.0.11 : détecte le contexte réseau avant d'ouvrir (si pas déjà connu) +
* timeout 90s : si pas reconnecté après ce délai, propose choix manuel.
*
* @param {string} [forcedOrigin] - origine à forcer (pour le choix manuel
* dans le fallback après timeout). Si absent : détection auto.
*/
async function triggerReconnect(forcedOrigin) {
state.reconnecting = true;
hideSessionExpiredBanner();
hideReconnectFailedBanner();
showReconnectingBanner();
// Annuler tout timeout précédent
if (state.reconnectTimeoutId) {
clearTimeout(state.reconnectTimeoutId);
state.reconnectTimeoutId = null;
}
try {
let origin = forcedOrigin;
if (!origin) {
// v5.0.11 : re-détecter le réseau à chaque expiration pour gérer le
// cas où on a changé de contexte (bureau → TT) pendant la session.
await detectNetworkContextAsync(true);
origin = state.lastKnownOrigin
|| (state.session && state.session.origin)
|| "https://itsma.vd.ch";
}
console.log("[session] triggerReconnect → ouverture de", origin);
await sendMessage({ type: "openEasyVistaLogin", origin });
// Démarrer le timeout 90s : si pas reconnecté, basculer en mode "Échec"
state.reconnectTimeoutId = setTimeout(() => {
if (state.reconnecting && !state.session) {
console.warn("[session] reconnexion timeout 90s → bannière échec");
state.reconnecting = false;
hideReconnectingBanner();
showReconnectFailedBanner();
}
}, RECONNECT_TIMEOUT_MS);
} catch (err) {
console.warn("[session] openEasyVistaLogin failed:", err);
state.reconnecting = false;
hideReconnectingBanner();
showSessionExpiredBanner();
}
}
/**
* v5.0.11 : l'user clique "Annuler" pendant la reconnexion. On arrête le
* polling/timeout et on revient à l'état "Session expirée" normal.
*/
function cancelReconnect() {
if (state.reconnectTimeoutId) {
clearTimeout(state.reconnectTimeoutId);
state.reconnectTimeoutId = null;
}
state.reconnecting = false;
hideReconnectingBanner();
hideReconnectFailedBanner();
showSessionExpiredBanner();
}
// v5.0.0 : stockage des paramètres admin dans chrome.storage.local.
// Clé unique : "admin_config". Contient la config éditable (équipe,
// absences récurrentes, statuts etc.). Au 1er lancement : initialisée
@@ -6994,111 +6530,15 @@ function hideEvUnreachable() {
// que les mises à jour sont arrêtées.
function showSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) {
b.classList.remove("hidden");
// v5.0.10 : rebrancher le bouton "Ouvrir EasyVista" natif pour qu'il
// appelle triggerReconnect() au lieu de juste ouvrir un onglet. Ça
// déclenche la reconnexion SSO ET l'auto-reload du viewer quand la
// nouvelle session est détectée.
// On renomme aussi le bouton pour être explicite.
const btn = b.querySelector("#session-banner-reconnect");
if (btn && !btn.dataset.boundReconnect) {
btn.dataset.boundReconnect = "1";
btn.textContent = "🔄 Me reconnecter";
// Retirer d'éventuels anciens listeners en clonant le bouton
const clone = btn.cloneNode(true);
btn.parentNode.replaceChild(clone, btn);
clone.addEventListener("click", () => triggerReconnect());
}
}
if (b) b.classList.remove("hidden");
// Masquer la bannière EV si présente (on ne montre qu'une bannière à la fois)
hideEvUnreachableBanner();
hideReconnectingBanner();
}
function hideSessionExpiredBanner() {
const b = document.getElementById("session-expired-banner");
if (b) b.classList.add("hidden");
}
// v5.0.9 : bannière affichée pendant la reconnexion (remplace la bannière
// expirée après clic sur "Me reconnecter")
// v5.0.11 : ajoute un bouton "Annuler" pour interrompre le processus.
function showReconnectingBanner() {
let b = document.getElementById("session-reconnecting-banner");
if (!b) {
b = document.createElement("div");
b.id = "session-reconnecting-banner";
b.className = "banner-reconnecting";
const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body;
if (topbar.nextSibling) {
topbar.parentNode.insertBefore(b, topbar.nextSibling);
} else {
document.body.insertBefore(b, document.body.firstChild);
}
}
b.innerHTML = `
<span class="banner-spinner"></span>
<span class="banner-text">Reconnexion à EasyVista en cours Connectez-vous dans l'onglet qui vient de s'ouvrir.</span>
<button type="button" class="banner-cancel-btn">Annuler</button>
`;
const cancelBtn = b.querySelector(".banner-cancel-btn");
if (cancelBtn) cancelBtn.addEventListener("click", () => cancelReconnect());
b.classList.remove("hidden");
hideSessionExpiredBanner();
hideReconnectFailedBanner();
}
function hideReconnectingBanner() {
const b = document.getElementById("session-reconnecting-banner");
if (b) b.classList.add("hidden");
}
// v5.0.11 : bannière "Reconnexion échouée" avec choix manuel du réseau
// (Bureau/Télétravail). Affichée après timeout 90s de reconnexion.
function showReconnectFailedBanner() {
let b = document.getElementById("session-reconnect-failed-banner");
if (!b) {
b = document.createElement("div");
b.id = "session-reconnect-failed-banner";
b.className = "banner-reconnect-failed";
const topbar = document.querySelector(".topbar") || document.querySelector("header") || document.body;
if (topbar.nextSibling) {
topbar.parentNode.insertBefore(b, topbar.nextSibling);
} else {
document.body.insertBefore(b, document.body.firstChild);
}
}
b.innerHTML = `
<span class="banner-icon"></span>
<span class="banner-text">
<strong>Reconnexion échouée.</strong>
Indiquez vous êtes pour réessayer :
</span>
<button type="button" class="banner-btn-primary" data-origin="https://itsma.etat-de-vaud.ch">🏢 Au bureau</button>
<button type="button" class="banner-btn-primary" data-origin="https://itsma.vd.ch">🏠 En télétravail</button>
<button type="button" class="banner-cancel-btn">Annuler</button>
`;
// Boutons de choix de réseau : retry avec origine forcée
b.querySelectorAll(".banner-btn-primary").forEach(btn => {
btn.addEventListener("click", () => {
const origin = btn.dataset.origin;
hideReconnectFailedBanner();
triggerReconnect(origin);
});
});
// Bouton Annuler : retour à la bannière "Session expirée" simple
const cancelBtn = b.querySelector(".banner-cancel-btn");
if (cancelBtn) cancelBtn.addEventListener("click", () => {
hideReconnectFailedBanner();
showSessionExpiredBanner();
});
b.classList.remove("hidden");
hideSessionExpiredBanner();
hideReconnectingBanner();
}
function hideReconnectFailedBanner() {
const b = document.getElementById("session-reconnect-failed-banner");
if (b) b.classList.add("hidden");
}
// v4.2.5 : bannière non bloquante "EasyVista inaccessible"
function showEvUnreachableBanner() {
const b = document.getElementById("ev-unreachable-banner");