import { useQuery } from "@tanstack/react-query"; import { ChevronDown, ChevronRight, Star } from "lucide-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { fetchRoutes } from "~/api/transit"; import RouteIcon from "~/components/RouteIcon"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useFavorites } from "~/hooks/useFavorites"; import { useSessionState } from "~/hooks/useSessionState"; import "../tailwind-full.css"; // Feeds that contain many agencies and need a two-level (Feed → Agency → Routes) // accordion. Add new multi-agency feeds here when ready. const MULTI_AGENCY_FEEDS: Record = { xunta: { displayName: "Transporte Público de Galicia (Xunta)" }, }; export default function RoutesPage() { const { t } = useTranslation(); usePageTitle(t("navbar.routes", "Rutas")); const [searchQuery, setSearchQuery] = useSessionState( "routes_searchQuery", "" ); const [isFavoritesExpanded, setIsFavoritesExpanded] = useSessionState( "routes_isFavoritesExpanded", true ); const { isFavorite: isFavoriteRoute } = useFavorites("favouriteRoutes"); const { toggleFavorite: toggleFavoriteAgency, isFavorite: isFavoriteAgency } = useFavorites("favouriteAgencies"); const [expandedAgencies, setExpandedAgencies] = useSessionState< Record >("routes_expandedAgencies", {}); const [expandedFeeds, setExpandedFeeds] = useSessionState< Record >("routes_expandedFeeds", {}); const [expandedSubAgencies, setExpandedSubAgencies] = useSessionState< Record >("routes_expandedSubAgencies", {}); const toggleAgencyExpanded = (agency: string) => { setExpandedAgencies((prev) => ({ ...prev, [agency]: !prev[agency] })); }; const toggleFeedExpanded = (feedId: string) => { setExpandedFeeds((prev) => ({ ...prev, [feedId]: !prev[feedId] })); }; const toggleSubAgencyExpanded = (key: string) => { setExpandedSubAgencies((prev) => ({ ...prev, [key]: !prev[key] })); }; // Each entry is either a plain feed ID ("tussa") — which includes all agencies // in that feed — or a "feedId:agencyId" pair ("renfe:cercanias") to restrict // results to a single agency within a feed. // Multi-agency feeds (listed in MULTI_AGENCY_FEEDS) are fetched separately. const mainFeeds = [ "vitrasa", "tranvias", "tussa", "ourense", "lugo", "shuttle", "renfe:1071VC", ]; const { data: routes, isLoading } = useQuery({ queryKey: ["routes", "main"], queryFn: () => fetchRoutes(mainFeeds), }); const { data: xuntaRoutes, isLoading: isLoadingXunta } = useQuery({ queryKey: ["routes", "xunta"], queryFn: () => fetchRoutes(["xunta"]), }); const filteredRoutes = useMemo(() => { return routes?.filter( (route) => route.shortName?.toLowerCase().includes(searchQuery.toLowerCase()) || route.longName?.toLowerCase().includes(searchQuery.toLowerCase()) ); }, [routes, searchQuery]); const routesByAgency = useMemo(() => { return filteredRoutes?.reduce( (acc, route) => { const agency = route.agencyName || t("routes.unknown_agency", "Otros"); if (!acc[agency]) acc[agency] = []; acc[agency].push(route); return acc; }, {} as Record ); }, [filteredRoutes, t]); const sortedAgencyEntries = useMemo(() => { if (!routesByAgency) return []; return Object.entries(routesByAgency).sort(([a, routesA], [b, routesB]) => { // Use the agency's own gtfsId (feedId:agencyId) as the stable key — this // matches the "agency#feedId:agencyId" alert selector format and correctly // handles feeds that contain multiple agencies. const agencyIdA = routesA?.[0]?.agencyId ?? routesA?.[0]?.id.split(":")[0] ?? a.toLowerCase(); const agencyIdB = routesB?.[0]?.agencyId ?? routesB?.[0]?.id.split(":")[0] ?? b.toLowerCase(); const feedIdA = agencyIdA.split(":")[0]; const feedIdB = agencyIdB.split(":")[0]; // First, sort by favorite status const isFavA = isFavoriteAgency(agencyIdA); const isFavB = isFavoriteAgency(agencyIdB); if (isFavA && !isFavB) return -1; if (!isFavA && isFavB) return 1; // Then by fixed order const indexA = mainFeeds.indexOf(feedIdA); const indexB = mainFeeds.indexOf(feedIdB); if (indexA === -1 && indexB === -1) { return a.localeCompare(b); } if (indexA === -1) return 1; if (indexB === -1) return -1; return indexA - indexB; }); }, [routesByAgency, mainFeeds, isFavoriteAgency]); // Group multi-agency feed routes by agency name, filtered by search query. const multiAgencyGroups = useMemo(() => { const result: Record< string, { displayName: string; agenciesMap: Record } > = {}; const xuntaFiltered = xuntaRoutes?.filter( (route) => !searchQuery || route.shortName?.toLowerCase().includes(searchQuery.toLowerCase()) || route.longName?.toLowerCase().includes(searchQuery.toLowerCase()) ); if (xuntaFiltered && xuntaFiltered.length > 0) { const agenciesMap: Record = {}; for (const route of xuntaFiltered) { const id = route.agencyId ?? route.agencyName ?? "otros"; if (!agenciesMap[id]) agenciesMap[id] = []; agenciesMap[id].push(route); } result["xunta"] = { displayName: MULTI_AGENCY_FEEDS.xunta.displayName, agenciesMap, }; } return result; }, [xuntaRoutes, searchQuery]); const allRoutes = useMemo( () => [...(routes ?? []), ...(xuntaRoutes ?? [])], [routes, xuntaRoutes] ); const favoriteRoutes = useMemo(() => { return ( allRoutes.filter( (route) => isFavoriteRoute(route.id) && (!searchQuery || route.shortName ?.toLowerCase() .includes(searchQuery.toLowerCase()) || route.longName?.toLowerCase().includes(searchQuery.toLowerCase())) ) || [] ); }, [allRoutes, searchQuery, isFavoriteRoute]); return (
setSearchQuery(e.target.value)} />
{isLoading && (
)}
{favoriteRoutes.length > 0 && !searchQuery && (
{isFavoritesExpanded && (
{favoriteRoutes.map((route) => (

{route.longName}

))}
)}
)} {sortedAgencyEntries.map(([agency, agencyRoutes]) => { // Use the agency's own gtfsId (feedId:agencyId) as the stable favourite key. const agencyId = agencyRoutes?.[0]?.agencyId ?? agencyRoutes?.[0]?.id.split(":")[0] ?? agency.toLowerCase(); const isFav = isFavoriteAgency(agencyId); const isExpanded = searchQuery ? true : (expandedAgencies[agency] ?? isFav); return (
{isExpanded && (
{agencyRoutes.map((route) => (

{route.longName}

))}
)}
); })} {/* Multi-agency feeds: Feed accordion → Agency sub-accordion → Routes */} {Object.entries(multiAgencyGroups).map(([feedId, feedGroup]) => { const isFeedExpanded = searchQuery ? true : (expandedFeeds[feedId] ?? false); const totalRouteCount = Object.values(feedGroup.agenciesMap).reduce( (sum, r) => sum + (r?.length ?? 0), 0 ); return (
{isFeedExpanded && (
{isLoadingXunta ? (
) : ( Object.entries(feedGroup.agenciesMap) .sort(([a], [b]) => a.localeCompare(b)) .map(([agencyId, agencyRoutes]) => { const agencyName = agencyRoutes?.[0]?.agencyName ?? agencyId; const agencyCode = agencyId.includes(":") ? agencyId.split(":")[1] : agencyId; const isFav = isFavoriteAgency(agencyId); const isSubExpanded = searchQuery ? true : (expandedSubAgencies[agencyId] ?? false); return (
{isSubExpanded && (
{agencyRoutes?.map((route) => (

{route.longName}

))}
)}
); }) )}
)}
); })}
); }