From 073c7174490ed3d8ae34c3f8c8f1b91bce711f6f Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 30 Jan 2026 19:59:47 +0100 Subject: feat: Add date parameter to GetRouteDetails and update fetchRouteDetails to support date queries feat: Enhance localization with new date-related strings in English, Spanish, and Galician feat: Improve RouteDetailsPage with layout options and date selection for better user experience --- src/frontend/app/api/transit.ts | 24 +++- src/frontend/app/i18n/locales/en-GB.json | 4 + src/frontend/app/i18n/locales/es-ES.json | 4 + src/frontend/app/i18n/locales/gl-ES.json | 4 + src/frontend/app/routes/routes-$id.tsx | 202 ++++++++++++++++++++++++------- 5 files changed, 191 insertions(+), 47 deletions(-) (limited to 'src/frontend/app') diff --git a/src/frontend/app/api/transit.ts b/src/frontend/app/api/transit.ts index 317271a..fbff5fa 100644 --- a/src/frontend/app/api/transit.ts +++ b/src/frontend/app/api/transit.ts @@ -23,12 +23,24 @@ export const fetchRoutes = async (feeds: string[] = []): Promise => { return RouteSchema.array().parse(data); }; -export const fetchRouteDetails = async (id: string): Promise => { - const resp = await fetch(`/api/transit/routes/${encodeURIComponent(id)}`, { - headers: { - Accept: "application/json", - }, - }); +export const fetchRouteDetails = async ( + id: string, + date?: string +): Promise => { + const params = new URLSearchParams(); + if (date) { + params.set("date", date); + } + + const query = params.toString(); + const resp = await fetch( + `/api/transit/routes/${encodeURIComponent(id)}${query ? `?${query}` : ""}`, + { + headers: { + Accept: "application/json", + }, + } + ); if (!resp.ok) { throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 8863634..df895a9 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -169,6 +169,10 @@ "direction_inbound": "Inbound", "stops": "Stops", "unknown_agency": "Others", + "day_yesterday": "Yesterday", + "day_today": "Today", + "day_tomorrow": "Tomorrow", + "week_date": "Date", "trip_count": "{{count}} trips today", "trip_count_one": "1 trip today", "trip_count_short": "({{count}} trips)", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index bc62a8f..58e2f08 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -169,6 +169,10 @@ "direction_inbound": "Vuelta", "stops": "Paradas", "unknown_agency": "Otros", + "day_yesterday": "Ayer", + "day_today": "Hoy", + "day_tomorrow": "Mañana", + "week_date": "Fecha", "trip_count": "{{count}} viajes hoy", "trip_count_one": "1 viaje hoy", "trip_count_short": "({{count}} viajes)", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index e339d06..181915a 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -7,6 +7,10 @@ "data_gtfs": "Horarios programados", "data_gtfs_source": "Feed GTFS oficial (datos abertos municipais)", "data_realtime": "Datos en tempo real", + "day_yesterday": "Onte", + "day_today": "Hoxe", + "day_tomorrow": "Mañá", + "week_date": "Data", "data_realtime_source": "API da cidade", "data_traffic": "Estado do tráfico", "data_traffic_source": "Datos abertos municipais", diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx index 62de642..7de16eb 100644 --- a/src/frontend/app/routes/routes-$id.tsx +++ b/src/frontend/app/routes/routes-$id.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { LayoutGrid, List, Map as MapIcon } from "lucide-react"; import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -19,17 +20,35 @@ import "../tailwind-full.css"; export default function RouteDetailsPage() { const { id } = useParams(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [selectedPatternId, setSelectedPatternId] = useState( null ); const [selectedStopId, setSelectedStopId] = useState(null); + const [layoutMode, setLayoutMode] = useState<"balanced" | "map" | "list">( + "balanced" + ); + const [selectedWeekDate, setSelectedWeekDate] = useState( + () => new Date() + ); const mapRef = useRef(null); const stopRefs = useRef>({}); + const formatDateKey = (value: Date) => { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + const selectedDateKey = useMemo( + () => formatDateKey(selectedWeekDate), + [selectedWeekDate] + ); + const { data: route, isLoading } = useQuery({ - queryKey: ["route", id], - queryFn: () => fetchRouteDetails(id!), + queryKey: ["route", id, selectedDateKey], + queryFn: () => fetchRouteDetails(id!, selectedDateKey), enabled: !!id, }); @@ -71,6 +90,35 @@ export default function RouteDetailsPage() { useBackButton({ to: "/routes" }); + const weekDays = useMemo(() => { + const base = new Date(); + return [-2, -1, 0, 1, 2, 3, 4].map((offset) => { + const date = new Date(base); + date.setDate(base.getDate() + offset); + + let label: string; + if (offset === -1) { + label = t("routes.day_yesterday", "Ayer"); + } else if (offset === 0) { + label = t("routes.day_today", "Hoy"); + } else if (offset === 1) { + label = t("routes.day_tomorrow", "Mañana"); + } else { + label = date.toLocaleDateString(i18n.language || "es-ES", { + weekday: "short", + day: "numeric", + month: "short", + }); + } + + return { + key: formatDateKey(date), + date, + label, + }; + }); + }, [i18n.language, t]); + if (isLoading) { return (
@@ -100,6 +148,31 @@ export default function RouteDetailsPage() { const selectedPattern = activePatterns.find((p) => p.id === selectedPatternId) || activePatterns[0]; + const mapHeightClass = + layoutMode === "map" + ? "h-[75%] md:h-[75%]" + : layoutMode === "list" + ? "h-[25%] md:h-[25%]" + : "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: "list", + label: t("routes.layout_list", "Paradas"), + icon: List, + }, + ] as const; + const handleStopClick = ( stopId: string, lat: number, @@ -109,7 +182,7 @@ export default function RouteDetailsPage() { setSelectedStopId(stopId); mapRef.current?.flyTo({ center: [lon, lat], - zoom: 16, + zoom: 15, duration: 1000, }); @@ -160,7 +233,7 @@ export default function RouteDetailsPage() {
-
+
+ +
+ {layoutOptions.map((option) => { + const Icon = option.icon; + const isActive = layoutMode === option.id; + return ( + + ); + })} +
- { + setSelectedPatternId(e.target.value); + setSelectedStopId(null); + }} > - {patterns.map((pattern) => ( - + {patterns.map((pattern) => ( + + ))} + + ))} + + + + +
+
-
-

+
+

{t("routes.stops", "Paradas")}

-
+
{selectedPattern?.stops.map((stop, idx) => (
handleStopClick(stop.id, stop.lat, stop.lon, false) } - className={`flex items-start gap-4 p-3 rounded-lg border transition-colors cursor-pointer ${ + className={`flex items-start gap-3 p-2.5 rounded-lg border transition-colors cursor-pointer ${ selectedStopId === stop.id ? "bg-primary/5 border-primary" : "bg-surface border-border hover:border-primary/50" @@ -268,17 +388,17 @@ export default function RouteDetailsPage() { >
{idx < selectedPattern.stops.length - 1 && ( -
+
)}
-

+

{stop.name} {stop.code && ( - + {stop.code} )} -- cgit v1.3