Files
ferrovia-infogare/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js
T
Ferrovia ad999aa44f V2.5 : VOICI LES MODIFICATIONS MAJEURS APPRTÉES DANS CETTE VERSION :
-> (BÉTA) : SYSTÈME D'ANNONCES EN GARE VIA UN MODEL IA DE SIMONE HERAULT.
2026-07-02 22:22:37 +02:00

913 lines
43 KiB
JavaScript

import React, { useEffect, useState, useCallback } from 'react';
import './ClassiquesArriveesQuais.css';
import supabase from '../../../../lib/supabase';
import useAnnonceSonoreRealtime from '../../../../hooks/useAnnonceSonoreRealtime';
import useAutoAnnonce from '../../../../hooks/useAutoAnnonce';
function Clock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const hh = String(now.getHours()).padStart(2,'0');
const mm = String(now.getMinutes()).padStart(2,'0');
const ss = String(now.getSeconds()).padStart(2,'0');
return (
<div className="aff-quais-arrivees-redesign__clock">
<span>{hh}</span>
<span>{mm}</span>
<span className="aff-quais-arrivees-redesign__clock-s">{ss}</span>
</div>
);
}
export default function ClassiquesArriveesQuais() {
const paramsForThis = new URLSearchParams(window.location.search || '');
const gareParam = paramsForThis.get('gare');
const gareIdParam = paramsForThis.get('gare_id') || paramsForThis.get('gareId');
const quaiParam = paramsForThis.get('quai');
const gareDisplayParam = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g,' ')).replace(/-/g,' ') : null;
const mock = {
time: '16h44',
status: "à l'heure",
trainType: 'Fluo',
trainNumber: '891811',
destinationBig: 'Seurre',
destinationStation: '',
stops: [
'Ouges','Saulon','Longecourt','Aiserey','Brazey-en-Plaine','Saint-Jean-de-Losne','Chaugey','Pagny','Seurre','Mervans','Louhans','Saint-Amour','Bourg-en-Bresse'
],
};
const [selectedHoraire, setSelectedHoraire] = useState(null);
const [loadingHoraire, setLoadingHoraire] = useState(false);
const [, setHoraireError] = useState(null);
const [, setCandidatesCount] = useState(0);
const [stationType, setStationType] = useState(null);
const [currentTime, setCurrentTime] = useState(new Date());
const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null);
useAnnonceSonoreRealtime(resolvedGareId);
useAutoAnnonce(selectedHoraire ? [selectedHoraire] : [], currentTime, true);
const [alternateDelayPhase, setAlternateDelayPhase] = useState(true);
const [alternateCancelPhase, setAlternateCancelPhase] = useState(true);
useEffect(() => {
const id = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
const interval = setInterval(() => {
setAlternateDelayPhase(prev => !prev);
}, 5000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const interval = setInterval(() => {
setAlternateCancelPhase(prev => !prev);
}, 5000);
return () => clearInterval(interval);
}, []);
const [perturbations, setPerturbations] = useState([]);
const [, setTravaux] = useState([]);
const weekdayNameForColumn = useCallback((d) => {
const i = d.getDay();
switch(i) {
case 1: return 'circule_lundi';
case 2: return 'circule_mardi';
case 3: return 'circule_mercredi';
case 4: return 'circule_jeudi';
case 5: return 'circule_vendredi';
case 6: return 'circule_samedi';
case 0: return 'circule_dimanche';
default: return null;
}
}, []);
const isPublicHoliday = useCallback((d) => {
const y = d.getFullYear();
const m = d.getMonth() + 1;
const day = d.getDate();
const s = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const holidays = [
`${y}-01-01`,
`${y}-05-01`,
`${y}-05-08`,
`${y}-07-14`,
`${y}-08-15`,
`${y}-11-01`,
`${y}-11-11`,
`${y}-12-25`,
];
if (y === 2026) {
holidays.push('2026-04-06');
holidays.push('2026-05-14');
holidays.push('2026-05-25');
}
return holidays.includes(s);
}, []);
const circulatesOn = useCallback((h, d) => {
if (isPublicHoliday(d)) {
const v = h['circule_jours_feries'];
if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v === 1;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes';
}
return (typeof v === 'undefined' || v === null) ? false : Boolean(v);
}
const col = weekdayNameForColumn(d);
if (!col) return true;
const v = h[col];
if (typeof v === 'boolean') return v;
if (typeof v === 'number') return v === 1;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
return s === 'true' || s === '1' || s === 't' || s === 'y' || s === 'yes';
}
return (typeof v === 'undefined' || v === null) ? true : Boolean(v);
}, [isPublicHoliday, weekdayNameForColumn]);
const normalizeName = useCallback((s) => {
if (!s) return '';
try { return String(s).normalize('NFD').replace(/\p{M}/gu,'').toLowerCase().trim().replace(/[-_]+/g,' ').replace(/\s+/g,' '); } catch (e) { return String(s).toLowerCase().trim(); }
}, []);
const parseTimeHM = useCallback((s) => {
if (!s) return null;
if (typeof s === 'number') return s;
const m = String(s).match(/(\d{1,2}):(\d{2})/);
if (m) return parseInt(m[1],10)*60 + parseInt(m[2],10);
const d = new Date(s);
if (!isNaN(d.getTime())) return d.getHours()*60 + d.getMinutes();
return null;
}, []);
const formatTimeNoSeconds = useCallback((v) => {
if (v == null) return '';
const tryDate = new Date(v);
if (!isNaN(tryDate.getTime())) {
const hh = String(tryDate.getHours()).padStart(2,'0');
const mm = String(tryDate.getMinutes()).padStart(2,'0');
return `${hh}h${mm}`;
}
if (typeof v === 'string') {
const m = v.match(/(\d{1,2}):(\d{2})(?::\d{2})?/);
if (m) return `${String(m[1]).padStart(2,'0')}h${m[2]}`;
const m2 = v.match(/T(\d{2}):(\d{2})/);
if (m2) return `${m2[1]}h${m2[2]}`;
return v.slice(0,5).replace(':','h');
}
return String(v).slice(0,5).replace(':','h');
}, []);
const formatTimeFromHM = useCallback((totalMinutes) => {
if (typeof totalMinutes !== 'number' || isNaN(totalMinutes)) return '';
const m = ((totalMinutes % 1440) + 1440) % 1440;
const hh = String(Math.floor(m / 60)).padStart(2, '0');
const mm = String(m % 60).padStart(2, '0');
return `${hh}h${mm}`;
}, []);
const thresholdForStationType = useCallback((type) => {
if (!type) return 0;
const t = String(type).trim().toLowerCase();
if (t === 'interurbaine' || t.includes('interurb')) return 360;
if (t === 'ville' || t.includes('ville')) return 30;
return 0;
}, []);
const getStops = (h) => {
if (!h) return [];
if (Array.isArray(h.stops) && h.stops.length) return h.stops;
if (Array.isArray(h.gares_desservies) && h.gares_desservies.length) return h.gares_desservies;
if (Array.isArray(h.garesDesservies) && h.garesDesservies.length) return h.garesDesservies;
if (Array.isArray(h.arrets) && h.arrets.length) return h.arrets;
const candidates = ['stops','gares_desservies','garesDesservies','gares_desservies_json','arrets','gares_desservies_json'];
for (const key of candidates) {
const raw = h[key];
if (!raw) continue;
if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') {
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; } catch (e) {}
}
}
return [];
};
const safeParseArrets = (p) => {
const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations);
if (!raw) return [];
if (Array.isArray(raw)) return raw;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
if (Array.isArray(parsed.arrets)) return parsed.arrets;
return parsed;
}
return [];
} catch (e) { return []; }
};
const parsePerturbArretsObject = useCallback((p) => {
const raw = p && (p.perturbations_arrets || p.arrets || p.arrets_perturbations);
if (!raw) return { arrets: [], jours: null };
if (typeof raw === 'object') {
if (Array.isArray(raw.arrets)) return { arrets: raw.arrets, jours: raw.jours_perturbation || raw.jours || null };
if (Array.isArray(raw)) return { arrets: raw, jours: null };
return { arrets: [], jours: null };
}
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return { arrets: Array.isArray(parsed.arrets) ? parsed.arrets : (Array.isArray(parsed) ? parsed : []), jours: parsed.jours_perturbation || parsed.jours || null };
}
return { arrets: [], jours: null };
} catch (e) {
return { arrets: [], jours: null };
}
}, []);
const getPerturbationJours = useCallback((p) => {
if (!p) return null;
const candidate = p.jours_perturbation || p.jours || null;
const result = new Set();
const pushDate = (d) => {
if (!d) return;
try { const dd = new Date(d); if (!isNaN(dd.getTime())) result.add(dd.toISOString().slice(0,10)); } catch(e){}
};
if (candidate) {
if (Array.isArray(candidate)) candidate.forEach(item => pushDate(item.date || item));
else if (typeof candidate === 'string') {
try { const parsed = JSON.parse(candidate); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); }
catch (e) { pushDate(candidate); }
} else if (typeof candidate === 'object') { if (Array.isArray(candidate)) candidate.forEach(it => pushDate(it.date || it)); else pushDate(candidate.date || null); }
}
try {
const parsedObj = parsePerturbArretsObject(p);
const jp = parsedObj.jours;
if (jp) {
if (Array.isArray(jp)) jp.forEach(it => pushDate(it.date || it));
else if (typeof jp === 'string') {
try { const parsed = JSON.parse(jp); if (Array.isArray(parsed)) parsed.forEach(it => pushDate(it.date || it)); } catch (e) { pushDate(jp); }
} else if (typeof jp === 'object') pushDate(jp.date || null);
}
} catch (e) {}
return result.size ? result : null;
}, [parsePerturbArretsObject]);
const parseDelayMinutesFromArret = useCallback((arret) => {
if (!arret) return null;
const keys = ['retard_minutes','retard','delay_minutes','delay','retard_min'];
for (const k of keys) {
if (typeof arret[k] !== 'undefined' && arret[k] !== null && arret[k] !== '') {
const n = Number(arret[k]); if (!isNaN(n)) return Math.round(n);
}
}
return null;
}, []);
const buildPerturbMap = useCallback((list = []) => {
const idx = { byHoraire: {}, byNumber: {}, byStopId: {}, byStopName: {}, global: [] };
(list || []).forEach(p => {
const horaireId = p.horaire_id || p.horaire || p.horaireId || p.horaireID;
if (horaireId != null) { const k = String(horaireId); idx.byHoraire[k] = idx.byHoraire[k] || []; idx.byHoraire[k].push(p); }
const number = p.number || p.train_number || p.train || p.numero || p.numero_train;
if (number != null) { const kn = String(number); idx.byNumber[kn] = idx.byNumber[kn] || []; idx.byNumber[kn].push(p); }
const arrets = safeParseArrets(p);
if (Array.isArray(arrets) && arrets.length) {
arrets.forEach(a => {
const stopId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code_uic || a.gare_uic || a.code;
const stopName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;
if (stopId != null) { const ks = String(stopId); idx.byStopId[ks] = idx.byStopId[ks] || []; idx.byStopId[ks].push({ perturbation: p, arret: a }); }
if (stopName) { const key = normalizeName(stopName); idx.byStopName[key] = idx.byStopName[key] || []; idx.byStopName[key].push({ perturbation: p, arret: a }); }
});
}
if ((horaireId == null && number == null && (!Array.isArray(arrets) || !arrets.length))) { idx.global.push(p); }
});
return idx;
}, [normalizeName]);
const findPerturbsForHoraireAndStop = useCallback((horaire, stationId, stationName, idx) => {
const res = [];
const map = idx || buildPerturbMap(perturbations || []);
const hId = horaire && (horaire.id || horaire.horaire_id || horaire.numero || horaire.numero_train);
const number = horaire && (horaire.numero_train || horaire.number || horaire.numero || horaire.train_number);
if (hId != null) { const kh = String(hId); if (map.byHoraire && map.byHoraire[kh]) res.push(...map.byHoraire[kh]); }
if (number != null) { const kn = String(number); if (map.byNumber && map.byNumber[kn]) res.push(...map.byNumber[kn]); }
if (stationId != null) { const ks = String(stationId); if (map.byStopId && map.byStopId[ks]) map.byStopId[ks].forEach(x => res.push(x.perturbation)); }
if (stationName && map.byStopName) {
const key = normalizeName(stationName);
if (map.byStopName[key]) map.byStopName[key].forEach(x => res.push(x.perturbation));
try {
const needle = key;
for (const existingKey of Object.keys(map.byStopName || {})) {
if (existingKey === needle) continue;
if (existingKey.includes(needle) || needle.includes(existingKey)) { map.byStopName[existingKey].forEach(x => res.push(x.perturbation)); }
}
} catch (e) {}
}
return Array.from(new Set(res));
}, [buildPerturbMap, perturbations, normalizeName]);
const computeStatusForHoraireAtStop = useCallback((horaire, stationId, stationName, idx, serviceDate = null) => {
const found = findPerturbsForHoraireAndStop(horaire, stationId, stationName, idx) || [];
const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };
let horaireYmd = toYMD(horaire && (horaire._date || horaire.date || horaire.date_service || horaire.service_date));
if (!horaireYmd && serviceDate) horaireYmd = serviceDate;
let maxDelay = null;
for (const p of found) {
try {
if (horaireYmd) {
const pDate = toYMD(p.date || p.date_service);
const db = toYMD(p.date_debut || p.start_date);
const df = toYMD(p.date_fin || p.end_date);
if (pDate && pDate !== horaireYmd) continue;
if (db && df && !(horaireYmd >= db && horaireYmd <= df)) continue;
}
} catch (e) {}
try {
const joursSet = getPerturbationJours(p);
if (joursSet && horaireYmd) { if (!joursSet.has(horaireYmd)) continue; }
} catch (e) {}
const parsedObj = parsePerturbArretsObject(p) || { arrets: [], jours: null };
const arrets = parsedObj.arrets || [];
let matched = false;
for (const a of arrets) {
const aId = a.gare_id || a.gareId || a.id || a.station_id || a.stationId || a.uic || a.code;
const aName = a.gare_nom || a.nom || a.name || a.station_name || a.station_nom;
const na = normalizeName(aName || '');
const ns = normalizeName(stationName || '');
const nameMatches = stationName && (na === ns || na.includes(ns) || ns.includes(na));
if ((stationId != null && String(aId) === String(stationId)) || nameMatches) {
matched = true;
const t = (a.type_perturbation || a.type || '').toString().toLowerCase();
if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };
if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {
const d = parseDelayMinutesFromArret(a) || parseDelayMinutesFromArret(p) || 0;
if (maxDelay == null || d > maxDelay) maxDelay = d;
}
}
}
if (!matched) {
const ref = p.horaire_id || p.horaire || p.horaireId || p.numero_train || p.train_number || p.numero;
const numberRef = p.number || p.train_number || p.numero || p.numero_train;
const appliesByRef = ref && (String(ref) === String(horaire.id) || String(ref) === String(horaire.numero_train) || String(ref) === String(horaire.numero));
const appliesByNumber = numberRef && (String(numberRef) === String(horaire.numero_train) || String(numberRef) === String(horaire.numero) || String(numberRef) === String(horaire.number));
const isGlobal = (!Array.isArray(arrets) || arrets.length === 0) && !ref && !numberRef;
if (appliesByRef || appliesByNumber || isGlobal) {
const t = (p.type_perturbation || p.type || '').toString().toLowerCase();
if (t.includes('arret_supprime') || /suppressed|supprim/.test(t)) return { label: 'supprimé', detail: '', delayMinutes: null };
if (t.includes('retard') || t.includes('delay') || /retard/.test(t)) {
const d = parseDelayMinutesFromArret(p) || 0;
if (maxDelay == null || d > maxDelay) maxDelay = d;
}
}
}
}
if (maxDelay != null) return { label: `retardé +${maxDelay}min.`, detail: '', delayMinutes: maxDelay };
return { label: "à l'heure", detail: '', delayMinutes: null };
}, [findPerturbsForHoraireAndStop, parseDelayMinutesFromArret, getPerturbationJours, normalizeName, parsePerturbArretsObject]);
const perturbMap = React.useMemo(() => buildPerturbMap(perturbations || []), [perturbations, buildPerturbMap]);
const serviceDate = React.useMemo(() => {
const todayYmd = new Date().toLocaleDateString('en-CA');
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowYmd = tomorrow.toLocaleDateString('en-CA');
return selectedHoraire && selectedHoraire.isTomorrow ? tomorrowYmd : todayYmd;
}, [selectedHoraire]);
useEffect(() => {
let mounted = true;
let inFlight = false;
const fetchPerturb = async () => {
if (!mounted) return;
if (inFlight) return;
inFlight = true;
try {
if (!supabase) return;
const [{ data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([
supabase.from('perturbations').select('*'),
supabase.from('travaux').select('*')
]);
if (!mounted) return;
if (pError || tError) {
} else {
const todayYmd = new Date().toLocaleDateString('en-CA');
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowYmd = tomorrow.toLocaleDateString('en-CA');
const filteredPerturb = (perturbData || []).filter(p => {
try {
const toYMD = (v) => { if (!v) return null; const d = new Date(v); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0,10); };
const pDate = toYMD(p.date || p.date_service);
const db = toYMD(p.date_debut || p.start_date);
const df = toYMD(p.date_fin || p.end_date);
const appliesToToday = (!pDate || pDate === todayYmd) && (!db || !df || (todayYmd >= db && todayYmd <= df));
const appliesToTomorrow = (!pDate || pDate === tomorrowYmd) && (!db || !df || (tomorrowYmd >= db && tomorrowYmd <= df));
if (!appliesToToday && !appliesToTomorrow) return false;
try {
const joursSet = getPerturbationJours(p);
if (joursSet) {
const hasToday = joursSet.has(todayYmd);
const hasTomorrow = joursSet.has(tomorrowYmd);
if (!hasToday && !hasTomorrow) return false;
}
} catch (e) {}
return true;
} catch (e) { return true; }
});
setPerturbations(filteredPerturb || []);
setTravaux(travauxData || []);
}
} catch (e) {
} finally {
inFlight = false;
}
};
fetchPerturb();
const id = setInterval(fetchPerturb, 15000);
return () => { mounted = false; clearInterval(id); };
}, [getPerturbationJours]);
useEffect(() => {
if (!selectedHoraire || !selectedHoraire.horaire) return;
const horaire = selectedHoraire.horaire;
const stop = selectedHoraire.stop || null;
const stationId = stop ? (stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId) : null;
const stationName = stop ? (typeof stop === 'string' ? stop : (stop.gare_nom || stop.nom || stop.name || stop.stop_name || '')) : null;
try {
const map = buildPerturbMap(perturbations || []);
const todayYmd = new Date().toLocaleDateString('en-CA');
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowYmd = tomorrow.toLocaleDateString('en-CA');
const serviceDate = selectedHoraire.isTomorrow ? tomorrowYmd : todayYmd;
const status = computeStatusForHoraireAtStop(horaire, stationId, stationName, map, serviceDate);
const originalBase = parseTimeHM(selectedHoraire.rawTime || horaire.heure_arrivee || horaire.time || horaire.heure);
const displayTimeOriginal = (typeof originalBase === 'number') ? formatTimeFromHM(originalBase) : (selectedHoraire.rawTime ? formatTimeNoSeconds(selectedHoraire.rawTime) : (horaire.heure_arrivee || horaire.time || horaire.heure ? formatTimeNoSeconds(horaire.heure_arrivee || horaire.time || horaire.heure) : null));
let updatedTime = null;
if (status && typeof status.delayMinutes === 'number' && !Number.isNaN(status.delayMinutes) && typeof originalBase === 'number') {
updatedTime = formatTimeFromHM(originalBase + status.delayMinutes);
}
// Guard to prevent infinite render loop: only set selectedHoraire if status or time actually changed
const statusChanged = !horaire._status || horaire._status.label !== status.label || horaire._status.delayMinutes !== status.delayMinutes;
const timeChanged = selectedHoraire._displayTime !== displayTimeOriginal || selectedHoraire._updatedTime !== updatedTime;
if (statusChanged || timeChanged) {
setSelectedHoraire(prev => ({
...(prev||{}),
horaire: { ...(prev && prev.horaire ? prev.horaire : {}), _status: status },
_displayTime: displayTimeOriginal,
_updatedTime: updatedTime
}));
}
} catch (e) {}
}, [selectedHoraire, perturbations, buildPerturbMap, computeStatusForHoraireAtStop, parseTimeHM, formatTimeFromHM, formatTimeNoSeconds]);
useEffect(() => {
let mounted = true;
async function loadNextForQuai() {
setLoadingHoraire(true);
setHoraireError(null);
setSelectedHoraire(null);
if (!supabase) {
setHoraireError('Supabase non initialisé');
setLoadingHoraire(false);
return;
}
try {
let fetchedType = null;
try {
let fetchedId = gareIdParam || null;
if (gareIdParam) {
const { data: g } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).single();
if (g) {
fetchedType = g.type;
fetchedId = g.id;
}
} else if (gareDisplayParam) {
const { data: gdata } = await supabase.from('gares').select('id,type,nom').ilike('nom', `%${gareDisplayParam}%`);
if (gdata && gdata.length) {
const exactMatch = gdata.find(g => {
const n1 = String(g.nom || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
const n2 = String(gareDisplayParam || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim();
return n1 === n2;
});
const chosen = exactMatch || gdata[0];
fetchedType = chosen ? chosen.type : null;
fetchedId = chosen ? chosen.id : null;
}
}
if (mounted) {
setStationType(fetchedType);
setResolvedGareId(fetchedId);
}
} catch (e) {}
const { data: all, error } = await supabase.from('horaires').select('id, *');
if (error) {
if (mounted) setHoraireError(error.message || String(error));
setLoadingHoraire(false);
return;
}
if (!mounted) return;
if (!Array.isArray(all) || all.length === 0) {
setLoadingHoraire(false);
return;
}
const normalizedTarget = gareDisplayParam ? normalizeName(gareDisplayParam) : null;
const targetQuai = quaiParam ? String(quaiParam).toLowerCase().replace(/\s+/g,'') : null;
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const nowHM = now.getHours()*60 + now.getMinutes();
const candidates = [];
for (const h of all) {
if (!h) continue;
const circulatesToday = circulatesOn(h, now);
const circulatesTomorrow = circulatesOn(h, tomorrow);
if (!circulatesToday && !circulatesTomorrow) continue;
const stops = getStops(h);
let maxIdx = null;
if (Array.isArray(stops) && stops.length) {
for (const st of stops) {
if (typeof st.index !== 'undefined' && st.index !== null) {
const n = Number(st.index);
if (!Number.isNaN(n)) maxIdx = maxIdx == null ? n : Math.max(maxIdx, n);
}
}
}
let stopMatch = null;
if (normalizedTarget) {
const departNameNorm = normalizeName(h.gare_depart_nom || h.gare_depart || h.origin_name || '');
if (departNameNorm && (departNameNorm === normalizedTarget || departNameNorm.includes(normalizedTarget) || normalizedTarget.includes(departNameNorm))) {
// Target is the origin of this train, which means it doesn't arrive here, skip.
continue;
} else {
const destNameNorm = normalizeName(h.gare_arrivee_nom || h.gare_arrivee || '');
if (destNameNorm && (destNameNorm === normalizedTarget || destNameNorm.includes(normalizedTarget) || normalizedTarget.includes(destNameNorm))) {
let finalStop = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_nom: h.gare_arrivee_nom || h.gare_arrivee };
if (Array.isArray(stops) && stops.length) {
for (const s of stops) {
const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '');
const nm = normalizeName(rawName);
if (!nm) continue;
if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) {
if (typeof s === 'object') {
if (!s.heure_arrivee && finalStop.heure_arrivee) s.heure_arrivee = finalStop.heure_arrivee;
finalStop = s;
} else {
finalStop.gare_nom = s;
}
break;
}
}
}
stopMatch = finalStop;
}
}
}
if (!stopMatch && gareIdParam) {
const gid = String(gareIdParam);
const arriveeId = h.gare_arrivee_id || h.gare_id || h.destination_id || null;
if (arriveeId != null && String(arriveeId) === gid) {
stopMatch = { heure_arrivee: h.heure_arrivee || h.time || h.heure, gare_id: arriveeId, gare_nom: h.gare_arrivee_nom || h.gare_arrivee };
}
if (!stopMatch && Array.isArray(stops) && stops.length) {
for (const s of stops) {
const sid = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null;
if (sid != null && String(sid) === gid) { stopMatch = s; break; }
}
}
}
if (!stopMatch && normalizedTarget && Array.isArray(stops) && stops.length) {
for (const s of stops) {
const rawName = (typeof s === 'string') ? s : (s.gare_nom || s.nom || s.name || s.stop_name || s.stopName || s.station || s.station_name || '');
const nm = normalizeName(rawName);
if (!nm) continue;
if (nm === normalizedTarget || nm.includes(normalizedTarget) || normalizedTarget.includes(nm)) {
const isOrigin = Boolean((typeof s === 'object') && (s.isFirst || s.is_first || s.isOrigin || (typeof s.index !== 'undefined' && Number(s.index) === 0)));
if (isOrigin) continue;
stopMatch = s;
break;
}
}
}
if (!stopMatch) continue;
const platform = (stopMatch && (stopMatch.quai_reel || stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.quai_reel || h.platform || h.quai || '';
const normPlatform = String(platform || '').toLowerCase().replace(/\s+/g,'');
if (targetQuai) {
if (!normPlatform) continue;
if (!(normPlatform === targetQuai || normPlatform.includes(targetQuai) || targetQuai.includes(normPlatform))) continue;
}
const stopIsObject = stopMatch && typeof stopMatch === 'object';
const hasArrival = stopIsObject && (
typeof stopMatch.heure_arrivee !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.arrival !== 'undefined' || typeof stopMatch.time !== 'undefined' || typeof stopMatch.arrival_time !== 'undefined' || typeof stopMatch.horaire_time !== 'undefined'
);
const hasDepartureOnly = stopIsObject && !hasArrival && (
typeof stopMatch.heure_depart !== 'undefined' || typeof stopMatch.departure_time !== 'undefined' || typeof stopMatch.departure !== 'undefined'
);
if (stopIsObject && hasDepartureOnly) continue;
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.arrival));
const arrHM = parseTimeHM(rawTime);
if (arrHM == null) continue;
let minutesUntil = arrHM - nowHM;
if (minutesUntil < 0) minutesUntil = arrHM + 24*60 - nowHM;
candidates.push({ horaire: h, stop: stopMatch, arrHM, minutesUntil, rawTime, circulatesToday, circulatesTomorrow });
}
if (!candidates.length) {
setCandidatesCount(0);
setLoadingHoraire(false);
setSelectedHoraire(null);
return;
}
const todayCandidates = candidates.filter(c => c.circulatesToday && c.arrHM >= nowHM - 1).sort((a,b) => a.arrHM - b.arrHM);
const tomorrowCandidates = candidates.filter(c => c.circulatesTomorrow && (c.arrHM < nowHM - 1 || !c.circulatesToday)).sort((a,b) => a.arrHM - b.arrHM);
let chosen = null;
if (todayCandidates.length > 0) {
chosen = { ...todayCandidates[0], isTomorrow: false };
} else if (tomorrowCandidates.length > 0) {
chosen = { ...tomorrowCandidates[0], isTomorrow: true };
}
if (mounted) {
setCandidatesCount(candidates.length);
setSelectedHoraire(chosen || null);
}
} catch (err) {
console.error('Erreur supabase quai', err);
if (mounted) setHoraireError(err.message || String(err));
} finally {
if (mounted) setLoadingHoraire(false);
}
}
loadNextForQuai();
return () => { mounted = false; };
}, [gareDisplayParam, gareIdParam, quaiParam, parseTimeHM, normalizeName, circulatesOn]);
// Rail is handled purely in CSS for better performance and alignment
const showPlatform = (() => {
const sched = selectedHoraire ? selectedHoraire.arrHM : null;
if (sched === null || !stationType) return true;
const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes();
const threshold = thresholdForStationType(stationType);
return nowHM >= (sched - threshold);
})();
if (!loadingHoraire && !selectedHoraire) {
return (
<div className="aff-quais-arrivees-redesign__container aff-quais-arrivees-redesign__empty">
<div className="aff-quais-arrivees-redesign__empty-text">Aucune Arrivée Prévue</div>
<div className="aff-quais-arrivees-redesign__footer">
<div className="aff-quais-arrivees-redesign__footer-left"></div>
<div className="aff-quais-arrivees-redesign__footer-right">
<Clock />
</div>
</div>
</div>
);
}
return (
<div className="aff-quais-arrivees-redesign__container">
{/* LEFT PANEL: Lilac/Light Grey Background */}
<div className="aff-quais-arrivees-redesign__left">
{/* SNCF Logo */}
<img src="/type/quais/logo-sncf-color.svg" alt="SNCF" className="aff-quais-arrivees-redesign__sncf-logo" />
<div className="aff-quais-arrivees-redesign__time-status">
{(() => {
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
const statusObj = horaire && horaire._status ? horaire._status : null;
const isSuppressed = statusObj && statusObj.label === 'supprimé';
const isDelayed = statusObj && typeof statusObj.delayMinutes === 'number';
const delayMinutes = statusObj ? statusObj.delayMinutes : null;
let timeText = horaire ? formatTimeNoSeconds(selectedHoraire.rawTime || horaire.heure_arrivee || horaire.time || horaire.heure) : mock.time;
let statusText = horaire ? (horaire.status || (statusObj && statusObj.label) || mock.status) : mock.status;
if (showPlatform) {
if (isSuppressed) {
if (!alternateCancelPhase) {
timeText = 'supprimé';
statusText = '';
} else {
statusText = 'supprimé';
}
} else if (isDelayed) {
if (!alternateDelayPhase) {
timeText = `retardé +${delayMinutes} min`;
statusText = '';
} else {
statusText = `retardé +${delayMinutes} min`;
}
}
} else {
timeText = '--h--';
statusText = '';
}
const isTimeSuppressed = isSuppressed && !alternateCancelPhase;
const isTimeDelayed = isDelayed && !alternateDelayPhase;
const isLongText = timeText.length > 5;
let timeClass = '';
if (isLongText) timeClass += ' aff-quais-arrivees-redesign__time--long-text';
if (isTimeSuppressed) timeClass += ' aff-quais-arrivees-redesign__time--suppressed';
if (isTimeDelayed) timeClass += ' aff-quais-arrivees-redesign__time--delayed';
let statusClass = '';
if (isSuppressed) statusClass += ' aff-quais-arrivees-redesign__status--suppressed';
if (isDelayed) statusClass += ' aff-quais-arrivees-redesign__status--delayed';
return (
<>
<span className={`aff-quais-arrivees-redesign__time${timeClass}`}>{timeText}</span>
{statusText && <span className={`aff-quais-arrivees-redesign__status${statusClass}`}>{String(statusText).toLowerCase()}</span>}
</>
);
})()}
</div>
<div className="aff-quais-arrivees-redesign__destination">
{(() => {
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
return showPlatform ? (horaire ? (horaire.gare_depart_nom || horaire.gare_depart || horaire.origin_name || horaire.origin || '') : mock.destinationBig) : '--';
})()}
</div>
<div className="aff-quais-arrivees-redesign__train-info">
{(() => {
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
const trainType = horaire ? (horaire.trainType || horaire.train_type || horaire.type_train || horaire.op || horaire.operator) : mock.trainType;
const trainNumber = horaire ? (horaire.numero_train || horaire.numero || horaire.number || '') : mock.trainNumber;
return showPlatform ? (
<>
<span className="aff-quais-arrivees-redesign__train-type">{trainType}</span>
<span className="aff-quais-arrivees-redesign__train-number">{trainNumber}</span>
</>
) : '';
})()}
</div>
{/* Accessibility Icons */}
<div className="aff-quais-arrivees-redesign__icons">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#7e859b" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="4" r="1.5" fill="#7e859b" />
<path d="M9 13a4 4 0 0 0 4 4h3" />
<path d="M12 7.5v6" />
<path d="M8 20a5 5 0 1 1 5-5" />
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#7e859b" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: '12px' }}>
<circle cx="5.5" cy="17.5" r="3.5" />
<circle cx="18.5" cy="17.5" r="3.5" />
<path d="M15 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm-3 11.5V14l-3-3 4-3 2 3h2" />
</svg>
</div>
{/* Watermark */}
<div className="aff-quais-arrivees-redesign__watermark">arrivée</div>
</div>
{/* RIGHT PANEL: Green Background with Provenance Stops */}
<div className="aff-quais-arrivees-redesign__right">
{showPlatform && <div className="aff-quais-arrivees-redesign__provenance-title">gares de provenance :</div>}
<ul className="aff-quais-arrivees-redesign__stops-list">
{(() => {
if (!showPlatform) return null;
const horaire = selectedHoraire && selectedHoraire.horaire ? selectedHoraire.horaire : null;
let stops = horaire ? getStops(horaire) : [];
if (!Array.isArray(stops) || stops.length === 0) {
if (horaire && typeof horaire.stops === 'string') {
try { const p = JSON.parse(horaire.stops); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ }
}
}
if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || [];
const labeled = stops.map((stop, idx) => {
let label = '';
if (typeof stop === 'string') label = stop;
else if (stop && typeof stop === 'object') {
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 && (stop.properties.name || stop.properties.stop_name)) || '';
if (!label && stop.stop && typeof stop.stop === 'object') {
label = stop.stop.name || stop.stop.stop_name || '';
}
}
label = String(label || '').trim();
return { label, original: stop, idx };
}).filter(it => it.label);
if (labeled.length === 0) return null;
let matchedIndex = -1;
try {
if (selectedHoraire && selectedHoraire.stop) {
const target = selectedHoraire.stop;
const targetName = (typeof target === 'string') ? normalizeName(target) : normalizeName((target.gare_nom || target.nom || target.name || target.stop_name || target.station || ''));
const targetId = (typeof target === 'object') ? (target.gare_id || target.gareId || target.id || target.stop_id || target.stopId || null) : null;
for (let i = 0; i < stops.length; i++) {
const s = stops[i];
if (targetId != null) {
const sid = (s && typeof s === 'object') ? (s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null) : null;
if (sid != null && String(sid) === String(targetId)) { matchedIndex = i; break; }
}
const name = (typeof s === 'string') ? normalizeName(s) : normalizeName((s && (s.gare_nom || s.nom || s.name || s.stop_name || s.station || '')) || '');
if (name && targetName && name === targetName) { matchedIndex = i; break; }
}
}
} catch (e) { /* ignore */ }
let previousStops = labeled.filter(it => (matchedIndex >= 0) ? (it.idx <= matchedIndex) : true);
previousStops = previousStops.sort((a,b) => a.idx - b.idx);
if (previousStops.length > 1) {
const collapsed = [];
for (const it of previousStops) {
const prev = collapsed.length ? String(collapsed[collapsed.length-1].label).trim().toLowerCase() : null;
const cur = it.label ? String(it.label).trim().toLowerCase() : '';
if (cur && prev && prev === cur) {
continue;
}
collapsed.push(it);
}
previousStops = collapsed;
}
if (previousStops.length === 0) return null;
return previousStops.map((item, mapIndex) => {
const stop = item.original || {};
const stopId = stop.gare_id || stop.gareId || stop.id || stop.stop_id || stop.stopId || null;
const stopStatus = computeStatusForHoraireAtStop(horaire, stopId, item.label, perturbMap, serviceDate);
const isStopSuppressed = stopStatus && stopStatus.label === 'supprimé';
const isStopDelayed = stopStatus && typeof stopStatus.delayMinutes === 'number';
const stopDelayMin = stopStatus ? stopStatus.delayMinutes : null;
let stopTextClass = '';
if (isStopSuppressed) stopTextClass += ' aff-quais-arrivees-redesign__stop-text--suppressed';
if (isStopDelayed) stopTextClass += ' aff-quais-arrivees-redesign__stop-text--delayed';
return (
<li key={`${item.label}-${item.idx}-${mapIndex}`} className="aff-quais-arrivees-redesign__stop">
<span className="aff-quais-arrivees-redesign__stop-bullet" />
<span className={`aff-quais-arrivees-redesign__stop-text${stopTextClass}`}>
{item.label}
{isStopSuppressed && <span className="aff-quais-arrivees-redesign__stop-status-badge aff-quais-arrivees-redesign__stop-status-badge--suppressed"> (non desservi)</span>}
{isStopDelayed && <span className="aff-quais-arrivees-redesign__stop-status-badge aff-quais-arrivees-redesign__stop-status-badge--delayed"> (+{stopDelayMin} min)</span>}
</span>
</li>
);
});
})()}
</ul>
</div>
{/* BOTTOM FOOTER: Full Width */}
<div className="aff-quais-arrivees-redesign__footer">
<div className="aff-quais-arrivees-redesign__footer-left">
ain.
</div>
<div className="aff-quais-arrivees-redesign__footer-right">
<Clock />
</div>
</div>
</div>
);
}