From 1f45ef6dcd0840aa67bc42d578013b1dd086c54d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 6 Apr 2026 00:54:41 +0200 Subject: Display all Xunta routes in route list --- .../Services/Processors/RenfeRealTimeProcessor.cs | 1 - src/frontend/app/hooks/useSessionState.ts | 39 ++++ src/frontend/app/routes/routes-$id.tsx | 6 +- src/frontend/app/routes/routes.tsx | 260 +++++++++++++++++++-- 4 files changed, 287 insertions(+), 19 deletions(-) create mode 100644 src/frontend/app/hooks/useSessionState.ts diff --git a/src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs index 31f9928..5e5468e 100644 --- a/src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs @@ -32,7 +32,6 @@ public partial class RenfeRealTimeProcessor : AbstractRealTimeProcessor foreach (Arrival contextArrival in context.Arrivals) { - // var trainNumber = contextArrival.TripId.Split(":")[1][..5]; var trainNumber = RenfeTrainNumberExpression.Match(contextArrival.TripId).Groups[1].Value; contextArrival.Headsign.Destination = trainNumber + " - " + contextArrival.Headsign.Destination; diff --git a/src/frontend/app/hooks/useSessionState.ts b/src/frontend/app/hooks/useSessionState.ts new file mode 100644 index 0000000..f5a9b4e --- /dev/null +++ b/src/frontend/app/hooks/useSessionState.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from "react"; + +/** + * Like useState, but backed by sessionStorage so state survives back-navigation + * within the same browser tab. Cleared when the tab is closed. + */ +export function useSessionState( + key: string, + defaultValue: T +): [T, (updater: T | ((prev: T) => T)) => void] { + const [state, setStateRaw] = useState(() => { + try { + const stored = sessionStorage.getItem(key); + return stored !== null ? (JSON.parse(stored) as T) : defaultValue; + } catch { + return defaultValue; + } + }); + + const setState = useCallback( + (updater: T | ((prev: T) => T)) => { + setStateRaw((prev) => { + const next = + typeof updater === "function" + ? (updater as (prev: T) => T)(prev) + : updater; + try { + sessionStorage.setItem(key, JSON.stringify(next)); + } catch { + // sessionStorage may be unavailable (private browsing quota, etc.) + } + return next; + }); + }, + [key] + ); + + return [state, setState]; +} diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx index bccaf56..278a848 100644 --- a/src/frontend/app/routes/routes-$id.tsx +++ b/src/frontend/app/routes/routes-$id.tsx @@ -510,7 +510,7 @@ export default function RouteDetailsPage() { onClick={() => setIsPatternPickerOpen(false)} >
event.stopPropagation()} >
@@ -546,10 +546,10 @@ export default function RouteDetailsPage() { return (
-
+
{directionLabel}
-
+
{sortedPatterns.map((pattern) => { const destination = pattern.headsign || pattern.name || ""; diff --git a/src/frontend/app/routes/routes.tsx b/src/frontend/app/routes/routes.tsx index 52b67c9..d7fba1e 100644 --- a/src/frontend/app/routes/routes.tsx +++ b/src/frontend/app/routes/routes.tsx @@ -1,35 +1,65 @@ import { useQuery } from "@tanstack/react-query"; import { ChevronDown, ChevronRight, Star } from "lucide-react"; -import { useMemo, useState } from "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] = useState(""); - const [isFavoritesExpanded, setIsFavoritesExpanded] = useState(true); + 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] = useState< + 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. - const orderedAgencies = [ + // Multi-agency feeds (listed in MULTI_AGENCY_FEEDS) are fetched separately. + const mainFeeds = [ "vitrasa", "tranvias", "tussa", @@ -37,12 +67,16 @@ export default function RoutesPage() { "lugo", "shuttle", "renfe:1071VC", - "xunta:XG621", ]; const { data: routes, isLoading } = useQuery({ - queryKey: ["routes"], - queryFn: () => fetchRoutes(orderedAgencies), + queryKey: ["routes", "main"], + queryFn: () => fetchRoutes(mainFeeds), + }); + + const { data: xuntaRoutes, isLoading: isLoadingXunta } = useQuery({ + queryKey: ["routes", "xunta"], + queryFn: () => fetchRoutes(["xunta"]), }); const filteredRoutes = useMemo(() => { @@ -89,8 +123,8 @@ export default function RoutesPage() { if (!isFavA && isFavB) return 1; // Then by fixed order - const indexA = orderedAgencies.indexOf(feedIdA); - const indexB = orderedAgencies.indexOf(feedIdB); + const indexA = mainFeeds.indexOf(feedIdA); + const indexB = mainFeeds.indexOf(feedIdB); if (indexA === -1 && indexB === -1) { return a.localeCompare(b); } @@ -98,11 +132,56 @@ export default function RoutesPage() { if (indexB === -1) return -1; return indexA - indexB; }); - }, [routesByAgency, orderedAgencies, isFavoriteAgency]); + }, [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 filteredRoutes?.filter((route) => isFavoriteRoute(route.id)) || []; - }, [filteredRoutes, isFavoriteRoute]); + return ( + allRoutes.filter( + (route) => + isFavoriteRoute(route.id) && + (!searchQuery || + route.shortName + ?.toLowerCase() + .includes(searchQuery.toLowerCase()) || + route.longName?.toLowerCase().includes(searchQuery.toLowerCase())) + ) || [] + ); + }, [allRoutes, searchQuery, isFavoriteRoute]); return (
@@ -152,7 +231,7 @@ export default function RoutesPage() {
); })} + + {/* 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} +

+
+ +
+ ))} +
+ )} +
+ ); + }) + )} +
+ )} +
+ ); + })}
); -- cgit v1.3