diff options
Diffstat (limited to 'src/frontend/app')
| -rw-r--r-- | src/frontend/app/api/transit.ts | 24 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 4 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 4 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 4 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes-$id.tsx | 202 |
5 files changed, 191 insertions, 47 deletions
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<Route[]> => { return RouteSchema.array().parse(data); }; -export const fetchRouteDetails = async (id: string): Promise<RouteDetails> => { - const resp = await fetch(`/api/transit/routes/${encodeURIComponent(id)}`, { - headers: { - Accept: "application/json", - }, - }); +export const fetchRouteDetails = async ( + id: string, + date?: string +): Promise<RouteDetails> => { + 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<string | null>( null ); const [selectedStopId, setSelectedStopId] = useState<string | null>(null); + const [layoutMode, setLayoutMode] = useState<"balanced" | "map" | "list">( + "balanced" + ); + const [selectedWeekDate, setSelectedWeekDate] = useState<Date>( + () => new Date() + ); const mapRef = useRef<MapRef>(null); const stopRefs = useRef<Record<string, HTMLDivElement | null>>({}); + 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 ( <div className="flex justify-center py-12"> @@ -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() { <div className="flex flex-col h-full overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col relative overflow-hidden"> - <div className="h-1/2 relative"> + <div className={`${mapHeightClass} relative`}> <AppMap ref={mapRef} initialViewState={ @@ -212,45 +285,92 @@ export default function RouteDetailsPage() { /> </Source> </AppMap> + + <div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-full border border-border bg-background/90 p-1 shadow-sm backdrop-blur"> + {layoutOptions.map((option) => { + const Icon = option.icon; + const isActive = layoutMode === option.id; + return ( + <button + key={option.id} + type="button" + onClick={() => setLayoutMode(option.id)} + className={`h-8 w-8 rounded-full flex items-center justify-center transition-colors ${ + isActive + ? "bg-primary text-white" + : "text-muted hover:text-text" + }`} + aria-label={option.label} + title={option.label} + > + <Icon size={16} /> + </button> + ); + })} + </div> </div> - <select - className="px-4 py-2 box-border bg-surface text-text focus:ring-2 focus:ring-primary outline-none" - value={selectedPattern?.id} - onChange={(e) => { - setSelectedPatternId(e.target.value); - setSelectedStopId(null); - }} - > - {Object.entries(patternsByDirection).map(([dir, patterns]) => ( - <optgroup - key={dir} - label={ - dir === "0" - ? t("routes.direction_outbound", "Ida") - : t("routes.direction_inbound", "Vuelta") - } + <div className="px-3 py-2 bg-surface border-y border-border"> + <div className="flex items-center gap-2"> + <select + className="w-full px-3 py-1.5 box-border bg-surface text-text focus:ring-2 focus:ring-primary outline-none text-sm rounded-md border border-border flex-2" + value={selectedPattern?.id} + onChange={(e) => { + setSelectedPatternId(e.target.value); + setSelectedStopId(null); + }} > - {patterns.map((pattern) => ( - <option key={pattern.id} value={pattern.id}> - {pattern.code - ? `${parseInt(pattern.code.slice(-2)).toString()}: ` - : ""} - {pattern.headsign || pattern.name}{" "} - {t("routes.trip_count_short", { - count: pattern.tripCount, - })} + {Object.entries(patternsByDirection).map(([dir, patterns]) => ( + <optgroup + key={dir} + label={ + dir === "0" + ? t("routes.direction_outbound", "Ida") + : t("routes.direction_inbound", "Vuelta") + } + > + {patterns.map((pattern) => ( + <option key={pattern.id} value={pattern.id}> + {pattern.code + ? `${parseInt(pattern.code.slice(-2)).toString()}: ` + : ""} + {pattern.headsign || pattern.name}{" "} + {t("routes.trip_count_short", { + count: pattern.tripCount, + })} + </option> + ))} + </optgroup> + ))} + </select> + + <select + className="w-full px-3 py-1.5 box-border bg-surface text-text focus:ring-2 focus:ring-primary outline-none text-sm rounded-md border border-border flex-1" + value={selectedDateKey} + onChange={(e) => { + const next = weekDays.find( + (day) => day.key === e.target.value + ); + if (next) { + setSelectedWeekDate(next.date); + } + }} + aria-label={t("routes.week_date", "Fecha")} + > + {weekDays.map((day) => ( + <option key={day.key} value={day.key}> + {day.label} </option> ))} - </optgroup> - ))} - </select> + </select> + </div> + </div> - <div className="flex-1 overflow-y-auto p-4 bg-background"> - <h3 className="text-lg font-bold mb-4"> + <div className="flex-1 overflow-y-auto px-4 py-3 bg-background"> + <h3 className="text-base font-semibold mb-3 text-text"> {t("routes.stops", "Paradas")} </h3> - <div className="space-y-4"> + <div className="space-y-2"> {selectedPattern?.stops.map((stop, idx) => ( <div key={`${stop.id}-${idx}`} @@ -260,7 +380,7 @@ export default function RouteDetailsPage() { onClick={() => 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() { > <div className="flex flex-col items-center"> <div - className={`w-3 h-3 rounded-full mt-1.5 ${selectedStopId === stop.id ? "bg-primary" : "bg-gray-400"}`} + className={`w-2.5 h-2.5 rounded-full mt-1.5 ${selectedStopId === stop.id ? "bg-primary" : "bg-gray-400"}`} ></div> {idx < selectedPattern.stops.length - 1 && ( - <div className="w-0.5 h-full bg-border -mb-3 mt-1"></div> + <div className="w-0.5 h-full bg-border -mb-2.5 mt-1"></div> )} </div> <div className="flex-1"> - <p className="font-semibold text-text"> + <p className="font-semibold text-text text-sm"> {stop.name} {stop.code && ( - <span className="text-xs font-normal text-gray-500 ml-2"> + <span className="text-[11px] font-normal text-gray-500 ml-2"> {stop.code} </span> )} |
