diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-09 00:00:39 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-09 00:00:50 +0100 |
| commit | d71f0ed16d175285f2e8cbde6091994c2aa1d962 (patch) | |
| tree | e8b0bcc3f432fa9d5243dd4595af256511643151 | |
| parent | 5288cfbed34f94c4321b8d9dc497cfd0da3ffd26 (diff) | |
Enhance route details handling and add favorites functionality; improve error logging and response structure
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/TransitController.cs | 15 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Services/OtpService.cs | 4 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Types/Transit/RouteDtos.cs | 2 | ||||
| -rw-r--r-- | src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs | 4 | ||||
| -rw-r--r-- | src/frontend/app/api/schema.ts | 2 | ||||
| -rw-r--r-- | src/frontend/app/hooks/useFavorites.ts | 28 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes-$id.tsx | 159 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes.tsx | 222 |
8 files changed, 354 insertions, 82 deletions
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<RouteDetailsResponse>(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<int> 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<RouteDetailsContent.Args> 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<string[]>(() => { + 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 ( + <button + type="button" + onClick={() => toggleFavorite(id)} + className="p-2 rounded-full hover:bg-surface" + aria-label={t("routes.toggle_favorite", "Alternar favorita")} + > + <Star + size={20} + className={isFav ? "fill-yellow-500 text-yellow-500" : "text-muted"} + /> + </button> + ); +} + export default function RouteDetailsPage() { const { id } = useParams(); const { t, i18n } = useTranslation(); @@ -45,6 +73,8 @@ export default function RouteDetailsPage() { const mapRef = useRef<MapRef>(null); const stopRefs = useRef<Record<string, HTMLDivElement | null>>({}); + 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(() => <FavoriteStar id={id} />, [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<number, typeof route.patterns> + ); + }, [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 ( <div className="flex justify-center py-12"> @@ -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<number, typeof route.patterns> - ); - - 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) ?? []; @@ -240,16 +299,16 @@ export default function RouteDetailsPage() { 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"), icon: List, @@ -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", + ], }} /> </Source> @@ -595,6 +662,24 @@ export default function RouteDetailsPage() { <h3 className="text-base font-semibold mb-3 text-text"> {t("routes.stops", "Paradas")} </h3> + + {selectedPattern?.tripCount === 0 && ( + <div className="flex flex-col items-center justify-center py-12 px-4 text-center"> + <div className="bg-surface p-4 rounded-full mb-4 border border-border"> + <Clock size={32} className="text-muted" /> + </div> + <h4 className="text-lg font-bold text-text mb-1"> + {t("routes.no_service_today", "Sin servicio hoy")} + </h4> + <p className="text-sm text-muted max-w-xs"> + {t( + "routes.no_service_today_desc", + "Este trayecto no tiene viajes programados para la fecha seleccionada." + )} + </p> + </div> + )} + <div className="space-y-2"> {selectedPattern?.stops.map((stop, idx) => ( <div @@ -620,15 +705,37 @@ export default function RouteDetailsPage() { )} </div> <div className="flex-1"> - <p className="font-semibold text-text text-sm"> + <p + className={`font-semibold text-text text-sm ${selectedStopId === stop.id ? "text-primary" : ""}`} + > {stop.name} {stop.code && ( - <span className="text-[11px] font-normal text-gray-500 ml-2"> + <span + className={`text-[11px] font-normal ml-2 ${selectedStopId === stop.id ? "text-primary/70" : "text-gray-500"}`} + > {stop.code} </span> )} </p> + {(stop.pickupType === "NONE" || + stop.dropOffType === "NONE") && ( + <div className="flex items-center gap-1.5 mt-0.5"> + {stop.pickupType === "NONE" && ( + <span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-600 dark:text-amber-400"> + <ArrowDownCircle size={10} /> + {t("routes.drop_off_only", "Solo bajada")} + </span> + )} + {stop.dropOffType === "NONE" && ( + <span className="inline-flex items-center gap-1 text-[10px] font-medium text-blue-600 dark:text-blue-400"> + <ArrowUpCircle size={10} /> + {t("routes.pickup_only", "Solo subida")} + </span> + )} + </div> + )} + {selectedStopId === stop.id && ( <Link to={`/stops/${stop.id}`} diff --git a/src/frontend/app/routes/routes.tsx b/src/frontend/app/routes/routes.tsx index b33fe58..128bbc4 100644 --- a/src/frontend/app/routes/routes.tsx +++ b/src/frontend/app/routes/routes.tsx @@ -1,16 +1,30 @@ import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { ChevronDown, ChevronRight, Star } from "lucide-react"; +import { useMemo, useState } 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 "../tailwind-full.css"; export default function RoutesPage() { const { t } = useTranslation(); usePageTitle(t("navbar.routes", "Rutas")); const [searchQuery, setSearchQuery] = useState(""); + const { toggleFavorite: toggleFavoriteRoute, isFavorite: isFavoriteRoute } = + useFavorites("favouriteRoutes"); + const { toggleFavorite: toggleFavoriteAgency, isFavorite: isFavoriteAgency } = + useFavorites("favouriteAgencies"); + + const [expandedAgencies, setExpandedAgencies] = useState< + Record<string, boolean> + >({}); + + 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<string, typeof routes> - ); + 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<string, typeof routes> + ); + }, [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 ( <div className="container mx-auto px-4 py-6"> @@ -48,7 +91,7 @@ export default function RoutesPage() { <input type="text" placeholder={t("routes.search_placeholder", "Buscar rutas...")} - className="w-full px-4 py-3 rounded-xl border border-border bg-surface text-text focus:outline-none focus:ring-2 focus:ring-primary shadow-sm placeholder-gray-500" + className="w-full rounded-xl border border-border bg-surface px-4 py-3 text-text placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> @@ -60,47 +103,118 @@ export default function RoutesPage() { </div> )} - <div className="space-y-8"> - {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]) => ( - <div key={agency}> - <h2 className="text-xl font-bold text-text mb-4 border-b border-border pb-2"> - {agency} - </h2> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> + <div className="space-y-3"> + {favoriteRoutes.length > 0 && !searchQuery && ( + <div className="mb-2"> + <h2 className="mb-3 flex items-center gap-2 border-b border-border pb-2 text-sm font-semibold uppercase tracking-wide text-muted"> + <Star size={16} className="fill-yellow-500 text-yellow-500" /> + {t("routes.favorites", "Favoritas")} + </h2> + <div className="space-y-2"> + {favoriteRoutes.map((route) => ( + <div + key={`fav-${route.id}`} + className="rounded-xl border border-border bg-surface" + > + <Link + to={`/routes/${route.id}`} + className="flex items-center gap-3 px-4 py-3" + > + <RouteIcon + line={route.shortName ?? "?"} + mode="pill" + colour={route.color ?? undefined} + textColour={route.textColor ?? undefined} + /> + <div className="flex-1 min-w-0"> + <p className="truncate text-sm font-medium text-text"> + {route.longName} + </p> + </div> + </Link> + </div> + ))} + </div> + </div> + )} + + {sortedAgencyEntries.map(([agency, agencyRoutes]) => { + const isFav = isFavoriteAgency(agency); + const isExpanded = searchQuery + ? true + : (expandedAgencies[agency] ?? false); + + return ( + <div + key={agency} + className="overflow-hidden rounded-xl border border-border bg-surface" + > + <div + className={`flex items-center justify-between px-4 py-3 select-none ${isExpanded ? "border-b border-border" : ""}`} + > + <button + type="button" + onClick={() => toggleAgencyExpanded(agency)} + className="flex flex-1 items-center gap-3 text-left" + > + <div className="text-muted"> + {isExpanded ? ( + <ChevronDown size={18} /> + ) : ( + <ChevronRight size={18} /> + )} + </div> + <h2 className="text-base font-semibold text-text"> + {agency} + </h2> + <span className="rounded-full bg-background px-2 py-0.5 text-xs text-muted"> + {agencyRoutes.length} + </span> + </button> + <button + type="button" + onClick={() => toggleFavoriteAgency(agency)} + className={`rounded-full p-2 transition-colors ${ + isFav + ? "text-yellow-500" + : "text-muted hover:text-yellow-500" + }`} + aria-label={t( + "routes.toggle_favorite_agency", + "Alternar agencia favorita" + )} + > + <Star size={16} className={isFav ? "fill-current" : ""} /> + </button> + </div> + + {isExpanded && ( + <div className="space-y-1 px-3 py-2"> {agencyRoutes.map((route) => ( - <Link - key={route.id} - to={`/routes/${route.id}`} - className="flex items-center gap-3 p-4 bg-surface rounded-lg shadow hover:shadow-lg transition-shadow border border-border" - > - <RouteIcon - line={route.shortName ?? "?"} - mode="pill" - colour={route.color ?? undefined} - textColour={route.textColor ?? undefined} - /> - <div className="flex-1 min-w-0"> - <p className="text-sm md:text-md font-semibold text-text"> - {route.longName} - </p> - </div> - </Link> + <div key={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" + > + <RouteIcon + line={route.shortName ?? "?"} + mode="pill" + colour={route.color ?? undefined} + textColour={route.textColor ?? undefined} + /> + <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> ); |
