From 4b7eaa318f22d7cc768491c421cb7aeac477f95d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 22 Dec 2025 18:16:57 +0100 Subject: Implement retrieving next arrivals for a stop (scheduled only) --- .../Controllers/ArrivalsController.cs | 68 ++++++++++--- .../GraphClient/App/ArrivalsAtStop.cs | 110 +++++++++++++++------ src/Costasdev.Busurbano.Backend/Program.cs | 9 +- .../Types/Arrivals/Arrival.cs | 82 +++++++++++++++ .../Types/Arrivals/StopArrivalsResponse.cs | 15 +++ .../Types/Otp/OtpModels.cs | 4 +- .../Types/Planner/PlannerModels.cs | 80 --------------- .../Types/Planner/PlannerResponse.cs | 80 +++++++++++++++ src/frontend/app/api/arrivals.ts | 31 ++++++ src/frontend/app/api/schema.ts | 96 ++++++++++++++++++ src/frontend/app/components/Stops/ArrivalCard.css | 17 ++++ src/frontend/app/components/Stops/ArrivalCard.tsx | 72 ++++++++++++++ src/frontend/app/components/Stops/ArrivalList.tsx | 25 +++++ .../app/components/map/StopSummarySheet.tsx | 109 ++++---------------- src/frontend/app/hooks/useArrivals.ts | 16 +++ src/frontend/app/root.tsx | 11 ++- src/frontend/app/routes/map.tsx | 1 - src/frontend/package-lock.json | 37 ++++++- src/frontend/package.json | 4 +- 19 files changed, 643 insertions(+), 224 deletions(-) create mode 100644 src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs create mode 100644 src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs create mode 100644 src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs create mode 100644 src/frontend/app/api/arrivals.ts create mode 100644 src/frontend/app/api/schema.ts create mode 100644 src/frontend/app/components/Stops/ArrivalCard.css create mode 100644 src/frontend/app/components/Stops/ArrivalCard.tsx create mode 100644 src/frontend/app/components/Stops/ArrivalList.tsx create mode 100644 src/frontend/app/hooks/useArrivals.ts diff --git a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs index eb81784..5dee48d 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs @@ -1,13 +1,16 @@ -using Costasdev.Busurbano.Backend.GraphClient; +using System.Net; +using Costasdev.Busurbano.Backend.GraphClient; using Costasdev.Busurbano.Backend.GraphClient.App; +using Costasdev.Busurbano.Backend.Types; +using Costasdev.Busurbano.Backend.Types.Arrivals; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; namespace Costasdev.Busurbano.Backend.Controllers; [ApiController] -[Route("api")] -public class ArrivalsController : ControllerBase +[Route("api/stops")] +public partial class ArrivalsController : ControllerBase { private readonly ILogger _logger; private readonly IMemoryCache _cache; @@ -25,9 +28,16 @@ public class ArrivalsController : ControllerBase } [HttpGet("arrivals")] - public async Task GetArrivals(string id) + public async Task GetArrivals( + [FromQuery] string id, + [FromQuery] bool reduced + ) { - var requestContent = ArrivalsAtStopContent.Query(id); + var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); + var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); + var todayLocal = nowLocal.Date; + + var requestContent = ArrivalsAtStopContent.Query(new(id, reduced ? 4 : 10)); var request = new HttpRequestMessage(HttpMethod.Post, "http://100.67.54.115:3957/otp/gtfs/v1"); request.Content = JsonContent.Create(new GraphClientRequest { @@ -37,16 +47,50 @@ public class ArrivalsController : ControllerBase var response = await _httpClient.SendAsync(request); var responseBody = await response.Content.ReadFromJsonAsync>(); - if (responseBody is not { IsSuccess: true }) + if (responseBody is not { IsSuccess: true } || responseBody.Data?.Stop == null) { - _logger.LogError( - "Error fetching stop data, received {StatusCode} {ResponseBody}", - response.StatusCode, - await response.Content.ReadAsStringAsync() - ); + LogErrorFetchingStopData(response.StatusCode, await response.Content.ReadAsStringAsync()); return StatusCode(500, "Error fetching stop data"); } - return Ok(responseBody.Data?.Stop); + var stop = responseBody.Data.Stop; + List arrivals = []; + foreach (var item in stop.Arrivals) + { + var departureTime = todayLocal.AddSeconds(item.ScheduledDepartureSeconds); + var minutesToArrive = (int)(departureTime - nowLocal).TotalMinutes; + //var isRunning = departureTime < nowLocal; + + Arrival arrival = new() + { + Route = new RouteInfo + { + ShortName = item.Trip.RouteShortName, + Colour = item.Trip.Route.Color, + TextColour = item.Trip.Route.TextColor + }, + Headsign = new HeadsignInfo + { + Destination = item.Headsign + }, + Estimate = new ArrivalDetails + { + Minutes = minutesToArrive, + Precission = departureTime < nowLocal ? ArrivalPrecission.Past : ArrivalPrecission.Scheduled + } + }; + + arrivals.Add(arrival); + } + + return Ok(new StopArrivalsResponse + { + StopCode = stop.Code, + StopName = stop.Name, + Arrivals = arrivals + }); } + + [LoggerMessage(LogLevel.Error, "Error fetching stop data, received {statusCode} {responseBody}")] + partial void LogErrorFetchingStopData(HttpStatusCode statusCode, string responseBody); } diff --git a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs index dfecdd6..53c1165 100644 --- a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs +++ b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs @@ -3,26 +3,34 @@ using System.Text.Json.Serialization; namespace Costasdev.Busurbano.Backend.GraphClient.App; -public class ArrivalsAtStopContent : IGraphRequest +public class ArrivalsAtStopContent : IGraphRequest { - public static string Query(string id) + public record Args(string Id, int DepartureCount); + + public static string Query(Args args) { return string.Create(CultureInfo.InvariantCulture, $@" query Query {{ - stop(id:""{id}"") {{ + stop(id:""{args.Id}"") {{ code name - arrivals: stoptimesWithoutPatterns(numberOfDepartures:10) {{ + arrivals: stoptimesWithoutPatterns(numberOfDepartures:{args.DepartureCount}) {{ + headsign + scheduledDeparture + pickupType + trip {{ gtfsId + serviceId routeShortName route {{ color textColor }} + departureStoptime {{ + scheduledDeparture + }} }} - headsign - scheduledDeparture }} }} }} @@ -32,51 +40,97 @@ public class ArrivalsAtStopContent : IGraphRequest public class ArrivalsAtStopResponse : AbstractGraphResponse { - [JsonPropertyName("stop")] - public StopItem Stop { get; set; } + [JsonPropertyName("stop")] public StopItem Stop { get; set; } public class StopItem { - [JsonPropertyName("code")] - public required string Code { get; set; } + [JsonPropertyName("code")] public required string Code { get; set; } - [JsonPropertyName("name")] - public required string Name { get; set; } + [JsonPropertyName("name")] public required string Name { get; set; } - [JsonPropertyName("arrivals")] - public List Arrivals { get; set; } = []; + [JsonPropertyName("arrivals")] public List Arrivals { get; set; } = []; } public class Arrival { - [JsonPropertyName("headsign")] - public required string Headsign { get; set; } + [JsonPropertyName("headsign")] public required string Headsign { get; set; } [JsonPropertyName("scheduledDeparture")] public int ScheduledDepartureSeconds { get; set; } - [JsonPropertyName("trip")] - public required TripDetails Trip { get; set; } + [JsonPropertyName("pickupType")] public required string PickupTypeOriginal { get; set; } + + public PickupType PickupTypeParsed => PickupTypeParsed.Parse(PickupTypeOriginal); + + [JsonPropertyName("trip")] public required TripDetails Trip { get; set; } } public class TripDetails { - [JsonPropertyName("gtfsId")] - public required string GtfsId { get; set; } + [JsonPropertyName("gtfsId")] public required string GtfsId { get; set; } + + [JsonPropertyName("serviceId")] public required string ServiceId { get; set; } + + [JsonPropertyName("routeShortName")] public required string RouteShortName { get; set; } + + [JsonPropertyName("departureStoptime")] + public required DepartureStoptime DepartureStoptime { get; set; } - [JsonPropertyName("routeShortName")] - public required string RouteShortName { get; set; } + [JsonPropertyName("route")] public required RouteDetails Route { get; set; } + } - [JsonPropertyName("route")] - public required RouteDetails Route { get; set; } + public class DepartureStoptime + { + [JsonPropertyName("scheduledDeparture")] + public int ScheduledDeparture { get; set; } } public class RouteDetails { - [JsonPropertyName("color")] - public required string Color { get; set; } + [JsonPropertyName("color")] public required string Color { get; set; } + + [JsonPropertyName("textColor")] public required string TextColor { get; set; } + } + + public class PickupType + { + private readonly string _value; + + private PickupType(string value) + { + _value = value; + } + + public PickupType Parse(string value) + { + return value switch + { + "SCHEDULED" => Scheduled, + "NONE" => None, + "CALL_AGENCY" => CallAgency, + "COORDINATE_WITH_DRIVER" => CoordinateWithDriver, + _ => throw new ArgumentException("Unsupported pickup type ", value) + }; + } + + public static readonly PickupType Scheduled = new PickupType("SCHEDULED"); + public static readonly PickupType None = new PickupType("NONE"); + public static readonly PickupType CallAgency = new PickupType("CALL_AGENCY"); + public static readonly PickupType CoordinateWithDriver = new PickupType("COORDINATE_WITH_DRIVER"); + + public override bool Equals(object? other) + { + if (other is not PickupType otherPt) + { + return false; + } + + return otherPt._value == _value; + } - [JsonPropertyName("textColor")] - public required string TextColor { get; set; } + public override int GetHashCode() + { + return _value.GetHashCode(); + } } } diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index 74c6337..70372e8 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Costasdev.Busurbano.Backend.Configuration; using Costasdev.Busurbano.Backend.Services; using Costasdev.Busurbano.Backend.Services.Providers; @@ -6,7 +7,13 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(builder.Configuration.GetSection("App")); -builder.Services.AddControllers(); +builder.Services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + builder.Services.AddHttpClient(); builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); diff --git a/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs new file mode 100644 index 0000000..c813ccf --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs @@ -0,0 +1,82 @@ +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Backend.Types.Arrivals; + +public class Arrival +{ + [JsonPropertyName("route")] + public required RouteInfo Route { get; set; } + + [JsonPropertyName("headsign")] + public required HeadsignInfo Headsign { get; set; } + + [JsonPropertyName("estimate")] + public required ArrivalDetails Estimate { get; set; } + + [JsonPropertyName("delay")] + public DelayBadge? Delay { get; set; } + + [JsonPropertyName("shift")] + public ShiftBadge? Shift { get; set; } +} + +public class RouteInfo +{ + [JsonPropertyName("shortName")] + public required string ShortName { get; set; } + + [JsonPropertyName("colour")] + public required string Colour { get; set; } + + [JsonPropertyName("textColour")] + public required string TextColour { get; set; } +} + +public class HeadsignInfo +{ + [JsonPropertyName("badge")] + public string? Badge { get; set; } + + [JsonPropertyName("destination")] + public required string Destination { get; set; } + + [JsonPropertyName("marquee")] + public string? Marquee { get; set; } +} + +public class ArrivalDetails +{ + [JsonPropertyName("minutes")] + public required int Minutes { get; set; } + + [JsonPropertyName("precission")] + public ArrivalPrecission Precission { get; set; } = ArrivalPrecission.Scheduled; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ArrivalPrecission +{ + [JsonStringEnumMemberName("confident")] + Confident = 0, + [JsonStringEnumMemberName("unsure")] + Unsure = 1, + [JsonStringEnumMemberName("scheduled")] + Scheduled = 2, + [JsonStringEnumMemberName("past")] + Past = 3 +} + +public class DelayBadge +{ + [JsonPropertyName("minutes")] + public int Minutes { get; set; } +} + +public class ShiftBadge +{ + [JsonPropertyName("shiftName")] + public string ShiftName { get; set; } + + [JsonPropertyName("shiftTrip")] + public string ShiftTrip { get; set; } +} diff --git a/src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs b/src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs new file mode 100644 index 0000000..8c5438c --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Backend.Types.Arrivals; + +public class StopArrivalsResponse +{ + [JsonPropertyName("stopCode")] + public required string StopCode { get; set; } + + [JsonPropertyName("stopName")] + public required string StopName { get; set; } + + [JsonPropertyName("arrivals")] + public List Arrivals { get; set; } = []; +} diff --git a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs index 1c47a4a..b67663d 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs @@ -98,7 +98,7 @@ public class OtpLeg public OtpGeometry? LegGeometry { get; set; } [JsonPropertyName("steps")] - public List Steps { get; set; } = new(); + public List Steps { get; set; } = []; [JsonPropertyName("headsign")] public string? Headsign { get; set; } @@ -113,7 +113,7 @@ public class OtpLeg public string? RouteTextColor { get; set; } [JsonPropertyName("intermediateStops")] - public List IntermediateStops { get; set; } = new(); + public List IntermediateStops { get; set; } = []; } public class OtpPlace diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs deleted file mode 100644 index c31d12a..0000000 --- a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace Costasdev.Busurbano.Backend.Types.Planner; - -public class RoutePlan -{ - public List Itineraries { get; set; } = new(); - public long? TimeOffsetSeconds { get; set; } -} - -public class Itinerary -{ - public double DurationSeconds { get; set; } - public DateTime StartTime { get; set; } - public DateTime EndTime { get; set; } - public double WalkDistanceMeters { get; set; } - public double WalkTimeSeconds { get; set; } - public double TransitTimeSeconds { get; set; } - public double WaitingTimeSeconds { get; set; } - public List Legs { get; set; } = new(); - public double? CashFareEuro { get; set; } - public double? CardFareEuro { get; set; } -} - -public class Leg -{ - public string? Mode { get; set; } // WALK, BUS, etc. - public string? RouteName { get; set; } - public string? RouteShortName { get; set; } - public string? RouteLongName { get; set; } - public string? Headsign { get; set; } - public string? AgencyName { get; set; } - public string? RouteColor { get; set; } - public string? RouteTextColor { get; set; } - public PlannerPlace? From { get; set; } - public PlannerPlace? To { get; set; } - public DateTime StartTime { get; set; } - public DateTime EndTime { get; set; } - public double DistanceMeters { get; set; } - - // GeoJSON LineString geometry - public PlannerGeometry? Geometry { get; set; } - - public List Steps { get; set; } = new(); - - public List IntermediateStops { get; set; } = new(); -} - -public class PlannerPlace -{ - public string? Name { get; set; } - public double Lat { get; set; } - public double Lon { get; set; } - public string? StopId { get; set; } - public string? StopCode { get; set; } -} - -public class PlannerGeometry -{ - public string Type { get; set; } = "LineString"; - public List> Coordinates { get; set; } = new(); // [[lon, lat], ...] -} - -public class Step -{ - public double DistanceMeters { get; set; } - public string? RelativeDirection { get; set; } - public string? AbsoluteDirection { get; set; } - public string? StreetName { get; set; } - public double Lat { get; set; } - public double Lon { get; set; } -} - -// For Autocomplete/Reverse -public class PlannerSearchResult -{ - public string? Name { get; set; } - public string? Label { get; set; } - public double Lat { get; set; } - public double Lon { get; set; } - public string? Layer { get; set; } -} diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs new file mode 100644 index 0000000..c31d12a --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs @@ -0,0 +1,80 @@ +namespace Costasdev.Busurbano.Backend.Types.Planner; + +public class RoutePlan +{ + public List Itineraries { get; set; } = new(); + public long? TimeOffsetSeconds { get; set; } +} + +public class Itinerary +{ + public double DurationSeconds { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public double WalkDistanceMeters { get; set; } + public double WalkTimeSeconds { get; set; } + public double TransitTimeSeconds { get; set; } + public double WaitingTimeSeconds { get; set; } + public List Legs { get; set; } = new(); + public double? CashFareEuro { get; set; } + public double? CardFareEuro { get; set; } +} + +public class Leg +{ + public string? Mode { get; set; } // WALK, BUS, etc. + public string? RouteName { get; set; } + public string? RouteShortName { get; set; } + public string? RouteLongName { get; set; } + public string? Headsign { get; set; } + public string? AgencyName { get; set; } + public string? RouteColor { get; set; } + public string? RouteTextColor { get; set; } + public PlannerPlace? From { get; set; } + public PlannerPlace? To { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public double DistanceMeters { get; set; } + + // GeoJSON LineString geometry + public PlannerGeometry? Geometry { get; set; } + + public List Steps { get; set; } = new(); + + public List IntermediateStops { get; set; } = new(); +} + +public class PlannerPlace +{ + public string? Name { get; set; } + public double Lat { get; set; } + public double Lon { get; set; } + public string? StopId { get; set; } + public string? StopCode { get; set; } +} + +public class PlannerGeometry +{ + public string Type { get; set; } = "LineString"; + public List> Coordinates { get; set; } = new(); // [[lon, lat], ...] +} + +public class Step +{ + public double DistanceMeters { get; set; } + public string? RelativeDirection { get; set; } + public string? AbsoluteDirection { get; set; } + public string? StreetName { get; set; } + public double Lat { get; set; } + public double Lon { get; set; } +} + +// For Autocomplete/Reverse +public class PlannerSearchResult +{ + public string? Name { get; set; } + public string? Label { get; set; } + public double Lat { get; set; } + public double Lon { get; set; } + public string? Layer { get; set; } +} diff --git a/src/frontend/app/api/arrivals.ts b/src/frontend/app/api/arrivals.ts new file mode 100644 index 0000000..8ae6e78 --- /dev/null +++ b/src/frontend/app/api/arrivals.ts @@ -0,0 +1,31 @@ +import { + StopArrivalsResponseSchema, + type StopArrivalsResponse, +} from "./schema"; + +export const fetchArrivals = async ( + stopId: string, + reduced: boolean = false +): Promise => { + const resp = await fetch( + `/api/stops/arrivals?id=${stopId}&reduced=${reduced}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + try { + return StopArrivalsResponseSchema.parse(data); + } catch (e) { + console.error("Zod parsing failed for arrivals:", e); + console.log("Received data:", data); + throw e; + } +}; diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts new file mode 100644 index 0000000..60e2d97 --- /dev/null +++ b/src/frontend/app/api/schema.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +export const RouteInfoSchema = z.object({ + shortName: z.string(), + colour: z.string(), + textColour: z.string(), +}); + +export const HeadsignInfoSchema = z.object({ + badge: z.string().optional().nullable(), + destination: z.string(), + marquee: z.string().optional().nullable(), +}); + +export const ArrivalPrecissionSchema = z.enum([ + "confident", + "unsure", + "scheduled", + "past", +]); + +export const ArrivalDetailsSchema = z.object({ + minutes: z.number(), + precission: ArrivalPrecissionSchema, +}); + +export const DelayBadgeSchema = z.object({ + minutes: z.number(), +}); + +export const ShiftBadgeSchema = z.object({ + shiftName: z.string(), + shiftTrip: z.string(), +}); + +export const ArrivalSchema = z.object({ + route: RouteInfoSchema, + headsign: HeadsignInfoSchema, + estimate: ArrivalDetailsSchema, + delay: DelayBadgeSchema.optional().nullable(), + shift: ShiftBadgeSchema.optional().nullable(), +}); + +export const StopArrivalsResponseSchema = z.object({ + stopCode: z.string(), + stopName: z.string(), + arrivals: z.array(ArrivalSchema), +}); + +export type RouteInfo = z.infer; +export type HeadsignInfo = z.infer; +export type ArrivalPrecission = z.infer; +export type ArrivalDetails = z.infer; +export type DelayBadge = z.infer; +export type ShiftBadge = z.infer; +export type Arrival = z.infer; +export type StopArrivalsResponse = z.infer; + +// Consolidated Circulation (Legacy/Alternative API) +export const ConsolidatedCirculationSchema = z.object({ + line: z.string(), + route: z.string(), + schedule: z + .object({ + running: z.boolean(), + minutes: z.number(), + serviceId: z.string(), + tripId: z.string(), + shapeId: z.string().optional().nullable(), + }) + .optional() + .nullable(), + realTime: z + .object({ + minutes: z.number(), + distance: z.number(), + }) + .optional() + .nullable(), + currentPosition: z + .object({ + latitude: z.number(), + longitude: z.number(), + orientationDegrees: z.number(), + shapeIndex: z.number().optional().nullable(), + }) + .optional() + .nullable(), + isPreviousTrip: z.boolean().optional().nullable(), + previousTripShapeId: z.string().optional().nullable(), + nextStreets: z.array(z.string()).optional().nullable(), +}); + +export type ConsolidatedCirculation = z.infer< + typeof ConsolidatedCirculationSchema +>; diff --git a/src/frontend/app/components/Stops/ArrivalCard.css b/src/frontend/app/components/Stops/ArrivalCard.css new file mode 100644 index 0000000..5835352 --- /dev/null +++ b/src/frontend/app/components/Stops/ArrivalCard.css @@ -0,0 +1,17 @@ +@import "../../tailwind.css"; + +.time-running { + @apply bg-green-600/20 dark:bg-green-600/25 text-[#1a9e56] dark:text-[#22c55e]; +} + +.time-delayed { + @apply bg-orange-600/20 dark:bg-orange-600/25 text-[#d06100] dark:text-[#fb923c]; +} + +.time-past { + @apply bg-gray-600/20 dark:bg-gray-600/25 text-gray-600 dark:text-gray-400; +} + +.time-scheduled { + @apply bg-blue-900/20 dark:bg-blue-600/25 text-[#0b3d91] dark:text-[#93c5fd]; +} diff --git a/src/frontend/app/components/Stops/ArrivalCard.tsx b/src/frontend/app/components/Stops/ArrivalCard.tsx new file mode 100644 index 0000000..96d0af0 --- /dev/null +++ b/src/frontend/app/components/Stops/ArrivalCard.tsx @@ -0,0 +1,72 @@ +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import LineIcon from "~/components/LineIcon"; +import { type Arrival } from "../../api/schema"; +import "./ArrivalCard.css"; + +interface ArrivalCardProps { + arrival: Arrival; + reduced?: boolean; +} + +export const ArrivalCard: React.FC = ({ + arrival, + reduced, +}) => { + const { t } = useTranslation(); + const { route, headsign, estimate } = arrival; + + const etaValue = Math.max(0, Math.round(estimate.minutes)).toString(); + const etaUnit = t("estimates.minutes", "min"); + + const timeClass = useMemo(() => { + switch (estimate.precission) { + case "confident": + return "time-running"; + case "unsure": + return "time-delayed"; + case "past": + return "time-past"; + default: + return "time-scheduled"; + } + }, [estimate.precission]); + + return ( +
+
+ +
+
+ + {headsign.destination} + +
+
+
+ {etaValue} + + {etaUnit} + +
+
+
+ ); +}; diff --git a/src/frontend/app/components/Stops/ArrivalList.tsx b/src/frontend/app/components/Stops/ArrivalList.tsx new file mode 100644 index 0000000..a1210d5 --- /dev/null +++ b/src/frontend/app/components/Stops/ArrivalList.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { type Arrival } from "../../api/schema"; +import { ArrivalCard } from "./ArrivalCard"; + +interface ArrivalListProps { + arrivals: Arrival[]; + reduced?: boolean; +} + +export const ArrivalList: React.FC = ({ + arrivals, + reduced, +}) => { + return ( +
+ {arrivals.map((arrival, index) => ( + + ))} +
+ ); +}; diff --git a/src/frontend/app/components/map/StopSummarySheet.tsx b/src/frontend/app/components/map/StopSummarySheet.tsx index b24e71c..16a9cbe 100644 --- a/src/frontend/app/components/map/StopSummarySheet.tsx +++ b/src/frontend/app/components/map/StopSummarySheet.tsx @@ -1,11 +1,10 @@ import { RefreshCw } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React from "react"; import { useTranslation } from "react-i18next"; import { Sheet } from "react-modal-sheet"; import { Link } from "react-router"; -import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; -import { APP_CONSTANTS } from "~/config/constants"; -import { type ConsolidatedCirculation } from "../../routes/stops-$id"; +import { ArrivalList } from "~/components/Stops/ArrivalList"; +import { useStopArrivals } from "../../hooks/useArrivals"; import { ErrorDisplay } from "../ErrorDisplay"; import LineIcon from "../LineIcon"; import "./StopSummarySheet.css"; @@ -27,95 +26,24 @@ export interface StopSheetProps { }; } -interface ErrorInfo { - type: "network" | "server" | "unknown"; - status?: number; - message?: string; -} - -const loadConsolidatedData = async ( - stopId: string -): Promise => { - const resp = await fetch( - `${APP_CONSTANTS.consolidatedCirculationsEndpoint}?stopId=${stopId}`, - { - headers: { - Accept: "application/json", - }, - } - ); - - if (!resp.ok) { - throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); - } - - return await resp.json(); -}; - export const StopSheet: React.FC = ({ isOpen, onClose, stop, }) => { const { t } = useTranslation(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [lastUpdated, setLastUpdated] = useState(null); - - const parseError = (error: any): ErrorInfo => { - if (!navigator.onLine) { - return { type: "network", message: "No internet connection" }; - } - - if ( - error.message?.includes("Failed to fetch") || - error.message?.includes("NetworkError") - ) { - return { type: "network" }; - } - - if (error.message?.includes("HTTP")) { - const statusMatch = error.message.match(/HTTP (\d+):/); - const status = statusMatch ? parseInt(statusMatch[1]) : undefined; - return { type: "server", status }; - } - - return { type: "unknown", message: error.message }; - }; - - const loadData = async () => { - try { - setLoading(true); - setError(null); - setData(null); - - const stopData = await loadConsolidatedData(stop.stopId); - setData(stopData); - setLastUpdated(new Date()); - } catch (err) { - console.error("Failed to load stop data:", err); - setError(parseError(err)); - } finally { - setLoading(false); - } - }; + const { + data, + isLoading: loading, + error, + refetch: loadData, + dataUpdatedAt, + } = useStopArrivals(stop.stopId, true, isOpen); - useEffect(() => { - if (isOpen && stop.stopId) { - loadData(); - } - }, [isOpen, stop.stopId]); + const lastUpdated = dataUpdatedAt ? new Date(dataUpdatedAt) : null; // Show only the next 4 arrivals - const sortedData = data - ? [...data].sort( - (a, b) => - (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) - - (b.realTime?.minutes ?? b.schedule?.minutes ?? 999) - ) - : []; - const limitedEstimates = sortedData.slice(0, 4); + const limitedEstimates = data?.arrivals.slice(0, 4) ?? []; return ( @@ -147,8 +75,11 @@ export const StopSheet: React.FC = ({ ) : error ? ( loadData()} title={t( "errors.estimates_title", "Error al cargar estimaciones" @@ -167,11 +98,7 @@ export const StopSheet: React.FC = ({ {t("estimates.none", "No hay estimaciones disponibles")} ) : ( - + )} diff --git a/src/frontend/app/hooks/useArrivals.ts b/src/frontend/app/hooks/useArrivals.ts new file mode 100644 index 0000000..4b0d331 --- /dev/null +++ b/src/frontend/app/hooks/useArrivals.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchArrivals } from "../api/arrivals"; + +export const useStopArrivals = ( + stopId: string, + reduced: boolean = false, + enabled: boolean = true +) => { + return useQuery({ + queryKey: ["arrivals", stopId, reduced], + queryFn: () => fetchArrivals(stopId, reduced), + enabled: !!stopId && enabled, + refetchInterval: 30000, // Refresh every 30 seconds + retry: false, // Disable retries to see errors immediately + }); +}; diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx index 49c9dc8..1354660 100644 --- a/src/frontend/app/root.tsx +++ b/src/frontend/app/root.tsx @@ -20,8 +20,11 @@ const pmtiles = new Protocol(); maplibregl.addProtocol("pmtiles", pmtiles.tile); //#endregion +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import "./i18n"; +const queryClient = new QueryClient(); + export const links: Route.LinksFunction = () => []; export function Layout({ children }: { children: React.ReactNode }) { @@ -89,9 +92,11 @@ export default function App() { } return ( - - - + + + + + ); } diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 279f096..1ce9942 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -146,7 +146,6 @@ export default function StopMap() { stopCode: props.code, name: props.name || "Unknown Stop", lines: routes.map((route) => { - console.log(route); return { line: route.shortName, colour: route.colour, diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 5c12580..6c8284b 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -12,6 +12,7 @@ "@react-router/node": "^7.9.6", "@react-router/serve": "^7.9.6", "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.90.12", "framer-motion": "^12.23.24", "fuse.js": "^7.1.0", "i18next-browser-languagedetector": "^8.2.0", @@ -27,7 +28,8 @@ "react-loading-skeleton": "^3.5.0", "react-modal-sheet": "^5.2.1", "react-router": "^7.9.6", - "tailwindcss": "^4.1.17" + "tailwindcss": "^4.1.17", + "zod": "^4.2.1" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -2156,6 +2158,32 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -7298,10 +7326,9 @@ } }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", - "dev": true, + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", "peer": true, "funding": { diff --git a/src/frontend/package.json b/src/frontend/package.json index 7734ae2..bd55dff 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -18,6 +18,7 @@ "@react-router/node": "^7.9.6", "@react-router/serve": "^7.9.6", "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.90.12", "framer-motion": "^12.23.24", "fuse.js": "^7.1.0", "i18next-browser-languagedetector": "^8.2.0", @@ -33,7 +34,8 @@ "react-loading-skeleton": "^3.5.0", "react-modal-sheet": "^5.2.1", "react-router": "^7.9.6", - "tailwindcss": "^4.1.17" + "tailwindcss": "^4.1.17", + "zod": "^4.2.1" }, "devDependencies": { "@eslint/js": "^9.39.1", -- cgit v1.3