From 594b106010c0a5f9de38f6f0f3bda9b5f92f6699 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 20 Apr 2026 15:33:59 +0200 Subject: Implementar vista de horario por parada y día MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #155 --- src/frontend/app/api/schema.ts | 27 ++ src/frontend/app/api/transit.ts | 25 ++ src/frontend/app/routes.tsx | 1 + src/frontend/app/routes/routes-$id.tsx | 4 +- src/frontend/app/routes/stops-$id.schedule.tsx | 338 +++++++++++++++++++++++++ src/frontend/app/routes/stops-$id.tsx | 11 +- 6 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 src/frontend/app/routes/stops-$id.schedule.tsx (limited to 'src/frontend') diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index 71eeae9..864ea57 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -261,3 +261,30 @@ export type PlannerStep = z.infer; export type PlannerLeg = z.infer; export type Itinerary = z.infer; export type RoutePlan = z.infer; + +// Stop Schedule +export const ScheduledTripSchema = z.object({ + scheduledDeparture: z.number().int(), + routeId: z.string(), + routeShortName: z.string().nullable(), + routeColor: z.string(), + routeTextColor: z.string(), + headsign: z.string().nullable(), + originStop: z.string().nullable().optional(), + destinationStop: z.string().nullable().optional(), + operator: z.string().nullable().optional(), + pickupType: z.string().nullable().optional(), + dropOffType: z.string().nullable().optional(), + isFirstStop: z.boolean().optional(), + isLastStop: z.boolean().optional(), +}); + +export const StopScheduleResponseSchema = z.object({ + stopCode: z.string(), + stopName: z.string(), + stopLocation: PositionSchema.optional().nullable(), + trips: z.array(ScheduledTripSchema), +}); + +export type ScheduledTrip = z.infer; +export type StopScheduleResponse = z.infer; diff --git a/src/frontend/app/api/transit.ts b/src/frontend/app/api/transit.ts index fbff5fa..28d9fe5 100644 --- a/src/frontend/app/api/transit.ts +++ b/src/frontend/app/api/transit.ts @@ -1,8 +1,10 @@ import { RouteDetailsSchema, RouteSchema, + StopScheduleResponseSchema, type Route, type RouteDetails, + type StopScheduleResponse, } from "./schema"; export const fetchRoutes = async (feeds: string[] = []): Promise => { @@ -49,3 +51,26 @@ export const fetchRouteDetails = async ( const data = await resp.json(); return RouteDetailsSchema.parse(data); }; + +export const fetchStopSchedule = async ( + id: string, + date?: string +): Promise => { + const params = new URLSearchParams({ id }); + if (date) { + params.set("date", date); + } + + const resp = await fetch(`/api/stops/schedule?${params.toString()}`, { + headers: { + Accept: "application/json", + }, + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + return StopScheduleResponseSchema.parse(data); +}; diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx index 7c02728..08dcc26 100644 --- a/src/frontend/app/routes.tsx +++ b/src/frontend/app/routes.tsx @@ -7,6 +7,7 @@ export default [ route("/routes/:id", "routes/routes-$id.tsx"), route("/stops", "routes/stops.tsx"), route("/stops/:id", "routes/stops-$id.tsx"), + route("/stops/:id/schedule", "routes/stops-$id.schedule.tsx"), route("/settings", "routes/settings.tsx"), route("/about", "routes/about.tsx"), route("/favourites", "routes/favourites.tsx"), diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx index 278a848..200b0bd 100644 --- a/src/frontend/app/routes/routes-$id.tsx +++ b/src/frontend/app/routes/routes-$id.tsx @@ -150,7 +150,9 @@ export default function RouteDetailsPage() { const rightNode = useMemo(() => , [id]); usePageRightNode(rightNode); - useBackButton({ to: "/routes" }); + const backTo = + (location.state as { backTo?: string } | null)?.backTo ?? "/routes"; + useBackButton({ to: backTo }); const weekDays = useMemo(() => { const base = new Date(); diff --git a/src/frontend/app/routes/stops-$id.schedule.tsx b/src/frontend/app/routes/stops-$id.schedule.tsx new file mode 100644 index 0000000..fa18c92 --- /dev/null +++ b/src/frontend/app/routes/stops-$id.schedule.tsx @@ -0,0 +1,338 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowUpToLine, CalendarDays, Clock, Timer } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useParams } from "react-router"; +import type { ScheduledTrip } from "~/api/schema"; +import { fetchStopSchedule } from "~/api/transit"; +import RouteIcon from "~/components/RouteIcon"; +import { useBackButton, usePageTitle } from "~/contexts/PageTitleContext"; +import "../tailwind-full.css"; + +const formatDateKey = (d: Date): string => { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const formatTime = (seconds: number): string => { + const h = Math.floor(seconds / 3600) % 24; + const m = Math.floor((seconds % 3600) / 60); + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +}; + +function TripRow({ + trip, + isPast, + backTo, +}: { + trip: ScheduledTrip; + isPast: boolean; + backTo: string; +}) { + const { t } = useTranslation(); + const isDropOffOnly = trip.pickupType === "NONE"; + const isPickUpOnly = trip.dropOffType === "NONE"; + + const badges: React.ReactNode[] = []; + if (trip.isFirstStop) { + badges.push( + + {t("schedule.trip_start", "Inicio")} + + ); + } + if (trip.isLastStop) { + badges.push( + + {t("schedule.trip_end", "Final")} + + ); + } + if (isDropOffOnly) { + badges.push( + + {t("routes.drop_off_only", "Bajada")} + + ); + } + if (isPickUpOnly) { + badges.push( + + {t("routes.pickup_only", "Subida")} + + ); + } + + return ( + + {/* Time */} + + {formatTime(trip.scheduledDeparture)} + + + {/* Route icon */} +
+ +
+ + {/* Destination + origin → dest + operator */} +
+ + {trip.headsign ?? trip.routeShortName} + + {(trip.originStop || trip.destinationStop) && ( +

+ {trip.originStop} + {trip.originStop && trip.destinationStop ? " → " : ""} + {trip.destinationStop} +

+ )} + {trip.operator && ( +

+ {trip.operator} +

+ )} +
+ + {/* Badges */} + {badges.length > 0 && ( +
{badges}
+ )} + + ); +} + +export default function StopSchedulePage() { + const { id } = useParams<{ id: string }>(); + const { t, i18n } = useTranslation(); + + const weekDays = useMemo(() => { + const base = new Date(); + return [-1, 0, 1, 2, 3, 4, 5].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]); + + const [selectedDateKey, setSelectedDateKey] = useState(() => + formatDateKey(new Date()) + ); + + const isToday = selectedDateKey === formatDateKey(new Date()); + + const now = new Date(); + const nowSeconds = + now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds(); + + const scrollRef = useRef(null); + const nowMarkerRef = useRef(null); + const scrollKey = `stop-schedule-scroll-${id}-${selectedDateKey}`; + + const { data, isLoading, isError } = useQuery({ + queryKey: ["stop-schedule", id, selectedDateKey], + queryFn: () => fetchStopSchedule(id!, selectedDateKey), + enabled: !!id, + }); + + // Restore scroll position after data loads + useEffect(() => { + if (!data || !scrollRef.current) return; + const saved = sessionStorage.getItem(scrollKey); + if (saved) { + scrollRef.current.scrollTop = parseInt(saved, 10); + } + }, [data, scrollKey]); + + const handleScroll = () => { + if (scrollRef.current) { + sessionStorage.setItem(scrollKey, String(scrollRef.current.scrollTop)); + } + }; + + const scrollToTop = () => { + scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const scrollToNow = () => { + nowMarkerRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }; + + const nowIndex = useMemo(() => { + if (!data || !isToday) return -1; + return data.trips.findIndex( + (trip) => trip.scheduledDeparture >= nowSeconds + ); + }, [data, isToday, nowSeconds]); + + usePageTitle( + data?.stopName + ? `${t("schedule.title_prefix", "Horarios")} · ${data.stopName}` + : t("schedule.title", "Horarios de parada") + ); + + useBackButton({ to: `/stops/${id}` }); + + return ( +
+ {/* Toolbar */} +
+ + + + {isToday && ( + + )} +
+ + {/* Content */} +
+ {isLoading ? ( +
+ {[...Array(12)].map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : isError ? ( +
+

+ {t("schedule.error", "No se pudo cargar el horario.")} +

+
+ ) : !data || data.trips.length === 0 ? ( +
+
+ +
+

+ {t("schedule.no_service", "Sin servicio")} +

+

+ {t( + "schedule.no_service_desc", + "No hay viajes programados en esta parada para la fecha seleccionada." + )} +

+
+ ) : ( + <> +
+

+ {t("schedule.summary", { + count: data.trips.length, + defaultValue: "{{count}} salidas", + })} +

+
+
+ {data.trips.map((trip, i) => ( +
+ {i === nowIndex && ( +
+ + + {t("schedule.now", "Ahora")} + +
+ )} + +
+ ))} +
+ + )} +
+
+ ); +} diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index 38f6c59..d1b7a48 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -1,7 +1,7 @@ -import { CircleHelp, Eye, EyeClosed, Star } from "lucide-react"; +import { CalendarDays, CircleHelp, Eye, EyeClosed, Star } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useLocation, useParams } from "react-router"; +import { Link, useLocation, useParams } from "react-router"; import { fetchArrivals } from "~/api/arrivals"; import { type Arrival, @@ -266,6 +266,13 @@ export default function Estimates() {
+ + +