From d71f0ed16d175285f2e8cbde6091994c2aa1d962 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 9 Mar 2026 00:00:39 +0100 Subject: Enhance route details handling and add favorites functionality; improve error logging and response structure --- .../Controllers/TransitController.cs | 15 +- src/Enmarcha.Backend/Services/OtpService.cs | 4 +- src/Enmarcha.Backend/Types/Transit/RouteDtos.cs | 2 + .../Queries/RouteDetailsContent.cs | 4 + src/frontend/app/api/schema.ts | 2 + src/frontend/app/hooks/useFavorites.ts | 28 +++ src/frontend/app/routes/routes-$id.tsx | 159 ++++++++++++--- src/frontend/app/routes/routes.tsx | 222 ++++++++++++++++----- 8 files changed, 354 insertions(+), 82 deletions(-) create mode 100644 src/frontend/app/hooks/useFavorites.ts diff --git a/src/Enmarcha.Backend/Controllers/TransitController.cs b/src/Enmarcha.Backend/Controllers/TransitController.cs index 7876dbe..9b13972 100644 --- a/src/Enmarcha.Backend/Controllers/TransitController.cs +++ b/src/Enmarcha.Backend/Controllers/TransitController.cs @@ -124,8 +124,21 @@ public class TransitController : ControllerBase var query = RouteDetailsContent.Query(new RouteDetailsContent.Args(id, serviceDate)); var response = await SendOtpQueryAsync(query); - if (response?.Data?.Route == null) + if (response == null) { + return StatusCode(500, "Failed to connect to OTP."); + } + + if (!response.IsSuccess) + { + var messages = string.Join("; ", response.Errors?.Select(e => e.Message) ?? []); + _logger.LogError("OTP returned errors: {Errors}", messages); + return StatusCode(500, $"OTP Error: {messages}"); + } + + if (response.Data?.Route == null) + { + _logger.LogWarning("Route details not found for {Id} on {ServiceDate}", id, serviceDate); return NotFound(); } diff --git a/src/Enmarcha.Backend/Services/OtpService.cs b/src/Enmarcha.Backend/Services/OtpService.cs index e72877e..0de06bf 100644 --- a/src/Enmarcha.Backend/Services/OtpService.cs +++ b/src/Enmarcha.Backend/Services/OtpService.cs @@ -108,7 +108,9 @@ public class OtpService .Select(t => t.Stoptimes.ElementAtOrDefault(i)?.ScheduledDeparture ?? -1) .Where(d => d != -1) .OrderBy(d => d) - .ToList() + .ToList(), + PickupType = pattern.TripsForDate.FirstOrDefault()?.Stoptimes.ElementAtOrDefault(i)?.PickupType, + DropOffType = pattern.TripsForDate.FirstOrDefault()?.Stoptimes.ElementAtOrDefault(i)?.DropoffType }).ToList() }; } diff --git a/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs b/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs index 3904555..8427ec7 100644 --- a/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs +++ b/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs @@ -43,4 +43,6 @@ public class PatternStopDto public double Lat { get; set; } public double Lon { get; set; } public List ScheduledDepartures { get; set; } = []; + public string? PickupType { get; set; } + public string? DropOffType { get; set; } } diff --git a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs index 59a0991..2891b63 100644 --- a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs +++ b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs @@ -44,6 +44,8 @@ public class RouteDetailsContent : IGraphRequest tripsForDate(serviceDate: "{{args.ServiceDate}}") { stoptimes { scheduledDeparture + pickupType + dropoffType } } } @@ -108,5 +110,7 @@ public class RouteDetailsResponse : AbstractGraphResponse public class StoptimeItem { [JsonPropertyName("scheduledDeparture")] public int ScheduledDeparture { get; set; } + [JsonPropertyName("pickupType")] public string? PickupType { get; set; } + [JsonPropertyName("dropoffType")] public string? DropoffType { get; set; } } } diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index bc47116..0c55969 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -107,6 +107,8 @@ export const PatternStopSchema = z.object({ lat: z.number(), lon: z.number(), scheduledDepartures: z.array(z.number()), + pickupType: z.string().optional().nullable(), + dropOffType: z.string().optional().nullable(), }); export const PatternSchema = z.object({ diff --git a/src/frontend/app/hooks/useFavorites.ts b/src/frontend/app/hooks/useFavorites.ts new file mode 100644 index 0000000..962ac2d --- /dev/null +++ b/src/frontend/app/hooks/useFavorites.ts @@ -0,0 +1,28 @@ +import { useState } from "react"; + +/** + * A simple hook for managing favorite items in localStorage. + * @param key LocalStorage key to use + * @returns [favorites, toggleFavorite, isFavorite] + */ +export function useFavorites(key: string) { + const [favorites, setFavorites] = useState(() => { + if (typeof window === "undefined") return []; + const stored = localStorage.getItem(key); + return stored ? JSON.parse(stored) : []; + }); + + const toggleFavorite = (id: string) => { + setFavorites((prev) => { + const next = prev.includes(id) + ? prev.filter((item) => item !== id) + : [...prev, id]; + localStorage.setItem(key, JSON.stringify(next)); + return next; + }); + }; + + const isFavorite = (id: string) => favorites.includes(id); + + return { favorites, toggleFavorite, isFavorite }; +} diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx index 6cc872d..2174244 100644 --- a/src/frontend/app/routes/routes-$id.tsx +++ b/src/frontend/app/routes/routes-$id.tsx @@ -1,11 +1,14 @@ 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"; @@ -21,13 +24,38 @@ import { fetchRouteDetails } from "~/api/transit"; import { AppMap } from "~/components/shared/AppMap"; import { useBackButton, + usePageRightNode, usePageTitle, usePageTitleNode, } from "~/contexts/PageTitleContext"; import { useStopArrivals } 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(); @@ -45,6 +73,8 @@ export default function RouteDetailsPage() { 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"); @@ -138,6 +168,9 @@ export default function RouteDetailsPage() { usePageTitleNode(titleNode); + const rightNode = useMemo(() => , [id]); + usePageRightNode(rightNode); + useBackButton({ to: "/routes" }); const weekDays = useMemo(() => { @@ -169,6 +202,43 @@ export default function RouteDetailsPage() { }); }, [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 (
@@ -183,21 +253,6 @@ export default function RouteDetailsPage() { ); } - const activePatterns = route.patterns.filter((p) => p.tripCount > 0); - - const patternsByDirection = activePatterns.reduce( - (acc, pattern) => { - const dir = pattern.directionId; - if (!acc[dir]) acc[dir] = []; - acc[dir].push(pattern); - return acc; - }, - {} as Record - ); - - const selectedPattern = - activePatterns.find((p) => p.id === selectedPatternId) || activePatterns[0]; - const selectedPatternLabel = selectedPattern ? selectedPattern.headsign || selectedPattern.name : t("routes.details", "Detalles de ruta"); @@ -210,6 +265,10 @@ export default function RouteDetailsPage() { { 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) ?? []; @@ -239,16 +298,16 @@ export default function RouteDetailsPage() { : "h-[50%] md:h-[50%]"; const layoutOptions = [ - { - id: "balanced", - label: t("routes.layout_balanced", "Equilibrada"), - icon: LayoutGrid, - }, { 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"), @@ -380,11 +439,19 @@ export default function RouteDetailsPage() { type="circle" paint={{ "circle-radius": 6, - "circle-color": "#ffffff", + "circle-color": [ + "case", + ["==", ["get", "id"], selectedStopId ?? ""], + route.color ? formatHex(route.color) : "#3b82f6", + "#ffffff", + ], "circle-stroke-width": 2, - "circle-stroke-color": route.color - ? formatHex(route.color) - : "#3b82f6", + "circle-stroke-color": [ + "case", + ["==", ["get", "id"], selectedStopId ?? ""], + "#ffffff", + route.color ? formatHex(route.color) : "#3b82f6", + ], }} /> @@ -595,6 +662,24 @@ export default function RouteDetailsPage() {

{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) => (
-

+

{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 && ( + >({}); + + const toggleAgencyExpanded = (agency: string) => { + setExpandedAgencies((prev) => ({ ...prev, [agency]: !prev[agency] })); + }; const orderedAgencies = [ "vitrasa", @@ -26,21 +40,50 @@ export default function RoutesPage() { queryFn: () => fetchRoutes(orderedAgencies), }); - const filteredRoutes = routes?.filter( - (route) => - route.shortName?.toLowerCase().includes(searchQuery.toLowerCase()) || - route.longName?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const filteredRoutes = useMemo(() => { + return routes?.filter( + (route) => + route.shortName?.toLowerCase().includes(searchQuery.toLowerCase()) || + route.longName?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [routes, searchQuery]); - const routesByAgency = 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 - ); + 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], [b]) => { + // First, sort by favorite status + const isFavA = isFavoriteAgency(a); + const isFavB = isFavoriteAgency(b); + if (isFavA && !isFavB) return -1; + if (!isFavA && isFavB) return 1; + + // Then by fixed order + const indexA = orderedAgencies.indexOf(a.toLowerCase()); + const indexB = orderedAgencies.indexOf(b.toLowerCase()); + if (indexA === -1 && indexB === -1) { + return a.localeCompare(b); + } + if (indexA === -1) return 1; + if (indexB === -1) return -1; + return indexA - indexB; + }); + }, [routesByAgency, orderedAgencies, isFavoriteAgency]); + + const favoriteRoutes = useMemo(() => { + return filteredRoutes?.filter((route) => isFavoriteRoute(route.id)) || []; + }, [filteredRoutes, isFavoriteRoute]); return (
@@ -48,7 +91,7 @@ export default function RoutesPage() { setSearchQuery(e.target.value)} /> @@ -60,47 +103,118 @@ export default function RoutesPage() {
)} -
- {routesByAgency && - Object.entries(routesByAgency) - .sort(([a], [b]) => { - const indexA = orderedAgencies.indexOf(a.toLowerCase()); - const indexB = orderedAgencies.indexOf(b.toLowerCase()); - if (indexA === -1 && indexB === -1) { - return a.localeCompare(b); - } - if (indexA === -1) return 1; - if (indexB === -1) return -1; - return indexA - indexB; - }) - .map(([agency, agencyRoutes]) => ( -
-

- {agency} -

-
+
+ {favoriteRoutes.length > 0 && !searchQuery && ( +
+

+ + {t("routes.favorites", "Favoritas")} +

+
+ {favoriteRoutes.map((route) => ( +
+ + +
+

+ {route.longName} +

+
+ +
+ ))} +
+
+ )} + + {sortedAgencyEntries.map(([agency, agencyRoutes]) => { + const isFav = isFavoriteAgency(agency); + const isExpanded = searchQuery + ? true + : (expandedAgencies[agency] ?? false); + + return ( +
+
+ + +
+ + {isExpanded && ( +
{agencyRoutes.map((route) => ( - - -
-

- {route.longName} -

-
- +
+ + +
+

+ {route.longName} +

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