From 710b5a3a6d2669b696dfe782b07fe7aa1b9fb3bb Mon Sep 17 00:00:00 2001 From: Admin Git Date: Sun, 4 Oct 2026 00:32:16 +0200 Subject: [PATCH] UPDATE PROCHAINS D2PARTS --- src/app/[region]/page.tsx | 8 +- src/app/page.tsx | 57 ++- src/components/OffersPageClient.tsx | 160 ++++--- src/components/ProchainsDepartsClient.tsx | 418 +++++++++++++---- src/components/RegionContent.tsx | 4 +- src/components/SiteHeader.tsx | 11 - src/components/StationDeparturesBoard.tsx | 458 +++++++++++++----- src/components/TimetableSearchForm.tsx | 110 ++++- src/components/TrainDetailsClient.tsx | 537 +++++++++++++++------- src/lib/materielUtils.ts | 95 ++++ src/lib/parcoursUtils.ts | 269 +++++++++++ src/lib/perturbationUtils.ts | 156 ++++++- 12 files changed, 1780 insertions(+), 503 deletions(-) create mode 100644 src/lib/materielUtils.ts create mode 100644 src/lib/parcoursUtils.ts diff --git a/src/app/[region]/page.tsx b/src/app/[region]/page.tsx index 2db75fa..becefad 100644 --- a/src/app/[region]/page.tsx +++ b/src/app/[region]/page.tsx @@ -82,14 +82,14 @@ export default async function RegionPage({ console.error('Error fetching info_trafic:', e); } - // 3. Fetch actualites strictly filtered by region_id + // 3. Fetch actualites (region-specific or global with region_id null) let actualitesList: ActualiteItem[] = []; try { const { data: actusData } = await supabase .from('actualites') .select('*') .eq('actif', true) - .in('region_id', regionCandidates) + .or(`region_id.is.null,region_id.in.(${regionCandidates.join(',')})`) .is('deleted_at', null) .order('date_publication', { ascending: false }) .order('created_at', { ascending: false }); @@ -100,14 +100,14 @@ export default async function RegionPage({ console.error('Error fetching actualites:', e); } - // 4. Fetch evenements strictly filtered by region_id + // 4. Fetch evenements (region-specific or global with region_id null) let evenementsList: EvenementItem[] = []; try { const { data: eventsData } = await supabase .from('evenements') .select('*') .eq('actif', true) - .in('region_id', regionCandidates) + .or(`region_id.is.null,region_id.in.(${regionCandidates.join(',')})`) .is('deleted_at', null) .order('date_debut', { ascending: false }); if (eventsData) { diff --git a/src/app/page.tsx b/src/app/page.tsx index c7f5008..7d441d1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,9 @@ import React from 'react'; import Link from 'next/link'; +import { supabase } from '@/lib/supabase'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; export const metadata = { title: 'Portail des Régions | TER Ferrovia', @@ -20,7 +24,22 @@ const regions = [ { slug: 'auvergne-rhone-alpes', name: 'Auvergne-Rhône-Alpes', logo: 'https://www.ter.sncf.com/assets/medias/aura/headerLogo.svg', color: '#004f9f' }, ]; -export default function PortalPage() { +export default async function PortalPage() { + let actualites: any[] = []; + try { + const { data } = await supabase + .from('actualites') + .select('*') + .eq('actif', true) + .is('deleted_at', null) + .order('date_publication', { ascending: false }) + .order('created_at', { ascending: false }) + .limit(6); + if (data) actualites = data; + } catch (err) { + console.error('Error fetching actualites on portal page:', err); + } + return (
@@ -62,6 +81,42 @@ export default function PortalPage() { ))}
+ + {/* Section Actualités du réseau */} + {actualites.length > 0 && ( +
+
+

Actualités du réseau

+

Informations et actualités TER Ferrovia

+
+ +
+ {actualites.map((actu) => ( +
+
+ {actu.image_couverture || actu.image_url ? ( +
+ {actu.titre} +
+ ) : ( +
+ + {actu.categorie || 'Actualité'} + +

{actu.titre}

+
+ )} +

{actu.titre}

+

{actu.resume || actu.contenu}

+
+
+ {actu.date_publication ? new Date(actu.date_publication).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' }) : ''} +
+
+ ))} +
+
+ )}
diff --git a/src/components/OffersPageClient.tsx b/src/components/OffersPageClient.tsx index 8f1e945..5bc84e8 100644 --- a/src/components/OffersPageClient.tsx +++ b/src/components/OffersPageClient.tsx @@ -7,6 +7,9 @@ import { supabase } from '@/lib/supabase'; import { getRegionTheme } from '@/lib/regionTheme'; import SiteHeader from '@/components/SiteHeader'; import SiteFooter from '@/components/SiteFooter'; +import { isPerturbationActiveOnDate, formatPerturbationCause, getPerturbationPriority } from '@/lib/perturbationUtils'; +import { resolveEffectiveMateriel } from '@/lib/materielUtils'; +import { resolvePerturbedRoute } from '@/lib/parcoursUtils'; interface OffersPageClientProps { regionSlug?: string; @@ -211,34 +214,28 @@ export default function OffersPageClient({ }; // Helper to extract exact segment stops & times for user departure -> arrival segment - const resolveTrainSegment = (item: any, depQuery: string, arrQuery: string, gMap: Record) => { - let stops: TrainStop[] = []; - - if (item.gares_desservies && Array.isArray(item.gares_desservies) && item.gares_desservies.length > 0) { - stops = item.gares_desservies.map((g: any) => ({ + const resolveTrainSegment = (resolvedStops: any[], depQuery: string, arrQuery: string, defaultFinalDest: string) => { + // Only consider served, unsuppressed stops + const activeStops: TrainStop[] = resolvedStops + .filter((s: any) => !s.is_supprime) + .map((g: any) => ({ time: (g.heure_depart || g.heure_arrivee || '00:00').slice(0, 5), - stationName: g.gare_nom || (g.gare_id ? gMap[g.gare_id] : ''), + stationName: g.gare_nom || '', })); - } else { - stops = [ - { time: (item.heure_depart || '08:00').slice(0, 5), stationName: item.gare_depart_nom || (item.gare_depart_id ? gMap[item.gare_depart_id] : '') || depQuery }, - { time: (item.heure_arrivee || '08:45').slice(0, 5), stationName: item.gare_arrivee_nom || (item.gare_arrivee_id ? gMap[item.gare_arrivee_id] : '') || arrQuery }, - ]; - } - const depIdx = stops.findIndex(s => matchStation(depQuery, s.stationName)); - const arrIdx = stops.findIndex(s => matchStation(arrQuery, s.stationName)); + const depIdx = activeStops.findIndex(s => matchStation(depQuery, s.stationName)); + const arrIdx = activeStops.findIndex(s => matchStation(arrQuery, s.stationName)); // STRICT DIRECTION FILTERING: Train MUST travel in direction depIdx < arrIdx! if (depIdx === -1 || arrIdx === -1 || depIdx >= arrIdx) { return null; } - const segStops = stops.slice(depIdx, arrIdx + 1); - const segDepTime = stops[depIdx].time; - const segArrTime = stops[arrIdx].time; - const segDepName = stops[depIdx].stationName; - const segArrName = stops[arrIdx].stationName; + const segStops = activeStops.slice(depIdx, arrIdx + 1); + const segDepTime = activeStops[depIdx].time; + const segArrTime = activeStops[arrIdx].time; + const segDepName = activeStops[depIdx].stationName; + const segArrName = activeStops[arrIdx].stationName; // Calculate segment duration const [h1, m1] = segDepTime.split(':').map(Number); @@ -249,7 +246,7 @@ export default function OffersPageClient({ const durMins = totalMins % 60; const durStr = durHours > 0 ? `${durHours}h${durMins < 10 ? '0' : ''}${durMins}` : `${durMins} min`; - const finalDest = stops[stops.length - 1]?.stationName || item.gare_arrivee_nom || arrQuery; + const finalDest = activeStops[activeStops.length - 1]?.stationName || defaultFinalDest || arrQuery; return { depTime: segDepTime, @@ -356,6 +353,11 @@ export default function OffersPageClient({ .eq('actif', true) .is('deleted_at', null); + // 5. Query materiel-roulant from Supabase + const { data: allMatsData } = await supabase + .from('materiel-roulant') + .select('*'); + // Determine day of week column from outwardDate const dayLower = (outwardDate || '').toLowerCase(); let targetCol = 'circule_lundi'; @@ -397,7 +399,21 @@ export default function OffersPageClient({ if (!filterDay(item)) return; - const seg = resolveTrainSegment(item, departure, arrival, gMap); + // Match active perturbation for this train strictly on target date + const matchingPert = pertData?.find((p: any) => { + const matchTrain = + p.horaire_id === item.id || + String(p.horaire_id) === String(item.id) || + String(p.horaire_id) === String(item.numero_train) || + (p.numero_train && String(p.numero_train) === String(item.numero_train)); + if (!matchTrain) return false; + return isPerturbationActiveOnDate(p, targetIsoDate); + }); + + // Resolve real-world route considering active perturbations + const resolvedParcours = resolvePerturbedRoute(item, matchingPert, gMap); + + const seg = resolveTrainSegment(resolvedParcours.stops, departure, arrival, resolvedParcours.destination); if (!seg) return; // Check if segment departure time is >= user's chosen time @@ -408,33 +424,53 @@ export default function OffersPageClient({ if (seenKeys.has(uniqueKey)) return; seenKeys.add(uniqueKey); - // Match active perturbation for this train - const matchingPert = pertData?.find((p: any) => { - const matchTrain = p.horaire_id === item.id || p.horaire_id === item.numero_train; - if (!matchTrain) return false; + let pertType = matchingPert?.type_perturbation || (resolvedParcours.hasParcoursModification ? 'parcours_modifie' : 'Perturbation'); + let pertDelay = matchingPert?.duree_retard_minutes || 0; + let pertCause = matchingPert?.cause || ''; + let pertDetails = matchingPert?.cause_details || matchingPert?.notes || ''; - const pStart = (p.date_debut || '').slice(0, 10); - const pEnd = (p.date_fin || '').slice(0, 10); + if (matchingPert) { + const arretsList = Array.isArray(matchingPert.perturbations_arrets) + ? matchingPert.perturbations_arrets + : Array.isArray(matchingPert.perturbations_arrets?.arrets) + ? matchingPert.perturbations_arrets.arrets + : []; - if (pStart && pEnd && pStart <= targetIsoDate && targetIsoDate <= pEnd) { - return true; - } - if (p.jours_perturbation && Array.isArray(p.jours_perturbation)) { - const jpMatch = p.jours_perturbation.some((jp: any) => { - const d = (jp.date || jp.dateDebut || '').slice(0, 10); - return d === targetIsoDate; + if (arretsList.length > 0) { + const stopPert = arretsList.find((pa: any) => { + const paNom = (pa.gare_nom || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); + const depNorm = (seg.depName || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); + return paNom.includes(depNorm) || depNorm.includes(paNom); }); - if (jpMatch) return true; + if (stopPert) { + if (stopPert.type_perturbation) pertType = stopPert.type_perturbation; + if (stopPert.cause_arret) pertDetails = stopPert.cause_arret; + const stopDelay = stopPert.retard_cumule ?? stopPert.retard_entrant ?? stopPert.retard_minutes; + if (typeof stopDelay === 'number' && stopDelay > 0) { + pertDelay = stopDelay; + } + } } - return !pStart && !pEnd && (!p.jours_perturbation || p.jours_perturbation.length === 0); + } + + const priority = getPerturbationPriority({ + isSupprime: matchingPert?.type_perturbation === 'suppression', + hasParcoursModification: resolvedParcours.hasParcoursModification, + perturbationType: pertType, + delayMinutes: pertDelay, }); + let pertLabel = 'Information'; + if (priority === 'suppression') pertLabel = 'Train supprimé'; + else if (priority === 'parcours_modifie') pertLabel = 'Parcours modifié'; + else if (priority === 'retard') pertLabel = 'Retard'; + const pertInfo: PerturbationInfo | null = matchingPert ? { id: matchingPert.id?.toString(), - type: matchingPert.type_perturbation || 'Perturbation', - cause: matchingPert.cause || 'Incident de circulation', - causeDetails: matchingPert.cause_details || matchingPert.notes || '', - delayMinutes: matchingPert.duree_retard_minutes || 0, + type: pertLabel, + cause: formatPerturbationCause(pertCause) || 'Incident de circulation', + causeDetails: pertDetails, + delayMinutes: pertDelay, notes: matchingPert.notes || '', } : null; @@ -442,15 +478,19 @@ export default function OffersPageClient({ const isShortRoute = seg.arrName.toLowerCase().includes('pagny') || seg.arrName.toLowerCase().includes('brazey') || seg.depName.toLowerCase().includes('brazey'); const priceVal = isShortRoute ? '6,50 €' : '13,00 €'; - // Extract matériel roulant image from composition_train BDD - const composition = Array.isArray(item.composition_train) ? item.composition_train : []; - const firstMat = composition.length > 0 ? composition[0] : null; - + // Extract matériel roulant image using resolveEffectiveMateriel const diffDays = Math.ceil((new Date(targetIsoDate).getTime() - new Date().setHours(0,0,0,0)) / (1000 * 60 * 60 * 24)); const showMateriel = diffDays <= 1; - - const matImageUrl = showMateriel ? (firstMat?.materiel_image_url || 'https://supabase-ferrovia.mrpatator.fr/storage/v1/object/public/materiel-roulant/images/1789909846752_regiolis_firsttosecond_bluegreenmobigo_4c__1_.png') : undefined; - const matNameText = firstMat ? `Un train "${firstMat.materiel_nom || 'Régiolis'}" ${firstMat.materiel_nom_technique ? `(${firstMat.materiel_nom_technique})` : ''}` : (item.modele_train || 'Un train "AGC"'); + + const effectiveMat = resolveEffectiveMateriel(item.composition_train, allMatsData || [], { + modeleTrain: item.modele_train || 'Régiolis', + capaciteTexte: item.capacite_texte, + imageUrl: 'https://supabase-ferrovia.mrpatator.fr/storage/v1/object/public/materiel-roulant/images/1789909846752_regiolis_firsttosecond_bluegreenmobigo_4c__1_.png' + }); + + const matImageUrl = showMateriel ? effectiveMat.imageUrl : undefined; + const matNameText = `Un train "${effectiveMat.nom}"${effectiveMat.nomTechnique ? ` (${effectiveMat.nomTechnique})` : ''}`; + const capacityVal = effectiveMat.places ? `${effectiveMat.voitures} voitures - ${effectiveMat.places} places` : (item.capacite_texte || '4 voitures - 220 places'); mappedList.push({ id: item.id?.toString() || idx.toString(), @@ -464,7 +504,7 @@ export default function OffersPageClient({ finalDestination: seg.finalDestination, operator: item.operateur || 'SNCF Voyageurs - 1187', trainModel: matNameText, - capacityText: item.capacite_texte || '4 voitures - 220 places', + capacityText: capacityVal, onboardServices: '🚲', priceClass: idx === hData.length - 1 ? 'À partir de' : '2nd classe', price: priceVal, @@ -637,13 +677,27 @@ export default function OffersPageClient({ > {/* PERTURBATION WARNING BANNER */} {offer.perturbation && ( -
+
- warning - Perturbation : - {offer.perturbation.cause || offer.perturbation.type || 'Incident de circulation'} + + {offer.perturbation.type === 'Train supprimé' ? '✕' : offer.perturbation.type === 'Parcours modifié' ? '↔' : '!'} + + {offer.perturbation.type} : + {offer.perturbation.cause || 'Incident de circulation'}
- {offer.perturbation.delayMinutes ? ( + {offer.perturbation.delayMinutes && offer.perturbation.type === 'Retard' ? ( Retard : +{offer.perturbation.delayMinutes} min diff --git a/src/components/ProchainsDepartsClient.tsx b/src/components/ProchainsDepartsClient.tsx index cbb76e9..8ff92e0 100644 --- a/src/components/ProchainsDepartsClient.tsx +++ b/src/components/ProchainsDepartsClient.tsx @@ -7,7 +7,8 @@ import { supabase } from '@/lib/supabase'; import SiteHeader from '@/components/SiteHeader'; import SiteFooter from '@/components/SiteFooter'; import { getRegionTheme } from '@/lib/regionTheme'; -import { isPerturbationActiveOnDate } from '@/lib/perturbationUtils'; +import { isPerturbationActiveOnDate, getPerturbationBannerStyle, formatPerturbationCause, getPerturbationPriority } from '@/lib/perturbationUtils'; +import { resolvePerturbedRoute } from '@/lib/parcoursUtils'; interface ProchainsDepartsClientProps { regionSlug?: string; @@ -31,6 +32,9 @@ export interface StopItem { perturbationType?: string; perturbationDetails?: string; isSupprime?: boolean; + isNouveauTerminus?: boolean; + isNouvelleOrigine?: boolean; + isAddedManually?: boolean; retardMinutes?: number; } @@ -40,6 +44,7 @@ export interface DepartureRow { revisedTime?: string; delayText?: string; isSupprime?: boolean; + isTrainSupplementaire?: boolean; destination: string; modeIcon: 'bus' | 'train'; modeLabel: string; @@ -62,6 +67,7 @@ export interface ArrivalRow { revisedTime?: string; delayText?: string; isSupprime?: boolean; + isTrainSupplementaire?: boolean; provenance: string; modeIcon: 'bus' | 'train'; modeLabel: string; @@ -910,18 +916,35 @@ export default function ProchainsDepartsClient({ const arrList: ArrivalRow[] = []; const dateLabelStr = isNextDay ? formatDateLabel(targetDateIso) : undefined; - hData.forEach((h: any, idx: number) => { - if (!checkCirculation(h, targetDateIso)) return; - - let gares = []; - if (typeof h.gares_desservies === 'string') { - try { gares = JSON.parse(h.gares_desservies); } catch (e) {} - } else if (Array.isArray(h.gares_desservies)) { - gares = h.gares_desservies; + // Collect active substitution horaire IDs for targetDateIso + const subHoraireIds = new Set(); + pertData?.forEach((p: any) => { + if (p.sillon_substitution_id && isPerturbationActiveOnDate(p, targetDateIso)) { + subHoraireIds.add(String(p.sillon_substitution_id)); } + }); + + hData.forEach((h: any, idx: number) => { + const isSub = subHoraireIds.has(String(h.id)) || subHoraireIds.has(String(h.numero_train)); + const isCirculating = checkCirculation(h, targetDateIso); + if (!isCirculating && !isSub) return; + + // Perturbation match strictly on timetable target date + const matchingPert = pertData?.find((p: any) => { + return ( + p.horaire_id === h.id || + String(p.horaire_id) === String(h.id) || + String(p.horaire_id) === String(h.numero_train) || + (p.numero_train && String(p.numero_train) === String(h.numero_train)) + ) && isPerturbationActiveOnDate(p, targetDateIso); + }); + + // Resolve real-world route considering perturbations (new terminus, new origin, deleted or added stops) + const resolvedParcours = resolvePerturbedRoute(h, matchingPert, gMap); + const gares = resolvedParcours.stops; const stopIdx = gares.findIndex((g: any) => { - const gNom = (g.gare_nom || gMap[g.gare_id] || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); + const gNom = (g.gare_nom || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); return gNom.includes(cleanName) || cleanName.includes(gNom); }); @@ -929,16 +952,11 @@ export default function ProchainsDepartsClient({ const stop = gares[stopIdx]; const depTime = (stop.heure_depart || stop.heure_arrivee || '').slice(0, 5) || '08:00'; const arrTime = (stop.heure_arrivee || stop.heure_depart || '').slice(0, 5) || '08:00'; - const isTerminus = stopIdx === gares.length - 1 && !stop.heure_depart; - const isOrigin = stopIdx === 0; + const isTerminus = stop.is_nouveau_terminus || (stopIdx === gares.length - 1 && !stop.heure_depart) || stop.is_arrivee; + const isOrigin = stop.is_nouvelle_origine || stopIdx === 0 || stop.is_depart; const badge = (h.ligne_id && lMap[h.ligne_id]) || (h.badge_ligne ? String(h.badge_ligne).trim() : undefined) || deriveBadge(h); - // Perturbation match - const matchingPert = pertData?.find((p: any) => { - return (p.horaire_id === h.id || String(p.horaire_id) === String(h.numero_train)) && isPerturbationActiveOnDate(p, targetDateIso); - }); - // Travaux match on line const matchingTravaux = trData?.find((t: any) => { const tL = (t.ligne_nom || '').toLowerCase().trim(); @@ -959,37 +977,90 @@ export default function ProchainsDepartsClient({ return true; }); - let pertType: any = matchingPert?.type_perturbation || (matchingTravaux ? 'information' : undefined); - let pertDetails: string | undefined = matchingPert?.cause_details || matchingPert?.cause || (matchingTravaux ? (matchingTravaux.description || matchingTravaux.titre) : undefined); - let isSupp = matchingPert?.type_perturbation === 'suppression'; + let pertCause = matchingPert?.cause || ''; + let pertDetails: string | undefined = matchingPert?.cause_details || matchingPert?.notes || (matchingTravaux ? (matchingTravaux.description || matchingTravaux.titre) : undefined); + let isSupp = matchingPert?.type_perturbation === 'suppression' || Boolean(stop.is_supprime); + let delayMins = matchingPert?.duree_retard_minutes; + + // Stop-specific cause and delay if defined + if (stop.cause_arret) pertCause = stop.cause_arret; + if (stop.retard_minutes !== undefined && stop.retard_minutes > 0) { + delayMins = stop.retard_minutes; + } + + const formattedCause = formatPerturbationCause(pertCause); + + // Strictly resolve priority: Supprimé > Modification de parcours > Retardé > A l'heure + const priority = getPerturbationPriority({ + isSupprime: isSupp, + hasParcoursModification: resolvedParcours.hasParcoursModification, + perturbationType: matchingPert?.type_perturbation, + typesPerturbation: stop.types_perturbation, + delayMinutes: delayMins, + }); + + let pertType: any = undefined; let revisedTimeStr: string | undefined = undefined; let delayTextStr: string | undefined = undefined; - if (isSupp) { - delayTextStr = 'Train supprimé'; - } else if (matchingPert?.duree_retard_minutes) { - const delayMins = matchingPert.duree_retard_minutes; + if (priority === 'suppression') { + isSupp = true; + pertType = 'suppression'; + delayTextStr = formattedCause ? `Train supprimé (${formattedCause})` : (stop.cause_arret || 'Train supprimé'); + } else if (priority === 'parcours_modifie') { + isSupp = false; + pertType = 'parcours_modifie'; + const extraRouteInfo = resolvedParcours.nouveauTerminusNom ? `Terminus ${resolvedParcours.nouveauTerminusNom}` : (resolvedParcours.nouvelleOrigineNom ? `Origine ${resolvedParcours.nouvelleOrigineNom}` : ''); + if (formattedCause && extraRouteInfo) { + delayTextStr = `Modification de parcours : ${extraRouteInfo} (${formattedCause})`; + } else if (extraRouteInfo) { + delayTextStr = `Modification de parcours : ${extraRouteInfo}`; + } else if (formattedCause) { + delayTextStr = `Modification de parcours (${formattedCause})`; + } else { + delayTextStr = 'Modification de parcours'; + } + } else if (priority === 'retard') { + isSupp = false; + pertType = 'retard'; const [hH, hM] = depTime.split(':').map(Number); - const revTotal = hH * 60 + hM + delayMins; + const revTotal = (isNaN(hH) ? 0 : hH) * 60 + (isNaN(hM) ? 0 : hM) + (delayMins || 0); const revH = Math.floor(revTotal / 60) % 24; const revM = revTotal % 60; revisedTimeStr = `${String(revH).padStart(2, '0')}:${String(revM).padStart(2, '0')}`; - delayTextStr = `Retard estimé de ${delayMins} min`; + delayTextStr = formattedCause ? `Retard estimé de ${delayMins} min (${formattedCause})` : `Retard estimé de ${delayMins} min`; + } else { + // A l'heure + isSupp = false; + if (matchingTravaux) { + pertType = 'information'; + delayTextStr = matchingTravaux.titre || matchingTravaux.description || 'Information trafic'; + } else { + pertType = undefined; + delayTextStr = undefined; + } } - // Map stops for timeline - const stopsMapped: StopItem[] = gares.map((g: any, gIdx: number) => { - const sDep = (g.heure_depart || g.heure_arrivee || '').slice(0, 5); - const sArr = (g.heure_arrivee || g.heure_depart || '').slice(0, 5); + // Map stops for timeline accordion + const stopsMapped: StopItem[] = gares.map((g: any) => { + const sDep = g.heure_depart || g.heure_arrivee || ''; + const sArr = g.heure_arrivee || g.heure_depart || ''; const rawQ = g.quai || g.quai_reel || ''; return { departTime: sDep, arriveeTime: sArr, - gareNom: g.gare_nom || gMap[g.gare_id] || '', + gareNom: g.gare_nom, voie: formatQuaiDisplay(rawQ, sDep || sArr, targetDateIso, h.type_train, currentStationType), rawQuai: rawQ, - isDepart: gIdx === 0, - isArrivee: gIdx === gares.length - 1, + isDepart: g.is_depart, + isArrivee: g.is_arrivee, + isSupprime: g.is_supprime, + isNouveauTerminus: g.is_nouveau_terminus, + isNouvelleOrigine: g.is_nouvelle_origine, + isAddedManually: g.is_added_manually, + retardMinutes: g.retard_minutes, + perturbationType: g.type_perturbation || pertType, + perturbationDetails: g.cause_arret || pertDetails, }; }); @@ -1003,7 +1074,8 @@ export default function ProchainsDepartsClient({ revisedTime: revisedTimeStr, delayText: delayTextStr, isSupprime: isSupp, - destination: h.gare_arrivee_nom || gares[gares.length - 1]?.gare_nom || 'Terminus', + isTrainSupplementaire: isSub, + destination: resolvedParcours.destination, modeIcon: (h.type_train || '').toLowerCase().includes('car') ? 'bus' : 'train', modeLabel: h.type_train || 'Train MOBIGO', trainNumber: h.numero_train || `89${idx + 301}`, @@ -1027,7 +1099,8 @@ export default function ProchainsDepartsClient({ revisedTime: revisedTimeStr, delayText: delayTextStr, isSupprime: isSupp, - provenance: h.gare_depart_nom || gares[0]?.gare_nom || 'Origine', + isTrainSupplementaire: isSub, + provenance: resolvedParcours.provenance, modeIcon: (h.type_train || '').toLowerCase().includes('car') ? 'bus' : 'train', modeLabel: h.type_train || 'Train MOBIGO', trainNumber: h.numero_train || `89${idx + 301}`, @@ -1519,6 +1592,11 @@ export default function ProchainsDepartsClient({ {item.trainBadge} )} + {item.isTrainSupplementaire && ( + + Train Supplémentaire + + )}
@@ -1536,6 +1614,11 @@ export default function ProchainsDepartsClient({ {item.trainBadge} )} + {item.isTrainSupplementaire && ( + + Train Supplémentaire + + )}
{/* Column 4: Voie (prominently visible on mobile & desktop) */} @@ -1564,23 +1647,44 @@ export default function ProchainsDepartsClient({
- {/* Information Banner on closed row */} - {hasInfo && !isExpanded && ( -
setExpandedId(isExpanded ? null : item.id)} - className="relative bg-[#e8f4fc] px-4 py-1.5 sm:px-6 sm:py-1.5 text-[12px] text-[#0055a5] flex items-center gap-2 cursor-pointer border-t border-b border-[#d8ebf9]" - > -
- - i - - - {item.isSupprime ? 'Train supprimé' : (item.delayText || 'Information')} - -
- )} + {/* Status Banner on closed row (retard, suppression, modification de parcours) */} + {hasInfo && !isExpanded && (() => { + const priority = getPerturbationPriority({ + isSupprime: item.isSupprime, + hasParcoursModification: item.perturbationType === 'parcours_modifie' || item.perturbationType?.includes('parcours'), + perturbationType: item.perturbationType, + delayMinutes: item.revisedTime ? 1 : undefined, + }); + if (priority === 'a_l_heure' && item.perturbationType !== 'information') return null; + const bStyle = getPerturbationBannerStyle(priority === 'a_l_heure' ? 'information' : priority); + return ( +
setExpandedId(isExpanded ? null : item.id)} + className="relative px-4 py-1.5 sm:px-6 sm:py-1.5 text-[12px] flex items-center gap-2 cursor-pointer border-t border-b" + style={{ + backgroundColor: bStyle.bgColor, + borderColor: `${bStyle.textColor}33`, + color: bStyle.textColor, + }} + > +
+ + {bStyle.iconText} + + + {item.delayText || bStyle.defaultCause || 'Information'} + +
+ ); + })()} - {/* ─── EXPANDED TIMELINE ACCORDION (STRICTLY CONFORME IMG 2) ─── */} + {/* ─── EXPANDED TIMELINE ACCORDION (STRICTLY CONFORME IMG 2 & IMG 1) ─── */} {isExpanded && (
@@ -1588,7 +1692,7 @@ export default function ProchainsDepartsClient({

Liste des gares

-
+
desservies par le {item.modeIcon === 'bus' ? 'CAR' : 'TRAIN'} {(item.modeLabel || 'MOBIGO').toUpperCase().replace(/^(TRAIN|CAR)\s+/i, '')} {item.trainNumber || ''} @@ -1600,34 +1704,74 @@ export default function ProchainsDepartsClient({ {item.trainBadge} )} + {item.isTrainSupplementaire && ( + + Train Supplémentaire + + )}

{item.operator || 'Opéré par SNCF Voyageurs - 1187'}

- {/* Information description box with full HTML support */} - {item.perturbationDetails && ( -
-
- - i - - Information : -
- {/<[a-z][\s\S]*>/i.test(item.perturbationDetails) ? ( -
- ) : ( -
- {item.perturbationDetails} -
- )} -
- )} + {/* Perturbation Banner & Details in Timetable Accordion */} + {hasInfo && (() => { + const priority = getPerturbationPriority({ + isSupprime: item.isSupprime, + hasParcoursModification: item.perturbationType === 'parcours_modifie' || item.perturbationType?.includes('parcours'), + perturbationType: item.perturbationType, + delayMinutes: item.revisedTime ? 1 : undefined, + }); + if (priority === 'a_l_heure' && item.perturbationType !== 'information' && !item.perturbationDetails) return null; + const bStyle = getPerturbationBannerStyle(priority === 'a_l_heure' ? 'information' : priority); + const headerTitle = item.delayText || ( + priority === 'suppression' + ? 'Train supprimé' + : priority === 'parcours_modifie' + ? 'Modification de parcours' + : priority === 'retard' + ? 'Retard estimé' + : 'Information trafic' + ); - {/* Table of stops with 100% continuous yellow line */} + return ( +
+
+ + {bStyle.iconText} + + {headerTitle} +
+ + {item.perturbationDetails && ( +
+ {/<[a-z][\s\S]*>/i.test(item.perturbationDetails) ? ( +
+ ) : ( +
+ {item.perturbationDetails} +
+ )} +
+ )} +
+ ); + })()} + + {/* Table of stops with 100% continuous yellow line (Matching Image 1) */}
{/* Table Subheader */} @@ -1657,7 +1801,7 @@ export default function ProchainsDepartsClient({ > {/* Column 1: Green circular radio selector ring */}
- {!isOrigin && ( + {!isOrigin && !stop.isSupprime && (
- {/* Column 2: Departure time */} - { - if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom })); - }} - className={`font-normal text-gray-900 ${!isOrigin ? 'cursor-pointer' : ''}`} - > - {stop.departTime || stop.arriveeTime} - + {/* Column 2: Departure time (always displayed, struck-through if suppressed as requested) */} +
+ {stop.isSupprime ? ( + + {stop.departTime || stop.arriveeTime} + + ) : stop.retardMinutes ? ( +
+ + {stop.departTime || stop.arriveeTime} + + + {(() => { + const [sH, sM] = (stop.departTime || stop.arriveeTime || '00:00').split(':').map(Number); + const total = sH * 60 + sM + stop.retardMinutes; + const rH = Math.floor(total / 60) % 24; + const rM = total % 60; + return `${String(rH).padStart(2, '0')}:${String(rM).padStart(2, '0')}`; + })()} + +
+ ) : ( + { + if (!isOrigin && !stop.isSupprime) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom })); + }} + className={`font-normal text-gray-900 ${!isOrigin ? 'cursor-pointer' : ''}`} + > + {stop.departTime || stop.arriveeTime} + + )} +
- {/* Column 3: Solid Snap Dot */} + {/* Column 3: Solid Snap Dot (Yellow #d2db24 uninterrupted, or primary if selected) */}
- {/* Column 4: Station name */} - { - if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom })); - }} - className={`font-normal text-gray-900 ${!isOrigin ? 'cursor-pointer hover:underline' : ''} ${ - isSelected ? 'font-semibold text-gray-950' : '' - }`} - > - {stop.gareNom} - + {/* Column 4: Station name & status (strictly conforming to Image 1) */} +
+ {stop.isSupprime ? ( +
+ + {stop.gareNom} + +
+ ! + Arrêt supprimé +
+
+ {stop.perturbationDetails || "Accident à un passage à niveau"} +
+
+ ) : stop.isNouveauTerminus ? ( +
+ { + if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom })); + }} + className={`font-normal text-gray-900 block leading-snug ${!isOrigin ? 'cursor-pointer hover:underline' : ''} ${isSelected ? 'font-semibold text-gray-950' : ''}`} + > + {stop.gareNom} + +
+ i + Terminus exceptionnel +
+
+ ) : stop.isNouvelleOrigine ? ( +
+ + {stop.gareNom} + +
+ i + Origine exceptionnelle +
+
+ ) : stop.isAddedManually ? ( +
+ { + if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom })); + }} + className={`font-normal text-gray-900 block leading-snug ${!isOrigin ? 'cursor-pointer hover:underline' : ''} ${isSelected ? 'font-semibold text-gray-950' : ''}`} + > + {stop.gareNom} + +
+ + + Arrêt exceptionnel +
+
+ ) : ( +
+ { + if (!isOrigin) setSelectedArrivalStops(prev => ({ ...prev, [item.id]: stop.gareNom })); + }} + className={`font-normal text-gray-900 block leading-snug ${!isOrigin ? 'cursor-pointer hover:underline' : ''} ${isSelected ? 'font-semibold text-gray-950' : ''}`} + > + {stop.gareNom} + +
+ )} +
{/* Column 5: Voie */} - {stop.voie} + {stop.isSupprime ? '-' : (stop.voie || '-')}
); diff --git a/src/components/RegionContent.tsx b/src/components/RegionContent.tsx index b294c26..f2570ad 100644 --- a/src/components/RegionContent.tsx +++ b/src/components/RegionContent.tsx @@ -622,7 +622,7 @@ export default function RegionContent({ .from('actualites') .select('*') .eq('actif', true) - .in('region_id', regionCandidates) + .or(`region_id.is.null,region_id.in.(${regionCandidates.join(',')})`) .is('deleted_at', null) .order('date_publication', { ascending: false }) .order('created_at', { ascending: false }); @@ -635,7 +635,7 @@ export default function RegionContent({ .from('evenements') .select('*') .eq('actif', true) - .in('region_id', regionCandidates) + .or(`region_id.is.null,region_id.in.(${regionCandidates.join(',')})`) .is('deleted_at', null) .order('date_debut', { ascending: false }); if (eData) { diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index 31b3f03..68e78d5 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -91,17 +91,6 @@ export default function SiteHeader({
)} - - {(brandPrimary || brandSecondary) && ( -
- - {brandPrimary || "RÉGION"} - - - {brandSecondary || "BOURGOGNE-FRANCHE-COMTÉ"} - -
- )}
diff --git a/src/components/StationDeparturesBoard.tsx b/src/components/StationDeparturesBoard.tsx index 416f8bf..4dfa356 100644 --- a/src/components/StationDeparturesBoard.tsx +++ b/src/components/StationDeparturesBoard.tsx @@ -4,10 +4,14 @@ import React, { useState, useEffect, useMemo } from 'react'; import Link from 'next/link'; import { supabase } from '@/lib/supabase'; import { getRegionTheme } from '@/lib/regionTheme'; +import { isPerturbationActiveOnDate, getPerturbationBannerStyle, formatPerturbationCause, getPerturbationPriority } from '@/lib/perturbationUtils'; +import { resolvePerturbedRoute } from '@/lib/parcoursUtils'; +import { isTrainActiveOnDate } from '@/lib/circulation'; export interface ResolvedDeparture { id: string | number; scheduledTime: string; + revisedTime?: string; destName: string; trainType: string; trainNumber: string; @@ -15,6 +19,15 @@ export interface ResolvedDeparture { track: string; rawQuai?: string | null; sourceHoraire?: any; + isSupprime?: boolean; + isTrainSupplementaire?: boolean; + statusLabel?: string; + statusBannerStyle?: { + bgColor: string; + textColor: string; + iconBgColor: string; + iconText: string; + }; } interface StationDeparturesBoardProps { @@ -40,8 +53,18 @@ export default function StationDeparturesBoard({ const [selectedStation, setSelectedStation] = useState(initialStation); const [horairesDataList, setHorairesDataList] = useState([]); + const [perturbationsList, setPerturbationsList] = useState([]); const [garesTypeMap, setGaresTypeMap] = useState>({}); const [expandedDepartures, setExpandedDepartures] = useState(false); + const [currentTime, setCurrentTime] = useState(() => new Date()); + + // Periodically refresh current time every 30 seconds to auto-hide departed trains + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 30000); + return () => clearInterval(timer); + }, []); // Sync station if prop changes useEffect(() => { @@ -50,7 +73,7 @@ export default function StationDeparturesBoard({ } }, [initialStation]); - // Fetch gares and horaires + // Fetch gares, horaires and perturbations useEffect(() => { const fetchBoardData = async () => { try { @@ -83,6 +106,16 @@ export default function StationDeparturesBoard({ gare_arrivee_nom: gMap[h.gare_arrivee_id] || '', }))); } + + const { data: pData } = await supabase + .from('perturbations') + .select('*') + .eq('actif', true) + .is('deleted_at', null); + + if (pData) { + setPerturbationsList(pData); + } } catch (err) { console.error('Error fetching data for StationDeparturesBoard:', err); } @@ -100,89 +133,205 @@ export default function StationDeparturesBoard({ return `${year}-${month}-${day}`; }; - const isTrainCirculating = (h: any, targetDate: string = getLocalDateStr()) => { - if (h.actif === false) return false; - if (Array.isArray(h.jours_personnalises) && h.jours_personnalises.length > 0) { - const customDay = h.jours_personnalises.find((jp: any) => { - const d = jp.date || jp.jour || ''; - return d === targetDate || d.slice(0, 10) === targetDate; - }); - if (customDay) return customDay.circule === true; - } - return false; + const getTomorrowDateStr = (d: Date = new Date()) => { + const tom = new Date(d); + tom.setDate(tom.getDate() + 1); + return getLocalDateStr(tom); }; - // Filter and resolve departures for selectedStation - const stationDepartures: ResolvedDeparture[] = useMemo(() => { - if (!selectedStation || horairesDataList.length === 0) return []; + const isTrainCirculating = (h: any, targetDate: string = getLocalDateStr()) => { + return isTrainActiveOnDate(h, targetDate); + }; - const selectedStationNorm = normalizeStationName(selectedStation); - const todayDateStr = getLocalDateStr(); - const results: ResolvedDeparture[] = []; - - for (const h of horairesDataList) { - if (!isTrainCirculating(h, todayDateStr)) continue; - - let gares: any[] = []; - if (typeof h.gares_desservies === 'string') { - try { gares = JSON.parse(h.gares_desservies); } catch (e) { gares = []; } - } else if (Array.isArray(h.gares_desservies)) { - gares = h.gares_desservies; - } - - const stopIndex = gares.findIndex(g => { - const gNomNorm = normalizeStationName(g.gare_nom || ''); - return gNomNorm === selectedStationNorm || - gNomNorm.includes(selectedStationNorm) || - selectedStationNorm.includes(gNomNorm); - }); - - if (stopIndex !== -1) { - const stop = gares[stopIndex]; - const isTerminus = stop.is_arrivee || (stopIndex === gares.length - 1 && !stop.heure_depart) || !stop.heure_depart; - - if (!isTerminus) { - const lastStop = gares[gares.length - 1]; - const destination = h.gare_arrivee_nom || lastStop?.gare_nom || "Terminus"; - const departureTime = (stop.heure_depart || '').slice(0, 5); - - // Track display logic - const currentStationType = garesTypeMap[selectedStationNorm] || 'ville'; - const now = new Date(); - const [depH, depM] = departureTime.split(':').map(Number); - const depMinutes = (isNaN(depH) ? 0 : depH) * 60 + (isNaN(depM) ? 0 : depM); - const currentMinutes = now.getHours() * 60 + now.getMinutes(); - const diffMinutes = depMinutes - currentMinutes; - - let displayQuai = '-'; - if (stop.quai && stop.quai.trim() !== '') { - if (currentStationType === 'ville') { - if (diffMinutes <= 30) displayQuai = stop.quai; - } else { - if (diffMinutes <= 360) displayQuai = stop.quai; - } - } - if (displayQuai === '-' && stop.quai) { - displayQuai = stop.quai; - } - - results.push({ - id: `${h.id}-${stopIndex}`, - scheduledTime: departureTime, - destName: destination, - trainType: h.type_train || 'TRAIN MOBIGO', - trainNumber: h.numero_train || '', - trainLabel: `${h.type_train?.toUpperCase() || theme.operatorLabel || 'TER'} ${h.numero_train || ''}`.trim(), - track: displayQuai, - rawQuai: stop.quai, - sourceHoraire: h, - }); - } - } + // Filter and resolve departures for selectedStation: + // - If departure time is passed for today, hide it. + // - If no departures remain today, display tomorrow's departures with "Plus aucun départ pour Aujourd'hui" info banner. + const { departures: stationDepartures, isShowingTomorrow } = useMemo(() => { + if (!selectedStation || horairesDataList.length === 0) { + return { departures: [], isShowingTomorrow: false }; } - return results.sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime)); - }, [selectedStation, horairesDataList, garesTypeMap, theme.operatorLabel]); + const selectedStationNorm = normalizeStationName(selectedStation); + const todayDateStr = getLocalDateStr(currentTime); + const tomorrowDateStr = getTomorrowDateStr(currentTime); + const nowTimeStr = `${String(currentTime.getHours()).padStart(2, '0')}:${String(currentTime.getMinutes()).padStart(2, '0')}`; + + const resolveDeparturesForDate = (targetDateStr: string, isTomorrow: boolean = false): ResolvedDeparture[] => { + const results: ResolvedDeparture[] = []; + + // Collect active substitution horaire IDs for targetDateStr + const substitutionHoraireIds = new Set(); + perturbationsList.forEach((p: any) => { + if (p.sillon_substitution_id && isPerturbationActiveOnDate(p, targetDateStr)) { + substitutionHoraireIds.add(String(p.sillon_substitution_id)); + } + }); + + for (const h of horairesDataList) { + const isSub = substitutionHoraireIds.has(String(h.id)) || substitutionHoraireIds.has(String(h.numero_train)); + const isCirculating = isTrainCirculating(h, targetDateStr); + + // Circulate if normally circulating OR if designated as a substitution train + if (!isCirculating && !isSub) continue; + + // Check for perturbations active strictly on this timetable's date (today or tomorrow) + const matchingPert = perturbationsList.find((p: any) => { + const trainMatch = + p.horaire_id === h.id || + String(p.horaire_id) === String(h.id) || + String(p.horaire_id) === String(h.numero_train) || + (p.numero_train && String(p.numero_train) === String(h.numero_train)); + return trainMatch && isPerturbationActiveOnDate(p, targetDateStr); + }); + + // Resolve real-world route considering active perturbations (nouveau terminus, nouvelle origine, arrêt supprimé) + const resolvedParcours = resolvePerturbedRoute(h, matchingPert, {}); + const gares = resolvedParcours.stops; + + const stopIndex = gares.findIndex(g => { + const gNomNorm = normalizeStationName(g.gare_nom || ''); + return gNomNorm === selectedStationNorm || + gNomNorm.includes(selectedStationNorm) || + selectedStationNorm.includes(gNomNorm); + }); + + if (stopIndex !== -1) { + const stop = gares[stopIndex]; + const isTerminus = stop.is_nouveau_terminus || stop.is_arrivee || (stopIndex === gares.length - 1 && !stop.heure_depart) || !stop.heure_depart; + + if (!isTerminus) { + const destination = resolvedParcours.destination; + const departureTime = (stop.heure_depart || stop.heure_arrivee || '').slice(0, 5); + + // Track display logic + const currentStationType = garesTypeMap[selectedStationNorm] || 'ville'; + const [depH, depM] = departureTime.split(':').map(Number); + const depMinutes = (isNaN(depH) ? 0 : depH) * 60 + (isNaN(depM) ? 0 : depM); + const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes(); + const diffMinutes = depMinutes - currentMinutes; + + let displayQuai = '-'; + const rawQ = stop.quai || stop.quai_reel; + if (rawQ && rawQ.trim() !== '' && !stop.is_supprime) { + if (isTomorrow) { + displayQuai = rawQ; + } else { + if (currentStationType === 'ville') { + if (diffMinutes <= 30) displayQuai = rawQ; + } else { + if (diffMinutes <= 360) displayQuai = rawQ; + } + } + } + if (displayQuai === '-' && rawQ && !stop.is_supprime) { + displayQuai = rawQ; + } + + let pertType = matchingPert?.type_perturbation; + let delayMins: number | undefined = matchingPert?.duree_retard_minutes || undefined; + let isSupp = pertType === 'suppression' || Boolean(stop.is_supprime); + let pertCause = matchingPert?.cause || matchingPert?.cause_details || matchingPert?.titre; + + if (stop.cause_arret) pertCause = stop.cause_arret; + if (stop.retard_minutes !== undefined && stop.retard_minutes > 0) { + delayMins = stop.retard_minutes; + } + + const priority = getPerturbationPriority({ + isSupprime: isSupp, + hasParcoursModification: resolvedParcours.hasParcoursModification, + perturbationType: pertType, + typesPerturbation: stop.types_perturbation, + delayMinutes: delayMins, + }); + + let revisedTimeStr: string | undefined = undefined; + let statusLabelStr: string | undefined = undefined; + let bannerStyle: any = undefined; + + if (priority === 'suppression') { + isSupp = true; + const formattedCause = formatPerturbationCause(pertCause); + statusLabelStr = formattedCause ? `Train supprimé (${formattedCause})` : (stop.cause_arret || 'Train supprimé'); + bannerStyle = getPerturbationBannerStyle('suppression'); + } else if (priority === 'parcours_modifie') { + isSupp = false; + const formattedCause = formatPerturbationCause(matchingPert?.cause || pertCause); + const extraRouteInfo = resolvedParcours.nouveauTerminusNom ? `Terminus ${resolvedParcours.nouveauTerminusNom}` : (resolvedParcours.nouvelleOrigineNom ? `Origine ${resolvedParcours.nouvelleOrigineNom}` : ''); + if (formattedCause && extraRouteInfo) { + statusLabelStr = `Modification de parcours : ${extraRouteInfo} (${formattedCause})`; + } else if (extraRouteInfo) { + statusLabelStr = `Modification de parcours : ${extraRouteInfo}`; + } else if (formattedCause) { + statusLabelStr = `Modification de parcours (${formattedCause})`; + } else { + statusLabelStr = 'Modification de parcours'; + } + bannerStyle = getPerturbationBannerStyle('parcours_modifie'); + } else if (priority === 'retard') { + isSupp = false; + const dMins = delayMins || 15; + const formattedCause = formatPerturbationCause(matchingPert?.cause || pertCause); + statusLabelStr = formattedCause ? `Retard estimé de ${dMins} min (${formattedCause})` : `Retard estimé de ${dMins} min`; + bannerStyle = getPerturbationBannerStyle('retard'); + const [dH, dM] = departureTime.split(':').map(Number); + const totalM = (isNaN(dH) ? 0 : dH) * 60 + (isNaN(dM) ? 0 : dM) + dMins; + const rH = Math.floor(totalM / 60) % 24; + const rM = totalM % 60; + revisedTimeStr = `${String(rH).padStart(2, '0')}:${String(rM).padStart(2, '0')}`; + } else if (matchingPert) { + isSupp = false; + const formattedCause = formatPerturbationCause(matchingPert?.cause || pertCause); + statusLabelStr = formattedCause || matchingPert.titre || 'Information trafic'; + bannerStyle = getPerturbationBannerStyle('information'); + } + + results.push({ + id: `${h.id}-${stopIndex}`, + scheduledTime: departureTime, + revisedTime: revisedTimeStr, + destName: destination, + trainType: h.type_train || 'TRAIN MOBIGO', + trainNumber: h.numero_train || '', + trainLabel: `${h.type_train?.toUpperCase() || theme.operatorLabel || 'TER'} ${h.numero_train || ''}`.trim(), + track: displayQuai, + rawQuai: stop.quai || stop.quai_reel, + sourceHoraire: h, + isSupprime: isSupp, + isTrainSupplementaire: isSub, + statusLabel: statusLabelStr, + statusBannerStyle: bannerStyle, + }); + } + } + } + + return results.sort((a, b) => a.scheduledTime.localeCompare(b.scheduledTime)); + }; + + // 1. Resolve today's departures + const allTodayDepartures = resolveDeparturesForDate(todayDateStr, false); + + // 2. Filter out departures whose departure time has already passed for today + const activeTodayDepartures = allTodayDepartures.filter(dep => { + const effectiveTime = dep.revisedTime || dep.scheduledTime; + // Handle delay past midnight if applicable + if (dep.revisedTime && dep.revisedTime < dep.scheduledTime) { + if (nowTimeStr >= '12:00') return true; + return dep.revisedTime >= nowTimeStr; + } + return effectiveTime >= nowTimeStr; + }); + + // 3. If there are still departures today, display them + if (activeTodayDepartures.length > 0) { + return { departures: activeTodayDepartures, isShowingTomorrow: false }; + } + + // 4. No remaining departures today -> resolve and display tomorrow's departures + const tomorrowDepartures = resolveDeparturesForDate(tomorrowDateStr, true); + return { departures: tomorrowDepartures, isShowingTomorrow: true }; + }, [selectedStation, horairesDataList, perturbationsList, garesTypeMap, theme.operatorLabel, currentTime]); const defaultPillStations = availableStations || [ "Dijon", "Besançon Viotte", "Belfort", "Chalon-sur-Saône" @@ -235,6 +384,16 @@ export default function StationDeparturesBoard({
)} + {/* Info banner when displaying tomorrow's departures because no departures remain today */} + {isShowingTomorrow && stationDepartures.length > 0 && ( +
+ + i + + Plus aucun départ pour Aujourd'hui +
+ )} + {/* Table header */}
{stationDepartures.length > 0 ? ( stationDepartures.map((dep, dIdx) => ( -
- {/* Time */} -
- - {dep.scheduledTime} +
+
+ {/* Time */} +
+ {dep.isSupprime ? ( + + {dep.scheduledTime} + + ) : dep.revisedTime ? ( +
+ + {dep.scheduledTime} + + + {dep.revisedTime} + +
+ ) : ( + + {dep.scheduledTime} + + )} +
+ + {/* Destination */} +
+
+ + {dep.destName} + + {dep.isTrainSupplementaire && ( + + Train Supplémentaire + + )} +
+
+ + train + + {dep.trainLabel} +
+
+ + {/* Voie / Quai */} + + {dep.track} + + {/* Buy / Action Button */} +
- {/* Destination */} -
-
- {dep.destName} -
-
+ {/* Status banner (retardé, supprimé, modification de parcours) */} + {dep.statusLabel && dep.statusBannerStyle && ( +
- train + {dep.statusBannerStyle.iconText} + + + {dep.statusLabel} - {dep.trainLabel}
-
- - {/* Voie / Quai */} - - {dep.track} - - - {/* Buy / Action Button */} - + )}
)) ) : ( diff --git a/src/components/TimetableSearchForm.tsx b/src/components/TimetableSearchForm.tsx index 01c39c5..320ca02 100644 --- a/src/components/TimetableSearchForm.tsx +++ b/src/components/TimetableSearchForm.tsx @@ -79,6 +79,16 @@ const DISCOUNT_CARDS = [ const TIME_SLOTS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`); +const formatDisplayDate = (d: Date): string => { + const daysShort = ['Dim.', 'Lun.', 'Mar.', 'Mer.', 'Jeu.', 'Ven.', 'Sam.']; + const monthsShort = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']; + return `${daysShort[d.getDay()]} ${d.getDate()} ${monthsShort[d.getMonth()]}`; +}; + +const formatDisplayTime = (d: Date): string => { + return `${String(d.getHours()).padStart(2, '0')}:00`; +}; + export default function TimetableSearchForm({ initialDeparture = '', initialArrival = '', @@ -104,15 +114,19 @@ export default function TimetableSearchForm({ const theme = getRegionTheme(currentRegionSlug); + // Current date tracking + const [selectedOutwardDate, setSelectedOutwardDate] = useState(() => new Date()); + const [selectedReturnDate, setSelectedReturnDate] = useState(() => new Date()); + // Form states const [departureStation, setDepartureStation] = useState(initialDeparture); const [arrivalStation, setArrivalStation] = useState(initialArrival); const [viaStation, setViaStation] = useState(''); const [showVia, setShowVia] = useState(false); - const [outwardDate, setOutwardDate] = useState('Dim. 20 sept.'); - const [outwardTime, setOutwardTime] = useState('16:00'); + const [outwardDate, setOutwardDate] = useState(() => formatDisplayDate(new Date())); + const [outwardTime, setOutwardTime] = useState(() => formatDisplayTime(new Date())); const [hasReturn, setHasReturn] = useState(false); - const [returnDate, setReturnDate] = useState('Dim. 20 sept.'); + const [returnDate, setReturnDate] = useState(() => formatDisplayDate(new Date())); const [returnTime, setReturnTime] = useState('--:--'); // Interactive active field panel state @@ -121,15 +135,29 @@ export default function TimetableSearchForm({ // Station database items const [allStations, setAllStations] = useState(DEFAULT_STATIONS); - // Calendar state for date picker (default September 20, 2026) - const [calendarDate, setCalendarDate] = useState(new Date(2026, 8, 20)); // Month 8 is Sept - const [selectedDayNumber, setSelectedDayNumber] = useState(20); + // Calendar state for date picker (synchronized with current date) + const [calendarDate, setCalendarDate] = useState(() => { + const n = new Date(); + return new Date(n.getFullYear(), n.getMonth(), 1); + }); + const [selectedDayNumber, setSelectedDayNumber] = useState(() => new Date().getDate()); // Passenger state matching Img 1 const [passengers, setPassengers] = useState([ { id: 1, age: '', discountCard: 'Carte de réduction (optionnel)' } ]); + // Synchronize calendar view when switching active field + useEffect(() => { + if (activeField === 'outwardDate') { + setCalendarDate(new Date(selectedOutwardDate.getFullYear(), selectedOutwardDate.getMonth(), 1)); + setSelectedDayNumber(selectedOutwardDate.getDate()); + } else if (activeField === 'returnDate') { + setCalendarDate(new Date(selectedReturnDate.getFullYear(), selectedReturnDate.getMonth(), 1)); + setSelectedDayNumber(selectedReturnDate.getDate()); + } + }, [activeField, selectedOutwardDate, selectedReturnDate]); + // Priority: URL searchParams > props > sessionStorage > default useEffect(() => { // 1. Try from URL search params @@ -344,7 +372,14 @@ export default function TimetableSearchForm({ const daysInMonth = new Date(year, month + 1, 0).getDate(); const firstDayOfWeek = (new Date(year, month, 1).getDay() + 6) % 7; // Mon = 0 + const todayObj = new Date(); + todayObj.setHours(0, 0, 0, 0); + const minMonthDate = new Date(todayObj.getFullYear(), todayObj.getMonth(), 1); + const currentMonthDate = new Date(year, month, 1); + const isPrevMonthDisabled = currentMonthDate <= minMonthDate; + const handlePrevMonth = () => { + if (isPrevMonthDisabled) return; setCalendarDate(new Date(year, month - 1, 1)); }; @@ -355,13 +390,28 @@ export default function TimetableSearchForm({ const handleSelectDay = (dayNum: number) => { setSelectedDayNumber(dayNum); const d = new Date(year, month, dayNum); - const daysShort = ['Dim.', 'Lun.', 'Mar.', 'Mer.', 'Jeu.', 'Ven.', 'Sam.']; - const monthsShort = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']; - const formatted = `${daysShort[d.getDay()]} ${dayNum} ${monthsShort[d.getMonth()]}`; + const formatted = formatDisplayDate(d); if (activeField === 'outwardDate') { + setSelectedOutwardDate(d); setOutwardDate(formatted); + + // If selected date is today and outwardTime is in the past, adjust outwardTime to current hour + const now = new Date(); + if (d.toDateString() === now.toDateString()) { + const [outH] = outwardTime.split(':').map(Number); + if (outH < now.getHours()) { + setOutwardTime(formatDisplayTime(now)); + } + } + + // If return date is earlier than outward date, bump return date + if (hasReturn && selectedReturnDate < d) { + setSelectedReturnDate(d); + setReturnDate(formatted); + } } else if (activeField === 'returnDate') { + setSelectedReturnDate(d); setReturnDate(formatted); setHasReturn(true); } @@ -721,7 +771,13 @@ export default function TimetableSearchForm({ @@ -755,10 +811,15 @@ export default function TimetableSearchForm({ const isSelected = dayNum === selectedDayNumber; const today = new Date(); today.setHours(0, 0, 0, 0); - const sevenDaysAgo = new Date(today); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const minAllowedDate = activeField === 'returnDate' + ? (new Date(selectedOutwardDate.getFullYear(), selectedOutwardDate.getMonth(), selectedOutwardDate.getDate()) > today + ? new Date(selectedOutwardDate.getFullYear(), selectedOutwardDate.getMonth(), selectedOutwardDate.getDate()) + : today) + : today; + const dayDate = new Date(year, month, dayNum, 0, 0, 0, 0); - const isPastDate = dayDate < sevenDaysAgo; + const isPastDate = dayDate < minAllowedDate; return ( @@ -797,9 +858,24 @@ export default function TimetableSearchForm({ const isSelected = slot === currentSelected; const now = new Date(); - const isTodaySelected = (year === now.getFullYear() && month === now.getMonth() && selectedDayNumber === now.getDate()); + const currentHour = now.getHours(); const slotHour = parseInt(slot.split(':')[0], 10); - const isPastTime = isTodaySelected && slotHour < now.getHours(); + + let isPastTime = false; + if (activeField === 'outwardTime') { + const isToday = selectedOutwardDate.toDateString() === now.toDateString(); + isPastTime = isToday && slotHour < currentHour; + } else if (activeField === 'returnTime') { + const isReturnToday = selectedReturnDate.toDateString() === now.toDateString(); + const isSameDayAsOutward = selectedReturnDate.toDateString() === selectedOutwardDate.toDateString(); + const outwardHour = parseInt(outwardTime.split(':')[0], 10); + + if (isReturnToday && slotHour < currentHour) { + isPastTime = true; + } else if (isSameDayAsOutward && !isNaN(outwardHour) && slotHour < outwardHour) { + isPastTime = true; + } + } return (
{slot}
diff --git a/src/components/TrainDetailsClient.tsx b/src/components/TrainDetailsClient.tsx index 39bff5f..87bc0a8 100644 --- a/src/components/TrainDetailsClient.tsx +++ b/src/components/TrainDetailsClient.tsx @@ -1,10 +1,14 @@ "use client"; import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; import { supabase } from '@/lib/supabase'; import { getRegionTheme } from '@/lib/regionTheme'; import SiteHeader from '@/components/SiteHeader'; import SiteFooter from '@/components/SiteFooter'; +import { isPerturbationActiveOnDate, formatPerturbationCause, getPerturbationPriority, getPerturbationBannerStyle } from '@/lib/perturbationUtils'; +import { resolveEffectiveMateriel } from '@/lib/materielUtils'; +import { resolvePerturbedRoute } from '@/lib/parcoursUtils'; interface StopItem { gare_id?: string; @@ -15,6 +19,11 @@ interface StopItem { duree_arret?: string; is_depart?: boolean; is_arrivee?: boolean; + is_nouveau_terminus?: boolean; + is_nouvelle_origine?: boolean; + is_added_manually?: boolean; + is_supprime?: boolean; + cause_arret?: string; } interface TrainDetailsClientProps { @@ -34,11 +43,17 @@ export default function TrainDetailsClient({ const [stops, setStops] = useState([]); const [delayMinutes, setDelayMinutes] = useState(0); const [destination, setDestination] = useState('Terminus'); + const [provenance, setProvenance] = useState('Origine'); const [operatorInfo, setOperatorInfo] = useState('SNCF Voyageurs'); const [trainType, setTrainType] = useState('TER'); + const [nouveauTerminusNom, setNouveauTerminusNom] = useState(null); + const [nouvelleOrigineNom, setNouvelleOrigineNom] = useState(null); + const [hasParcoursModifie, setHasParcoursModifie] = useState(false); + const [materielInfo, setMaterielInfo] = useState<{ nom: string, nom_technique?: string, imageUrl: string, places: number, voitures: number } | null>(null); - const [perturbationInfo, setPerturbationInfo] = useState<{ type: string, delayMinutes: number, message: string } | null>(null); + const [perturbationInfo, setPerturbationInfo] = useState<{ type: string, delayMinutes: number, message: string; cause?: string; causeDetails?: string } | null>(null); + const [substitutionInfo, setSubstitutionInfo] = useState<{ type: string, numero: string, depart: string, arrivee: string, heureDepart: string, heureArrivee: string } | null>(null); const currentDateDisplay = dateStr || new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }); @@ -78,7 +93,18 @@ export default function TrainDetailsClient({ setMenuStructure(mData[0].structure); } - // 2. Fetch train details + // 2. Fetch gares for name mapping + const { data: gDataAll } = await supabase.from('gares').select('id, nom, type'); + const gMap: Record = {}; + const garesTypeMap: Record = {}; + if (gDataAll) { + gDataAll.forEach((g: any) => { + gMap[g.id] = g.nom; + garesTypeMap[g.id] = g.type || 'ville'; + }); + } + + // 3. Fetch train details const { data: hData } = await supabase .from('horaires') .select('*') @@ -91,155 +117,166 @@ export default function TrainDetailsClient({ const h = hData[0]; setTrainType(h.type_train?.toUpperCase() || 'TER'); - if (h.gare_arrivee_nom) { - setDestination(h.gare_arrivee_nom); - } else if (h.gare_arrivee_id) { - const { data: gArr } = await supabase - .from('gares') - .select('nom') - .eq('id', h.gare_arrivee_id) - .single(); - if (gArr) setDestination(gArr.nom); - } - - - // Fetch perturbations + // Date format conversion const parts = currentDateDisplay.split('/'); const targetIsoDate = parts.length === 3 ? `${parts[2]}-${parts[1]}-${parts[0]}` : new Date().toISOString().split('T')[0]; - + + // 4. Fetch perturbations active on this target date const { data: pertData } = await supabase.from('perturbations').select('*').eq('actif', true); let currentDelay = 0; - let pArrets: any[] = []; - + let matchingPert: any = null; + if (pertData) { - const matchingPert = pertData.find((p: any) => { - const matchTrain = p.horaire_id === h.id || p.horaire_id === h.numero_train; + matchingPert = pertData.find((p: any) => { + const matchTrain = + p.horaire_id === h.id || + String(p.horaire_id) === String(h.id) || + String(p.horaire_id) === String(h.numero_train) || + (p.numero_train && String(p.numero_train) === String(h.numero_train)); if (!matchTrain) return false; - - const pStart = (p.date_debut || '').slice(0, 10); - const pEnd = (p.date_fin || '').slice(0, 10); - - if (pStart && pEnd && pStart <= targetIsoDate && targetIsoDate <= pEnd) return true; - - if (p.jours_perturbation && Array.isArray(p.jours_perturbation)) { - return p.jours_perturbation.some((jp: any) => { - const d = (jp.date || jp.dateDebut || '').slice(0, 10); - return d === targetIsoDate; - }); - } - return !pStart && !pEnd && (!p.jours_perturbation || p.jours_perturbation.length === 0); + return isPerturbationActiveOnDate(p, targetIsoDate); }); if (matchingPert) { - pArrets = Array.isArray(matchingPert.perturbations_arrets) ? matchingPert.perturbations_arrets : []; + const pArrets = Array.isArray(matchingPert.perturbations_arrets) + ? matchingPert.perturbations_arrets + : Array.isArray(matchingPert.perturbations_arrets?.arrets) + ? matchingPert.perturbations_arrets.arrets + : []; - // Find max delay in perturbations_arrets let maxArretsDelay = 0; - pArrets.forEach(pa => { + pArrets.forEach((pa: any) => { if (pa.retard_cumule > maxArretsDelay) maxArretsDelay = pa.retard_cumule; if (pa.retard_entrant > maxArretsDelay) maxArretsDelay = pa.retard_entrant; }); currentDelay = maxArretsDelay > 0 ? maxArretsDelay : (matchingPert.duree_retard_minutes || 0); + const formattedCause = formatPerturbationCause(matchingPert.cause); + const detailMsg = matchingPert.cause_details || matchingPert.notes || ''; + const displayMsg = formattedCause && detailMsg ? `${formattedCause} : ${detailMsg}` : (formattedCause || detailMsg); + setPerturbationInfo({ type: matchingPert.type_perturbation || 'Retard', delayMinutes: currentDelay, - message: matchingPert.cause_details || matchingPert.notes || '', + message: displayMsg, + cause: formattedCause, + causeDetails: matchingPert.cause_details || matchingPert.notes || '', }); setDelayMinutes(currentDelay); - } - } -// Fetch materiel roulant - if (h.composition_train && Array.isArray(h.composition_train) && h.composition_train.length > 0) { - const firstMat = h.composition_train[0]; - if (firstMat.materiel_image_url) { - setMaterielInfo({ - nom: firstMat.materiel_nom || 'Train', - nom_technique: firstMat.materiel_nom_technique, - imageUrl: firstMat.materiel_image_url, - places: firstMat.materiel_nb_places || 163, - voitures: firstMat.materiel_voitures || 3, - }); - } - } - if (h.gares_desservies) { - let parsedStops: any[] = []; - if (typeof h.gares_desservies === 'string') { - try { parsedStops = JSON.parse(h.gares_desservies); } catch (e) { parsedStops = []; } - } else if (Array.isArray(h.gares_desservies)) { - parsedStops = h.gares_desservies; - } + // Fetch substitution train if defined + if (matchingPert.sillon_substitution_id) { + const { data: subHData } = await supabase + .from('horaires') + .select('*') + .eq('id', matchingPert.sillon_substitution_id) + .single(); - if (parsedStops.length > 0) { - // Fetch types for gares to apply quai logic - const stopGareIds = parsedStops.map(s => s.gare_id).filter(Boolean); - let garesTypeMap: Record = {}; - if (stopGareIds.length > 0) { - const { data: gData } = await supabase.from('gares').select('id, type').in('id', stopGareIds); - if (gData) { - gData.forEach(g => { - garesTypeMap[g.id] = g.type || 'ville'; + if (subHData) { + let subStops: any[] = []; + if (typeof subHData.gares_desservies === 'string') { + try { subStops = JSON.parse(subHData.gares_desservies); } catch (e) { subStops = []; } + } else if (Array.isArray(subHData.gares_desservies)) { + subStops = subHData.gares_desservies; + } + const sDepNom = subHData.gare_depart_nom || subStops[0]?.gare_nom || gMap[subHData.gare_depart_id] || 'Départ'; + const sArrNom = subHData.gare_arrivee_nom || subStops[subStops.length - 1]?.gare_nom || gMap[subHData.gare_arrivee_id] || 'Arrivée'; + const sDepH = (subHData.heure_depart || subStops[0]?.heure_depart || '').slice(0, 5); + const sArrH = (subHData.heure_arrivee || subStops[subStops.length - 1]?.heure_arrivee || '').slice(0, 5); + + setSubstitutionInfo({ + type: subHData.type_train || 'Car MOBIGO', + numero: subHData.numero_train || '', + depart: sDepNom, + arrivee: sArrNom, + heureDepart: sDepH, + heureArrivee: sArrH, }); } } - - const now = new Date(); - const currentMinutes = now.getHours() * 60 + now.getMinutes(); - - - const resolved: StopItem[] = parsedStops.map((s, idx) => { - const prev = s.heure_depart || s.heure_arrivee || '12:00'; - let est = prev; - - // Get delay specifically for this stop - const stopPert = pArrets.find(pa => pa.gare_nom === s.gare_nom || pa.gare_id === s.gare_id); - const stopDelay = stopPert && stopPert.retard_cumule !== undefined ? stopPert.retard_cumule : currentDelay; - - if (stopDelay > 0) { - const [hh, mm] = prev.split(':').map(Number); - const totalMin = (hh * 60 + mm + stopDelay) % 1440; - const newH = String(Math.floor(totalMin / 60)).padStart(2, '0'); - const newM = String(totalMin % 60).padStart(2, '0'); - est = `${newH}:${newM}`; - } - - - // Logic for displaying Quai/Voie - let displayVoie = undefined; - if (s.quai) { - const type = s.gare_id ? garesTypeMap[s.gare_id] || 'ville' : 'ville'; - const [depH, depM] = prev.split(':').map(Number); - const depMinutes = (isNaN(depH) ? 0 : depH) * 60 + (isNaN(depM) ? 0 : depM); - const diffMinutes = depMinutes - currentMinutes; - - if (type === 'ville') { - if (diffMinutes <= 30) displayVoie = `Voie ${s.quai}`; - } else if (type === 'interurbaine') { - if (diffMinutes <= 360) displayVoie = `Voie ${s.quai}`; - } else { - if (diffMinutes <= 1440) displayVoie = `Voie ${s.quai}`; - } - } - - return { - gare_nom: s.gare_nom || `Arrêt ${idx + 1}`, - heure_prevue: prev.slice(0, 5), - heure_estimee: est.slice(0, 5), - voie: displayVoie, - duree_arret: idx > 0 && idx < parsedStops.length - 1 ? "30 secondes d'arrêt" : undefined, - is_depart: idx === 0, - is_arrivee: idx === parsedStops.length - 1, - }; - }); - - setStops(resolved); - if (!h.gare_arrivee_nom && !h.gare_arrivee_id) { - setDestination(resolved[resolved.length - 1].gare_nom); - } } } + + // 5. Resolve real-world route considering perturbation (nouveau terminus, nouvelle origine, arrêt supprimé, retards) + const resolvedParcours = resolvePerturbedRoute(h, matchingPert, gMap); + setDestination(resolvedParcours.destination); + setProvenance(resolvedParcours.provenance); + setNouveauTerminusNom(resolvedParcours.nouveauTerminusNom || null); + setNouvelleOrigineNom(resolvedParcours.nouvelleOrigineNom || null); + setHasParcoursModifie(resolvedParcours.hasParcoursModification); + + // 6. Fetch and resolve effective rolling stock (handling unavailable matériel & reassignments) + const { data: allMats } = await supabase.from('materiel-roulant').select('*'); + const effectiveMat = resolveEffectiveMateriel(h.composition_train, allMats || [], { + modeleTrain: h.modele_train || 'Train', + capaciteTexte: h.capacite_texte, + imageUrl: h.image_materiel || undefined + }); + + if (effectiveMat.imageUrl) { + setMaterielInfo({ + nom: effectiveMat.nom, + nom_technique: effectiveMat.nomTechnique, + imageUrl: effectiveMat.imageUrl, + places: effectiveMat.places, + voitures: effectiveMat.voitures, + }); + } + + // 7. Resolve timeline stops from resolvedParcours + const now = new Date(); + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + + const resolved: StopItem[] = resolvedParcours.stops.map((s, idx) => { + const prev = s.heure_depart || s.heure_arrivee || '12:00'; + let est = prev; + + const stopDelay = s.retard_cumule !== undefined ? s.retard_cumule : (s.retard_minutes !== undefined ? s.retard_minutes : currentDelay); + if (stopDelay > 0 && !s.is_supprime) { + const [hh, mm] = prev.split(':').map(Number); + const totalMin = (hh * 60 + mm + stopDelay) % 1440; + const newH = String(Math.floor(totalMin / 60)).padStart(2, '0'); + const newM = String(totalMin % 60).padStart(2, '0'); + est = `${newH}:${newM}`; + } + + // Logic for displaying Quai/Voie + let displayVoie = undefined; + const rawQ = s.quai || s.quai_reel; + if (rawQ && !s.is_supprime) { + const type = s.gare_id ? garesTypeMap[s.gare_id] || 'ville' : 'ville'; + const [depH, depM] = prev.split(':').map(Number); + const depMinutes = (isNaN(depH) ? 0 : depH) * 60 + (isNaN(depM) ? 0 : depM); + const diffMinutes = depMinutes - currentMinutes; + + if (type === 'ville') { + if (diffMinutes <= 30) displayVoie = `Voie ${rawQ}`; + } else if (type === 'interurbaine') { + if (diffMinutes <= 360) displayVoie = `Voie ${rawQ}`; + } else { + if (diffMinutes <= 1440) displayVoie = `Voie ${rawQ}`; + } + } + + return { + gare_id: s.gare_id, + gare_nom: s.gare_nom || `Arrêt ${idx + 1}`, + heure_prevue: prev.slice(0, 5), + heure_estimee: est.slice(0, 5), + voie: displayVoie, + duree_arret: idx > 0 && idx < resolvedParcours.stops.length - 1 && !s.is_supprime ? "30 secondes d'arrêt" : undefined, + is_depart: s.is_depart, + is_arrivee: s.is_arrivee, + is_nouveau_terminus: s.is_nouveau_terminus, + is_nouvelle_origine: s.is_nouvelle_origine, + is_added_manually: s.is_added_manually, + is_supprime: s.is_supprime, + cause_arret: s.cause_arret, + }; + }); + + setStops(resolved); } } catch (err) { console.error('Error loading train details:', err); @@ -247,7 +284,7 @@ export default function TrainDetailsClient({ }; fetchTrainDetails(); - }, [trainNumber, regionSlug]); + }, [trainNumber, regionSlug, currentDateDisplay]); const darkenColor = (hex: string, amount: number) => { const num = parseInt(hex.replace('#', ''), 16); @@ -260,6 +297,8 @@ export default function TrainDetailsClient({ const darkBg = darkenColor(theme.primary, 60); const isSuppressed = perturbationInfo?.type?.toLowerCase() === 'suppression'; + const isParcoursModifie = hasParcoursModifie || perturbationInfo?.type === 'modification_parcours'; + const activeServedStopsCount = stops.filter(s => !s.is_supprime).length; return (
@@ -268,7 +307,7 @@ export default function TrainDetailsClient({ {/* ═══════════════════════════════════════════════════════ */} - {/* SECTION 1: Dark theme header area (Image 2 style) */} + {/* SECTION 1: Dark theme header area */} {/* ═══════════════════════════════════════════════════════ */}
@@ -308,7 +347,7 @@ export default function TrainDetailsClient({ - {stops.length} arrêts + {activeServedStopsCount} arrêts desservis @@ -440,47 +479,122 @@ export default function TrainDetailsClient({
- {/* ─── CARD: PERTURBATION (Retard ou Suppression) ─── */} - {perturbationInfo && ( -
+ {/* ─── CARD: PERTURBATION (Strict priority: Supprimé > Modification de parcours > Retardé > A l'heure) ─── */} + {perturbationInfo && (() => { + const priority = getPerturbationPriority({ + isSupprime: isSuppressed, + hasParcoursModification: isParcoursModifie, + perturbationType: perturbationInfo.type, + delayMinutes: delayMinutes, + }); + + if (priority === 'a_l_heure' && !perturbationInfo.causeDetails) return null; + + const isSupp = priority === 'suppression'; + const isModif = priority === 'parcours_modifie'; + + return ( +
+
+
+ {isSupp ? ( + + + + ) : isModif ? ( + ↔ + ) : ( + + + + )} +
+
+
+ {isSupp + ? (perturbationInfo.cause ? `Train supprimé (${perturbationInfo.cause})` : 'Train supprimé') + : isModif + ? (nouveauTerminusNom + ? `Modification de parcours : Terminus ${nouveauTerminusNom}${perturbationInfo.cause ? ` (${perturbationInfo.cause})` : ''}` + : `Modification de parcours${perturbationInfo.cause ? ` (${perturbationInfo.cause})` : ''}`) + : (perturbationInfo.cause + ? `Retard estimé à ${delayMinutes} min (${perturbationInfo.cause})` + : `Retard estimé à ${delayMinutes} min`)} +
+
+ Train {trainType} {trainNumber} + {nouveauTerminusNom && ` • Nouveau terminus exceptionnel : ${nouveauTerminusNom}`} + {nouvelleOrigineNom && ` • Nouvelle origine : ${nouvelleOrigineNom}`} + {perturbationInfo.causeDetails && ` • ${perturbationInfo.causeDetails}`} +
+
+
+ + › +
+ ); + })()} + + {/* ─── CARD: SILLON DE SUBSTITUTION (SI DEFINI) ─── */} + {substitutionInfo && ( +
-
- {isSuppressed ? ( - - - - ) : ( - - - - )} +
+ directions_bus
-
- {isSuppressed ? 'Train supprimé' : `Retard estimé à ${delayMinutes} min`} +
+ + {substitutionInfo.type} {substitutionInfo.numero} + + + Train Supplémentaire +
-
- Train {trainType} {trainNumber} +
+ Substitution : {substitutionInfo.depart} ({substitutionInfo.heureDepart}) → {substitutionInfo.arrivee} ({substitutionInfo.heureArrivee})
- - › + + Voir › +
)} {/* ─── DESTINATION & OPÉRATEUR ─── */}
-

- Destination {destination} -

+
+

+ Destination {destination} +

+ {nouveauTerminusNom && ( + + Nouveau terminus + + )} + {nouvelleOrigineNom && ( + + Nouvelle origine + + )} +

Opéré par {operatorInfo}

{/* ═══════════════════════════════════════════════════════ */} - {/* TIMELINE VERTICALE */} + {/* TIMELINE VERTICALE DES ARRÊTS */} {/* ═══════════════════════════════════════════════════════ */}
-
+
{stops.map((stop, idx) => { - const isDelayed = !isSuppressed && delayMinutes > 0 && stop.heure_estimee !== stop.heure_prevue; - const isFirst = idx === 0; - const isLast = idx === stops.length - 1; + const isStopSuppressed = isSuppressed || Boolean(stop.is_supprime); + const isDelayed = !isStopSuppressed && delayMinutes > 0 && stop.heure_estimee !== stop.heure_prevue; + const isFirst = stop.is_depart || idx === 0; + const isLast = stop.is_arrivee || stop.is_nouveau_terminus || idx === stops.length - 1; return ( -
+
+ {/* Column 1: Time pill */}
- {isSuppressed ? stop.heure_prevue : (stop.heure_estimee || stop.heure_prevue)} + {isStopSuppressed ? stop.heure_prevue : (stop.heure_estimee || stop.heure_prevue)} {isDelayed && ( @@ -511,41 +633,96 @@ export default function TrainDetailsClient({ )}
+ {/* Column 2: Timeline circular dot */}
+ {/* Column 3: Station Name & Badges (Matching Image 1) */}
-
- {stop.gare_nom} -
+ {isStopSuppressed ? ( +
+ + {stop.gare_nom} + +
+ ! + Arrêt supprimé +
+
+ {stop.cause_arret || perturbationInfo?.cause || "Accident à un passage à niveau"} +
+
+ ) : stop.is_nouveau_terminus ? ( +
+ + {stop.gare_nom} + +
+ i + Terminus exceptionnel +
+
+ ) : stop.is_nouvelle_origine ? ( +
+ + {stop.gare_nom} + +
+ i + Origine exceptionnelle +
+
+ ) : stop.is_added_manually ? ( +
+ + {stop.gare_nom} + +
+ + + Arrêt exceptionnel +
+
+ ) : ( +
+ + {stop.gare_nom} + +
+ )} - {stop.duree_arret && !isFirst && !isLast && ( + {stop.duree_arret && !isFirst && !isLast && !isStopSuppressed && (
{stop.duree_arret}
)} - {stop.voie && !isSuppressed && ( + {stop.voie && !isStopSuppressed && (
{stop.voie}
)} - {isFirst && ( + {isFirst && !isStopSuppressed && (
- Départ + {stop.is_nouvelle_origine ? 'Départ (Nouvelle origine)' : 'Départ'}
)} - {isLast && ( + {isLast && !isStopSuppressed && (
- Arrivée + {stop.is_nouveau_terminus ? 'Arrivée (Nouveau terminus)' : 'Arrivée'}
)}
@@ -560,18 +737,30 @@ export default function TrainDetailsClient({ {perturbationInfo?.message && (
-
+
-
- - - +
+ {isParcoursModifie ? ( + ↔ + ) : ( + + + + )}
-

- Info Trafic +

+ {isParcoursModifie ? 'Modification de parcours' : 'Info Trafic'}

-

+

{perturbationInfo.message}

diff --git a/src/lib/materielUtils.ts b/src/lib/materielUtils.ts new file mode 100644 index 0000000..b4cd709 --- /dev/null +++ b/src/lib/materielUtils.ts @@ -0,0 +1,95 @@ +export interface MaterielRoulantRow { + id: number | string; + nom?: string; + nom_technique?: string; + capacite?: number; + image_url?: string; + statut_maintenance?: string; // 'OPERATIONNEL' | 'EN_AVARIE_ATELIER' | 'MAINTENANCE_PROGRAMMEE' + type_train?: string; + numero_serie?: string; + depot_attache?: string; +} + +export interface EffectiveMaterielResult { + materielId?: number | string; + nom: string; + nomTechnique?: string; + imageUrl?: string; + places: number; + voitures: number; + isReassigned?: boolean; + statutMaintenance?: string; +} + +/** + * Resolves the effective rolling stock to display for a train schedule. + * If the original rolling stock is unavailable (defined in roster management: statut_maintenance !== 'OPERATIONNEL') + * and a new rolling stock has been attributed, display the new rolling stock's image and specifications. + */ +export function resolveEffectiveMateriel( + compositionTrain: any[] | null | undefined, + allMateriels: MaterielRoulantRow[] = [], + fallbackData?: { + modeleTrain?: string; + capaciteTexte?: string; + imageUrl?: string; + } +): EffectiveMaterielResult { + const comp = Array.isArray(compositionTrain) ? compositionTrain : []; + + // Map to easily look up latest DB details by id + const matMap = new Map(); + for (const m of allMateriels) { + if (m && m.id != null) { + matMap.set(String(m.id), m); + } + } + + // If composition is empty, return fallback + if (comp.length === 0) { + return { + nom: fallbackData?.modeleTrain || 'Train', + imageUrl: fallbackData?.imageUrl, + places: 163, + voitures: 3, + isReassigned: false, + }; + } + + const firstEntry = comp[0]; + const firstDbMat = firstEntry?.materiel_id ? matMap.get(String(firstEntry.materiel_id)) : undefined; + const isFirstUnavailable = firstDbMat?.statut_maintenance && firstDbMat.statut_maintenance !== 'OPERATIONNEL'; + + // Check if a replacement is present in subsequent composition items: + // e.g. when a replacement material was appended via roster management + let replacementEntry = comp.slice(1).reverse().find(c => { + if (!c) return false; + const db = c.materiel_id ? matMap.get(String(c.materiel_id)) : undefined; + return !db || db.statut_maintenance === 'OPERATIONNEL'; + }); + + const isReassigned = Boolean(isFirstUnavailable && replacementEntry); + const targetEntry = isReassigned && replacementEntry ? replacementEntry : ( + // If not first unavailable but has multiple items, use the active one + firstEntry + ); + + const targetDbMat = targetEntry?.materiel_id ? matMap.get(String(targetEntry.materiel_id)) : undefined; + + const imageUrl = targetDbMat?.image_url || targetEntry?.materiel_image_url || fallbackData?.imageUrl; + const nom = targetDbMat?.nom || targetEntry?.materiel_nom || fallbackData?.modeleTrain || 'Train'; + const nomTechnique = targetDbMat?.nom_technique || targetEntry?.materiel_nom_technique; + const places = targetDbMat?.capacite || targetEntry?.capacite || targetEntry?.materiel_nb_places || 163; + const voitures = targetEntry?.materiel_voitures || (places > 200 ? 4 : 3); + + return { + materielId: targetEntry?.materiel_id, + nom, + nomTechnique, + imageUrl, + places, + voitures, + isReassigned, + statutMaintenance: targetDbMat?.statut_maintenance, + }; +} diff --git a/src/lib/parcoursUtils.ts b/src/lib/parcoursUtils.ts new file mode 100644 index 0000000..45da4bc --- /dev/null +++ b/src/lib/parcoursUtils.ts @@ -0,0 +1,269 @@ +import { formatPerturbationCause } from './perturbationUtils'; + +export interface PerturbedStop { + gare_id?: string; + gare_nom: string; + heure_depart?: string; + heure_arrivee?: string; + quai?: string; + quai_reel?: string; + voie?: string; + is_depart?: boolean; + is_arrivee?: boolean; + is_nouveau_terminus?: boolean; + is_nouvelle_origine?: boolean; + is_added_manually?: boolean; + is_supprime?: boolean; + retard_minutes?: number; + retard_cumule?: number; + cause_arret?: string; + types_perturbation?: string[]; + type_perturbation?: string; +} + +export interface ResolvedParcours { + stops: PerturbedStop[]; + destination: string; + provenance: string; + hasParcoursModification: boolean; + nouveauTerminusNom?: string; + nouvelleOrigineNom?: string; + sillonSubstitutionId?: string; + matchingPert?: any; +} + +const normName = (s: string) => (s || '').toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); + +/** + * Resolves the real-world route of a train taking into account any active perturbation: + * - nouveau terminus: route terminates at new terminus, remaining stops marked non desservis + * - nouvelle origine: route starts at new origin, preceding stops marked non desservis + * - arrêts ajoutés manuellement: included in stop sequence + * - arrêts supprimés: marked is_supprime + * - stop-specific delays: propagated / updated + */ +export function resolvePerturbedRoute( + horaire: any, + matchingPert: any, + gMap: Record = {} +): ResolvedParcours { + // 1. Get original stops from horaire + let originalGares: any[] = []; + if (typeof horaire?.gares_desservies === 'string') { + try { originalGares = JSON.parse(horaire.gares_desservies); } catch (e) { originalGares = []; } + } else if (Array.isArray(horaire?.gares_desservies)) { + originalGares = horaire.gares_desservies; + } + + const defaultDest = horaire?.gare_arrivee_nom || originalGares[originalGares.length - 1]?.gare_nom || gMap[horaire?.gare_arrivee_id] || 'Terminus'; + const defaultProv = horaire?.gare_depart_nom || originalGares[0]?.gare_nom || gMap[horaire?.gare_depart_id] || 'Origine'; + + if (!matchingPert) { + const stops: PerturbedStop[] = originalGares.map((g, idx) => ({ + gare_id: g.gare_id, + gare_nom: g.gare_nom || gMap[g.gare_id] || '', + heure_depart: (g.heure_depart || g.heure_arrivee || '').slice(0, 5), + heure_arrivee: (g.heure_arrivee || g.heure_depart || '').slice(0, 5), + quai: g.quai || g.quai_reel, + quai_reel: g.quai_reel, + is_depart: idx === 0, + is_arrivee: idx === originalGares.length - 1, + })); + return { + stops, + destination: defaultDest, + provenance: defaultProv, + hasParcoursModification: false, + }; + } + + // 2. Check if matchingPert has perturbations_arrets + let pertArretsRaw: any[] = Array.isArray(matchingPert.perturbations_arrets) + ? matchingPert.perturbations_arrets + : (Array.isArray(matchingPert.perturbations_arrets?.arrets) ? matchingPert.perturbations_arrets.arrets : []); + + const defaultPertCause = formatPerturbationCause(matchingPert?.cause) || matchingPert?.cause_details || 'Incident technique'; + + // Support root-level nouveau_terminus / nouvelle_origine if perturbations_arrets is empty + if (pertArretsRaw.length === 0) { + const rootNewTerm = matchingPert.nouveau_terminus || matchingPert.gare_terminus_nom; + const rootNewOrig = matchingPert.nouvelle_origine || matchingPert.gare_origine_nom; + if (rootNewTerm || rootNewOrig) { + pertArretsRaw = originalGares.map(g => { + const gNom = g.gare_nom || gMap[g.gare_id] || ''; + return { + ...g, + gare_nom: gNom, + is_nouveau_terminus: rootNewTerm && normName(gNom) === normName(rootNewTerm), + is_nouvelle_origine: rootNewOrig && normName(gNom) === normName(rootNewOrig), + }; + }); + } + } + + if (pertArretsRaw.length === 0) { + const isSuppGlobal = matchingPert.type_perturbation === 'suppression'; + const globalDelay = matchingPert.duree_retard_minutes || 0; + const isModifGlobal = matchingPert.type_perturbation === 'modification_parcours'; + + const stops: PerturbedStop[] = originalGares.map((g, idx) => ({ + gare_id: g.gare_id, + gare_nom: g.gare_nom || gMap[g.gare_id] || '', + heure_depart: (g.heure_depart || g.heure_arrivee || '').slice(0, 5), + heure_arrivee: (g.heure_arrivee || g.heure_depart || '').slice(0, 5), + quai: g.quai || g.quai_reel, + quai_reel: g.quai_reel, + is_depart: idx === 0, + is_arrivee: idx === originalGares.length - 1, + is_supprime: isSuppGlobal, + cause_arret: isSuppGlobal ? defaultPertCause : undefined, + retard_minutes: globalDelay > 0 ? globalDelay : undefined, + retard_cumule: globalDelay > 0 ? globalDelay : undefined, + })); + + return { + stops, + destination: defaultDest, + provenance: defaultProv, + hasParcoursModification: isModifGlobal, + sillonSubstitutionId: matchingPert.sillon_substitution_id || undefined, + matchingPert, + }; + } + + // 3. Process perturbed route from pertArretsRaw + let effectiveArrets = [...pertArretsRaw]; + + // Check for nouvelle origine + const origIdx = effectiveArrets.findIndex(a => a.is_nouvelle_origine); + let nouvelleOrigineNom: string | undefined = undefined; + let skippedLeadingStops: any[] = []; + + if (origIdx !== -1) { + nouvelleOrigineNom = effectiveArrets[origIdx].gare_nom; + skippedLeadingStops = effectiveArrets.slice(0, origIdx); + effectiveArrets = effectiveArrets.slice(origIdx); + } + + // Check for nouveau terminus + const termIdx = effectiveArrets.findIndex(a => a.is_nouveau_terminus); + let nouveauTerminusNom: string | undefined = undefined; + let skippedTrailingStops: any[] = []; + + if (termIdx !== -1) { + nouveauTerminusNom = effectiveArrets[termIdx].gare_nom; + skippedTrailingStops = effectiveArrets.slice(termIdx + 1); + effectiveArrets = effectiveArrets.slice(0, termIdx + 1); + } + + // If pertArretsRaw was truncated in BDD and didn't include the trailing stops from originalGares: + if (nouveauTerminusNom && skippedTrailingStops.length === 0 && originalGares.length > 0) { + const normTerm = normName(nouveauTerminusNom); + const origTermIdx = originalGares.findIndex(g => normName(g.gare_nom || gMap[g.gare_id] || '') === normTerm); + if (origTermIdx !== -1 && origTermIdx < originalGares.length - 1) { + skippedTrailingStops = originalGares.slice(origTermIdx + 1); + } + } + + // If pertArretsRaw didn't include the leading stops before nouvelleOrigineNom: + if (nouvelleOrigineNom && skippedLeadingStops.length === 0 && originalGares.length > 0) { + const normOrig = normName(nouvelleOrigineNom); + const origOrigIdx = originalGares.findIndex(g => normName(g.gare_nom || gMap[g.gare_id] || '') === normOrig); + if (origOrigIdx > 0) { + skippedLeadingStops = originalGares.slice(0, origOrigIdx); + } + } + + const hasModif = Boolean( + nouveauTerminusNom || + nouvelleOrigineNom || + effectiveArrets.some(a => (a.types_perturbation || []).includes('modification_parcours') || a.is_added_manually) || + matchingPert.type_perturbation === 'modification_parcours' + ); + + const destination = nouveauTerminusNom || effectiveArrets[effectiveArrets.length - 1]?.gare_nom || defaultDest; + const provenance = nouvelleOrigineNom || effectiveArrets[0]?.gare_nom || defaultProv; + + // Build active stops list + const activeStops: PerturbedStop[] = effectiveArrets.map((a, idx) => { + const sDep = (a.heure_depart || a.heure_arrivee || '').slice(0, 5); + const sArr = (a.heure_arrivee || a.heure_depart || '').slice(0, 5); + const types = a.types_perturbation || (a.type_perturbation ? [a.type_perturbation] : []); + const isSupp = Boolean( + matchingPert.type_perturbation === 'suppression' || + types.includes('arret_supprime') || + a.is_supprime || + a.supprime + ); + const stopDelay = a.retard_cumule ?? a.retard_entrant ?? a.retard_minutes ?? (matchingPert.duree_retard_minutes || undefined); + + return { + gare_id: a.gare_id, + gare_nom: a.gare_nom || gMap[a.gare_id] || '', + heure_depart: sDep, + heure_arrivee: sArr, + quai: a.quai || a.quai_reel, + quai_reel: a.quai_reel, + is_depart: idx === 0, + is_arrivee: idx === effectiveArrets.length - 1, + is_nouveau_terminus: Boolean(a.is_nouveau_terminus), + is_nouvelle_origine: Boolean(a.is_nouvelle_origine), + is_added_manually: Boolean(a.is_added_manually), + is_supprime: isSupp, + retard_minutes: typeof stopDelay === 'number' && stopDelay > 0 ? stopDelay : undefined, + retard_cumule: typeof a.retard_cumule === 'number' ? a.retard_cumule : undefined, + cause_arret: formatPerturbationCause(a.cause_arret) || (isSupp ? defaultPertCause : undefined), + types_perturbation: types, + type_perturbation: a.type_perturbation || types[0], + }; + }); + + // Leading stops skipped due to nouvelle origine (marked as suppressed) + const suppressedLeading: PerturbedStop[] = skippedLeadingStops.map(g => ({ + gare_id: g.gare_id, + gare_nom: g.gare_nom || gMap[g.gare_id] || '', + heure_depart: (g.heure_depart || g.heure_arrivee || '').slice(0, 5), + heure_arrivee: (g.heure_arrivee || g.heure_depart || '').slice(0, 5), + quai: g.quai || g.quai_reel, + quai_reel: g.quai_reel, + is_depart: false, + is_arrivee: false, + is_supprime: true, + cause_arret: formatPerturbationCause(g.cause_arret) || defaultPertCause, + types_perturbation: ['arret_supprime', 'modification_parcours'], + type_perturbation: 'arret_supprime', + })); + + // Trailing stops skipped due to nouveau terminus (marked as suppressed) + const suppressedTrailing: PerturbedStop[] = skippedTrailingStops.map(g => ({ + gare_id: g.gare_id, + gare_nom: g.gare_nom || gMap[g.gare_id] || '', + heure_depart: (g.heure_depart || g.heure_arrivee || '').slice(0, 5), + heure_arrivee: (g.heure_arrivee || g.heure_depart || '').slice(0, 5), + quai: g.quai || g.quai_reel, + quai_reel: g.quai_reel, + is_depart: false, + is_arrivee: false, + is_supprime: true, + cause_arret: formatPerturbationCause(g.cause_arret) || defaultPertCause, + types_perturbation: ['arret_supprime', 'modification_parcours'], + type_perturbation: 'arret_supprime', + })); + + const allResolvedStops: PerturbedStop[] = [ + ...suppressedLeading, + ...activeStops, + ...suppressedTrailing, + ]; + + return { + stops: allResolvedStops, + destination, + provenance, + hasParcoursModification: hasModif, + nouveauTerminusNom, + nouvelleOrigineNom, + sillonSubstitutionId: matchingPert.sillon_substitution_id || undefined, + matchingPert, + }; +} diff --git a/src/lib/perturbationUtils.ts b/src/lib/perturbationUtils.ts index 1c021a2..dbd374c 100644 --- a/src/lib/perturbationUtils.ts +++ b/src/lib/perturbationUtils.ts @@ -15,15 +15,101 @@ export interface PerturbationData { actif?: boolean; } +export type PerturbationPriorityType = 'suppression' | 'parcours_modifie' | 'retard' | 'a_l_heure'; + +/** + * Resolves perturbation priority strictly conforming to user rule: + * Supprimé > Modification de parcours > Retardé > A l'heure + */ +export function getPerturbationPriority(data: { + isSupprime?: boolean; + hasParcoursModification?: boolean; + perturbationType?: string; + typesPerturbation?: string[]; + delayMinutes?: number; +}): PerturbationPriorityType { + const types = (data.typesPerturbation || []).map(t => (t || '').toLowerCase()); + const pType = (data.perturbationType || '').toLowerCase(); + + // 1. SUPPRIMÉ + if ( + data.isSupprime || + pType === 'suppression' || + pType.includes('suppr') || + types.includes('arret_supprime') || + types.includes('suppression') + ) { + return 'suppression'; + } + + // 2. MODIFICATION DE PARCOURS + if ( + data.hasParcoursModification || + pType === 'modification_parcours' || + pType === 'parcours_modifie' || + pType.includes('parcours') || + pType.includes('modifi') || + types.includes('modification_parcours') || + types.includes('parcours_modifie') + ) { + return 'parcours_modifie'; + } + + // 3. RETARDÉ + if ( + (typeof data.delayMinutes === 'number' && data.delayMinutes > 0) || + pType.includes('retard') || + types.includes('retard') || + types.includes('arret_prolonge') + ) { + return 'retard'; + } + + // 4. A L'HEURE + return 'a_l_heure'; +} + +/** + * Formats a perturbation cause into a clear, concise passenger label + */ +export function formatPerturbationCause(cause?: string): string { + if (!cause) return ''; + const c = cause.trim(); + const lower = c.toLowerCase(); + if (lower.includes('passage_niveau') || lower.includes('passage a niveau') || lower.includes('passage à niveau')) return 'Accident à un passage à niveau'; + if (lower.includes('accident_personne') || lower.includes('personne')) return 'Accident de personne'; + if (lower.includes('deraillement')) return 'Déraillement de train'; + if (lower === 'incident_technique') return 'Incident technique'; + if (lower === 'panne_materiel' || lower === 'panne_de_train' || lower.includes('panne_de_train')) return 'Panne de train'; + if (lower === 'conditions_meteo' || lower === 'meteo') return 'Conditions météo'; + if (lower === 'obstacle_voie') return 'Obstacle sur la voie'; + if (lower === 'travaux') return 'Travaux sur la voie'; + if (lower === 'greve') return 'Mouvement social'; + if (lower === 'panne_signalisation') return 'Panne de signalisation'; + if (c.includes('_')) { + const formatted = c.replace(/_/g, ' '); + return formatted.charAt(0).toUpperCase() + formatted.slice(1); + } + return c.charAt(0).toUpperCase() + c.slice(1); +} + /** * Checks if a perturbation strictly applies to the recorded target ISO date (YYYY-MM-DD) */ export function isPerturbationActiveOnDate(pert: any, targetDateIso: string): boolean { if (!pert || pert.actif === false) return false; - // 1. Check jours_perturbation array if present - if (Array.isArray(pert.jours_perturbation) && pert.jours_perturbation.length > 0) { - return pert.jours_perturbation.some((jp: any) => { + // 1. Check jours_perturbation (from pert, pert.perturbations_arrets, or pert.details) + const joursArr = (Array.isArray(pert.jours_perturbation) && pert.jours_perturbation.length > 0) + ? pert.jours_perturbation + : (Array.isArray(pert.perturbations_arrets?.jours_perturbation) && pert.perturbations_arrets.jours_perturbation.length > 0) + ? pert.perturbations_arrets.jours_perturbation + : (Array.isArray(pert.details?.jours_perturbation) && pert.details.jours_perturbation.length > 0) + ? pert.details.jours_perturbation + : null; + + if (joursArr) { + const match = joursArr.some((jp: any) => { if (typeof jp === 'string') { return jp.slice(0, 10) === targetDateIso; } @@ -33,11 +119,28 @@ export function isPerturbationActiveOnDate(pert: any, targetDateIso: string): bo } return false; }); + if (match) return true; } // 2. Check date_debut and date_fin - const start = pert.date_debut ? pert.date_debut.slice(0, 10) : null; - const end = pert.date_fin ? pert.date_fin.slice(0, 10) : null; + const extractDate = (val: any): string | null => { + if (!val) return null; + if (typeof val === 'string') { + const match = val.match(/^(\d{4}-\d{2}-\d{2})/); + if (match) return match[1]; + } + const d = new Date(val); + if (!isNaN(d.getTime())) { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + return null; + }; + + const start = extractDate(pert.date_debut); + const end = extractDate(pert.date_fin); if (start && end) { return targetDateIso >= start && targetDateIso <= end; @@ -57,28 +160,18 @@ export interface BannerStyle { bgColor: string; // Background color matching Image 2 textColor: string; // Text color iconBgColor: string; // Icon circle bg color - iconText: string; // Icon character ('i', '!', '✕', '↔') - badgeLabel: string; // Label ('Information', 'Informations', 'Retard', 'Suppression', 'Parcours modifié') + iconText: string; // Icon character ('i', '!', '✕', '↔', '✓') + badgeLabel: string; // Label ('Information', 'Informations', 'Retard', 'Suppression', 'Parcours modifié', 'A l'heure') defaultCause?: string; } /** - * Gets banner styling and metadata strictly matching Image 2 + * Gets banner styling and metadata strictly matching Image 2 & user priority */ export function getPerturbationBannerStyle(type: string): BannerStyle { const normType = (type || '').toLowerCase(); - if (normType.includes('retard')) { - return { - bgColor: '#fff1ed', - textColor: '#c00', - iconBgColor: '#e65100', - iconText: '!', - badgeLabel: 'Retard', - defaultCause: 'Retard estimé' - }; - } - + // 1. SUPPRIMÉ if (normType.includes('suppr')) { return { bgColor: '#fdeded', @@ -90,6 +183,7 @@ export function getPerturbationBannerStyle(type: string): BannerStyle { }; } + // 2. MODIFICATION DE PARCOURS if (normType.includes('parcours') || normType.includes('modifi')) { return { bgColor: '#f0f7ff', @@ -101,6 +195,30 @@ export function getPerturbationBannerStyle(type: string): BannerStyle { }; } + // 3. RETARDÉ + if (normType.includes('retard')) { + return { + bgColor: '#fff1ed', + textColor: '#c00', + iconBgColor: '#e65100', + iconText: '!', + badgeLabel: 'Retard', + defaultCause: 'Retard estimé' + }; + } + + // 4. A L'HEURE + if (normType.includes('heure') || normType === 'a_l_heure' || normType === 'normal') { + return { + bgColor: '#f0fdf4', + textColor: '#15803d', + iconBgColor: '#15803d', + iconText: '✓', + badgeLabel: 'A l\'heure', + defaultCause: 'Train à l\'heure' + }; + } + // Default: Information return { bgColor: '#eef7fc',