From ad999aa44f52fbae09156c542d180ce56cb4fbab Mon Sep 17 00:00:00 2001 From: Ferrovia Date: Thu, 2 Jul 2026 22:22:37 +0200 Subject: [PATCH] =?UTF-8?q?V2.5=20:=20VOICI=20LES=20MODIFICATIONS=20MAJEUR?= =?UTF-8?q?S=20APPRT=C3=89ES=20DANS=20CETTE=20VERSION=20:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -> (BÉTA) : SYSTÈME D'ANNONCES EN GARE VIA UN MODEL IA DE SIMONE HERAULT. --- patch_afficheurs.js | 35 ++++++ query_horaires.js | 9 ++ src/hooks/useAnnonceSonoreRealtime.js | 110 ++++++++++++++++++ src/hooks/useAutoAnnonce.js | 92 +++++++++++++++ src/pages/afficheurs/afl/AFLArrivees.js | 17 ++- src/pages/afficheurs/afl/AFLArriveesQuais.js | 38 ++++++ src/pages/afficheurs/afl/AFLDeparts.js | 17 ++- src/pages/afficheurs/afl/AFLDepartsQuais.js | 38 ++++++ .../classiques/ClassiquesArrivees.js | 61 ++++++---- .../classiques/ClassiquesDeparts.js | 17 ++- .../quais/ClassiquesArriveesQuais.js | 55 ++++++--- .../quais/ClassiquesDepartsQuais.js | 43 ++++--- test_horaire.js | 23 ++++ test_realtime.js | 24 ++++ 14 files changed, 517 insertions(+), 62 deletions(-) create mode 100644 patch_afficheurs.js create mode 100644 query_horaires.js create mode 100644 src/hooks/useAnnonceSonoreRealtime.js create mode 100644 src/hooks/useAutoAnnonce.js create mode 100644 test_horaire.js create mode 100644 test_realtime.js diff --git a/patch_afficheurs.js b/patch_afficheurs.js new file mode 100644 index 0000000..04d5183 --- /dev/null +++ b/patch_afficheurs.js @@ -0,0 +1,35 @@ +const fs = require('fs'); +const path = require('path'); + +const files = [ + { p: 'src/pages/afficheurs/afl/AFLDeparts.js', arr: false }, + { p: 'src/pages/afficheurs/afl/AFLArrivees.js', arr: true }, + { p: 'src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js', arr: false }, + { p: 'src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js', arr: true }, + { p: 'src/pages/afficheurs/afl/quais/AFLDepartsQuais.js', arr: false }, + { p: 'src/pages/afficheurs/afl/quais/AFLArriveesQuais.js', arr: true }, +]; + +for (const { p, arr } of files) { + const fullPath = path.join(process.cwd(), p); + if (!fs.existsSync(fullPath)) { + console.log(`File not found: ${fullPath}`); + continue; + } + let content = fs.readFileSync(fullPath, 'utf8'); + + if (!content.includes('import useAutoAnnonce from')) { + content = content.replace( + "import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';", + "import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime';\nimport useAutoAnnonce from '../../../hooks/useAutoAnnonce';" + ); + } + + if (!content.includes('useAutoAnnonce(')) { + const hookCall = ` useAnnonceSonoreRealtime(resolvedGareId);\n useAutoAnnonce(horaires, currentTime, ${arr});`; + content = content.replace(" useAnnonceSonoreRealtime(resolvedGareId);", hookCall); + } + + fs.writeFileSync(fullPath, content, 'utf8'); + console.log(`Patched ${p}`); +} diff --git a/query_horaires.js b/query_horaires.js new file mode 100644 index 0000000..9dc2278 --- /dev/null +++ b/query_horaires.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('horaires').select('*').limit(1); + if (error) console.error(error); + else console.log(JSON.stringify(data[0], null, 2)); +} +run(); diff --git a/src/hooks/useAnnonceSonoreRealtime.js b/src/hooks/useAnnonceSonoreRealtime.js new file mode 100644 index 0000000..83ef403 --- /dev/null +++ b/src/hooks/useAnnonceSonoreRealtime.js @@ -0,0 +1,110 @@ +import { useEffect, useRef } from 'react'; +import supabase from '../lib/supabase'; + +export default function useAnnonceSonoreRealtime(gareId) { + const audioRef = useRef(null); + const lastAnnonceIdRef = useRef(null); + + useEffect(() => { + if (!supabase || !gareId) return; + + console.debug(`[useAnnonceSonoreRealtime] Setup for gareId: ${gareId}`); + + const playAnnonce = (newRecord) => { + // 1. Couper l'audio en cours de lecture + if (audioRef.current) { + try { audioRef.current.pause(); } catch (e) {} + audioRef.current = null; + } + + // 2. Couper le TTS en cours de lecture + if (typeof window !== 'undefined' && window.speechSynthesis) { + try { window.speechSynthesis.cancel(); } catch (e) {} + } + + // 3. Diffuser l'annonce + if (newRecord.audio_path) { + const { data } = supabase.storage.from('banque-annonces').getPublicUrl(newRecord.audio_path); + const publicUrl = data?.publicUrl; + + if (publicUrl) { + console.debug(`[useAnnonceSonoreRealtime] Lecture audio de : ${publicUrl}`); + const audio = new Audio(publicUrl); + audioRef.current = audio; + audio.play().catch((err) => { + console.error('[useAnnonceSonoreRealtime] Erreur lecture audio:', err); + }); + } + } else if (newRecord.tts_texte) { + if (typeof window !== 'undefined' && window.speechSynthesis) { + console.debug(`[useAnnonceSonoreRealtime] Lecture TTS de : "${newRecord.tts_texte}"`); + const utterance = new SpeechSynthesisUtterance(newRecord.tts_texte); + utterance.lang = 'fr-FR'; + + const voices = window.speechSynthesis.getVoices(); + const frenchVoices = voices.filter(v => v.lang && (v.lang.includes('fr-FR') || v.lang.startsWith('fr'))); + const claireVoice = frenchVoices.find(v => v.name && v.name.toLowerCase().includes('claire')); + + utterance.voice = claireVoice || frenchVoices[0] || null; + window.speechSynthesis.speak(utterance); + } + } + }; + + // --- FALLBACK POLLING (Seule méthode utilisée) --- + // Puisque le WebSocket vers ton instance auto-hébergée (wss://...) est bloqué par le reverse proxy, + // on désactive l'abonnement Realtime pour éviter les erreurs "CHANNEL_ERROR" dans ta console. + // À la place, on interroge la base de données toutes les 2.5 secondes. + const fetchLatest = async () => { + try { + const { data, error } = await supabase + .from('annonces_diffusions') + .select('*') + .eq('gare_id', gareId) + .order('created_at', { ascending: false }) + .limit(1); + + if (!error && data && data.length > 0) { + const latest = data[0]; + + // Si c'est une annonce qu'on n'a pas encore vue + if (latest.id !== lastAnnonceIdRef.current) { + const now = new Date(); + const createdAt = new Date(latest.created_at); + const diffSeconds = (now.getTime() - createdAt.getTime()) / 1000; + + // On ne joue l'annonce que si elle a été insérée il y a moins de 15 secondes + if (diffSeconds < 15) { + console.debug('[useAnnonceSonoreRealtime] Annonce spontanée détectée :', latest); + lastAnnonceIdRef.current = latest.id; + playAnnonce(latest); + } else { + // Si elle est trop vieille, on l'ignore silencieusement + lastAnnonceIdRef.current = latest.id; + } + } + } + } catch (err) { + // Ignorer les erreurs réseau silencieusement + } + }; + + // On lance le polling toutes les 2.5 secondes pour une réactivité maximale + const pollInterval = setInterval(fetchLatest, 2500); + // On fait aussi un fetch immédiat au démarrage pour initialiser l'ID + fetchLatest(); + + return () => { + console.debug('[useAnnonceSonoreRealtime] Nettoyage...'); + clearInterval(pollInterval); + + if (audioRef.current) { + try { audioRef.current.pause(); } catch (e) {} + audioRef.current = null; + } + if (typeof window !== 'undefined' && window.speechSynthesis) { + try { window.speechSynthesis.cancel(); } catch (e) {} + } + }; + }, [gareId]); +} diff --git a/src/hooks/useAutoAnnonce.js b/src/hooks/useAutoAnnonce.js new file mode 100644 index 0000000..989244d --- /dev/null +++ b/src/hooks/useAutoAnnonce.js @@ -0,0 +1,92 @@ +import { useEffect, useRef } from 'react'; + +// Hook pour générer automatiquement les annonces sur l'afficheur (fallback TTS robuste) +export default function useAutoAnnonce(horaires, currentTime, isArrival = false) { + const announcedRefs = useRef(new Set()); + const isPlayingRef = useRef(false); + + useEffect(() => { + if (!horaires || horaires.length === 0 || !currentTime) return; + + // Déterminer le délai d'annonce (2 min avant l'arrivée ou le départ) + const TRIGGER_MINUTES = 2; + + const checkAndPlay = () => { + if (isPlayingRef.current) return; // Ne pas interrompre si une annonce joue déjà + + for (const train of horaires) { + const numTrain = train.num_train || train.numero_train || train.numero; + if (!numTrain) continue; + + const timeStr = isArrival + ? (train.heure_arrivee || train.arrival_time || train.time || train.heure) + : (train.heure_depart || train.departure_time || train.time || train.heure); + + if (!timeStr) continue; + + // Si timeStr est juste "HH:MM", on crée une date du jour avec cette heure + let trainTime; + if (typeof timeStr === 'string' && timeStr.length <= 5 && timeStr.includes(':')) { + const [hh, mm] = timeStr.split(':'); + trainTime = new Date(currentTime); + trainTime.setHours(parseInt(hh, 10), parseInt(mm, 10), 0, 0); + } else { + trainTime = new Date(timeStr); + } + + if (isNaN(trainTime.getTime())) continue; + + const diffMs = trainTime.getTime() - currentTime.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + // Si on est dans la minute cible et que l'annonce n'a pas encore été jouée + if (diffMins === TRIGGER_MINUTES && !announcedRefs.current.has(numTrain)) { + announcedRefs.current.add(numTrain); + + const gareObj = isArrival + ? (train.gare_depart_nom || train.gare_depart || train.origine) + : (train.gare_arrivee_nom || train.gare_arrivee || train.destination); + + const gareName = typeof gareObj === 'object' && gareObj !== null ? gareObj.nom : gareObj; + + let voieText = train.voie ? `Voie ${train.voie}.` : ""; + const typeTrain = train.type_train || "train"; + const gareText = gareName || "destination inconnue"; + + const text = isArrival + ? `${voieText} Le ${typeTrain} numéro ${numTrain} en provenance de ${gareText}, va entrer en gare. Éloignez-vous de la bordure du quai.` + : `${voieText} Le ${typeTrain} numéro ${numTrain} à destination de ${gareText}, va partir. Prenez garde à la fermeture des portes.`; + + // Lancement de l'annonce TTS + isPlayingRef.current = true; + + if (typeof window !== 'undefined' && window.speechSynthesis) { + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = 'fr-FR'; + const voices = window.speechSynthesis.getVoices(); + const frenchVoices = voices.filter(v => v.lang && (v.lang.includes('fr-FR') || v.lang.startsWith('fr'))); + const claireVoice = frenchVoices.find(v => v.name && v.name.toLowerCase().includes('claire')); + utterance.voice = claireVoice || frenchVoices[0] || null; + + utterance.onend = () => { + isPlayingRef.current = false; + }; + utterance.onerror = () => { + isPlayingRef.current = false; + }; + + console.debug(`[useAutoAnnonce] Lecture TTS Automatique: "${text}"`); + window.speechSynthesis.speak(utterance); + } else { + isPlayingRef.current = false; + } + + // On s'arrête après avoir lancé une annonce pour éviter de tout superposer + break; + } + } + }; + + checkAndPlay(); + }, [horaires, currentTime, isArrival]); +} diff --git a/src/pages/afficheurs/afl/AFLArrivees.js b/src/pages/afficheurs/afl/AFLArrivees.js index 48a1689..75077e7 100644 --- a/src/pages/afficheurs/afl/AFLArrivees.js +++ b/src/pages/afficheurs/afl/AFLArrivees.js @@ -2,6 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react'; import './AFLArrivees.css'; import supabase from '../../../lib/supabase'; import MarqueeText from '../../../components/MarqueeText'; +import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; +import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (v) => { @@ -84,15 +86,23 @@ export default function AFLArrivees(){ // debug des données brutes renvoyées par Supabase const [, setRawDebug] = useState({ count: 0, sample: [] }); + const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); + useAnnonceSonoreRealtime(resolvedGareId); + useAutoAnnonce(horaires, currentTime, true); + // Récupérer le type de gare une seule fois au montage ou quand les paramètres changent useEffect(() => { let mounted = true; const fetchStationType = async () => { try { let fetchedType = null; + let fetchedId = gareIdParam || null; if (gareIdParam) { const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1); - if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; + if (!gerr && gdata && gdata.length) { + fetchedType = gdata[0] && gdata[0].type; + fetchedId = gdata[0] && gdata[0].id; + } } else if (gareDisplay) { const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); @@ -102,11 +112,14 @@ export default function AFLArrivees(){ const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); return n1 === n2; }); - fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + const chosen = exactMatch || gdata[0]; + fetchedType = chosen ? chosen.type : null; + fetchedId = chosen ? chosen.id : null; } } if (mounted) { setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + setResolvedGareId(fetchedId); } } catch (e) { /* ignore */ } }; diff --git a/src/pages/afficheurs/afl/AFLArriveesQuais.js b/src/pages/afficheurs/afl/AFLArriveesQuais.js index 8402604..625778d 100644 --- a/src/pages/afficheurs/afl/AFLArriveesQuais.js +++ b/src/pages/afficheurs/afl/AFLArriveesQuais.js @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import supabase from '../../../lib/supabase'; import './AFLArriveesQuais.css'; +import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; // Helper utilitaire : format HH:MM sans secondes (ex: 15:57) const formatTimeNoSeconds = (v) => { @@ -26,9 +27,46 @@ const AFLArriveesQuais = () => { // Parse URL manually since we are not in a React Router context const searchParams = new URLSearchParams(window.location.search); const gareParam = searchParams.get('gare') || ''; + const gareIdParam = searchParams.get('gare_id') || searchParams.get('gareId'); const quaiParam = searchParams.get('quai') || searchParams.get('voie') || ''; const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); + useAnnonceSonoreRealtime(resolvedGareId); + + useEffect(() => { + let mounted = true; + const resolveGareId = async () => { + if (!supabase) return; + try { + if (gareIdParam) { + const { data: gdata } = await supabase.from('gares').select('id').eq('id', gareIdParam).limit(1); + if (mounted && gdata && gdata.length) { + setResolvedGareId(gdata[0].id); + } + } else if (gareDisplay) { + const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; + const { data: gdata } = await supabase.from('gares').select('id, nom').ilike('nom', flexNeedle); + 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(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); + return n1 === n2; + }); + const chosen = exactMatch || gdata[0]; + if (mounted && chosen) { + setResolvedGareId(chosen.id); + } + } + } + } catch (e) { + console.error('[AFLArriveesQuais] Erreur resolution gare:', e); + } + }; + resolveGareId(); + return () => { mounted = false; }; + }, [gareIdParam, gareDisplay]); + // Real-time clock update useEffect(() => { const timer = setInterval(() => setCurrentTime(new Date()), 1000); diff --git a/src/pages/afficheurs/afl/AFLDeparts.js b/src/pages/afficheurs/afl/AFLDeparts.js index f50b2b5..539a25b 100644 --- a/src/pages/afficheurs/afl/AFLDeparts.js +++ b/src/pages/afficheurs/afl/AFLDeparts.js @@ -2,6 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react'; import './AFLDeparts.css'; import supabase from '../../../lib/supabase'; import MarqueeText from '../../../components/MarqueeText'; +import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; +import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (v) => { @@ -84,15 +86,23 @@ export default function AFLDeparts(){ // debug des données brutes renvoyées par Supabase const [, setRawDebug] = useState({ count: 0, sample: [] }); + const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); + useAnnonceSonoreRealtime(resolvedGareId); + useAutoAnnonce(horaires, currentTime, false); + // Récupérer le type de gare une seule fois au montage ou quand les paramètres changent useEffect(() => { let mounted = true; const fetchStationType = async () => { try { let fetchedType = null; + let fetchedId = gareIdParam || null; if (gareIdParam) { const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1); - if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; + if (!gerr && gdata && gdata.length) { + fetchedType = gdata[0] && gdata[0].type; + fetchedId = gdata[0] && gdata[0].id; + } } else if (gareDisplay) { const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); @@ -102,11 +112,14 @@ export default function AFLDeparts(){ const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); return n1 === n2; }); - fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + const chosen = exactMatch || gdata[0]; + fetchedType = chosen ? chosen.type : null; + fetchedId = chosen ? chosen.id : null; } } if (mounted) { setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + setResolvedGareId(fetchedId); } } catch (e) { /* ignore */ } }; diff --git a/src/pages/afficheurs/afl/AFLDepartsQuais.js b/src/pages/afficheurs/afl/AFLDepartsQuais.js index a2c6651..732a67d 100644 --- a/src/pages/afficheurs/afl/AFLDepartsQuais.js +++ b/src/pages/afficheurs/afl/AFLDepartsQuais.js @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import supabase from '../../../lib/supabase'; import './AFLDepartsQuais.css'; +import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; // Helper utilitaire : format HH:MM sans secondes (ex: 15:57) const formatTimeNoSeconds = (v) => { @@ -26,9 +27,46 @@ const AFLDepartsQuais = () => { // Parse URL manually since we are not in a React Router context const searchParams = new URLSearchParams(window.location.search); const gareParam = searchParams.get('gare') || ''; + const gareIdParam = searchParams.get('gare_id') || searchParams.get('gareId'); const quaiParam = searchParams.get('quai') || searchParams.get('voie') || ''; const gareDisplay = gareParam ? decodeURIComponent(String(gareParam).replace(/\+/g, ' ')).replace(/-/g, ' ') : null; + const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); + useAnnonceSonoreRealtime(resolvedGareId); + + useEffect(() => { + let mounted = true; + const resolveGareId = async () => { + if (!supabase) return; + try { + if (gareIdParam) { + const { data: gdata } = await supabase.from('gares').select('id').eq('id', gareIdParam).limit(1); + if (mounted && gdata && gdata.length) { + setResolvedGareId(gdata[0].id); + } + } else if (gareDisplay) { + const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; + const { data: gdata } = await supabase.from('gares').select('id, nom').ilike('nom', flexNeedle); + 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(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); + return n1 === n2; + }); + const chosen = exactMatch || gdata[0]; + if (mounted && chosen) { + setResolvedGareId(chosen.id); + } + } + } + } catch (e) { + console.error('[AFLDepartsQuais] Erreur resolution gare:', e); + } + }; + resolveGareId(); + return () => { mounted = false; }; + }, [gareIdParam, gareDisplay]); + // Real-time clock update useEffect(() => { const timer = setInterval(() => setCurrentTime(new Date()), 1000); diff --git a/src/pages/afficheurs/classiques/ClassiquesArrivees.js b/src/pages/afficheurs/classiques/ClassiquesArrivees.js index 60c0c4b..743b5a6 100644 --- a/src/pages/afficheurs/classiques/ClassiquesArrivees.js +++ b/src/pages/afficheurs/classiques/ClassiquesArrivees.js @@ -3,6 +3,8 @@ import './ClassiquesArrivees.css'; import supabase from '../../../lib/supabase'; import Logo from '../../../components/Logo'; import MarqueeText from '../../../components/MarqueeText'; +import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; +import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (v) => { @@ -97,15 +99,23 @@ export default function ClassiquesArrivees(){ // debug des données brutes renvoyées par Supabase const [rawDebug, setRawDebug] = useState({ count: 0, sample: [] }); + const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); + useAnnonceSonoreRealtime(resolvedGareId); + useAutoAnnonce(horaires, currentTime, true); + // Récupérer le type de gare une seule fois au montage ou quand les paramètres changent useEffect(() => { let mounted = true; const fetchStationType = async () => { try { let fetchedType = null; + let fetchedId = gareIdParam || null; if (gareIdParam) { const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1); - if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; + if (!gerr && gdata && gdata.length) { + fetchedType = gdata[0] && gdata[0].type; + fetchedId = gdata[0] && gdata[0].id; + } } else if (gareDisplay) { const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); @@ -115,11 +125,14 @@ export default function ClassiquesArrivees(){ const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); return n1 === n2; }); - fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + const chosen = exactMatch || gdata[0]; + fetchedType = chosen ? chosen.type : null; + fetchedId = chosen ? chosen.id : null; } } if (mounted) { setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + setResolvedGareId(fetchedId); } } catch (e) { /* ignore */ } }; @@ -665,29 +678,29 @@ export default function ClassiquesArrivees(){ if (gareDisplay) { const normalized = normalizeName(String(gareDisplay).replace(/-/g, ' ')); displayHoraires = displayHoraires.filter(h => { - // Exclure si la gare ciblée est la gare d'arrivée finale - const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); - if (destName === normalized) return false; - - // Inclure si gare de départ correspond + // Exclure si la gare ciblée est la gare de départ const departName = normalizeName((h.gare_depart_nom || h.gare_depart_name || h.gare_depart || '').toString()); - if (departName === normalized) return true; + if (departName === normalized) return false; - // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le terminus (isLast ou index==max) + // Inclure si gare d'arrivée correspond + const destName = normalizeName((h.gare_arrivee_nom || h.gare_arrivee || '').toString()); + if (destName === normalized) return true; + + // Chercher dans les arrêts desservis ; ignorer si l'arrêt est le point de départ (isFirst ou index==0) const stopsList = getStops(h); if (Array.isArray(stopsList) && stopsList.length) { - let maxIdx = null; + let minIdx = null; for (const st of stopsList) { 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); + if (!Number.isNaN(n)) minIdx = minIdx == null ? n : Math.min(minIdx, n); } } for (const s of stopsList) { const stopName = normalizeName((s.gare_nom || s.nom || s.name || s.stop_name || '').toString()); if (!stopName) continue; - const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx)); - if (isTerminus) continue; + const isOrigine = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && minIdx != null && Number(s.index) === minIdx)); + if (isOrigine) continue; if (stopName === normalized) return true; } } @@ -696,29 +709,29 @@ export default function ClassiquesArrivees(){ } else if (gareIdParam) { const gid = String(gareIdParam); displayHoraires = displayHoraires.filter(h => { - // Exclure si gare d'arrivée finale correspond - const destId = (h.gare_arrivee_id || h.gare_arrivee || null); - if (destId != null && String(destId) === gid) return false; - - // Inclure si gare de départ correspond + // Exclure si gare de départ correspond const departId = h.gare_depart_id || h.gare_id || h.origin_id || null; - if (departId != null && String(departId) === gid) return true; + if (departId != null && String(departId) === gid) return false; - // Chercher dans les arrêts desservis (ignorer terminus) + // Inclure si gare d'arrivée finale correspond + const destId = (h.gare_arrivee_id || h.gare_arrivee || null); + if (destId != null && String(destId) === gid) return true; + + // Chercher dans les arrêts desservis (ignorer origine) const stopsList = getStops(h); if (Array.isArray(stopsList) && stopsList.length) { - let maxIdx = null; + let minIdx = null; for (const st of stopsList) { 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); + if (!Number.isNaN(n)) minIdx = minIdx == null ? n : Math.min(minIdx, n); } } for (const s of stopsList) { const stopId = s.gare_id || s.gareId || s.id || s.stop_id || s.stopId || null; if (stopId == null) continue; - const isTerminus = Boolean(s.isLast || s.is_last || (typeof s.index !== 'undefined' && maxIdx != null && Number(s.index) === maxIdx)); - if (isTerminus) continue; + const isOrigine = Boolean(s.isFirst || s.is_first || (typeof s.index !== 'undefined' && minIdx != null && Number(s.index) === minIdx)); + if (isOrigine) continue; if (String(stopId) === gid) return true; } } diff --git a/src/pages/afficheurs/classiques/ClassiquesDeparts.js b/src/pages/afficheurs/classiques/ClassiquesDeparts.js index d883100..05ce3ef 100644 --- a/src/pages/afficheurs/classiques/ClassiquesDeparts.js +++ b/src/pages/afficheurs/classiques/ClassiquesDeparts.js @@ -3,6 +3,8 @@ import './ClassiquesDeparts.css'; import supabase from '../../../lib/supabase'; import Logo from '../../../components/Logo'; import MarqueeText from '../../../components/MarqueeText'; +import useAnnonceSonoreRealtime from '../../../hooks/useAnnonceSonoreRealtime'; +import useAutoAnnonce from '../../../hooks/useAutoAnnonce'; // Helper utilitaire : format HHhMM sans secondes (ex: 06h44) const formatTimeNoSeconds = (v) => { @@ -97,15 +99,23 @@ export default function ClassiquesDeparts(){ // debug des données brutes renvoyées par Supabase const [rawDebug, setRawDebug] = useState({ count: 0, sample: [] }); + const [resolvedGareId, setResolvedGareId] = useState(gareIdParam || null); + useAnnonceSonoreRealtime(resolvedGareId); + useAutoAnnonce(horaires, currentTime, false); + // Récupérer le type de gare une seule fois au montage ou quand les paramètres changent useEffect(() => { let mounted = true; const fetchStationType = async () => { try { let fetchedType = null; + let fetchedId = gareIdParam || null; if (gareIdParam) { const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type').eq('id', gareIdParam).limit(1); - if (!gerr && gdata && gdata.length) fetchedType = gdata[0] && gdata[0].type; + if (!gerr && gdata && gdata.length) { + fetchedType = gdata[0] && gdata[0].type; + fetchedId = gdata[0] && gdata[0].id; + } } else if (gareDisplay) { const flexNeedle = `%${String(gareDisplay).replace(/[-_+\s]+/g, '%')}%`; const { data: gdata, error: gerr } = await supabase.from('gares').select('id,type,nom').ilike('nom', flexNeedle); @@ -115,11 +125,14 @@ export default function ClassiquesDeparts(){ const n2 = String(gareDisplay || '').normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[-_+\s]+/g, ' ').trim(); return n1 === n2; }); - fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + const chosen = exactMatch || gdata[0]; + fetchedType = chosen ? chosen.type : null; + fetchedId = chosen ? chosen.id : null; } } if (mounted) { setStationType(fetchedType ? String(fetchedType).toLowerCase() : null); + setResolvedGareId(fetchedId); } } catch (e) { /* ignore */ } }; diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js index 7e39763..631f5ad 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js +++ b/src/pages/afficheurs/classiques/quais/ClassiquesArriveesQuais.js @@ -1,6 +1,8 @@ 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(() => { @@ -20,6 +22,13 @@ function Clock() { } 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", @@ -38,6 +47,10 @@ export default function ClassiquesArriveesQuais() { 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); @@ -172,12 +185,7 @@ export default function ClassiquesArriveesQuais() { return 0; }, []); - 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 getStops = (h) => { if (!h) return []; @@ -489,22 +497,31 @@ export default function ClassiquesArriveesQuais() { try { let fetchedType = null; try { + let fetchedId = gareIdParam || null; if (gareIdParam) { - const { data: g } = await supabase.from('gares').select('type').eq('gare_id', gareIdParam).single(); - if (g) fetchedType = g.type; + 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('type,nom').ilike('nom', `%${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; }); - fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + const chosen = exactMatch || gdata[0]; + fetchedType = chosen ? chosen.type : null; + fetchedId = chosen ? chosen.id : null; } } + if (mounted) { + setStationType(fetchedType); + setResolvedGareId(fetchedId); + } } catch (e) {} - if (mounted) setStationType(fetchedType); const { data: all, error } = await supabase.from('horaires').select('id, *'); if (error) { @@ -607,7 +624,7 @@ export default function ClassiquesArriveesQuais() { } if (!stopMatch) continue; - const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || ''; + 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) { @@ -625,13 +642,13 @@ export default function ClassiquesArriveesQuais() { 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 depHM = parseTimeHM(rawTime); - if (depHM == null) continue; + const arrHM = parseTimeHM(rawTime); + if (arrHM == null) continue; - let minutesUntil = depHM - nowHM; - if (minutesUntil < 0) minutesUntil = depHM + 24*60 - nowHM; + let minutesUntil = arrHM - nowHM; + if (minutesUntil < 0) minutesUntil = arrHM + 24*60 - nowHM; - candidates.push({ horaire: h, stop: stopMatch, depHM, minutesUntil, rawTime, circulatesToday, circulatesTomorrow }); + candidates.push({ horaire: h, stop: stopMatch, arrHM, minutesUntil, rawTime, circulatesToday, circulatesTomorrow }); } if (!candidates.length) { @@ -641,8 +658,8 @@ export default function ClassiquesArriveesQuais() { return; } - const todayCandidates = candidates.filter(c => c.circulatesToday && c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); - const tomorrowCandidates = candidates.filter(c => c.circulatesTomorrow && (c.depHM <= nowHM || !c.circulatesToday)).sort((a,b) => a.depHM - b.depHM); + 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) { @@ -669,7 +686,7 @@ export default function ClassiquesArriveesQuais() { // Rail is handled purely in CSS for better performance and alignment const showPlatform = (() => { - const sched = selectedHoraire ? selectedHoraire.depHM : null; + const sched = selectedHoraire ? selectedHoraire.arrHM : null; if (sched === null || !stationType) return true; const nowHM = currentTime.getHours() * 60 + currentTime.getMinutes(); const threshold = thresholdForStationType(stationType); diff --git a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js index 214b762..a479848 100644 --- a/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js +++ b/src/pages/afficheurs/classiques/quais/ClassiquesDepartsQuais.js @@ -1,6 +1,8 @@ import React, { useEffect, useState, useCallback } from 'react'; import './ClassiquesDepartsQuais.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(() => { @@ -20,6 +22,13 @@ function Clock() { } export default function ClassiquesDepartsQuais() { + 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", @@ -38,6 +47,10 @@ export default function ClassiquesDepartsQuais() { 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, false); const [alternateDelayPhase, setAlternateDelayPhase] = useState(true); const [alternateCancelPhase, setAlternateCancelPhase] = useState(true); @@ -172,12 +185,7 @@ export default function ClassiquesDepartsQuais() { return 0; }, []); - 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 getStops = (h) => { if (!h) return []; @@ -489,22 +497,31 @@ export default function ClassiquesDepartsQuais() { try { let fetchedType = null; try { + let fetchedId = gareIdParam || null; if (gareIdParam) { - const { data: g } = await supabase.from('gares').select('type').eq('gare_id', gareIdParam).single(); - if (g) fetchedType = g.type; + 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('type,nom').ilike('nom', `%${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; }); - fetchedType = exactMatch ? exactMatch.type : gdata[0].type; + const chosen = exactMatch || gdata[0]; + fetchedType = chosen ? chosen.type : null; + fetchedId = chosen ? chosen.id : null; } } + if (mounted) { + setStationType(fetchedType); + setResolvedGareId(fetchedId); + } } catch (e) {} - if (mounted) setStationType(fetchedType); const { data: all, error } = await supabase.from('horaires').select('id, *'); if (error) { @@ -605,7 +622,7 @@ export default function ClassiquesDepartsQuais() { } if (!stopMatch) continue; - const platform = (stopMatch && (stopMatch.quai || stopMatch.platform || stopMatch.quay)) || h.platform || h.quai || ''; + 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) { @@ -639,8 +656,8 @@ export default function ClassiquesDepartsQuais() { return; } - const todayCandidates = candidates.filter(c => c.circulatesToday && c.depHM > nowHM).sort((a,b) => a.depHM - b.depHM); - const tomorrowCandidates = candidates.filter(c => c.circulatesTomorrow && (c.depHM <= nowHM || !c.circulatesToday)).sort((a,b) => a.depHM - b.depHM); + const todayCandidates = candidates.filter(c => c.circulatesToday && c.depHM >= nowHM - 1).sort((a,b) => a.depHM - b.depHM); + const tomorrowCandidates = candidates.filter(c => c.circulatesTomorrow && (c.depHM < nowHM - 1 || !c.circulatesToday)).sort((a,b) => a.depHM - b.depHM); let chosen = null; if (todayCandidates.length > 0) { diff --git a/test_horaire.js b/test_horaire.js new file mode 100644 index 0000000..f21bbe0 --- /dev/null +++ b/test_horaire.js @@ -0,0 +1,23 @@ +const horaires = [{ heure_depart: '21:00', numero_train: '123' }]; +const isArrival = false; +const currentTime = new Date(); +currentTime.setHours(20, 55, 0, 0); + +for (const train of horaires) { + const numTrain = train.num_train || train.numero_train || train.numero; + const timeStr = isArrival + ? (train.heure_arrivee || train.arrival_time || train.time || train.heure) + : (train.heure_depart || train.departure_time || train.time || train.heure); + + let trainTime; + if (typeof timeStr === 'string' && timeStr.length <= 5 && timeStr.includes(':')) { + const [hh, mm] = timeStr.split(':'); + trainTime = new Date(currentTime); + trainTime.setHours(parseInt(hh, 10), parseInt(mm, 10), 0, 0); + } else { + trainTime = new Date(timeStr); + } + const diffMs = trainTime.getTime() - currentTime.getTime(); + const diffMins = Math.floor(diffMs / 60000); + console.log(`numTrain=${numTrain}, timeStr=${timeStr}, diffMins=${diffMins}`); +} diff --git a/test_realtime.js b/test_realtime.js new file mode 100644 index 0000000..90f1ad8 --- /dev/null +++ b/test_realtime.js @@ -0,0 +1,24 @@ +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); + +const channel = supabase + .channel('test_channel') + .on( + 'postgres_changes', + { event: 'INSERT', schema: 'public', table: 'annonces_diffusions' }, + (payload) => { + console.log('Realtime Event Received!', payload); + } + ) + .subscribe((status) => { + console.log('Subscription status:', status); + }); + +console.log('Waiting for events...'); +// We will kill this process manually or let it timeout +setTimeout(() => { + console.log('Timeout. Exiting.'); + process.exit(0); +}, 10000);