diff options
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/ArrivalsController.cs | 107 | ||||
| -rw-r--r-- | src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs | 62 | ||||
| -rw-r--r-- | src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopScheduleContent.cs | 185 | ||||
| -rw-r--r-- | src/frontend/app/api/schema.ts | 27 | ||||
| -rw-r--r-- | src/frontend/app/api/transit.ts | 25 | ||||
| -rw-r--r-- | src/frontend/app/routes.tsx | 1 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes-$id.tsx | 4 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.schedule.tsx | 338 | ||||
| -rw-r--r-- | src/frontend/app/routes/stops-$id.tsx | 11 |
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)} |
