From 19868a451f8d97e8c5062779b538ece2fbbe78cf Mon Sep 17 00:00:00 2001 From: Ferrovia Date: Fri, 10 Jul 2026 17:55:13 +0200 Subject: [PATCH] =?UTF-8?q?V2.0.1=20:=20VOICI=20LES=20MODIFICATIONS=20MAJE?= =?UTF-8?q?URS=20APPRT=C3=89ES=20DANS=20CETTE=20VERSION=20:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -> REFONTE DU DESIGN DES AFFICHEURS CLASSIQUES (GÉNÉRAL + QUAIS) -> IMPLÉMENTATION DU DESIGN AFL : GÉNÉRAL ET QUAIS -> CHOIX DE LA SÉLECTION DES TYPES D'AFFICHEURS (GÉNÉRAL / QUAIS) UNE FOIS LE TYPE D'AFFICHEUR SÉLECTIONNÉS --- list_tables.js | 8 ++++ query_all.js | 9 ++++ query_annonces.js | 9 ++++ query_columns.js | 9 ++++ query_join.js | 12 +++++ src/lib/fetchAllRows.js | 47 +++++++++++++++++++ src/lib/supabase.js | 14 +++++- src/pages/SelectAfficheur.js | 10 ++-- src/pages/afficheurs/afl/AFLArrivees.js | 13 ++--- src/pages/afficheurs/afl/AFLArriveesQuais.js | 9 ++-- src/pages/afficheurs/afl/AFLDeparts.js | 13 ++--- src/pages/afficheurs/afl/AFLDepartsQuais.js | 9 ++-- .../classiques/ClassiquesArrivees.js | 14 +++--- .../classiques/ClassiquesDeparts.js | 14 +++--- .../quais/ClassiquesArriveesQuais.js | 7 +-- .../quais/ClassiquesDepartsQuais.js | 7 +-- 16 files changed, 156 insertions(+), 48 deletions(-) create mode 100644 list_tables.js create mode 100644 query_all.js create mode 100644 query_annonces.js create mode 100644 query_columns.js create mode 100644 query_join.js create mode 100644 src/lib/fetchAllRows.js diff --git a/list_tables.js b/list_tables.js new file mode 100644 index 0000000..8c6d556 --- /dev/null +++ b/list_tables.js @@ -0,0 +1,8 @@ +require('dotenv').config(); +const { createClient } = require('@supabase/supabase-js'); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); +async function run() { + const { data, error } = await supabase.rpc('get_tables'); + // If rpc fails, we can query information_schema or just list common table names +} +run(); diff --git a/query_all.js b/query_all.js new file mode 100644 index 0000000..7f73d07 --- /dev/null +++ b/query_all.js @@ -0,0 +1,9 @@ +require('dotenv').config(); +const { createClient } = require('@supabase/supabase-js'); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); +async function run() { + const { data, error } = await supabase.from('annonces_diffusions').select('*').order('created_at', { ascending: false }).limit(1); + if (error) console.error(error); + else console.log(JSON.stringify(data[0], null, 2)); +} +run(); diff --git a/query_annonces.js b/query_annonces.js new file mode 100644 index 0000000..bb90ed0 --- /dev/null +++ b/query_annonces.js @@ -0,0 +1,9 @@ +require('dotenv').config(); +const { createClient } = require('@supabase/supabase-js'); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); +async function run() { + const { data, error } = await supabase.from('annonces').select('*').limit(2); + if (error) console.error(error); + else console.log(JSON.stringify(data, null, 2)); +} +run(); diff --git a/query_columns.js b/query_columns.js new file mode 100644 index 0000000..4d4f7b8 --- /dev/null +++ b/query_columns.js @@ -0,0 +1,9 @@ +require('dotenv').config(); +const { createClient } = require('@supabase/supabase-js'); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); +async function run() { + const { data, error } = await supabase.from('annonces_diffusions').select('*').limit(1); + if (error) console.error(error); + else console.log(Object.keys(data[0])); +} +run(); diff --git a/query_join.js b/query_join.js new file mode 100644 index 0000000..54b7e28 --- /dev/null +++ b/query_join.js @@ -0,0 +1,12 @@ +require('dotenv').config(); +const { createClient } = require('@supabase/supabase-js'); +const supabase = createClient(process.env.REACT_APP_SUPABASE_URL, process.env.REACT_APP_SUPABASE_ANON_KEY); +async function run() { + const { data, error } = await supabase.from('annonces_diffusions').select(` + *, + annonce:annonces_enregistrees(variables_values) + `).order('created_at', { ascending: false }).limit(1); + if (error) console.error(error); + else console.log(JSON.stringify(data[0], null, 2)); +} +run(); diff --git a/src/lib/fetchAllRows.js b/src/lib/fetchAllRows.js new file mode 100644 index 0000000..4a28681 --- /dev/null +++ b/src/lib/fetchAllRows.js @@ -0,0 +1,47 @@ +/** + * Récupère TOUTES les lignes d'une table Supabase en paginant automatiquement. + * + * PostgREST (Supabase) limite par défaut à 1000 lignes par requête. + * Cette fonction envoie plusieurs requêtes avec `.range()` pour obtenir + * l'intégralité des résultats, puis les concatène. + * + * Usage : + * fetchAllRows(supabase.from('horaires').select('*')) + * + * En interne, `.range()` modifie le header Range de la requête et chaque + * `await` sur un PostgrestBuilder déclenche un nouveau fetch HTTP, ce qui + * permet de paginer sur le même builder. + * + * @param {object} query - Query Supabase (ex: supabase.from('horaires').select('*')) + * @param {number} [pageSize=500] - Nombre de lignes par page (≤ 1000) + * @returns {Promise<{data: Array, error: object|null}>} + */ +export async function fetchAllRows(query, pageSize = 500) { + let allData = []; + let from = 0; + let hasMore = true; + + while (hasMore) { + const to = from + pageSize - 1; + const { data, error } = await query.range(from, to); + + if (error) { + return { data: null, error }; + } + + if (!data || data.length === 0) { + hasMore = false; + } else { + allData = allData.concat(data); + if (data.length < pageSize) { + hasMore = false; + } else { + from += pageSize; + } + } + } + + return { data: allData, error: null }; +} + +export default fetchAllRows; diff --git a/src/lib/supabase.js b/src/lib/supabase.js index d28a077..b869bdf 100644 --- a/src/lib/supabase.js +++ b/src/lib/supabase.js @@ -19,7 +19,19 @@ if (!supabaseUrl || !supabaseAnonKey) { 'Vérifiez REACT_APP_SUPABASE_URL et REACT_APP_SUPABASE_ANON_KEY dans .env et redémarrez le serveur.' ); } else { - supabase = createClient(supabaseUrl, supabaseAnonKey); + supabase = createClient(supabaseUrl, supabaseAnonKey, { + realtime: { + // Désactivé : le reverse proxy ne supporte pas le WebSocket vers le port 3001. + // L'appli utilise un fallback par polling (useAnnonceSonoreRealtime). + params: { eventsPerSecond: 0 }, + }, + // Empêcher la connexion WebSocket automatique + global: { + headers: {}, + }, + }); + // Désactiver complètement le canal Realtime pour éviter les erreurs wss:// dans la console + try { supabase.realtime.disconnect(); } catch (e) { /* ignore */ } } export { supabase }; diff --git a/src/pages/SelectAfficheur.js b/src/pages/SelectAfficheur.js index 9f3681b..0246b06 100644 --- a/src/pages/SelectAfficheur.js +++ b/src/pages/SelectAfficheur.js @@ -27,7 +27,7 @@ export default function SelectAfficheur({ onBack, navigate }) { } try { setLoading(true); - const { data, error } = await supabase.from('horaires').select('stops, quai_reel, voie, quai, gare_depart_nom, gare_arrivee_nom'); + const { data, error } = await supabase.from('horaires').select('quai_reel, voie, quai, gare_depart_nom, gare_arrivee_nom, gares_desservies'); if (error) throw error; if (!mounted) return; @@ -36,10 +36,10 @@ export default function SelectAfficheur({ onBack, navigate }) { (data || []).forEach(h => { let stopsList = []; - if (Array.isArray(h.stops)) { - stopsList = h.stops; - } else if (typeof h.stops === 'string') { - try { stopsList = JSON.parse(h.stops); } catch (e) {} + if (Array.isArray(h.gares_desservies)) { + stopsList = h.gares_desservies; + } else if (typeof h.gares_desservies === 'string') { + try { stopsList = JSON.parse(h.gares_desservies); } catch (e) {} } if (!Array.isArray(stopsList)) stopsList = []; diff --git a/src/pages/afficheurs/afl/AFLArrivees.js b/src/pages/afficheurs/afl/AFLArrivees.js index e1a8a19..a4cf606 100644 --- a/src/pages/afficheurs/afl/AFLArrivees.js +++ b/src/pages/afficheurs/afl/AFLArrivees.js @@ -1,6 +1,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import './AFLArrivees.css'; import supabase from '../../../lib/supabase'; +import { fetchAllRows } from '../../../lib/fetchAllRows'; import MarqueeText from '../../../components/MarqueeText'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; @@ -586,7 +587,7 @@ export default function AFLArrivees(){ setError(null); try { const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }, { data: travauxSubstData }] = await Promise.all([ - supabase.from('horaires').select('id, *'), + fetchAllRows(supabase.from('horaires').select('*')), supabase.from('perturbations').select('*'), supabase.from('travaux').select('*'), supabase.from('travaux_substitutions').select('*') @@ -609,14 +610,14 @@ export default function AFLArrivees(){ // conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare) let parsedHoraires = (horairesData || []).map(h => h || {}); - // Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD; - // essayer de parser pour garantir que stops est un tableau utilisable. + // Certains champs (comme gares_desservies) peuvent arriver en tant que chaîne JSON depuis la BDD; + // essayer de parser pour garantir que gares_desservies est un tableau utilisable. parsedHoraires = parsedHoraires.map(h => { try { - if (h && typeof h.stops === 'string') { - const parsed = JSON.parse(h.stops); + if (h && typeof h.gares_desservies === 'string') { + const parsed = JSON.parse(h.gares_desservies); if (Array.isArray(parsed)) { - return { ...h, stops: parsed }; + return { ...h, gares_desservies: parsed }; } } } catch (e) { diff --git a/src/pages/afficheurs/afl/AFLArriveesQuais.js b/src/pages/afficheurs/afl/AFLArriveesQuais.js index 7efe4af..c0acf34 100644 --- a/src/pages/afficheurs/afl/AFLArriveesQuais.js +++ b/src/pages/afficheurs/afl/AFLArriveesQuais.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import supabase from '../../../lib/supabase'; +import { fetchAllRows } from '../../../lib/fetchAllRows'; import './AFLArriveesQuais.css'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; @@ -419,7 +420,7 @@ const AFLArriveesQuais = () => { const fetchData = useCallback(async () => { try { const [{ data: horairesData }, { data: subData }, { data: perturbData }, { data: travauxData }] = await Promise.all([ - supabase.from('horaires').select('*'), + fetchAllRows(supabase.from('horaires').select('*')), supabase.from('travaux_substitutions').select('*'), supabase.from('perturbations').select('*'), supabase.from('travaux').select('*') @@ -427,9 +428,9 @@ const AFLArriveesQuais = () => { let parsedHoraires = (horairesData || []).map(h => { try { - if (h && typeof h.stops === 'string') { - const parsed = JSON.parse(h.stops); - if (Array.isArray(parsed)) return { ...h, stops: parsed }; + if (h && typeof h.gares_desservies === 'string') { + const parsed = JSON.parse(h.gares_desservies); + if (Array.isArray(parsed)) return { ...h, gares_desservies: parsed }; } } catch (e) {} return h || {}; diff --git a/src/pages/afficheurs/afl/AFLDeparts.js b/src/pages/afficheurs/afl/AFLDeparts.js index a587eb0..0d1a655 100644 --- a/src/pages/afficheurs/afl/AFLDeparts.js +++ b/src/pages/afficheurs/afl/AFLDeparts.js @@ -1,6 +1,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import './AFLDeparts.css'; import supabase from '../../../lib/supabase'; +import { fetchAllRows } from '../../../lib/fetchAllRows'; import MarqueeText from '../../../components/MarqueeText'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; @@ -586,7 +587,7 @@ export default function AFLDeparts(){ setError(null); try { const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }, { data: travauxSubstData }] = await Promise.all([ - supabase.from('horaires').select('id, *'), + fetchAllRows(supabase.from('horaires').select('*')), supabase.from('perturbations').select('*'), supabase.from('travaux').select('*'), supabase.from('travaux_substitutions').select('*') @@ -609,14 +610,14 @@ export default function AFLDeparts(){ // conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare) let parsedHoraires = (horairesData || []).map(h => h || {}); - // Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD; - // essayer de parser pour garantir que stops est un tableau utilisable. + // Certains champs (comme gares_desservies) peuvent arriver en tant que chaîne JSON depuis la BDD; + // essayer de parser pour garantir que gares_desservies est un tableau utilisable. parsedHoraires = parsedHoraires.map(h => { try { - if (h && typeof h.stops === 'string') { - const parsed = JSON.parse(h.stops); + if (h && typeof h.gares_desservies === 'string') { + const parsed = JSON.parse(h.gares_desservies); if (Array.isArray(parsed)) { - return { ...h, stops: parsed }; + return { ...h, gares_desservies: parsed }; } } } catch (e) { diff --git a/src/pages/afficheurs/afl/AFLDepartsQuais.js b/src/pages/afficheurs/afl/AFLDepartsQuais.js index 9b69bc8..d21ba89 100644 --- a/src/pages/afficheurs/afl/AFLDepartsQuais.js +++ b/src/pages/afficheurs/afl/AFLDepartsQuais.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import supabase from '../../../lib/supabase'; +import { fetchAllRows } from '../../../lib/fetchAllRows'; import './AFLDepartsQuais.css'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; @@ -420,7 +421,7 @@ const AFLDepartsQuais = () => { const fetchData = useCallback(async () => { try { const [{ data: horairesData }, { data: subData }, { data: perturbData }, { data: travauxData }] = await Promise.all([ - supabase.from('horaires').select('*'), + fetchAllRows(supabase.from('horaires').select('*')), supabase.from('travaux_substitutions').select('*'), supabase.from('perturbations').select('*'), supabase.from('travaux').select('*') @@ -428,9 +429,9 @@ const AFLDepartsQuais = () => { let parsedHoraires = (horairesData || []).map(h => { try { - if (h && typeof h.stops === 'string') { - const parsed = JSON.parse(h.stops); - if (Array.isArray(parsed)) return { ...h, stops: parsed }; + if (h && typeof h.gares_desservies === 'string') { + const parsed = JSON.parse(h.gares_desservies); + if (Array.isArray(parsed)) return { ...h, gares_desservies: parsed }; } } catch (e) {} return h || {}; diff --git a/src/pages/afficheurs/classiques/ClassiquesArrivees.js b/src/pages/afficheurs/classiques/ClassiquesArrivees.js index 3a110bf..1769134 100644 --- a/src/pages/afficheurs/classiques/ClassiquesArrivees.js +++ b/src/pages/afficheurs/classiques/ClassiquesArrivees.js @@ -5,6 +5,7 @@ import Logo from '../../../components/Logo'; import MarqueeText from '../../../components/MarqueeText'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; +import { fetchAllRows } from '../../../lib/fetchAllRows'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (v) => { @@ -625,9 +626,7 @@ export default function ClassiquesArrivees(){ setError(null); try { const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ - // DEBUG: récupérer explicitement l'id et toutes les colonnes pour s'assurer que l'identifiant - // `id` est bien présent et peut être mis en correspondance avec `horaire_id` dans perturbations - supabase.from('horaires').select('id, *'), + fetchAllRows(supabase.from('horaires').select('*')), supabase.from('perturbations').select('*'), supabase.from('travaux').select('*') ]); @@ -649,14 +648,13 @@ export default function ClassiquesArrivees(){ // conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare) let parsedHoraires = (horairesData || []).map(h => h || {}); - // Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD; - // essayer de parser pour garantir que stops est un tableau utilisable. + // Parser gares_desservies si c'est une chaîne JSON parsedHoraires = parsedHoraires.map(h => { try { - if (h && typeof h.stops === 'string') { - const parsed = JSON.parse(h.stops); + if (h && typeof h.gares_desservies === 'string') { + const parsed = JSON.parse(h.gares_desservies); if (Array.isArray(parsed)) { - return { ...h, stops: parsed }; + return { ...h, gares_desservies: parsed }; } } } catch (e) { diff --git a/src/pages/afficheurs/classiques/ClassiquesDeparts.js b/src/pages/afficheurs/classiques/ClassiquesDeparts.js index a5c1554..daf9ba8 100644 --- a/src/pages/afficheurs/classiques/ClassiquesDeparts.js +++ b/src/pages/afficheurs/classiques/ClassiquesDeparts.js @@ -5,6 +5,7 @@ import Logo from '../../../components/Logo'; import MarqueeText from '../../../components/MarqueeText'; import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; +import { fetchAllRows } from '../../../lib/fetchAllRows'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (v) => { @@ -625,9 +626,7 @@ export default function ClassiquesDeparts(){ setError(null); try { const [{ data: horairesData, error: hError }, { data: perturbData, error: pError }, { data: travauxData, error: tError }] = await Promise.all([ - // DEBUG: récupérer explicitement l'id et toutes les colonnes pour s'assurer que l'identifiant - // `id` est bien présent et peut être mis en correspondance avec `horaire_id` dans perturbations - supabase.from('horaires').select('id, *'), + fetchAllRows(supabase.from('horaires').select('*')), supabase.from('perturbations').select('*'), supabase.from('travaux').select('*') ]); @@ -649,14 +648,13 @@ export default function ClassiquesDeparts(){ // conserver la liste complète telle que renvoyée par la BDD (avant filtrage par gare) let parsedHoraires = (horairesData || []).map(h => h || {}); - // Certains champs (comme stops) peuvent arriver en tant que chaîne JSON depuis la BDD; - // essayer de parser pour garantir que stops est un tableau utilisable. + // Parser gares_desservies si c'est une chaîne JSON parsedHoraires = parsedHoraires.map(h => { try { - if (h && typeof h.stops === 'string') { - const parsed = JSON.parse(h.stops); + if (h && typeof h.gares_desservies === 'string') { + const parsed = JSON.parse(h.gares_desservies); if (Array.isArray(parsed)) { - return { ...h, stops: parsed }; + return { ...h, gares_desservies: parsed }; } } } catch (e) { diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js index 110cb82..9e55103 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js +++ b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js @@ -1,6 +1,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesArriveesQuais.css'; import supabase from '../../../../lib/supabase'; +import { fetchAllRows } from '../../../../lib/fetchAllRows'; import useAnnonceSonoreRealtime from '../../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../../hooks/useAutoAnnonce'; function Clock() { @@ -490,7 +491,7 @@ export default function ClassiquesArriveesQuais() { } } catch (e) {} - const { data: all, error } = await supabase.from('horaires').select('id, *'); + const { data: all, error } = await fetchAllRows(supabase.from('horaires').select('*')); if (error) { if (mounted) setHoraireError(error.message || String(error)); setLoadingHoraire(false); @@ -784,8 +785,8 @@ export default function ClassiquesArriveesQuais() { 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 (horaire && typeof horaire.gares_desservies === 'string') { + try { const p = JSON.parse(horaire.gares_desservies); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ } } } if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || []; diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js index 4230373..92eb22c 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js +++ b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js @@ -1,6 +1,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesDepartsQuais.css'; import supabase from '../../../../lib/supabase'; +import { fetchAllRows } from '../../../../lib/fetchAllRows'; import useAnnonceSonoreRealtime from '../../../../hooks/useAnnonceSonoreRealtime'; import useAutoAnnonce from '../../../../hooks/useAutoAnnonce'; function Clock() { @@ -490,7 +491,7 @@ export default function ClassiquesDepartsQuais() { } } catch (e) {} - const { data: all, error } = await supabase.from('horaires').select('id, *'); + const { data: all, error } = await fetchAllRows(supabase.from('horaires').select('*')); if (error) { if (mounted) setHoraireError(error.message || String(error)); setLoadingHoraire(false); @@ -781,8 +782,8 @@ export default function ClassiquesDepartsQuais() { 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 (horaire && typeof horaire.gares_desservies === 'string') { + try { const p = JSON.parse(horaire.gares_desservies); if (Array.isArray(p)) stops = p; } catch (e) { /* ignore */ } } } if (!Array.isArray(stops) || stops.length === 0) stops = mock.stops || [];