diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45d1254..ce43f35 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,36 @@
---
+## v2026.5.47 — Vue « échelle de temps exacte » : aide à la saisie, erreurs mises en évidence, corrections de fiabilité
+
+> Cette version peaufine la vue horizontale « échelle de temps exacte » : un vrai retour visuel accompagne la pose d'un rendez-vous ou d'une absence, et les erreurs de saisie (rendez-vous dont le début est égal à la fin) sont désormais bien visibles et signalées. Elle corrige aussi plusieurs problèmes de navigation entre dates et de synchronisation du dossier partagé, réorganise le panneau de synchronisation, et ajoute un bouton de test de la communication avec le script du poste.
+
+### Vue « échelle de temps exacte » — aide à la saisie
+
+- **Retour visuel au survol d'un créneau libre** : une zone surlignée suit la souris et affiche **l'heure exacte** (début → fin) du rendez-vous ou de l'absence qu'un clic droit poserait. Une bulle fixe, en haut de la ligne, rappelle la **plage libre et la durée disponible** du créneau (« Libre 12:00–18:00 / 6 h disponible »). Elle se place toujours sur un espace libre — jamais par-dessus un rendez-vous — et ne masque pas l'heure choisie.
+- **Erreurs de saisie mises en évidence** : un rendez-vous dont le **début est égal à la fin** (ex. 10:00 → 10:00) est désormais affiché en **rouge** et reste **visible** (avant, il disparaissait sur l'échelle exacte). Survoler une telle zone pour en poser un autre affiche un **avertissement** et **empêche** la création tant que l'erreur n'est pas corrigée.
+- **Interventions courtes visibles** : une intervention de 1 à 5 minutes garde une largeur minimale et reste visible.
+- **Zone du nom** : le fond du nom du technicien remplit toute la hauteur de la ligne (nom centré).
+- **Barre des heures** : reste collée en haut au défilement et passe sous la banderole de reconnexion (au lieu de disparaître derrière).
+
+### Corrections de fiabilité
+
+- **Navigation rapide entre dates** : le jour que l'on quitte ne peut plus écraser le jour qui vient de s'afficher.
+- **Technicien retiré de l'équipe** : n'apparaît plus sur les jours relus depuis le dossier partagé.
+- **Synchronisation du dossier partagé** : correction d'un blocage où l'écriture n'aboutissait jamais (conflit permanent entre l'index et le fichier relu) ; l'index se répare de lui-même.
+- **Jour présent au dossier mais pas en local** : lu depuis le dossier d'emblée — plus de re-remplissage visible inutile.
+- **Absences** : plus de doublon « absence récurrente » + « vraie absence » le même jour ; le fond des cartes d'absence remplit toute la carte en échelle exacte.
+- **Réservations multiples le même jour** : les deux créneaux coexistent ; saisir le n° d'intervention dans le formulaire remplit automatiquement ville, rue et catégorie.
+- **Barre de progression** et **message de reconnexion** : bien positionnés en vue horizontale (barre en haut de la sidebar, message centré dans le contenu).
+
+### Synchronisation multi-postes
+
+- **Nouveau bouton « Tester la communication »** (onglet Synchro) : diagnostic de bout en bout extension → script du poste → dossier partagé.
+- **Panneau Synchro réorganisé** en sections claires : état de la synchro, dossier partagé, script sur le poste, journal d'activité.
+- **Script du poste (native messaging host) 2026.1.8** : verrou dédié sur l'index du dossier partagé (protège l'écriture simultanée par plusieurs postes ou onglets) et icône de la zone de notification ré-affichée au redémarrage de l'explorateur Windows. Le bouton « Télécharger le script » récupère cette version.
+
+---
+
## v2026.5.46 — Refonte de la couche données, vue « échelle de temps exacte », session fiabilisée, réservations enrichies
> Version majeure par son contenu : la couche données est entièrement refondue (store unifié par jour, un seul verdict par intervention, synchronisation du dossier partagé en sous-dossier `v2`), une nouvelle disposition « échelle de temps exacte » s'ajoute à la vue horizontale, la détection de session EasyVista est fiabilisée de bout en bout, et les réservations portent enfin leurs informations (n°, lieu, catégorie) sur tous les postes. Le script PowerShell du dossier partagé est désormais publié avec l'extension et téléchargeable depuis les Paramètres.
diff --git a/README.md b/README.md
index 204e2e7..e61ccd4 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ Extension Chrome / Firefox pour visualiser de manière claire et rapide le plann
- **Auteur** : Quentin Rouiller (QRO), Technicien DGNSI — Canton de Vaud
- **Public cible** : coordinateurs DGNSI qui pilotent dans EasyVista (`itsma.etat-de-vaud.ch` / `itsma.vd.ch`) le planning de l'équipe technicienne
- **Démarrage projet** : jeudi 16 avril 2026
-- **Version actuelle** : [`v2026.5.45`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.45) (latest)
+- **Version actuelle** : [`v2026.5.47`](https://gitea.netaplaid.ch/FroSteel/Planification/releases/tag/v2026.5.47) (latest)
- **Contact** : voir [page wiki Contact](https://gitea.netaplaid.ch/FroSteel/Planification/wiki/Contact) ou [ouvrir une issue](https://gitea.netaplaid.ch/FroSteel/Planification/issues/new)
- **Manifest** : V3 (Chrome/Edge/Firefox 140+)
- **Format** : `.zip` (Chromium) + `.xpi` signé Mozilla (Firefox)
diff --git a/firefox-updates.json b/firefox-updates.json
index 7375123..56c3ac3 100644
--- a/firefox-updates.json
+++ b/firefox-updates.json
@@ -2,10 +2,15 @@
"addons": {
"planification-dgnsi@netaplaid.ch": {
"updates": [
+ {
+ "version": "2026.5.47",
+ "update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.47/planification-v2026.5.47-firefox.xpi",
+ "update_hash": "sha256:e97883102af2f00fd468c9be62deac6c1d2546ef3e25afe90d79a390bbd1f0bb"
+ },
{
"version": "2026.5.46",
"update_link": "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v2026.5.46/planification-v2026.5.46-firefox.xpi",
- "update_hash": "sha256:052cac5d79a8ec9f1eeb7172e8491f2e2a82b5937622131905cfcaa3eba4737b"
+ "update_hash": "sha256:673dc0527073523750de348414ae1d3b5c0cb42cc8128a7839bbb09bffb6242c"
},
{
"version": "2026.5.45",
diff --git a/native-host/bin/host.ps1 b/native-host/bin/host.ps1
index 0a05c6f..065b331 100644
--- a/native-host/bin/host.ps1
+++ b/native-host/bin/host.ps1
@@ -30,7 +30,7 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
-$HOST_VERSION = '2026.1.7'
+$HOST_VERSION = '2026.1.8'
# v2026.1.7 - le host n'est plus persistant a vie : s'il ne recoit AUCUN message
# pendant ce delai, il se ferme proprement (le navigateur le relance a la
# prochaine activite de l'extension). Il lance aussi le tray a la demande.
@@ -155,18 +155,44 @@ function Read-Index([string]$share) {
function Update-Index([string]$share, [string]$date, [long]$savedAt) {
$idx = Join-Path $share $INDEX_NAME
- $map = @{}
- if (Test-Path -LiteralPath $idx) {
- try {
- $existing = Get-Content -LiteralPath $idx -Raw -Encoding UTF8 | ConvertFrom-Json
- foreach ($p in $existing.PSObject.Properties) { $map[$p.Name] = $p.Value }
- } catch {}
+ # v2026.1.8 - verrou DEDIE a l'index. Plusieurs hotes (plusieurs onglets
+ # Planification ouverts, ou plusieurs postes) font un read-modify-write
+ # concurrent sur _index.json ; sans verrou, deux Move-Item se recouvrent et la
+ # maj d'un hote est perdue (lost update) -> l'index prend du retard sur les
+ # fichiers-jour -> conflits CAS a repetition ("refusion" en boucle). On
+ # serialise donc la sequence lecture->modif->ecriture. Vol du verrou perime
+ # (> 10 s) comme pour les fichiers-jour. Best-effort : si on n'obtient pas le
+ # verrou, on renonce (la prochaine ecriture reussie remettra l'index a jour).
+ $lock = $idx + '.lock'
+ $haveLock = $false
+ for ($i = 0; $i -lt 20 -and -not $haveLock; $i++) {
+ try { [System.IO.File]::Open($lock, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write).Close(); $haveLock = $true }
+ catch {
+ try {
+ $age = ((Get-Date) - (Get-Item -LiteralPath $lock).LastWriteTime).TotalSeconds
+ if ($age -gt 10) { Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue }
+ else { Start-Sleep -Milliseconds 50 }
+ } catch { Start-Sleep -Milliseconds 50 }
+ }
+ }
+ if (-not $haveLock) { Write-Activity 'DEBUG' "Index non mis a jour pour le $(Format-DateFR $date) (verrou index indisponible)"; return }
+ try {
+ # lecture DANS le verrou -> on part toujours de la derniere version.
+ $map = @{}
+ if (Test-Path -LiteralPath $idx) {
+ try {
+ $existing = Get-Content -LiteralPath $idx -Raw -Encoding UTF8 | ConvertFrom-Json
+ foreach ($p in $existing.PSObject.Properties) { $map[$p.Name] = $p.Value }
+ } catch {}
+ }
+ $map[$date] = $savedAt
+ $tmp = $idx + '.tmp'
+ [System.IO.File]::WriteAllText($tmp, ($map | ConvertTo-Json -Compress -Depth 5), $Utf8NoBom)
+ Move-Item -LiteralPath $tmp -Destination $idx -Force
+ Write-Activity 'DEBUG' "Index du dossier mis a jour pour le $(Format-DateFR $date)"
+ } finally {
+ Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue
}
- $map[$date] = $savedAt
- $tmp = $idx + '.tmp'
- [System.IO.File]::WriteAllText($tmp, ($map | ConvertTo-Json -Compress -Depth 5), $Utf8NoBom)
- Move-Item -LiteralPath $tmp -Destination $idx -Force
- Write-Activity 'DEBUG' "Index du dossier mis a jour pour le $(Format-DateFR $date)"
}
function Get-SavedAt([string]$share, [string]$date, [string]$file) {
diff --git a/native-host/bin/tray.ps1 b/native-host/bin/tray.ps1
index 3d25eff..5a60fbf 100644
--- a/native-host/bin/tray.ps1
+++ b/native-host/bin/tray.ps1
@@ -160,6 +160,35 @@ $ni.Icon = Get-TrayIcon
$ni.Text = 'Planification - synchronisation active'
$ni.Visible = $true
+# v2026.1.8 - "toujours present si actif" : le NotifyIcon disparait quand
+# explorer.exe redemarre (Windows rediffuse alors le message "TaskbarCreated").
+# Sans le traiter, l'icone ne revient JAMAIS tant que le tray tourne. On ecoute
+# ce message via une petite fenetre native et on re-affiche l'icone.
+try {
+ Add-Type -ReferencedAssemblies System.Windows.Forms -TypeDefinition @'
+using System;
+using System.Windows.Forms;
+using System.Runtime.InteropServices;
+public class PlanifTrayWatcher : NativeWindow {
+ [DllImport("user32.dll", CharSet = CharSet.Auto)]
+ static extern uint RegisterWindowMessage(string lpString);
+ public static readonly uint TaskbarCreated = RegisterWindowMessage("TaskbarCreated");
+ private NotifyIcon _icon;
+ public PlanifTrayWatcher(NotifyIcon icon) {
+ _icon = icon;
+ this.CreateHandle(new CreateParams());
+ }
+ protected override void WndProc(ref Message m) {
+ if (m.Msg == TaskbarCreated && _icon != null) {
+ try { _icon.Visible = false; _icon.Visible = true; } catch {}
+ }
+ base.WndProc(ref m);
+ }
+}
+'@ -ErrorAction Stop
+ $script:trayWatcher = New-Object PlanifTrayWatcher($ni)
+} catch {}
+
$menu = New-Object System.Windows.Forms.ContextMenuStrip
$miLog = $menu.Items.Add('Voir le journal de synchronisation')
$miLog.add_Click({ Show-LogWindow })
diff --git a/src/manifest.json b/src/manifest.json
index 28b90b3..048f5e4 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Planification",
- "version": "2026.5.46",
+ "version": "2026.5.47",
"description": "Vue claire et rapide du planning des techniciens EasyVista. Développé par Quentin Rouiller — DGNSI, Canton de Vaud.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl/El4I9gxcZkp5bnheg4ZBCKxckRLt9xQ5x+i6phEPYlydl6NapWlkbOyycUaeTZcpoEh/tKQnpMpoEc5jY8zCoRTwh/vfejVBZE6ANDG/dc3iix/1Z8OhRI4zMaRYQ+DQTjS5Q6l6wkcwzbQB85HZNhke/k1qIW6RCuBjeJYmES5WVDwzfCNVvaF6uQvdOCpLjrA3+LI6vUBEwaMg1hM42mC3dU0z9p7qzAby/dH3XpZD9HTFXLJL9/1zryN9rXNDpvZwYWnJ0hLTu5tfmURkvgKOwquX1KJy9vupmhUALRtS08Fplx/e3kUH3LD+coS1ExLwoOZ+1ORBUE9JCB8wIDAQAB",
"permissions": [
diff --git a/src/viewer.css b/src/viewer.css
index 514a9b9..b1f6b4f 100644
--- a/src/viewer.css
+++ b/src/viewer.css
@@ -1130,6 +1130,18 @@ html.view-classic .error-box.error-box-centered {
box-shadow: var(--shadow);
text-align: center;
}
+/* v2026.5.47 — message de reconnexion EasyVista (#session-needed /
+ #ev-unreachable) CENTRÉ dans la zone de contenu en vue horizontale (avant :
+ posé en haut, comme un item flex de main#main). main#main est
+ position:relative en horizontal ; on centre dans le contenu (à droite de la
+ sidebar) via left = 50% + --sidebar-w/2. */
+html.view-horizontal .session-needed {
+ position: absolute !important;
+ top: 50% !important;
+ left: calc(50% + var(--sidebar-w, 200px) / 2) !important;
+ transform: translate(-50%, -50%) !important;
+ margin: 0 !important;
+}
.session-needed h2 {
margin: 0 0 12px 0;
color: var(--text);
@@ -1549,6 +1561,35 @@ html.view-horizontal .timeline-noon {
background: #c44040 !important;
}
+/* v2026.5.47 — intervention à DURÉE NULLE (début == fin, ex. 10:00→10:00 :
+ erreur de saisie EV). Même rouge plein que les conflits ci-dessus, pour la
+ repérer d'un coup d'oeil dans toutes les vues. */
+.iv-mini-card.iv-zero-duration {
+ background: #b03030 !important;
+ color: #ffffff !important;
+ border-color: #7a1f1f !important;
+}
+.iv-mini-card.iv-zero-duration .iv-mini-card-bar,
+.iv-mini-card.iv-zero-duration .iv-mini-time-vertical,
+.iv-mini-card.iv-zero-duration .iv-mini-card-text {
+ color: #ffffff !important;
+ background: transparent !important;
+}
+/* Vue exacte : la bulle est très fine (min-width) → le bord gauche (poignée)
+ doit être rouge lui aussi, sinon on ne verrait que sa couleur de catégorie. */
+html.view-horizontal .iv-exact-card.iv-zero-duration .iv-exact-handle {
+ background-color: #7a1f1f !important;
+ background-image: none !important;
+}
+/* v2026.5.47 — durée nulle PLUS VISIBLE en vue exacte : un peu plus large
+ que les interventions courtes (11 px vs 6 px) + hachures rouges pour attirer
+ l'oeil sur l'erreur. */
+html.view-horizontal .iv-exact-card.iv-zero-duration {
+ min-width: 11px !important;
+ background-image: repeating-linear-gradient(
+ 45deg, #b03030 0 4px, #7a1f1f 4px 8px) !important;
+}
+
/* ==========================================================================
Interventions — layout v2 (heures verticales)
========================================================================== */
@@ -4352,6 +4393,16 @@ html.theme-dark .card.absence-cat-recurring {
background: linear-gradient(to bottom, rgba(6, 182, 212, 0.12) 0%, var(--bg-elevated) 40%);
}
+/* v2026.5.46 — vue horizontale : en échelle exacte, la carte d'un tech
+ absent est HAUTE (elle s'étire sur toute la frise) alors que son contenu est
+ court. Le dégradé « couleur → neutre à 40 % » (pensé pour les cartes courtes
+ de la vue classique) laissait tout le bas de la carte en neutre. En vue
+ horizontale on remplit donc TOUTE la carte de la couleur douce (theme-aware
+ via --c-*-soft) ; la vue classique garde le dégradé. */
+html.view-horizontal .card.absence-cat-maladie { background: var(--c-maladie-soft); }
+html.view-horizontal .card.absence-cat-conge,
+html.view-horizontal .card.absence-cat-recurring { background: var(--c-conge-soft); }
+
/* Message "Absent le vendredi" (ou autre récurrence) en cyan.
Règle UNIQUE — les bordures haut/bas (qui venaient d'un 2e bloc plus
haut, v4.1.20) sont repliées ici ; le bloc dupliqué a été retiré. */
@@ -4521,6 +4572,14 @@ html.view-horizontal .card-header {
padding: 6px 10px !important;
gap: 3px !important;
flex: 0 0 auto;
+ /* v2026.5.47 — le fond de la zone nom remplit TOUTE la hauteur de la
+ ligne (nom toujours centré via justify-content:center). En mode normal le
+ parent .card est déjà align-items:stretch, mais en mode heure exacte il
+ passe à align-items:center (pour centrer les bulles du corps), ce qui
+ laissait le header en bandeau fixe plus court que la ligne, avec du fond de
+ carte visible au-dessus/en dessous. align-self:stretch force le header à
+ s'étirer dans les deux modes ; le corps garde son propre centrage. */
+ align-self: stretch !important;
}
/* v2026.5.40 : nom du tech complet (jusqu'à 3 lignes). Si vraiment
trop long, .card-tech-name-tight est appliqué dynamiquement par le JS
@@ -5199,23 +5258,29 @@ html.view-horizontal .horizontal-sidebar .date-custom {
justify-content: center !important;
}
-/* 13. Barre de rafraîchissement en vue horizontale : overlay par-dessus
- user-badge + titre (zone haut de sidebar). */
+/* 13. Barre de rafraîchissement en vue horizontale.
+ v2026.5.47 — Quentin : la barre doit RESTER dans la barre de gauche,
+ AU-DESSUS des initiales — pas dans le contenu où elle couvrait
+ les heures du mode « échelle exacte ». Elle repasse donc en overlay sur la
+ SIDEBAR (left:0, width = --sidebar-w) ; top suit --stack-top (sous la
+ banderole de session/reco si présente). Pour NE PAS recouvrir les
+ initiales, le contenu de la sidebar est poussé vers le bas pendant le
+ rafraîchissement (classe .planif-refreshing sur , cf. showProgressBar). */
html.view-horizontal #progress-bar {
position: fixed !important;
- top: 0 !important;
+ top: var(--stack-top, 0px) !important;
left: 0 !important;
- width: 200px !important; /* largeur de la sidebar */
+ width: var(--sidebar-w, 200px) !important;
+ right: auto !important;
z-index: 9999 !important;
border-radius: 0 !important;
margin: 0 !important;
pointer-events: none;
}
-/* Sur breakpoint étroit (sidebar 170px) */
-@media (max-width: 1400px) {
- html.view-horizontal #progress-bar {
- width: 170px !important;
- }
+/* Réserve la hauteur de la barre en haut de la sidebar pendant un
+ rafraîchissement → les initiales / le titre passent SOUS la barre. */
+html.planif-refreshing.view-horizontal .horizontal-sidebar {
+ padding-top: 34px !important;
}
/* v2026.5.40 : banderole "En pompier du..." en vue HORIZONTALE
@@ -7017,6 +7082,76 @@ html.view-horizontal .iv-exact-lane + .iv-exact-lane {
outline-offset: -2px;
}
+/* v2026.5.47 — surbrillance de survol : la ZONE (1 h par défaut) qu'un clic
+ droit poserait en rendez-vous/absence, calée sous la souris à l'heure snappée
+ (créneau libre, mode heure exacte). Plus marquée que le survol gris du trou,
+ pour qu'on VOIE clairement la zone visée. Purement visuelle. */
+.iv-exact-selpreview {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ pointer-events: none;
+ z-index: 0;
+ box-sizing: border-box;
+ border-radius: 4px;
+ border: 2px solid var(--accent, #3b82f6);
+ background: rgba(94, 168, 232, 0.28);
+ background: color-mix(in srgb, var(--accent, #3b82f6) 28%, transparent);
+}
+/* v2026.5.47 — bulle « temps disponible » FIXE en haut de la bande (elle ne
+ suit PLUS la souris) : plage libre du créneau survolé, ou avertissement rouge
+ si erreur. Seule la zone de sélection (.iv-exact-selpreview) suit le curseur. */
+.iv-exact-selinfo {
+ position: absolute;
+ top: 3px;
+ left: 50%;
+ transform: translateX(-50%);
+ white-space: nowrap;
+ text-align: center;
+ line-height: 1.25;
+ font-size: calc(11px * var(--text-scale, 1));
+ padding: 3px 10px;
+ border-radius: 6px;
+ background: var(--bg-elevated, #21242b);
+ color: var(--text, #e8eaed);
+ border: 1px solid var(--accent, #3b82f6);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
+ pointer-events: none;
+ z-index: 3;
+}
+.iv-exact-selinfo-main { font-weight: 700; }
+.iv-exact-selinfo-sub { font-size: 0.9em; opacity: 0.8; font-weight: 500; }
+.iv-exact-selinfo.danger .iv-exact-selinfo-sub { opacity: 0.95; }
+/* v2026.5.47 — heure choisie (début–fin) affichée DANS le rectangle de
+ sélection mobile ; suit le curseur, lisible sur le fond translucide.
+ v2026.5.47 — placée EN BAS du rectangle (la bulle fixe est en haut) → la
+ bulle ne cache jamais l'heure quand la souris passe dessous. */
+.iv-exact-selpreview-hour {
+ position: absolute;
+ bottom: 3px;
+ left: 50%;
+ transform: translateX(-50%);
+ white-space: nowrap;
+ font-size: calc(10.5px * var(--text-scale, 1));
+ font-weight: 700;
+ color: #ffffff;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.75);
+ pointer-events: none;
+}
+/* v2026.5.47 — un rendez-vous en ERREUR (début == fin) tombe dans la zone
+ qu'on créerait : surbrillance ET bulle passent en rouge (le clic droit est
+ par ailleurs bloqué, cf. disabledReason). */
+.iv-exact-selpreview.danger {
+ border-color: #b03030;
+ background: rgba(176, 48, 48, 0.30);
+ background: color-mix(in srgb, #b03030 32%, transparent);
+}
+.iv-exact-selinfo.danger {
+ background: #b03030;
+ color: #ffffff;
+ border-color: #7a1f1f;
+}
+
/* ── Les cartes posées (héritent de .iv-mini-card : anatomie, conflits
rouges !important, liseré clos/résolu, tooltips, reschedule) ────────── */
html.view-horizontal .iv-exact-card {
@@ -7035,7 +7170,11 @@ html.view-horizontal .iv-exact-card {
height: auto !important;
pointer-events: auto;
flex: none;
- min-width: 0;
+ /* v2026.5.47 — largeur MINIMALE visible : une intervention très courte
+ (1-5 min) ou à durée nulle (10:00→10:00) a une largeur proportionnelle
+ quasi nulle → elle disparaissait. Ce plancher la garde visible (très fine),
+ sans élargir les bulles normales (plus larges que ce plancher). */
+ min-width: 6px;
z-index: 1;
container-type: inline-size;
border-radius: 4px;
@@ -7155,10 +7294,12 @@ html.view-horizontal .iv-exact-card .iv-mini-card-text-mini .iv-mini-mini-ville
html.view-horizontal.horizontal-exact .exact-hours-ruler {
display: block;
position: sticky;
- /* v2026.5.46 — top:0 : en vue horizontale la barre du haut est
- masquée, donc --stack-top (calé sur elle) envoyait la règle hors écran.
- La sidebar utilise déjà top:0 dans la même structure et tient. */
- top: 0;
+ /* v2026.5.47 — top suit --stack-top : la règle des heures reste collée en
+ haut au scroll, ET passe SOUS la banderole de session/reconnexion quand il y
+ en a une (au lieu de disparaître derrière), 0 sinon. (--stack-top = topbar
+ masquée en horizontal + hauteur des banderoles visibles ; corrigé depuis
+ des ajustements précédents, il ne sort plus la règle de l'écran.) */
+ top: var(--stack-top, 0px);
z-index: 20;
background: var(--bg);
/* v2026.5.46 — l'alignement n'est PLUS un calcul théorique (faux au
diff --git a/src/viewer.js b/src/viewer.js
index 641a7ba..65d0f65 100644
--- a/src/viewer.js
+++ b/src/viewer.js
@@ -17,6 +17,63 @@
// ============================================================================
// viewer.js — vue claire du planning techniciens
// ============================================================================
+// v2026.5.46 — bug « navigation rapide entre dates » : rendus périmés
+// refusés (verrous storeSyncFolder + pipeline 3bis + filet renderFromData).
+// v2026.5.46 — bug « tech retiré encore affiché » : la projection filtre
+// l'annuaire du jour par l'équipe configurée actuelle (TEAM).
+// v2026.5.46 — vue horizontale « échelle exacte » : lignes d'heures et
+// ligne « maintenant » bornées avant le bloc des absents journée entière ;
+// isTechAbsent évalue la plage configurée (DAY_START/DAY_END).
+// v2026.5.46 — bug « navigation rapide » (suite) : jeton d'annulation pris
+// dès l'entrée de loadForDate + gardes après les await pré-pipeline.
+// v2026.5.46 — même famille : garde du chemin de boot sans session
+// (refreshSessionAndLoad) contre une navigation pendant la lecture du store.
+// v2026.5.46 — retours de revue : propriété du pipeline (_loadPipelineToken,
+// « Arrêter » ciblé, 3bis avorté sans effet), re-rendu des réglages permis,
+// double plage pour « absent toute la journée », _dayRange() unique,
+// segments calculés une fois, filtre TEAM en hasOwnProperty.
+// v2026.5.46 — retours de revue (2e passe) : cycle de vie du bouton
+// « Arrêter » lié à la propriété du pipeline (plus de bouton fantôme, clic
+// sans pipeline sans effet), zones absentes adjacentes fusionnées (plus de
+// tronçons dans les interstices), dayLen dans _dayRange().
+// v2026.5.46 — bug « réservations multiples le même jour » : le créneau
+// (heures) entre dans ivKey/_ivAbsenceSig des réservations/absences — deux
+// créneaux d'un même tech/jour ne fusionnent plus (un seul s'affichait quand
+// EV partageait le request_id). + modal Modifier réservation : le N°
+// d'intervention auto-remplit ville/rue/catégorie (helper _wireResNumberLookup
+// partagé avec la création).
+// v2026.5.46 — passe /simplify : _fusionnerPlages() remplace 4 copies de
+// la fusion d'intervalles, _jourAffiche() nomme le prédicat « jour affiché »
+// (3 sites), tolérance des interstices dérivée du gap CSS réel, dayE
+// destructuré partout où dayStart+dayLen le re-dérivait.
+// v2026.5.46 — bug « boucle de conflit du cache dossier » : le CAS de
+// storeSyncFolder utilisait le savedAt renvoyé par « stat » (que l'hôte sert
+// depuis _index.json, parfois périmé) comme référence d'écriture, alors que
+// l'hôte compare le CAS au savedAt RELU dans le fichier. Index en retard =
+// conflit permanent, écriture jamais aboutie, index jamais rafraîchi (deadlock).
+// On prend désormais la référence CAS dans le fichier réellement lu et fusionné
+// (folder.savedAt), pas dans stat : l'écriture aboutit et guérit l'index.
+// v2026.5.46 — deux correctifs d'affichage (vue horizontale) :
+// 1. fond des cartes d'absence : en échelle exacte la carte est haute et le
+// dégradé « couleur → neutre à 40 % » laissait le bas vide → en vue
+// horizontale la couleur douce remplit toute la carte (CSS).
+// 2. plus de DOUBLON absence récurrente + vraie absence : si le tech a une
+// vraie absence EV ce jour (congé/maladie), l'absence récurrente configurée
+// ne s'affiche plus (fond/badge/note suivent la vraie absence).
+// v2026.5.47 — passage au numéro de version 2026.5.47 (la 2026.5.46 est publiée ;
+// le travail non poussé se fait désormais sous 2026.5.47). Pas de pull.
+// v2026.5.47 — vue horizontale : la barre de progression « Actualisation
+// X/Y » ne recouvre plus le haut de la sidebar (badge/titre/zone de reconnexion) ;
+// elle se cale au-dessus de la zone de CONTENU, à droite de la sidebar
+// (--sidebar-w mesuré en JS, robuste aux breakpoints/zoom).
+// v2026.5.47b — la barre passe SOUS la banderole de session/reconnexion
+// quand il y en a une (top = --stack-top, qui intègre la hauteur des banderoles),
+// au lieu de la recouvrir ; top:0 quand il n'y a pas de banderole.
+// v2026.5.47 — onglet Synchro : bouton « Tester la communication » qui fait
+// l'aller-retour extension → hôte (version) → dossier (stat réel) et affiche un
+// diagnostic lisible. Va de pair avec l'hôte natif v2026.1.8 (verrou dédié sur
+// _index.json contre les « lost update » multi-postes ; icône tray ré-affichée
+// au redémarrage d'explorer via TaskbarCreated).
// v2026.5.46 — refonte complète de la couche données : store unifié par
// jour (planv2_*), fusion unique vie/mort, verdict unique par intervention,
// projection rendu centralisée, sync dossier v2. Cf. section « COUCHE
@@ -255,7 +312,7 @@ window.addEventListener("unhandledrejection", (event) => {
LOG.info("boot", "viewer.js chargé", { version: LOG.version() });
LOG.warn("planification",
- `Planification v${LOG.version()} — couche données v2 (store unifié par jour, verdict unique, dossier partagé v2), vue horizontale « échelle de temps exacte », session EasyVista fiabilisée, réservations enrichies et partagées — Mode ${LOG.isDebug() ? "DEBUG" : "PROD"}.`);
+ `Planification v${LOG.version()} — Mode ${LOG.isDebug() ? "DEBUG" : "PROD"}.`);
// ============================================================================
// Configuration
@@ -755,7 +812,11 @@ function startNewRefresh() {
return currentRefreshToken;
}
function abortCurrentRefresh() {
- abortedToken = currentRefreshToken;
+ // v2026.5.46 — « Arrêter » vise le pipeline EN COURS (propriétaire du
+ // bouton) et LUI SEUL : sans pipeline actif il n'y a rien à annuler, et une
+ // navigation encore pré-pipeline (jeton pris à l'entrée) survit toujours —
+ // elle affichera son jour.
+ if (_loadPipelineToken !== null) abortedToken = _loadPipelineToken;
// Réveiller tous les loadForDate en attente (normalement un seul)
for (const [token, resolve] of abortResolvers) {
if (token <= currentRefreshToken) {
@@ -786,8 +847,9 @@ function abortCurrentRefresh() {
// - l'utilisateur a explicitement cliqué "Arrêter" (abortedToken).
// Sans myToken fourni (compat), on ne teste que l'abort explicite.
function isRefreshAborted(myToken) {
- if (abortedToken === currentRefreshToken) return true;
- if (typeof myToken === "number" && myToken < currentRefreshToken) return true;
+ if (typeof myToken !== "number") return abortedToken === currentRefreshToken;
+ if (abortedToken === myToken) return true; // « Arrêter » a visé CE flux
+ if (myToken < currentRefreshToken) return true; // un flux plus récent existe
return false;
}
function cleanupAbortResolver(myToken) {
@@ -897,7 +959,13 @@ async function refreshSessionAndLoad() {
if (!resp.ok || !resp.session) {
// si un store existe pour le jour demandé, on l'affiche avec une
// bannière "session expirée" sticky au-dessus. Sinon écran plein.
- const day = await storeLoad(state.currentDate);
+ const dateAuLancement = state.currentDate;
+ const day = await storeLoad(dateAuLancement);
+ // v2026.5.46 — l'utilisateur a pu naviguer pendant la lecture du store
+ // (boot sans session) : si la date affichée a changé entre-temps, on ne
+ // touche ni l'état ni l'écran (sinon les données du jour lu seraient
+ // affichées sous l'étiquette du nouveau jour).
+ if (state.currentDate !== dateAuLancement) return;
if (day) {
state.currentDay = day;
renderFromData({
@@ -4961,13 +5029,43 @@ function renderAdminSectionSync(container, cfg, saveFn) {
intro.style.cssText = SUB_HINT_STYLE;
container.appendChild(intro);
+ // v2026.5.47 — panneau réorganisé en SECTIONS logiques (retour Quentin :
+ // « c'était pas clair, Re-vérifier n'était pas au bon endroit »). Chaque action
+ // vit dans la section qui la concerne :
+ // 1. État → grille + « Re-vérifier l'état » (c'est lui qu'il rafraîchit)
+ // 2. Dossier → le chemin du partage
+ // 3. Script poste → installeur + « Tester la communication » + résultat
+ // 4. Journal → « Ouvrir dans un onglet » + mode détaillé + log en direct
+
+ // ── 1. État ──────────────────────────────────────────────────────────────
+ const etatTitle = document.createElement("div");
+ etatTitle.textContent = "État de la synchronisation";
+ etatTitle.style.cssText = SUB_TITLE_STYLE;
+ container.appendChild(etatTitle);
+
const syncStatus = document.createElement("div");
syncStatus.className = "admin-diag-grid";
syncStatus.innerHTML = `
État
⏳ vérification…
`;
container.appendChild(syncStatus);
+ const reprobeRow = document.createElement("div");
+ reprobeRow.style.cssText = "display:flex; gap:8px; margin-top:8px;";
+ const reprobeBtn = document.createElement("button");
+ reprobeBtn.type = "button";
+ reprobeBtn.className = "btn btn-subtle";
+ reprobeBtn.textContent = "🔁 Re-vérifier l'état";
+ reprobeBtn.addEventListener("click", () => _refreshSyncStatus());
+ reprobeRow.appendChild(reprobeBtn);
+ container.appendChild(reprobeRow);
+
+ // ── 2. Dossier partagé ───────────────────────────────────────────────────
+ const dossierTitle = document.createElement("div");
+ dossierTitle.textContent = "Dossier partagé";
+ dossierTitle.style.cssText = SUB_TITLE_STYLE;
+ container.appendChild(dossierTitle);
+
const pathRow = _makeAdminRow(
- "Dossier partagé (UNC ou lecteur réseau)",
+ "Chemin (UNC ou lecteur réseau)",
"Ex. \\\\SERVEUR\\partage\\…\\Cache — laissé vide = synchronisation désactivée."
);
const pathInput = document.createElement("input");
@@ -4989,34 +5087,45 @@ function renderAdminSectionSync(container, cfg, saveFn) {
pathRow.querySelector(".admin-row-control").appendChild(pathInput);
container.appendChild(pathRow);
- const btnRow = document.createElement("div");
- btnRow.style.cssText = "display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; align-items:center;";
+ // ── 3. Script de synchronisation (sur le poste) ──────────────────────────
+ const scriptTitle = document.createElement("div");
+ scriptTitle.textContent = "Script de synchronisation (sur le poste)";
+ scriptTitle.style.cssText = SUB_TITLE_STYLE;
+ container.appendChild(scriptTitle);
+
+ const scriptHint = document.createElement("p");
+ scriptHint.textContent = "Petit script à installer sur le poste Windows : il fait le lien avec le dossier partagé. Installe-le, puis teste la communication de bout en bout.";
+ scriptHint.style.cssText = SUB_HINT_STYLE;
+ container.appendChild(scriptHint);
+
+ const scriptRow = document.createElement("div");
+ scriptRow.style.cssText = "display:flex; gap:8px; flex-wrap:wrap; align-items:center;";
const dlLink = document.createElement("a");
// v2026.5.46 — le script hôte (PowerShell) est publié en ASSET de la release
// de la version courante : téléchargement direct du zip, prêt à dézipper et
// lancer (Gerer.cmd). URL construite avec la version → chaque version de
// l extension pointe vers SON installeur.
dlLink.href = "https://gitea.netaplaid.ch/FroSteel/Planification/releases/download/v" +
- ((typeof LOG !== "undefined" && LOG.version && LOG.version()) || "2026.5.46") +
+ ((typeof LOG !== "undefined" && LOG.version && LOG.version()) || "2026.5.47") +
"/planification-native-host.zip";
dlLink.target = "_blank";
dlLink.rel = "noopener";
dlLink.className = "btn";
dlLink.textContent = "⬇ Télécharger l'installeur";
- btnRow.appendChild(dlLink);
- const reprobeBtn = document.createElement("button");
- reprobeBtn.type = "button";
- reprobeBtn.className = "btn";
- reprobeBtn.textContent = "🔁 Re-vérifier";
- reprobeBtn.addEventListener("click", () => _refreshSyncStatus());
- btnRow.appendChild(reprobeBtn);
- const tabBtn = document.createElement("button");
- tabBtn.type = "button";
- tabBtn.className = "btn";
- tabBtn.textContent = "↗ Ouvrir le journal dans un onglet";
- tabBtn.addEventListener("click", () => nmShowLog());
- btnRow.appendChild(tabBtn);
- container.appendChild(btnRow);
+ scriptRow.appendChild(dlLink);
+ // v2026.5.47 — test de communication de bout en bout (extension → hôte →
+ // dossier). Rangé ici, avec l'installeur : on installe, puis on teste.
+ const testBtn = document.createElement("button");
+ testBtn.type = "button";
+ testBtn.className = "btn";
+ testBtn.textContent = "🔌 Tester la communication";
+ testBtn.addEventListener("click", () => _testComm());
+ scriptRow.appendChild(testBtn);
+ container.appendChild(scriptRow);
+
+ const testResult = document.createElement("pre");
+ testResult.style.cssText = "margin:8px 0 0 0; padding:10px; background:var(--bg-muted); border:1px solid var(--border); border-radius:6px; font-family:Consolas,monospace; font-size:12px; white-space:pre-wrap; word-break:break-word; display:none;";
+ container.appendChild(testResult);
// (Cache local : déplacé dans l'onglet « Données » — v2026.1.6.)
@@ -5025,6 +5134,18 @@ function renderAdminSectionSync(container, cfg, saveFn) {
logTitle.style.cssText = SUB_TITLE_STYLE;
container.appendChild(logTitle);
+ // v2026.5.47 — « Ouvrir dans un onglet » rangé AVEC le journal (c'est le
+ // journal qu'il ouvre en grand), plus dans un pêle-mêle de boutons.
+ const journalActions = document.createElement("div");
+ journalActions.style.cssText = "display:flex; gap:8px; margin:0 0 8px 0;";
+ const tabBtn = document.createElement("button");
+ tabBtn.type = "button";
+ tabBtn.className = "btn btn-subtle";
+ tabBtn.textContent = "↗ Ouvrir dans un onglet";
+ tabBtn.addEventListener("click", () => nmShowLog());
+ journalActions.appendChild(tabBtn);
+ container.appendChild(journalActions);
+
const verboseWrap = document.createElement("label");
verboseWrap.style.cssText = "display:flex; align-items:center; gap:8px; font-size:13px; margin:0 0 8px 0; cursor:pointer;";
const verboseChk = document.createElement("input");
@@ -5045,6 +5166,44 @@ function renderAdminSectionSync(container, cfg, saveFn) {
_loadSyncLog();
});
+ // v2026.5.47 — test de communication de bout en bout, sur demande
+ // (bouton). Trois étapes lisibles : hôte joignable + version, dossier
+ // configuré, puis aller-retour RÉEL (stat d'une date via l'hôte → prouve que
+ // l'hôte atteint le dossier partagé). Best-effort, jamais bloquant.
+ async function _testComm() {
+ testResult.style.display = "block";
+ testResult.textContent = "⏳ test en cours…";
+ const lines = [];
+ try {
+ const ok = await nmProbe(true);
+ if (!ok) {
+ testResult.textContent = "❌ Le script hôte ne répond pas (non installé ou arrêté).\n" +
+ "→ Télécharge et lance l'installeur, puis relance ce test.";
+ return;
+ }
+ lines.push(`✅ Script hôte joignable — v${_nmVersion || "?"} (communication extension ↔ hôte OK)`);
+ const path = (cfg.sharedCachePath || "").trim();
+ if (!path) {
+ lines.push("⚠️ Aucun dossier partagé défini — la synchronisation est désactivée.");
+ testResult.textContent = lines.join("\n");
+ return;
+ }
+ lines.push(`• Dossier : ${path}`);
+ const testDate = state.currentDate || new Date().toISOString().slice(0, 10);
+ const st = await nmStatDay(testDate, path);
+ if (st && typeof st.exists === "boolean") {
+ const maj = (st.exists && st.savedAt) ? ` (dernière maj ${new Date(st.savedAt).toLocaleString()})` : "";
+ lines.push(`✅ Dossier atteint — aller-retour hôte ↔ dossier OK.`);
+ lines.push(` Planning du ${testDate} : ${st.exists ? "présent dans le dossier" + maj : "absent du dossier"}.`);
+ } else {
+ lines.push("❌ Le dossier n'a pas répondu (chemin injoignable, hors réseau interne, ou droits insuffisants).");
+ }
+ } catch (e) {
+ lines.push("❌ Erreur pendant le test (voir console F12) : " + (e && e.message));
+ }
+ testResult.textContent = lines.join("\n");
+ }
+
async function _refreshSyncStatus() {
syncStatus.innerHTML = `État
⏳ vérification…
`;
let line;
@@ -5745,7 +5904,7 @@ function _lookupInterventionInfo(num) {
// créneau (repérées par technicien + heure de début). Best-effort.
async function _attachReservationMeta(techIds, isoDate, startHHMM, meta) {
try {
- const isCurrent = (state.currentDate === isoDate && state.currentDay && state.currentDay.date === isoDate);
+ const isCurrent = _jourAffiche(isoDate);
const day = isCurrent ? state.currentDay : await storeLoad(isoDate);
if (!day || !Array.isArray(day.ivs)) return;
const ids = (techIds || []).map(String);
@@ -5825,6 +5984,46 @@ async function _fetchReservationInfo(num) {
// v2026.5.46 — opts.startTime/opts.endTime : heures pré-remplies (clic
// droit sur la ligne d'un tech → créneau libre le plus proche du curseur).
+// v2026.5.46 — auto-remplissage ville/rue/catégorie à partir du N°
+// d'intervention (DS), partagé par les modals de création ET de modification
+// de réservation. Installe les écouteurs sur numField : à la saisie d'un n°
+// (>= 4 car.), remplit les champs cibles NON modifiés à la main — d'abord
+// depuis le cache planning (instantané), puis depuis le fetch EV réel. Une
+// barre de chargement optionnelle signale le fetch. Les champs déjà touchés
+// par le coordinateur ne sont jamais écrasés.
+function _wireResNumberLookup(numField, cibles) {
+ const { catSelect, villeField, rueField, loadingBar } = cibles;
+ let catTouched = false, villeTouched = false, rueTouched = false;
+ catSelect.addEventListener("change", () => { catTouched = true; });
+ villeField.addEventListener("input", () => { villeTouched = true; });
+ rueField.addEventListener("input", () => { rueTouched = true; });
+ const apply = (info) => {
+ if (!info) return;
+ if (!catTouched && info.categorie) catSelect.value = info.categorie;
+ if (!villeTouched && info.ville) villeField.value = info.ville;
+ if (!rueTouched && info.rue) rueField.value = info.rue;
+ };
+ let seqRef = 0, debounce = null, lastNum = null;
+ const trigger = () => {
+ const num = numField.value.trim();
+ if (num.length < 4) { if (loadingBar) loadingBar.classList.add("hidden"); return; } // pas encore "complet"
+ // déjà recherché tel quel → on ne relance PAS (le 'change' au blur du champ,
+ // ex. clic sur la date, relançait un fetch identique). Seule une
+ // MODIFICATION du n° re-déclenche.
+ if (num === lastNum) return;
+ lastNum = num;
+ LOG.info("reservation", `n° saisi : ${num} → recherche infos (planning + EV)`);
+ apply(_lookupInterventionInfo(num)); // instantané (cache planning)
+ const seq = ++seqRef; // fetch EV réel en arrière-plan
+ if (loadingBar) loadingBar.classList.remove("hidden");
+ _fetchReservationInfo(num).then(info => {
+ if (seq === seqRef) { apply(info); if (loadingBar) loadingBar.classList.add("hidden"); }
+ });
+ };
+ numField.addEventListener("change", trigger);
+ numField.addEventListener("input", () => { clearTimeout(debounce); debounce = setTimeout(trigger, 700); });
+}
+
function showReservationModal(preselectTechId, opts = {}) {
const existing = document.getElementById("reservation-modal");
if (existing) existing.remove();
@@ -5916,39 +6115,9 @@ function showReservationModal(preselectTechId, opts = {}) {
const rueField = _mkField("Rue / chemin / route", "");
card.appendChild(fieldGrid);
- // Auto-remplissage depuis le n° (sans écraser ce que le coordinateur a saisi)
- let catTouched = false, villeTouched = false, rueTouched = false;
- catSelect.addEventListener("change", () => { catTouched = true; });
- villeField.addEventListener("input", () => { villeTouched = true; });
- rueField.addEventListener("input", () => { rueTouched = true; });
- const _applyResInfo = (info) => {
- if (!info) return;
- if (!catTouched && info.categorie) catSelect.value = info.categorie;
- if (!villeTouched && info.ville) villeField.value = info.ville;
- if (!rueTouched && info.rue) rueField.value = info.rue;
- };
- let _resFetchSeq = 0, _resDebounce = null, _resLastLookupNum = null;
- const _triggerResLookup = () => {
- const num = numField.value.trim();
- if (num.length < 4) { resLoadingBar.classList.add("hidden"); return; } // pas encore "complet"
- // v2026.5.46 — déjà recherché tel quel → on ne relance PAS (le blur du
- // champ déclenche 'change' quand on clique ailleurs, ex. la date, et
- // relançait un fetch identique). Seule une MODIFICATION du n° re-déclenche.
- if (num === _resLastLookupNum) return;
- _resLastLookupNum = num;
- LOG.info("reservation", `n° saisi : ${num} → recherche infos (planning + EV)`);
- _applyResInfo(_lookupInterventionInfo(num)); // instantané (cache planning)
- const seq = ++_resFetchSeq; // fetch EV réel en arrière-plan
- resLoadingBar.classList.remove("hidden"); // barre de chargement pendant le fetch EV
- _fetchReservationInfo(num).then(info => {
- if (seq === _resFetchSeq) { _applyResInfo(info); resLoadingBar.classList.add("hidden"); }
- });
- };
- numField.addEventListener("change", _triggerResLookup);
- numField.addEventListener("input", () => {
- clearTimeout(_resDebounce);
- _resDebounce = setTimeout(_triggerResLookup, 700);
- });
+ // v2026.5.46 — auto-remplissage ville/rue/catégorie depuis le n°
+ // d'intervention : logique partagée avec la modal de MODIFICATION.
+ _wireResNumberLookup(numField, { catSelect, villeField, rueField, loadingBar: resLoadingBar });
// Boutons
const actions = document.createElement("div"); actions.className = "modal-actions";
@@ -6078,6 +6247,15 @@ function showReservationEditModal(iv) {
const rueField = _mkField("Rue / chemin / route", iv.resRue, "");
card.appendChild(grid);
+ // v2026.5.46 — comme à la création : saisir/ajouter un N° d'intervention
+ // (DS) récupère et remplit ville/rue/catégorie automatiquement (helper
+ // partagé). Avant, la modal de modification laissait ces champs à la main.
+ const editLoadingBar = document.createElement("div");
+ editLoadingBar.className = "res-loading-bar hidden";
+ editLoadingBar.style.marginTop = "6px";
+ card.appendChild(editLoadingBar);
+ _wireResNumberLookup(numField, { catSelect, villeField, rueField, loadingBar: editLoadingBar });
+
const actions = document.createElement("div"); actions.className = "modal-actions"; actions.style.marginTop = "14px";
const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "btn btn-secondary"; cancelBtn.textContent = "Annuler";
cancelBtn.addEventListener("click", () => overlay.remove());
@@ -6682,9 +6860,19 @@ const IV_FOLDER_DROP_FIELDS = [
/** Identité stable d'une iv : requestId > actionId > ref. @author QRO */
function ivKey(iv) {
- if (iv.requestId) return "q:" + iv.requestId;
- if (iv.actionId !== undefined && iv.actionId !== null) return "a:" + iv.actionId;
- if (iv.ref) return "r:" + iv.ref;
+ // v2026.5.46 — réservations / absences : le CRÉNEAU fait partie de
+ // l'identité. EasyVista peut rattacher le MÊME request_id (ou recycler
+ // l'actionId) à plusieurs créneaux du même jour pour un même tech (ex. deux
+ // réservations 15h-16h et 16h-17h liées à la même intervention). Sans le
+ // créneau, ivKey renvoyait la même clé pour les deux → dayDedupeIvs les
+ // fusionnait et une seule s'affichait. Les vraies interventions (draggables,
+ // request_id unique) gardent une clé inchangée : slot vide.
+ const slot = (iv.type === "AL-Reservation" || iv.type === "AL-Absence")
+ ? "@" + (iv.startDate || "") + "T" + (iv.startTime || "")
+ : "";
+ if (iv.requestId) return "q:" + iv.requestId + slot;
+ if (iv.actionId !== undefined && iv.actionId !== null) return "a:" + iv.actionId + slot;
+ if (iv.ref) return "r:" + iv.ref + slot;
return null;
}
@@ -6721,8 +6909,7 @@ function _minToHHMMSafe(min) {
function _ivIsFullDayShown(iv) {
const s = timeToMinutes(_ivShownStart(iv));
const e = timeToMinutes(_ivShownEnd(iv));
- const ds = (typeof DAY_START === "number") ? DAY_START : 8 * 60;
- const de = (typeof DAY_END === "number") ? DAY_END : 18 * 60;
+ const { dayS: ds, dayE: de } = _dayRange();
return s !== null && e !== null && s <= ds && e >= de;
}
// Pose (ou retire) les heures d'affichage d'une iv pour le jour donné.
@@ -6733,8 +6920,9 @@ function _ivStampDisplayTimes(iv, isoDate) {
const startIso = _ddmmyyyyToIso(iv.startDate);
const endIso = _ddmmyyyyToIso(iv.endDate);
if (!startIso || !endIso) return;
- const dayStartHHMM = _minToHHMMSafe(typeof DAY_START === "number" ? DAY_START : 8 * 60);
- const dayEndHHMM = _minToHHMMSafe(typeof DAY_END === "number" ? DAY_END : 18 * 60);
+ const { dayS, dayE } = _dayRange();
+ const dayStartHHMM = _minToHHMMSafe(dayS);
+ const dayEndHHMM = _minToHHMMSafe(dayE);
if (isoDate === startIso) { // 1er jour : début réel → fin de journée
iv._dispStartTime = iv.startTime || dayStartHHMM;
iv._dispEndTime = dayEndHHMM;
@@ -7094,7 +7282,13 @@ function _ivApplyLiveFields(rec, iv, tech, now) {
// Signature composite résa/absence (EV recycle leur actionId entre requêtes).
function _ivAbsenceSig(iv, techId) {
- return [techId, iv.type, iv.startDate || "", iv.endDate || "", iv.isPompier ? "P" : "", (iv.label || "").trim()].join("|");
+ // v2026.5.46 — le créneau (heures de début/fin) entre dans la signature :
+ // deux réservations du même tech le même jour ne diffèrent QUE par leur
+ // horaire ; sans lui, ce filet de secours (matching quand ni actionId ni réf
+ // ne collent) les confondait et fusionnait la seconde sur la première.
+ return [techId, iv.type, iv.startDate || "", iv.endDate || "",
+ iv.startTime || "", iv.endTime || "",
+ iv.isPompier ? "P" : "", (iv.label || "").trim()].join("|");
}
/**
@@ -7316,7 +7510,18 @@ function storeMarkMoved(day, rec, change) {
*/
function storeProject(day, isoDate) {
if (!day) return [];
- const techs = (day.techs || []).map(t => ({ ...t, interventions: [] }));
+ // v2026.5.46 — l'affichage suit l'ÉQUIPE ACTUELLE (Paramètres → Équipe).
+ // L'annuaire du jour (day.techs) peut encore contenir un tech retiré de la
+ // config : la fusion dossier (dayMerge) fait l'UNION avec les fichiers du
+ // partage, écrits avant le retrait ou par un poste dont l'équipe diffère.
+ // On filtre à la PROJECTION seulement : le store et le dossier partagé
+ // conservent tout (re-ajouter le tech le fait réapparaître avec son
+ // historique, les autres postes ne perdent rien). TEAM vide (config pas
+ // encore chargée / première installation) → pas de filtre.
+ const hasTeam = !!TEAM && Object.keys(TEAM).length > 0;
+ const techs = (day.techs || [])
+ .filter(t => !hasTeam || Object.prototype.hasOwnProperty.call(TEAM, String(t.id)))
+ .map(t => ({ ...t, interventions: [] }));
const byTech = new Map(techs.map(t => [String(t.id), t]));
for (const iv of (day.ivs || [])) {
if (ivIsDead(iv)) continue;
@@ -7343,6 +7548,12 @@ function storeProject(day, isoDate) {
return techs;
}
+/** Le jour isoDate est-il celui actuellement affiché (état + entrée mémoire) ? */
+function _jourAffiche(isoDate) {
+ return state.currentDate === isoDate && !!state.currentDay &&
+ state.currentDay.date === isoDate;
+}
+
/** Re-rend le jour courant depuis le store en mémoire (après mutation). */
function storeRenderCurrent(source) {
if (!state.currentDay || typeof renderFromData !== "function") return;
@@ -7372,6 +7583,11 @@ function storeRenderCurrent(source) {
// remplacer state.currentDay (et ses références partagées avec le rendu et la
// file d'enrichissement) en plein milieu des mutations.
let _loadPipelineDate = null;
+// v2026.5.46 — jeton du pipeline PROPRIÉTAIRE de _loadPipelineDate : seul
+// lui a le droit de retirer le report de convergence dans son finally (un
+// pipeline avorté de la même date ne doit pas retirer le verrou posé par son
+// successeur). Sert aussi de cible au bouton « Arrêter ».
+let _loadPipelineToken = null;
const NM_HOST = "ch.netaplaid.planification";
const NM_HOST_REQUIRED_VERSION = "2026.1.7";
@@ -7771,8 +7987,7 @@ async function storeSyncFolder(isoDate, opts = {}) {
// d'enrichissement non encore persistées incluses), sinon le disque.
// Garde-fou : currentDay doit bien être l'entrée de CETTE date (course
// possible pendant une navigation rapide entre jours).
- const isCurrent = (state.currentDate === isoDate && state.currentDay &&
- state.currentDay.date === isoDate);
+ const isCurrent = _jourAffiche(isoDate);
const local = isCurrent ? state.currentDay : await storeLoad(isoDate);
const st = await nmStatDay(isoDate, path);
const folderExists = !!(st && st.exists);
@@ -7783,6 +7998,14 @@ async function storeSyncFolder(isoDate, opts = {}) {
const folder = folderExists
? await nmReadDay(isoDate, path, `Restauration du planning du ${fr} depuis le dossier`)
: null;
+ // v2026.5.46 — référence CAS = savedAt DU FICHIER réellement lu,
+ // CAPTURÉ AVANT toute fusion/mutation. dayMerge peut renvoyer folder PAR
+ // RÉFÉRENCE quand le store local est vide (jour présent seulement dans le
+ // dossier) : le merged.savedAt = Date.now() ci-dessous écraserait alors
+ // folder.savedAt et le CAS repartirait sur une valeur fausse. On ne se
+ // fie donc PAS à folderAt (issu de « stat »/_index.json, parfois périmé) ;
+ // folderAt ne sert que de secours si la lecture du fichier a échoué.
+ const folderFileAt = (folder && typeof folder.savedAt === "number") ? folder.savedAt : folderAt;
const merged = dayMerge(local, folder);
if (!merged) return;
// contenu fusionné = NOUVEL état → nouveau savedAt. Sans ça, on
@@ -7790,18 +8013,34 @@ async function storeSyncFolder(isoDate, opts = {}) {
// poste tiers passerait à tort, et les postes à localAt === folderAt
// ne tireraient jamais cette fusion.
merged.savedAt = Date.now();
- const pulledNewer = folderExists && folderAt > localAt;
+ // v2026.5.46 — « le dossier est plus récent » se juge sur le savedAt
+ // RÉEL du fichier lu (folderFileAt), pas sur folderAt (stat/index périmé) :
+ // sinon un fichier plus récent d'un autre poste ne re-rendait pas.
+ const pulledNewer = folderExists && folderFileAt > localAt;
// 1. aligner le store local sur la fusion (set brut → ne re-déclenche pas le push)
await chrome.storage.local.set({
[STORE_PREFIX + isoDate]: { ...merged, ivs: merged.ivs.map(iv => ivStrip(iv, false)) }
});
- if (isCurrent) {
+ // v2026.5.46 — verrou anti-rendu périmé (navigation rapide).
+ // La date affichée a pu changer pendant les await natifs ci-dessus
+ // (lectures du partage réseau : plusieurs secondes) : on re-évalue avant
+ // de toucher l'état à l'écran, sinon le jour quitté écrase le jour
+ // affiché. Un appel de pipeline (fromPipeline) ne garde la main que si
+ // SON jeton est encore vivant : le 3bis d'un pipeline avorté ne doit
+ // ni remplacer state.currentDay ni re-rendre. Hors pipeline, un
+ // pipeline actif sur cette date garde la main sur state.currentDay.
+ const pipelineVivant = typeof opts.myToken === "number" && !isRefreshAborted(opts.myToken);
+ const stillCurrent = _jourAffiche(isoDate) &&
+ (opts.fromPipeline ? pipelineVivant : _loadPipelineDate !== isoDate);
+ if (stillCurrent) {
state.currentDay = merged;
if (pulledNewer) storeRenderCurrent("dossier-sync");
}
- // 2. pousser la fusion vers le dossier (compare-and-swap)
+ // 2. pousser la fusion vers le dossier (compare-and-swap sur folderFileAt,
+ // la référence capturée AVANT la fusion — cf. plus haut : ce que l'hôte
+ // relira dans le fichier pour comparer).
const r = await nmSaveDay(isoDate, path, merged, `Synchronisation du planning du ${fr} (fusion)`,
- folderExists ? folderAt : null);
+ folderExists ? folderFileAt : null);
if (r && r.ok) return;
if (r && (r.conflict || r.locked)) { // qqn a écrit entre-temps → on refusionne
await new Promise(res => setTimeout(res, 800 + attempt * 500));
@@ -7851,25 +8090,49 @@ async function loadForDate(isoDate, opts = {}) {
document.getElementById("date-picker").value = isoDate;
updateDatePickerDayLabel(isoDate); // v2026.5.16 : label "Mardi" à côté
+ // v2026.5.46 — le jeton d'annulation se prend DÈS L'ENTRÉE, avant le
+ // moindre await. Pris après la lecture du store (comme avant), deux
+ // navigations rapprochées pouvaient s'inverser : si la lecture du jour
+ // cliqué EN PREMIER répondait après celle du second, le premier prenait un
+ // jeton plus récent et invalidait le jour réellement affiché
+ // (state.currentDay corrompu, enrichissement du jour à l'écran stoppé).
+ // L'ordre des jetons suit désormais l'ordre des clics.
+ const myToken = startNewRefresh();
+
// Ordre de chargement (perf) : on ne bloque le rendu sur la
// réconciliation dossier QUE si c'est indispensable :
// - jour passé SANS store local → besoin du dossier pour afficher qqch ;
// - session absente → pas de fetch EV pour rafraîchir, autant réconcilier.
// Sinon → rendu immédiat depuis le store, convergence dossier en arrière-plan.
let day = await storeLoad(isoDate);
+ if (isRefreshAborted(myToken)) return; // v2026.5.46 — navigation entre-temps
state.currentDay = day;
const _staleSavedAt = day ? (day.savedAt || 0) : 0; // âge du store AVANT ce chargement
- // v2026.5.46 — la convergence dossier vit DANS le pipeline (étape 3bis,
- // juste après le fetch EV) : on ne la fait en amont QUE sans session (pas de
- // fetch possible → le dossier est la seule source).
- const _reconcileUpFront = !state.session;
+ // v2026.5.46 — la convergence dossier vit DANS le pipeline (étape 3bis, juste
+ // après le fetch EV) ; on ne la fait EN AMONT que dans les deux cas où le
+ // dossier est la seule chose à montrer d'emblée :
+ // - session absente → pas de fetch EV possible ;
+ // - v2026.5.47 — store local VIDE mais jour présent dans le dossier :
+ // sans ça, le pipeline rendait le SQUELETTE EV puis « rafraîchissait » en
+ // 3bis (lecture du partage ~plusieurs secondes) → l'utilisateur voyait un
+ // re-remplissage inutile alors que le dossier avait déjà tout. On lit donc
+ // le dossier d'abord et on affiche directement les cartes complètes.
+ // (Le commentaire d'ordre de chargement plus haut prévoyait déjà ce cas
+ // « jour sans store local → besoin du dossier », jamais implémenté.)
+ const _reconcileUpFront = !state.session || !day;
if (_reconcileUpFront) {
await reconcileCacheForDate(isoDate);
+ if (isRefreshAborted(myToken)) return; // v2026.5.46 — navigation entre-temps
day = await storeLoad(isoDate);
+ if (isRefreshAborted(myToken)) return; // v2026.5.46 — navigation entre-temps
state.currentDay = day;
}
if (!state.session) {
+ // v2026.5.46 — rien ne se charge dans cet état : ne pas laisser un
+ // bouton « Arrêter » fantôme d'un pipeline mort (session tombée en vol,
+ // pipeline supplanté → son finally ne masque plus le bouton lui-même).
+ showAbortButton(false);
// afficher le store + bannière, plutôt que l'écran "session"
if (day) {
renderFromData({
@@ -7885,11 +8148,11 @@ async function loadForDate(isoDate, opts = {}) {
return;
}
- // (v3.1.1) Tout chargement = un nouveau jeton d'annulation. Le bouton
- // "Arrêter" apparaît pour TOUT refresh ; il disparaît quand le chargement
- // est vraiment fini (finally).
- const myToken = startNewRefresh();
+ // (v3.1.1) Le bouton "Arrêter" apparaît pour TOUT refresh ; il disparaît
+ // quand le chargement est vraiment fini (finally). Le jeton d'annulation,
+ // lui, est pris dès l'entrée de la fonction (ordre des clics).
_loadPipelineDate = isoDate; // suspend la convergence dossier le temps du pipeline
+ _loadPipelineToken = myToken; // ce pipeline devient propriétaire du report
showAbortButton(true);
const t0 = performance.now();
LOG.info("load", `début pour ${isoDate} (token=${myToken})`);
@@ -7948,7 +8211,12 @@ async function loadForDate(isoDate, opts = {}) {
// traitera plus que ce qui MANQUE encore après cette fusion.
if (!isRefreshAborted(myToken)) {
const tSync = performance.now();
- try { await storeSyncFolder(isoDate, { fromPipeline: true }); } catch (e) { /* best-effort */ }
+ try { await storeSyncFolder(isoDate, { fromPipeline: true, myToken }); } catch (e) { /* best-effort */ }
+ // v2026.5.46 — re-test après l'attente du host natif.
+ // La sync dossier vient d'attendre le partage réseau (secondes) : un
+ // autre jour a pu être chargé entre-temps ; sans ce re-test, le rendu
+ // ci-dessous réaffichait le jour quitté par-dessus celui à l'écran.
+ if (isRefreshAborted(myToken)) return;
if (state.currentDay && state.currentDay.date === isoDate) day = state.currentDay;
renderFromData({
techs: storeProject(day, isoDate),
@@ -8024,10 +8292,14 @@ async function loadForDate(isoDate, opts = {}) {
if (wasExplicitAbort) showAbortToast();
}
} finally {
- if (_loadPipelineDate === isoDate) _loadPipelineDate = null; // secours (abort/erreur)
- // Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui
- // se termine (pas un chargement postérieur lancé entre-temps).
- if (currentRefreshToken === myToken) {
+ // v2026.5.46 — seul le pipeline PROPRIÉTAIRE nettoie le report de
+ // convergence ET masque le bouton « Arrêter » : un pipeline avorté de la
+ // même date ne doit pas retirer le verrou posé par son successeur, et le
+ // bouton appartient au pipeline (un successeur l'a soit repris à son
+ // compte, soit masqué lui-même sur son chemin sans session).
+ if (_loadPipelineToken === myToken) {
+ _loadPipelineDate = null;
+ _loadPipelineToken = null;
showAbortButton(false);
}
cleanupAbortResolver(myToken);
@@ -10172,12 +10444,17 @@ function clearCheckMark() {
function showProgressBar() {
const bar = document.getElementById("progress-bar");
if (bar) bar.classList.remove("hidden");
+ // v2026.5.47 — en vue horizontale, la barre est un overlay en haut de la
+ // sidebar : cette classe réserve sa hauteur (CSS) pour que les initiales/titre
+ // passent DESSOUS au lieu d'être recouverts.
+ document.documentElement.classList.add("planif-refreshing");
if (typeof _srSyncStickyOffsets === "function") _srSyncStickyOffsets();
}
function hideProgressBar() {
const bar = document.getElementById("progress-bar");
if (bar) bar.classList.add("hidden");
+ document.documentElement.classList.remove("planif-refreshing");
updateProgressBar(0, 0);
if (typeof _srSyncStickyOffsets === "function") _srSyncStickyOffsets();
}
@@ -10229,6 +10506,24 @@ function showAbortToast() {
// ré-analyse. Les verdicts gone/moved et les tombstones ne sont jamais rendus
// (sauf case diagnostic « Garder les disparitions »).
function renderFromData(data) {
+ // v2026.5.46 — filet global anti-rendu périmé.
+ // Un rendu construit pour un AUTRE jour que celui à l'écran est refusé
+ // (chargement ou synchro asynchrone qui aboutit après une navigation
+ // rapide entre dates ; tous les appelants légitimes passent le jour
+ // affiché en targetDate — vérifié sur les 7 appels existants).
+ // (re-rendre CE QUI EST DÉJÀ À L'ÉCRAN reste permis pendant un
+ // chargement d'une autre date — les réglages repassent state.currentData
+ // tel quel, même référence, pour rafraîchir vue/stats/bandeaux.
+ // CONTRAT : l'exemption exige le MÊME OBJET — pas de copie, pas de capture
+ // avant un await. L'identité de référence est un choix : une comparaison
+ // par date laisserait passer un rendu périmé tant que l'écran affiche
+ // encore l'ancienne date, picker et cartes mélangés — le bug d'origine.)
+ if (data && data.targetDate && state.currentDate &&
+ data.targetDate !== state.currentDate && data !== state.currentData) {
+ LOG.warn("render", "rendu périmé refusé (autre date que celle à l'écran)",
+ { targetDate: data.targetDate, currentDate: state.currentDate, source: data.source });
+ return;
+ }
// v2026.5.46 — TOUS les filtres d'affichage vivent dans storeProject
// (tombstones, verdicts gone/moved, disparues jamais analysées, dock,
// déplacées récentes) : les appelants passent une projection. On garde ici
@@ -10797,7 +11092,7 @@ function compareTechs(a, b, targetDate) {
// absences (éventuellement partielles). Avant, une absence matin 08-12 seule
// faisait passer le tech en "absent toute la journée" car il n'avait QUE des
// absences. Maintenant on check explicitement que l'absence couvre ≥ 90% de
-// la plage 08:00-18:00.
+// la plage AFFICHÉE (DAY_START-DAY_END, réglable ; 8h-18h par défaut).
function isTechAbsent(tech, isoDate) {
const recurring = RECURRING_ABSENCES[tech.id];
if (recurring && isoDate) {
@@ -10805,6 +11100,8 @@ function isTechAbsent(tech, isoDate) {
if (recurring.includes(day)) return true;
}
if (tech.interventions.length === 0) return false;
+ const { dayS, dayE, dayLen } = _dayRange(); // invariant : hors de la boucle (tri à chaud)
+ const TRAVAIL_DEBUT = 8 * 60, TRAVAIL_FIN = 18 * 60; // journée de travail standard
// Parmi les absences (hors pompier), est-ce qu'une seule couvre la journée ?
const fullDayAbsences = tech.interventions.filter(iv => {
if (iv.type !== "AL-Absence" || iv.isPompier) return false;
@@ -10818,11 +11115,18 @@ function isTechAbsent(tech, isoDate) {
// Absence couvre toute la journée si son créneau déborde largement
// la plage affichée (≥ 90%). Une demi-journée (4h) sur 10h = 40% → ne
// passera pas, donc on ne marquera pas le tech comme absent toute la journée.
- const DAY_LEN_MIN = 10 * 60; // 08:00 → 18:00 = 10h
- const clampedStart = Math.max(startMin, 8 * 60);
- const clampedEnd = Math.min(endMin, 18 * 60);
- const coveredMin = Math.max(0, clampedEnd - clampedStart);
- return coveredMin >= 0.9 * DAY_LEN_MIN;
+ // v2026.5.46 — « toute la journée » se juge sur DEUX plages ; il
+ // suffit d'en couvrir une à 90 % ou plus :
+ // - la plage AFFICHÉE (DAY_START/DAY_END, réglable) : une absence qui
+ // remplit visuellement la journée à l'écran classe le tech absent
+ // (l'ancienne plage 8-18 figée créait des faux négatifs dès que les
+ // heures configurées changeaient) ;
+ // - la journée de travail STANDARD 08:00-18:00 : une absence EV posée
+ // sur toute la journée de travail reste « toute la journée » même si
+ // l'écran affiche une plage plus large (ex. 06:00-20:00).
+ const covAffichee = Math.max(0, Math.min(endMin, dayE) - Math.max(startMin, dayS));
+ const covTravail = Math.max(0, Math.min(endMin, TRAVAIL_FIN) - Math.max(startMin, TRAVAIL_DEBUT));
+ return covAffichee >= 0.9 * dayLen || covTravail >= 0.9 * (TRAVAIL_FIN - TRAVAIL_DEBUT);
});
return fullDayAbsences.length > 0;
}
@@ -10998,8 +11302,7 @@ function buildCard(tech, isoDate) {
if (startMin == null && typeof _cardCursorToProjectedStart === "function") {
startMin = _cardCursorToProjectedStart(card, ev, 60, isHorizontal, { noVirtualPeriods: true });
}
- const dayS = (typeof DAY_START === "number") ? DAY_START : 8 * 60;
- const dayE = (typeof DAY_END === "number") ? DAY_END : 18 * 60;
+ const { dayS, dayE } = _dayRange();
if (startMin == null) startMin = dayS;
startMin = Math.max(dayS, Math.min(startMin, dayE - 30));
const endMin = Math.min(startMin + 60, dayE);
@@ -11024,6 +11327,20 @@ function buildCard(tech, isoDate) {
}
if (cov >= dayE) disabledReason = "Journée complète — aucun créneau libre";
}
+ // v2026.5.47 — un rendez-vous EN ERREUR (début == fin, ex. 10:00→10:00)
+ // dans la zone qu'on créerait → on BLOQUE le menu (boutons grisés) pour
+ // forcer sa correction avant d'empiler un autre RDV par-dessus (prioritaire
+ // sur « journée complète »).
+ for (const iv2 of (tech.interventions || [])) {
+ if (iv2.type !== "AL-Intervention") continue;
+ const s2 = timeToMinutes(_ivShownStart(iv2));
+ const e2 = timeToMinutes(_ivShownEnd(iv2));
+ if (s2 === null || e2 === null || e2 > s2) continue; // pas une erreur durée nulle
+ if (s2 >= startMin && s2 <= endMin) {
+ disabledReason = "Rendez-vous en erreur (début = fin) à corriger d'abord";
+ break;
+ }
+ }
LOG.info("reservation", `clic droit carte → menu rapide`, { techId: tech.id, startMin, endMin, bloque: disabledReason || false });
_showQuickAddMenu(ev, tech, _minToHHMMSafe(startMin), _minToHHMMSafe(endMin), disabledReason);
});
@@ -11033,6 +11350,12 @@ function buildCard(tech, isoDate) {
// v2026.5.41 : absence récurrente générique (configurée par tech dans
// Paramètres → Équipe). Code couleur cyan (comme Congé).
const isRecurring = isRecurringAbsence(tech, isoDate);
+ // v2026.5.46 — pas de DOUBLON absence récurrente + vraie absence : si le
+ // tech a une VRAIE absence EV ce jour (congé/maladie, hors pompier), on
+ // n'affiche PAS en plus l'absence récurrente configurée (la vraie prime).
+ // showRecurring pilote fond, badge et note « Absent le ».
+ const hasRealAbsence = tech.interventions.some(iv => iv.type === "AL-Absence" && !iv.isPompier);
+ const showRecurring = isRecurring && !hasRealAbsence;
if (isPompier) card.classList.add("is-pompier");
if (isAbsent) card.classList.add("is-absent");
@@ -11042,7 +11365,7 @@ function buildCard(tech, isoDate) {
// v2026.5.30 : les absences récurrentes hardcodées prennent la catégorie
// "recurring" (même cyan que Congé, texte distinct).
let absenceCategory = null; // "maladie" | "conge" | "pompier" | "recurring" | null
- if (isRecurring) {
+ if (showRecurring) {
absenceCategory = "recurring";
} else if (isPompier) {
absenceCategory = "pompier";
@@ -11146,7 +11469,7 @@ function buildCard(tech, isoDate) {
const badge = document.createElement("div");
badge.className = "card-tech-badge";
- if (isRecurring) {
+ if (showRecurring) {
badge.classList.add("badge-recurring");
badge.textContent = "Absent";
} else if (isPompier) {
@@ -11324,9 +11647,10 @@ function buildCard(tech, isoDate) {
}
}
- // v2026.5.41 : absence récurrente générique. Affichage d'un message
- // "Absent le " priorité, même si un bloc AL-Absence couvre le jour.
- const isRecurringDay = isRecurringAbsence(tech, isoDate);
+ // v2026.5.41 : absence récurrente générique — message "Absent le ".
+ // v2026.5.46 : ne s'affiche QUE s'il n'y a pas déjà une vraie absence EV
+ // ce jour (showRecurring) — plus de doublon « Absent le vendredi » + « Congé ».
+ const isRecurringDay = showRecurring;
// Absent sans interv → on stop là (après avoir posé le message récurrent si applicable).
if (isAbsent && realInterventions.length === 0) {
@@ -11595,7 +11919,20 @@ function buildCard(tech, isoDate) {
// dans init(). Défaut 8h-18h.
let DAY_START = 8 * 60; // 08:00 en minutes
let DAY_END = 18 * 60; // 18:00 en minutes
+
let DAY_LEN = DAY_END - DAY_START;
+
+// v2026.5.46 — accès canonique à la plage horaire affichée (remplace les
+// copies dispersées de « typeof DAY_START === "number" ? ... : 8 * 60 » :
+// une seule définition des valeurs de repli).
+function _dayRange() {
+ const dayS = (typeof DAY_START === "number") ? DAY_START : 8 * 60;
+ const dayE = (typeof DAY_END === "number") ? DAY_END : 18 * 60;
+ const dayLen = (typeof DAY_LEN === "number" && DAY_LEN > 0)
+ ? DAY_LEN
+ : Math.max(1, dayE - dayS);
+ return { dayS, dayE, dayLen };
+}
async function _initDayBoundsFromConfig() {
try {
const cfg = await loadAdminConfig();
@@ -11656,6 +11993,17 @@ function _buildOneMiniCard(iv, realInterventions, cardEl, opts) {
const ref = iv.ref || "";
const startTime = _ivShownStart(iv);
const endTime = _ivShownEnd(iv);
+ // v2026.5.47 — intervention à durée NULLE (début == fin, ex. 10:00→10:00) :
+ // erreur de saisie EV → rouge « comme un conflit » (classe iv-zero-duration).
+ // Sans ça elle est invisible en vue exacte (largeur 0) et rien ne signale
+ // l'erreur dans les autres vues.
+ if (iv.type === "AL-Intervention") {
+ const _sMin = timeToMinutes(startTime);
+ const _eMin = timeToMinutes(endTime);
+ if (_sMin !== null && _eMin !== null && _eMin <= _sMin) {
+ card.classList.add("iv-zero-duration");
+ }
+ }
// Barre couleur (4px à gauche)
// v2026.5.46 — mode exact : pour une intervention déplaçable, la
@@ -11967,8 +12315,7 @@ function _buildMiniCardsRow(realInterventions, cardEl) {
// « Matin – Après-midi » si un élément restant chevauche midi (11h→13h :
// carte entière, pas de coupure). Journée seule → un seul bloc pleine largeur.
const MIDI = 12 * 60;
- const dayS = (typeof DAY_START === "number") ? DAY_START : 8 * 60;
- const dayE = (typeof DAY_END === "number") ? DAY_END : 18 * 60;
+ const { dayS, dayE } = _dayRange();
const fullDay = sorted.filter(o => o.s <= dayS && o.e >= dayE);
const rest = sorted.filter(o => !(o.s <= dayS && o.e >= dayE));
const crossesMid = rest.some(o => o.s < MIDI && o.e > MIDI);
@@ -12114,16 +12461,32 @@ function _buildExactRow(realInterventions, cardEl, isAbsent) {
for (const iv of realInterventions) {
const s0 = timeToMinutes(_ivShownStart(iv));
const e0 = timeToMinutes(_ivShownEnd(iv));
- if (s0 === null || e0 === null || e0 <= s0) continue;
+ if (s0 === null || e0 === null) continue;
let cs = Math.max(s0, DAY_START);
let ce = Math.min(e0, DAY_END);
- if (ce <= cs) {
+ if (e0 <= s0) {
+ // v2026.5.47 — durée NULLE/incohérente (début >= fin, ex. 10:00→10:00 :
+ // erreur de saisie EV). Avant : sautée (continue) → INVISIBLE en vue exacte.
+ // On la garde en pastille au point de début (clampé à la plage) ; sa largeur
+ // 0 est rendue visible par le min-width CSS de .iv-exact-card, et
+ // _buildOneMiniCard la passe en rouge (classe iv-zero-duration).
+ cs = Math.min(Math.max(s0, DAY_START), DAY_END);
+ ce = cs;
+ } else if (ce <= cs) {
+ // Durée POSITIVE mais entièrement hors de la plage affichée : pastille
+ // 15 min plaquée au bord le plus proche (comportement existant).
if (e0 <= DAY_START) { cs = DAY_START; ce = DAY_START + 15; }
else { cs = DAY_END - 15; ce = DAY_END; }
}
- items.push({ iv, s: cs, e: ce });
+ // v2026.5.47 — repère les rendez-vous EN ERREUR (durée nulle : début ==
+ // fin) pour l'avertissement de survol + le blocage du clic droit ci-dessous.
+ const zero = (e0 <= s0) && iv.type === "AL-Intervention";
+ items.push({ iv, s: cs, e: ce, zero });
}
items.sort((a, b) => a.s - b.s || a.e - b.e);
+ // v2026.5.47 — positions (minutes) des rendez-vous en erreur (durée nulle),
+ // pour signaler/bloquer quand la zone d'un futur RDV les chevaucherait.
+ const zeroErrPts = items.filter(it => it.zero).map(it => it.s);
// 2) sous-lignes (lanes) : glouton — première sous-ligne libre à l'heure
// de début, sinon nouvelle sous-ligne en dessous.
@@ -12164,15 +12527,7 @@ function _buildExactRow(realInterventions, cardEl, isAbsent) {
// à midi (gap-idx identiques aux autres représentations), mini 15 min,
// aucun trou pour un tech absent toute la journée.
if (!isAbsent) {
- const occupied = items.map(it => [it.s, it.e]).sort((a, b) => a[0] - b[0]);
- const merged = [];
- for (const [s, e] of occupied) {
- if (merged.length && s <= merged[merged.length - 1][1]) {
- merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], e);
- } else {
- merged.push([s, e]);
- }
- }
+ const merged = _fusionnerPlages(items.map(it => [it.s, it.e]));
const MIDI_EX = 12 * 60;
const pushHole = (arr, s, e) => {
if (s < MIDI_EX && e > MIDI_EX) { arr.push([s, MIDI_EX]); arr.push([MIDI_EX, e]); }
@@ -12185,6 +12540,78 @@ function _buildExactRow(realInterventions, cardEl, isAbsent) {
cursor = Math.max(cursor, e);
}
if (cursor < DAY_END) pushHole(holes, cursor, DAY_END);
+
+ // v2026.5.47 — surbrillance de survol : montre la ZONE (1 h par défaut,
+ // à l'heure snappée sous le curseur) qu'un clic droit poserait en rendez-vous
+ // ou absence, en suivant la souris sur les créneaux libres. Purement visuel
+ // (pointer-events:none) : n'affecte pas le popover « Libre / durée » ni le
+ // clic. Reflète le calcul du menu clic droit (start snappé, +60 min clampé).
+ const selPreview = document.createElement("div");
+ selPreview.className = "iv-exact-selpreview";
+ selPreview.setAttribute("aria-hidden", "true");
+ selPreview.style.display = "none";
+ // v2026.5.47 — bulle FIXE en haut de la bande : plage LIBRE + DURÉE
+ // disponible du créneau survolé (comme le popover, mais immédiat et fixe).
+ // La ZONE de sélection mobile porte, elle, l'HEURE choisie (début–fin) qui
+ // suit le curseur dynamiquement. Rouge si un RDV en erreur (durée nulle)
+ // tombe dans la zone.
+ const selInfo = document.createElement("div");
+ selInfo.className = "iv-exact-selinfo";
+ selInfo.setAttribute("aria-hidden", "true");
+ selInfo.style.display = "none";
+ const selInfoMain = document.createElement("div");
+ selInfoMain.className = "iv-exact-selinfo-main";
+ const selInfoSub = document.createElement("div");
+ selInfoSub.className = "iv-exact-selinfo-sub";
+ selInfo.appendChild(selInfoMain);
+ selInfo.appendChild(selInfoSub);
+ const selHour = document.createElement("div");
+ selHour.className = "iv-exact-selpreview-hour";
+ selPreview.appendChild(selHour);
+ const _moveSelPreview = (ev) => {
+ if (typeof _cursorToTimeOnTimeline !== "function") return;
+ const t = _cursorToTimeOnTimeline(row, ev.clientX);
+ if (!t) { selPreview.style.display = "none"; selInfo.style.display = "none"; return; }
+ const sm = Math.max(DAY_START, Math.min(t.totalMin, DAY_END - 30));
+ const em = Math.min(sm + 60, DAY_END);
+ selPreview.style.left = ((sm - DAY_START) / DAY_LEN) * 100 + "%";
+ selPreview.style.width = ((em - sm) / DAY_LEN) * 100 + "%";
+ selHour.textContent = minutesToTime(sm) + "–" + minutesToTime(em);
+ const hs = parseInt(ev.currentTarget.dataset.startMin, 10);
+ const he = parseInt(ev.currentTarget.dataset.endMin, 10);
+ // v2026.5.47 — la bulle info fixe se cale au CENTRE du créneau libre
+ // survolé (zone SANS rendez-vous) → jamais par-dessus une intervention.
+ // (Le popover mobile « Libre / durée » a été retiré des trous, ci-dessous.)
+ selInfo.style.left = (((hs + he) / 2 - DAY_START) / DAY_LEN) * 100 + "%";
+ const hasErr = zeroErrPts.some(p => p >= sm && p <= em);
+ selPreview.classList.toggle("danger", hasErr);
+ selInfo.classList.toggle("danger", hasErr);
+ if (hasErr) {
+ selInfoMain.textContent = "Erreur : rendez-vous début = fin";
+ selInfoSub.textContent = "à corriger avant de réserver";
+ } else {
+ const dur = he - hs;
+ const dh = Math.floor(dur / 60), dm = dur % 60;
+ const dtxt = dh === 0 ? (dm + " min") : dm === 0 ? (dh + " h") : (dh + " h " + dm + " min");
+ selInfoMain.textContent = "Libre " + minutesToTime(hs) + "–" + minutesToTime(he);
+ selInfoSub.textContent = dtxt + " disponible";
+ }
+ selPreview.style.display = "block";
+ // v2026.5.47 — selInfo (temps total dispo) n'apparaît qu'après 500 ms
+ // (timer armé au mouseenter du trou) ; selPreview + selHour (la zone de
+ // réservation/absence) restent EN DIRECT, sans délai.
+ };
+ let _selInfoTimer = null;
+ const _armSelInfo = () => {
+ if (_selInfoTimer) clearTimeout(_selInfoTimer);
+ _selInfoTimer = setTimeout(() => { selInfo.style.display = "block"; }, 500);
+ };
+ const _hideSelPreview = () => {
+ if (_selInfoTimer) { clearTimeout(_selInfoTimer); _selInfoTimer = null; }
+ selPreview.style.display = "none";
+ selInfo.style.display = "none";
+ };
+
for (const [s, e] of holes) {
if (e - s < 15) continue;
const h = document.createElement("div");
@@ -12196,11 +12623,19 @@ function _buildExactRow(realInterventions, cardEl, isAbsent) {
h.dataset.kind = "hole";
const gapIdx = s + "-" + e;
h.dataset.gapIdx = gapIdx;
- h.addEventListener("mouseenter", () => highlightGap(cardEl, gapIdx, true));
- h.addEventListener("mouseleave", () => highlightGap(cardEl, gapIdx, false));
- if (typeof bindTimelinePopover === "function") bindTimelinePopover(h);
+ h.addEventListener("mouseenter", (e) => { highlightGap(cardEl, gapIdx, true); _moveSelPreview(e); _armSelInfo(); });
+ h.addEventListener("mousemove", _moveSelPreview);
+ h.addEventListener("mouseleave", () => { highlightGap(cardEl, gapIdx, false); _hideSelPreview(); });
+ // v2026.5.47 — plus de popover mobile « Libre / durée » sur les trous :
+ // la bulle info FIXE (selInfo) porte désormais cette info. (Le popover
+ // reste actif sur les INTERVENTIONS, hors de cette boucle.)
row.appendChild(h);
}
+ // Posée APRÈS les trous → visible au-dessus d'eux ; sous les bulles
+ // (z-index 1) qui la recouvrent si la zone d'1 h déborde sur une occupation.
+ row.appendChild(selPreview);
+ // Bulle info FIXE (temps disponible / alerte), au-dessus de tout (z-index 3).
+ row.appendChild(selInfo);
}
// 5) cartes posées en absolu : MÊMES mini-cards que le mode flux (mêmes
@@ -12250,17 +12685,50 @@ function _buildExactRuler() {
// carte au bas de la dernière → les repères d'heures traversent TOUS les techs
// d'un seul trait, sur toute la longueur. Recalculé au rendu et au resize
// (mêmes moments que _alignExactRuler). Les traits scrollent avec le contenu.
+// v2026.5.46 — fusionne des intervalles [début, fin] (tri inclus) ;
+// ecartMax > 0 soude aussi les intervalles séparés d'au plus cet écart.
+// Remplace trois copies inline du même algorithme (trous du mode exact,
+// trous de la timeline, _findNearestFreeSlot) + le saut des absents.
+function _fusionnerPlages(plages, ecartMax = 0) {
+ const fusion = [];
+ for (const [s, e] of plages.slice().sort((a, b) => a[0] - b[0])) {
+ const derniere = fusion[fusion.length - 1];
+ if (derniere && s - derniere[1] <= ecartMax) derniere[1] = Math.max(derniere[1], e);
+ else fusion.push([s, e]);
+ }
+ return fusion;
+}
+
// v2026.5.46 — helper partagé (lignes d'heures ET ligne « maintenant ») :
// découpe la tranche verticale [top, bottom] en segments qui SAUTENT les
// cartes des techs absents (.card.is-absent).
function _exactAbsentSegments(container, top, bottom) {
+ // v2026.5.46 — les lignes s'arrêtent AVANT le bloc des absents.
+ // Retirer la seule hauteur des cartes .is-absent laissait des tronçons
+ // dans les interstices (gap 6 px de la vue horizontale) entre deux cartes
+ // absentes, et la borne basse était le bas de la DERNIÈRE carte — une
+ // carte absente (tri : absents en dernier). On borne donc au bas de la
+ // dernière carte NON absente ; le découpage ci-dessous reste utile pour
+ // une carte absente située au-dessus (cas pompier + absent, trié en tête).
+ let lastActiveBottom = null;
const skip = [];
- container.querySelectorAll(".card.is-absent").forEach(c => {
- skip.push([c.offsetTop, c.offsetTop + c.offsetHeight]);
+ container.querySelectorAll(".card").forEach(c => {
+ if (c.classList.contains("is-absent")) {
+ skip.push([c.offsetTop, c.offsetTop + c.offsetHeight]);
+ } else {
+ lastActiveBottom = c.offsetTop + c.offsetHeight;
+ }
});
- skip.sort((a, b) => a[0] - b[0]);
+ if (lastActiveBottom === null) return []; // que des absents → aucune ligne
+ bottom = Math.min(bottom, lastActiveBottom);
+ // v2026.5.46 — fusionner les plages voisines : l'interstice entre deux
+ // cartes absentes ADJACENTES ne doit pas laisser de tronçon de ligne
+ // orphelin (ex. deux pompiers absents triés en tête). Tolérance dérivée du
+ // layout réel (gap de flex entre cartes), doublée pour les arrondis.
+ const gapPx = parseFloat(getComputedStyle(container).rowGap) || 6;
+ const zones = _fusionnerPlages(skip, gapPx * 2);
let segs = [[top, bottom]];
- for (const [rs, re] of skip) {
+ for (const [rs, re] of zones) {
const next = [];
for (const [s, e] of segs) {
if (re <= s || rs >= e) { next.push([s, e]); continue; } // pas de recouvrement
@@ -12290,12 +12758,12 @@ function _layoutExactGrid() {
const lastCard = cards[cards.length - 1];
const bottomPx = lastCard.offsetTop + lastCard.offsetHeight;
const leftBase = rowRect.left - contRect.left;
- const dayLen = (typeof DAY_LEN === "number" && DAY_LEN > 0) ? DAY_LEN : 600;
- const dayStart = (typeof DAY_START === "number") ? DAY_START : 480;
- const dayEnd = (typeof DAY_END === "number") ? DAY_END : 1080;
- // v2026.5.46 — les lignes ne traversent pas les techs absents ;
- // logique partagée avec la ligne « maintenant » (_exactAbsentSegments).
- const _segments = (top, bottom) => _exactAbsentSegments(container, top, bottom);
+ const { dayS: dayStart, dayE: dayEnd, dayLen } = _dayRange();
+ // v2026.5.46 — les lignes ne traversent pas les techs absents ; logique
+ // partagée avec la ligne « maintenant » (_exactAbsentSegments). Segments
+ // calculés UNE fois : bornes invariantes dans la boucle, et un appel par
+ // graduation forcerait un reflow lecture/écriture à chaque trait.
+ const segments = _exactAbsentSegments(container, topPx, bottomPx);
// v2026.5.46 — pas de 30 min : trait plein à chaque heure, trait FIN à
// chaque demi-heure (h30), césure renforcée à midi.
for (let m = Math.ceil(dayStart / 60) * 60; m <= dayEnd; m += 30) {
@@ -12304,7 +12772,7 @@ function _layoutExactGrid() {
const x = leftBase + ((m - dayStart) / dayLen) * rowRect.width;
const cls = "exact-grid-vline"
+ (isNoon ? " exact-grid-vline-noon" : (isHalf ? " exact-grid-vline-half" : ""));
- for (const [s, e] of _segments(topPx, bottomPx)) {
+ for (const [s, e] of segments) {
if (e - s <= 0) continue;
const line = document.createElement("div");
line.className = cls;
@@ -12368,15 +12836,7 @@ function buildTimeline(realInterventions, pompierBlocks, absenceBlocks, cardEl,
}
// Calcul des trous (que si pas absent complet)
- const occupiedRanges = segments.map(s => [s.start, s.end]).sort((a, b) => a[0] - b[0]);
- const merged = [];
- for (const [s, e] of occupiedRanges) {
- if (merged.length && s <= merged[merged.length - 1][1]) {
- merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], e);
- } else {
- merged.push([s, e]);
- }
- }
+ const merged = _fusionnerPlages(segments.map(s => [s.start, s.end]));
// on découpe TOUJOURS les holes à 12h00 pour qu'ils correspondent
// exactement aux gap-placeholder par demi-journée (sinon un hole qui
// chevauche midi se retrouve avec un data-gap-idx différent du gap
@@ -12958,12 +13418,11 @@ function _cursorToTimeOnTimeline(timelineBarEl, clientX) {
let xRatio = (clientX - r.left) / r.width;
if (xRatio < 0) xRatio = 0;
else if (xRatio > 1) xRatio = 1;
- const dayLen = (typeof DAY_LEN === "number" && DAY_LEN > 0) ? DAY_LEN : 600;
- const dayStart = (typeof DAY_START === "number") ? DAY_START : 480;
+ const { dayS: dayStart, dayE: dayEnd, dayLen } = _dayRange();
let totalMin = dayStart + xRatio * dayLen;
totalMin = _snap30(totalMin);
if (totalMin < dayStart) totalMin = dayStart;
- if (totalMin > dayStart + dayLen) totalMin = dayStart + dayLen;
+ if (totalMin > dayEnd) totalMin = dayEnd;
return { hour: Math.floor(totalMin / 60), minute: totalMin % 60, totalMin };
}
@@ -13029,9 +13488,7 @@ function _cardCursorToProjectedStart(targetCard, ev, durationMin, isHorizontal,
}
items.sort((a, b) => a.startMin - b.startMin);
- const dayStart = (typeof DAY_START === "number") ? DAY_START : 480;
- const dayLen = (typeof DAY_LEN === "number" && DAY_LEN > 0) ? DAY_LEN : 600;
- const dayEnd = dayStart + dayLen;
+ const { dayS: dayStart, dayE: dayEnd } = _dayRange();
if (items.length === 0) {
// si on est sur un bloc horizontal vide → heure d'aprèm/matin
@@ -13758,10 +14215,9 @@ function _updateReschedulePlaceholder(targetCard, newStartMin, newEndMin) {
const exactRow = HORIZONTAL_EXACT_MODE ? targetCard.querySelector(".iv-exact-row") : null;
const markHost = exactRow || bar;
if (markHost) {
- const dayLen = (typeof DAY_LEN === "number" && DAY_LEN > 0) ? DAY_LEN : 600;
- const dayStart = (typeof DAY_START === "number") ? DAY_START : 480;
+ const { dayS: dayStart, dayE: dayEnd, dayLen } = _dayRange();
const leftPct = ((newStartMin - dayStart) / dayLen) * 100;
- const widthPct = ((Math.min(newEndMin, dayStart + dayLen) - newStartMin) / dayLen) * 100;
+ const widthPct = ((Math.min(newEndMin, dayEnd) - newStartMin) / dayLen) * 100;
let mark;
if (_rescheduleDrag.timelineMark && _rescheduleDrag.timelineMark.parentBar === markHost) {
@@ -14024,8 +14480,7 @@ async function _moveTargetBusy(iv, change) {
const ns = timeToMinutes(change.newStartTime);
const ne = timeToMinutes(change.newEndTime);
if (ns === null || ne === null || ne <= ns) return null;
- const dayS = (typeof DAY_START === "number") ? DAY_START : 8 * 60;
- const dayE = (typeof DAY_END === "number") ? DAY_END : 18 * 60;
+ const { dayS, dayE } = _dayRange();
const hits = [];
const spans = []; // toutes les occupations (pour chercher un trou)
for (const o of ivs) {
@@ -14068,14 +14523,7 @@ async function _moveTargetBusy(iv, change) {
function _findNearestFreeSlot(spans, ns, ne, dayS, dayE) {
const dur = ne - ns;
if (dur <= 0) return null;
- const merged = [];
- for (const s of spans.slice().sort((a, b) => a[0] - b[0])) {
- if (merged.length && s[0] <= merged[merged.length - 1][1]) {
- merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], s[1]);
- } else {
- merged.push([s[0], s[1]]);
- }
- }
+ const merged = _fusionnerPlages(spans);
const holes = [];
let cur = dayS;
for (const m of merged) {
@@ -20229,6 +20677,14 @@ function _srSyncStickyOffsets() {
const tl = document.getElementById("topline");
const tlH = (tl && tl.offsetParent !== null) ? tl.offsetHeight : 0;
root.setProperty("--topline-h", tlH + "px");
+ // v2026.5.47 — largeur RÉELLE de la sidebar horizontale → --sidebar-w :
+ // la barre de progression se cale à sa DROITE (au-dessus du contenu, plus
+ // par-dessus la sidebar/zone de reconnexion). Mesure robuste aux breakpoints
+ // (200/160px) et au zoom. On ne l'écrit que si la sidebar est visible, sinon
+ // on garde le repli CSS (200px) — évite un left:0 transitoire qui rechevaucherait.
+ const sb = document.getElementById("horizontal-sidebar");
+ const sbW = (sb && sb.offsetParent !== null) ? sb.offsetWidth : 0;
+ if (sbW > 0) root.setProperty("--sidebar-w", sbW + "px");
}
// alias rétro-compat (anciens appels)
function _srSyncTopbarHeight() { _srSyncStickyOffsets(); }