import { useQuery } from "@tanstack/react-query"; import { ArrowDownCircle, ArrowUpCircle, Bus, ChevronDown, Clock, LayoutGrid, List, Map as MapIcon, Star, X, } from "lucide-react"; import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AttributionControl, Layer, Source, type MapRef, } from "react-map-gl/maplibre"; import { Link, useLocation, useNavigate, useParams } from "react-router"; import { fetchRouteDetails } from "~/api/transit"; import { AppMap } from "~/components/shared/AppMap"; import { useBackButton, usePageRightNode, usePageTitle, usePageTitleNode, } from "~/contexts/PageTitleContext"; import { useStopEstimates } from "~/hooks/useArrivals"; import { useFavorites } from "~/hooks/useFavorites"; import { formatHex } from "~/utils/colours"; import "../tailwind-full.css"; function FavoriteStar({ id }: { id?: string }) { const { isFavorite, toggleFavorite } = useFavorites("favouriteRoutes"); const { t } = useTranslation(); if (!id) return null; const isFav = isFavorite(id); return ( ); } export default function RouteDetailsPage() { const { id } = useParams(); const { t, i18n } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const selectedPatternId = location.hash ? location.hash.slice(1) : null; const [selectedStopId, setSelectedStopId] = useState(null); const [layoutMode, setLayoutMode] = useState<"balanced" | "map" | "list">( "balanced" ); const [isPatternPickerOpen, setIsPatternPickerOpen] = useState(false); const [selectedWeekDate, setSelectedWeekDate] = useState( () => new Date() ); const mapRef = useRef(null); const stopRefs = useRef>({}); const { isFavorite, toggleFavorite } = useFavorites("favouriteRoutes"); const formatDateKey = (value: Date) => { const year = value.getFullYear(); const month = String(value.getMonth() + 1).padStart(2, "0"); const day = String(value.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; }; const selectedDateKey = useMemo( () => formatDateKey(selectedWeekDate), [selectedWeekDate] ); const ONE_HOUR_SECONDS = 3600; const isTodaySelectedDate = selectedDateKey === formatDateKey(new Date()); const now = new Date(); const nowSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds(); const formatDelayMinutes = (delayMinutes: number) => { if (delayMinutes === 0) return "OK"; return delayMinutes > 0 ? ` (R${Math.abs(delayMinutes)})` : ` (A${Math.abs(delayMinutes)})`; }; const { data: route, isLoading } = useQuery({ queryKey: ["route", id, selectedDateKey], queryFn: () => fetchRouteDetails(id!, selectedDateKey), enabled: !!id, }); const { data: selectedStopEstimates, isLoading: isRealtimeLoading } = useStopEstimates( selectedStopId ?? "", id ?? "", undefined, Boolean(selectedStopId) && Boolean(id) && isTodaySelectedDate ); usePageTitle( route?.shortName ? `${route.shortName} - ${route.longName}` : t("routes.details", "Detalles de ruta") ); const titleNode = useMemo(() => { if (!route) { return ( {t("routes.details", "Detalles de ruta")} ); } return (
{route.shortName || route.longName} {route.longName}
); }, [route, t]); usePageTitleNode(titleNode); const rightNode = useMemo(() => , [id]); usePageRightNode(rightNode); useBackButton({ to: "/routes" }); const weekDays = useMemo(() => { const base = new Date(); return [-2, -1, 0, 1, 2, 3, 4].map((offset) => { const date = new Date(base); date.setDate(base.getDate() + offset); let label: string; if (offset === -1) { label = t("routes.day_yesterday", "Ayer"); } else if (offset === 0) { label = t("routes.day_today", "Hoy"); } else if (offset === 1) { label = t("routes.day_tomorrow", "Mañana"); } else { label = date.toLocaleDateString(i18n.language || "es-ES", { weekday: "short", day: "numeric", month: "short", }); } return { key: formatDateKey(date), date, label, }; }); }, [i18n.language, t]); const activePatterns = useMemo(() => { return route?.patterns.filter((p) => p.tripCount > 0) ?? []; }, [route?.patterns]); const patternsByDirection = useMemo(() => { return activePatterns.reduce( (acc, pattern) => { const dir = pattern.directionId; if (!acc[dir]) acc[dir] = []; acc[dir].push(pattern); return acc; }, {} as Record ); }, [activePatterns, route?.patterns]); const selectedPattern = useMemo(() => { if (!route) return null; if (selectedPatternId) { const found = activePatterns.find((p) => p.id === selectedPatternId); if (found) return found; } // Try to find the most frequent pattern in direction 0 (outbound) const outboundPatterns = (patternsByDirection[0] ?? []).sort( (a, b) => b.tripCount - a.tripCount ); if (outboundPatterns.length > 0) return outboundPatterns[0]; // Fallback to any pattern with trips const anyPatterns = [...activePatterns].sort( (a, b) => b.tripCount - a.tripCount ); return anyPatterns[0] || route.patterns[0]; }, [activePatterns, patternsByDirection, selectedPatternId, route]); if (isLoading) { return (
); } if (!route) { return (
{t("routes.not_found", "Línea no encontrada")}
); } const selectedPatternLabel = selectedPattern ? selectedPattern.headsign || selectedPattern.name : t("routes.details", "Detalles de ruta"); const sameDirectionPatterns = selectedPattern ? (patternsByDirection[selectedPattern.directionId] ?? []) : []; const departuresByStop = (() => { const byStop = new Map< string, { departure: number; patternId: string; tripId?: string | null }[] >(); if (selectedPattern?.tripCount === 0) { return byStop; } for (const pattern of sameDirectionPatterns) { for (const stop of pattern.stops) { const current = byStop.get(stop.id) ?? []; current.push( ...stop.scheduledDepartures.map((departure) => ({ departure, patternId: pattern.id, tripId: null, })) ); byStop.set(stop.id, current); } } for (const stopDepartures of byStop.values()) { stopDepartures.sort((a, b) => a.departure - b.departure); } return byStop; })(); const mapHeightClass = layoutMode === "map" ? "h-[75%] md:h-[75%]" : layoutMode === "list" ? "h-[25%] md:h-[25%]" : "h-[50%] md:h-[50%]"; const layoutOptions = [ { id: "map", label: t("routes.layout_map", "Mapa"), icon: MapIcon, }, { id: "balanced", label: t("routes.layout_balanced", "Equilibrada"), icon: LayoutGrid, }, { id: "list", label: t("routes.layout_list", "Paradas"), icon: List, }, ] as const; const handleStopClick = ( stopId: string, lat: number, lon: number, scroll = true ) => { setSelectedStopId(stopId); mapRef.current?.flyTo({ center: [lon, lat], zoom: 15, duration: 1000, }); if (scroll) { stopRefs.current[stopId]?.scrollIntoView({ behavior: "smooth", block: "center", }); } }; const geojson: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: selectedPattern?.geometry ? [ { type: "Feature", geometry: { type: "LineString", coordinates: selectedPattern.geometry, }, properties: {}, }, ] : [], }; const stopsGeojson: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: selectedPattern?.stops.map((stop) => ({ type: "Feature", geometry: { type: "Point", coordinates: [stop.lon, stop.lat], }, properties: { id: stop.id, name: stop.name, code: stop.code, lat: stop.lat, lon: stop.lon, }, })) || [], }; return (
{ const feature = e.features?.[0]; if (feature && feature.layer.id === "stop-circles") { const { id, lat, lon } = feature.properties; handleStopClick(id, lat, lon, true); } }} showTraffic={false} attributionControl={false} > {selectedPattern?.geometry && ( )}
{layoutOptions.map((option) => { const Icon = option.icon; const isActive = layoutMode === option.id; return ( ); })}
{isPatternPickerOpen && (
setIsPatternPickerOpen(false)} >
event.stopPropagation()} >

{t("routes.trips", "Trayectos")}

{t("routes.choose_trip", "Elige un trayecto")}

{[0, 1].map((dir) => { const patterns = patternsByDirection[dir] ?? []; if (patterns.length === 0) return null; const directionLabel = dir === 0 ? t("routes.direction_outbound", "Ida") : t("routes.direction_inbound", "Vuelta"); const sortedPatterns = [...patterns].sort( (a, b) => b.tripCount - a.tripCount ); return (
{directionLabel}
{sortedPatterns.map((pattern) => { const destination = pattern.headsign || pattern.name || ""; const firstStop = pattern.stops[0]?.name ?? ""; const lastStop = pattern.stops[pattern.stops.length - 1]?.name ?? ""; const times = pattern.stops[0]?.scheduledDepartures?.slice( 0, 3 ) ?? []; return ( ); })}
); })}
)}

{t("routes.stops", "Paradas")}

{selectedPattern?.tripCount === 0 && (

{t("routes.no_service_today", "Sin servicio hoy")}

{t( "routes.no_service_today_desc", "Este trayecto no tiene viajes programados para la fecha seleccionada." )}

)}
{selectedPattern?.stops.map((stop, idx) => (
{ stopRefs.current[stop.id] = el; }} onClick={() => handleStopClick(stop.id, stop.lat, stop.lon, false) } className={`flex items-start gap-3 p-2.5 rounded-lg border transition-colors cursor-pointer ${ selectedStopId === stop.id ? "bg-primary/5 border-primary" : "bg-surface border-border hover:border-primary/50" }`} >
{idx < selectedPattern.stops.length - 1 && (
)}

{stop.name} {stop.code && ( {stop.code} )}

{(stop.pickupType === "NONE" || stop.dropOffType === "NONE") && (
{stop.pickupType === "NONE" && ( {t("routes.drop_off_only", "Solo bajada")} )} {stop.dropOffType === "NONE" && ( {t("routes.pickup_only", "Solo subida")} )}
)} {selectedStopId === stop.id && ( {t("routes.view_stop", "Ver parada")} )} {selectedStopId === stop.id && (departuresByStop.get(stop.id)?.length ?? 0) > 0 && (
{(departuresByStop.get(stop.id) ?? []).map( (item, i) => { const isPast = isTodaySelectedDate && item.departure < nowSeconds; return ( {Math.floor(item.departure / 3600) .toString() .padStart(2, "0")} : {Math.floor((item.departure % 3600) / 60) .toString() .padStart(2, "0")} ); } )}
)} {selectedStopId === stop.id && isTodaySelectedDate && (
{t("routes.realtime", "Tiempo real")}
{isRealtimeLoading ? (
{t("routes.loading_realtime", "Cargando...")}
) : (selectedStopEstimates?.arrivals.length ?? 0) === 0 ? (
{t( "routes.realtime_no_route_estimates", "Sin estimaciones para esta línea" )}
) : ( <> {(() => { const firstArrival = selectedStopEstimates!.arrivals[0]; const isFirstSelectedPattern = firstArrival.patternId === selectedPattern?.id; return (
{t("routes.next_arrival", "Próximo")} {firstArrival.estimate.minutes}′ {firstArrival.delay?.minutes ? formatDelayMinutes( firstArrival.delay.minutes ) : ""}
); })()} {selectedStopEstimates!.arrivals.length > 1 && (
{selectedStopEstimates!.arrivals .slice(1) .map((arrival, i) => { const isSelectedPattern = arrival.patternId === selectedPattern?.id; return ( {arrival.estimate.minutes}′ {arrival.delay?.minutes ? formatDelayMinutes( arrival.delay.minutes ) : ""} ); })}
)} )}
)}
))}
); }