summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-04-06 00:54:41 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2026-04-06 00:54:41 +0200
commit1f45ef6dcd0840aa67bc42d578013b1dd086c54d (patch)
treeaaac18d7e913c3824be839c56091c280124ab0c8
parentb30fd3a498d09f5a6c7a7175c178ea289e3ccd78 (diff)
Display all Xunta routes in route listHEADmain
-rw-r--r--src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs1
-rw-r--r--src/frontend/app/hooks/useSessionState.ts39
-rw-r--r--src/frontend/app/routes/routes-$id.tsx6
-rw-r--r--src/frontend/app/routes/routes.tsx260
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>
);