aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-04-20 15:33:59 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2026-04-20 15:43:27 +0200
commit594b106010c0a5f9de38f6f0f3bda9b5f92f6699 (patch)
tree53dd28558e39a8fcbd7bac84f76c63b32637679f
parentf2a37bc6366beccce247f834adee752b8e6323ae (diff)
Implementar vista de horario por parada y día
Closes #155
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs107
-rw-r--r--src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs62
-rw-r--r--src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopScheduleContent.cs185
-rw-r--r--src/frontend/app/api/schema.ts27
-rw-r--r--src/frontend/app/api/transit.ts25
-rw-r--r--src/frontend/app/routes.tsx1
-rw-r--r--src/frontend/app/routes/routes-$id.tsx4
-rw-r--r--src/frontend/app/routes/stops-$id.schedule.tsx338
-rw-r--r--src/frontend/app/routes/stops-$id.tsx11
9 files changed, 757 insertions, 3 deletions
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
index 5608723..9c7c9b9 100644
--- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
+++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
@@ -6,10 +6,12 @@ using Enmarcha.Backend.Helpers;
using Enmarcha.Backend.Services;
using Enmarcha.Backend.Types;
using Enmarcha.Backend.Types.Arrivals;
+using Enmarcha.Backend.Types.Schedule;
using FuzzySharp;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
+using System.Globalization;
namespace Enmarcha.Backend.Controllers;
@@ -414,4 +416,109 @@ public partial class ArrivalsController : ControllerBase
[LoggerMessage(LogLevel.Error, "Error fetching stop data, received {statusCode} {responseBody}")]
partial void LogErrorFetchingStopData(HttpStatusCode statusCode, string responseBody);
+
+ [HttpGet("schedule")]
+ public async Task<IActionResult> GetSchedule(
+ [FromQuery] string id,
+ [FromQuery] string? date
+ )
+ {
+ using var activity = Telemetry.Source.StartActivity("GetSchedule");
+ activity?.SetTag("stop.id", id);
+
+ if (string.IsNullOrWhiteSpace(id))
+ return BadRequest("'id' is required.");
+
+ string serviceDate;
+ if (!string.IsNullOrWhiteSpace(date))
+ {
+ if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate))
+ return BadRequest("Invalid date. Use yyyy-MM-dd.");
+
+ serviceDate = parsedDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid");
+ var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz);
+ serviceDate = nowLocal.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+ }
+
+ var cacheKey = $"stop_schedule_{id}_{serviceDate}";
+ if (_cache.TryGetValue(cacheKey, out StopScheduleResponse? cached) && cached != null)
+ return Ok(cached);
+
+ var rawStop = await GetStopScheduleFromOtpAsync(id, serviceDate);
+ if (rawStop == null)
+ return StatusCode(500, "Error fetching stop schedule from OTP");
+
+ var feedId = id.Split(':')[0];
+ var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId);
+ var showOperator = feedId == "xunta";
+
+ var trips = rawStop.StoptimesForServiceDate
+ .Where(p => p.Stoptimes.Count > 0)
+ .SelectMany(p =>
+ {
+ var color = !string.IsNullOrWhiteSpace(p.Pattern.Route.Color) ? p.Pattern.Route.Color : fallbackColor;
+ var textColor = p.Pattern.Route.TextColor is null or "000000"
+ ? ContrastHelper.GetBestTextColour(color)
+ : p.Pattern.Route.TextColor;
+ var shortName = _feedService.NormalizeRouteShortName(feedId, p.Pattern.Route.ShortName ?? "");
+
+ return p.Stoptimes.Select(s => new ScheduledTripDto
+ {
+ ScheduledDeparture = s.ScheduledDepartureSeconds,
+ RouteId = p.Pattern.Route.GtfsId,
+ RouteShortName = shortName,
+ RouteColor = color,
+ RouteTextColor = textColor,
+ Headsign = s.Trip?.TripHeadsign ?? p.Pattern.Headsign,
+ OriginStop = s.Trip?.DepartureStoptime?.Stop?.Name,
+ DestinationStop = s.Trip?.ArrivalStoptime?.Stop?.Name,
+ Operator = showOperator ? s.Trip?.Route?.Agency?.Name : null,
+ PickupType = s.PickupType,
+ DropOffType = s.DropoffType,
+ IsFirstStop = s.Trip?.DepartureStoptime?.Stop?.GtfsId == id,
+ IsLastStop = s.Trip?.ArrivalStoptime?.Stop?.GtfsId == id
+ });
+ })
+ .OrderBy(t => t.ScheduledDeparture)
+ .ToList();
+
+ var result = new StopScheduleResponse
+ {
+ StopCode = _feedService.NormalizeStopCode(feedId, rawStop.Code),
+ StopName = FeedService.NormalizeStopName(feedId, rawStop.Name),
+ StopLocation = new Position { Latitude = rawStop.Lat, Longitude = rawStop.Lon },
+ Trips = trips
+ };
+
+ var tz2 = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid");
+ var todayKey = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz2).ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+ var cacheDuration = serviceDate == todayKey ? TimeSpan.FromHours(1) : TimeSpan.FromHours(6);
+ _cache.Set(cacheKey, result, cacheDuration);
+
+ return Ok(result);
+ }
+
+ private async Task<StopScheduleOtpResponse.StopItem?> GetStopScheduleFromOtpAsync(string id, string serviceDate)
+ {
+ var query = StopScheduleContent.Query(new StopScheduleContent.Args(id, serviceDate));
+ var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1")
+ {
+ Content = JsonContent.Create(new GraphClientRequest { Query = query })
+ };
+
+ var response = await _httpClient.SendAsync(request);
+ var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<StopScheduleOtpResponse>>();
+
+ if (responseBody is not { IsSuccess: true } || responseBody.Data?.Stop == null)
+ {
+ LogErrorFetchingStopData(response.StatusCode, await response.Content.ReadAsStringAsync());
+ return null;
+ }
+
+ return responseBody.Data.Stop;
+ }
}
diff --git a/src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs b/src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs
new file mode 100644
index 0000000..47b1f4a
--- /dev/null
+++ b/src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs
@@ -0,0 +1,62 @@
+using System.Text.Json.Serialization;
+using Enmarcha.Backend.Types;
+
+namespace Enmarcha.Backend.Types.Schedule;
+
+public class StopScheduleResponse
+{
+ [JsonPropertyName("stopCode")]
+ public required string StopCode { get; set; }
+
+ [JsonPropertyName("stopName")]
+ public required string StopName { get; set; }
+
+ [JsonPropertyName("stopLocation")]
+ public Position? StopLocation { get; set; }
+
+ [JsonPropertyName("trips")]
+ public List<ScheduledTripDto> Trips { get; set; } = [];
+}
+
+public class ScheduledTripDto
+{
+ /// <summary>Seconds from midnight of the service day.</summary>
+ [JsonPropertyName("scheduledDeparture")]
+ public int ScheduledDeparture { get; set; }
+
+ [JsonPropertyName("routeId")]
+ public required string RouteId { get; set; }
+
+ [JsonPropertyName("routeShortName")]
+ public string? RouteShortName { get; set; }
+
+ [JsonPropertyName("routeColor")]
+ public required string RouteColor { get; set; }
+
+ [JsonPropertyName("routeTextColor")]
+ public required string RouteTextColor { get; set; }
+
+ [JsonPropertyName("headsign")]
+ public string? Headsign { get; set; }
+
+ [JsonPropertyName("originStop")]
+ public string? OriginStop { get; set; }
+
+ [JsonPropertyName("destinationStop")]
+ public string? DestinationStop { get; set; }
+
+ [JsonPropertyName("operator")]
+ public string? Operator { get; set; }
+
+ [JsonPropertyName("pickupType")]
+ public string? PickupType { get; set; }
+
+ [JsonPropertyName("dropOffType")]
+ public string? DropOffType { get; set; }
+
+ [JsonPropertyName("isFirstStop")]
+ public bool IsFirstStop { get; set; }
+
+ [JsonPropertyName("isLastStop")]
+ public bool IsLastStop { get; set; }
+}
diff --git a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopScheduleContent.cs b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopScheduleContent.cs
new file mode 100644
index 0000000..09c710b
--- /dev/null
+++ b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopScheduleContent.cs
@@ -0,0 +1,185 @@
+using System.Text.Json.Serialization;
+
+namespace Enmarcha.Sources.OpenTripPlannerGql.Queries;
+
+public class StopScheduleContent : IGraphRequest<StopScheduleContent.Args>
+{
+ public record Args(string Id, string Date);
+
+ public static string Query(Args args)
+ {
+ return $@"
+ query Query {{
+ stop(id:""{args.Id}"") {{
+ code
+ name
+ lat
+ lon
+ stoptimesForServiceDate(date:""{args.Date}"") {{
+ pattern {{
+ id
+ headsign
+ directionId
+ route {{
+ gtfsId
+ shortName
+ color
+ textColor
+ }}
+ }}
+ stoptimes {{
+ scheduledDeparture
+ pickupType
+ dropoffType
+ trip {{
+ gtfsId
+ tripHeadsign
+ departureStoptime {{
+ stop {{
+ gtfsId
+ name
+ }}
+ }}
+ arrivalStoptime {{
+ stop {{
+ gtfsId
+ name
+ }}
+ }}
+ route {{
+ agency {{
+ name
+ }}
+ }}
+ }}
+ }}
+ }}
+ }}
+ }}
+ ";
+ }
+}
+
+public class StopScheduleOtpResponse : AbstractGraphResponse
+{
+ [JsonPropertyName("stop")]
+ public StopItem? Stop { get; set; }
+
+ public class StopItem
+ {
+ [JsonPropertyName("code")]
+ public required string Code { get; set; }
+
+ [JsonPropertyName("name")]
+ public required string Name { get; set; }
+
+ [JsonPropertyName("lat")]
+ public double Lat { get; set; }
+
+ [JsonPropertyName("lon")]
+ public double Lon { get; set; }
+
+ [JsonPropertyName("stoptimesForServiceDate")]
+ public List<PatternStoptimes> StoptimesForServiceDate { get; set; } = [];
+ }
+
+ public class PatternStoptimes
+ {
+ [JsonPropertyName("pattern")]
+ public required PatternRef Pattern { get; set; }
+
+ [JsonPropertyName("stoptimes")]
+ public List<Stoptime> Stoptimes { get; set; } = [];
+ }
+
+ public class PatternRef
+ {
+ [JsonPropertyName("id")]
+ public required string Id { get; set; }
+
+ [JsonPropertyName("headsign")]
+ public string? Headsign { get; set; }
+
+ [JsonPropertyName("directionId")]
+ public int DirectionId { get; set; }
+
+ [JsonPropertyName("route")]
+ public required RouteRef Route { get; set; }
+ }
+
+ public class RouteRef
+ {
+ [JsonPropertyName("gtfsId")]
+ public required string GtfsId { get; set; }
+
+ [JsonPropertyName("shortName")]
+ public string? ShortName { get; set; }
+
+ [JsonPropertyName("color")]
+ public string? Color { get; set; }
+
+ [JsonPropertyName("textColor")]
+ public string? TextColor { get; set; }
+ }
+
+ public class Stoptime
+ {
+ [JsonPropertyName("scheduledDeparture")]
+ public int ScheduledDepartureSeconds { get; set; }
+
+ [JsonPropertyName("pickupType")]
+ public string? PickupType { get; set; }
+
+ [JsonPropertyName("dropoffType")]
+ public string? DropoffType { get; set; }
+
+ [JsonPropertyName("trip")]
+ public TripRef? Trip { get; set; }
+ }
+
+ public class TripRef
+ {
+ [JsonPropertyName("gtfsId")]
+ public required string GtfsId { get; set; }
+
+ [JsonPropertyName("tripHeadsign")]
+ public string? TripHeadsign { get; set; }
+
+ [JsonPropertyName("departureStoptime")]
+ public TerminusStoptime? DepartureStoptime { get; set; }
+
+ [JsonPropertyName("arrivalStoptime")]
+ public TerminusStoptime? ArrivalStoptime { get; set; }
+
+ [JsonPropertyName("route")]
+ public TripRouteRef? Route { get; set; }
+ }
+
+ public class TerminusStoptime
+ {
+ [JsonPropertyName("stop")]
+ public StopRef? Stop { get; set; }
+ }
+
+ public class StopRef
+ {
+ [JsonPropertyName("gtfsId")]
+ public string? GtfsId { get; set; }
+
+ [JsonPropertyName("name")]
+ public required string Name { get; set; }
+ }
+
+ public class TripRouteRef
+ {
+ [JsonPropertyName("agency")]
+ public AgencyRef? Agency { get; set; }
+ }
+
+ public class AgencyRef
+ {
+ [JsonPropertyName("name")]
+ public required string Name { get; set; }
+ }
+}
+
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<typeof PlannerStepSchema>;
export type PlannerLeg = z.infer<typeof PlannerLegSchema>;
export type Itinerary = z.infer<typeof ItinerarySchema>;
export type RoutePlan = z.infer<typeof RoutePlanSchema>;
+
+// 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<typeof ScheduledTripSchema>;
+export type StopScheduleResponse = z.infer<typeof StopScheduleResponseSchema>;
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<Route[]> => {
@@ -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<StopScheduleResponse> => {
+ 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(() => <FavoriteStar id={id} />, [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(
+ <span
+ key="first"
+ className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
+ >
+ {t("schedule.trip_start", "Inicio")}
+ </span>
+ );
+ }
+ if (trip.isLastStop) {
+ badges.push(
+ <span
+ key="last"
+ className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
+ >
+ {t("schedule.trip_end", "Final")}
+ </span>
+ );
+ }
+ if (isDropOffOnly) {
+ badges.push(
+ <span
+ key="dropoff"
+ className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
+ >
+ {t("routes.drop_off_only", "Bajada")}
+ </span>
+ );
+ }
+ if (isPickUpOnly) {
+ badges.push(
+ <span
+ key="pickup"
+ className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400"
+ >
+ {t("routes.pickup_only", "Subida")}
+ </span>
+ );
+ }
+
+ return (
+ <Link
+ to={`/routes/${encodeURIComponent(trip.routeId)}`}
+ state={{ backTo: backTo }}
+ className={`flex items-center gap-2.5 px-3 py-3 border-b border-border last:border-b-0 hover:bg-surface/60 active:bg-surface transition-colors ${isPast ? "opacity-40" : ""}`}
+ >
+ {/* Time */}
+ <span
+ className={`w-11 shrink-0 text-sm font-mono font-semibold tabular-nums ${isPast ? "line-through text-muted" : "text-text"}`}
+ >
+ {formatTime(trip.scheduledDeparture)}
+ </span>
+
+ {/* Route icon */}
+ <div className="shrink-0">
+ <RouteIcon
+ line={trip.routeShortName ?? "?"}
+ colour={trip.routeColor}
+ textColour={trip.routeTextColor}
+ mode="pill"
+ />
+ </div>
+
+ {/* Destination + origin → dest + operator */}
+ <div className="flex-1 min-w-0">
+ <span className="block text-sm font-medium text-text truncate">
+ {trip.headsign ?? trip.routeShortName}
+ </span>
+ {(trip.originStop || trip.destinationStop) && (
+ <p className="text-[11px] text-muted truncate leading-tight">
+ {trip.originStop}
+ {trip.originStop && trip.destinationStop ? " → " : ""}
+ {trip.destinationStop}
+ </p>
+ )}
+ {trip.operator && (
+ <p className="text-[10px] text-muted/70 truncate leading-tight">
+ {trip.operator}
+ </p>
+ )}
+ </div>
+
+ {/* Badges */}
+ {badges.length > 0 && (
+ <div className="shrink-0 flex flex-col gap-1 items-end">{badges}</div>
+ )}
+ </Link>
+ );
+}
+
+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<string>(() =>
+ 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<HTMLDivElement>(null);
+ const nowMarkerRef = useRef<HTMLDivElement>(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 (
+ <div className="flex flex-col h-full overflow-hidden bg-background">
+ {/* Toolbar */}
+ <div className="px-3 py-2 bg-surface border-b border-border shrink-0 flex items-center gap-2">
+ <CalendarDays size={15} className="text-muted shrink-0" />
+ <select
+ className="flex-1 px-2 py-1.5 bg-surface text-text text-sm rounded-md border border-border focus:ring-2 focus:ring-primary outline-none"
+ value={selectedDateKey}
+ onChange={(e) => setSelectedDateKey(e.target.value)}
+ aria-label={t("routes.week_date", "Fecha")}
+ >
+ {weekDays.map((day) => (
+ <option key={day.key} value={day.key}>
+ {day.label}
+ </option>
+ ))}
+ </select>
+ <button
+ className="shrink-0 p-1.5 rounded-md border border-border text-muted hover:text-text hover:bg-background active:scale-95 transition-all"
+ onClick={scrollToTop}
+ aria-label={t("schedule.scroll_to_top", "Ir al inicio")}
+ title={t("schedule.scroll_to_top", "Ir al inicio")}
+ >
+ <ArrowUpToLine size={15} />
+ </button>
+ {isToday && (
+ <button
+ className={`shrink-0 p-1.5 rounded-md border transition-all active:scale-95 ${nowIndex === -1 ? "text-muted/40 border-border/40 cursor-default" : "border-border text-muted hover:text-text hover:bg-background"}`}
+ onClick={nowIndex !== -1 ? scrollToNow : undefined}
+ aria-label={t("schedule.scroll_to_now", "Ir a ahora")}
+ title={t("schedule.scroll_to_now", "Ahora")}
+ disabled={nowIndex === -1}
+ >
+ <Timer size={15} />
+ </button>
+ )}
+ </div>
+
+ {/* Content */}
+ <div
+ ref={scrollRef}
+ onScroll={handleScroll}
+ className="flex-1 overflow-y-auto bg-background"
+ >
+ {isLoading ? (
+ <div className="flex flex-col">
+ {[...Array(12)].map((_, i) => (
+ <div
+ key={i}
+ className="flex items-center gap-2.5 px-3 py-3 border-b border-border animate-pulse"
+ >
+ <div className="w-11 h-4 rounded bg-slate-200 dark:bg-slate-700 shrink-0" />
+ <div className="w-10 h-5 rounded-full bg-slate-200 dark:bg-slate-700 shrink-0" />
+ <div className="flex-1 flex flex-col gap-1">
+ <div className="h-4 rounded bg-slate-200 dark:bg-slate-700 max-w-30" />
+ <div className="h-3 rounded bg-slate-100 dark:bg-slate-800 max-w-48" />
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : isError ? (
+ <div className="flex flex-col items-center justify-center py-12 px-4 text-center">
+ <p className="text-sm text-muted">
+ {t("schedule.error", "No se pudo cargar el horario.")}
+ </p>
+ </div>
+ ) : !data || data.trips.length === 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={28} className="text-muted" />
+ </div>
+ <h4 className="text-base font-bold text-text mb-1">
+ {t("schedule.no_service", "Sin servicio")}
+ </h4>
+ <p className="text-sm text-muted max-w-xs">
+ {t(
+ "schedule.no_service_desc",
+ "No hay viajes programados en esta parada para la fecha seleccionada."
+ )}
+ </p>
+ </div>
+ ) : (
+ <>
+ <div className="px-3 py-1.5 bg-surface border-b border-border">
+ <p className="text-[11px] text-muted uppercase tracking-wide font-semibold">
+ {t("schedule.summary", {
+ count: data.trips.length,
+ defaultValue: "{{count}} salidas",
+ })}
+ </p>
+ </div>
+ <div>
+ {data.trips.map((trip, i) => (
+ <div key={`${trip.scheduledDeparture}-${trip.routeId}-${i}`}>
+ {i === nowIndex && (
+ <div
+ ref={nowMarkerRef}
+ className="flex items-center gap-2 px-3 py-1 bg-primary/10 border-b border-primary/20"
+ >
+ <Timer size={11} className="text-primary" />
+ <span className="text-[10px] text-primary font-semibold uppercase tracking-wide">
+ {t("schedule.now", "Ahora")}
+ </span>
+ </div>
+ )}
+ <TripRow
+ trip={trip}
+ isPast={isToday && trip.scheduledDeparture < nowSeconds}
+ backTo={`/stops/${id}/schedule`}
+ />
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
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() {
</div>
<div className="flex items-center gap-2">
+ <Link
+ to={`/stops/${stopId}/schedule`}
+ className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-muted"
+ aria-label={t("stop.view_schedule", "Ver horarios")}
+ >
+ <CalendarDays className="w-5 h-5" />
+ </Link>
<button
className="p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-muted"
onClick={() => setIsHelpModalOpen(true)}