Files
ferrovia-infogare/.idea/copilotDiffState.xml
T
2026-04-10 21:54:37 +02:00

53 lines
346 KiB
XML

<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CopilotDiffPersistence">
<option name="pendingDiffs">
<map>
<entry key="/ClassiquesDepartsQuais (snapshot).css">
<value>
<PendingDiffInfo>
<option name="filePath" value="/ClassiquesDepartsQuais (snapshot).css" />
<option name="originalContent" value="&#10;/* Partie gauche strictement comme l'image */&#10;.aff-quais__left--strict {&#10; width: 100%;&#10; max-width: 480px;&#10; min-width: 340px;&#10; background: #fff;&#10; display: flex;&#10; flex-direction: column;&#10; align-items: flex-start;&#10; justify-content: flex-start;&#10; padding: 32px 32px 24px 32px;&#10; box-sizing: border-box;&#10; position: relative;&#10; height: 100vh;&#10; overflow: hidden;&#10;}&#10;.aff-quais__sncf-logo--strict {&#10; width: 54px;&#10; height: auto;&#10; margin-bottom: 18px;&#10; margin-left: 0;&#10; margin-top: 0;&#10;}&#10;.aff-quais__watermark {&#10; position: absolute;&#10; right: 18px;&#10; top: 50%;&#10; transform: translateY(-50%) rotate(-90deg);&#10; font-size: 100px;&#10; color: rgba(23,55,113,0.07);&#10; font-weight: 800;&#10; letter-spacing: 6px;&#10; pointer-events: none;&#10; user-select: none;&#10; z-index: 0;&#10;}&#10;.aff-quais__row--time-status {&#10; display: flex;&#10; align-items: flex-end;&#10; gap: 18px;&#10; margin-bottom: 18px;&#10; z-index: 1;&#10;}&#10;.aff-quais__time--strict {&#10; color: #0d4a87;&#10; font-weight: 800;&#10; font-size: 56px;&#10; line-height: 1;&#10; letter-spacing: 0.5px;&#10; margin-right: 12px;&#10;}&#10;.aff-quais__time-status--strict {&#10; color: #0d4a87;&#10; font-size: 22px;&#10; font-weight: 400;&#10; opacity: 1;&#10; align-self: flex-end;&#10; margin-bottom: 7px;&#10;}&#10;.aff-quais__destination--strict {&#10; margin-top: 10px;&#10; margin-bottom: 0;&#10; color: #0d4a87;&#10; z-index: 1;&#10;}&#10;.aff-quais__destination-line1--strict {&#10; font-size: 38px;&#10; font-weight: 800;&#10; line-height: 1.1;&#10;}&#10;.aff-quais__destination-line2--strict {&#10; font-size: 38px;&#10; font-weight: 800;&#10; line-height: 1.1;&#10;}&#10;.aff-quais__train-id--strict {&#10; margin-top: 12px;&#10; color: #173771;&#10; font-weight: 700;&#10; font-size: 22px;&#10; letter-spacing: 0.5px;&#10; z-index: 1;&#10;}&#10;.aff-quais__train-number--strict {&#10; color: #173771;&#10; font-weight: 400;&#10; font-size: 22px;&#10;}&#10;.aff-quais__quai{&#10; margin-top: 8px;&#10; color:#173771;&#10; font-weight:800;&#10; background: rgba(23,55,113,0.08);&#10; padding:6px 10px;&#10; border-radius:6px;&#10; display:inline-block;&#10;}&#10;&#10;/* central divider */&#10;.aff-quais__divider{&#10; width:18px;&#10; background: linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.18));&#10; display:flex;&#10; justify-content:center;&#10; align-items:flex-start;&#10; padding-top:36px;&#10;}&#10;.aff-quais__divider:before{&#10; content:'';&#10; width:6px;&#10; background: transparent;&#10; box-shadow: 0 18px 0 0 #f0e34f, 0 54px 0 0 #f0e34f, 0 90px 0 0 #f0e34f, 0 126px 0 0 #f0e34f, 0 162px 0 0 #f0e34f, 0 198px 0 0 #f0e34f;&#10; display:block;&#10; border-radius:4px;&#10;}&#10;&#10;/* Right panel with stops */&#10;.aff-quais__right{&#10; flex:1;&#10; background: linear-gradient(180deg,#205aa1 0%, #144b85 100%);&#10; display:flex;&#10; align-items:center;&#10; box-sizing:border-box;&#10; padding: 24px 36px;&#10;}&#10;.aff-quais__stops-box{&#10; width:100%;&#10; max-width:820px;&#10; margin-left:24px;&#10;}&#10;.aff-quais__stops-list{&#10; list-style:none;&#10; margin:0;&#10; padding:0;&#10; width:100%;&#10;}&#10;.aff-quais__stop{&#10; display:flex;&#10; align-items:center;&#10; gap:18px;&#10; padding: 10px 16px;&#10; color:#fff;&#10; font-size:30px;&#10; line-height:1.1;&#10;}&#10;.aff-quais__bullet{&#10; width:14px;&#10; height:14px;&#10; background: #f0e34f; /* yellow bullet */&#10; border-radius:50%;&#10; display:inline-block;&#10; box-shadow: 0 0 0 4px rgba(240,227,79,0.06) inset;&#10;}&#10;.aff-quais__stop--active{&#10; background: rgba(155,123,224,0.15);&#10; border-radius:6px;&#10;}&#10;.aff-quais__stop--active .aff-quais__bullet{&#10; background:#9b7be0; /* purple for final */&#10;}&#10;.aff-quais__stop-text{&#10; flex:1;&#10; padding-left:6px;&#10;}&#10;&#10;/* --- STRICT STYLES FOR RIGHT PANEL --- */&#10;&#10;.aff-quais__right--strict {&#10; flex: 1;&#10; background: #205aa1; /* fond bleu uniforme */&#10; display: flex;&#10; align-items: flex-start;&#10; justify-content: flex-start;&#10; padding: 48px 0 48px 0;&#10; min-width: 0;&#10; height: 100vh;&#10; box-sizing: border-box;&#10;}&#10;&#10;.aff-quais__stops-list--strict {&#10; list-style: none;&#10; margin: 0;&#10; padding: 0 0 0 38px;&#10; width: 100%;&#10; max-width: 600px;&#10;}&#10;&#10;.aff-quais__stop--strict {&#10; display: flex;&#10; align-items: center;&#10; font-size: 2.1rem;&#10; color: #fff;&#10; font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;&#10; font-weight: 400;&#10; margin-bottom: 6px;&#10; line-height: 1.18;&#10; padding: 0 0 0 0;&#10;}&#10;&#10;.aff-quais__stop-bullet--strict {&#10; display: inline-block;&#10; width: 13px;&#10; height: 13px;&#10; border-radius: 50%;&#10; background: #ffe600;&#10; margin-right: 16px;&#10; flex-shrink: 0;&#10; margin-top: 2px;&#10;}&#10;&#10;.aff-quais__stop-text--strict {&#10; display: inline-block;&#10; font-size: inherit;&#10; font-weight: inherit;&#10; color: inherit;&#10;}&#10;&#10;.aff-quais__stop--strict-dest {&#10; font-weight: 800;&#10; background: #b6d2f7;&#10; color: #205aa1;&#10; border-radius: 6px;&#10; padding: 2px 12px 2px 0;&#10; margin-left: -16px;&#10;}&#10;&#10;@media (max-width: 900px) {&#10; .aff-quais__right--strict {&#10; padding: 18px 0 18px 0;&#10; }&#10; .aff-quais__stops-list--strict {&#10; padding-left: 12px;&#10; }&#10; .aff-quais__stop--strict {&#10; font-size: 1.1rem;&#10; }&#10; .aff-quais__stop-bullet--strict {&#10; width: 8px;&#10; height: 8px;&#10; margin-right: 8px;&#10; }&#10; .aff-quais__stop--strict-dest {&#10; padding: 1px 6px 1px 0;&#10; border-radius: 4px;&#10; }&#10;}&#10;&#10;/* --- END STRICT STYLES FOR RIGHT PANEL --- */&#10;&#10;.aff-quais__footer{&#10; position:absolute;&#10; left:0;&#10; right:0;&#10; bottom:0;&#10; height:86px;&#10; pointer-events:none;&#10;}&#10;.aff-quais__footer-inner{&#10; display:flex;&#10; justify-content:space-between;&#10; align-items:center;&#10; height:100%;&#10; padding: 0 28px;&#10; background: linear-gradient(to bottom, rgba(0,0,0,0.15), rgba(0,0,0,0.25));&#10;}&#10;.aff-quais__footer-left{&#10; color:#fff;&#10; font-weight:700;&#10; font-size:20px;&#10;}&#10;.aff-quais__clock-box{&#10; background: #173771;&#10; color:#fff;&#10; padding: 6px 12px;&#10; border-radius: 8px;&#10; border: 6px solid #ffffff;&#10;}&#10;.aff-quais__clock-main{&#10; font-size:20px;&#10; font-weight:800;&#10;}&#10;&#10;/* position the clock bottom-right inside a small white rounded box like on the image */&#10;.aff-quais__footer-right{&#10; position:relative;&#10;}&#10;.aff-quais__clock{&#10; position:relative;&#10;}&#10;.aff-quais__clock-box{&#10; background: transparent;&#10; border-radius:6px;&#10; border: 4px solid #ffffff;&#10; padding:4px 8px;&#10; background: rgba(23,55,113,0.9);&#10;}&#10;.aff-quais__clock-main{ font-size:18px }&#10;&#10;@media (max-width: 900px){&#10; .aff-quais__left{width:42%;}&#10; .aff-quais__destination-line1{font-size:36px}&#10; .aff-quais__destination-line2{font-size:28px}&#10; .aff-quais__time{font-size:48px}&#10;}&#10;&#10;" />
<option name="updatedContent" value="/* Partie gauche strictement comme l'image */&#10;.aff-quais__left--strict {&#10; width: 100%;&#10; max-width: 480px;&#10; min-width: 340px;&#10; background: #fff;&#10; display: flex;&#10; flex-direction: column;&#10; align-items: flex-start;&#10; justify-content: flex-start;&#10; padding: 32px 32px 24px 32px;&#10; box-sizing: border-box;&#10; position: relative;&#10; height: 100vh;&#10; overflow: hidden;&#10; border-top-right-radius: 0;&#10; border-bottom-right-radius: 0;&#10; box-shadow: none;&#10;}&#10;.aff-quais__sncf-logo--strict {&#10; width: 54px;&#10; height: auto;&#10; margin-bottom: 18px;&#10; margin-left: 0;&#10; margin-top: 0;&#10;}&#10;.aff-quais__watermark {&#10; position: absolute;&#10; right: 18px;&#10; top: 50%;&#10; transform: translateY(-50%) rotate(-90deg);&#10; font-size: 100px;&#10; color: rgba(23,55,113,0.07);&#10; font-weight: 800;&#10; letter-spacing: 6px;&#10; pointer-events: none;&#10; user-select: none;&#10; z-index: 0;&#10;}&#10;.aff-quais__row--time-status {&#10; display: flex;&#10; align-items: flex-end;&#10; gap: 18px;&#10; margin-bottom: 18px;&#10; z-index: 1;&#10;}&#10;.aff-quais__time--strict {&#10; color: #0d4a87;&#10; font-weight: 800;&#10; font-size: 56px;&#10; line-height: 1;&#10; letter-spacing: 0.5px;&#10; margin-right: 12px;&#10;}&#10;.aff-quais__time-status--strict {&#10; color: #0d4a87;&#10; font-size: 22px;&#10; font-weight: 400;&#10; opacity: 1;&#10; align-self: flex-end;&#10; margin-bottom: 7px;&#10;}&#10;.aff-quais__destination--strict {&#10; margin-top: 10px;&#10; margin-bottom: 0;&#10; color: #0d4a87;&#10; z-index: 1;&#10;}&#10;.aff-quais__destination-line1--strict {&#10; font-size: 38px;&#10; font-weight: 800;&#10; line-height: 1.1;&#10;}&#10;.aff-quais__destination-line2--strict {&#10; font-size: 38px;&#10; font-weight: 800;&#10; line-height: 1.1;&#10;}&#10;.aff-quais__train-id--strict {&#10; margin-top: 12px;&#10; color: #173771;&#10; font-weight: 700;&#10; font-size: 22px;&#10; letter-spacing: 0.5px;&#10; z-index: 1;&#10;}&#10;.aff-quais__train-number--strict {&#10; color: #173771;&#10; font-weight: 400;&#10; font-size: 22px;&#10;}&#10;.aff-quais__quai{&#10; margin-top: 8px;&#10; color:#173771;&#10; font-weight:800;&#10; background: rgba(23,55,113,0.08);&#10; padding:6px 10px;&#10; border-radius:6px;&#10; display:inline-block;&#10;}&#10;&#10;/* central divider */&#10;.aff-quais__divider{&#10; width:18px;&#10; background: linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.18));&#10; display:flex;&#10; justify-content:center;&#10; align-items:flex-start;&#10; padding-top:36px;&#10;}&#10;.aff-quais__divider:before{&#10; content:'';&#10; width:6px;&#10; background: transparent;&#10; box-shadow: 0 18px 0 0 #f0e34f, 0 54px 0 0 #f0e34f, 0 90px 0 0 #f0e34f, 0 126px 0 0 #f0e34f, 0 162px 0 0 #f0e34f, 0 198px 0 0 #f0e34f;&#10; display:block;&#10; border-radius:4px;&#10;}&#10;&#10;/* Right panel with stops */&#10;.aff-quais__right{&#10; flex:1;&#10; background: linear-gradient(180deg,#205aa1 0%, #144b85 100%);&#10; display:flex;&#10; align-items:center;&#10; box-sizing:border-box;&#10; padding: 24px 36px;&#10;}&#10;.aff-quais__stops-box{&#10; width:100%;&#10; max-width:820px;&#10; margin-left:24px;&#10;}&#10;.aff-quais__stops-list{&#10; list-style:none;&#10; margin:0;&#10; padding:0;&#10; width:100%;&#10;}&#10;.aff-quais__stop{&#10; display:flex;&#10; align-items:center;&#10; gap:18px;&#10; padding: 10px 16px;&#10; color:#fff;&#10; font-size:30px;&#10; line-height:1.1;&#10;}&#10;.aff-quais__bullet{&#10; width:14px;&#10; height:14px;&#10; background: #f0e34f; /* yellow bullet */&#10; border-radius:50%;&#10; display:inline-block;&#10; box-shadow: 0 0 0 4px rgba(240,227,79,0.06) inset;&#10;}&#10;.aff-quais__stop--active{&#10; background: rgba(155,123,224,0.15);&#10; border-radius:6px;&#10;}&#10;.aff-quais__stop--active .aff-quais__bullet{&#10; background:#9b7be0; /* purple for final */&#10;}&#10;.aff-quais__stop-text{&#10; flex:1;&#10; padding-left:6px;&#10;}&#10;&#10;/* --- STRICT STYLES FOR RIGHT PANEL --- */&#10;&#10;.aff-quais__right--strict {&#10; flex: 1;&#10; background: #205aa1; /* fond bleu uniforme */&#10; display: flex;&#10; align-items: flex-start;&#10; justify-content: flex-start;&#10; padding: 48px 0 48px 0;&#10; min-width: 0;&#10; height: 100vh;&#10; box-sizing: border-box;&#10; border-top-left-radius: 0;&#10; border-bottom-left-radius: 0;&#10; box-shadow: none;&#10;}&#10;&#10;.aff-quais__stops-list--strict {&#10; list-style: none;&#10; margin: 0;&#10; padding: 0 0 0 38px;&#10; width: 100%;&#10; max-width: 600px;&#10;}&#10;&#10;.aff-quais__stop--strict {&#10; display: flex;&#10; align-items: center;&#10; font-size: 2.1rem;&#10; color: #fff;&#10; font-family: 'Achemine', 'Helvetica Neue', Arial, sans-serif;&#10; font-weight: 400;&#10; margin-bottom: 6px;&#10; line-height: 1.18;&#10; padding: 0 0 0 0;&#10;}&#10;&#10;.aff-quais__stop-bullet--strict {&#10; display: inline-block;&#10; width: 13px;&#10; height: 13px;&#10; border-radius: 50%;&#10; background: #ffe600;&#10; margin-right: 16px;&#10; flex-shrink: 0;&#10; margin-top: 2px;&#10;}&#10;&#10;.aff-quais__stop-text--strict {&#10; display: inline-block;&#10; font-size: inherit;&#10; font-weight: inherit;&#10; color: inherit;&#10;}&#10;&#10;.aff-quais__stop--strict-dest {&#10; font-weight: 800;&#10; background: #b6d2f7;&#10; color: #205aa1;&#10; border-radius: 6px;&#10; padding: 2px 12px 2px 0;&#10; margin-left: -16px;&#10;}&#10;&#10;.aff-quais__container--strict {&#10; display: flex;&#10; flex-direction: row;&#10; width: 100vw;&#10; height: 100vh;&#10; min-height: 0;&#10; min-width: 0;&#10; background: #205aa1;&#10; box-sizing: border-box;&#10; overflow: hidden;&#10;}&#10;&#10;.aff-quais__separator--strict {&#10; width: 14px;&#10; min-width: 14px;&#10; max-width: 14px;&#10; background: #205aa1;&#10; height: 100vh;&#10; display: block;&#10; z-index: 2;&#10;}&#10;&#10;@media (max-width: 900px) {&#10; .aff-quais__right--strict {&#10; padding: 18px 0 18px 0;&#10; }&#10; .aff-quais__stops-list--strict {&#10; padding-left: 12px;&#10; }&#10; .aff-quais__stop--strict {&#10; font-size: 1.1rem;&#10; }&#10; .aff-quais__stop-bullet--strict {&#10; width: 8px;&#10; height: 8px;&#10; margin-right: 8px;&#10; }&#10; .aff-quais__stop--strict-dest {&#10; padding: 1px 6px 1px 0;&#10; border-radius: 4px;&#10; }&#10;}&#10;&#10;/* --- END STRICT STYLES FOR RIGHT PANEL --- */&#10;&#10;.aff-quais__footer{&#10; position:absolute;&#10; left:0;&#10; right:0;&#10; bottom:0;&#10; height:86px;&#10; pointer-events:none;&#10;}&#10;.aff-quais__footer-inner{&#10; display:flex;&#10; justify-content:space-between;&#10; align-items:center;&#10; height:100%;&#10; padding: 0 28px;&#10; background: linear-gradient(to bottom, rgba(0,0,0,0.15), rgba(0,0,0,0.25));&#10;}&#10;.aff-quais__footer-left{&#10; color:#fff;&#10; font-weight:700;&#10; font-size:20px;&#10;}&#10;.aff-quais__clock-box{&#10; background: #173771;&#10; color:#fff;&#10; padding: 6px 12px;&#10; border-radius: 8px;&#10; border: 6px solid #ffffff;&#10;}&#10;.aff-quais__clock-main{&#10; font-size:20px;&#10; font-weight:800;&#10;}&#10;&#10;/* position the clock bottom-right inside a small white rounded box like on the image */&#10;.aff-quais__footer-right{&#10; position:relative;&#10;}&#10;.aff-quais__clock{&#10; position:relative;&#10;}&#10;.aff-quais__clock-box{&#10; background: transparent;&#10; border-radius:6px;&#10; border: 4px solid #ffffff;&#10; padding:4px 8px;&#10; background: rgba(23,55,113,0.9);&#10;}&#10;.aff-quais__clock-main{ font-size:18px }&#10;&#10;@media (max-width: 900px){&#10; .aff-quais__left{width:42%;}&#10; .aff-quais__destination-line1{font-size:36px}&#10; .aff-quais__destination-line2{font-size:28px}&#10; .aff-quais__time{font-size:48px}&#10;}" />
</PendingDiffInfo>
</value>
</entry>
<entry key="$PROJECT_DIR$/.env.example">
<value>
<PendingDiffInfo>
<option name="filePath" value="$PROJECT_DIR$/.env.example" />
<option name="updatedContent" value="# Exemple de variables d'environnement pour CRA (create-react-app)&#10;# Copiez ce fichier en `.env.local` ou `.env` à la racine du projet&#10;# et remplacez par vos valeurs Supabase.&#10;&#10;REACT_APP_SUPABASE_URL=https://xyzcompany.supabase.co&#10;REACT_APP_SUPABASE_ANON_KEY=public-anon-key&#10;" />
</PendingDiffInfo>
</value>
</entry>
<entry key="$PROJECT_DIR$/src/pages/afficheurs/classiques/ClassiquesDeparts.js">
<value>
<PendingDiffInfo>
<option name="filePath" value="$PROJECT_DIR$/src/pages/afficheurs/classiques/ClassiquesDeparts.js" />
<option name="originalContent" value="import React, { useEffect, useState, useCallback } from 'react';&#10;import './ClassiquesDeparts.css';&#10;import supabase from '../../../lib/supabase';&#10;&#10;// Helper utilitaire : format HHhMM sans secondes (ex: 06h44)&#10;const formatTimeNoSeconds = (v) =&gt; {&#10; if (v == null) return '';&#10; const tryDate = new Date(v);&#10; if (!isNaN(tryDate.getTime())) {&#10;&#9;const hh = String(tryDate.getHours()).padStart(2, '0');&#10;&#9;const mm = String(tryDate.getMinutes()).padStart(2, '0');&#10;&#9;return `${hh}h${mm}`;&#10; }&#10; if (typeof v === 'string') {&#10;&#9;const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);&#10;&#9;if (m) return `${String(m[1]).padStart(2,'0')}h${m[2]}`;&#10;&#9;const m2 = v.match(/T(\d{2}):(\d{2})/);&#10;&#9;if (m2) return `${m2[1]}h${m2[2]}`;&#10;&#9;return v.slice(0,5).replace(':','h');&#10; }&#10; return String(v).slice(0,5).replace(':','h');&#10;};&#10;&#10;// Données proviennent exclusivement de la table `horaires`.&#10;function Logo({op}){&#10; // Tente de charger un logo SVG depuis /type/ en fonction de l'opérateur.&#10; // mapping pour opérateurs connus -&gt; noms de fichiers dans public/type&#10; const mapping = {&#10;&#9;'TGV inOUI': 'logo-inoui.svg',&#10;&#9;'TGV inOUI ': 'logo-inoui.svg',&#10;&#9;'TGV': 'logo-tgv.svg',&#10;&#9;'TER': 'sncf-voyageurs-logo.png',&#10;&#9;'MOBIGO': 'logo-mobigo.svg',&#10;&#9;'TGV Lyria': 'logo-lyria.svg',&#10;&#9;'TGV Lyria ': 'logo-lyria.svg',&#10; };&#10;&#10; const defaultFileFromOp = (name) =&gt; {&#10;&#9;if (!name) return null;&#10;&#9;// nettoyer et fabriquer un nom plausible : &quot;logo-&lt;slug&gt;.svg&quot;&#10;&#9;const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');&#10;&#9;return `logo-${slug}.svg`;&#10; };&#10;&#10; const fileName = mapping[op] || mapping[op.split(' ')[0]] || defaultFileFromOp(op);&#10;&#10; const [hasImg, setHasImg] = React.useState(true);&#10;&#10; return (&#10;&#9;&lt;div className=&quot;aff-classiques-departs__logo-box&quot; aria-hidden&gt;&#10;&#9; {hasImg &amp;&amp; fileName ? (&#10;&#9;&#9;&lt;img&#10;&#9;&#9; src={`/type/${fileName}`}&#10;&#9;&#9; alt={op}&#10;&#9;&#9; className=&quot;aff-classiques-departs__op-logo&quot;&#10;&#9;&#9; onError={()=&gt; setHasImg(false)}&#10;&#9;&#9;/&gt;&#10;&#9; ) : (&#10;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__logo-text&quot; aria-hidden&gt;{op.split(' ')[0]}&lt;/div&gt;&#10;&#9; )}&#10;&#9;&lt;/div&gt;&#10; );&#10;}&#10;&#10;// (La fonction TrainType a été retirée car la colonne affiche désormais directement les&#10;// informations via .train-meta dans la colonne .col-train-type)&#10;&#10;function Clock(){&#10; const [now, setNow] = useState(new Date());&#10; useEffect(() =&gt; {&#10;&#9;const id = setInterval(() =&gt; setNow(new Date()), 1000);&#10;&#9;return () =&gt; clearInterval(id);&#10; }, []);&#10;&#10; const hh = String(now.getHours()).padStart(2, '0');&#10; const mm = String(now.getMinutes()).padStart(2, '0');&#10; const ss = String(now.getSeconds()).padStart(2, '0');&#10;&#10; return (&#10;&#9;&lt;div className=&quot;aff-classiques-departs__clock-container&quot; aria-hidden&gt;&#10;&#9; &lt;div className=&quot;aff-classiques-departs__clock-box&quot;&gt;&#10;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__clock-main&quot;&gt;{hh}:{mm}&lt;/div&gt;&#10;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__clock-seconds&quot;&gt;{ss}&lt;/div&gt;&#10;&#9; &lt;/div&gt;&#10;&#9;&lt;/div&gt;&#10; );&#10;}&#10;&#10;export default function ClassiquesDeparts(){&#10; // lire le paramètre de requête ?gare=Nom-de-la-gare&#10; const params = new URLSearchParams(window.location.search);&#10; const gareParam = params.get('gare');&#10; // support pour ?gare_id=123 ou ?gareId=123&#10; const gareIdParam = params.get('gare_id') || params.get('gareId');&#10; // gérer les +, décoder et remplacer les tirets par des espaces&#10; const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null;&#10;&#10; // debug flag activable via ?debug=1 or ?debug=true&#10; const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true');&#10; const debug = useCallback((...args) =&gt; { if (debugEnabled) console.debug(...args); }, [debugEnabled]);&#10;&#10; // État pour alterner l'affichage toutes les 3 secondes&#10; const [showStatus, setShowStatus] = useState(false);&#10;&#10; // Données depuis Supabase&#10; const [horaires, setHoraires] = useState([]);&#10; const [allHoraires, setAllHoraires] = useState([]);&#10; const [perturbations, setPerturbations] = useState([]);&#10; // type de la gare affichée (valeurs attendues: 'interurbaine', 'ville', ...)&#10; const [stationType, setStationType] = useState(null);&#10; const [, setTravaux] = useState([]);&#10; const [, setLoading] = useState(false);&#10; const [, setError] = useState(null);&#10; // debug des données brutes renvoyées par Supabase&#10; const [rawDebug, setRawDebug] = useState({ count: 0, sample: [] });&#10;&#10; // --- Helpers pour perturbations (définis ici pour éviter use-before-define dans useEffect) ---&#10; const normalizeName = useCallback((s) =&gt; {&#10;&#9;if (!s) return '';&#10;&#9;try {&#10;&#9; const t = String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim();&#10;&#9; return t.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ');&#10;&#9;} catch (e) {&#10;&#9; return String(s).toLowerCase().trim();&#10;&#9;}&#10; }, []);&#10;&#10; const safeParseArrets = useCallback((p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return [];&#10; if (Array.isArray(raw)) return raw;&#10; try {&#10;&#9;const parsed = JSON.parse(raw);&#10;&#9;// some payloads wrap { arrets: [...], jours_perturbation: [...] }&#10;&#9;if (parsed &amp;&amp; typeof parsed === 'object') {&#10;&#9;if (Array.isArray(parsed.arrets)) return parsed.arrets;&#10;&#9;// if parsed itself is an array-like object, return it&#10;&#9;return parsed;&#10;&#9;}&#10;&#9;return [];&#10; } catch (e) { return []; }&#10; }, []);&#10;&#10; // Retourne la liste d'arrêts d'un horaire, supporte plusieurs noms de champs (stops, gares_desservies...)&#10; const getStops = useCallback((h) =&gt; {&#10;&#9;if (!h) return [];&#10;&#9;if (Array.isArray(h.stops) &amp;&amp; h.stops.length) return h.stops;&#10;&#9;if (Array.isArray(h.gares_desservies) &amp;&amp; h.gares_desservies.length) return h.gares_desservies;&#10;&#9;if (Array.isArray(h.garesDesservies) &amp;&amp; h.garesDesservies.length) return h.garesDesservies;&#10;&#9;// parfois la colonne est stockée sous forme de chaîne JSON&#10;&#9;const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json'];&#10;&#9;for (const key of candidates) {&#10;&#9; const raw = h[key];&#10;&#9; if (!raw) continue;&#10;&#9; if (Array.isArray(raw)) return raw;&#10;&#9; if (typeof raw === 'string') {&#10;&#9;&#9;try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ }&#10;&#9; }&#10;&#9;}&#10;&#9;return [];&#10; }, []);&#10;&#10; // parse perturbations_arrets as object to extract both arrets and jours_perturbation&#10; const parsePerturbArretsObject = useCallback((p) =&gt; {&#10;&#9;const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10;&#9;if (!raw) return { arrets: [], jours: null };&#10;&#9;if (typeof raw === 'object') {&#10;&#9; if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null };&#10;&#9; if (Array.isArray(raw)) return { arrets: raw, jours: null };&#10;&#9; return { arrets: [], jours: null };&#10;&#9;}&#10;&#9;try {&#10;&#9; const parsed = JSON.parse(raw);&#10;&#9; if (parsed &amp;&amp; typeof parsed === 'object') {&#10;&#9;&#9;return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null };&#10;&#9; }&#10;&#9; return { arrets: [], jours: null };&#10;&#9;} catch (e) {&#10;&#9; return { arrets: [], jours: null };&#10;&#9;}&#10; }, []);&#10;&#10; // extrait un set de dates 'YYYY-MM-DD' depuis p.jours_perturbation (top-level) ou depuis perturbations_arrets.jours_perturbation&#10; const getPerturbationJours = useCallback((p) =&gt; {&#10;&#9;if (!p) return null;&#10;&#9;// check top-level field first&#10;&#9;const candidate = p.jours_perturbation || p.jours || null;&#10;&#9;const result = new Set();&#10;&#9;const pushDate = (d) =&gt; {&#10;&#9; if (!d) return;&#10;&#9; try {&#10;&#9;&#9;const dd = new Date(d);&#10;&#9;&#9;if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10));&#10;&#9; } catch (e) {}&#10;&#9;};&#10;&#9;if (candidate) {&#10;&#9; if (Array.isArray(candidate)) {&#10;&#9;&#9;candidate.forEach(item =&gt; pushDate(item.date || item));&#10;&#9; } else if (typeof candidate === 'string') {&#10;&#9;&#9;try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); }&#10;&#9;&#9;catch (e) { pushDate(candidate); }&#10;&#9; } else if (typeof candidate === 'object') {&#10;&#9;&#9;if (Array.isArray(candidate)) candidate.forEach(it =&gt; pushDate(it.date || it));&#10;&#9;&#9;else pushDate(candidate.date || null);&#10;&#9; }&#10;&#9;}&#10;&#9;// fallback: inspect perturbations_arrets object&#10;&#9;try {&#10;&#9; const parsedObj = parsePerturbArretsObject(p);&#10;&#9; const jp = parsedObj.jours;&#10;&#9; if (jp) {&#10;&#9;&#9;if (Array.isArray(jp)) jp.forEach(it =&gt; pushDate(it.date || it));&#10;&#9;&#9;else if (typeof jp === 'string') {&#10;&#9;&#9; try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); } catch (e) { pushDate(jp); }&#10;&#9;&#9;} else if (typeof jp === 'object') pushDate(jp.date || null);&#10;&#9; }&#10;&#9;} catch (e) {}&#10;&#10;&#9;return result.size ? result : null;&#10; }, [parsePerturbArretsObject]);&#10;&#10; const parseDelayMinutesFromArret = useCallback((arret) =&gt; {&#10;&#9;if (!arret) return null;&#10;&#9;const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min'];&#10;&#9;for (const k of keys) {&#10;&#9; if (typeof arret[k] !== 'undefined' &amp;&amp; arret[k] !== null &amp;&amp; arret[k] !== '') {&#10;&#9;&#9;const n = Number(arret[k]);&#10;&#9;&#9;if (!isNaN(n)) return Math.round(n);&#10;&#9; }&#10;&#9;}&#10;&#9;return null;&#10; }, []);&#10;&#10; // parse HH:mm or ISO into minutes since midnight (utilisable ailleurs dans le composant)&#10; const parseTimeHM = useCallback((s) =&gt; {&#10;&#9;if (!s) return null;&#10;&#9;if (typeof s === 'number') return s;&#10;&#9;const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10;&#9;if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10;&#9;const d = new Date(s);&#10;&#9;if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10;&#9;return null;&#10; }, []);&#10;&#10; // retourne le threshold (en minutes) pour afficher le quai selon le type de gare&#10; const thresholdForStationType = useCallback((type) =&gt; {&#10;&#9;if (!type) return 0;&#10;&#9;const t = String(type).toLowerCase();&#10;&#9;if (t.includes('interurb') || t.includes('interurbaine')) return 360; // 6 heures&#10;&#9;if (t.includes('ville')) return 30; // 30 minutes&#10;&#9;return 0;&#10; }, []);&#10;&#10; const formatTimeFromHM = useCallback((totalMinutes) =&gt; {&#10;&#9;if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return '';&#10;&#9;const m = ((totalMinutes % 1440) + 1440) % 1440;&#10;&#9;const hh = String(Math.floor(m / 60)).padStart(2, '0');&#10;&#9;const mm = String(m % 60).padStart(2, '0');&#10;&#9;return `${hh}h${mm}`;&#10; }, []);&#10;&#10; // buildPerturbMap now accepts an optional list of horaires to link horaire_id -&gt; numero_train&#10; const buildPerturbMap = useCallback((list = perturbations, horairesList = allHoraires) =&gt; {&#10;&#9;const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] };&#10;&#9;(list || []).forEach(p =&gt; {&#10;&#9; const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID;&#10;&#9; if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); }&#10;&#9; const number = p.number || p.train_number || p.train || p.numero || p.numero_train;&#10;&#9; if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); }&#10;&#9; const arrets = safeParseArrets(p);&#10;&#9; if (Array.isArray(arrets) &amp;&amp; arrets.length) {&#10;&#9;&#9;arrets.forEach(a =&gt; {&#10;&#9;&#9; const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code;&#10;&#9;&#9; const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10;&#9;&#9; if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); }&#10;&#9;&#9; if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); }&#10;&#9;&#9;});&#10;&#9; }&#10;&#9; if ((horaireId == null &amp;&amp; number == null &amp;&amp; (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); }&#10;&#9;});&#10;&#10;&#9;// If we have horairesList, propagate horaire_id perturbations to byNumber using the matching horaire's numero_train&#10;&#9;if (Array.isArray(horairesList) &amp;&amp; horairesList.length) {&#10;&#9; try {&#10;&#9;&#9;const byH = Object.keys(idx.byHoraire || {});&#10;&#9;&#9;if (byH &amp;&amp; byH.length) {&#10;&#9;&#9; for (const hid of byH) {&#10;&#9;&#9;&#9;const h = horairesList.find(x =&gt; String(x.id || x.horaire_id || x.horaireId || x.numero) === String(hid));&#10;&#9;&#9;&#9;if (h) {&#10;&#9;&#9;&#9; const num = h.numero_train || h.number || h.numero || null;&#10;&#9;&#9;&#9; if (num != null) {&#10;&#9;&#9;&#9;&#9;const numKey = String(num);&#10;&#9;&#9;&#9;&#9;idx.byNumber[numKey] = idx.byNumber[numKey] || [];&#10;&#9;&#9;&#9;&#9;// append all perturbations that reference this horaire id&#10;&#9;&#9;&#9;&#9;(idx.byHoraire[hid] || []).forEach(p =&gt; idx.byNumber[numKey].push(p));&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#9;&#9; }&#10;&#9;&#9;}&#10;&#9; } catch (e) {&#10;&#9;&#9;// ignore mapping errors&#10;&#9; }&#10;&#9;}&#10;&#10;&#9;return idx;&#10; }, [perturbations, allHoraires, safeParseArrets, normalizeName]);&#10;&#10;&#10; const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) =&gt; {&#10;&#9;const res = [];&#10;&#9;const map = idx || buildPerturbMap();&#10;&#9;&#9;&#9;&#9;try { debug('findPerturbsForHoraireAndStop', { horaireId: horaire &amp;&amp; (horaire.id || horaire.horaire_id), numero: horaire &amp;&amp; (horaire.numero_train || horaire.numero), stationId, stationName, counts: { byHoraire: Object.keys(map.byHoraire||{}).length, byNumber: Object.keys(map.byNumber||{}).length } }); } catch (e) {}&#10;&#9;const hId = horaire &amp;&amp; (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train);&#10;&#9;const number = horaire &amp;&amp; (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number);&#10;&#9;if (hId != null) {&#10;&#9; const kh = String(hId);&#10;&#9; if (map.byHoraire &amp;&amp; map.byHoraire[kh]) res.push(...map.byHoraire[kh]);&#10;&#9;}&#10;&#9;if (number != null) {&#10;&#9; const kn = String(number);&#10;&#9; if (map.byNumber &amp;&amp; map.byNumber[kn]) res.push(...map.byNumber[kn]);&#10;&#9;}&#10;&#9;if (stationId != null) {&#10;&#9; const ks = String(stationId);&#10;&#9; if (map.byStopId &amp;&amp; map.byStopId[ks]) map.byStopId[ks].forEach(x =&gt; res.push(x.perturbation));&#10;&#9;}&#10;&#9;if (stationName &amp;&amp; map.byStopName) {&#10;&#9; const key = normalizeName(stationName);&#10;&#9; if (map.byStopName[key]) map.byStopName[key].forEach(x =&gt; res.push(x.perturbation));&#10;&#10;&#9; // recherche permissive : accepter les clés qui contiennent ou sont contenues par le needle&#10;&#9; try {&#10;&#9;&#9;const needle = key;&#10;&#9;&#9;for (const existingKey of Object.keys(map.byStopName || {})) {&#10;&#9;&#9; if (existingKey === needle) continue;&#10;&#9;&#9; if (existingKey.includes(needle) || needle.includes(existingKey)) {&#10;&#9;&#9;&#9;map.byStopName[existingKey].forEach(x =&gt; res.push(x.perturbation));&#10;&#9;&#9; }&#10;&#9;&#9;}&#10;&#9; } catch (e) {&#10;&#9;&#9;// ignore&#10;&#9; }&#10;&#9;}&#10;&#9;return Array.from(new Set(res));&#10; }, [buildPerturbMap, normalizeName, debug]);&#10;&#10;&#10; // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing&#10; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) =&gt; {&#10;&#9;const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || [];&#10;&#9;&#9;&#9;&#9;try { debug('computeStatusForHoraireAtStop', { id: horaire &amp;&amp; horaire.id, numero: horaire &amp;&amp; (horaire.numero_train || horaire.numero), stationId, stationName, found: found.length }); } catch (e) {}&#10;&#10;&#9;const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10;&#9;let horaireYmd = toYMD(horaire &amp;&amp; (horaire._date || horaire.date || horaire.date_service || horaire.service_date));&#10;&#9;if (!horaireYmd &amp;&amp; serviceDate) horaireYmd = serviceDate;&#10;&#10;&#9;let maxDelay = null;&#10;&#10;&#9;for (const p of found) {&#10;&#9; // date filter: if perturbation carries explicit dates or jours_perturbation, check they match horaireYmd&#10;&#9; try {&#10;&#9;&#9;if (horaireYmd) {&#10;&#9;&#9; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10;&#9;&#9; const db = toYMD(p.date_debut || p.start_date);&#10;&#9;&#9; const df = toYMD(p.date_fin || p.end_date);&#10;&#9;&#9; if (pDate &amp;&amp; pDate !== horaireYmd) { debug('perturbation date mismatch', p.id); continue; }&#10;&#9;&#9; if (db &amp;&amp; df &amp;&amp; !(horaireYmd &gt;= db &amp;&amp; horaireYmd &lt;= df)) { debug('perturbation date-range mismatch', p.id); continue; }&#10;&#9;&#9;}&#10;&#9; } catch (e) { /* ignore date parse errors -&gt; assume applies */ }&#10;&#10;&#9; // also check jours_perturbation (may be inside perturbations_arrets wrapper)&#10;&#9; try {&#10;&#9;&#9;const joursSet = getPerturbationJours(p);&#10;&#9;&#9;if (joursSet &amp;&amp; horaireYmd) {&#10;&#9;&#9; if (!joursSet.has(horaireYmd)) { debug('perturbation jours mismatch', p.id); continue; }&#10;&#9;&#9;}&#10;&#9; } catch (e) {}&#10;&#10;&#9; const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };&#10;&#9; const arrets = parsedObj.arrets || [];&#10;&#9; let matched = false;&#10;&#9;&#9;&#9;for (const a of arrets) {&#10;&#9;&#9;&#9;&#9;const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code;&#10;&#9;&#9;&#9;&#9;const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10;&#9;&#9;&#9;&#9;const na = normalizeName(aName || '');&#10;&#9;&#9;&#9;&#9;const ns = normalizeName(stationName || '');&#10;&#9;&#9;&#9;&#9;const nameMatches = stationName &amp;&amp; (na === ns || na.includes(ns) || ns.includes(na));&#10;&#9;&#9;&#9;&#9;if ((stationId != null &amp;&amp; String(aId) === String(stationId)) || nameMatches) {&#10;&#9;&#9; matched = true;&#10;&#9;&#9; const t = (a.type_perturbation || a.type || '').toString().toLowerCase();&#10;&#9;&#9; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10;&#9;&#9; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10;&#9;&#9;&#9;const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;&#10;&#9;&#9;&#9;if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10;&#9;&#9; }&#10;&#9;&#9;}&#10;&#9; }&#10;&#10;&#9; if (!matched) {&#10;&#9;&#9;// top-level perturbation that references the horaire/numero&#10;&#9;&#9;const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero;&#10;&#9;&#9;const numberRef = p.number || p.train_number || p.numero || p.numero_train;&#10;&#9;&#9;const appliesByRef = ref &amp;&amp; (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero));&#10;&#9;&#9;const appliesByNumber = numberRef &amp;&amp; (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number));&#10;&#9;&#9;const isGlobal = (!Array.isArray(arrets) || arrets.length === 0) &amp;&amp; !ref &amp;&amp; !numberRef;&#10;&#9;&#9;if (appliesByRef || appliesByNumber || isGlobal) {&#10;&#9;&#9; const t = (p.type_perturbation || p.type || '').toString().toLowerCase();&#10;&#9;&#9; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10;&#9;&#9; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10;&#9;&#9;&#9;const d = parseDelayMinutesFromArret(p) || 0;&#10;&#9;&#9;&#9;if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10;&#9;&#9; }&#10;&#9;&#9;}&#10;&#9; }&#10;&#9;}&#10;&#10;&#9;if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay };&#10;&#9;return { label: &quot;à l'heure&quot;, detail: '', delayMinutes: null };&#10; }, [findPerturbsForHoraireAndStop, parsePerturbArretsObject, getPerturbationJours, parseDelayMinutesFromArret, normalizeName]);&#10;&#10; // Applique les perturbations à une liste d'horaires : calcule status par arrêt et met à jour l'heure affichée&#10; const applyPerturbationsToHoraires = useCallback((list = [], perturbList = perturbations, displayStationId = null, displayStationName = null, serviceDate = null) =&gt; {&#10;&#9;const map = buildPerturbMap(perturbList || []);&#10; // fallback to global display station context&#10; const targetStationId = displayStationId || gareIdParam || null;&#10; const targetStationName = displayStationName || gareDisplay || null;&#10;&#10;&#9;&#9; return (list || []).map(h =&gt; {&#10;&#9;&#9; const horaire = { ...h };&#10;&#9;&#9;&#9;&#9;&#9; const stops = getStops(horaire);&#10;&#9; const stopsStatus = [];&#10;&#9; for (let i = 0; i &lt; stops.length; i++) {&#10;&#9;&#9;const s = stops[i] || {};&#10;&#9;&#9;const stationId = s.gare_id || s.gareId || s.id || s.station_id || s.stationId || s.uic || s.code;&#10;&#9;&#9;const stationName = s.gare_nom || s.nom || s.name || s.station_name || s.station_nom;&#10;&#9;&#9;&#9;&#9;&#9;&#9;const st = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, serviceDate);&#10;&#9;&#9;stopsStatus.push({ stationId, stationName, status: st });&#10;&#9; }&#10;&#10; // compute display time using delay at the display station if any&#10;&#9; const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate);&#10;&#9; let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || '');&#10; if (displayStatus &amp;&amp; typeof displayStatus.delayMinutes === 'number' &amp;&amp; !isNaN(displayStatus.delayMinutes)) {&#10; if (typeof horaire._depHM === 'number') {&#10; displayTime = formatTimeFromHM(horaire._depHM + displayStatus.delayMinutes);&#10; } else {&#10; const tryParse = (s) =&gt; {&#10; if (!s) return null;&#10; const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10; if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10; const d = new Date(s);&#10; if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10; return null;&#10; };&#10; const base = tryParse(horaire._rawTime || horaire.heure_depart || horaire.time || horaire.heure || '');&#10; if (typeof base === 'number') displayTime = formatTimeFromHM(base + displayStatus.delayMinutes);&#10; }&#10; }&#10;&#10;&#9; horaire._stopsStatus = stopsStatus;&#10;&#9; // prefer platform from stop data (quai) if available&#10;&#9; const stopForStation = (stops || []).find(s =&gt; {&#10;&#9;&#9;const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;&#10;&#9;&#9;const nm = s.gare_nom || s.nom || s.name || s.stop_name || '';&#10;&#9;&#9;const ns = normalizeName(nm || '');&#10;&#9;&#9;const target = normalizeName(targetStationName || '');&#10;&#9;&#9;return (targetStationId &amp;&amp; String(sid) === String(targetStationId)) || (targetStationName &amp;&amp; (ns === target || ns.includes(target) || target.includes(ns)));&#10;&#9; }) || null;&#10;&#9; horaire._status = displayStatus;&#10;&#9; horaire._displayTime = displayTime;&#10;&#9; horaire._displayPlatform = (stopForStation &amp;&amp; (stopForStation.quai || stopForStation.platform || stopForStation.quay)) || horaire.platform || '';&#10;&#9; return horaire;&#10;&#9;});&#10; }, [buildPerturbMap, computeStatusForHoraireAtStop, formatTimeFromHM, perturbations, gareDisplay, gareIdParam, getStops, normalizeName, debug]);&#10;&#10; useEffect(() =&gt; {&#10;&#9;const id = setInterval(() =&gt; setShowStatus(s =&gt; !s), 3000);&#10;&#9;return () =&gt; clearInterval(id);&#10; }, []);&#10;&#10; // Récupération Supabase — maintenant avec polling pour synchroniser sans recharger la page&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; let inFlight = false; // empêche les fetchs concurrents&#10;&#10; const fetchData = async () =&gt; {&#10;&#9; if (!mounted) return;&#10;&#9; if (inFlight) return; // éviter les appels simultanés si le fetch précédent n'est pas terminé&#10;&#9; inFlight = true;&#10;&#9; if (!supabase) {&#10;&#9;&#9;// Supabase non initialisé — avertir et définir un message d'erreur pour l'UI&#10;&#9;&#9;// eslint-disable-next-line no-console&#10;&#9;&#9;console.warn('Supabase non initialisé — vérifiez REACT_APP_SUPABASE_URL et REACT_APP_SUPABASE_ANON_KEY');&#10;&#9;&#9;setError('Supabase non initialisé — vérifiez les variables d\'environnement');&#10;&#9;&#9;setLoading(false);&#10;&#9;&#9;inFlight = false;&#10;&#9;&#9;return;&#10;&#9; }&#10;&#9; setLoading(true);&#10;&#9; setError(null);&#10;&#9; try {&#10;&#9;&#9;const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([&#10;&#9;&#9; // DEBUG: récupérer explicitement l'id et toutes les colonnes pour s'assurer que l'identifiant&#10;&#9;&#9; // `id` est bien présent et peut être mis en correspondance avec `horaire_id` dans perturbations&#10;&#9;&#9; supabase.from('horaires').select('id, *'),&#10;&#9;&#9; supabase.from('perturbations').select('*'),&#10;&#9;&#9; supabase.from('travaux').select('*')&#10;&#9;&#9;]);&#10;&#10;&#9;&#9;if (!mounted) { inFlight = false; return; }&#10;&#10;&#9;&#9;if (hError || pError || tError) {&#10;&#9;&#9; if (mounted) setError((hError || pError || tError).message || String(hError || pError || tError));&#10;&#9;&#9; setLoading(false);&#10;&#9;&#9; inFlight = false;&#10;&#9;&#9; return;&#10;&#9;&#9;}&#10;&#10;&#9;&#9;// debug: afficher ce que Supabase retourne&#10;&#9;&#9;// eslint-disable-next-line no-console&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;debug('Supabase horaires fetched', { count: (horairesData || []).length, sample: (horairesData || []).slice(0,5) });&#10;&#9;&#9;if (mounted) setRawDebug({ count: (horairesData || []).length, sample: (horairesData || []).slice(0,3) });&#10;&#10;&#9;&#9;// conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare)&#10;&#9;&#9;let parsedHoraires = (horairesData || []).map(h =&gt; h || {});&#10;&#10;&#9;&#9;// Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD;&#10;&#9;&#9;// essayer de parser pour garantir que stops est un tableau utilisable.&#10;&#9;&#9;parsedHoraires = parsedHoraires.map(h =&gt; {&#10;&#9;&#9; try {&#10;&#9;&#9;&#9;if (h &amp;&amp; typeof h.stops === 'string') {&#10;&#9;&#9;&#9; const parsed = JSON.parse(h.stops);&#10;&#9;&#9;&#9; if (Array.isArray(parsed)) {&#10;&#9;&#9;&#9;&#9;return { ...h, stops: parsed };&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#9;&#9; } catch (e) {&#10;&#9;&#9;&#9;// ignore parse error&#10;&#9;&#9; }&#10;&#9;&#9; return h;&#10;&#9;&#9;});&#10;&#10;&#9;&#9;// debug: afficher après normalisation&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;// eslint-disable-next-line no-console&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;debug('Supabase horaires normalized', { count: parsedHoraires.length, sampleStopsExample: (parsedHoraires[0] &amp;&amp; parsedHoraires[0].stops) || null });&#10;&#10;&#9;&#9;// stocker la liste complète (non filtrée) pour associer horaire_id -&gt; numero_train&#10; if (mounted) setAllHoraires(parsedHoraires || []);&#10;&#10; // Récupérer le type de gare depuis la table `gares` si possible&#10; try {&#10; let fetchedType = null;&#10; if (gareIdParam) {&#10; const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1);&#10; if (!gerr &amp;&amp; gdata &amp;&amp; gdata.length) fetchedType = gdata[0] &amp;&amp; gdata[0].type;&#10; } else if (gareDisplay) {&#10; const needle = `%${gareDisplay}%`;&#10; try {&#10; const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').or(`nom.ilike.${needle},name.ilike.${needle}`).limit(1);&#10; if (!gerr &amp;&amp; gdata &amp;&amp; gdata.length) fetchedType = gdata[0] &amp;&amp; gdata[0].type;&#10; } catch (e) {&#10; try {&#10; const { data: gdata } = await supabase.from('gares').select('id,type').ilike('nom', needle).limit(1);&#10; if (gdata &amp;&amp; gdata.length) fetchedType = gdata[0] &amp;&amp; gdata[0].type;&#10; } catch (err) { /* ignore */ }&#10; }&#10; }&#10; if (mounted) setStationType(fetchedType ? String(fetchedType).toLowerCase() : null);&#10; } catch (e) { /* ignore errors reading gares table */ }&#10;&#9;&#9;// Filtrer par gare : garder uniquement les horaires qui DESSERVENT la gare ciblée&#10;&#9;&#9;// (soit comme gare_depart, soit comme un arrêt intermédiaire dans `stops` / `gares_desservies`)&#10;&#9;&#9;// Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus)&#10;&#9;&#9;let displayHoraires = parsedHoraires.slice();&#10;&#9;&#9;if (gareDisplay) {&#10;&#9;&#9; const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' '));&#10;&#9;&#9; displayHoraires = displayHoraires.filter(h =&gt; {&#10;&#9;&#9;&#9;// Exclure si la gare ciblée est la gare d'arrivée finale&#10;&#9;&#9;&#9;const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString());&#10;&#9;&#9;&#9;if (destName &amp;&amp; (destName === normalized || destName.includes(normalized) || normalized.includes(destName))) return false;&#10;&#10;&#9;&#9;&#9;// Inclure si gare de départ correspond&#10;&#9;&#9;&#9;const departName = normalizeName((h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString());&#10;&#9;&#9;&#9;if (departName &amp;&amp; (departName === normalized || departName.includes(normalized) || normalized.includes(departName))) return true;&#10;&#10;&#9;&#9;&#9;// Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max)&#10;&#9;&#9;&#9;const stopsList = getStops(h);&#10;&#9;&#9;&#9;if (Array.isArray(stopsList) &amp;&amp; stopsList.length) {&#10;&#9;&#9;&#9; let maxIdx = null;&#10;&#9;&#9;&#9; for (const st of stopsList) {&#10;&#9;&#9;&#9;&#9;if (typeof st.index !== 'undefined' &amp;&amp; st.index !== null) {&#10;&#9;&#9;&#9;&#9; const n = Number(st.index);&#10;&#9;&#9;&#9;&#9; if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);&#10;&#9;&#9;&#9;&#9;}&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9; for (const s of stopsList) {&#10;&#9;&#9;&#9;&#9;const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString());&#10;&#9;&#9;&#9;&#9;if (!stopName) continue;&#10;&#9;&#9;&#9;&#9;const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' &amp;&amp; maxIdx != null &amp;&amp; Number(s.index) === maxIdx));&#10;&#9;&#9;&#9;&#9;if (isTerminus) continue;&#10;&#9;&#9;&#9;&#9;if (stopName === normalized || stopName.includes(normalized) || normalized.includes(stopName)) return true;&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#9;&#9;&#9;return false;&#10;&#9;&#9; });&#10;&#9;&#9;} else if (gareIdParam) {&#10;&#9;&#9; const gid = String(gareIdParam);&#10;&#9;&#9; displayHoraires = displayHoraires.filter(h =&gt; {&#10;&#9;&#9;&#9;// Exclure si gare d'arrivée finale correspond&#10;&#9;&#9;&#9;const destId = (h.gare_arrivee_id || h.gare_arrivee || null);&#10;&#9;&#9;&#9;if (destId != null &amp;&amp; String(destId) === gid) return false;&#10;&#10;&#9;&#9;&#9;// Inclure si gare de départ correspond&#10;&#9;&#9;&#9;const departId = h.gare_depart_id || h.gare_id || h.origin_id || null;&#10;&#9;&#9;&#9;if (departId != null &amp;&amp; String(departId) === gid) return true;&#10;&#10;&#9;&#9;&#9;// Chercher dans les arrêts desservis (ignorer terminus)&#10;&#9;&#9;&#9;const stopsList = getStops(h);&#10;&#9;&#9;&#9;if (Array.isArray(stopsList) &amp;&amp; stopsList.length) {&#10;&#9;&#9;&#9; let maxIdx = null;&#10;&#9;&#9;&#9; for (const st of stopsList) {&#10;&#9;&#9;&#9;&#9;if (typeof st.index !== 'undefined' &amp;&amp; st.index !== null) {&#10;&#9;&#9;&#9;&#9; const n = Number(st.index);&#10;&#9;&#9;&#9;&#9; if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);&#10;&#9;&#9;&#9;&#9;}&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9; for (const s of stopsList) {&#10;&#9;&#9;&#9;&#9;const stopId = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;&#10;&#9;&#9;&#9;&#9;if (stopId == null) continue;&#10;&#9;&#9;&#9;&#9;const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' &amp;&amp; maxIdx != null &amp;&amp; Number(s.index) === maxIdx));&#10;&#9;&#9;&#9;&#9;if (isTerminus) continue;&#10;&#9;&#9;&#9;&#9;if (String(stopId) === gid) return true;&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#9;&#9;&#9;return false;&#10;&#9;&#9; });&#10;&#9;&#9;}&#10;&#10;&#9;&#9;// Normaliser date de service (si présente)&#10;&#9;&#9;const dateKeys = ['date_depart','date','jour','service_date','date_service','date_dep'];&#10;&#9;&#9;const normalizeDate = (o) =&gt; {&#10;&#9;&#9; for (const k of dateKeys) {&#10;&#9;&#9;&#9;if (!o) continue;&#10;&#9;&#9;&#9;const v = o[k];&#10;&#9;&#9;&#9;if (!v) continue;&#10;&#9;&#9;&#9;const d = new Date(v);&#10;&#9;&#9;&#9;if (!isNaN(d.getTime())) return d.toISOString().slice(0,10);&#10;&#9;&#9; }&#10;&#9;&#9; return null;&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const withDates = displayHoraires.map(h =&gt; ({ ...h, _date: normalizeDate(h) }));&#10;&#10;&#9;&#9;&#9;&#9;&#9;// parse HH:mm or HH:MM:SS into minutes since midnight&#10;&#9;&#9;&#9;&#9;&#9;const parseTimeHM = (s) =&gt; {&#10;&#9;&#9;&#9;&#9;&#9;&#9;if (!s) return null;&#10;&#9;&#9;&#9;&#9;&#9;&#9;if (typeof s === 'number') return s;&#10;&#9;&#9;&#9;&#9;&#9;&#9;const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10;&#9;&#9;&#9;&#9;&#9;&#9;if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10;&#9;&#9;&#9;&#9;&#9;&#9;const d = new Date(s);&#10;&#9;&#9;&#9;&#9;&#9;&#9;if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10;&#9;&#9;&#9;&#9;&#9;&#9;return null;&#10;&#9;&#9;&#9;&#9;&#9;};&#10;&#10;&#9;&#9;&#9;&#9;&#9;// enrichir withDates avec heures en minutes pour aujourd'hui et demain&#10;&#9;&#9;&#9;&#9;&#9;const now = new Date();&#10;&#9;&#9;&#9;&#9;&#9;const todayHM = now.getHours()*60 + now.getMinutes();&#10;&#9;&#9;&#9;&#9;&#9;const enrich = withDates.map(h =&gt; {&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;let raw = h.heure_depart || h.time || h.heure || '';&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;// si stops contient la gare ciblée, préférer son heure&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;const stopsListForH = getStops(h);&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;if ((gareIdParam || gareDisplay) &amp;&amp; Array.isArray(stopsListForH)) {&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9; const byId = gareIdParam ? stopsListForH.find(s =&gt; String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null;&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9; const byName = gareDisplay ? stopsListForH.find(s =&gt; normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null;&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9; const stop = byId || byName;&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9; if (stop &amp;&amp; (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure;&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;}&#10;&#9;&#9;&#9;&#9;&#9;&#9;const depHM = parseTimeHM(raw);&#10;&#9;&#9;&#9;&#9;&#9;&#9;const displayTime = formatTimeNoSeconds(raw);&#10;&#9;&#9;&#9;&#9;&#9;&#9;return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime };&#10;&#9;&#9;&#9;&#9;&#9;});&#10;&#10;&#10;&#9;&#9;// fonctions utilitaires de jours&#10;&#9;&#9;const pad = (n) =&gt; String(n).padStart(2,'0');&#10;&#9;&#9;const localDateStr = (d) =&gt; `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;&#10;&#9;&#9;const today = new Date();&#10;&#9;&#9;const todayStr = localDateStr(today);&#10;&#9;&#9;const tomorrow = new Date(today);&#10;&#9;&#9;tomorrow.setDate(today.getDate()+1);&#10;&#9;&#9;const tomorrowStr = localDateStr(tomorrow);&#10;&#10;&#9;&#9;const weekdayIndex = (d) =&gt; d.getDay(); // 0 dimanche .. 6 samedi&#10;&#9;&#9;const weekdayNameForColumn = (d) =&gt; {&#10;&#9;&#9; const i = weekdayIndex(d);&#10;&#9;&#9; switch(i) {&#10;&#9;&#9;&#9;case 1: return 'circule_lundi';&#10;&#9;&#9;&#9;case 2: return 'circule_mardi';&#10;&#9;&#9;&#9;case 3: return 'circule_mercredi';&#10;&#9;&#9;&#9;case 4: return 'circule_jeudi';&#10;&#9;&#9;&#9;case 5: return 'circule_vendredi';&#10;&#9;&#9;&#9;case 6: return 'circule_samedi';&#10;&#9;&#9;&#9;case 0: return 'circule_dimanche';&#10;&#9;&#9;&#9;default: return null;&#10;&#9;&#9; }&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const circulatesOn = (h, d) =&gt; {&#10;&#9;&#9; const col = weekdayNameForColumn(d);&#10;&#9;&#9; if (!col) return true;&#10;&#9;&#9; const v = h[col];&#10;&#9;&#9; if (typeof v === 'boolean') return v;&#10;&#9;&#9; if (typeof v === 'number') return v === 1;&#10;&#9;&#9; if (typeof v === 'string') {&#10;&#9;&#9;&#9;const s = v.trim().toLowerCase();&#10;&#9;&#9;&#9;return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes';&#10;&#9;&#9; }&#10;&#9;&#9; // si la colonne est undefined ou null, on considère que ça circule (fallback permissif)&#10;&#9;&#9; return (typeof v === 'undefined' || v === null) ? true : Boolean(v);&#10;&#9;&#9;};&#10;&#10;&#9;&#9;// Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_*&#10;&#9;&#9;// Calculer les travaux applicables et les substitutions&#10;&#9;&#9;const travauxList = travauxData || [];&#10;&#10;&#9;&#9;const parseDateOnly = (v) =&gt; {&#10;&#9;&#9; if (!v) return null;&#10;&#9;&#9; const d = new Date(v);&#10;&#9;&#9; if (isNaN(d.getTime())) return null;&#10;&#9;&#9; return new Date(d.getFullYear(), d.getMonth(), d.getDate());&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const parseJoursTravaux = (val) =&gt; {&#10;&#9;&#9; if (val == null) return null;&#10;&#9;&#9; if (Array.isArray(val)) return new Set(val.map(x =&gt; String(x).toLowerCase()));&#10;&#9;&#9; const s = String(val || '');&#10;&#9;&#9; const parts = s.split(/[,;\s]+/).map(p =&gt; p.trim()).filter(Boolean);&#10;&#9;&#9; return parts.length ? new Set(parts.map(p =&gt; p.toLowerCase())) : null;&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const weekdayShort = (i) =&gt; {&#10;&#9;&#9; switch(i) { case 0: return 'dim'; case 1: return 'lun'; case 2: return 'mar'; case 3: return 'mer'; case 4: return 'jeu'; case 5: return 'ven'; case 6: return 'sam'; default: return null; }&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const travauxApplies = (t, h, date) =&gt; {&#10;&#9;&#9; if (!t) return false;&#10;&#9;&#9; const db = parseDateOnly(t.date_debut || t.dateDebut || t.start_date);&#10;&#9;&#9; const df = parseDateOnly(t.date_fin || t.dateFin || t.end_date);&#10;&#9;&#9; if (!db || !df) return false;&#10;&#9;&#9; const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());&#10;&#9;&#9; if (d &lt; db || d &gt; df) return false;&#10;&#9;&#9; const joursSet = parseJoursTravaux(t.jours_travaux || t.jours || t.days);&#10;&#9;&#9; if (joursSet) {&#10;&#9;&#9;&#9;const name = weekdayShort(d.getDay());&#10;&#9;&#9;&#9;if (!name) return false;&#10;&#9;&#9;&#9;let ok = false;&#10;&#9;&#9;&#9;for (const v of joursSet) { if (v.includes(name) || name.includes(v)) { ok = true; break; } }&#10;&#9;&#9;&#9;if (!ok) return false;&#10;&#9;&#9; }&#10;&#9;&#9; const startHM = parseTimeHM(t.heure_debut || t.heureDebut || t.start_time);&#10;&#9;&#9; const endHM = parseTimeHM(t.heure_fin || t.heureFin || t.end_time);&#10;&#9;&#9; if (startHM == null || endHM == null) return true;&#10;&#9;&#9; if (typeof h._depHM === 'number') return h._depHM &gt;= startHM &amp;&amp; h._depHM &lt;= endHM;&#10;&#9;&#9; return false;&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const isSubstitution = (h) =&gt; {&#10;&#9;&#9; if (!h) return false;&#10;&#9;&#9; // nouvelle colonne spécifique&#10;&#9;&#9; if (h.substitution_disponible === true) return true;&#10;&#9;&#9; // anciennes variantes&#10;&#9;&#9; const subFlag = h.substitution === true || h.is_substitution === true || h.isSubstitution === true;&#10;&#9;&#9; const subStr = (typeof h.substitution === 'string' &amp;&amp; ['true','1','yes','y','t'].includes(h.substitution.toLowerCase()));&#10;&#9;&#9; const typeMatch = h.type_train &amp;&amp; /subst|substitut|remplac/i.test(String(h.type_train));&#10;&#9;&#9; const commentMatch = h.comment &amp;&amp; /subst|substitut|remplac/i.test(String(h.comment));&#10;&#9;&#9; return Boolean(subFlag || subStr || typeMatch || commentMatch);&#10;&#9;&#9;};&#10;&#10;&#9;&#9;// compute linkage and travaux applicability per horaire&#10;&#9;&#9;const enriched = enrich.map(h =&gt; {&#10;&#9;&#9; let linked = false;&#10;&#9;&#9; let appliesToday = false;&#10;&#9;&#9; let appliesTomorrow = false;&#10;&#9;&#9; for (const t of travauxList) {&#10;&#9;&#9;&#9;// Extraire identifiants de ligne (différentes variantes possibles)&#10;&#9;&#9;&#9;const tLine = t.ligne_id || t.ligne || t.line_id || t.line || t.ligneId || t.lineId;&#10;&#9;&#9;&#9;const hLine = h.ligne_id || h.ligne || h.line_id || h.line || h.ligneId || h.lineId;&#10;&#10;&#9;&#9;&#9;// Si le travail a une ligne attribuée, n'appliquer le travail QUE si la ligne correspond&#10;&#9;&#9;&#9;// (si hLine est absent on considère qu'on ne peut pas appliquer ce travail à cet horaire)&#10;&#9;&#9;&#9;if (typeof tLine !== 'undefined' &amp;&amp; tLine !== null) {&#10;&#9;&#9;&#9; if (typeof hLine === 'undefined' || hLine === null) {&#10;&#9;&#9;&#9;&#9;// pas de ligne sur l'horaire -&gt; ignorer ce travail pour cet horaire&#10;&#9;&#9;&#9;&#9;continue;&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9; if (String(tLine) !== String(hLine)) {&#10;&#9;&#9;&#9;&#9;// lignes différentes -&gt; ignorer ce travail pour cet horaire&#10;&#9;&#9;&#9;&#9;continue;&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#10;&#9;&#9;&#9;const ref = t.horaire_id || t.horaire || t.horaireId || t.numero_train || t.train_number || t.numero;&#10;&#9;&#9;&#9;if (ref &amp;&amp; (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true;&#10;&#9;&#9;&#9;if (!appliesToday &amp;&amp; travauxApplies(t,h,today)) appliesToday = true;&#10;&#9;&#9;&#9;if (!appliesTomorrow &amp;&amp; travauxApplies(t,h,tomorrow)) appliesTomorrow = true;&#10;&#9;&#9;&#9;if (linked &amp;&amp; appliesToday &amp;&amp; appliesTomorrow) break;&#10;&#9;&#9; }&#10;&#9;&#9; return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow };&#10;&#9;&#9;});&#10;&#10;&#9;&#9;// Build today and tomorrow candidate lists&#10;&#9;&#9;const todayCandidates = enriched.filter(h =&gt; {&#10;&#9;&#9; if (h._date &amp;&amp; h._date !== todayStr) return false;&#10;&#9;&#9; if (!circulatesOn(h, today)) return false;&#10;&#9;&#9; if (typeof h._depHM !== 'number') return false;&#10;&#9;&#9; if (h._depHM &lt;= todayHM) return false;&#10;&#10;&#9;&#9; // if travaux apply at this horaire today, hide regular horaires&#10;&#9;&#9; if (h._travauxAppliesToday &amp;&amp; !isSubstitution(h)) return false;&#10;&#10;&#9;&#9; if (isSubstitution(h)) {&#10;&#9;&#9;&#9;// show substitution only if substitution_disponible AND linked to travaux that apply today&#10;&#9;&#9;&#9;return Boolean(h.substitution_disponible === true &amp;&amp; h._linkedToTravaux &amp;&amp; h._travauxAppliesToday);&#10;&#9;&#9; }&#10;&#10;&#9;&#9; return true;&#10;&#9;&#9;});&#10;&#10;&#9;&#9;const tomorrowCandidates = enriched.filter(h =&gt; {&#10;&#9;&#9; if (h._date &amp;&amp; h._date !== tomorrowStr) return false;&#10;&#9;&#9; if (!circulatesOn(h, tomorrow)) return false;&#10;&#10;&#9;&#9; // hide regular horaires if travaux apply tomorrow during their time window&#10;&#9;&#9; if (h._travauxAppliesTomorrow &amp;&amp; !isSubstitution(h)) return false;&#10;&#10;&#9;&#9; if (isSubstitution(h)) {&#10;&#9;&#9;&#9;// show substitution only if substitution_disponible AND linked to travaux that apply tomorrow&#10;&#9;&#9;&#9;return Boolean(h.substitution_disponible === true &amp;&amp; h._linkedToTravaux &amp;&amp; h._travauxAppliesTomorrow);&#10;&#9;&#9; }&#10;&#10;&#9;&#9; return true;&#10;&#9;&#9;});&#10;&#10;&#9;&#9;// final list: if there are any todayCandidates use them; otherwise use tomorrowCandidates&#10;&#9;&#9;let finalList;&#10;&#9;&#9;let serviceDateStr;&#10;&#9;&#9;if (todayCandidates &amp;&amp; todayCandidates.length &gt; 0) {&#10;&#9;&#9; finalList = todayCandidates.slice().sort((a,b)=&gt; (a._depHM||99999)-(b._depHM||99999));&#10;&#9;&#9; serviceDateStr = todayStr;&#10;&#9;&#9;} else {&#10;&#9;&#9; finalList = (tomorrowCandidates || []).slice().sort((a,b)=&gt; (a._depHM||99999)-(b._depHM||99999));&#10;&#9;&#9; serviceDateStr = tomorrowStr;&#10;&#9;&#9;}&#10;&#10;&#9;&#9;// Construire un index local des perturbations à partir des données fraîchement récupérées&#10;&#9;&#9;// Filtrer pour ne garder que les perturbations applicables aujourd'hui (éviter d'appliquer les perturbations d'hier)&#10;&#9;&#9;const todayYmd = new Date().toLocaleDateString('en-CA');&#10;&#9;&#9;const filteredPerturb = (perturbData || []).filter(p =&gt; {&#10;&#9;&#9; try {&#10;&#9;&#9;&#9;const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10;&#9;&#9;&#9;const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10;&#9;&#9;&#9;const db = toYMD(p.date_debut || p.start_date);&#10;&#9;&#9;&#9;const df = toYMD(p.date_fin || p.end_date);&#10;&#9;&#9;&#9;// si pDate fourni et ne correspond pas à aujourd'hui -&gt; ignorer&#10;&#9;&#9;&#9;if (pDate &amp;&amp; pDate !== todayYmd) return false;&#10;&#9;&#9;&#9;// si fourche de dates fournie et aujourd'hui n'est pas dans l'intervalle -&gt; ignorer&#10;&#9;&#9;&#9;if (db &amp;&amp; df &amp;&amp; !(todayYmd &gt;= db &amp;&amp; todayYmd &lt;= df)) return false;&#10;&#9;&#9;&#9;// si jours_perturbation fourni -&gt; vérifier qu'aujourd'hui en fait partie&#10;&#9;&#9;&#9;try {&#10;&#9;&#9;&#9; const joursSet = getPerturbationJours(p);&#10;&#9;&#9;&#9; if (joursSet &amp;&amp; !joursSet.has(todayYmd)) return false;&#10;&#9;&#9;&#9;} catch (e) {&#10;&#9;&#9;&#9; // ignore parse errors and keep perturbation&#10;&#9;&#9;&#9;}&#10;&#9;&#9;&#9;return true;&#10;&#9;&#9; } catch (e) {&#10;&#9;&#9;&#9;return true;&#10;&#9;&#9; }&#10;&#9;&#9;});&#10;&#10;&#9;&#9;const localPerturbMap = buildPerturbMap(filteredPerturb || [], parsedHoraires || []);&#10;&#9;&#9;// Debug: afficher sommaire de l'index des perturbations&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;debug('localPerturbMap summary', {&#10;&#9;&#9; horaireCount: Object.keys(localPerturbMap.byHoraire || {}).length,&#10;&#9;&#9; numberCount: Object.keys(localPerturbMap.byNumber || {}).length,&#10;&#9;&#9; stopIdCount: Object.keys(localPerturbMap.byStopId || {}).length,&#10;&#9;&#9; stopNameCount: Object.keys(localPerturbMap.byStopName || {}).length,&#10;&#9;&#9; globalCount: (localPerturbMap.global || []).length,&#10;&#9;&#9; sampleStopNames: Object.keys(localPerturbMap.byStopName || {}).slice(0,10)&#10;&#9;&#9;});&#10;&#10;&#9;&#9;// Annoter chaque horaire avec un statut calculé (synchronisé avec la BDD)&#10;&#9;&#9;// déterminer la date de service utilisée pour ces finalList (aujourd'hui ou demain)&#10;&#9;&#9;const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr);&#10;&#10;&#9;&#9;// Debug: log first few annotated horaires&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;try { debug('annotated horaires sample', annotated.slice(0,6).map(a =&gt; ({ id: a.id, numero: a.numero_train || a.numero, _status: a._status, _displayTime: a._displayTime }))); } catch (e) {}&#10;&#10;&#9;&#9;// afficher seulement les 9 premières lignes sur l'afficheur&#10;&#9;&#9;setHoraires((annotated || []).slice(0, 9));&#10;&#9;&#9;setPerturbations(filteredPerturb || []);&#10;&#9;&#9;setTravaux(travauxData || []);&#10;&#9; } catch (err) {&#10;&#9;&#9;// eslint-disable-next-line no-console&#10;&#9;&#9;console.error('Erreur Supabase horaires', err);&#10;&#9;&#9;if (mounted) setError(err.message || String(err));&#10;&#9; } finally {&#10;&#9;&#9;if (mounted) setLoading(false);&#10;&#9;&#9;inFlight = false;&#10;&#9; }&#10;&#9;};&#10;&#10;&#9;// Exécuter immédiatement, puis périodiquement pour synchroniser sans recharger&#10;&#9;fetchData();&#10;&#9;const intervalMs = 15000; // 15 secondes par défaut&#10;&#9;const intervalId = setInterval(fetchData, intervalMs);&#10;&#10;&#9;return () =&gt; { mounted = false; clearInterval(intervalId); };&#10; }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops]);&#10;&#10; // Helpers pour perturbations (memoized plus haut)&#10;&#10; return (&#10;&#9;&lt;div className=&quot;aff-classiques-departs__container&quot;&gt;&#10;&#9; {/* nom de la gare configurable via ?gare=Nom-de-la-gare */}&#10;&#9; &lt;div className=&quot;aff-classiques-departs__watermark&quot;&gt;départs&lt;/div&gt;&#10;&#9; &lt;ul className=&quot;aff-classiques-departs__list&quot;&gt;&#10;&#9;&#9;{horaires.length === 0 ? (&#10;&#9;&#9; &lt;li className=&quot;aff-classiques-departs__row aff-classiques-departs__row--empty&quot; key=&quot;empty&quot;&gt;&#10;&#9;&#9;&#9;&lt;div&gt;Aucun horaire disponible.&lt;/div&gt;&#10;&#9;&#9;&#9; &lt;div style={{fontSize:'0.8rem', opacity:0.8}}&gt;lignes brutes renvoyées : {rawDebug.count}&lt;/div&gt;&#10;&#9;&#9;&#9; {rawDebug.count &gt; 0 &amp;&amp; rawDebug.sample &amp;&amp; rawDebug.sample.length &gt; 0 ? (&#10;&#9;&#9;&#9;&#9; &lt;pre style={{maxHeight: 160, overflow: 'auto', fontSize: '0.75rem', marginTop: 8, background: '#111', color:'#fff', padding:8}}&gt;&#10;&#9;&#9;&#9;&#9;{JSON.stringify(rawDebug.sample.slice(0,2), null, 2).slice(0,1000)}{JSON.stringify(rawDebug.sample.slice(0,2), null, 2).length &gt; 1000 ? '…(truncated)' : ''}&#10;&#9;&#9;&#9; &lt;/pre&gt;&#10;&#9;&#9;&#9;) : null}&#10;&#9;&#9; &lt;/li&gt;&#10;&#9;&#9;) : horaires.map((h, idx) =&gt; {&#10;&#9;&#9; const horaire = h || {};&#10;&#9;&#9; const op = horaire.type_train || horaire.op || '';&#10;&#9;&#9; const number = horaire.numero_train || horaire.number || horaire.numero || '';&#10;&#9;&#9; // Use enriched display time (computed during fetch) — fallback to formatting raw fields&#10;&#9;&#9; let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || '');&#10;&#10;&#9;&#9; const destination = horaire.gare_arrivee_nom || '';&#10;&#9;&#9; const platform = (horaire._displayPlatform || horaire.platform || '').toString().trim();&#10;&#9;&#9; // attachedPerturbs/rowObj previously used for mocked status; status is now computed from perturbations per stop&#10;&#10;&#9;&#9; // determine station context (the display station) — prefer explicit gareIdParam/gareDisplay if provided&#10;&#9;&#9; const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null);&#10;&#9;&#9; const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null);&#10;&#10;&#9;&#9; // prefer precomputed status and display time (synchronisé depuis la BDD) — sinon calculer à la volée&#10;&#9;&#9; const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations));&#10;&#10;&#9;&#9; // if the status is 'supprimé' and the component is currently showing the status (showStatus toggles every 3s), hide the time&#10;&#9;&#9; if (status &amp;&amp; status.label === 'supprimé' &amp;&amp; showStatus) {&#10;&#9;&#9;&#9;time = '';&#10;&#9;&#9; } else if (!horaire._displayTime &amp;&amp; status &amp;&amp; typeof status.delayMinutes === 'number' &amp;&amp; !isNaN(status.delayMinutes)) {&#10;&#9;&#9;&#9;// if we have _depHM (minutes since midnight) use it&#10;&#9;&#9;&#9;if (typeof horaire._depHM === 'number') {&#10;&#9;&#9;&#9; time = formatTimeFromHM(horaire._depHM + status.delayMinutes);&#10;&#9;&#9;&#9;} else {&#10;&#9;&#9;&#9; // fallback: try to parse original time string&#10;&#9;&#9;&#9; const tryParse = (s) =&gt; {&#10;&#9;&#9;&#9;&#9;if (!s) return null;&#10;&#9;&#9;&#9;&#9;const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10;&#9;&#9;&#9;&#9;if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10;&#9;&#9;&#9;&#9;const d = new Date(s);&#10;&#9;&#9;&#9;&#9;if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10;&#9;&#9;&#9;&#9;return null;&#10;&#9;&#9;&#9; };&#10;&#9;&#9;&#9; const base = tryParse(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || '');&#10;&#9;&#9;&#9; if (typeof base === 'number') time = formatTimeFromHM(base + status.delayMinutes);&#10;&#9;&#9;&#9;}&#10;&#9;&#9; }&#10;&#9;&#9; const statusClass = status.label === 'supprimé' ? 'aff-classiques-departs__train-meta--suppressed' : (status.label &amp;&amp; String(status.label).startsWith('retardé') ? 'aff-classiques-departs__train-meta--delayed' : '');&#10;&#10;&#9;&#9; // Ne pas afficher le quai si le train est supprimé ou si aucun quai n'est défini&#10;&#9;&#9; let showPlatform = !(status &amp;&amp; status.label === 'supprimé') &amp;&amp; Boolean(platform);&#10;&#9;&#9; if (showPlatform) {&#10;&#9;&#9;&#9;const now = new Date();&#10;&#9;&#9;&#9;const nowHM = now.getHours()*60 + now.getMinutes();&#10;&#9;&#9;&#9;const sched = (typeof horaire._depHM === 'number') ? horaire._depHM : parseTimeHM(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || '');&#10;&#9;&#9;&#9;const threshold = thresholdForStationType(stationType);&#10;&#9;&#9;&#9;if (typeof sched === 'number' &amp;&amp; typeof threshold === 'number' &amp;&amp; threshold &gt; 0) {&#10;&#9;&#9;&#9; // n'afficher le quai que si l'heure courante est au moins (scheduled - threshold)&#10;&#9;&#9;&#9; showPlatform = (nowHM &gt;= (sched - threshold));&#10;&#9;&#9;&#9;}&#10;&#9;&#9; }&#10;&#10;&#9;&#9; return (&#10;&#9;&#9;&#9;&lt;li className=&quot;aff-classiques-departs__row&quot; key={idx}&gt;&#10;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__col-logo&quot;&gt;&#10;&#9;&#9;&#9;&#9;&lt;Logo op={op} /&gt;&#10;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__col-train-type&quot;&gt;&#10;&#9;&#9;&#9;&#9;{showStatus ? (&#10;&#9;&#9;&#9;&#9; &lt;div className={`aff-classiques-departs__train-meta aff-classiques-departs__train-status ${statusClass}`} aria-live=&quot;polite&quot;&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__op-name&quot;&gt;{status.label}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__op-number&quot;&gt;{status.detail}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9;&#9;) : (&#10;&#9;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__train-meta&quot;&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__op-name&quot;&gt;{op}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__op-number&quot;&gt;{number}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9;&#9;)}&#10;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__col-time&quot;&gt;&#10;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__time&quot;&gt;{time}&lt;/div&gt;&#10;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__col-destination&quot;&gt;&#10;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__destination&quot;&gt;{destination}&lt;/div&gt;&#10;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9; {showPlatform ? (&#10;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__col-platform&quot;&gt;&#10;&#9;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__platform-box&quot;&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__platform-text&quot;&gt;{platform}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9;&#9;&lt;/div&gt;&#10;&#9;&#9;&#9; ) : null}&#10;&#9;&#9;&#9;&lt;/li&gt;&#10;&#9;&#9; );&#10;&#9;&#9;})}&#10;&#9; &lt;/ul&gt;&#10;&#9;&#9;&#9;&#9; {/* Footer visuel de l'afficheur — barre foncée en bas */}&#10;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__footer&quot; aria-hidden&gt;&#10;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__footer-inner&quot;&gt;&#10;&#9;&#9;&#9;&#9;&lt;Clock /&gt;&#10;&#10;&#9;&#9;&#9;&lt;/div&gt;&#10;&#9;&#9;&lt;/div&gt;&#10;&#10;&#9;&lt;/div&gt;&#10; )&#10;}&#10;&#10;&#10;" />
<option name="updatedContent" value="import React, { useEffect, useState, useCallback } from 'react';&#10;import './ClassiquesDeparts.css';&#10;import supabase from '../../../lib/supabase';&#10;&#10;// Helper utilitaire : format HHhMM sans secondes (ex: 06h44)&#10;const formatTimeNoSeconds = (v) =&gt; {&#10; if (v == null) return '';&#10; const tryDate = new Date(v);&#10; if (!isNaN(tryDate.getTime())) {&#10;&#9;const hh = String(tryDate.getHours()).padStart(2, '0');&#10;&#9;const mm = String(tryDate.getMinutes()).padStart(2, '0');&#10;&#9;return `${hh}h${mm}`;&#10; }&#10; if (typeof v === 'string') {&#10;&#9;const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);&#10;&#9;if (m) return `${String(m[1]).padStart(2,'0')}h${m[2]}`;&#10;&#9;const m2 = v.match(/T(\d{2}):(\d{2})/);&#10;&#9;if (m2) return `${m2[1]}h${m2[2]}`;&#10;&#9;return v.slice(0,5).replace(':','h');&#10; }&#10; return String(v).slice(0,5).replace(':','h');&#10;};&#10;&#10;// Données proviennent exclusivement de la table `horaires`.&#10;function Logo({op}){&#10; // Tente de charger un logo SVG depuis /type/ en fonction de l'opérateur.&#10; // mapping pour opérateurs connus -&gt; noms de fichiers dans public/type&#10; const mapping = {&#10;&#9;'TGV inOUI': 'logo-inoui.svg',&#10;&#9;'TGV inOUI ': 'logo-inoui.svg',&#10;&#9;'TGV': 'logo-tgv.svg',&#10;&#9;'TER': 'sncf-voyageurs-logo.png',&#10;&#9;'MOBIGO': 'logo-mobigo.svg',&#10;&#9;'TGV Lyria': 'logo-lyria.svg',&#10;&#9;'TGV Lyria ': 'logo-lyria.svg',&#10; };&#10;&#10; const defaultFileFromOp = (name) =&gt; {&#10;&#9;if (!name) return null;&#10;&#9;// nettoyer et fabriquer un nom plausible : &quot;logo-&lt;slug&gt;.svg&quot;&#10;&#9;const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');&#10;&#9;return `logo-${slug}.svg`;&#10; };&#10;&#10; const fileName = mapping[op] || mapping[op.split(' ')[0]] || defaultFileFromOp(op);&#10;&#10; const [hasImg, setHasImg] = React.useState(true);&#10;&#10; return (&#10;&#9;&lt;div className=&quot;aff-classiques-departs__logo-box&quot; aria-hidden&gt;&#10;&#9; {hasImg &amp;&amp; fileName ? (&#10;&#9;&#9;&lt;img&#10;&#9;&#9; src={`/type/${fileName}`}&#10;&#9;&#9; alt={op}&#10;&#9;&#9; className=&quot;aff-classiques-departs__op-logo&quot;&#10;&#9;&#9; onError={()=&gt; setHasImg(false)}&#10;&#9;&#9;/&gt;&#10;&#9; ) : (&#10;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__logo-text&quot; aria-hidden&gt;{op.split(' ')[0]}&lt;/div&gt;&#10;&#9; )}&#10;&#9;&lt;/div&gt;&#10; );&#10;}&#10;&#10;// (La fonction TrainType a été retirée car la colonne affiche désormais directement les&#10;// informations via .train-meta dans la colonne .col-train-type)&#10;&#10;function Clock(){&#10; const [now, setNow] = useState(new Date());&#10; useEffect(() =&gt; {&#10;&#9;const id = setInterval(() =&gt; setNow(new Date()), 1000);&#10;&#9;return () =&gt; clearInterval(id);&#10; }, []);&#10;&#10; const hh = String(now.getHours()).padStart(2, '0');&#10; const mm = String(now.getMinutes()).padStart(2, '0');&#10; const ss = String(now.getSeconds()).padStart(2, '0');&#10;&#10; return (&#10;&#9;&lt;div className=&quot;aff-classiques-departs__clock-container&quot; aria-hidden&gt;&#10;&#9; &lt;div className=&quot;aff-classiques-departs__clock-box&quot;&gt;&#10;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__clock-main&quot;&gt;{hh}:{mm}&lt;/div&gt;&#10;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__clock-seconds&quot;&gt;{ss}&lt;/div&gt;&#10;&#9; &lt;/div&gt;&#10;&#9;&lt;/div&gt;&#10; );&#10;}&#10;&#10;export default function ClassiquesDeparts(){&#10; // lire le paramètre de requête ?gare=Nom-de-la-gare&#10; const params = new URLSearchParams(window.location.search);&#10; const gareParam = params.get('gare');&#10; // support pour ?gare_id=123 ou ?gareId=123&#10; const gareIdParam = params.get('gare_id') || params.get('gareId');&#10; // gérer les +, décoder et remplacer les tirets par des espaces&#10; const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null;&#10;&#10; // debug flag activable via ?debug=1 or ?debug=true&#10; const debugEnabled = (params.get('debug') === '1' || String(params.get('debug')).toLowerCase() === 'true');&#10; const debug = useCallback((...args) =&gt; { if (debugEnabled) console.debug(...args); }, [debugEnabled]);&#10;&#10; // État pour alterner l'affichage toutes les 3 secondes&#10; const [showStatus, setShowStatus] = useState(false);&#10;&#10; // Données depuis Supabase&#10; const [horaires, setHoraires] = useState([]);&#10; const [allHoraires, setAllHoraires] = useState([]);&#10; const [perturbations, setPerturbations] = useState([]);&#10; // type de la gare affichée (valeurs attendues: 'interurbaine', 'ville', ...)&#10; const [stationType, setStationType] = useState(null);&#10; const [, setTravaux] = useState([]);&#10; const [, setLoading] = useState(false);&#10; const [, setError] = useState(null);&#10; // debug des données brutes renvoyées par Supabase&#10; const [rawDebug, setRawDebug] = useState({ count: 0, sample: [] });&#10;&#10; // --- Helpers pour perturbations (définis ici pour éviter use-before-define dans useEffect) ---&#10; const normalizeName = useCallback((s) =&gt; {&#10;&#9;if (!s) return '';&#10;&#9;try {&#10;&#9; const t = String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().trim();&#10;&#9; return t.replace(/[-_]+/g, ' ').replace(/\s+/g, ' ');&#10;&#9;} catch (e) {&#10;&#9; return String(s).toLowerCase().trim();&#10;&#9;}&#10; }, []);&#10;&#10; const safeParseArrets = useCallback((p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return [];&#10; if (Array.isArray(raw)) return raw;&#10; try {&#10;&#9;const parsed = JSON.parse(raw);&#10;&#9;// some payloads wrap { arrets: [...], jours_perturbation: [...] }&#10;&#9;if (parsed &amp;&amp; typeof parsed === 'object') {&#10;&#9;if (Array.isArray(parsed.arrets)) return parsed.arrets;&#10;&#9;// if parsed itself is an array-like object, return it&#10;&#9;return parsed;&#10;&#9;}&#10;&#9;return [];&#10; } catch (e) { return []; }&#10; }, []);&#10;&#10; // Retourne la liste d'arrêts d'un horaire, supporte plusieurs noms de champs (stops, gares_desservies...)&#10; const getStops = useCallback((h) =&gt; {&#10;&#9;if (!h) return [];&#10;&#9;if (Array.isArray(h.stops) &amp;&amp; h.stops.length) return h.stops;&#10;&#9;if (Array.isArray(h.gares_desservies) &amp;&amp; h.gares_desservies.length) return h.gares_desservies;&#10;&#9;if (Array.isArray(h.garesDesservies) &amp;&amp; h.garesDesservies.length) return h.garesDesservies;&#10;&#9;// parfois la colonne est stockée sous forme de chaîne JSON&#10;&#9;const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json'];&#10;&#9;for (const key of candidates) {&#10;&#9; const raw = h[key];&#10;&#9; if (!raw) continue;&#10;&#9; if (Array.isArray(raw)) return raw;&#10;&#9; if (typeof raw === 'string') {&#10;&#9;&#9;try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ }&#10;&#9; }&#10;&#9;}&#10;&#9;return [];&#10; }, []);&#10;&#10; // parse perturbations_arrets as object to extract both arrets and jours_perturbation&#10; const parsePerturbArretsObject = useCallback((p) =&gt; {&#10;&#9;const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10;&#9;if (!raw) return { arrets: [], jours: null };&#10;&#9;if (typeof raw === 'object') {&#10;&#9; if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null };&#10;&#9; if (Array.isArray(raw)) return { arrets: raw, jours: null };&#10;&#9; return { arrets: [], jours: null };&#10;&#9;}&#10;&#9;try {&#10;&#9; const parsed = JSON.parse(raw);&#10;&#9; if (parsed &amp;&amp; typeof parsed === 'object') {&#10;&#9;&#9;return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null };&#10;&#9; }&#10;&#9; return { arrets: [], jours: null };&#10;&#9;} catch (e) {&#10;&#9; return { arrets: [], jours: null };&#10;&#9;}&#10; }, []);&#10;&#10; // extrait un set de dates 'YYYY-MM-DD' depuis p.jours_perturbation (top-level) ou depuis perturbations_arrets.jours_perturbation&#10; const getPerturbationJours = useCallback((p) =&gt; {&#10;&#9;if (!p) return null;&#10;&#9;// check top-level field first&#10;&#9;const candidate = p.jours_perturbation || p.jours || null;&#10;&#9;const result = new Set();&#10;&#9;const pushDate = (d) =&gt; {&#10;&#9; if (!d) return;&#10;&#9; try {&#10;&#9;&#9;const dd = new Date(d);&#10;&#9;&#9;if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10));&#10;&#9; } catch (e) {}&#10;&#9;};&#10;&#9;if (candidate) {&#10;&#9; if (Array.isArray(candidate)) {&#10;&#9;&#9;candidate.forEach(item =&gt; pushDate(item.date || item));&#10;&#9; } else if (typeof candidate === 'string') {&#10;&#9;&#9;try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); }&#10;&#9;&#9;catch (e) { pushDate(candidate); }&#10;&#9; } else if (typeof candidate === 'object') {&#10;&#9;&#9;if (Array.isArray(candidate)) candidate.forEach(it =&gt; pushDate(it.date || it));&#10;&#9;&#9;else pushDate(candidate.date || null);&#10;&#9; }&#10;&#9;}&#10;&#9;// fallback: inspect perturbations_arrets object&#10;&#9;try {&#10;&#9; const parsedObj = parsePerturbArretsObject(p);&#10;&#9; const jp = parsedObj.jours;&#10;&#9; if (jp) {&#10;&#9;&#9;if (Array.isArray(jp)) jp.forEach(it =&gt; pushDate(it.date || it));&#10;&#9;&#9;else if (typeof jp === 'string') {&#10;&#9;&#9; try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); } catch (e) { pushDate(jp); }&#10;&#9;&#9;} else if (typeof jp === 'object') pushDate(jp.date || null);&#10;&#9; }&#10;&#9;} catch (e) {}&#10;&#10;&#9;return result.size ? result : null;&#10; }, [parsePerturbArretsObject]);&#10;&#10; const parseDelayMinutesFromArret = useCallback((arret) =&gt; {&#10;&#9;if (!arret) return null;&#10;&#9;const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min'];&#10;&#9;for (const k of keys) {&#10;&#9; if (typeof arret[k] !== 'undefined' &amp;&amp; arret[k] !== null &amp;&amp; arret[k] !== '') {&#10;&#9;&#9;const n = Number(arret[k]);&#10;&#9;&#9;if (!isNaN(n)) return Math.round(n);&#10;&#9; }&#10;&#9;}&#10;&#9;return null;&#10; }, []);&#10;&#10; // parse HH:mm or ISO into minutes since midnight (utilisable ailleurs dans le composant)&#10; const parseTimeHM = useCallback((s) =&gt; {&#10;&#9;if (!s) return null;&#10;&#9;if (typeof s === 'number') return s;&#10;&#9;const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10;&#9;if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10;&#9;const d = new Date(s);&#10;&#9;if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10;&#9;return null;&#10; }, []);&#10;&#10; // retourne le threshold (en minutes) pour afficher le quai selon le type de gare&#10; const thresholdForStationType = useCallback((type) =&gt; {&#10;&#9;if (!type) return 0;&#10;&#9;const t = String(type).toLowerCase();&#10;&#9;if (t.includes('interurb') || t.includes('interurbaine')) return 360; // 6 heures&#10;&#9;if (t.includes('ville')) return 30; // 30 minutes&#10;&#9;return 0;&#10; }, []);&#10;&#10; const formatTimeFromHM = useCallback((totalMinutes) =&gt; {&#10;&#9;if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return '';&#10;&#9;const m = ((totalMinutes % 1440) + 1440) % 1440;&#10;&#9;const hh = String(Math.floor(m / 60)).padStart(2, '0');&#10;&#9;const mm = String(m % 60).padStart(2, '0');&#10;&#9;return `${hh}h${mm}`;&#10; }, []);&#10;&#10; // buildPerturbMap now accepts an optional list of horaires to link horaire_id -&gt; numero_train&#10; const buildPerturbMap = useCallback((list = perturbations, horairesList = allHoraires) =&gt; {&#10;&#9;const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] };&#10;&#9;(list || []).forEach(p =&gt; {&#10;&#9; const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID;&#10;&#9; if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); }&#10;&#9; const number = p.number || p.train_number || p.train || p.numero || p.numero_train;&#10;&#9; if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); }&#10;&#9; const arrets = safeParseArrets(p);&#10;&#9; if (Array.isArray(arrets) &amp;&amp; arrets.length) {&#10;&#9;&#9;arrets.forEach(a =&gt; {&#10;&#9;&#9; const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code;&#10;&#9;&#9; const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10;&#9;&#9; if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); }&#10;&#9;&#9; if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); }&#10;&#9;&#9;});&#10;&#9; }&#10;&#9; if ((horaireId == null &amp;&amp; number == null &amp;&amp; (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); }&#10;&#9;});&#10;&#10;&#9;// If we have horairesList, propagate horaire_id perturbations to byNumber using the matching horaire's numero_train&#10;&#9;if (Array.isArray(horairesList) &amp;&amp; horairesList.length) {&#10;&#9; try {&#10;&#9;&#9;const byH = Object.keys(idx.byHoraire || {});&#10;&#9;&#9;if (byH &amp;&amp; byH.length) {&#10;&#9;&#9; for (const hid of byH) {&#10;&#9;&#9;&#9;const h = horairesList.find(x =&gt; String(x.id || x.horaire_id || x.horaireId || x.numero) === String(hid));&#10;&#9;&#9;&#9;if (h) {&#10;&#9;&#9;&#9; const num = h.numero_train || h.number || h.numero || null;&#10;&#9;&#9;&#9; if (num != null) {&#10;&#9;&#9;&#9;&#9;const numKey = String(num);&#10;&#9;&#9;&#9;&#9;idx.byNumber[numKey] = idx.byNumber[numKey] || [];&#10;&#9;&#9;&#9;&#9;// append all perturbations that reference this horaire id&#10;&#9;&#9;&#9;&#9;(idx.byHoraire[hid] || []).forEach(p =&gt; idx.byNumber[numKey].push(p));&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#9;&#9; }&#10;&#9;&#9;}&#10;&#9; } catch (e) {&#10;&#9;&#9;// ignore mapping errors&#10;&#9; }&#10;&#9;}&#10;&#10;&#9;return idx;&#10; }, [perturbations, allHoraires, safeParseArrets, normalizeName]);&#10;&#10;&#10; const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) =&gt; {&#10;&#9;const res = [];&#10;&#9;const map = idx || buildPerturbMap();&#10;&#9;&#9;&#9;&#9;try { debug('findPerturbsForHoraireAndStop', { horaireId: horaire &amp;&amp; (horaire.id || horaire.horaire_id), numero: horaire &amp;&amp; (horaire.numero_train || horaire.numero), stationId, stationName, counts: { byHoraire: Object.keys(map.byHoraire||{}).length, byNumber: Object.keys(map.byNumber||{}).length } }); } catch (e) {}&#10;&#9;const hId = horaire &amp;&amp; (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train);&#10;&#9;const number = horaire &amp;&amp; (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number);&#10;&#9;if (hId != null) {&#10;&#9; const kh = String(hId);&#10;&#9; if (map.byHoraire &amp;&amp; map.byHoraire[kh]) res.push(...map.byHoraire[kh]);&#10;&#9;}&#10;&#9;if (number != null) {&#10;&#9; const kn = String(number);&#10;&#9; if (map.byNumber &amp;&amp; map.byNumber[kn]) res.push(...map.byNumber[kn]);&#10;&#9;}&#10;&#9;if (stationId != null) {&#10;&#9; const ks = String(stationId);&#10;&#9; if (map.byStopId &amp;&amp; map.byStopId[ks]) map.byStopId[ks].forEach(x =&gt; res.push(x.perturbation));&#10;&#9;}&#10;&#9;if (stationName &amp;&amp; map.byStopName) {&#10;&#9; const key = normalizeName(stationName);&#10;&#9; if (map.byStopName[key]) map.byStopName[key].forEach(x =&gt; res.push(x.perturbation));&#10;&#10;&#9; // recherche permissive : accepter les clés qui contiennent ou sont contenues par le needle&#10;&#9; try {&#10;&#9;&#9;const needle = key;&#10;&#9;&#9;for (const existingKey of Object.keys(map.byStopName || {})) {&#10;&#9;&#9; if (existingKey === needle) continue;&#10;&#9;&#9; if (existingKey.includes(needle) || needle.includes(existingKey)) {&#10;&#9;&#9;&#9;map.byStopName[existingKey].forEach(x =&gt; res.push(x.perturbation));&#10;&#9;&#9; }&#10;&#9;&#9;}&#10;&#9; } catch (e) {&#10;&#9;&#9;// ignore&#10;&#9; }&#10;&#9;}&#10;&#9;return Array.from(new Set(res));&#10; }, [buildPerturbMap, normalizeName, debug]);&#10;&#10;&#10; // serviceDate (YYYY-MM-DD) optional: forces the evaluation date for perturbations when horaire._date is missing&#10; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) =&gt; {&#10;&#9;const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || [];&#10;&#9;&#9;&#9;&#9;try { debug('computeStatusForHoraireAtStop', { id: horaire &amp;&amp; horaire.id, numero: horaire &amp;&amp; (horaire.numero_train || horaire.numero), stationId, stationName, found: found.length }); } catch (e) {}&#10;&#10;&#9;const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10;&#9;let horaireYmd = toYMD(horaire &amp;&amp; (horaire._date || horaire.date || horaire.date_service || horaire.service_date));&#10;&#9;if (!horaireYmd &amp;&amp; serviceDate) horaireYmd = serviceDate;&#10;&#10;&#9;let maxDelay = null;&#10;&#10;&#9;for (const p of found) {&#10;&#9; // date filter: if perturbation carries explicit dates or jours_perturbation, check they match horaireYmd&#10;&#9; try {&#10;&#9;&#9;if (horaireYmd) {&#10;&#9;&#9; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10;&#9;&#9; const db = toYMD(p.date_debut || p.start_date);&#10;&#9;&#9; const df = toYMD(p.date_fin || p.end_date);&#10;&#9;&#9; if (pDate &amp;&amp; pDate !== horaireYmd) { debug('perturbation date mismatch', p.id); continue; }&#10;&#9;&#9; if (db &amp;&amp; df &amp;&amp; !(horaireYmd &gt;= db &amp;&amp; horaireYmd &lt;= df)) { debug('perturbation date-range mismatch', p.id); continue; }&#10;&#9;&#9;}&#10;&#9; } catch (e) { /* ignore date parse errors -&gt; assume applies */ }&#10;&#10;&#9; // also check jours_perturbation (may be inside perturbations_arrets wrapper)&#10;&#9; try {&#10;&#9;&#9;const joursSet = getPerturbationJours(p);&#10;&#9;&#9;if (joursSet &amp;&amp; horaireYmd) {&#10;&#9;&#9; if (!joursSet.has(horaireYmd)) { debug('perturbation jours mismatch', p.id); continue; }&#10;&#9;&#9;}&#10;&#9; } catch (e) {}&#10;&#10;&#9; const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };&#10;&#9; const arrets = parsedObj.arrets || [];&#10;&#9; let matched = false;&#10;&#9;&#9;&#9;for (const a of arrets) {&#10;&#9;&#9;&#9;&#9;const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code;&#10;&#9;&#9;&#9;&#9;const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10;&#9;&#9;&#9;&#9;const na = normalizeName(aName || '');&#10;&#9;&#9;&#9;&#9;const ns = normalizeName(stationName || '');&#10;&#9;&#9;&#9;&#9;const nameMatches = stationName &amp;&amp; (na === ns || na.includes(ns) || ns.includes(na));&#10;&#9;&#9;&#9;&#9;if ((stationId != null &amp;&amp; String(aId) === String(stationId)) || nameMatches) {&#10;&#9;&#9; matched = true;&#10;&#9;&#9; const t = (a.type_perturbation || a.type || '').toString().toLowerCase();&#10;&#9;&#9; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10;&#9;&#9; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10;&#9;&#9;&#9;const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;&#10;&#9;&#9;&#9;if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10;&#9;&#9; }&#10;&#9;&#9;}&#10;&#9; }&#10;&#10;&#9; if (!matched) {&#10;&#9;&#9;// top-level perturbation that references the horaire/numero&#10;&#9;&#9;const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero;&#10;&#9;&#9;const numberRef = p.number || p.train_number || p.numero || p.numero_train;&#10;&#9;&#9;const appliesByRef = ref &amp;&amp; (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero));&#10;&#9;&#9;const appliesByNumber = numberRef &amp;&amp; (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number));&#10;&#9;&#9;const isGlobal = (!Array.isArray(arrets) || arrets.length === 0) &amp;&amp; !ref &amp;&amp; !numberRef;&#10;&#9;&#9;if (appliesByRef || appliesByNumber || isGlobal) {&#10;&#9;&#9; const t = (p.type_perturbation || p.type || '').toString().toLowerCase();&#10;&#9;&#9; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10;&#9;&#9; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10;&#9;&#9;&#9;const d = parseDelayMinutesFromArret(p) || 0;&#10;&#9;&#9;&#9;if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10;&#9;&#9; }&#10;&#9;&#9;}&#10;&#9; }&#10;&#9;}&#10;&#10;&#9;if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay };&#10;&#9;return { label: &quot;à l'heure&quot;, detail: '', delayMinutes: null };&#10; }, [findPerturbsForHoraireAndStop, parsePerturbArretsObject, getPerturbationJours, parseDelayMinutesFromArret, normalizeName, debug]);&#10;&#10; // Applique les perturbations à une liste d'horaires : calcule status par arrêt et met à jour l'heure affichée&#10; const applyPerturbationsToHoraires = useCallback((list = [], perturbList = perturbations, displayStationId = null, displayStationName = null, serviceDate = null) =&gt; {&#10;&#9;const map = buildPerturbMap(perturbList || []);&#10; // fallback to global display station context&#10; const targetStationId = displayStationId || gareIdParam || null;&#10; const targetStationName = displayStationName || gareDisplay || null;&#10;&#10;&#9;&#9; return (list || []).map(h =&gt; {&#10;&#9;&#9; const horaire = { ...h };&#10;&#9;&#9;&#9;&#9;&#9; const stops = getStops(horaire);&#10;&#9; const stopsStatus = [];&#10;&#9; for (let i = 0; i &lt; stops.length; i++) {&#10;&#9;&#9;const s = stops[i] || {};&#10;&#9;&#9;const stationId = s.gare_id || s.gareId || s.id || s.station_id || s.stationId || s.uic || s.code;&#10;&#9;&#9;const stationName = s.gare_nom || s.nom || s.name || s.station_name || s.station_nom;&#10;&#9;&#9;&#9;&#9;&#9;&#9;const st = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, serviceDate);&#10;&#9;&#9;stopsStatus.push({ stationId, stationName, status: st });&#10;&#9; }&#10;&#10; // compute display time using delay at the display station if any&#10;&#9; const displayStatus = computeStatusForHoraireAtStop(horaire, targetStationId, targetStationName, map, serviceDate);&#10;&#9; let displayTime = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || '');&#10; if (displayStatus &amp;&amp; typeof displayStatus.delayMinutes === 'number' &amp;&amp; !isNaN(displayStatus.delayMinutes)) {&#10; if (typeof horaire._depHM === 'number') {&#10; displayTime = formatTimeFromHM(horaire._depHM + displayStatus.delayMinutes);&#10; } else {&#10; const tryParse = (s) =&gt; {&#10; if (!s) return null;&#10; const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10; if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10; const d = new Date(s);&#10; if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10; return null;&#10; };&#10; const base = tryParse(horaire._rawTime || horaire.heure_depart || horaire.time || horaire.heure || '');&#10; if (typeof base === 'number') displayTime = formatTimeFromHM(base + displayStatus.delayMinutes);&#10; }&#10; }&#10;&#10;&#9; horaire._stopsStatus = stopsStatus;&#10;&#9; // prefer platform from stop data (quai) if available&#10;&#9; const stopForStation = (stops || []).find(s =&gt; {&#10;&#9;&#9;const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;&#10;&#9;&#9;const nm = s.gare_nom || s.nom || s.name || s.stop_name || '';&#10;&#9;&#9;const ns = normalizeName(nm || '');&#10;&#9;&#9;const target = normalizeName(targetStationName || '');&#10;&#9;&#9;return (targetStationId &amp;&amp; String(sid) === String(targetStationId)) || (targetStationName &amp;&amp; (ns === target || ns.includes(target) || target.includes(ns)));&#10;&#9; }) || null;&#10;&#9; horaire._status = displayStatus;&#10;&#9; horaire._displayTime = displayTime;&#10;&#9; horaire._displayPlatform = (stopForStation &amp;&amp; (stopForStation.quai || stopForStation.platform || stopForStation.quay)) || horaire.platform || '';&#10;&#9; return horaire;&#10;&#9;});&#10; }, [buildPerturbMap, computeStatusForHoraireAtStop, formatTimeFromHM, perturbations, gareDisplay, gareIdParam, getStops, normalizeName]);&#10;&#10; useEffect(() =&gt; {&#10;&#9;const id = setInterval(() =&gt; setShowStatus(s =&gt; !s), 3000);&#10;&#9;return () =&gt; clearInterval(id);&#10; }, []);&#10;&#10; // Récupération Supabase — maintenant avec polling pour synchroniser sans recharger la page&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; let inFlight = false; // empêche les fetchs concurrents&#10;&#10; const fetchData = async () =&gt; {&#10;&#9; if (!mounted) return;&#10;&#9; if (inFlight) return; // éviter les appels simultanés si le fetch précédent n'est pas terminé&#10;&#9; inFlight = true;&#10;&#9; if (!supabase) {&#10;&#9;&#9;// Supabase non initialisé — avertir et définir un message d'erreur pour l'UI&#10;&#9;&#9;// eslint-disable-next-line no-console&#10;&#9;&#9;console.warn('Supabase non initialisé — vérifiez REACT_APP_SUPABASE_URL et REACT_APP_SUPABASE_ANON_KEY');&#10;&#9;&#9;setError('Supabase non initialisé — vérifiez les variables d\'environnement');&#10;&#9;&#9;setLoading(false);&#10;&#9;&#9;inFlight = false;&#10;&#9;&#9;return;&#10;&#9; }&#10;&#9; setLoading(true);&#10;&#9; setError(null);&#10;&#9; try {&#10;&#9;&#9;const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([&#10;&#9;&#9; // DEBUG: récupérer explicitement l'id et toutes les colonnes pour s'assurer que l'identifiant&#10;&#9;&#9; // `id` est bien présent et peut être mis en correspondance avec `horaire_id` dans perturbations&#10;&#9;&#9; supabase.from('horaires').select('id, *'),&#10;&#9;&#9; supabase.from('perturbations').select('*'),&#10;&#9;&#9; supabase.from('travaux').select('*')&#10;&#9;&#9;]);&#10;&#10;&#9;&#9;if (!mounted) { inFlight = false; return; }&#10;&#10;&#9;&#9;if (hError || pError || tError) {&#10;&#9;&#9; if (mounted) setError((hError || pError || tError).message || String(hError || pError || tError));&#10;&#9;&#9; setLoading(false);&#10;&#9;&#9; inFlight = false;&#10;&#9;&#9; return;&#10;&#9;&#9;}&#10;&#10;&#9;&#9;// debug: afficher ce que Supabase retourne&#10;&#9;&#9;// eslint-disable-next-line no-console&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;debug('Supabase horaires fetched', { count: (horairesData || []).length, sample: (horairesData || []).slice(0,5) });&#10;&#9;&#9;if (mounted) setRawDebug({ count: (horairesData || []).length, sample: (horairesData || []).slice(0,3) });&#10;&#10;&#9;&#9;// conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare)&#10;&#9;&#9;let parsedHoraires = (horairesData || []).map(h =&gt; h || {});&#10;&#10;&#9;&#9;// Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD;&#10;&#9;&#9;// essayer de parser pour garantir que stops est un tableau utilisable.&#10;&#9;&#9;parsedHoraires = parsedHoraires.map(h =&gt; {&#10;&#9;&#9; try {&#10;&#9;&#9;&#9;if (h &amp;&amp; typeof h.stops === 'string') {&#10;&#9;&#9;&#9; const parsed = JSON.parse(h.stops);&#10;&#9;&#9;&#9; if (Array.isArray(parsed)) {&#10;&#9;&#9;&#9;&#9;return { ...h, stops: parsed };&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#9;&#9; } catch (e) {&#10;&#9;&#9;&#9;// ignore parse error&#10;&#9;&#9; }&#10;&#9;&#9; return h;&#10;&#9;&#9;});&#10;&#10;&#9;&#9;// debug: afficher après normalisation&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;// eslint-disable-next-line no-console&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;debug('Supabase horaires normalized', { count: parsedHoraires.length, sampleStopsExample: (parsedHoraires[0] &amp;&amp; parsedHoraires[0].stops) || null });&#10;&#10;&#9;&#9;// stocker la liste complète (non filtrée) pour associer horaire_id -&gt; numero_train&#10; if (mounted) setAllHoraires(parsedHoraires || []);&#10;&#10; // Récupérer le type de gare depuis la table `gares` si possible&#10; try {&#10; let fetchedType = null;&#10; if (gareIdParam) {&#10; const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1);&#10; if (!gerr &amp;&amp; gdata &amp;&amp; gdata.length) fetchedType = gdata[0] &amp;&amp; gdata[0].type;&#10; } else if (gareDisplay) {&#10; const needle = `%${gareDisplay}%`;&#10; try {&#10; const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').or(`nom.ilike.${needle},name.ilike.${needle}`).limit(1);&#10; if (!gerr &amp;&amp; gdata &amp;&amp; gdata.length) fetchedType = gdata[0] &amp;&amp; gdata[0].type;&#10; } catch (e) {&#10; try {&#10; const { data: gdata } = await supabase.from('gares').select('id,type').ilike('nom', needle).limit(1);&#10; if (gdata &amp;&amp; gdata.length) fetchedType = gdata[0] &amp;&amp; gdata[0].type;&#10; } catch (err) { /* ignore */ }&#10; }&#10; }&#10; if (mounted) setStationType(fetchedType ? String(fetchedType).toLowerCase() : null);&#10; } catch (e) { /* ignore errors reading gares table */ }&#10;&#9;&#9;// Filtrer par gare : garder uniquement les horaires qui DESSERVENT la gare ciblée&#10;&#9;&#9;// (soit comme gare_depart, soit comme un arrêt intermédiaire dans `stops` / `gares_desservies`)&#10;&#9;&#9;// Exclure les horaires pour lesquels la gare ciblée est la gare d'arrivée finale (terminus)&#10;&#9;&#9;let displayHoraires = parsedHoraires.slice();&#10;&#9;&#9;if (gareDisplay) {&#10;&#9;&#9; const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' '));&#10;&#9;&#9; displayHoraires = displayHoraires.filter(h =&gt; {&#10;&#9;&#9;&#9;// Exclure si la gare ciblée est la gare d'arrivée finale&#10;&#9;&#9;&#9;const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString());&#10;&#9;&#9;&#9;if (destName &amp;&amp; (destName === normalized || destName.includes(normalized) || normalized.includes(destName))) return false;&#10;&#10;&#9;&#9;&#9;// Inclure si gare de départ correspond&#10;&#9;&#9;&#9;const departName = normalizeName((h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString());&#10;&#9;&#9;&#9;if (departName &amp;&amp; (departName === normalized || departName.includes(normalized) || normalized.includes(departName))) return true;&#10;&#10;&#9;&#9;&#9;// Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max)&#10;&#9;&#9;&#9;const stopsList = getStops(h);&#10;&#9;&#9;&#9;if (Array.isArray(stopsList) &amp;&amp; stopsList.length) {&#10;&#9;&#9;&#9; let maxIdx = null;&#10;&#9;&#9;&#9; for (const st of stopsList) {&#10;&#9;&#9;&#9;&#9;if (typeof st.index !== 'undefined' &amp;&amp; st.index !== null) {&#10;&#9;&#9;&#9;&#9; const n = Number(st.index);&#10;&#9;&#9;&#9;&#9; if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);&#10;&#9;&#9;&#9;&#9;}&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9; for (const s of stopsList) {&#10;&#9;&#9;&#9;&#9;const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString());&#10;&#9;&#9;&#9;&#9;if (!stopName) continue;&#10;&#9;&#9;&#9;&#9;const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' &amp;&amp; maxIdx != null &amp;&amp; Number(s.index) === maxIdx));&#10;&#9;&#9;&#9;&#9;if (isTerminus) continue;&#10;&#9;&#9;&#9;&#9;if (stopName === normalized || stopName.includes(normalized) || normalized.includes(stopName)) return true;&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#9;&#9;&#9;return false;&#10;&#9;&#9; });&#10;&#9;&#9;} else if (gareIdParam) {&#10;&#9;&#9; const gid = String(gareIdParam);&#10;&#9;&#9; displayHoraires = displayHoraires.filter(h =&gt; {&#10;&#9;&#9;&#9;// Exclure si gare d'arrivée finale correspond&#10;&#9;&#9;&#9;const destId = (h.gare_arrivee_id || h.gare_arrivee || null);&#10;&#9;&#9;&#9;if (destId != null &amp;&amp; String(destId) === gid) return false;&#10;&#10;&#9;&#9;&#9;// Inclure si gare de départ correspond&#10;&#9;&#9;&#9;const departId = h.gare_depart_id || h.gare_id || h.origin_id || null;&#10;&#9;&#9;&#9;if (departId != null &amp;&amp; String(departId) === gid) return true;&#10;&#10;&#9;&#9;&#9;// Chercher dans les arrêts desservis (ignorer terminus)&#10;&#9;&#9;&#9;const stopsList = getStops(h);&#10;&#9;&#9;&#9;if (Array.isArray(stopsList) &amp;&amp; stopsList.length) {&#10;&#9;&#9;&#9; let maxIdx = null;&#10;&#9;&#9;&#9; for (const st of stopsList) {&#10;&#9;&#9;&#9;&#9;if (typeof st.index !== 'undefined' &amp;&amp; st.index !== null) {&#10;&#9;&#9;&#9;&#9; const n = Number(st.index);&#10;&#9;&#9;&#9;&#9; if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);&#10;&#9;&#9;&#9;&#9;}&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9; for (const s of stopsList) {&#10;&#9;&#9;&#9;&#9;const stopId = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;&#10;&#9;&#9;&#9;&#9;if (stopId == null) continue;&#10;&#9;&#9;&#9;&#9;const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' &amp;&amp; maxIdx != null &amp;&amp; Number(s.index) === maxIdx));&#10;&#9;&#9;&#9;&#9;if (isTerminus) continue;&#10;&#9;&#9;&#9;&#9;if (String(stopId) === gid) return true;&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#9;&#9;&#9;return false;&#10;&#9;&#9; });&#10;&#9;&#9;}&#10;&#10;&#9;&#9;// Normaliser date de service (si présente)&#10;&#9;&#9;const dateKeys = ['date_depart','date','jour','service_date','date_service','date_dep'];&#10;&#9;&#9;const normalizeDate = (o) =&gt; {&#10;&#9;&#9; for (const k of dateKeys) {&#10;&#9;&#9;&#9;if (!o) continue;&#10;&#9;&#9;&#9;const v = o[k];&#10;&#9;&#9;&#9;if (!v) continue;&#10;&#9;&#9;&#9;const d = new Date(v);&#10;&#9;&#9;&#9;if (!isNaN(d.getTime())) return d.toISOString().slice(0,10);&#10;&#9;&#9; }&#10;&#9;&#9; return null;&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const withDates = displayHoraires.map(h =&gt; ({ ...h, _date: normalizeDate(h) }));&#10;&#10;&#9;&#9;&#9;&#9;&#9;// parse HH:mm or HH:MM:SS into minutes since midnight&#10;&#9;&#9;&#9;&#9;&#9;const parseTimeHM = (s) =&gt; {&#10;&#9;&#9;&#9;&#9;&#9;&#9;if (!s) return null;&#10;&#9;&#9;&#9;&#9;&#9;&#9;if (typeof s === 'number') return s;&#10;&#9;&#9;&#9;&#9;&#9;&#9;const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10;&#9;&#9;&#9;&#9;&#9;&#9;if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10;&#9;&#9;&#9;&#9;&#9;&#9;const d = new Date(s);&#10;&#9;&#9;&#9;&#9;&#9;&#9;if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10;&#9;&#9;&#9;&#9;&#9;&#9;return null;&#10;&#9;&#9;&#9;&#9;&#9;};&#10;&#10;&#9;&#9;&#9;&#9;&#9;// enrichir withDates avec heures en minutes pour aujourd'hui et demain&#10;&#9;&#9;&#9;&#9;&#9;const now = new Date();&#10;&#9;&#9;&#9;&#9;&#9;const todayHM = now.getHours()*60 + now.getMinutes();&#10;&#9;&#9;&#9;&#9;&#9;const enrich = withDates.map(h =&gt; {&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;let raw = h.heure_depart || h.time || h.heure || '';&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;// si stops contient la gare ciblée, préférer son heure&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;const stopsListForH = getStops(h);&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;if ((gareIdParam || gareDisplay) &amp;&amp; Array.isArray(stopsListForH)) {&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9; const byId = gareIdParam ? stopsListForH.find(s =&gt; String(s.gare_id || s.gareId || s.id) === String(gareIdParam)) : null;&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9; const byName = gareDisplay ? stopsListForH.find(s =&gt; normalizeName((s.gare_nom || s.nom || s.name || '').toString()) === normalizeName(gareDisplay)) : null;&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9; const stop = byId || byName;&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9; if (stop &amp;&amp; (stop.heure_depart || stop.time || stop.heure)) raw = stop.heure_depart || stop.time || stop.heure;&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;}&#10;&#9;&#9;&#9;&#9;&#9;&#9;const depHM = parseTimeHM(raw);&#10;&#9;&#9;&#9;&#9;&#9;&#9;const displayTime = formatTimeNoSeconds(raw);&#10;&#9;&#9;&#9;&#9;&#9;&#9;return { ...h, _rawTime: raw, _depHM: depHM, _displayTime: displayTime };&#10;&#9;&#9;&#9;&#9;&#9;});&#10;&#10;&#10;&#9;&#9;// fonctions utilitaires de jours&#10;&#9;&#9;const pad = (n) =&gt; String(n).padStart(2,'0');&#10;&#9;&#9;const localDateStr = (d) =&gt; `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;&#10;&#9;&#9;const today = new Date();&#10;&#9;&#9;const todayStr = localDateStr(today);&#10;&#9;&#9;const tomorrow = new Date(today);&#10;&#9;&#9;tomorrow.setDate(today.getDate()+1);&#10;&#9;&#9;const tomorrowStr = localDateStr(tomorrow);&#10;&#10;&#9;&#9;const weekdayIndex = (d) =&gt; d.getDay(); // 0 dimanche .. 6 samedi&#10;&#9;&#9;const weekdayNameForColumn = (d) =&gt; {&#10;&#9;&#9; const i = weekdayIndex(d);&#10;&#9;&#9; switch(i) {&#10;&#9;&#9;&#9;case 1: return 'circule_lundi';&#10;&#9;&#9;&#9;case 2: return 'circule_mardi';&#10;&#9;&#9;&#9;case 3: return 'circule_mercredi';&#10;&#9;&#9;&#9;case 4: return 'circule_jeudi';&#10;&#9;&#9;&#9;case 5: return 'circule_vendredi';&#10;&#9;&#9;&#9;case 6: return 'circule_samedi';&#10;&#9;&#9;&#9;case 0: return 'circule_dimanche';&#10;&#9;&#9;&#9;default: return null;&#10;&#9;&#9; }&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const circulatesOn = (h, d) =&gt; {&#10;&#9;&#9; const col = weekdayNameForColumn(d);&#10;&#9;&#9; if (!col) return true;&#10;&#9;&#9; const v = h[col];&#10;&#9;&#9; if (typeof v === 'boolean') return v;&#10;&#9;&#9; if (typeof v === 'number') return v === 1;&#10;&#9;&#9; if (typeof v === 'string') {&#10;&#9;&#9;&#9;const s = v.trim().toLowerCase();&#10;&#9;&#9;&#9;return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes';&#10;&#9;&#9; }&#10;&#9;&#9; // si la colonne est undefined ou null, on considère que ça circule (fallback permissif)&#10;&#9;&#9; return (typeof v === 'undefined' || v === null) ? true : Boolean(v);&#10;&#9;&#9;};&#10;&#10;&#9;&#9;// Construire listes pour aujourd'hui et demain en tenant compte des colonnes circule_*&#10;&#9;&#9;// Calculer les travaux applicables et les substitutions&#10;&#9;&#9;const travauxList = travauxData || [];&#10;&#10;&#9;&#9;const parseDateOnly = (v) =&gt; {&#10;&#9;&#9; if (!v) return null;&#10;&#9;&#9; const d = new Date(v);&#10;&#9;&#9; if (isNaN(d.getTime())) return null;&#10;&#9;&#9; return new Date(d.getFullYear(), d.getMonth(), d.getDate());&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const parseJoursTravaux = (val) =&gt; {&#10;&#9;&#9; if (val == null) return null;&#10;&#9;&#9; if (Array.isArray(val)) return new Set(val.map(x =&gt; String(x).toLowerCase()));&#10;&#9;&#9; const s = String(val || '');&#10;&#9;&#9; const parts = s.split(/[,;\s]+/).map(p =&gt; p.trim()).filter(Boolean);&#10;&#9;&#9; return parts.length ? new Set(parts.map(p =&gt; p.toLowerCase())) : null;&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const weekdayShort = (i) =&gt; {&#10;&#9;&#9; switch(i) { case 0: return 'dim'; case 1: return 'lun'; case 2: return 'mar'; case 3: return 'mer'; case 4: return 'jeu'; case 5: return 'ven'; case 6: return 'sam'; default: return null; }&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const travauxApplies = (t, h, date) =&gt; {&#10;&#9;&#9; if (!t) return false;&#10;&#9;&#9; const db = parseDateOnly(t.date_debut || t.dateDebut || t.start_date);&#10;&#9;&#9; const df = parseDateOnly(t.date_fin || t.dateFin || t.end_date);&#10;&#9;&#9; if (!db || !df) return false;&#10;&#9;&#9; const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());&#10;&#9;&#9; if (d &lt; db || d &gt; df) return false;&#10;&#9;&#9; const joursSet = parseJoursTravaux(t.jours_travaux || t.jours || t.days);&#10;&#9;&#9; if (joursSet) {&#10;&#9;&#9;&#9;const name = weekdayShort(d.getDay());&#10;&#9;&#9;&#9;if (!name) return false;&#10;&#9;&#9;&#9;let ok = false;&#10;&#9;&#9;&#9;for (const v of joursSet) { if (v.includes(name) || name.includes(v)) { ok = true; break; } }&#10;&#9;&#9;&#9;if (!ok) return false;&#10;&#9;&#9; }&#10;&#9;&#9; const startHM = parseTimeHM(t.heure_debut || t.heureDebut || t.start_time);&#10;&#9;&#9; const endHM = parseTimeHM(t.heure_fin || t.heureFin || t.end_time);&#10;&#9;&#9; if (startHM == null || endHM == null) return true;&#10;&#9;&#9; if (typeof h._depHM === 'number') return h._depHM &gt;= startHM &amp;&amp; h._depHM &lt;= endHM;&#10;&#9;&#9; return false;&#10;&#9;&#9;};&#10;&#10;&#9;&#9;const isSubstitution = (h) =&gt; {&#10;&#9;&#9; if (!h) return false;&#10;&#9;&#9; // nouvelle colonne spécifique&#10;&#9;&#9; if (h.substitution_disponible === true) return true;&#10;&#9;&#9; // anciennes variantes&#10;&#9;&#9; const subFlag = h.substitution === true || h.is_substitution === true || h.isSubstitution === true;&#10;&#9;&#9; const subStr = (typeof h.substitution === 'string' &amp;&amp; ['true','1','yes','y','t'].includes(h.substitution.toLowerCase()));&#10;&#9;&#9; const typeMatch = h.type_train &amp;&amp; /subst|substitut|remplac/i.test(String(h.type_train));&#10;&#9;&#9; const commentMatch = h.comment &amp;&amp; /subst|substitut|remplac/i.test(String(h.comment));&#10;&#9;&#9; return Boolean(subFlag || subStr || typeMatch || commentMatch);&#10;&#9;&#9;};&#10;&#10;&#9;&#9;// compute linkage and travaux applicability per horaire&#10;&#9;&#9;const enriched = enrich.map(h =&gt; {&#10;&#9;&#9; let linked = false;&#10;&#9;&#9; let appliesToday = false;&#10;&#9;&#9; let appliesTomorrow = false;&#10;&#9;&#9; for (const t of travauxList) {&#10;&#9;&#9;&#9;// Extraire identifiants de ligne (différentes variantes possibles)&#10;&#9;&#9;&#9;const tLine = t.ligne_id || t.ligne || t.line_id || t.line || t.ligneId || t.lineId;&#10;&#9;&#9;&#9;const hLine = h.ligne_id || h.ligne || h.line_id || h.line || h.ligneId || h.lineId;&#10;&#10;&#9;&#9;&#9;// Si le travail a une ligne attribuée, n'appliquer le travail QUE si la ligne correspond&#10;&#9;&#9;&#9;// (si hLine est absent on considère qu'on ne peut pas appliquer ce travail à cet horaire)&#10;&#9;&#9;&#9;if (typeof tLine !== 'undefined' &amp;&amp; tLine !== null) {&#10;&#9;&#9;&#9; if (typeof hLine === 'undefined' || hLine === null) {&#10;&#9;&#9;&#9;&#9;// pas de ligne sur l'horaire -&gt; ignorer ce travail pour cet horaire&#10;&#9;&#9;&#9;&#9;continue;&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9; if (String(tLine) !== String(hLine)) {&#10;&#9;&#9;&#9;&#9;// lignes différentes -&gt; ignorer ce travail pour cet horaire&#10;&#9;&#9;&#9;&#9;continue;&#10;&#9;&#9;&#9; }&#10;&#9;&#9;&#9;}&#10;&#10;&#9;&#9;&#9;const ref = t.horaire_id || t.horaire || t.horaireId || t.numero_train || t.train_number || t.numero;&#10;&#9;&#9;&#9;if (ref &amp;&amp; (String(ref) === String(h.id) || String(ref) === String(h.numero_train) || String(ref) === String(h.numero))) linked = true;&#10;&#9;&#9;&#9;if (!appliesToday &amp;&amp; travauxApplies(t,h,today)) appliesToday = true;&#10;&#9;&#9;&#9;if (!appliesTomorrow &amp;&amp; travauxApplies(t,h,tomorrow)) appliesTomorrow = true;&#10;&#9;&#9;&#9;if (linked &amp;&amp; appliesToday &amp;&amp; appliesTomorrow) break;&#10;&#9;&#9; }&#10;&#9;&#9; return { ...h, _linkedToTravaux: linked, _travauxAppliesToday: appliesToday, _travauxAppliesTomorrow: appliesTomorrow };&#10;&#9;&#9;});&#10;&#10;&#9;&#9;// Build today and tomorrow candidate lists&#10;&#9;&#9;const todayCandidates = enriched.filter(h =&gt; {&#10;&#9;&#9; if (h._date &amp;&amp; h._date !== todayStr) return false;&#10;&#9;&#9; if (!circulatesOn(h, today)) return false;&#10;&#9;&#9; if (typeof h._depHM !== 'number') return false;&#10;&#9;&#9; if (h._depHM &lt;= todayHM) return false;&#10;&#10;&#9;&#9; // if travaux apply at this horaire today, hide regular horaires&#10;&#9;&#9; if (h._travauxAppliesToday &amp;&amp; !isSubstitution(h)) return false;&#10;&#10;&#9;&#9; if (isSubstitution(h)) {&#10;&#9;&#9;&#9;// show substitution only if substitution_disponible AND linked to travaux that apply today&#10;&#9;&#9;&#9;return Boolean(h.substitution_disponible === true &amp;&amp; h._linkedToTravaux &amp;&amp; h._travauxAppliesToday);&#10;&#9;&#9; }&#10;&#10;&#9;&#9; return true;&#10;&#9;&#9;});&#10;&#10;&#9;&#9;const tomorrowCandidates = enriched.filter(h =&gt; {&#10;&#9;&#9; if (h._date &amp;&amp; h._date !== tomorrowStr) return false;&#10;&#9;&#9; if (!circulatesOn(h, tomorrow)) return false;&#10;&#10;&#9;&#9; // hide regular horaires if travaux apply tomorrow during their time window&#10;&#9;&#9; if (h._travauxAppliesTomorrow &amp;&amp; !isSubstitution(h)) return false;&#10;&#10;&#9;&#9; if (isSubstitution(h)) {&#10;&#9;&#9;&#9;// show substitution only if substitution_disponible AND linked to travaux that apply tomorrow&#10;&#9;&#9;&#9;return Boolean(h.substitution_disponible === true &amp;&amp; h._linkedToTravaux &amp;&amp; h._travauxAppliesTomorrow);&#10;&#9;&#9; }&#10;&#10;&#9;&#9; return true;&#10;&#9;&#9;});&#10;&#10;&#9;&#9;// final list: if there are any todayCandidates use them; otherwise use tomorrowCandidates&#10;&#9;&#9;let finalList;&#10;&#9;&#9;let serviceDateStr;&#10;&#9;&#9;if (todayCandidates &amp;&amp; todayCandidates.length &gt; 0) {&#10;&#9;&#9; finalList = todayCandidates.slice().sort((a,b)=&gt; (a._depHM||99999)-(b._depHM||99999));&#10;&#9;&#9; serviceDateStr = todayStr;&#10;&#9;&#9;} else {&#10;&#9;&#9; finalList = (tomorrowCandidates || []).slice().sort((a,b)=&gt; (a._depHM||99999)-(b._depHM||99999));&#10;&#9;&#9; serviceDateStr = tomorrowStr;&#10;&#9;&#9;}&#10;&#10;&#9;&#9;// Construire un index local des perturbations à partir des données fraîchement récupérées&#10;&#9;&#9;// Filtrer pour ne garder que les perturbations applicables aujourd'hui (éviter d'appliquer les perturbations d'hier)&#10;&#9;&#9;const todayYmd = new Date().toLocaleDateString('en-CA');&#10;&#9;&#9;const filteredPerturb = (perturbData || []).filter(p =&gt; {&#10;&#9;&#9; try {&#10;&#9;&#9;&#9;const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10;&#9;&#9;&#9;const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10;&#9;&#9;&#9;const db = toYMD(p.date_debut || p.start_date);&#10;&#9;&#9;&#9;const df = toYMD(p.date_fin || p.end_date);&#10;&#9;&#9;&#9;// si pDate fourni et ne correspond pas à aujourd'hui -&gt; ignorer&#10;&#9;&#9;&#9;if (pDate &amp;&amp; pDate !== todayYmd) return false;&#10;&#9;&#9;&#9;// si fourche de dates fournie et aujourd'hui n'est pas dans l'intervalle -&gt; ignorer&#10;&#9;&#9;&#9;if (db &amp;&amp; df &amp;&amp; !(todayYmd &gt;= db &amp;&amp; todayYmd &lt;= df)) return false;&#10;&#9;&#9;&#9;// si jours_perturbation fourni -&gt; vérifier qu'aujourd'hui en fait partie&#10;&#9;&#9;&#9;try {&#10;&#9;&#9;&#9; const joursSet = getPerturbationJours(p);&#10;&#9;&#9;&#9; if (joursSet &amp;&amp; !joursSet.has(todayYmd)) return false;&#10;&#9;&#9;&#9;} catch (e) {&#10;&#9;&#9;&#9; // ignore parse errors and keep perturbation&#10;&#9;&#9;&#9;}&#10;&#9;&#9;&#9;return true;&#10;&#9;&#9; } catch (e) {&#10;&#9;&#9;&#9;return true;&#10;&#9;&#9; }&#10;&#9;&#9;});&#10;&#10;&#9;&#9;const localPerturbMap = buildPerturbMap(filteredPerturb || [], parsedHoraires || []);&#10;&#9;&#9;// Debug: afficher sommaire de l'index des perturbations&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;debug('localPerturbMap summary', {&#10;&#9;&#9; horaireCount: Object.keys(localPerturbMap.byHoraire || {}).length,&#10;&#9;&#9; numberCount: Object.keys(localPerturbMap.byNumber || {}).length,&#10;&#9;&#9; stopIdCount: Object.keys(localPerturbMap.byStopId || {}).length,&#10;&#9;&#9; stopNameCount: Object.keys(localPerturbMap.byStopName || {}).length,&#10;&#9;&#9; globalCount: (localPerturbMap.global || []).length,&#10;&#9;&#9; sampleStopNames: Object.keys(localPerturbMap.byStopName || {}).slice(0,10)&#10;&#9;&#9;});&#10;&#10;&#9;&#9;// Annoter chaque horaire avec un statut calculé (synchronisé avec la BDD)&#10;&#9;&#9;// déterminer la date de service utilisée pour ces finalList (aujourd'hui ou demain)&#10;&#9;&#9;const annotated = applyPerturbationsToHoraires(finalList, filteredPerturb || [], null, null, serviceDateStr);&#10;&#10;&#9;&#9;// Debug: log first few annotated horaires&#10;&#9;&#9;&#9;&#9;&#9;&#9;&#9;&#9;try { debug('annotated horaires sample', annotated.slice(0,6).map(a =&gt; ({ id: a.id, numero: a.numero_train || a.numero, _status: a._status, _displayTime: a._displayTime }))); } catch (e) {}&#10;&#10;&#9;&#9;// afficher seulement les 9 premières lignes sur l'afficheur&#10;&#9;&#9;setHoraires((annotated || []).slice(0, 9));&#10;&#9;&#9;setPerturbations(filteredPerturb || []);&#10;&#9;&#9;setTravaux(travauxData || []);&#10;&#9; } catch (err) {&#10;&#9;&#9;// eslint-disable-next-line no-console&#10;&#9;&#9;console.error('Erreur Supabase horaires', err);&#10;&#9;&#9;if (mounted) setError(err.message || String(err));&#10;&#9; } finally {&#10;&#9;&#9;if (mounted) setLoading(false);&#10;&#9;&#9;inFlight = false;&#10;&#9; }&#10;&#9;};&#10;&#10;&#9;// Exécuter immédiatement, puis périodiquement pour synchroniser sans recharger&#10;&#9;fetchData();&#10;&#9;const intervalMs = 15000; // 15 secondes par défaut&#10;&#9;const intervalId = setInterval(fetchData, intervalMs);&#10;&#10;&#9;return () =&gt; { mounted = false; clearInterval(intervalId); };&#10; }, [gareDisplay, gareIdParam, buildPerturbMap, computeStatusForHoraireAtStop, findPerturbsForHoraireAndStop, normalizeName, formatTimeFromHM, applyPerturbationsToHoraires, debug, getStops, getPerturbationJours]);&#10;&#10; // Helpers pour perturbations (memoized plus haut)&#10;&#10; return (&#10;&#9;&lt;div className=&quot;aff-classiques-departs__container&quot;&gt;&#10;&#9; {/* nom de la gare configurable via ?gare=Nom-de-la-gare */}&#10;&#9; &lt;div className=&quot;aff-classiques-departs__watermark&quot;&gt;départs&lt;/div&gt;&#10;&#9; &lt;ul className=&quot;aff-classiques-departs__list&quot;&gt;&#10;&#9;&#9;{horaires.length === 0 ? (&#10;&#9;&#9; &lt;li className=&quot;aff-classiques-departs__row aff-classiques-departs__row--empty&quot; key=&quot;empty&quot;&gt;&#10;&#9;&#9;&#9;&lt;div&gt;Aucun horaire disponible.&lt;/div&gt;&#10;&#9;&#9;&#9; &lt;div style={{fontSize:'0.8rem', opacity:0.8}}&gt;lignes brutes renvoyées : {rawDebug.count}&lt;/div&gt;&#10;&#9;&#9;&#9; {rawDebug.count &gt; 0 &amp;&amp; rawDebug.sample &amp;&amp; rawDebug.sample.length &gt; 0 ? (&#10;&#9;&#9;&#9;&#9; &lt;pre style={{maxHeight: 160, overflow: 'auto', fontSize: '0.75rem', marginTop: 8, background: '#111', color:'#fff', padding:8}}&gt;&#10;&#9;&#9;&#9;&#9;{JSON.stringify(rawDebug.sample.slice(0,2), null, 2).slice(0,1000)}{JSON.stringify(rawDebug.sample.slice(0,2), null, 2).length &gt; 1000 ? '…(truncated)' : ''}&#10;&#9;&#9;&#9; &lt;/pre&gt;&#10;&#9;&#9;&#9;) : null}&#10;&#9;&#9; &lt;/li&gt;&#10;&#9;&#9;) : horaires.map((h, idx) =&gt; {&#10;&#9;&#9; const horaire = h || {};&#10;&#9;&#9; const op = horaire.type_train || horaire.op || '';&#10;&#9;&#9; const number = horaire.numero_train || horaire.number || horaire.numero || '';&#10;&#9;&#9; // Use enriched display time (computed during fetch) — fallback to formatting raw fields&#10;&#9;&#9; let time = horaire._displayTime || formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure || '');&#10;&#10;&#9;&#9; const destination = horaire.gare_arrivee_nom || '';&#10;&#9;&#9; const platform = (horaire._displayPlatform || horaire.platform || '').toString().trim();&#10;&#9;&#9; // attachedPerturbs/rowObj previously used for mocked status; status is now computed from perturbations per stop&#10;&#10;&#9;&#9; // determine station context (the display station) — prefer explicit gareIdParam/gareDisplay if provided&#10;&#9;&#9; const stationId = horaire.gare_depart_id || horaire.gare_id || horaire.origin_id || (gareIdParam || null);&#10;&#9;&#9; const stationName = (gareDisplay) ? gareDisplay : (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || null);&#10;&#10;&#9;&#9; // prefer precomputed status and display time (synchronisé depuis la BDD) — sinon calculer à la volée&#10;&#9;&#9; const status = horaire._status || computeStatusForHoraireAtStop(horaire, stationId, stationName, buildPerturbMap(perturbations));&#10;&#10;&#9;&#9; // if the status is 'supprimé' and the component is currently showing the status (showStatus toggles every 3s), hide the time&#10;&#9;&#9; if (status &amp;&amp; status.label === 'supprimé' &amp;&amp; showStatus) {&#10;&#9;&#9;&#9;time = '';&#10;&#9;&#9; } else if (!horaire._displayTime &amp;&amp; status &amp;&amp; typeof status.delayMinutes === 'number' &amp;&amp; !isNaN(status.delayMinutes)) {&#10;&#9;&#9;&#9;// if we have _depHM (minutes since midnight) use it&#10;&#9;&#9;&#9;if (typeof horaire._depHM === 'number') {&#10;&#9;&#9;&#9; time = formatTimeFromHM(horaire._depHM + status.delayMinutes);&#10;&#9;&#9;&#9;} else {&#10;&#9;&#9;&#9; // fallback: try to parse original time string&#10;&#9;&#9;&#9; const tryParse = (s) =&gt; {&#10;&#9;&#9;&#9;&#9;if (!s) return null;&#10;&#9;&#9;&#9;&#9;const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10;&#9;&#9;&#9;&#9;if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10;&#9;&#9;&#9;&#9;const d = new Date(s);&#10;&#9;&#9;&#9;&#9;if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10;&#9;&#9;&#9;&#9;return null;&#10;&#9;&#9;&#9; };&#10;&#9;&#9;&#9; const base = tryParse(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || '');&#10;&#9;&#9;&#9; if (typeof base === 'number') time = formatTimeFromHM(base + status.delayMinutes);&#10;&#9;&#9;&#9;}&#10;&#9;&#9; }&#10;&#9;&#9; const statusClass = status.label === 'supprimé' ? 'aff-classiques-departs__train-meta--suppressed' : (status.label &amp;&amp; String(status.label).startsWith('retardé') ? 'aff-classiques-departs__train-meta--delayed' : '');&#10;&#10;&#9;&#9; // Ne pas afficher le quai si le train est supprimé ou si aucun quai n'est défini&#10;&#9;&#9; let showPlatform = !(status &amp;&amp; status.label === 'supprimé') &amp;&amp; Boolean(platform);&#10;&#9;&#9; if (showPlatform) {&#10;&#9;&#9;&#9;const now = new Date();&#10;&#9;&#9;&#9;const nowHM = now.getHours()*60 + now.getMinutes();&#10;&#9;&#9;&#9;const sched = (typeof horaire._depHM === 'number') ? horaire._depHM : parseTimeHM(horaire.heure_depart || horaire.time || horaire.heure || horaire._rawTime || '');&#10;&#9;&#9;&#9;const threshold = thresholdForStationType(stationType);&#10;&#9;&#9;&#9;if (typeof sched === 'number' &amp;&amp; typeof threshold === 'number' &amp;&amp; threshold &gt; 0) {&#10;&#9;&#9;&#9; // n'afficher le quai que si l'heure courante est au moins (scheduled - threshold)&#10;&#9;&#9;&#9; showPlatform = (nowHM &gt;= (sched - threshold));&#10;&#9;&#9;&#9;}&#10;&#9;&#9; }&#10;&#10;&#9;&#9; return (&#10;&#9;&#9;&#9;&lt;li className=&quot;aff-classiques-departs__row&quot; key={idx}&gt;&#10;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__col-logo&quot;&gt;&#10;&#9;&#9;&#9;&#9;&lt;Logo op={op} /&gt;&#10;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__col-train-type&quot;&gt;&#10;&#9;&#9;&#9;&#9;{showStatus ? (&#10;&#9;&#9;&#9;&#9; &lt;div className={`aff-classiques-departs__train-meta aff-classiques-departs__train-status ${statusClass}`} aria-live=&quot;polite&quot;&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__op-name&quot;&gt;{status.label}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__op-number&quot;&gt;{status.detail}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9;&#9;) : (&#10;&#9;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__train-meta&quot;&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__op-name&quot;&gt;{op}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__op-number&quot;&gt;{number}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9;&#9;)}&#10;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__col-time&quot;&gt;&#10;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__time&quot;&gt;{time}&lt;/div&gt;&#10;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__col-destination&quot;&gt;&#10;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__destination&quot;&gt;{destination}&lt;/div&gt;&#10;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9; {showPlatform ? (&#10;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__col-platform&quot;&gt;&#10;&#9;&#9;&#9;&#9; &lt;div className=&quot;aff-classiques-departs__platform-box&quot;&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__platform-text&quot;&gt;{platform}&lt;/div&gt;&#10;&#9;&#9;&#9;&#9; &lt;/div&gt;&#10;&#9;&#9;&#9;&#9;&lt;/div&gt;&#10;&#9;&#9;&#9; ) : null}&#10;&#9;&#9;&#9;&lt;/li&gt;&#10;&#9;&#9; );&#10;&#9;&#9;})}&#10;&#9; &lt;/ul&gt;&#10;&#9;&#9;&#9;&#9; {/* Footer visuel de l'afficheur — barre foncée en bas */}&#10;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__footer&quot; aria-hidden&gt;&#10;&#9;&#9;&#9;&lt;div className=&quot;aff-classiques-departs__footer-inner&quot;&gt;&#10;&#9;&#9;&#9;&#9;&lt;Clock /&gt;&#10;&#10;&#9;&#9;&#9;&lt;/div&gt;&#10;&#9;&#9;&lt;/div&gt;&#10;&#10;&#9;&lt;/div&gt;&#10; )&#10;}&#10;&#10;&#10;" />
</PendingDiffInfo>
</value>
</entry>
<entry key="$PROJECT_DIR$/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js">
<value>
<PendingDiffInfo>
<option name="filePath" value="$PROJECT_DIR$/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js" />
<option name="originalContent" value="import React, { useEffect, useState, useRef, useCallback } from 'react';&#10;import './ClassiquesArriveesQuais.css';&#10;import '../ClassiquesArrivees.css';&#10;&#10;/*&#10; ClassiquesArriveesQuais.js&#10; Variante &quot;Arrivées - quais&quot; du composant quais.&#10;&#10; Différences principales avec la version départs:&#10; - utilise `heure_arrivee` / `gare_arrivee_*` lorsque disponible pour afficher l'heure et la destination&#10; - watermark affiché: &quot;arrivée&quot;&#10; - styles importés depuis `ClassiquesArriveesQuais.css` qui reprend la palette de `ClassiquesArrivees.css`&#10;&#10; Champs utilisés pour le calcul/affichage:&#10; - horaire.heure_arrivee, horaire.time, horaire.heure&#10; - horaire.gare_arrivee_nom, horaire.gare_arrivee&#10; - stop.heure_arrivee, stop.gare_nom, stop.gare_id&#10; - perturbations: mêmes clés que pour les départs (retard, retard_minutes, perturbations_arrets, etc.)&#10;*/&#10;import supabase from '../../../../lib/supabase';&#10;&#10;// Hook pour récupérer la map des logos depuis /type/data.json&#10;function useTypeLogos(){&#10; const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' });&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10;&#10; const load = async () =&gt; {&#10; const candidates = ['/type/quais/data-color.json', '/type/data.json'];&#10; for(const url of candidates){&#10; try{&#10; const res = await fetch(url);&#10; if(!res.ok) continue;&#10; const data = await res.json();&#10; if(!mounted) return;&#10; const logos = {};&#10; if(Array.isArray(data.logos)){&#10; data.logos.forEach(item =&gt; {&#10; // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path&#10; let file = item.file;&#10; if(!file &amp;&amp; item.path){&#10; const parts = String(item.path).split('/');&#10; file = parts[parts.length-1];&#10; }&#10; if(!file) return;&#10; const basename = file.replace(/\.[^/.]+$/, '');&#10; const semantic = basename.replace(/^logo-/, '');&#10; const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-');&#10; // Prefer color logos in the 'quais' subfolder if data came from the quais file&#10; if(url.includes('/quais/')){&#10; logos[key] = `/type/quais/${file}`;&#10; } else {&#10; logos[key] = `/type/${file}`;&#10; }&#10; });&#10; }&#10; logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png';&#10; setMap(logos);&#10; return;&#10; }catch(e){&#10; // try next candidate (ignore error and continue loop)&#10; }&#10; }&#10; // no candidate succeeded — keep fallback&#10; };&#10;&#10; load();&#10; return () =&gt; { mounted = false; };&#10; }, []);&#10; return map;&#10;}&#10;&#10;function getTypeLogo(type, TYPE_LOGOS_MAP){&#10; const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' };&#10; if(!type) return map.default;&#10; const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-');&#10; if(map[norm]) return map[norm];&#10; const keys = Object.keys(map).filter(k =&gt; k !== 'default');&#10; for(const k of keys){&#10; if(k.includes(norm) || norm.includes(k)) return map[k];&#10; }&#10; return map.default;&#10;}&#10;&#10;function Clock() {&#10; const [now, setNow] = useState(new Date());&#10; useEffect(() =&gt; {&#10; const id = setInterval(() =&gt; setNow(new Date()), 1000);&#10; return () =&gt; clearInterval(id);&#10; }, []);&#10; const hh = String(now.getHours()).padStart(2,'0');&#10; const mm = String(now.getMinutes()).padStart(2,'0');&#10; const ss = String(now.getSeconds()).padStart(2,'0');&#10; return (&#10; &lt;div className=&quot;aff-quais__clock&quot;&gt;&#10; &lt;div className=&quot;aff-quais__clock-box&quot;&gt;&lt;span className=&quot;aff-quais__clock-main&quot;&gt;{hh}:{mm}:{ss}&lt;/span&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10; );&#10;}&#10;&#10;export default function ClassiquesArriveesQuais(){&#10; const typeLogos = useTypeLogos();&#10;&#10; const rightRef = useRef(null);&#10; const stopsListRef = useRef(null);&#10; const railRef = useRef(null);&#10;&#10; // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée)&#10; const mock = {&#10; time: '16h44',&#10; status: &quot;à l'heure&quot;,&#10; trainType: 'Fluo',&#10; trainNumber: '891811',&#10; destinationBig: 'Seurre',&#10; destinationStation: '',&#10; stops: [&#10; 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse'&#10; ],&#10; };&#10;&#10; // état pour l'horaire sélectionné&#10; const [selectedHoraire, setSelectedHoraire] = useState(null);&#10; const [loadingHoraire, setLoadingHoraire] = useState(false);&#10; const [horaireError, setHoraireError] = useState(null);&#10; const [candidatesCount, setCandidatesCount] = useState(0);&#10; // perturbations/travaux (récupérées depuis Supabase)&#10; const [perturbations, setPerturbations] = useState([]);&#10; const [, setTravaux] = useState([]);&#10;&#10; // helpers locaux (simples) — similaires à ceux du composant principal&#10; const normalizeName = useCallback((s) =&gt; {&#10; if (!s) return '';&#10; try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); }&#10; }, []);&#10;&#10; const parseTimeHM = useCallback((s) =&gt; {&#10; if (!s) return null;&#10; if (typeof s === 'number') return s;&#10; const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10; if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10; const d = new Date(s);&#10; if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10; return null;&#10; }, []);&#10;&#10; const formatTimeNoSeconds = useCallback((v) =&gt; {&#10; if (v == null) return '';&#10; const tryDate = new Date(v);&#10; if (!isNaN(tryDate.getTime())) {&#10; const hh = String(tryDate.getHours()).padStart(2,'0');&#10; const mm = String(tryDate.getMinutes()).padStart(2,'0');&#10; return `${hh}h${mm}`;&#10; }&#10; if (typeof v === 'string') {&#10; const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);&#10; if (m) return `${String(m[1]).padStart(2,'0')}h${m[2]}`;&#10; const m2 = v.match(/T(\d{2}):(\d{2})/);&#10; if (m2) return `${m2[1]}h${m2[2]}`;&#10; return v.slice(0,5).replace(':','h');&#10; }&#10; return String(v).slice(0,5).replace(':','h');&#10; }, []);&#10;&#10; // ...existing code...&#10;&#10; const formatTimeFromHM = useCallback((totalMinutes) =&gt; {&#10; if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return '';&#10; const m = ((totalMinutes % 1440) + 1440) % 1440;&#10; const hh = String(Math.floor(m / 60)).padStart(2, '0');&#10; const mm = String(m % 60).padStart(2, '0');&#10; return `${hh}h${mm}`;&#10; }, []);&#10;&#10; // lecture des query params pour gare &amp; quai&#10; const paramsForThis = new URLSearchParams(window.location.search || '');&#10; const gareParam = paramsForThis.get('gare');&#10; const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId');&#10; const quaiParam = paramsForThis.get('quai');&#10; const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true');&#10; const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null;&#10;&#10; // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs)&#10; const getStops = (h) =&gt; {&#10; if (!h) return [];&#10; if (Array.isArray(h.stops) &amp;&amp; h.stops.length) return h.stops;&#10; if (Array.isArray(h.gares_desservies) &amp;&amp; h.gares_desservies.length) return h.gares_desservies;&#10; if (Array.isArray(h.garesDesservies) &amp;&amp; h.garesDesservies.length) return h.garesDesservies;&#10; if (Array.isArray(h.arrets) &amp;&amp; h.arrets.length) return h.arrets;&#10; const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','gares_desservies_json'];&#10; for (const key of candidates) {&#10; const raw = h[key];&#10; if (!raw) continue;&#10; if (Array.isArray(raw)) return raw;&#10; if (typeof raw === 'string') {&#10; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ }&#10; }&#10; }&#10; return [];&#10; };&#10;&#10; // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) ---&#10; const safeParseArrets = (p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return [];&#10; if (Array.isArray(raw)) return raw;&#10; try {&#10; const parsed = JSON.parse(raw);&#10; if (parsed &amp;&amp; typeof parsed === 'object') {&#10; if (Array.isArray(parsed.arrets)) return parsed.arrets;&#10; return parsed;&#10; }&#10; return [];&#10; } catch (e) { return []; }&#10; };&#10;&#10; const parsePerturbArretsObject = (p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return { arrets: [], jours: null };&#10; if (typeof raw === 'object') {&#10; if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null };&#10; if (Array.isArray(raw)) return { arrets: raw, jours: null };&#10; return { arrets: [], jours: null };&#10; }&#10; try {&#10; const parsed = JSON.parse(raw);&#10; if (parsed &amp;&amp; typeof parsed === 'object') {&#10; return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null };&#10; }&#10; return { arrets: [], jours: null };&#10; } catch (e) {&#10; return { arrets: [], jours: null };&#10; }&#10; };&#10;&#10; const getPerturbationJours = useCallback((p) =&gt; {&#10; if (!p) return null;&#10; const candidate = p.jours_perturbation || p.jours || null;&#10; const result = new Set();&#10; const pushDate = (d) =&gt; {&#10; if (!d) return;&#10; try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10)); } catch(e){}&#10; };&#10; if (candidate) {&#10; if (Array.isArray(candidate)) candidate.forEach(item =&gt; pushDate(item.date || item));&#10; else if (typeof candidate === 'string') {&#10; try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); }&#10; catch (e) { pushDate(candidate); }&#10; } else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it =&gt; pushDate(it.date || it)); else pushDate(candidate.date || null); }&#10; }&#10; try {&#10; const parsedObj = parsePerturbArretsObject(p);&#10; const jp = parsedObj.jours;&#10; if (jp) {&#10; if (Array.isArray(jp)) jp.forEach(it =&gt; pushDate(it.date || it));&#10; else if (typeof jp === 'string') {&#10; try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); } catch (e) { pushDate(jp); }&#10; } else if (typeof jp === 'object') pushDate(jp.date || null);&#10; }&#10; } catch (e) {}&#10; return result.size ? result : null;&#10; }, [parsePerturbArretsObject]);&#10;&#10; const parseDelayMinutesFromArret = (arret) =&gt; {&#10; if (!arret) return null;&#10; const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min'];&#10; for (const k of keys) {&#10; if (typeof arret[k] !== 'undefined' &amp;&amp; arret[k] !== null &amp;&amp; arret[k] !== '') {&#10; const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n);&#10; }&#10; }&#10; return null;&#10; };&#10;&#10; const buildPerturbMap = useCallback((list = []) =&gt; {&#10; const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] };&#10; (list || []).forEach(p =&gt; {&#10; const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID;&#10; if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); }&#10; const number = p.number || p.train_number || p.train || p.numero || p.numero_train;&#10; if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); }&#10; const arrets = safeParseArrets(p);&#10; if (Array.isArray(arrets) &amp;&amp; arrets.length) {&#10; arrets.forEach(a =&gt; {&#10; const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code;&#10; const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10; if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); }&#10; if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); }&#10; });&#10; }&#10; if ((horaireId == null &amp;&amp; number == null &amp;&amp; (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); }&#10; });&#10; return idx;&#10; }, [normalizeName]);&#10;&#10; const findPerturbsForHoraireAndStop = (horaire, stationId, stationName, idx) =&gt; {&#10; const res = [];&#10; const map = idx || buildPerturbMap(perturbations || []);&#10; const hId = horaire &amp;&amp; (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train);&#10; const number = horaire &amp;&amp; (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number);&#10; if (hId != null) { const kh = String(hId); if (map.byHoraire &amp;&amp; map.byHoraire[kh]) res.push(...map.byHoraire[kh]); }&#10; if (number != null) { const kn = String(number); if (map.byNumber &amp;&amp; map.byNumber[kn]) res.push(...map.byNumber[kn]); }&#10; if (stationId != null) { const ks = String(stationId); if (map.byStopId &amp;&amp; map.byStopId[ks]) map.byStopId[ks].forEach(x =&gt; res.push(x.perturbation)); }&#10; if (stationName &amp;&amp; map.byStopName) {&#10; const key = normalizeName(stationName);&#10; if (map.byStopName[key]) map.byStopName[key].forEach(x =&gt; res.push(x.perturbation));&#10; try {&#10; const needle = key;&#10; for (const existingKey of Object.keys(map.byStopName || {})) {&#10; if (existingKey === needle) continue;&#10; if (existingKey.includes(needle) || needle.includes(existingKey)) { map.byStopName[existingKey].forEach(x =&gt; res.push(x.perturbation)); }&#10; }&#10; } catch (e) {}&#10; }&#10; return Array.from(new Set(res));&#10; };&#10;&#10; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) =&gt; {&#10; const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || [];&#10; const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10; let horaireYmd = toYMD(horaire &amp;&amp; (horaire._date || horaire.date || horaire.date_service || horaire.service_date));&#10; if (!horaireYmd &amp;&amp; serviceDate) horaireYmd = serviceDate;&#10; let maxDelay = null;&#10; for (const p of found) {&#10; try {&#10; if (horaireYmd) {&#10; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10; const db = toYMD(p.date_debut || p.start_date);&#10; const df = toYMD(p.date_fin || p.end_date);&#10; if (pDate &amp;&amp; pDate !== horaireYmd) continue;&#10; if (db &amp;&amp; df &amp;&amp; !(horaireYmd &gt;= db &amp;&amp; horaireYmd &lt;= df)) continue;&#10; }&#10; } catch (e) {}&#10; try {&#10; const joursSet = getPerturbationJours(p);&#10; if (joursSet &amp;&amp; horaireYmd) { if (!joursSet.has(horaireYmd)) continue; }&#10; } catch (e) {}&#10; const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };&#10; const arrets = parsedObj.arrets || [];&#10; let matched = false;&#10; for (const a of arrets) {&#10; const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code;&#10; const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10; const na = normalizeName(aName || '');&#10; const ns = normalizeName(stationName || '');&#10; const nameMatches = stationName &amp;&amp; (na === ns || na.includes(ns) || ns.includes(na));&#10; if ((stationId != null &amp;&amp; String(aId) === String(stationId)) || nameMatches) {&#10; matched = true;&#10; const t = (a.type_perturbation || a.type || '').toString().toLowerCase();&#10; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10; const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;&#10; if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10; }&#10; }&#10; }&#10; if (!matched) {&#10; const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero;&#10; const numberRef = p.number || p.train_number || p.numero || p.numero_train;&#10; const appliesByRef = ref &amp;&amp; (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero));&#10; const appliesByNumber = numberRef &amp;&amp; (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number));&#10; const isGlobal = (!Array.isArray(arrets) || arrets.length === 0) &amp;&amp; !ref &amp;&amp; !numberRef;&#10; if (appliesByRef || appliesByNumber || isGlobal) {&#10; const t = (p.type_perturbation || p.type || '').toString().toLowerCase();&#10; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10; const d = parseDelayMinutesFromArret(p) || 0;&#10; if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10; }&#10; }&#10; }&#10; }&#10; if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay };&#10; return { label: &quot;à l'heure&quot;, detail: '', delayMinutes: null };&#10; }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours]);&#10;&#10; // Fetch perturbations &amp; travaux from Supabase periodically&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; let inFlight = false;&#10; const fetchPerturb = async () =&gt; {&#10; if (!mounted) return;&#10; if (inFlight) return;&#10; inFlight = true;&#10; try {&#10; if (!supabase) return;&#10; const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([&#10; supabase.from('perturbations').select('*'),&#10; supabase.from('travaux').select('*')&#10; ]);&#10; if (!mounted) return;&#10; if (pError || tError) {&#10; // ignore quietly — keep previous data&#10; } else {&#10; // Filtrer les perturbations pour ne conserver que celles valides aujourd'hui&#10; const todayYmd = new Date().toLocaleDateString('en-CA');&#10; const filteredPerturb = (perturbData || []).filter(p =&gt; {&#10; try {&#10; const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10; const db = toYMD(p.date_debut || p.start_date);&#10; const df = toYMD(p.date_fin || p.end_date);&#10; if (pDate &amp;&amp; pDate !== todayYmd) return false;&#10; if (db &amp;&amp; df &amp;&amp; !(todayYmd &gt;= db &amp;&amp; todayYmd &lt;= df)) return false;&#10; try { const joursSet = getPerturbationJours(p); if (joursSet &amp;&amp; !joursSet.has(todayYmd)) return false; } catch (e) {}&#10; return true;&#10; } catch (e) { return true; }&#10; });&#10; setPerturbations(filteredPerturb || []);&#10; setTravaux(travauxData || []);&#10; }&#10; } catch (e) {&#10; // ignore&#10; } finally {&#10; inFlight = false;&#10; }&#10; };&#10; fetchPerturb();&#10; const id = setInterval(fetchPerturb, 15000);&#10; return () =&gt; { mounted = false; clearInterval(id); };&#10; }, []);&#10;&#10; // Annotate selectedHoraire with computed status when perturbations change&#10; useEffect(() =&gt; {&#10; if (!selectedHoraire || !selectedHoraire.horaire) return;&#10; const horaire = selectedHoraire.horaire;&#10; const stop = selectedHoraire.stop || null;&#10; const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null;&#10; const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null;&#10; try {&#10; const map = buildPerturbMap(perturbations || []);&#10; const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null);&#10; // keep original scheduled time for display (_displayTime)&#10; const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure);&#10; const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null));&#10; // compute updated time (delayed) but store separately — do not override the original displayed time&#10; let updatedTime = null;&#10; if (status &amp;&amp; typeof status.delayMinutes === 'number' &amp;&amp; !Number.isNaN(status.delayMinutes) &amp;&amp; typeof originalBase === 'number') {&#10; updatedTime = formatTimeFromHM(originalBase + status.delayMinutes);&#10; }&#10; setSelectedHoraire(prev =&gt; ({ ...(prev||{}), horaire: { ...(prev &amp;&amp; prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime }));&#10; } catch (e) { /* ignore */ }&#10; }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]);&#10;&#10; // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai)&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; async function loadNextForQuai() {&#10; setLoadingHoraire(true);&#10; setHoraireError(null);&#10; setSelectedHoraire(null);&#10; if (!supabase) {&#10; setHoraireError('Supabase non initialisé');&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10; try {&#10; const { data: all, error } = await supabase.from('horaires').select('id, *');&#10; if (error) {&#10; if (mounted) setHoraireError(error.message || String(error));&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10; if (!mounted) return;&#10; if (!Array.isArray(all) || all.length === 0) {&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10;&#10; const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null;&#10; const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null;&#10;&#10; const now = new Date();&#10; const nowHM = now.getHours()*60 + now.getMinutes();&#10;&#10; const candidates = [];&#10;&#10; for (const h of all) {&#10; if (!h) continue;&#10; // build stops list (support multiple field names)&#10; const stops = getStops(h);&#10;&#10; // compute max index for terminus detection if stops contain index&#10; let maxIdx = null;&#10; if (Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const st of stops) {&#10; if (typeof st.index !== 'undefined' &amp;&amp; st.index !== null) {&#10; const n = Number(st.index);&#10; if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);&#10; }&#10; }&#10; }&#10;&#10; // Match stop by name or id (prefer stop entry over horaire-level arrival)&#10; let stopMatch = null;&#10; if (Array.isArray(stops) &amp;&amp; stops.length) {&#10; // first try to match by id if gareIdParam present&#10; if (gareIdParam) {&#10; const gid = String(gareIdParam);&#10; for (const s of stops) {&#10; const sid = s &amp;&amp; (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null);&#10; if (sid != null &amp;&amp; String(sid) === gid) { stopMatch = s; break; }&#10; }&#10; }&#10; // then try to match by normalized name&#10; if (!stopMatch &amp;&amp; normalizedTarget) {&#10; for (const s of stops) {&#10; const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || s.station_name || '');&#10; const nm = normalizeName(rawName);&#10; if (!nm) continue;&#10; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { stopMatch = s; break; }&#10; }&#10; }&#10; }&#10;&#10; // if no stopMatch found in stops, check horaire-level gare_arrivee (arrival terminus)&#10; if (!stopMatch) {&#10; const arriveeName = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || '');&#10; if (arriveeName &amp;&amp; normalizedTarget &amp;&amp; (arriveeName === normalizedTarget || arriveeName.includes(normalizedTarget) || normalizedTarget.includes(arriveeName))) {&#10; stopMatch = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_nom: h.gare_arrivee_nom || h.gare_arrivee, gare_id: h.gare_arrivee_id || h.gare_arrivee };&#10; }&#10; }&#10;&#10; if (!stopMatch) continue; // does not arrive at the targeted station&#10;&#10; // determine platform/quai for this stop/horaire&#10; const platform = (stopMatch &amp;&amp; (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || '';&#10; const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,'');&#10; // Only include horaires that have a platform/quai for the displayed station&#10; if (!normPlatform) continue;&#10; if (targetQuai) {&#10; if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue;&#10; }&#10;&#10; // ensure we have an arrival time&#10; const stopIsObject = stopMatch &amp;&amp; typeof stopMatch === 'object';&#10; const hasArrival = stopIsObject &amp;&amp; (&#10; typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined' || typeof stopMatch.time !== 'undefined'&#10; );&#10; if (stopIsObject &amp;&amp; !hasArrival) continue;&#10;&#10; // pick time (arrival) from stopMatch or fallback to horaire-level arrival&#10; const rawTime = (hasArrival ? (stopMatch.heure_arrivee || stopMatch.time || stopMatch.heure || stopMatch.arrival_time || stopMatch.arrival) : (h.heure_arrivee || h.time || h.heure || h.arrival_time || h.arrivee));&#10; const depHM = parseTimeHM(rawTime);&#10; if (depHM == null) continue;&#10;&#10; // compute minutes until arrival (allow next-day wrap)&#10; let minutesUntil = depHM - nowHM;&#10; if (minutesUntil &lt; 0) minutesUntil = depHM + 24*60 - nowHM;&#10;&#10; candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime });&#10; }&#10;&#10; if (!candidates.length) {&#10; setCandidatesCount(0);&#10; setLoadingHoraire(false);&#10; setSelectedHoraire(null);&#10; return;&#10; }&#10;&#10; // split into today (depHM &gt; nowHM) and tomorrow (depHM &lt;= nowHM)&#10; const todayCandidates = candidates.filter(c =&gt; c.depHM &gt; nowHM).sort((a,b) =&gt; a.depHM - b.depHM);&#10; const tomorrowCandidates = candidates.filter(c =&gt; c.depHM &lt;= nowHM).sort((a,b) =&gt; a.depHM - b.depHM);&#10;&#10; let chosen = null;&#10; if (todayCandidates.length &gt; 0) chosen = todayCandidates[0];&#10; else if (tomorrowCandidates.length &gt; 0) chosen = tomorrowCandidates[0];&#10;&#10; if (mounted) {&#10; setCandidatesCount(candidates.length);&#10; setSelectedHoraire(chosen || null);&#10; }&#10; } catch (err) {&#10; console.error('Erreur supabase quai', err);&#10; if (mounted) setHoraireError(err.message || String(err));&#10; } finally {&#10; if (mounted) setLoadingHoraire(false);&#10; }&#10; }&#10;&#10; loadNextForQuai();&#10; return () =&gt; { mounted = false; };&#10; }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM]);&#10;&#10; // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present)&#10; const [showTime, setShowTime] = useState(true);&#10; // derive a simple status label string to use in the dependency array (avoids complex expressions)&#10; const _selectedStatusLabel = (() =&gt; {&#10; const horaireObj = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const computedLabel = horaireObj &amp;&amp; horaireObj._status &amp;&amp; horaireObj._status.label ? String(horaireObj._status.label) : null;&#10; const rawStatus = horaireObj &amp;&amp; horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status &amp;&amp; horaireObj.status.label ? String(horaireObj.status.label) : null)) : null;&#10; return String(computedLabel || rawStatus || mock.status || '').toLowerCase();&#10; })();&#10;&#10; useEffect(()=&gt;{&#10; let id = null;&#10; if (/retard|supprim/.test(_selectedStatusLabel)) {&#10; // ensure we start by showing the time&#10; setShowTime(true);&#10; id = setInterval(() =&gt; setShowTime(prev =&gt; !prev), 3000);&#10; } else {&#10; setShowTime(true);&#10; }&#10; return () =&gt; { if (id) clearInterval(id); };&#10; }, [_selectedStatusLabel, mock.status]);&#10;&#10; // compute and position the rail so it covers from first stop center to last stop center&#10; useEffect(() =&gt; {&#10; function updateRail(){&#10; const rail = railRef.current;&#10; const list = stopsListRef.current;&#10; const right = rightRef.current;&#10; if(!rail || !list || !right) return;&#10; const items = Array.from(list.querySelectorAll('li'));&#10; if(items.length === 0) return;&#10; const first = items[0];&#10; // find destination (last with dest class) or fallback to last item&#10; const last = items.find(el =&gt; el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1];&#10;&#10; const rightRect = right.getBoundingClientRect();&#10; const firstRect = first.getBoundingClientRect();&#10; const lastRect = last.getBoundingClientRect();&#10;&#10; const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top;&#10; const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top;&#10;&#10; // add small padding so rail extends a bit beyond centers&#10; const pad = 9;&#10; const top = Math.max(0, firstCenterY - pad);&#10; const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2);&#10;&#10; // compute horizontal position so dots are centered next to the text&#10; const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text');&#10; const textRect = firstText ? firstText.getBoundingClientRect() : firstRect;&#10; const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px&#10; const railWidth = 35;&#10; const left = centerX - railWidth/2;&#10;&#10; // apply styles for the rail rectangle&#10; rail.style.top = `${Math.round(top)}px`;&#10; rail.style.height = `${Math.round(height)}px`;&#10; rail.style.left = `${Math.round(left)}px`;&#10; rail.style.width = `${railWidth}px`;&#10; // remove background-image reliance and render individual dots as DOM children&#10; // clear existing dots&#10; while (rail.firstChild) rail.removeChild(rail.firstChild);&#10;&#10; // create a dot element for each item and position it exactly at the item's vertical center&#10; const railWidthComputed = parseInt(rail.style.width, 8) || railWidth;&#10; items.forEach(it =&gt; {&#10; const r = it.getBoundingClientRect();&#10; const centerY = r.top + r.height/2 - (rightRect.top + top);&#10; const dot = document.createElement('span');&#10; dot.className = 'aff-quais__rail-dot';&#10; dot.style.position = 'absolute';&#10; dot.style.left = '50%';&#10; dot.style.transform = 'translate(-50%, -50%)';&#10; dot.style.top = `${Math.round(centerY)}px`;&#10; // set dot diameter to rail width&#10; dot.style.width = `${railWidthComputed}px`;&#10; dot.style.height = `${railWidthComputed}px`;&#10; dot.style.borderRadius = '50%';&#10; dot.style.background = '#ffe600';&#10; dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset';&#10; rail.appendChild(dot);&#10; });&#10; }&#10;&#10; updateRail();&#10; window.addEventListener('resize', updateRail);&#10; const obs = new MutationObserver(updateRail);&#10; const listNode = stopsListRef.current;&#10; if(listNode) obs.observe(listNode, { childList:true, subtree:true });&#10; return ()=&gt;{&#10; window.removeEventListener('resize', updateRail);&#10; if(obs) obs.disconnect();&#10; };&#10; }, [selectedHoraire]);&#10; // Observer changement des stops via selectedHoraire&#10; // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent)&#10;&#10; const displayTimeOrStatus = () =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status;&#10; const statusObj = horaire &amp;&amp; horaire._status ? horaire._status : null;&#10; // prefer precomputed _displayTime if available&#10; const time = selectedHoraire &amp;&amp; selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time);&#10;&#10; // build canonical status label when we have computed status object&#10; let canonicalStatus = null;&#10; if (statusObj) {&#10; if (typeof statusObj.delayMinutes === 'number' &amp;&amp; !Number.isNaN(statusObj.delayMinutes)) {&#10; canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`;&#10; } else if (statusObj.label &amp;&amp; String(statusObj.label).toLowerCase().includes('supprim')) {&#10; canonicalStatus = 'supprimé';&#10; } else if (statusObj.label) {&#10; canonicalStatus = String(statusObj.label);&#10; }&#10; }&#10;&#10; const s = (canonicalStatus || statusStrFromField || '').toLowerCase();&#10; const isAlert = s.includes('retard') || s.includes('supprim');&#10; const label = canonicalStatus || statusStrFromField || '';&#10; // Always render both time and status inside a fixed-size wrapper to avoid layout shift&#10; return (&#10; &lt;span className=&quot;aff-quais__time-status-wrapper&quot; aria-live=&quot;polite&quot;&gt;&#10; &lt;span className={`aff-quais__time aff-quais__time--strict ${showTime ? 'visible' : 'hidden'}`}&gt;{time}&lt;/span&gt;&#10; &lt;span className={`aff-quais__time-status aff-quais__time-status--strict aff-quais__status--alert ${(!showTime &amp;&amp; isAlert) ? 'visible' : 'hidden'}`}&gt;{label}&lt;/span&gt;&#10; &lt;/span&gt;&#10; );&#10; }&#10;&#10; return (&#10; &lt;div className=&quot;aff-quais__container aff-quais__container--strict aff-quais__container--arrivees&quot;&gt;&#10; &lt;div className=&quot;aff-quais__left aff-quais__left--strict&quot;&gt;&#10; {/* logo (depuis le type de train) */}&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;&#10; return &lt;img src={getTypeLogo(trainType, typeLogos)} alt={trainType || 'train'} className=&quot;aff-quais__type-logo--strict&quot; /&gt;;&#10; })()}&#10;&#10; &lt;div className=&quot;aff-quais__watermark&quot; aria-hidden&gt;départ&lt;/div&gt;&#10; {/* affichage des états de chargement / erreur pour debug */}&#10; {horaireError ? &lt;div className=&quot;aff-quais__error&quot; style={{color:'crimson',fontSize:'0.9rem',marginTop:6}}&gt;{horaireError}&lt;/div&gt; : null}&#10; {loadingHoraire ? &lt;div className=&quot;aff-quais__loading&quot; style={{color:'#666',fontSize:'0.85rem',marginTop:6}}&gt;Chargement...&lt;/div&gt; : null}&#10; {debugEnabled ? (&#10; &lt;div style={{marginTop:8, fontSize:'0.78rem', color:'#333'}}&gt;&#10; &lt;div&gt;Candidates: {candidatesCount}&lt;/div&gt;&#10; {selectedHoraire ? &lt;pre style={{maxHeight:120,overflow:'auto',background:'#f7f7f7',padding:6}}&gt;{JSON.stringify(selectedHoraire, null, 2)}&lt;/pre&gt; : null}&#10; &lt;/div&gt;&#10; ) : null}&#10; {/* logo du type en haut à droite */}&#10; &lt;div className=&quot;aff-quais__row aff-quais__row--time-status&quot;&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const status = horaire ? (horaire.status || (horaire._status &amp;&amp; horaire._status.label) || mock.status) : mock.status;&#10; const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time;&#10; const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim');&#10; if (isAlert) return displayTimeOrStatus();&#10; return (&#10; &lt;&gt;&#10; &lt;span className=&quot;aff-quais__time aff-quais__time--strict&quot;&gt;{time}&lt;/span&gt;&#10; &lt;span className=&quot;aff-quais__time-status aff-quais__time-status--strict&quot;&gt;{status}&lt;/span&gt;&#10; &lt;/&gt;&#10; );&#10; })()}&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__destination aff-quais__destination--strict&quot;&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const stop = selectedHoraire &amp;&amp; selectedHoraire.stop ? selectedHoraire.stop : null;&#10; // For arrivals: show the origin of the train (gare_depart_*) as the main station label&#10; const destinationBig = horaire ? (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || '') : mock.destinationBig;&#10; const rawDestinationStation = stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation);&#10; return (&#10; &lt;&gt;&#10; &lt;div className=&quot;aff-quais__destination-line1 aff-quais__destination-line1--strict&quot;&gt;{destinationBig}&lt;/div&gt;&#10; {(rawDestinationStation &amp;&amp; destinationBig &amp;&amp; normalizeName(rawDestinationStation) !== normalizeName(destinationBig)) ? (&lt;div className=&quot;aff-quais__destination-line2 aff-quais__destination-line2--strict&quot;&gt;{rawDestinationStation}&lt;/div&gt;) : null}&#10; &lt;/&gt;&#10; );&#10; })()}&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__train-id aff-quais__train-id--strict&quot;&gt;{(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;&#10; const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber;&#10; return (&lt;&gt;{trainType}&amp;nbsp;&amp;nbsp;&lt;span className=&quot;aff-quais__train-number--strict&quot;&gt;{trainNumber}&lt;/span&gt;&lt;/&gt;);&#10; })()}&lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__separator--strict&quot; aria-hidden&gt;&lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__right aff-quais__right--strict&quot; ref={rightRef}&gt;&#10; {/* barre verticale de points (élément explicite pour fiabilité) */}&#10; &lt;div className=&quot;aff-quais__stops-rail&quot; aria-hidden ref={railRef} /&gt;&#10; &lt;ul className=&quot;aff-quais__stops-list--strict&quot; ref={stopsListRef}&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; // utiliser la helper getStops pour supporter plusieurs formats&#10; let stops = horaire ? getStops(horaire) : [];&#10; if (!Array.isArray(stops) || stops.length === 0) {&#10; // fallback parsing si la colonne est une chaîne JSON&#10; if (horaire &amp;&amp; typeof horaire.stops === 'string') {&#10; try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ }&#10; }&#10; }&#10; if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || [];&#10;&#10; // construire une liste propre en extrayant un label pour chaque stop&#10; const labeled = stops.map((stop, idx) =&gt; {&#10; let label = '';&#10; if (typeof stop === 'string') label = stop;&#10; else if (stop &amp;&amp; typeof stop === 'object') {&#10; // vérifier plusieurs clés possibles&#10; label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties &amp;&amp; (stop.properties.name || stop.properties.stop_name)) || '';&#10; // si l'objet possède une clé 'stop' qui est elle-même un objet&#10; if (!label &amp;&amp; stop.stop &amp;&amp; typeof stop.stop === 'object') {&#10; label = stop.stop.name || stop.stop.stop_name || '';&#10; }&#10; }&#10; label = String(label || '').trim();&#10; return { label, original: stop, idx };&#10; }).filter(it =&gt; it.label);&#10;&#10; if (labeled.length === 0) return null;&#10;&#10; // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop)&#10; let matchedIndex = -1;&#10; try {&#10; if (selectedHoraire &amp;&amp; selectedHoraire.stop) {&#10; const target = selectedHoraire.stop;&#10; const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || ''));&#10; const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null;&#10; for (let i = 0; i &lt; stops.length; i++) {&#10; const s = stops[i];&#10; // compare by id first if available&#10; if (targetId != null) {&#10; const sid = (s &amp;&amp; typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null;&#10; if (sid != null &amp;&amp; String(sid) === String(targetId)) { matchedIndex = i; break; }&#10; }&#10; const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s &amp;&amp; (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || '');&#10; if (name &amp;&amp; targetName &amp;&amp; name === targetName) { matchedIndex = i; break; }&#10; }&#10; }&#10; } catch (e) { /* ignore */ }&#10;&#10; // For arrivals: show the stops served BEFORE the display station (i.e. previous stops)&#10; // If we have a matchedIndex, keep only items with idx &lt; matchedIndex.&#10; // Display stops in the reverse direction relative to the route: show the farthest previous stop first&#10; let subsequent = labeled.filter(it =&gt; (matchedIndex &gt;= 0) ? (it.idx &lt; matchedIndex) : true);&#10; // sort ascending so the oldest (farthest) previous stop appears first -&gt; reverse of travel direction&#10; subsequent = subsequent.sort((a,b) =&gt; a.idx - b.idx);&#10; // Remove consecutive duplicate labels (case-insensitive) to avoid repeated identical station names&#10; if (subsequent.length &gt; 1) {&#10; const collapsed = [];&#10; for (const it of subsequent) {&#10; const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null;&#10; const cur = it.label ? String(it.label).trim().toLowerCase() : '';&#10; if (cur &amp;&amp; prev &amp;&amp; prev === cur) {&#10; // skip duplicate consecutive entry&#10; continue;&#10; }&#10; collapsed.push(it);&#10; }&#10; subsequent = collapsed;&#10; }&#10; // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue&#10; if (subsequent.length === 0) return null;&#10;&#10; return subsequent.map((item, mapIndex) =&gt; {&#10; const isLast = item.idx === (stops.length - 1);&#10; return (&#10; &lt;li key={`${item.label}-${item.idx}-${mapIndex}`} className={isLast ? 'aff-quais__stop--strict aff-quais__stop--strict-dest' : 'aff-quais__stop--strict'}&gt;&#10; &lt;span className=&quot;aff-quais__stop-bullet--strict&quot; /&gt;&#10; &lt;span className=&quot;aff-quais__stop-text--strict&quot;&gt;{item.label}&lt;/span&gt;&#10; &lt;/li&gt;&#10; );&#10; });&#10; })()}&#10; &lt;/ul&gt;&#10; &lt;/div&gt;&#10; {/* footer band same as classic departures with clock on the right */}&#10; &lt;div className=&quot;aff-quais__footer&quot; aria-hidden&gt;&#10; &lt;div className=&quot;aff-quais__footer-inner&quot;&gt;&#10; &lt;div className=&quot;aff-quais__footer-left&quot;&gt;&lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__footer-right&quot;&gt;&lt;Clock /&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;/div&gt;&#10; );&#10;}&#10;&#10;&#10;" />
<option name="updatedContent" value="import React, { useEffect, useState, useRef, useCallback } from 'react';&#10;import './ClassiquesArriveesQuais.css';&#10;import '../ClassiquesArrivees.css';&#10;&#10;/*&#10; ClassiquesArriveesQuais.js&#10; Variante &quot;Arrivées - quais&quot; du composant quais.&#10;&#10; Différences principales avec la version départs:&#10; - utilise `heure_arrivee` / `gare_arrivee_*` lorsque disponible pour afficher l'heure et la destination&#10; - watermark affiché: &quot;arrivée&quot;&#10; - styles importés depuis `ClassiquesArriveesQuais.css` qui reprend la palette de `ClassiquesArrivees.css`&#10;&#10; Champs utilisés pour le calcul/affichage:&#10; - horaire.heure_arrivee, horaire.time, horaire.heure&#10; - horaire.gare_arrivee_nom, horaire.gare_arrivee&#10; - stop.heure_arrivee, stop.gare_nom, stop.gare_id&#10; - perturbations: mêmes clés que pour les départs (retard, retard_minutes, perturbations_arrets, etc.)&#10;*/&#10;import supabase from '../../../../lib/supabase';&#10;&#10;// Hook pour récupérer la map des logos depuis /type/data.json&#10;function useTypeLogos(){&#10; const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' });&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10;&#10; const load = async () =&gt; {&#10; const candidates = ['/type/quais/data-color.json', '/type/data.json'];&#10; for(const url of candidates){&#10; try{&#10; const res = await fetch(url);&#10; if(!res.ok) continue;&#10; const data = await res.json();&#10; if(!mounted) return;&#10; const logos = {};&#10; if(Array.isArray(data.logos)){&#10; data.logos.forEach(item =&gt; {&#10; // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path&#10; let file = item.file;&#10; if(!file &amp;&amp; item.path){&#10; const parts = String(item.path).split('/');&#10; file = parts[parts.length-1];&#10; }&#10; if(!file) return;&#10; const basename = file.replace(/\.[^/.]+$/, '');&#10; const semantic = basename.replace(/^logo-/, '');&#10; const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-');&#10; // Prefer color logos in the 'quais' subfolder if data came from the quais file&#10; if(url.includes('/quais/')){&#10; logos[key] = `/type/quais/${file}`;&#10; } else {&#10; logos[key] = `/type/${file}`;&#10; }&#10; });&#10; }&#10; logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png';&#10; setMap(logos);&#10; return;&#10; }catch(e){&#10; // try next candidate (ignore error and continue loop)&#10; }&#10; }&#10; // no candidate succeeded — keep fallback&#10; };&#10;&#10; load();&#10; return () =&gt; { mounted = false; };&#10; }, []);&#10; return map;&#10;}&#10;&#10;function getTypeLogo(type, TYPE_LOGOS_MAP){&#10; const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' };&#10; if(!type) return map.default;&#10; const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-');&#10; if(map[norm]) return map[norm];&#10; const keys = Object.keys(map).filter(k =&gt; k !== 'default');&#10; for(const k of keys){&#10; if(k.includes(norm) || norm.includes(k)) return map[k];&#10; }&#10; return map.default;&#10;}&#10;&#10;function Clock() {&#10; const [now, setNow] = useState(new Date());&#10; useEffect(() =&gt; {&#10; const id = setInterval(() =&gt; setNow(new Date()), 1000);&#10; return () =&gt; clearInterval(id);&#10; }, []);&#10; const hh = String(now.getHours()).padStart(2,'0');&#10; const mm = String(now.getMinutes()).padStart(2,'0');&#10; const ss = String(now.getSeconds()).padStart(2,'0');&#10; return (&#10; &lt;div className=&quot;aff-quais__clock&quot;&gt;&#10; &lt;div className=&quot;aff-quais__clock-box&quot;&gt;&lt;span className=&quot;aff-quais__clock-main&quot;&gt;{hh}:{mm}:{ss}&lt;/span&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10; );&#10;}&#10;&#10;export default function ClassiquesArriveesQuais(){&#10; const typeLogos = useTypeLogos();&#10;&#10; const rightRef = useRef(null);&#10; const stopsListRef = useRef(null);&#10; const railRef = useRef(null);&#10;&#10; // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée)&#10; const mock = {&#10; time: '16h44',&#10; status: &quot;à l'heure&quot;,&#10; trainType: 'Fluo',&#10; trainNumber: '891811',&#10; destinationBig: 'Seurre',&#10; destinationStation: '',&#10; stops: [&#10; 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse'&#10; ],&#10; };&#10;&#10; // état pour l'horaire sélectionné&#10; const [selectedHoraire, setSelectedHoraire] = useState(null);&#10; const [loadingHoraire, setLoadingHoraire] = useState(false);&#10; const [horaireError, setHoraireError] = useState(null);&#10; const [candidatesCount, setCandidatesCount] = useState(0);&#10; // perturbations/travaux (récupérées depuis Supabase)&#10; const [perturbations, setPerturbations] = useState([]);&#10; const [, setTravaux] = useState([]);&#10;&#10; // helpers locaux (simples) — similaires à ceux du composant principal&#10; const normalizeName = useCallback((s) =&gt; {&#10; if (!s) return '';&#10; try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); }&#10; }, []);&#10;&#10; const parseTimeHM = useCallback((s) =&gt; {&#10; if (!s) return null;&#10; if (typeof s === 'number') return s;&#10; const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10; if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10; const d = new Date(s);&#10; if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10; return null;&#10; }, []);&#10;&#10; const formatTimeNoSeconds = useCallback((v) =&gt; {&#10; if (v == null) return '';&#10; const tryDate = new Date(v);&#10; if (!isNaN(tryDate.getTime())) {&#10; const hh = String(tryDate.getHours()).padStart(2,'0');&#10; const mm = String(tryDate.getMinutes()).padStart(2,'0');&#10; return `${hh}h${mm}`;&#10; }&#10; if (typeof v === 'string') {&#10; const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);&#10; if (m) return `${String(m[1]).padStart(2,'0')}h${m[2]}`;&#10; const m2 = v.match(/T(\d{2}):(\d{2})/);&#10; if (m2) return `${m2[1]}h${m2[2]}`;&#10; return v.slice(0,5).replace(':','h');&#10; }&#10; return String(v).slice(0,5).replace(':','h');&#10; }, []);&#10;&#10; // ...existing code...&#10;&#10; const formatTimeFromHM = useCallback((totalMinutes) =&gt; {&#10; if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return '';&#10; const m = ((totalMinutes % 1440) + 1440) % 1440;&#10; const hh = String(Math.floor(m / 60)).padStart(2, '0');&#10; const mm = String(m % 60).padStart(2, '0');&#10; return `${hh}h${mm}`;&#10; }, []);&#10;&#10; // lecture des query params pour gare &amp; quai&#10; const paramsForThis = new URLSearchParams(window.location.search || '');&#10; const gareParam = paramsForThis.get('gare');&#10; const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId');&#10; const quaiParam = paramsForThis.get('quai');&#10; const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true');&#10; const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null;&#10;&#10; // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs)&#10; const getStops = (h) =&gt; {&#10; if (!h) return [];&#10; if (Array.isArray(h.stops) &amp;&amp; h.stops.length) return h.stops;&#10; if (Array.isArray(h.gares_desservies) &amp;&amp; h.gares_desservies.length) return h.gares_desservies;&#10; if (Array.isArray(h.garesDesservies) &amp;&amp; h.garesDesservies.length) return h.garesDesservies;&#10; if (Array.isArray(h.arrets) &amp;&amp; h.arrets.length) return h.arrets;&#10; const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','gares_desservies_json'];&#10; for (const key of candidates) {&#10; const raw = h[key];&#10; if (!raw) continue;&#10; if (Array.isArray(raw)) return raw;&#10; if (typeof raw === 'string') {&#10; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ }&#10; }&#10; }&#10; return [];&#10; };&#10;&#10; // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) ---&#10; const safeParseArrets = (p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return [];&#10; if (Array.isArray(raw)) return raw;&#10; try {&#10; const parsed = JSON.parse(raw);&#10; if (parsed &amp;&amp; typeof parsed === 'object') {&#10; if (Array.isArray(parsed.arrets)) return parsed.arrets;&#10; return parsed;&#10; }&#10; return [];&#10; } catch (e) { return []; }&#10; };&#10;&#10; const parsePerturbArretsObject = useCallback((p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return { arrets: [], jours: null };&#10; if (typeof raw === 'object') {&#10; if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null };&#10; if (Array.isArray(raw)) return { arrets: raw, jours: null };&#10; return { arrets: [], jours: null };&#10; }&#10; try {&#10; const parsed = JSON.parse(raw);&#10; if (parsed &amp;&amp; typeof parsed === 'object') {&#10; return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null };&#10; }&#10; return { arrets: [], jours: null };&#10; } catch (e) {&#10; return { arrets: [], jours: null };&#10; }&#10; }, []);&#10;&#10; const getPerturbationJours = useCallback((p) =&gt; {&#10; if (!p) return null;&#10; const candidate = p.jours_perturbation || p.jours || null;&#10; const result = new Set();&#10; const pushDate = (d) =&gt; {&#10; if (!d) return;&#10; try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10)); } catch(e){}&#10; };&#10; if (candidate) {&#10; if (Array.isArray(candidate)) candidate.forEach(item =&gt; pushDate(item.date || item));&#10; else if (typeof candidate === 'string') {&#10; try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); }&#10; catch (e) { pushDate(candidate); }&#10; } else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it =&gt; pushDate(it.date || it)); else pushDate(candidate.date || null); }&#10; }&#10; try {&#10; const parsedObj = parsePerturbArretsObject(p);&#10; const jp = parsedObj.jours;&#10; if (jp) {&#10; if (Array.isArray(jp)) jp.forEach(it =&gt; pushDate(it.date || it));&#10; else if (typeof jp === 'string') {&#10; try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); } catch (e) { pushDate(jp); }&#10; } else if (typeof jp === 'object') pushDate(jp.date || null);&#10; }&#10; } catch (e) {}&#10; return result.size ? result : null;&#10; }, [parsePerturbArretsObject]);&#10;&#10; const parseDelayMinutesFromArret = (arret) =&gt; {&#10; if (!arret) return null;&#10; const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min'];&#10; for (const k of keys) {&#10; if (typeof arret[k] !== 'undefined' &amp;&amp; arret[k] !== null &amp;&amp; arret[k] !== '') {&#10; const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n);&#10; }&#10; }&#10; return null;&#10; };&#10;&#10; const buildPerturbMap = useCallback((list = []) =&gt; {&#10; const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] };&#10; (list || []).forEach(p =&gt; {&#10; const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID;&#10; if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); }&#10; const number = p.number || p.train_number || p.train || p.numero || p.numero_train;&#10; if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); }&#10; const arrets = safeParseArrets(p);&#10; if (Array.isArray(arrets) &amp;&amp; arrets.length) {&#10; arrets.forEach(a =&gt; {&#10; const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code;&#10; const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10; if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); }&#10; if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); }&#10; });&#10; }&#10; if ((horaireId == null &amp;&amp; number == null &amp;&amp; (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); }&#10; });&#10; return idx;&#10; }, [normalizeName]);&#10;&#10; const findPerturbsForHoraireAndStop = (horaire, stationId, stationName, idx) =&gt; {&#10; const res = [];&#10; const map = idx || buildPerturbMap(perturbations || []);&#10; const hId = horaire &amp;&amp; (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train);&#10; const number = horaire &amp;&amp; (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number);&#10; if (hId != null) { const kh = String(hId); if (map.byHoraire &amp;&amp; map.byHoraire[kh]) res.push(...map.byHoraire[kh]); }&#10; if (number != null) { const kn = String(number); if (map.byNumber &amp;&amp; map.byNumber[kn]) res.push(...map.byNumber[kn]); }&#10; if (stationId != null) { const ks = String(stationId); if (map.byStopId &amp;&amp; map.byStopId[ks]) map.byStopId[ks].forEach(x =&gt; res.push(x.perturbation)); }&#10; if (stationName &amp;&amp; map.byStopName) {&#10; const key = normalizeName(stationName);&#10; if (map.byStopName[key]) map.byStopName[key].forEach(x =&gt; res.push(x.perturbation));&#10; try {&#10; const needle = key;&#10; for (const existingKey of Object.keys(map.byStopName || {})) {&#10; if (existingKey === needle) continue;&#10; if (existingKey.includes(needle) || needle.includes(existingKey)) { map.byStopName[existingKey].forEach(x =&gt; res.push(x.perturbation)); }&#10; }&#10; } catch (e) {}&#10; }&#10; return Array.from(new Set(res));&#10; };&#10;&#10; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) =&gt; {&#10; const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || [];&#10; const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10; let horaireYmd = toYMD(horaire &amp;&amp; (horaire._date || horaire.date || horaire.date_service || horaire.service_date));&#10; if (!horaireYmd &amp;&amp; serviceDate) horaireYmd = serviceDate;&#10; let maxDelay = null;&#10; for (const p of found) {&#10; try {&#10; if (horaireYmd) {&#10; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10; const db = toYMD(p.date_debut || p.start_date);&#10; const df = toYMD(p.date_fin || p.end_date);&#10; if (pDate &amp;&amp; pDate !== horaireYmd) continue;&#10; if (db &amp;&amp; df &amp;&amp; !(horaireYmd &gt;= db &amp;&amp; horaireYmd &lt;= df)) continue;&#10; }&#10; } catch (e) {}&#10; try {&#10; const joursSet = getPerturbationJours(p);&#10; if (joursSet &amp;&amp; horaireYmd) { if (!joursSet.has(horaireYmd)) continue; }&#10; } catch (e) {}&#10; const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };&#10; const arrets = parsedObj.arrets || [];&#10; let matched = false;&#10; for (const a of arrets) {&#10; const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code;&#10; const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10; const na = normalizeName(aName || '');&#10; const ns = normalizeName(stationName || '');&#10; const nameMatches = stationName &amp;&amp; (na === ns || na.includes(ns) || ns.includes(na));&#10; if ((stationId != null &amp;&amp; String(aId) === String(stationId)) || nameMatches) {&#10; matched = true;&#10; const t = (a.type_perturbation || a.type || '').toString().toLowerCase();&#10; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10; const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;&#10; if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10; }&#10; }&#10; }&#10; if (!matched) {&#10; const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero;&#10; const numberRef = p.number || p.train_number || p.numero || p.numero_train;&#10; const appliesByRef = ref &amp;&amp; (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero));&#10; const appliesByNumber = numberRef &amp;&amp; (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number));&#10; const isGlobal = (!Array.isArray(arrets) || arrets.length === 0) &amp;&amp; !ref &amp;&amp; !numberRef;&#10; if (appliesByRef || appliesByNumber || isGlobal) {&#10; const t = (p.type_perturbation || p.type || '').toString().toLowerCase();&#10; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10; const d = parseDelayMinutesFromArret(p) || 0;&#10; if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10; }&#10; }&#10; }&#10; }&#10; if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay };&#10; return { label: &quot;à l'heure&quot;, detail: '', delayMinutes: null };&#10; }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours, normalizeName]);&#10;&#10; // Fetch perturbations &amp; travaux from Supabase periodically&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; let inFlight = false;&#10; const fetchPerturb = async () =&gt; {&#10; if (!mounted) return;&#10; if (inFlight) return;&#10; inFlight = true;&#10; try {&#10; if (!supabase) return;&#10; const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([&#10; supabase.from('perturbations').select('*'),&#10; supabase.from('travaux').select('*')&#10; ]);&#10; if (!mounted) return;&#10; if (pError || tError) {&#10; // ignore quietly — keep previous data&#10; } else {&#10; // Filtrer les perturbations pour ne conserver que celles valides aujourd'hui&#10; const todayYmd = new Date().toLocaleDateString('en-CA');&#10; const filteredPerturb = (perturbData || []).filter(p =&gt; {&#10; try {&#10; const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10; const db = toYMD(p.date_debut || p.start_date);&#10; const df = toYMD(p.date_fin || p.end_date);&#10; if (pDate &amp;&amp; pDate !== todayYmd) return false;&#10; if (db &amp;&amp; df &amp;&amp; !(todayYmd &gt;= db &amp;&amp; todayYmd &lt;= df)) return false;&#10; try { const joursSet = getPerturbationJours(p); if (joursSet &amp;&amp; !joursSet.has(todayYmd)) return false; } catch (e) {}&#10; return true;&#10; } catch (e) { return true; }&#10; });&#10; setPerturbations(filteredPerturb || []);&#10; setTravaux(travauxData || []);&#10; }&#10; } catch (e) {&#10; // ignore&#10; } finally {&#10; inFlight = false;&#10; }&#10; };&#10; fetchPerturb();&#10; const id = setInterval(fetchPerturb, 15000);&#10; return () =&gt; { mounted = false; clearInterval(id); };&#10; }, [getPerturbationJours]);&#10;&#10; // Annotate selectedHoraire with computed status when perturbations change&#10; useEffect(() =&gt; {&#10; if (!selectedHoraire || !selectedHoraire.horaire) return;&#10; const horaire = selectedHoraire.horaire;&#10; const stop = selectedHoraire.stop || null;&#10; const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null;&#10; const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null;&#10; try {&#10; const map = buildPerturbMap(perturbations || []);&#10; const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null);&#10; // keep original scheduled time for display (_displayTime)&#10; const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure);&#10; const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null));&#10; // compute updated time (delayed) but store separately — do not override the original displayed time&#10; let updatedTime = null;&#10; if (status &amp;&amp; typeof status.delayMinutes === 'number' &amp;&amp; !Number.isNaN(status.delayMinutes) &amp;&amp; typeof originalBase === 'number') {&#10; updatedTime = formatTimeFromHM(originalBase + status.delayMinutes);&#10; }&#10; setSelectedHoraire(prev =&gt; ({ ...(prev||{}), horaire: { ...(prev &amp;&amp; prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime }));&#10; } catch (e) { /* ignore */ }&#10; }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]);&#10;&#10; // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai)&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; async function loadNextForQuai() {&#10; setLoadingHoraire(true);&#10; setHoraireError(null);&#10; setSelectedHoraire(null);&#10; if (!supabase) {&#10; setHoraireError('Supabase non initialisé');&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10; try {&#10; const { data: all, error } = await supabase.from('horaires').select('id, *');&#10; if (error) {&#10; if (mounted) setHoraireError(error.message || String(error));&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10; if (!mounted) return;&#10; if (!Array.isArray(all) || all.length === 0) {&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10;&#10; const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null;&#10; const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null;&#10;&#10; const now = new Date();&#10; const nowHM = now.getHours()*60 + now.getMinutes();&#10;&#10; const candidates = [];&#10;&#10; for (const h of all) {&#10; if (!h) continue;&#10; // build stops list (support multiple field names)&#10; const stops = getStops(h);&#10;&#10; // compute max index for terminus detection if stops contain index&#10; let maxIdx = null;&#10; if (Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const st of stops) {&#10; if (typeof st.index !== 'undefined' &amp;&amp; st.index !== null) {&#10; const n = Number(st.index);&#10; if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);&#10; }&#10; }&#10; }&#10;&#10; // Match stop by name or id (prefer stop entry over horaire-level arrival)&#10; let stopMatch = null;&#10; if (Array.isArray(stops) &amp;&amp; stops.length) {&#10; // first try to match by id if gareIdParam present&#10; if (gareIdParam) {&#10; const gid = String(gareIdParam);&#10; for (const s of stops) {&#10; const sid = s &amp;&amp; (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null);&#10; if (sid != null &amp;&amp; String(sid) === gid) { stopMatch = s; break; }&#10; }&#10; }&#10; // then try to match by normalized name&#10; if (!stopMatch &amp;&amp; normalizedTarget) {&#10; for (const s of stops) {&#10; const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || s.station_name || '');&#10; const nm = normalizeName(rawName);&#10; if (!nm) continue;&#10; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) { stopMatch = s; break; }&#10; }&#10; }&#10; }&#10;&#10; // if no stopMatch found in stops, check horaire-level gare_arrivee (arrival terminus)&#10; if (!stopMatch) {&#10; const arriveeName = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || '');&#10; if (arriveeName &amp;&amp; normalizedTarget &amp;&amp; (arriveeName === normalizedTarget || arriveeName.includes(normalizedTarget) || normalizedTarget.includes(arriveeName))) {&#10; stopMatch = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_nom: h.gare_arrivee_nom || h.gare_arrivee, gare_id: h.gare_arrivee_id || h.gare_arrivee };&#10; }&#10; }&#10;&#10; if (!stopMatch) continue; // does not arrive at the targeted station&#10;&#10; // determine platform/quai for this stop/horaire&#10; const platform = (stopMatch &amp;&amp; (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || '';&#10; const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,'');&#10; // Only include horaires that have a platform/quai for the displayed station&#10; if (!normPlatform) continue;&#10; if (targetQuai) {&#10; if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue;&#10; }&#10;&#10; // ensure we have an arrival time&#10; const stopIsObject = stopMatch &amp;&amp; typeof stopMatch === 'object';&#10; const hasArrival = stopIsObject &amp;&amp; (&#10; typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined' || typeof stopMatch.time !== 'undefined'&#10; );&#10; if (stopIsObject &amp;&amp; !hasArrival) continue;&#10;&#10; // pick time (arrival) from stopMatch or fallback to horaire-level arrival&#10; const rawTime = (hasArrival ? (stopMatch.heure_arrivee || stopMatch.time || stopMatch.heure || stopMatch.arrival_time || stopMatch.arrival) : (h.heure_arrivee || h.time || h.heure || h.arrival_time || h.arrivee));&#10; const depHM = parseTimeHM(rawTime);&#10; if (depHM == null) continue;&#10;&#10; // compute minutes until arrival (allow next-day wrap)&#10; let minutesUntil = depHM - nowHM;&#10; if (minutesUntil &lt; 0) minutesUntil = depHM + 24*60 - nowHM;&#10;&#10; candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime });&#10; }&#10;&#10; if (!candidates.length) {&#10; setCandidatesCount(0);&#10; setLoadingHoraire(false);&#10; setSelectedHoraire(null);&#10; return;&#10; }&#10;&#10; // split into today (depHM &gt; nowHM) and tomorrow (depHM &lt;= nowHM)&#10; const todayCandidates = candidates.filter(c =&gt; c.depHM &gt; nowHM).sort((a,b) =&gt; a.depHM - b.depHM);&#10; const tomorrowCandidates = candidates.filter(c =&gt; c.depHM &lt;= nowHM).sort((a,b) =&gt; a.depHM - b.depHM);&#10;&#10; let chosen = null;&#10; if (todayCandidates.length &gt; 0) chosen = todayCandidates[0];&#10; else if (tomorrowCandidates.length &gt; 0) chosen = tomorrowCandidates[0];&#10;&#10; if (mounted) {&#10; setCandidatesCount(candidates.length);&#10; setSelectedHoraire(chosen || null);&#10; }&#10; } catch (err) {&#10; console.error('Erreur supabase quai', err);&#10; if (mounted) setHoraireError(err.message || String(err));&#10; } finally {&#10; if (mounted) setLoadingHoraire(false);&#10; }&#10; }&#10;&#10; loadNextForQuai();&#10; return () =&gt; { mounted = false; };&#10; }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM, normalizeName]);&#10;&#10; // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present)&#10; const [showTime, setShowTime] = useState(true);&#10; // derive a simple status label string to use in the dependency array (avoids complex expressions)&#10; const _selectedStatusLabel = (() =&gt; {&#10; const horaireObj = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const computedLabel = horaireObj &amp;&amp; horaireObj._status &amp;&amp; horaireObj._status.label ? String(horaireObj._status.label) : null;&#10; const rawStatus = horaireObj &amp;&amp; horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status &amp;&amp; horaireObj.status.label ? String(horaireObj.status.label) : null)) : null;&#10; return String(computedLabel || rawStatus || mock.status || '').toLowerCase();&#10; })();&#10;&#10; useEffect(()=&gt;{&#10; let id = null;&#10; if (/retard|supprim/.test(_selectedStatusLabel)) {&#10; // ensure we start by showing the time&#10; setShowTime(true);&#10; id = setInterval(() =&gt; setShowTime(prev =&gt; !prev), 3000);&#10; } else {&#10; setShowTime(true);&#10; }&#10; return () =&gt; { if (id) clearInterval(id); };&#10; }, [_selectedStatusLabel, mock.status]);&#10;&#10; // compute and position the rail so it covers from first stop center to last stop center&#10; useEffect(() =&gt; {&#10; function updateRail(){&#10; const rail = railRef.current;&#10; const list = stopsListRef.current;&#10; const right = rightRef.current;&#10; if(!rail || !list || !right) return;&#10; const items = Array.from(list.querySelectorAll('li'));&#10; if(items.length === 0) return;&#10; const first = items[0];&#10; // find destination (last with dest class) or fallback to last item&#10; const last = items.find(el =&gt; el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1];&#10;&#10; const rightRect = right.getBoundingClientRect();&#10; const firstRect = first.getBoundingClientRect();&#10; const lastRect = last.getBoundingClientRect();&#10;&#10; const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top;&#10; const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top;&#10;&#10; // add small padding so rail extends a bit beyond centers&#10; const pad = 9;&#10; const top = Math.max(0, firstCenterY - pad);&#10; const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2);&#10;&#10; // compute horizontal position so dots are centered next to the text&#10; const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text');&#10; const textRect = firstText ? firstText.getBoundingClientRect() : firstRect;&#10; const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px&#10; const railWidth = 35;&#10; const left = centerX - railWidth/2;&#10;&#10; // apply styles for the rail rectangle&#10; rail.style.top = `${Math.round(top)}px`;&#10; rail.style.height = `${Math.round(height)}px`;&#10; rail.style.left = `${Math.round(left)}px`;&#10; rail.style.width = `${railWidth}px`;&#10; // remove background-image reliance and render individual dots as DOM children&#10; // clear existing dots&#10; while (rail.firstChild) rail.removeChild(rail.firstChild);&#10;&#10; // create a dot element for each item and position it exactly at the item's vertical center&#10; const railWidthComputed = parseInt(rail.style.width, 8) || railWidth;&#10; items.forEach(it =&gt; {&#10; const r = it.getBoundingClientRect();&#10; const centerY = r.top + r.height/2 - (rightRect.top + top);&#10; const dot = document.createElement('span');&#10; dot.className = 'aff-quais__rail-dot';&#10; dot.style.position = 'absolute';&#10; dot.style.left = '50%';&#10; dot.style.transform = 'translate(-50%, -50%)';&#10; dot.style.top = `${Math.round(centerY)}px`;&#10; // set dot diameter to rail width&#10; dot.style.width = `${railWidthComputed}px`;&#10; dot.style.height = `${railWidthComputed}px`;&#10; dot.style.borderRadius = '50%';&#10; dot.style.background = '#ffe600';&#10; dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset';&#10; rail.appendChild(dot);&#10; });&#10; }&#10;&#10; updateRail();&#10; window.addEventListener('resize', updateRail);&#10; const obs = new MutationObserver(updateRail);&#10; const listNode = stopsListRef.current;&#10; if(listNode) obs.observe(listNode, { childList:true, subtree:true });&#10; return ()=&gt;{&#10; window.removeEventListener('resize', updateRail);&#10; if(obs) obs.disconnect();&#10; };&#10; }, [selectedHoraire]);&#10; // Observer changement des stops via selectedHoraire&#10; // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent)&#10;&#10; const displayTimeOrStatus = () =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status;&#10; const statusObj = horaire &amp;&amp; horaire._status ? horaire._status : null;&#10; // prefer precomputed _displayTime if available&#10; const time = selectedHoraire &amp;&amp; selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time);&#10;&#10; // build canonical status label when we have computed status object&#10; let canonicalStatus = null;&#10; if (statusObj) {&#10; if (typeof statusObj.delayMinutes === 'number' &amp;&amp; !Number.isNaN(statusObj.delayMinutes)) {&#10; canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`;&#10; } else if (statusObj.label &amp;&amp; String(statusObj.label).toLowerCase().includes('supprim')) {&#10; canonicalStatus = 'supprimé';&#10; } else if (statusObj.label) {&#10; canonicalStatus = String(statusObj.label);&#10; }&#10; }&#10;&#10; const s = (canonicalStatus || statusStrFromField || '').toLowerCase();&#10; const isAlert = s.includes('retard') || s.includes('supprim');&#10; const label = canonicalStatus || statusStrFromField || '';&#10; // Always render both time and status inside a fixed-size wrapper to avoid layout shift&#10; return (&#10; &lt;span className=&quot;aff-quais__time-status-wrapper&quot; aria-live=&quot;polite&quot;&gt;&#10; &lt;span className={`aff-quais__time aff-quais__time--strict ${showTime ? 'visible' : 'hidden'}`}&gt;{time}&lt;/span&gt;&#10; &lt;span className={`aff-quais__time-status aff-quais__time-status--strict aff-quais__status--alert ${(!showTime &amp;&amp; isAlert) ? 'visible' : 'hidden'}`}&gt;{label}&lt;/span&gt;&#10; &lt;/span&gt;&#10; );&#10; }&#10;&#10; return (&#10; &lt;div className=&quot;aff-quais__container aff-quais__container--strict aff-quais__container--arrivees&quot;&gt;&#10; &lt;div className=&quot;aff-quais__left aff-quais__left--strict&quot;&gt;&#10; {/* logo (depuis le type de train) */}&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;&#10; return &lt;img src={getTypeLogo(trainType, typeLogos)} alt={trainType || 'train'} className=&quot;aff-quais__type-logo--strict&quot; /&gt;;&#10; })()}&#10;&#10; &lt;div className=&quot;aff-quais__watermark&quot; aria-hidden&gt;départ&lt;/div&gt;&#10; {/* affichage des états de chargement / erreur pour debug */}&#10; {horaireError ? &lt;div className=&quot;aff-quais__error&quot; style={{color:'crimson',fontSize:'0.9rem',marginTop:6}}&gt;{horaireError}&lt;/div&gt; : null}&#10; {loadingHoraire ? &lt;div className=&quot;aff-quais__loading&quot; style={{color:'#666',fontSize:'0.85rem',marginTop:6}}&gt;Chargement...&lt;/div&gt; : null}&#10; {debugEnabled ? (&#10; &lt;div style={{marginTop:8, fontSize:'0.78rem', color:'#333'}}&gt;&#10; &lt;div&gt;Candidates: {candidatesCount}&lt;/div&gt;&#10; {selectedHoraire ? &lt;pre style={{maxHeight:120,overflow:'auto',background:'#f7f7f7',padding:6}}&gt;{JSON.stringify(selectedHoraire, null, 2)}&lt;/pre&gt; : null}&#10; &lt;/div&gt;&#10; ) : null}&#10; {/* logo du type en haut à droite */}&#10; &lt;div className=&quot;aff-quais__row aff-quais__row--time-status&quot;&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const status = horaire ? (horaire.status || (horaire._status &amp;&amp; horaire._status.label) || mock.status) : mock.status;&#10; const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time;&#10; const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim');&#10; if (isAlert) return displayTimeOrStatus();&#10; return (&#10; &lt;&gt;&#10; &lt;span className=&quot;aff-quais__time aff-quais__time--strict&quot;&gt;{time}&lt;/span&gt;&#10; &lt;span className=&quot;aff-quais__time-status aff-quais__time-status--strict&quot;&gt;{status}&lt;/span&gt;&#10; &lt;/&gt;&#10; );&#10; })()}&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__destination aff-quais__destination--strict&quot;&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const stop = selectedHoraire &amp;&amp; selectedHoraire.stop ? selectedHoraire.stop : null;&#10; // For arrivals: show the origin of the train (gare_depart_*) as the main station label&#10; const destinationBig = horaire ? (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || '') : mock.destinationBig;&#10; const rawDestinationStation = stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation);&#10; return (&#10; &lt;&gt;&#10; &lt;div className=&quot;aff-quais__destination-line1 aff-quais__destination-line1--strict&quot;&gt;{destinationBig}&lt;/div&gt;&#10; {(rawDestinationStation &amp;&amp; destinationBig &amp;&amp; normalizeName(rawDestinationStation) !== normalizeName(destinationBig)) ? (&lt;div className=&quot;aff-quais__destination-line2 aff-quais__destination-line2--strict&quot;&gt;{rawDestinationStation}&lt;/div&gt;) : null}&#10; &lt;/&gt;&#10; );&#10; })()}&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__train-id aff-quais__train-id--strict&quot;&gt;{(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;&#10; const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber;&#10; return (&lt;&gt;{trainType}&amp;nbsp;&amp;nbsp;&lt;span className=&quot;aff-quais__train-number--strict&quot;&gt;{trainNumber}&lt;/span&gt;&lt;/&gt;);&#10; })()}&lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__separator--strict&quot; aria-hidden&gt;&lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__right aff-quais__right--strict&quot; ref={rightRef}&gt;&#10; {/* barre verticale de points (élément explicite pour fiabilité) */}&#10; &lt;div className=&quot;aff-quais__stops-rail&quot; aria-hidden ref={railRef} /&gt;&#10; &lt;ul className=&quot;aff-quais__stops-list--strict&quot; ref={stopsListRef}&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; // utiliser la helper getStops pour supporter plusieurs formats&#10; let stops = horaire ? getStops(horaire) : [];&#10; if (!Array.isArray(stops) || stops.length === 0) {&#10; // fallback parsing si la colonne est une chaîne JSON&#10; if (horaire &amp;&amp; typeof horaire.stops === 'string') {&#10; try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ }&#10; }&#10; }&#10; if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || [];&#10;&#10; // construire une liste propre en extrayant un label pour chaque stop&#10; const labeled = stops.map((stop, idx) =&gt; {&#10; let label = '';&#10; if (typeof stop === 'string') label = stop;&#10; else if (stop &amp;&amp; typeof stop === 'object') {&#10; // vérifier plusieurs clés possibles&#10; label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties &amp;&amp; (stop.properties.name || stop.properties.stop_name)) || '';&#10; // si l'objet possède une clé 'stop' qui est elle-même un objet&#10; if (!label &amp;&amp; stop.stop &amp;&amp; typeof stop.stop === 'object') {&#10; label = stop.stop.name || stop.stop.stop_name || '';&#10; }&#10; }&#10; label = String(label || '').trim();&#10; return { label, original: stop, idx };&#10; }).filter(it =&gt; it.label);&#10;&#10; if (labeled.length === 0) return null;&#10;&#10; // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop)&#10; let matchedIndex = -1;&#10; try {&#10; if (selectedHoraire &amp;&amp; selectedHoraire.stop) {&#10; const target = selectedHoraire.stop;&#10; const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || ''));&#10; const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null;&#10; for (let i = 0; i &lt; stops.length; i++) {&#10; const s = stops[i];&#10; // compare by id first if available&#10; if (targetId != null) {&#10; const sid = (s &amp;&amp; typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null;&#10; if (sid != null &amp;&amp; String(sid) === String(targetId)) { matchedIndex = i; break; }&#10; }&#10; const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s &amp;&amp; (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || '');&#10; if (name &amp;&amp; targetName &amp;&amp; name === targetName) { matchedIndex = i; break; }&#10; }&#10; }&#10; } catch (e) { /* ignore */ }&#10;&#10; // For arrivals: show the stops served BEFORE the display station (i.e. previous stops)&#10; // If we have a matchedIndex, keep only items with idx &lt; matchedIndex.&#10; // Display stops in the reverse direction relative to the route: show the farthest previous stop first&#10; let subsequent = labeled.filter(it =&gt; (matchedIndex &gt;= 0) ? (it.idx &lt; matchedIndex) : true);&#10; // sort ascending so the oldest (farthest) previous stop appears first -&gt; reverse of travel direction&#10; subsequent = subsequent.sort((a,b) =&gt; a.idx - b.idx);&#10; // Remove consecutive duplicate labels (case-insensitive) to avoid repeated identical station names&#10; if (subsequent.length &gt; 1) {&#10; const collapsed = [];&#10; for (const it of subsequent) {&#10; const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null;&#10; const cur = it.label ? String(it.label).trim().toLowerCase() : '';&#10; if (cur &amp;&amp; prev &amp;&amp; prev === cur) {&#10; // skip duplicate consecutive entry&#10; continue;&#10; }&#10; collapsed.push(it);&#10; }&#10; subsequent = collapsed;&#10; }&#10; // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue&#10; if (subsequent.length === 0) return null;&#10;&#10; return subsequent.map((item, mapIndex) =&gt; {&#10; const isLast = item.idx === (stops.length - 1);&#10; return (&#10; &lt;li key={`${item.label}-${item.idx}-${mapIndex}`} className={isLast ? 'aff-quais__stop--strict aff-quais__stop--strict-dest' : 'aff-quais__stop--strict'}&gt;&#10; &lt;span className=&quot;aff-quais__stop-bullet--strict&quot; /&gt;&#10; &lt;span className=&quot;aff-quais__stop-text--strict&quot;&gt;{item.label}&lt;/span&gt;&#10; &lt;/li&gt;&#10; );&#10; });&#10; })()}&#10; &lt;/ul&gt;&#10; &lt;/div&gt;&#10; {/* footer band same as classic departures with clock on the right */}&#10; &lt;div className=&quot;aff-quais__footer&quot; aria-hidden&gt;&#10; &lt;div className=&quot;aff-quais__footer-inner&quot;&gt;&#10; &lt;div className=&quot;aff-quais__footer-left&quot;&gt;&lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__footer-right&quot;&gt;&lt;Clock /&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;/div&gt;&#10; );&#10;}&#10;&#10;&#10;" />
</PendingDiffInfo>
</value>
</entry>
<entry key="$PROJECT_DIR$/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js">
<value>
<PendingDiffInfo>
<option name="filePath" value="$PROJECT_DIR$/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js" />
<option name="originalContent" value="import React, { useEffect, useState, useRef, useCallback } from 'react';&#10;import './ClassiquesDepartsQuais.css';&#10;import supabase from '../../../../lib/supabase';&#10;&#10;// Hook pour récupérer la map des logos depuis /type/data.json&#10;function useTypeLogos(){&#10; const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' });&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10;&#10; const load = async () =&gt; {&#10; const candidates = ['/type/quais/data-color.json', '/type/data.json'];&#10; for(const url of candidates){&#10; try{&#10; const res = await fetch(url);&#10; if(!res.ok) continue;&#10; const data = await res.json();&#10; if(!mounted) return;&#10; const logos = {};&#10; if(Array.isArray(data.logos)){&#10; data.logos.forEach(item =&gt; {&#10; // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path&#10; let file = item.file;&#10; if(!file &amp;&amp; item.path){&#10; const parts = String(item.path).split('/');&#10; file = parts[parts.length-1];&#10; }&#10; if(!file) return;&#10; const basename = file.replace(/\.[^/.]+$/, '');&#10; const semantic = basename.replace(/^logo-/, '');&#10; const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-');&#10; // Prefer color logos in the 'quais' subfolder if data came from the quais file&#10; if(url.includes('/quais/')){&#10; logos[key] = `/type/quais/${file}`;&#10; } else {&#10; logos[key] = `/type/${file}`;&#10; }&#10; });&#10; }&#10; logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png';&#10; setMap(logos);&#10; return;&#10; }catch(e){&#10; // try next candidate (ignore error and continue loop)&#10; }&#10; }&#10; // no candidate succeeded — keep fallback&#10; };&#10;&#10; load();&#10; return () =&gt; { mounted = false; };&#10; }, []);&#10; return map;&#10;}&#10;&#10;function getTypeLogo(type, TYPE_LOGOS_MAP){&#10; const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' };&#10; if(!type) return map.default;&#10; const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-');&#10; if(map[norm]) return map[norm];&#10; const keys = Object.keys(map).filter(k =&gt; k !== 'default');&#10; for(const k of keys){&#10; if(k.includes(norm) || norm.includes(k)) return map[k];&#10; }&#10; return map.default;&#10;}&#10;&#10;function Clock() {&#10; const [now, setNow] = useState(new Date());&#10; useEffect(() =&gt; {&#10; const id = setInterval(() =&gt; setNow(new Date()), 1000);&#10; return () =&gt; clearInterval(id);&#10; }, []);&#10; const hh = String(now.getHours()).padStart(2,'0');&#10; const mm = String(now.getMinutes()).padStart(2,'0');&#10; const ss = String(now.getSeconds()).padStart(2,'0');&#10; return (&#10; &lt;div className=&quot;aff-quais__clock&quot;&gt;&#10; &lt;div className=&quot;aff-quais__clock-box&quot;&gt;&lt;span className=&quot;aff-quais__clock-main&quot;&gt;{hh}:{mm}:{ss}&lt;/span&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10; );&#10;}&#10;&#10;export default function ClassiquesDepartsQuais(){&#10; const typeLogos = useTypeLogos();&#10;&#10; const rightRef = useRef(null);&#10; const stopsListRef = useRef(null);&#10; const railRef = useRef(null);&#10;&#10; // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée)&#10; const mock = {&#10; time: '16h44',&#10; status: &quot;à l'heure&quot;,&#10; trainType: 'Fluo',&#10; trainNumber: '891811',&#10; destinationBig: 'Seurre',&#10; destinationStation: '',&#10; stops: [&#10; 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse'&#10; ],&#10; };&#10;&#10; // état pour l'horaire sélectionné&#10; const [selectedHoraire, setSelectedHoraire] = useState(null);&#10; const [loadingHoraire, setLoadingHoraire] = useState(false);&#10; const [horaireError, setHoraireError] = useState(null);&#10; const [candidatesCount, setCandidatesCount] = useState(0);&#10; // perturbations/travaux (récupérées depuis Supabase)&#10; const [perturbations, setPerturbations] = useState([]);&#10; const [, setTravaux] = useState([]);&#10;&#10; // helpers locaux (simples) — similaires à ceux du composant principal&#10; const normalizeName = useCallback((s) =&gt; {&#10; if (!s) return '';&#10; try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); }&#10; }, []);&#10;&#10; const parseTimeHM = useCallback((s) =&gt; {&#10; if (!s) return null;&#10; if (typeof s === 'number') return s;&#10; const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10; if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10; const d = new Date(s);&#10; if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10; return null;&#10; }, []);&#10;&#10; const formatTimeNoSeconds = useCallback((v) =&gt; {&#10; if (v == null) return '';&#10; const tryDate = new Date(v);&#10; if (!isNaN(tryDate.getTime())) {&#10; const hh = String(tryDate.getHours()).padStart(2,'0');&#10; const mm = String(tryDate.getMinutes()).padStart(2,'0');&#10; return `${hh}h${mm}`;&#10; }&#10; if (typeof v === 'string') {&#10; const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);&#10; if (m) return `${String(m[1]).padStart(2,'0')}h${m[2]}`;&#10; const m2 = v.match(/T(\d{2}):(\d{2})/);&#10; if (m2) return `${m2[1]}h${m2[2]}`;&#10; return v.slice(0,5).replace(':','h');&#10; }&#10; return String(v).slice(0,5).replace(':','h');&#10; }, []);&#10;&#10; // ...existing code...&#10;&#10; const formatTimeFromHM = useCallback((totalMinutes) =&gt; {&#10; if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return '';&#10; const m = ((totalMinutes % 1440) + 1440) % 1440;&#10; const hh = String(Math.floor(m / 60)).padStart(2, '0');&#10; const mm = String(m % 60).padStart(2, '0');&#10; return `${hh}h${mm}`;&#10; }, []);&#10;&#10; // lecture des query params pour gare &amp; quai&#10; const paramsForThis = new URLSearchParams(window.location.search || '');&#10; const gareParam = paramsForThis.get('gare');&#10; const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId');&#10; const quaiParam = paramsForThis.get('quai');&#10; const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true');&#10; const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null;&#10;&#10; // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs)&#10; const getStops = (h) =&gt; {&#10; if (!h) return [];&#10; if (Array.isArray(h.stops) &amp;&amp; h.stops.length) return h.stops;&#10; if (Array.isArray(h.gares_desservies) &amp;&amp; h.gares_desservies.length) return h.gares_desservies;&#10; if (Array.isArray(h.garesDesservies) &amp;&amp; h.garesDesservies.length) return h.garesDesservies;&#10; if (Array.isArray(h.arrets) &amp;&amp; h.arrets.length) return h.arrets;&#10; const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','gares_desservies_json'];&#10; for (const key of candidates) {&#10; const raw = h[key];&#10; if (!raw) continue;&#10; if (Array.isArray(raw)) return raw;&#10; if (typeof raw === 'string') {&#10; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ }&#10; }&#10; }&#10; return [];&#10; };&#10;&#10; // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) ---&#10; const safeParseArrets = (p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return [];&#10; if (Array.isArray(raw)) return raw;&#10; try {&#10; const parsed = JSON.parse(raw);&#10; if (parsed &amp;&amp; typeof parsed === 'object') {&#10; if (Array.isArray(parsed.arrets)) return parsed.arrets;&#10; return parsed;&#10; }&#10; return [];&#10; } catch (e) { return []; }&#10; };&#10;&#10; const parsePerturbArretsObject = (p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return { arrets: [], jours: null };&#10; if (typeof raw === 'object') {&#10; if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null };&#10; if (Array.isArray(raw)) return { arrets: raw, jours: null };&#10; return { arrets: [], jours: null };&#10; }&#10; try {&#10; const parsed = JSON.parse(raw);&#10; if (parsed &amp;&amp; typeof parsed === 'object') {&#10; return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null };&#10; }&#10; return { arrets: [], jours: null };&#10; } catch (e) {&#10; return { arrets: [], jours: null };&#10; }&#10; };&#10;&#10; const getPerturbationJours = useCallback((p) =&gt; {&#10; if (!p) return null;&#10; const candidate = p.jours_perturbation || p.jours || null;&#10; const result = new Set();&#10; const pushDate = (d) =&gt; {&#10; if (!d) return;&#10; try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10)); } catch(e){}&#10; };&#10; if (candidate) {&#10; if (Array.isArray(candidate)) candidate.forEach(item =&gt; pushDate(item.date || item));&#10; else if (typeof candidate === 'string') {&#10; try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); }&#10; catch (e) { pushDate(candidate); }&#10; } else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it =&gt; pushDate(it.date || it)); else pushDate(candidate.date || null); }&#10; }&#10; try {&#10; const parsedObj = parsePerturbArretsObject(p);&#10; const jp = parsedObj.jours;&#10; if (jp) {&#10; if (Array.isArray(jp)) jp.forEach(it =&gt; pushDate(it.date || it));&#10; else if (typeof jp === 'string') {&#10; try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); } catch (e) { pushDate(jp); }&#10; } else if (typeof jp === 'object') pushDate(jp.date || null);&#10; }&#10; } catch (e) {}&#10; return result.size ? result : null;&#10; }, [parsePerturbArretsObject]);&#10;&#10; const parseDelayMinutesFromArret = (arret) =&gt; {&#10; if (!arret) return null;&#10; const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min'];&#10; for (const k of keys) {&#10; if (typeof arret[k] !== 'undefined' &amp;&amp; arret[k] !== null &amp;&amp; arret[k] !== '') {&#10; const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n);&#10; }&#10; }&#10; return null;&#10; };&#10;&#10; const buildPerturbMap = useCallback((list = []) =&gt; {&#10; const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] };&#10; (list || []).forEach(p =&gt; {&#10; const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID;&#10; if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); }&#10; const number = p.number || p.train_number || p.train || p.numero || p.numero_train;&#10; if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); }&#10; const arrets = safeParseArrets(p);&#10; if (Array.isArray(arrets) &amp;&amp; arrets.length) {&#10; arrets.forEach(a =&gt; {&#10; const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code;&#10; const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10; if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); }&#10; if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); }&#10; });&#10; }&#10; if ((horaireId == null &amp;&amp; number == null &amp;&amp; (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); }&#10; });&#10; return idx;&#10; }, [normalizeName]);&#10;&#10; const findPerturbsForHoraireAndStop = (horaire, stationId, stationName, idx) =&gt; {&#10; const res = [];&#10; const map = idx || buildPerturbMap(perturbations || []);&#10; const hId = horaire &amp;&amp; (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train);&#10; const number = horaire &amp;&amp; (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number);&#10; if (hId != null) { const kh = String(hId); if (map.byHoraire &amp;&amp; map.byHoraire[kh]) res.push(...map.byHoraire[kh]); }&#10; if (number != null) { const kn = String(number); if (map.byNumber &amp;&amp; map.byNumber[kn]) res.push(...map.byNumber[kn]); }&#10; if (stationId != null) { const ks = String(stationId); if (map.byStopId &amp;&amp; map.byStopId[ks]) map.byStopId[ks].forEach(x =&gt; res.push(x.perturbation)); }&#10; if (stationName &amp;&amp; map.byStopName) {&#10; const key = normalizeName(stationName);&#10; if (map.byStopName[key]) map.byStopName[key].forEach(x =&gt; res.push(x.perturbation));&#10; try {&#10; const needle = key;&#10; for (const existingKey of Object.keys(map.byStopName || {})) {&#10; if (existingKey === needle) continue;&#10; if (existingKey.includes(needle) || needle.includes(existingKey)) { map.byStopName[existingKey].forEach(x =&gt; res.push(x.perturbation)); }&#10; }&#10; } catch (e) {}&#10; }&#10; return Array.from(new Set(res));&#10; };&#10;&#10; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) =&gt; {&#10; const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || [];&#10; const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10; let horaireYmd = toYMD(horaire &amp;&amp; (horaire._date || horaire.date || horaire.date_service || horaire.service_date));&#10; if (!horaireYmd &amp;&amp; serviceDate) horaireYmd = serviceDate;&#10; let maxDelay = null;&#10; for (const p of found) {&#10; try {&#10; if (horaireYmd) {&#10; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10; const db = toYMD(p.date_debut || p.start_date);&#10; const df = toYMD(p.date_fin || p.end_date);&#10; if (pDate &amp;&amp; pDate !== horaireYmd) continue;&#10; if (db &amp;&amp; df &amp;&amp; !(horaireYmd &gt;= db &amp;&amp; horaireYmd &lt;= df)) continue;&#10; }&#10; } catch (e) {}&#10; try {&#10; const joursSet = getPerturbationJours(p);&#10; if (joursSet &amp;&amp; horaireYmd) { if (!joursSet.has(horaireYmd)) continue; }&#10; } catch (e) {}&#10; const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };&#10; const arrets = parsedObj.arrets || [];&#10; let matched = false;&#10; for (const a of arrets) {&#10; const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code;&#10; const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10; const na = normalizeName(aName || '');&#10; const ns = normalizeName(stationName || '');&#10; const nameMatches = stationName &amp;&amp; (na === ns || na.includes(ns) || ns.includes(na));&#10; if ((stationId != null &amp;&amp; String(aId) === String(stationId)) || nameMatches) {&#10; matched = true;&#10; const t = (a.type_perturbation || a.type || '').toString().toLowerCase();&#10; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10; const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;&#10; if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10; }&#10; }&#10; }&#10; if (!matched) {&#10; const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero;&#10; const numberRef = p.number || p.train_number || p.numero || p.numero_train;&#10; const appliesByRef = ref &amp;&amp; (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero));&#10; const appliesByNumber = numberRef &amp;&amp; (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number));&#10; const isGlobal = (!Array.isArray(arrets) || arrets.length === 0) &amp;&amp; !ref &amp;&amp; !numberRef;&#10; if (appliesByRef || appliesByNumber || isGlobal) {&#10; const t = (p.type_perturbation || p.type || '').toString().toLowerCase();&#10; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10; const d = parseDelayMinutesFromArret(p) || 0;&#10; if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10; }&#10; }&#10; }&#10; }&#10; if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay };&#10; return { label: &quot;à l'heure&quot;, detail: '', delayMinutes: null };&#10; }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours]);&#10;&#10; // Fetch perturbations &amp; travaux from Supabase periodically&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; let inFlight = false;&#10; const fetchPerturb = async () =&gt; {&#10; if (!mounted) return;&#10; if (inFlight) return;&#10; inFlight = true;&#10; try {&#10; if (!supabase) return;&#10; const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([&#10; supabase.from('perturbations').select('*'),&#10; supabase.from('travaux').select('*')&#10; ]);&#10; if (!mounted) return;&#10; if (pError || tError) {&#10; // ignore quietly — keep previous data&#10; } else {&#10; // Filter perturbations to those applicable today&#10; const todayYmd = new Date().toLocaleDateString('en-CA');&#10; const filteredPerturb = (perturbData || []).filter(p =&gt; {&#10; try {&#10; const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10; const db = toYMD(p.date_debut || p.start_date);&#10; const df = toYMD(p.date_fin || p.end_date);&#10; if (pDate &amp;&amp; pDate !== todayYmd) return false;&#10; if (db &amp;&amp; df &amp;&amp; !(todayYmd &gt;= db &amp;&amp; todayYmd &lt;= df)) return false;&#10; try { const joursSet = getPerturbationJours(p); if (joursSet &amp;&amp; !joursSet.has(todayYmd)) return false; } catch (e) {}&#10; return true;&#10; } catch (e) { return true; }&#10; });&#10; setPerturbations(filteredPerturb || []);&#10; setTravaux(travauxData || []);&#10; }&#10; } catch (e) {&#10; // ignore&#10; } finally {&#10; inFlight = false;&#10; }&#10; };&#10; fetchPerturb();&#10; const id = setInterval(fetchPerturb, 15000);&#10; return () =&gt; { mounted = false; clearInterval(id); };&#10; }, []);&#10;&#10; // Annotate selectedHoraire with computed status when perturbations change&#10; useEffect(() =&gt; {&#10; if (!selectedHoraire || !selectedHoraire.horaire) return;&#10; const horaire = selectedHoraire.horaire;&#10; const stop = selectedHoraire.stop || null;&#10; const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null;&#10; const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null;&#10; try {&#10; const map = buildPerturbMap(perturbations || []);&#10; const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null);&#10; // keep original scheduled time for display (_displayTime)&#10; const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure);&#10; const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null));&#10; // compute updated time (delayed) but store separately — do not override the original displayed time&#10; let updatedTime = null;&#10; if (status &amp;&amp; typeof status.delayMinutes === 'number' &amp;&amp; !Number.isNaN(status.delayMinutes) &amp;&amp; typeof originalBase === 'number') {&#10; updatedTime = formatTimeFromHM(originalBase + status.delayMinutes);&#10; }&#10; setSelectedHoraire(prev =&gt; ({ ...(prev||{}), horaire: { ...(prev &amp;&amp; prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime }));&#10; } catch (e) { /* ignore */ }&#10; }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]);&#10;&#10; // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai)&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; async function loadNextForQuai() {&#10; setLoadingHoraire(true);&#10; setHoraireError(null);&#10; setSelectedHoraire(null);&#10; if (!supabase) {&#10; setHoraireError('Supabase non initialisé');&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10; try {&#10; const { data: all, error } = await supabase.from('horaires').select('id, *');&#10; if (error) {&#10; if (mounted) setHoraireError(error.message || String(error));&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10; if (!mounted) return;&#10; if (!Array.isArray(all) || all.length === 0) {&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10;&#10; const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null;&#10; const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null;&#10;&#10; const now = new Date();&#10; const nowHM = now.getHours()*60 + now.getMinutes();&#10;&#10; const candidates = [];&#10;&#10; for (const h of all) {&#10; if (!h) continue;&#10; // build stops list (support multiple field names)&#10; const stops = getStops(h);&#10;&#10; // compute max index for terminus detection if stops contain index&#10; let maxIdx = null;&#10; if (Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const st of stops) {&#10; if (typeof st.index !== 'undefined' &amp;&amp; st.index !== null) {&#10; const n = Number(st.index);&#10; if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);&#10; }&#10; }&#10; }&#10;&#10; // find matching stop (by name or by gare_id)&#10; let stopMatch = null;&#10; // check horaire-level depart name first&#10; if (normalizedTarget) {&#10; // exclude horaires where the target gare is the final destination (terminus)&#10; const destNameNorm = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || '');&#10; if (destNameNorm &amp;&amp; (destNameNorm === normalizedTarget || destNameNorm.includes(normalizedTarget) || normalizedTarget.includes(destNameNorm))) {&#10; // gare ciblée est la gare d'arrivée finale -&gt; ne pas considérer cet horaire&#10; // skip to next horaire&#10; } else {&#10; const departName = normalizeName(h.gare_depart_nom || h.gare_depart || h.origin_name || '');&#10; if (departName &amp;&amp; (departName === normalizedTarget || departName.includes(normalizedTarget) || normalizedTarget.includes(departName))) {&#10; // créer un stopMatch initial à partir du niveau horaire&#10; let initialStop = { heure_depart: h.heure_depart || h.time || h.heure, gare_nom: h.gare_depart_nom || h.gare_depart };&#10; // si la liste stops contient un objet correspondant à la gare de départ, préférer cet objet&#10; if (Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const s of stops) {&#10; const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '');&#10; const nm = normalizeName(rawName);&#10; if (!nm) continue;&#10; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) {&#10; // prefer the stops entry (it may contain platform/quai information)&#10; // but preserve a horaire-level time if the stop entry lacks heure_depart&#10; if (typeof s === 'object') {&#10; if (!s.heure_depart &amp;&amp; initialStop.heure_depart) s.heure_depart = initialStop.heure_depart;&#10; initialStop = s;&#10; } else {&#10; // s is string name — keep initialStop but set gare_nom&#10; initialStop.gare_nom = s;&#10; }&#10; break;&#10; }&#10; }&#10; }&#10; stopMatch = initialStop;&#10; }&#10; }&#10; }&#10;&#10; // if gare_id param present, try to match by id as well&#10; if (!stopMatch &amp;&amp; gareIdParam) {&#10; const gid = String(gareIdParam);&#10; // check horaire-level id&#10; const departId = h.gare_depart_id || h.gare_id || h.origin_id || null;&#10; if (departId != null &amp;&amp; String(departId) === gid) {&#10; stopMatch = { heure_depart: h.heure_depart || h.time || h.heure, gare_id: departId, gare_nom: h.gare_depart_nom || h.gare_depart };&#10; }&#10; // check stops list ids&#10; if (!stopMatch &amp;&amp; Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const s of stops) {&#10; const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;&#10; if (sid != null &amp;&amp; String(sid) === gid) { stopMatch = s; break; }&#10; }&#10; }&#10; }&#10;&#10; // if still no match, try match by normalized name inside stops&#10; if (!stopMatch &amp;&amp; normalizedTarget &amp;&amp; Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const s of stops) {&#10; // support both string entries and object entries&#10; const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.stopName || s.station || s.station_name || '');&#10; const nm = normalizeName(rawName);&#10; if (!nm) continue;&#10; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) {&#10; // detect if this stop is a terminus (isLast / is_last / index == maxIdx)&#10; const isTerminus = Boolean((typeof s === 'object') &amp;&amp; (s.isLast || s.is_last || s.isTerminus || (typeof s.index !== 'undefined' &amp;&amp; maxIdx != null &amp;&amp; Number(s.index) === maxIdx)));&#10; if (isTerminus) continue; // ne pas considérer les terminus&#10; stopMatch = s;&#10; break;&#10; }&#10; }&#10; }&#10; if (!stopMatch) continue; // this horaire does not serve the target gare (or match failed)&#10;&#10; // determine platform/quai for this stop/horaire&#10; const platform = (stopMatch &amp;&amp; (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || '';&#10; const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,'');&#10;&#10; // if targetQuai is set, ensure it matches (loose compare: contains or equals)&#10; if (targetQuai) {&#10; if (!normPlatform) continue; // no platform info -&gt; skip&#10; if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue;&#10; }&#10;&#10; // Ne garder que les horaires qui ont un départ au stop (on ne veut pas afficher les arrivées)&#10; const stopIsObject = stopMatch &amp;&amp; typeof stopMatch === 'object';&#10; const hasDeparture = stopIsObject &amp;&amp; (&#10; typeof stopMatch.heure_depart !== 'undefined' || typeof stopMatch.departure_time !== 'undefined' || typeof stopMatch.departure !== 'undefined' || typeof stopMatch.time !== 'undefined' || typeof stopMatch.departureTime !== 'undefined' || typeof stopMatch.horaire_time !== 'undefined'&#10; );&#10; const hasArrivalOnly = stopIsObject &amp;&amp; !hasDeparture &amp;&amp; (&#10; typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined'&#10; );&#10; // si le stop correspond mais n'a que l'heure d'arrivée (pas de départ), ignorer cet horaire — on veut uniquement des départs&#10; if (stopIsObject &amp;&amp; hasArrivalOnly) continue;&#10;&#10; // pick time from stopMatch (si on a une heure de départ) ou fallback au niveau horaire&#10; const rawTime = (hasDeparture ? (stopMatch.heure_depart || stopMatch.time || stopMatch.heure || stopMatch.departure_time || stopMatch.departure || stopMatch.departureTime || stopMatch.horaire_time) : (h.heure_depart || h.time || h.heure || h.departure_time || h.departure || h.departureTime || h.horaire_time));&#10; const depHM = parseTimeHM(rawTime);&#10; if (depHM == null) continue;&#10;&#10; // compute minutes until departure (allow next-day wrap)&#10; let minutesUntil = depHM - nowHM;&#10; if (minutesUntil &lt; 0) minutesUntil = depHM + 24*60 - nowHM;&#10;&#10; candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime });&#10; }&#10;&#10; if (!candidates.length) {&#10; setCandidatesCount(0);&#10; setLoadingHoraire(false);&#10; setSelectedHoraire(null);&#10; return;&#10; }&#10;&#10; // split into today (depHM &gt; nowHM) and tomorrow (depHM &lt;= nowHM)&#10; const todayCandidates = candidates.filter(c =&gt; c.depHM &gt; nowHM).sort((a,b) =&gt; a.depHM - b.depHM);&#10; const tomorrowCandidates = candidates.filter(c =&gt; c.depHM &lt;= nowHM).sort((a,b) =&gt; a.depHM - b.depHM);&#10;&#10; let chosen = null;&#10; if (todayCandidates.length &gt; 0) chosen = todayCandidates[0];&#10; else if (tomorrowCandidates.length &gt; 0) chosen = tomorrowCandidates[0];&#10;&#10; if (mounted) {&#10; setCandidatesCount(candidates.length);&#10; setSelectedHoraire(chosen || null);&#10; }&#10; } catch (err) {&#10; console.error('Erreur supabase quai', err);&#10; if (mounted) setHoraireError(err.message || String(err));&#10; } finally {&#10; if (mounted) setLoadingHoraire(false);&#10; }&#10; }&#10;&#10; loadNextForQuai();&#10; return () =&gt; { mounted = false; };&#10; }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM]);&#10;&#10; // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present)&#10; const [showTime, setShowTime] = useState(true);&#10; // derive a simple status label string to use in the dependency array (avoids complex expressions)&#10; const _selectedStatusLabel = (() =&gt; {&#10; const horaireObj = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const computedLabel = horaireObj &amp;&amp; horaireObj._status &amp;&amp; horaireObj._status.label ? String(horaireObj._status.label) : null;&#10; const rawStatus = horaireObj &amp;&amp; horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status &amp;&amp; horaireObj.status.label ? String(horaireObj.status.label) : null)) : null;&#10; return String(computedLabel || rawStatus || mock.status || '').toLowerCase();&#10; })();&#10;&#10; useEffect(()=&gt;{&#10; let id = null;&#10; if (/retard|supprim/.test(_selectedStatusLabel)) {&#10; // ensure we start by showing the time&#10; setShowTime(true);&#10; id = setInterval(() =&gt; setShowTime(prev =&gt; !prev), 3000);&#10; } else {&#10; setShowTime(true);&#10; }&#10; return () =&gt; { if (id) clearInterval(id); };&#10; }, [_selectedStatusLabel, mock.status]);&#10;&#10; // compute and position the rail so it covers from first stop center to last stop center&#10; useEffect(() =&gt; {&#10; function updateRail(){&#10; const rail = railRef.current;&#10; const list = stopsListRef.current;&#10; const right = rightRef.current;&#10; if(!rail || !list || !right) return;&#10; const items = Array.from(list.querySelectorAll('li'));&#10; if(items.length === 0) return;&#10; const first = items[0];&#10; // find destination (last with dest class) or fallback to last item&#10; const last = items.find(el =&gt; el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1];&#10;&#10; const rightRect = right.getBoundingClientRect();&#10; const firstRect = first.getBoundingClientRect();&#10; const lastRect = last.getBoundingClientRect();&#10;&#10; const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top;&#10; const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top;&#10;&#10; // add small padding so rail extends a bit beyond centers&#10; const pad = 9;&#10; const top = Math.max(0, firstCenterY - pad);&#10; const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2);&#10;&#10; // compute horizontal position so dots are centered next to the text&#10; const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text');&#10; const textRect = firstText ? firstText.getBoundingClientRect() : firstRect;&#10; const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px&#10; const railWidth = 35;&#10; const left = centerX - railWidth/2;&#10;&#10; // apply styles for the rail rectangle&#10; rail.style.top = `${Math.round(top)}px`;&#10; rail.style.height = `${Math.round(height)}px`;&#10; rail.style.left = `${Math.round(left)}px`;&#10; rail.style.width = `${railWidth}px`;&#10; // remove background-image reliance and render individual dots as DOM children&#10; // clear existing dots&#10; while (rail.firstChild) rail.removeChild(rail.firstChild);&#10;&#10; // create a dot element for each item and position it exactly at the item's vertical center&#10; const railWidthComputed = parseInt(rail.style.width, 8) || railWidth;&#10; items.forEach(it =&gt; {&#10; const r = it.getBoundingClientRect();&#10; const centerY = r.top + r.height/2 - (rightRect.top + top);&#10; const dot = document.createElement('span');&#10; dot.className = 'aff-quais__rail-dot';&#10; dot.style.position = 'absolute';&#10; dot.style.left = '50%';&#10; dot.style.transform = 'translate(-50%, -50%)';&#10; dot.style.top = `${Math.round(centerY)}px`;&#10; // set dot diameter to rail width&#10; dot.style.width = `${railWidthComputed}px`;&#10; dot.style.height = `${railWidthComputed}px`;&#10; dot.style.borderRadius = '50%';&#10; dot.style.background = '#ffe600';&#10; dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset';&#10; rail.appendChild(dot);&#10; });&#10; }&#10;&#10; updateRail();&#10; window.addEventListener('resize', updateRail);&#10; const obs = new MutationObserver(updateRail);&#10; const listNode = stopsListRef.current;&#10; if(listNode) obs.observe(listNode, { childList:true, subtree:true });&#10; return ()=&gt;{&#10; window.removeEventListener('resize', updateRail);&#10; if(obs) obs.disconnect();&#10; };&#10; }, [selectedHoraire]);&#10; // Observer changement des stops via selectedHoraire&#10; // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent)&#10;&#10; const displayTimeOrStatus = () =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status;&#10; const statusObj = horaire &amp;&amp; horaire._status ? horaire._status : null;&#10; // prefer precomputed _displayTime if available&#10; const time = selectedHoraire &amp;&amp; selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time);&#10;&#10; // build canonical status label when we have computed status object&#10; let canonicalStatus = null;&#10; if (statusObj) {&#10; if (typeof statusObj.delayMinutes === 'number' &amp;&amp; !Number.isNaN(statusObj.delayMinutes)) {&#10; canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`;&#10; } else if (statusObj.label &amp;&amp; String(statusObj.label).toLowerCase().includes('supprim')) {&#10; canonicalStatus = 'supprimé';&#10; } else if (statusObj.label) {&#10; canonicalStatus = String(statusObj.label);&#10; }&#10; }&#10;&#10; const s = (canonicalStatus || statusStrFromField || '').toLowerCase();&#10; const isAlert = s.includes('retard') || s.includes('supprim');&#10; const label = canonicalStatus || statusStrFromField || '';&#10; // Always render both time and status inside a fixed-size wrapper to avoid layout shift&#10; return (&#10; &lt;span className=&quot;aff-quais__time-status-wrapper&quot; aria-live=&quot;polite&quot;&gt;&#10; &lt;span className={`aff-quais__time aff-quais__time--strict ${showTime ? 'visible' : 'hidden'}`}&gt;{time}&lt;/span&gt;&#10; &lt;span className={`aff-quais__time-status aff-quais__time-status--strict aff-quais__status--alert ${(!showTime &amp;&amp; isAlert) ? 'visible' : 'hidden'}`}&gt;{label}&lt;/span&gt;&#10; &lt;/span&gt;&#10; );&#10; }&#10;&#10; return (&#10; &lt;div className=&quot;aff-quais__container aff-quais__container--strict aff-quais__container--depart&quot;&gt;&#10; &lt;div className=&quot;aff-quais__left aff-quais__left--strict&quot;&gt;&#10; {/* logo (depuis le type de train) */}&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;&#10; return &lt;img src={getTypeLogo(trainType, typeLogos)} alt={trainType || 'train'} className=&quot;aff-quais__type-logo--strict&quot; /&gt;;&#10; })()}&#10;&#10; &lt;div className=&quot;aff-quais__watermark&quot; aria-hidden&gt;départ&lt;/div&gt;&#10; {/* affichage des états de chargement / erreur pour debug */}&#10; {horaireError ? &lt;div className=&quot;aff-quais__error&quot; style={{color:'crimson',fontSize:'0.9rem',marginTop:6}}&gt;{horaireError}&lt;/div&gt; : null}&#10; {loadingHoraire ? &lt;div className=&quot;aff-quais__loading&quot; style={{color:'#666',fontSize:'0.85rem',marginTop:6}}&gt;Chargement...&lt;/div&gt; : null}&#10; {debugEnabled ? (&#10; &lt;div style={{marginTop:8, fontSize:'0.78rem', color:'#333'}}&gt;&#10; &lt;div&gt;Candidates: {candidatesCount}&lt;/div&gt;&#10; {selectedHoraire ? &lt;pre style={{maxHeight:120,overflow:'auto',background:'#f7f7f7',padding:6}}&gt;{JSON.stringify(selectedHoraire, null, 2)}&lt;/pre&gt; : null}&#10; &lt;/div&gt;&#10; ) : null}&#10; {/* logo du type en haut à droite */}&#10; &lt;div className=&quot;aff-quais__row aff-quais__row--time-status&quot;&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const status = horaire ? (horaire.status || (horaire._status &amp;&amp; horaire._status.label) || mock.status) : mock.status;&#10; const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time;&#10; const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim');&#10; if (isAlert) return displayTimeOrStatus();&#10; return (&#10; &lt;&gt;&#10; &lt;span className=&quot;aff-quais__time aff-quais__time--strict&quot;&gt;{time}&lt;/span&gt;&#10; &lt;span className=&quot;aff-quais__time-status aff-quais__time-status--strict&quot;&gt;{status}&lt;/span&gt;&#10; &lt;/&gt;&#10; );&#10; })()}&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__destination aff-quais__destination--strict&quot;&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const stop = selectedHoraire &amp;&amp; selectedHoraire.stop ? selectedHoraire.stop : null;&#10; // destinationBig is the final destination of the train (gare_arrivee_*). The second line should be the station name of the stop on the route (if present) — avoid duplicating final destination.&#10; const destinationBig = horaire ? (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || '') : mock.destinationBig;&#10; const rawDestExpr = (stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation));&#10; return (&#10; &lt;&gt;&#10; &lt;div className=&quot;aff-quais__destination-line1 aff-quais__destination-line1--strict&quot;&gt;{destinationBig}&lt;/div&gt;&#10; {(rawDestExpr &amp;&amp; destinationBig &amp;&amp; normalizeName(rawDestExpr) !== normalizeName(destinationBig)) ? (&#10; &lt;div className=&quot;aff-quais__destination-line2 aff-quais__destination-line2--strict&quot;&gt;{rawDestExpr}&lt;/div&gt;&#10; ) : null}&#10; &lt;/&gt;&#10; );&#10; })()}&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__train-id aff-quais__train-id--strict&quot;&gt;{(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;&#10; const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber;&#10; return (&lt;&gt;{trainType}&amp;nbsp;&amp;nbsp;&lt;span className=&quot;aff-quais__train-number--strict&quot;&gt;{trainNumber}&lt;/span&gt;&lt;/&gt;);&#10; })()}&lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__separator--strict&quot; aria-hidden&gt;&lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__right aff-quais__right--strict&quot; ref={rightRef}&gt;&#10; {/* barre verticale de points (élément explicite pour fiabilité) */}&#10; &lt;div className=&quot;aff-quais__stops-rail&quot; aria-hidden ref={railRef} /&gt;&#10; &lt;ul className=&quot;aff-quais__stops-list--strict&quot; ref={stopsListRef}&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; // utiliser la helper getStops pour supporter plusieurs formats&#10; let stops = horaire ? getStops(horaire) : [];&#10; if (!Array.isArray(stops) || stops.length === 0) {&#10; // fallback parsing si la colonne est une chaîne JSON&#10; if (horaire &amp;&amp; typeof horaire.stops === 'string') {&#10; try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ }&#10; }&#10; }&#10; if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || [];&#10;&#10; // construire une liste propre en extrayant un label pour chaque stop&#10; const labeled = stops.map((stop, idx) =&gt; {&#10; let label = '';&#10; if (typeof stop === 'string') label = stop;&#10; else if (stop &amp;&amp; typeof stop === 'object') {&#10; // vérifier plusieurs clés possibles&#10; label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties &amp;&amp; (stop.properties.name || stop.properties.stop_name)) || '';&#10; // si l'objet possède une clé 'stop' qui est elle-même un objet&#10; if (!label &amp;&amp; stop.stop &amp;&amp; typeof stop.stop === 'object') {&#10; label = stop.stop.name || stop.stop.stop_name || '';&#10; }&#10; }&#10; label = String(label || '').trim();&#10; return { label, original: stop, idx };&#10; }).filter(it =&gt; it.label);&#10;&#10; if (labeled.length === 0) return null;&#10;&#10; // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop)&#10; let matchedIndex = -1;&#10; try {&#10; if (selectedHoraire &amp;&amp; selectedHoraire.stop) {&#10; const target = selectedHoraire.stop;&#10; const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || ''));&#10; const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null;&#10; for (let i = 0; i &lt; stops.length; i++) {&#10; const s = stops[i];&#10; // compare by id first if available&#10; if (targetId != null) {&#10; const sid = (s &amp;&amp; typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null;&#10; if (sid != null &amp;&amp; String(sid) === String(targetId)) { matchedIndex = i; break; }&#10; }&#10; const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s &amp;&amp; (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || '');&#10; if (name &amp;&amp; targetName &amp;&amp; name === targetName) { matchedIndex = i; break; }&#10; }&#10; }&#10; } catch (e) { /* ignore */ }&#10;&#10; // filtrer pour ne garder que les prochains arrêts après la gare renseignée&#10; let subsequent = labeled.filter(it =&gt; (matchedIndex &gt;= 0) ? (it.idx &gt; matchedIndex) : true);&#10; // trier par idx pour être sûr&#10; subsequent = subsequent.sort((a,b) =&gt; a.idx - b.idx);&#10; // Remove consecutive duplicate labels (case-insensitive)&#10; if (subsequent.length &gt; 1) {&#10; const collapsed = [];&#10; for (const it of subsequent) {&#10; const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null;&#10; const cur = it.label ? String(it.label).trim().toLowerCase() : '';&#10; if (cur &amp;&amp; prev &amp;&amp; prev === cur) {&#10; continue;&#10; }&#10; collapsed.push(it);&#10; }&#10; subsequent = collapsed;&#10; }&#10; // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue&#10; if (subsequent.length === 0) return null;&#10;&#10; return subsequent.map((item, mapIndex) =&gt; {&#10; const isLast = item.idx === (stops.length - 1);&#10; return (&#10; &lt;li key={`${item.label}-${item.idx}-${mapIndex}`} className={isLast ? 'aff-quais__stop--strict aff-quais__stop--strict-dest' : 'aff-quais__stop--strict'}&gt;&#10; &lt;span className=&quot;aff-quais__stop-bullet--strict&quot; /&gt;&#10; &lt;span className=&quot;aff-quais__stop-text--strict&quot;&gt;{item.label}&lt;/span&gt;&#10; &lt;/li&gt;&#10; );&#10; });&#10; })()}&#10; &lt;/ul&gt;&#10; &lt;/div&gt;&#10; {/* footer band same as classic departures with clock on the right */}&#10; &lt;div className=&quot;aff-quais__footer&quot; aria-hidden&gt;&#10; &lt;div className=&quot;aff-quais__footer-inner&quot;&gt;&#10; &lt;div className=&quot;aff-quais__footer-left&quot;&gt;&lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__footer-right&quot;&gt;&lt;Clock /&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;/div&gt;&#10; );&#10;}&#10;" />
<option name="updatedContent" value="import React, { useEffect, useState, useRef, useCallback } from 'react';&#10;import './ClassiquesDepartsQuais.css';&#10;import supabase from '../../../../lib/supabase';&#10;&#10;// Hook pour récupérer la map des logos depuis /type/data.json&#10;function useTypeLogos(){&#10; const [map, setMap] = useState({ default: '/type/quais/logo-sncf-color.svg' });&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10;&#10; const load = async () =&gt; {&#10; const candidates = ['/type/quais/data-color.json', '/type/data.json'];&#10; for(const url of candidates){&#10; try{&#10; const res = await fetch(url);&#10; if(!res.ok) continue;&#10; const data = await res.json();&#10; if(!mounted) return;&#10; const logos = {};&#10; if(Array.isArray(data.logos)){&#10; data.logos.forEach(item =&gt; {&#10; // item.file is expected (ex: logo-tgv.svg). If absent, try to extract from path&#10; let file = item.file;&#10; if(!file &amp;&amp; item.path){&#10; const parts = String(item.path).split('/');&#10; file = parts[parts.length-1];&#10; }&#10; if(!file) return;&#10; const basename = file.replace(/\.[^/.]+$/, '');&#10; const semantic = basename.replace(/^logo-/, '');&#10; const key = semantic.toUpperCase().replace(/[^A-Z0-9]+/g, '-');&#10; // Prefer color logos in the 'quais' subfolder if data came from the quais file&#10; if(url.includes('/quais/')){&#10; logos[key] = `/type/quais/${file}`;&#10; } else {&#10; logos[key] = `/type/${file}`;&#10; }&#10; });&#10; }&#10; logos.default = logos['SNCF-VOYAGEURS-LOGO'] || logos['SNFC-VOYAGEURS-LOGO'] || logos['SNCF'] || '/type/sncf-voyageurs-logo.png';&#10; setMap(logos);&#10; return;&#10; }catch(e){&#10; // try next candidate (ignore error and continue loop)&#10; }&#10; }&#10; // no candidate succeeded — keep fallback&#10; };&#10;&#10; load();&#10; return () =&gt; { mounted = false; };&#10; }, []);&#10; return map;&#10;}&#10;&#10;function getTypeLogo(type, TYPE_LOGOS_MAP){&#10; const map = TYPE_LOGOS_MAP || { default: '/type/sncf-voyageurs-logo.png' };&#10; if(!type) return map.default;&#10; const norm = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, '-');&#10; if(map[norm]) return map[norm];&#10; const keys = Object.keys(map).filter(k =&gt; k !== 'default');&#10; for(const k of keys){&#10; if(k.includes(norm) || norm.includes(k)) return map[k];&#10; }&#10; return map.default;&#10;}&#10;&#10;function Clock() {&#10; const [now, setNow] = useState(new Date());&#10; useEffect(() =&gt; {&#10; const id = setInterval(() =&gt; setNow(new Date()), 1000);&#10; return () =&gt; clearInterval(id);&#10; }, []);&#10; const hh = String(now.getHours()).padStart(2,'0');&#10; const mm = String(now.getMinutes()).padStart(2,'0');&#10; const ss = String(now.getSeconds()).padStart(2,'0');&#10; return (&#10; &lt;div className=&quot;aff-quais__clock&quot;&gt;&#10; &lt;div className=&quot;aff-quais__clock-box&quot;&gt;&lt;span className=&quot;aff-quais__clock-main&quot;&gt;{hh}:{mm}:{ss}&lt;/span&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10; );&#10;}&#10;&#10;export default function ClassiquesDepartsQuais(){&#10; const typeLogos = useTypeLogos();&#10;&#10; const rightRef = useRef(null);&#10; const stopsListRef = useRef(null);&#10; const railRef = useRef(null);&#10;&#10; // fallback mock strict (utilisé uniquement si aucune donnée réelle n'est trouvée)&#10; const mock = {&#10; time: '16h44',&#10; status: &quot;à l'heure&quot;,&#10; trainType: 'Fluo',&#10; trainNumber: '891811',&#10; destinationBig: 'Seurre',&#10; destinationStation: '',&#10; stops: [&#10; 'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse'&#10; ],&#10; };&#10;&#10; // état pour l'horaire sélectionné&#10; const [selectedHoraire, setSelectedHoraire] = useState(null);&#10; const [loadingHoraire, setLoadingHoraire] = useState(false);&#10; const [horaireError, setHoraireError] = useState(null);&#10; const [candidatesCount, setCandidatesCount] = useState(0);&#10; // perturbations/travaux (récupérées depuis Supabase)&#10; const [perturbations, setPerturbations] = useState([]);&#10; const [, setTravaux] = useState([]);&#10;&#10; // helpers locaux (simples) — similaires à ceux du composant principal&#10; const normalizeName = useCallback((s) =&gt; {&#10; if (!s) return '';&#10; try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); }&#10; }, []);&#10;&#10; const parseTimeHM = useCallback((s) =&gt; {&#10; if (!s) return null;&#10; if (typeof s === 'number') return s;&#10; const m = String(s).match(/(\d{1,2}):(\d{2})/);&#10; if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);&#10; const d = new Date(s);&#10; if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();&#10; return null;&#10; }, []);&#10;&#10; const formatTimeNoSeconds = useCallback((v) =&gt; {&#10; if (v == null) return '';&#10; const tryDate = new Date(v);&#10; if (!isNaN(tryDate.getTime())) {&#10; const hh = String(tryDate.getHours()).padStart(2,'0');&#10; const mm = String(tryDate.getMinutes()).padStart(2,'0');&#10; return `${hh}h${mm}`;&#10; }&#10; if (typeof v === 'string') {&#10; const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);&#10; if (m) return `${String(m[1]).padStart(2,'0')}h${m[2]}`;&#10; const m2 = v.match(/T(\d{2}):(\d{2})/);&#10; if (m2) return `${m2[1]}h${m2[2]}`;&#10; return v.slice(0,5).replace(':','h');&#10; }&#10; return String(v).slice(0,5).replace(':','h');&#10; }, []);&#10;&#10; // ...existing code...&#10;&#10; const formatTimeFromHM = useCallback((totalMinutes) =&gt; {&#10; if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return '';&#10; const m = ((totalMinutes % 1440) + 1440) % 1440;&#10; const hh = String(Math.floor(m / 60)).padStart(2, '0');&#10; const mm = String(m % 60).padStart(2, '0');&#10; return `${hh}h${mm}`;&#10; }, []);&#10;&#10; // lecture des query params pour gare &amp; quai&#10; const paramsForThis = new URLSearchParams(window.location.search || '');&#10; const gareParam = paramsForThis.get('gare');&#10; const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId');&#10; const quaiParam = paramsForThis.get('quai');&#10; const debugEnabled = (paramsForThis.get('debug') === '1' || String(paramsForThis.get('debug')).toLowerCase() === 'true');&#10; const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null;&#10;&#10; // helper: obtenir la liste d'arrêts d'un horaire (supporte plusieurs noms de champs)&#10; const getStops = (h) =&gt; {&#10; if (!h) return [];&#10; if (Array.isArray(h.stops) &amp;&amp; h.stops.length) return h.stops;&#10; if (Array.isArray(h.gares_desservies) &amp;&amp; h.gares_desservies.length) return h.gares_desservies;&#10; if (Array.isArray(h.garesDesservies) &amp;&amp; h.garesDesservies.length) return h.garesDesservies;&#10; if (Array.isArray(h.arrets) &amp;&amp; h.arrets.length) return h.arrets;&#10; const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','gares_desservies_json'];&#10; for (const key of candidates) {&#10; const raw = h[key];&#10; if (!raw) continue;&#10; if (Array.isArray(raw)) return raw;&#10; if (typeof raw === 'string') {&#10; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) { /* ignore */ }&#10; }&#10; }&#10; return [];&#10; };&#10;&#10; // --- Helpers pour perturbations (copiées/adaptées depuis ClassiquesDeparts) ---&#10; const safeParseArrets = (p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return [];&#10; if (Array.isArray(raw)) return raw;&#10; try {&#10; const parsed = JSON.parse(raw);&#10; if (parsed &amp;&amp; typeof parsed === 'object') {&#10; if (Array.isArray(parsed.arrets)) return parsed.arrets;&#10; return parsed;&#10; }&#10; return [];&#10; } catch (e) { return []; }&#10; };&#10;&#10; const parsePerturbArretsObject = useCallback((p) =&gt; {&#10; const raw = p &amp;&amp; (p.perturbations_arrets || p.arrets || p.arrets_perturbations);&#10; if (!raw) return { arrets: [], jours: null };&#10; if (typeof raw === 'object') {&#10; if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null };&#10; if (Array.isArray(raw)) return { arrets: raw, jours: null };&#10; return { arrets: [], jours: null };&#10; }&#10; try {&#10; const parsed = JSON.parse(raw);&#10; if (parsed &amp;&amp; typeof parsed === 'object') {&#10; return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null };&#10; }&#10; return { arrets: [], jours: null };&#10; } catch (e) {&#10; return { arrets: [], jours: null };&#10; }&#10; }, []);&#10;&#10; const getPerturbationJours = useCallback((p) =&gt; {&#10; if (!p) return null;&#10; const candidate = p.jours_perturbation || p.jours || null;&#10; const result = new Set();&#10; const pushDate = (d) =&gt; {&#10; if (!d) return;&#10; try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10)); } catch(e){}&#10; };&#10; if (candidate) {&#10; if (Array.isArray(candidate)) candidate.forEach(item =&gt; pushDate(item.date || item));&#10; else if (typeof candidate === 'string') {&#10; try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); }&#10; catch (e) { pushDate(candidate); }&#10; } else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it =&gt; pushDate(it.date || it)); else pushDate(candidate.date || null); }&#10; }&#10; try {&#10; const parsedObj = parsePerturbArretsObject(p);&#10; const jp = parsedObj.jours;&#10; if (jp) {&#10; if (Array.isArray(jp)) jp.forEach(it =&gt; pushDate(it.date || it));&#10; else if (typeof jp === 'string') {&#10; try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it =&gt; pushDate(it.date || it)); } catch (e) { pushDate(jp); }&#10; } else if (typeof jp === 'object') pushDate(jp.date || null);&#10; }&#10; } catch (e) {}&#10; return result.size ? result : null;&#10; }, [parsePerturbArretsObject]);&#10;&#10; const parseDelayMinutesFromArret = (arret) =&gt; {&#10; if (!arret) return null;&#10; const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min'];&#10; for (const k of keys) {&#10; if (typeof arret[k] !== 'undefined' &amp;&amp; arret[k] !== null &amp;&amp; arret[k] !== '') {&#10; const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n);&#10; }&#10; }&#10; return null;&#10; };&#10;&#10; const buildPerturbMap = useCallback((list = []) =&gt; {&#10; const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] };&#10; (list || []).forEach(p =&gt; {&#10; const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID;&#10; if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); }&#10; const number = p.number || p.train_number || p.train || p.numero || p.numero_train;&#10; if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); }&#10; const arrets = safeParseArrets(p);&#10; if (Array.isArray(arrets) &amp;&amp; arrets.length) {&#10; arrets.forEach(a =&gt; {&#10; const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code;&#10; const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10; if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); }&#10; if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); }&#10; });&#10; }&#10; if ((horaireId == null &amp;&amp; number == null &amp;&amp; (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); }&#10; });&#10; return idx;&#10; }, [normalizeName]);&#10;&#10; const findPerturbsForHoraireAndStop = (horaire, stationId, stationName, idx) =&gt; {&#10; const res = [];&#10; const map = idx || buildPerturbMap(perturbations || []);&#10; const hId = horaire &amp;&amp; (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train);&#10; const number = horaire &amp;&amp; (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number);&#10; if (hId != null) { const kh = String(hId); if (map.byHoraire &amp;&amp; map.byHoraire[kh]) res.push(...map.byHoraire[kh]); }&#10; if (number != null) { const kn = String(number); if (map.byNumber &amp;&amp; map.byNumber[kn]) res.push(...map.byNumber[kn]); }&#10; if (stationId != null) { const ks = String(stationId); if (map.byStopId &amp;&amp; map.byStopId[ks]) map.byStopId[ks].forEach(x =&gt; res.push(x.perturbation)); }&#10; if (stationName &amp;&amp; map.byStopName) {&#10; const key = normalizeName(stationName);&#10; if (map.byStopName[key]) map.byStopName[key].forEach(x =&gt; res.push(x.perturbation));&#10; try {&#10; const needle = key;&#10; for (const existingKey of Object.keys(map.byStopName || {})) {&#10; if (existingKey === needle) continue;&#10; if (existingKey.includes(needle) || needle.includes(existingKey)) { map.byStopName[existingKey].forEach(x =&gt; res.push(x.perturbation)); }&#10; }&#10; } catch (e) {}&#10; }&#10; return Array.from(new Set(res));&#10; };&#10;&#10; const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) =&gt; {&#10; const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || [];&#10; const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10; let horaireYmd = toYMD(horaire &amp;&amp; (horaire._date || horaire.date || horaire.date_service || horaire.service_date));&#10; if (!horaireYmd &amp;&amp; serviceDate) horaireYmd = serviceDate;&#10; let maxDelay = null;&#10; for (const p of found) {&#10; try {&#10; if (horaireYmd) {&#10; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10; const db = toYMD(p.date_debut || p.start_date);&#10; const df = toYMD(p.date_fin || p.end_date);&#10; if (pDate &amp;&amp; pDate !== horaireYmd) continue;&#10; if (db &amp;&amp; df &amp;&amp; !(horaireYmd &gt;= db &amp;&amp; horaireYmd &lt;= df)) continue;&#10; }&#10; } catch (e) {}&#10; try {&#10; const joursSet = getPerturbationJours(p);&#10; if (joursSet &amp;&amp; horaireYmd) { if (!joursSet.has(horaireYmd)) continue; }&#10; } catch (e) {}&#10; const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };&#10; const arrets = parsedObj.arrets || [];&#10; let matched = false;&#10; for (const a of arrets) {&#10; const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code;&#10; const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;&#10; const na = normalizeName(aName || '');&#10; const ns = normalizeName(stationName || '');&#10; const nameMatches = stationName &amp;&amp; (na === ns || na.includes(ns) || ns.includes(na));&#10; if ((stationId != null &amp;&amp; String(aId) === String(stationId)) || nameMatches) {&#10; matched = true;&#10; const t = (a.type_perturbation || a.type || '').toString().toLowerCase();&#10; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10; const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;&#10; if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10; }&#10; }&#10; }&#10; if (!matched) {&#10; const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero;&#10; const numberRef = p.number || p.train_number || p.numero || p.numero_train;&#10; const appliesByRef = ref &amp;&amp; (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero));&#10; const appliesByNumber = numberRef &amp;&amp; (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number));&#10; const isGlobal = (!Array.isArray(arrets) || arrets.length === 0) &amp;&amp; !ref &amp;&amp; !numberRef;&#10; if (appliesByRef || appliesByNumber || isGlobal) {&#10; const t = (p.type_perturbation || p.type || '').toString().toLowerCase();&#10; if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };&#10; if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {&#10; const d = parseDelayMinutesFromArret(p) || 0;&#10; if (maxDelay == null || d &gt; maxDelay) maxDelay = d;&#10; }&#10; }&#10; }&#10; }&#10; if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay };&#10; return { label: &quot;à l'heure&quot;, detail: '', delayMinutes: null };&#10; }, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours, normalizeName]);&#10;&#10; // Fetch perturbations &amp; travaux from Supabase periodically&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; let inFlight = false;&#10; const fetchPerturb = async () =&gt; {&#10; if (!mounted) return;&#10; if (inFlight) return;&#10; inFlight = true;&#10; try {&#10; if (!supabase) return;&#10; const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([&#10; supabase.from('perturbations').select('*'),&#10; supabase.from('travaux').select('*')&#10; ]);&#10; if (!mounted) return;&#10; if (pError || tError) {&#10; // ignore quietly — keep previous data&#10; } else {&#10; // Filter perturbations to those applicable today&#10; const todayYmd = new Date().toLocaleDateString('en-CA');&#10; const filteredPerturb = (perturbData || []).filter(p =&gt; {&#10; try {&#10; const toYMD = (v) =&gt; { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };&#10; const pDate = toYMD(p.date || p.date_service || p.start_date || p.date_debut);&#10; const db = toYMD(p.date_debut || p.start_date);&#10; const df = toYMD(p.date_fin || p.end_date);&#10; if (pDate &amp;&amp; pDate !== todayYmd) return false;&#10; if (db &amp;&amp; df &amp;&amp; !(todayYmd &gt;= db &amp;&amp; todayYmd &lt;= df)) return false;&#10; try { const joursSet = getPerturbationJours(p); if (joursSet &amp;&amp; !joursSet.has(todayYmd)) return false; } catch (e) {}&#10; return true;&#10; } catch (e) { return true; }&#10; });&#10; setPerturbations(filteredPerturb || []);&#10; setTravaux(travauxData || []);&#10; }&#10; } catch (e) {&#10; // ignore&#10; } finally {&#10; inFlight = false;&#10; }&#10; };&#10; fetchPerturb();&#10; const id = setInterval(fetchPerturb, 15000);&#10; return () =&gt; { mounted = false; clearInterval(id); };&#10; }, [getPerturbationJours]);&#10;&#10; // Annotate selectedHoraire with computed status when perturbations change&#10; useEffect(() =&gt; {&#10; if (!selectedHoraire || !selectedHoraire.horaire) return;&#10; const horaire = selectedHoraire.horaire;&#10; const stop = selectedHoraire.stop || null;&#10; const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null;&#10; const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null;&#10; try {&#10; const map = buildPerturbMap(perturbations || []);&#10; const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, null);&#10; // keep original scheduled time for display (_displayTime)&#10; const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure);&#10; const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_depart || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_depart || horaire.time || horaire.heure) : null));&#10; // compute updated time (delayed) but store separately — do not override the original displayed time&#10; let updatedTime = null;&#10; if (status &amp;&amp; typeof status.delayMinutes === 'number' &amp;&amp; !Number.isNaN(status.delayMinutes) &amp;&amp; typeof originalBase === 'number') {&#10; updatedTime = formatTimeFromHM(originalBase + status.delayMinutes);&#10; }&#10; setSelectedHoraire(prev =&gt; ({ ...(prev||{}), horaire: { ...(prev &amp;&amp; prev.horaire ? prev.horaire : {}), _status: status }, _displayTime: displayTimeOriginal, _updatedTime: updatedTime }));&#10; } catch (e) { /* ignore */ }&#10; }, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]);&#10;&#10; // récupérer depuis Supabase le prochain départ correspondant au couple (gare, quai)&#10; useEffect(() =&gt; {&#10; let mounted = true;&#10; async function loadNextForQuai() {&#10; setLoadingHoraire(true);&#10; setHoraireError(null);&#10; setSelectedHoraire(null);&#10; if (!supabase) {&#10; setHoraireError('Supabase non initialisé');&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10; try {&#10; const { data: all, error } = await supabase.from('horaires').select('id, *');&#10; if (error) {&#10; if (mounted) setHoraireError(error.message || String(error));&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10; if (!mounted) return;&#10; if (!Array.isArray(all) || all.length === 0) {&#10; setLoadingHoraire(false);&#10; return;&#10; }&#10;&#10; const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null;&#10; const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null;&#10;&#10; const now = new Date();&#10; const nowHM = now.getHours()*60 + now.getMinutes();&#10;&#10; const candidates = [];&#10;&#10; for (const h of all) {&#10; if (!h) continue;&#10; // build stops list (support multiple field names)&#10; const stops = getStops(h);&#10;&#10; // compute max index for terminus detection if stops contain index&#10; let maxIdx = null;&#10; if (Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const st of stops) {&#10; if (typeof st.index !== 'undefined' &amp;&amp; st.index !== null) {&#10; const n = Number(st.index);&#10; if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);&#10; }&#10; }&#10; }&#10;&#10; // find matching stop (by name or by gare_id)&#10; let stopMatch = null;&#10; // check horaire-level depart name first&#10; if (normalizedTarget) {&#10; // exclude horaires where the target gare is the final destination (terminus)&#10; const destNameNorm = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || '');&#10; if (destNameNorm &amp;&amp; (destNameNorm === normalizedTarget || destNameNorm.includes(normalizedTarget) || normalizedTarget.includes(destNameNorm))) {&#10; // gare ciblée est la gare d'arrivée finale -&gt; ne pas considérer cet horaire&#10; // skip to next horaire&#10; } else {&#10; const departName = normalizeName(h.gare_depart_nom || h.gare_depart || h.origin_name || '');&#10; if (departName &amp;&amp; (departName === normalizedTarget || departName.includes(normalizedTarget) || normalizedTarget.includes(departName))) {&#10; // créer un stopMatch initial à partir du niveau horaire&#10; let initialStop = { heure_depart: h.heure_depart || h.time || h.heure, gare_nom: h.gare_depart_nom || h.gare_depart };&#10; // si la liste stops contient un objet correspondant à la gare de départ, préférer cet objet&#10; if (Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const s of stops) {&#10; const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '');&#10; const nm = normalizeName(rawName);&#10; if (!nm) continue;&#10; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) {&#10; // prefer the stops entry (it may contain platform/quai information)&#10; // but preserve a horaire-level time if the stop entry lacks heure_depart&#10; if (typeof s === 'object') {&#10; if (!s.heure_depart &amp;&amp; initialStop.heure_depart) s.heure_depart = initialStop.heure_depart;&#10; initialStop = s;&#10; } else {&#10; // s is string name — keep initialStop but set gare_nom&#10; initialStop.gare_nom = s;&#10; }&#10; break;&#10; }&#10; }&#10; }&#10; stopMatch = initialStop;&#10; }&#10; }&#10; }&#10;&#10; // if gare_id param present, try to match by id as well&#10; if (!stopMatch &amp;&amp; gareIdParam) {&#10; const gid = String(gareIdParam);&#10; // check horaire-level id&#10; const departId = h.gare_depart_id || h.gare_id || h.origin_id || null;&#10; if (departId != null &amp;&amp; String(departId) === gid) {&#10; stopMatch = { heure_depart: h.heure_depart || h.time || h.heure, gare_id: departId, gare_nom: h.gare_depart_nom || h.gare_depart };&#10; }&#10; // check stops list ids&#10; if (!stopMatch &amp;&amp; Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const s of stops) {&#10; const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;&#10; if (sid != null &amp;&amp; String(sid) === gid) { stopMatch = s; break; }&#10; }&#10; }&#10; }&#10;&#10; // if still no match, try match by normalized name inside stops&#10; if (!stopMatch &amp;&amp; normalizedTarget &amp;&amp; Array.isArray(stops) &amp;&amp; stops.length) {&#10; for (const s of stops) {&#10; // support both string entries and object entries&#10; const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.stopName || s.station || s.station_name || '');&#10; const nm = normalizeName(rawName);&#10; if (!nm) continue;&#10; if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) {&#10; // detect if this stop is a terminus (isLast / is_last / index == maxIdx)&#10; const isTerminus = Boolean((typeof s === 'object') &amp;&amp; (s.isLast || s.is_last || s.isTerminus || (typeof s.index !== 'undefined' &amp;&amp; maxIdx != null &amp;&amp; Number(s.index) === maxIdx)));&#10; if (isTerminus) continue; // ne pas considérer les terminus&#10; stopMatch = s;&#10; break;&#10; }&#10; }&#10; }&#10; if (!stopMatch) continue; // this horaire does not serve the target gare (or match failed)&#10;&#10; // determine platform/quai for this stop/horaire&#10; const platform = (stopMatch &amp;&amp; (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || '';&#10; const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,'');&#10;&#10; // if targetQuai is set, ensure it matches (loose compare: contains or equals)&#10; if (targetQuai) {&#10; if (!normPlatform) continue; // no platform info -&gt; skip&#10; if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue;&#10; }&#10;&#10; // Ne garder que les horaires qui ont un départ au stop (on ne veut pas afficher les arrivées)&#10; const stopIsObject = stopMatch &amp;&amp; typeof stopMatch === 'object';&#10; const hasDeparture = stopIsObject &amp;&amp; (&#10; typeof stopMatch.heure_depart !== 'undefined' || typeof stopMatch.departure_time !== 'undefined' || typeof stopMatch.departure !== 'undefined' || typeof stopMatch.time !== 'undefined' || typeof stopMatch.departureTime !== 'undefined' || typeof stopMatch.horaire_time !== 'undefined'&#10; );&#10; const hasArrivalOnly = stopIsObject &amp;&amp; !hasDeparture &amp;&amp; (&#10; typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrivee !== 'undefined' || typeof stopMatch.arrival !== 'undefined'&#10; );&#10; // si le stop correspond mais n'a que l'heure d'arrivée (pas de départ), ignorer cet horaire — on veut uniquement des départs&#10; if (stopIsObject &amp;&amp; hasArrivalOnly) continue;&#10;&#10; // pick time from stopMatch (si on a une heure de départ) ou fallback au niveau horaire&#10; const rawTime = (hasDeparture ? (stopMatch.heure_depart || stopMatch.time || stopMatch.heure || stopMatch.departure_time || stopMatch.departure || stopMatch.departureTime || stopMatch.horaire_time) : (h.heure_depart || h.time || h.heure || h.departure_time || h.departure || h.departureTime || h.horaire_time));&#10; const depHM = parseTimeHM(rawTime);&#10; if (depHM == null) continue;&#10;&#10; // compute minutes until departure (allow next-day wrap)&#10; let minutesUntil = depHM - nowHM;&#10; if (minutesUntil &lt; 0) minutesUntil = depHM + 24*60 - nowHM;&#10;&#10; candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime });&#10; }&#10;&#10; if (!candidates.length) {&#10; setCandidatesCount(0);&#10; setLoadingHoraire(false);&#10; setSelectedHoraire(null);&#10; return;&#10; }&#10;&#10; // split into today (depHM &gt; nowHM) and tomorrow (depHM &lt;= nowHM)&#10; const todayCandidates = candidates.filter(c =&gt; c.depHM &gt; nowHM).sort((a,b) =&gt; a.depHM - b.depHM);&#10; const tomorrowCandidates = candidates.filter(c =&gt; c.depHM &lt;= nowHM).sort((a,b) =&gt; a.depHM - b.depHM);&#10;&#10; let chosen = null;&#10; if (todayCandidates.length &gt; 0) chosen = todayCandidates[0];&#10; else if (tomorrowCandidates.length &gt; 0) chosen = tomorrowCandidates[0];&#10;&#10; if (mounted) {&#10; setCandidatesCount(candidates.length);&#10; setSelectedHoraire(chosen || null);&#10; }&#10; } catch (err) {&#10; console.error('Erreur supabase quai', err);&#10; if (mounted) setHoraireError(err.message || String(err));&#10; } finally {&#10; if (mounted) setLoadingHoraire(false);&#10; }&#10; }&#10;&#10; loadNextForQuai();&#10; return () =&gt; { mounted = false; };&#10; }, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM, normalizeName]);&#10;&#10; // toggle flag to alternate time/status when needed (observe selectedHoraire.status when present)&#10; const [showTime, setShowTime] = useState(true);&#10; // derive a simple status label string to use in the dependency array (avoids complex expressions)&#10; const _selectedStatusLabel = (() =&gt; {&#10; const horaireObj = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const computedLabel = horaireObj &amp;&amp; horaireObj._status &amp;&amp; horaireObj._status.label ? String(horaireObj._status.label) : null;&#10; const rawStatus = horaireObj &amp;&amp; horaireObj.status ? (typeof horaireObj.status === 'string' ? horaireObj.status : (horaireObj.status &amp;&amp; horaireObj.status.label ? String(horaireObj.status.label) : null)) : null;&#10; return String(computedLabel || rawStatus || mock.status || '').toLowerCase();&#10; })();&#10;&#10; useEffect(()=&gt;{&#10; let id = null;&#10; if (/retard|supprim/.test(_selectedStatusLabel)) {&#10; // ensure we start by showing the time&#10; setShowTime(true);&#10; id = setInterval(() =&gt; setShowTime(prev =&gt; !prev), 3000);&#10; } else {&#10; setShowTime(true);&#10; }&#10; return () =&gt; { if (id) clearInterval(id); };&#10; }, [_selectedStatusLabel, mock.status]);&#10;&#10; // compute and position the rail so it covers from first stop center to last stop center&#10; useEffect(() =&gt; {&#10; function updateRail(){&#10; const rail = railRef.current;&#10; const list = stopsListRef.current;&#10; const right = rightRef.current;&#10; if(!rail || !list || !right) return;&#10; const items = Array.from(list.querySelectorAll('li'));&#10; if(items.length === 0) return;&#10; const first = items[0];&#10; // find destination (last with dest class) or fallback to last item&#10; const last = items.find(el =&gt; el.classList.contains('aff-quais__stop--strict-dest')) || items[items.length-1];&#10;&#10; const rightRect = right.getBoundingClientRect();&#10; const firstRect = first.getBoundingClientRect();&#10; const lastRect = last.getBoundingClientRect();&#10;&#10; const firstCenterY = firstRect.top + firstRect.height/4 - rightRect.top;&#10; const lastCenterY = lastRect.top + lastRect.height/2 - rightRect.top;&#10;&#10; // add small padding so rail extends a bit beyond centers&#10; const pad = 9;&#10; const top = Math.max(0, firstCenterY - pad);&#10; const height = Math.max(8, (lastCenterY - firstCenterY) + pad*2);&#10;&#10; // compute horizontal position so dots are centered next to the text&#10; const firstText = first.querySelector('.aff-quais__stop-text--strict') || first.querySelector('.aff-quais__stop-text');&#10; const textRect = firstText ? firstText.getBoundingClientRect() : firstRect;&#10; const centerX = (textRect.left - rightRect.left) - 28; // place rail left of text by ~20px&#10; const railWidth = 35;&#10; const left = centerX - railWidth/2;&#10;&#10; // apply styles for the rail rectangle&#10; rail.style.top = `${Math.round(top)}px`;&#10; rail.style.height = `${Math.round(height)}px`;&#10; rail.style.left = `${Math.round(left)}px`;&#10; rail.style.width = `${railWidth}px`;&#10; // remove background-image reliance and render individual dots as DOM children&#10; // clear existing dots&#10; while (rail.firstChild) rail.removeChild(rail.firstChild);&#10;&#10; // create a dot element for each item and position it exactly at the item's vertical center&#10; const railWidthComputed = parseInt(rail.style.width, 8) || railWidth;&#10; items.forEach(it =&gt; {&#10; const r = it.getBoundingClientRect();&#10; const centerY = r.top + r.height/2 - (rightRect.top + top);&#10; const dot = document.createElement('span');&#10; dot.className = 'aff-quais__rail-dot';&#10; dot.style.position = 'absolute';&#10; dot.style.left = '50%';&#10; dot.style.transform = 'translate(-50%, -50%)';&#10; dot.style.top = `${Math.round(centerY)}px`;&#10; // set dot diameter to rail width&#10; dot.style.width = `${railWidthComputed}px`;&#10; dot.style.height = `${railWidthComputed}px`;&#10; dot.style.borderRadius = '50%';&#10; dot.style.background = '#ffe600';&#10; dot.style.boxShadow = '0 0 0 4px rgba(255,230,102,0.06) inset';&#10; rail.appendChild(dot);&#10; });&#10; }&#10;&#10; updateRail();&#10; window.addEventListener('resize', updateRail);&#10; const obs = new MutationObserver(updateRail);&#10; const listNode = stopsListRef.current;&#10; if(listNode) obs.observe(listNode, { childList:true, subtree:true });&#10; return ()=&gt;{&#10; window.removeEventListener('resize', updateRail);&#10; if(obs) obs.disconnect();&#10; };&#10; }, [selectedHoraire]);&#10; // Observer changement des stops via selectedHoraire&#10; // Re-run rail effect lorsque selectedHoraire change (les stops affichés changent)&#10;&#10; const displayTimeOrStatus = () =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const statusStrFromField = horaire ? (horaire.status || mock.status) : mock.status;&#10; const statusObj = horaire &amp;&amp; horaire._status ? horaire._status : null;&#10; // prefer precomputed _displayTime if available&#10; const time = selectedHoraire &amp;&amp; selectedHoraire._displayTime ? selectedHoraire._displayTime : (horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time);&#10;&#10; // build canonical status label when we have computed status object&#10; let canonicalStatus = null;&#10; if (statusObj) {&#10; if (typeof statusObj.delayMinutes === 'number' &amp;&amp; !Number.isNaN(statusObj.delayMinutes)) {&#10; canonicalStatus = `retardé +${Math.round(statusObj.delayMinutes)} min.`;&#10; } else if (statusObj.label &amp;&amp; String(statusObj.label).toLowerCase().includes('supprim')) {&#10; canonicalStatus = 'supprimé';&#10; } else if (statusObj.label) {&#10; canonicalStatus = String(statusObj.label);&#10; }&#10; }&#10;&#10; const s = (canonicalStatus || statusStrFromField || '').toLowerCase();&#10; const isAlert = s.includes('retard') || s.includes('supprim');&#10; const label = canonicalStatus || statusStrFromField || '';&#10; // Always render both time and status inside a fixed-size wrapper to avoid layout shift&#10; return (&#10; &lt;span className=&quot;aff-quais__time-status-wrapper&quot; aria-live=&quot;polite&quot;&gt;&#10; &lt;span className={`aff-quais__time aff-quais__time--strict ${showTime ? 'visible' : 'hidden'}`}&gt;{time}&lt;/span&gt;&#10; &lt;span className={`aff-quais__time-status aff-quais__time-status--strict aff-quais__status--alert ${(!showTime &amp;&amp; isAlert) ? 'visible' : 'hidden'}`}&gt;{label}&lt;/span&gt;&#10; &lt;/span&gt;&#10; );&#10; }&#10;&#10; return (&#10; &lt;div className=&quot;aff-quais__container aff-quais__container--strict aff-quais__container--depart&quot;&gt;&#10; &lt;div className=&quot;aff-quais__left aff-quais__left--strict&quot;&gt;&#10; {/* logo (depuis le type de train) */}&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;&#10; return &lt;img src={getTypeLogo(trainType, typeLogos)} alt={trainType || 'train'} className=&quot;aff-quais__type-logo--strict&quot; /&gt;;&#10; })()}&#10;&#10; &lt;div className=&quot;aff-quais__watermark&quot; aria-hidden&gt;départ&lt;/div&gt;&#10; {/* affichage des états de chargement / erreur pour debug */}&#10; {horaireError ? &lt;div className=&quot;aff-quais__error&quot; style={{color:'crimson',fontSize:'0.9rem',marginTop:6}}&gt;{horaireError}&lt;/div&gt; : null}&#10; {loadingHoraire ? &lt;div className=&quot;aff-quais__loading&quot; style={{color:'#666',fontSize:'0.85rem',marginTop:6}}&gt;Chargement...&lt;/div&gt; : null}&#10; {debugEnabled ? (&#10; &lt;div style={{marginTop:8, fontSize:'0.78rem', color:'#333'}}&gt;&#10; &lt;div&gt;Candidates: {candidatesCount}&lt;/div&gt;&#10; {selectedHoraire ? &lt;pre style={{maxHeight:120,overflow:'auto',background:'#f7f7f7',padding:6}}&gt;{JSON.stringify(selectedHoraire, null, 2)}&lt;/pre&gt; : null}&#10; &lt;/div&gt;&#10; ) : null}&#10; {/* logo du type en haut à droite */}&#10; &lt;div className=&quot;aff-quais__row aff-quais__row--time-status&quot;&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const status = horaire ? (horaire.status || (horaire._status &amp;&amp; horaire._status.label) || mock.status) : mock.status;&#10; const time = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_depart || horaire.time || horaire.heure) : mock.time;&#10; const isAlert = (status || '').toLowerCase().includes('retard') || (status || '').toLowerCase().includes('supprim');&#10; if (isAlert) return displayTimeOrStatus();&#10; return (&#10; &lt;&gt;&#10; &lt;span className=&quot;aff-quais__time aff-quais__time--strict&quot;&gt;{time}&lt;/span&gt;&#10; &lt;span className=&quot;aff-quais__time-status aff-quais__time-status--strict&quot;&gt;{status}&lt;/span&gt;&#10; &lt;/&gt;&#10; );&#10; })()}&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__destination aff-quais__destination--strict&quot;&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const stop = selectedHoraire &amp;&amp; selectedHoraire.stop ? selectedHoraire.stop : null;&#10; // destinationBig is the final destination of the train (gare_arrivee_*). The second line should be the station name of the stop on the route (if present) — avoid duplicating final destination.&#10; const destinationBig = horaire ? (horaire.gare_arrivee_nom || horaire.gare_arrivee || horaire.destination || '') : mock.destinationBig;&#10; const rawDestExpr = (stop ? ((typeof stop === 'string') ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : (horaire ? (horaire.destination_station || '') : mock.destinationStation));&#10; return (&#10; &lt;&gt;&#10; &lt;div className=&quot;aff-quais__destination-line1 aff-quais__destination-line1--strict&quot;&gt;{destinationBig}&lt;/div&gt;&#10; {(rawDestExpr &amp;&amp; destinationBig &amp;&amp; normalizeName(rawDestExpr) !== normalizeName(destinationBig)) ? (&#10; &lt;div className=&quot;aff-quais__destination-line2 aff-quais__destination-line2--strict&quot;&gt;{rawDestExpr}&lt;/div&gt;&#10; ) : null}&#10; &lt;/&gt;&#10; );&#10; })()}&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__train-id aff-quais__train-id--strict&quot;&gt;{(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;&#10; const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber;&#10; return (&lt;&gt;{trainType}&amp;nbsp;&amp;nbsp;&lt;span className=&quot;aff-quais__train-number--strict&quot;&gt;{trainNumber}&lt;/span&gt;&lt;/&gt;);&#10; })()}&lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__separator--strict&quot; aria-hidden&gt;&lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__right aff-quais__right--strict&quot; ref={rightRef}&gt;&#10; {/* barre verticale de points (élément explicite pour fiabilité) */}&#10; &lt;div className=&quot;aff-quais__stops-rail&quot; aria-hidden ref={railRef} /&gt;&#10; &lt;ul className=&quot;aff-quais__stops-list--strict&quot; ref={stopsListRef}&gt;&#10; {(() =&gt; {&#10; const horaire = selectedHoraire &amp;&amp; selectedHoraire.horaire ? selectedHoraire.horaire : null;&#10; // utiliser la helper getStops pour supporter plusieurs formats&#10; let stops = horaire ? getStops(horaire) : [];&#10; if (!Array.isArray(stops) || stops.length === 0) {&#10; // fallback parsing si la colonne est une chaîne JSON&#10; if (horaire &amp;&amp; typeof horaire.stops === 'string') {&#10; try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ }&#10; }&#10; }&#10; if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || [];&#10;&#10; // construire une liste propre en extrayant un label pour chaque stop&#10; const labeled = stops.map((stop, idx) =&gt; {&#10; let label = '';&#10; if (typeof stop === 'string') label = stop;&#10; else if (stop &amp;&amp; typeof stop === 'object') {&#10; // vérifier plusieurs clés possibles&#10; label = stop.gare_nom || stop.nom || stop.name || stop.stop_name || stop.stopName || stop.station_name || stop.station || stop.label || stop.stop || stop.name_gare || (stop.properties &amp;&amp; (stop.properties.name || stop.properties.stop_name)) || '';&#10; // si l'objet possède une clé 'stop' qui est elle-même un objet&#10; if (!label &amp;&amp; stop.stop &amp;&amp; typeof stop.stop === 'object') {&#10; label = stop.stop.name || stop.stop.stop_name || '';&#10; }&#10; }&#10; label = String(label || '').trim();&#10; return { label, original: stop, idx };&#10; }).filter(it =&gt; it.label);&#10;&#10; if (labeled.length === 0) return null;&#10;&#10; // trouver l'index du stop correspondant à la gare demandée (selectedHoraire.stop)&#10; let matchedIndex = -1;&#10; try {&#10; if (selectedHoraire &amp;&amp; selectedHoraire.stop) {&#10; const target = selectedHoraire.stop;&#10; const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || ''));&#10; const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null;&#10; for (let i = 0; i &lt; stops.length; i++) {&#10; const s = stops[i];&#10; // compare by id first if available&#10; if (targetId != null) {&#10; const sid = (s &amp;&amp; typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null;&#10; if (sid != null &amp;&amp; String(sid) === String(targetId)) { matchedIndex = i; break; }&#10; }&#10; const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s &amp;&amp; (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || '');&#10; if (name &amp;&amp; targetName &amp;&amp; name === targetName) { matchedIndex = i; break; }&#10; }&#10; }&#10; } catch (e) { /* ignore */ }&#10;&#10; // filtrer pour ne garder que les prochains arrêts après la gare renseignée&#10; let subsequent = labeled.filter(it =&gt; (matchedIndex &gt;= 0) ? (it.idx &gt; matchedIndex) : true);&#10; // trier par idx pour être sûr&#10; subsequent = subsequent.sort((a,b) =&gt; a.idx - b.idx);&#10; // Remove consecutive duplicate labels (case-insensitive)&#10; if (subsequent.length &gt; 1) {&#10; const collapsed = [];&#10; for (const it of subsequent) {&#10; const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null;&#10; const cur = it.label ? String(it.label).trim().toLowerCase() : '';&#10; if (cur &amp;&amp; prev &amp;&amp; prev === cur) {&#10; continue;&#10; }&#10; collapsed.push(it);&#10; }&#10; subsequent = collapsed;&#10; }&#10; // si aucun arrêt après (ou pas de match) on peut retourner null ou le fallback; ici on renvoie le fallback si tout échoue&#10; if (subsequent.length === 0) return null;&#10;&#10; return subsequent.map((item, mapIndex) =&gt; {&#10; const isLast = item.idx === (stops.length - 1);&#10; return (&#10; &lt;li key={`${item.label}-${item.idx}-${mapIndex}`} className={isLast ? 'aff-quais__stop--strict aff-quais__stop--strict-dest' : 'aff-quais__stop--strict'}&gt;&#10; &lt;span className=&quot;aff-quais__stop-bullet--strict&quot; /&gt;&#10; &lt;span className=&quot;aff-quais__stop-text--strict&quot;&gt;{item.label}&lt;/span&gt;&#10; &lt;/li&gt;&#10; );&#10; });&#10; })()}&#10; &lt;/ul&gt;&#10; &lt;/div&gt;&#10; {/* footer band same as classic departures with clock on the right */}&#10; &lt;div className=&quot;aff-quais__footer&quot; aria-hidden&gt;&#10; &lt;div className=&quot;aff-quais__footer-inner&quot;&gt;&#10; &lt;div className=&quot;aff-quais__footer-left&quot;&gt;&lt;/div&gt;&#10; &lt;div className=&quot;aff-quais__footer-right&quot;&gt;&lt;Clock /&gt;&lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;/div&gt;&#10; &lt;/div&gt;&#10; );&#10;}&#10;" />
</PendingDiffInfo>
</value>
</entry>
</map>
</option>
</component>
</project>