diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-04-06 00:54:41 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-04-06 00:54:41 +0200 |
| commit | 1f45ef6dcd0840aa67bc42d578013b1dd086c54d (patch) | |
| tree | aaac18d7e913c3824be839c56091c280124ab0c8 | |
| parent | b30fd3a498d09f5a6c7a7175c178ea289e3ccd78 (diff) | |
| -rw-r--r-- | src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs | 1 | ||||
| -rw-r--r-- | src/frontend/app/hooks/useSessionState.ts | 39 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes-$id.tsx | 6 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes.tsx | 260 |
4 files changed, 287 insertions, 19 deletions
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<T>( + key: string, + defaultValue: T +): [T, (updater: T | ((prev: T) => T)) => void] { + const [state, setStateRaw] = useState<T>(() => { + 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)} > <div - className="w-full sm:max-w-lg bg-background rounded-t-2xl sm:rounded-2xl border border-border shadow-xl max-h-[75%] overflow-hidden" + className="w-full sm:max-w-lg bg-background rounded-t-2xl sm:rounded-2xl border border-border shadow-xl max-h-[80%] overflow-hidden" onClick={(event) => event.stopPropagation()} > <div className="flex items-center justify-between px-4 py-3 border-b border-border"> @@ -546,10 +546,10 @@ export default function RouteDetailsPage() { return ( <div key={dir}> - <div className="px-4 py-2 text-xs font-semibold text-muted uppercase tracking-wide"> + <div className="px-4 my-2 text-xs font-semibold text-muted uppercase tracking-wide"> {directionLabel} </div> - <div className="space-y-2 px-3 pb-3"> + <div className="space-y-2 px-3 mb-3"> {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<string, { displayName: string }> = { + 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<string>( + "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<string, boolean> + >("routes_expandedAgencies", {}); + + const [expandedFeeds, setExpandedFeeds] = useSessionState< + Record<string, boolean> + >("routes_expandedFeeds", {}); + + const [expandedSubAgencies, setExpandedSubAgencies] = useSessionState< Record<string, boolean> - >({}); + >("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<string, typeof xuntaRoutes> } + > = {}; + + 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<string, typeof xuntaFiltered> = {}; + 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 ( <div className="container mx-auto px-4 py-6"> @@ -152,7 +231,7 @@ export default function RoutesPage() { <div key={`fav-${route.id}`} className="rounded-lg"> <Link to={`/routes/${route.id}`} - className="flex items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-background" + className="flex items-center gap-3 rounded-lg px-3 py-1 hover:bg-background" > <RouteIcon line={route.shortName ?? "?"} @@ -255,6 +334,157 @@ export default function RoutesPage() { </div> ); })} + + {/* 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 ( + <div + key={feedId} + className="overflow-hidden rounded-xl border border-border bg-surface" + > + <button + type="button" + onClick={() => toggleFeedExpanded(feedId)} + className={`flex w-full items-center gap-3 px-4 py-4 text-left ${ + isFeedExpanded ? "border-b border-border" : "" + }`} + > + <div className="text-muted"> + {isFeedExpanded ? ( + <ChevronDown size={18} /> + ) : ( + <ChevronRight size={18} /> + )} + </div> + <h2 className="flex-1 text-base font-semibold text-text"> + {feedGroup.displayName} + </h2> + {isLoadingXunta ? ( + <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" /> + ) : ( + <span className="rounded-full bg-background px-2 py-0.5 text-xs text-muted"> + {totalRouteCount} + </span> + )} + </button> + + {isFeedExpanded && ( + <div className="space-y-0"> + {isLoadingXunta ? ( + <div className="flex justify-center py-6"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /> + </div> + ) : ( + 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 ( + <div + key={agencyId} + className="border-t border-border first:border-t-0" + > + <div + className={`flex items-center justify-between pl-8 pr-4 py-2.5 select-none ${ + isSubExpanded ? "border-b border-border" : "" + }`} + > + <button + type="button" + onClick={() => + toggleSubAgencyExpanded(agencyId) + } + className="flex flex-1 items-center gap-3 text-left" + > + <div className="text-muted"> + {isSubExpanded ? ( + <ChevronDown size={16} /> + ) : ( + <ChevronRight size={16} /> + )} + </div> + <span className="text-sm font-medium text-text"> + {agencyName} + </span> + <span className="text-xs text-muted font-mono"> + {agencyCode} + </span> + <span className="rounded-full bg-background px-2 py-0.5 text-xs text-muted"> + {agencyRoutes?.length ?? 0} + </span> + </button> + <button + type="button" + onClick={() => toggleFavoriteAgency(agencyId)} + className={`rounded-full p-1.5 transition-colors ${ + isFav + ? "text-yellow-500" + : "text-muted hover:text-yellow-500" + }`} + aria-label={t( + "routes.toggle_favorite_agency", + "Alternar agencia favorita" + )} + > + <Star + size={14} + className={isFav ? "fill-current" : ""} + /> + </button> + </div> + + {isSubExpanded && ( + <div className="space-y-1 px-3 py-2"> + {agencyRoutes?.map((route) => ( + <div key={route.id} className="rounded-lg"> + <Link + to={`/routes/${route.id}`} + className="flex items-center gap-3 rounded-lg px-3 py-1 hover:bg-background" + > + <RouteIcon + line={route.shortName ?? "?"} + mode="pill" + colour={route.color ?? "#6b7280"} + textColour={ + route.textColor ?? "#ffffff" + } + /> + <div className="flex-1 min-w-0"> + <p className="truncate text-sm font-medium text-text"> + {route.longName} + </p> + </div> + </Link> + </div> + ))} + </div> + )} + </div> + ); + }) + )} + </div> + )} + </div> + ); + })} </div> </div> ); |
