aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs68
-rw-r--r--src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs110
-rw-r--r--src/Costasdev.Busurbano.Backend/Program.cs9
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs82
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs15
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs4
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs (renamed from src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs)0
-rw-r--r--src/frontend/app/api/arrivals.ts31
-rw-r--r--src/frontend/app/api/schema.ts96
-rw-r--r--src/frontend/app/components/Stops/ArrivalCard.css17
-rw-r--r--src/frontend/app/components/Stops/ArrivalCard.tsx72
-rw-r--r--src/frontend/app/components/Stops/ArrivalList.tsx25
-rw-r--r--src/frontend/app/components/map/StopSummarySheet.tsx109
-rw-r--r--src/frontend/app/hooks/useArrivals.ts16
-rw-r--r--src/frontend/app/root.tsx11
-rw-r--r--src/frontend/app/routes/map.tsx1
-rw-r--r--src/frontend/package-lock.json37
-rw-r--r--src/frontend/package.json4
18 files changed, 563 insertions, 144 deletions
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<ArrivalsController> _logger;
private readonly IMemoryCache _cache;
@@ -25,9 +28,16 @@ public class ArrivalsController : ControllerBase
}
[HttpGet("arrivals")]
- public async Task<IActionResult> GetArrivals(string id)
+ public async Task<IActionResult> 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<GraphClientResponse<ArrivalsAtStopResponse>>();
- 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<Arrival> 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<string>
+public class ArrivalsAtStopContent : IGraphRequest<ArrivalsAtStopContent.Args>
{
- 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<string>
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<Arrival> Arrivals { get; set; } = [];
+ [JsonPropertyName("arrivals")] public List<Arrival> 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<AppConfiguration>(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<ShapeTraversalService>();
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<Arrival> 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<OtpWalkStep> Steps { get; set; } = new();
+ public List<OtpWalkStep> 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<OtpPlace> IntermediateStops { get; set; } = new();
+ public List<OtpPlace> IntermediateStops { get; set; } = [];
}
public class OtpPlace
diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs
index c31d12a..c31d12a 100644
--- a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs
+++ b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs
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<StopArrivalsResponse> => {
+ 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<typeof RouteInfoSchema>;
+export type HeadsignInfo = z.infer<typeof HeadsignInfoSchema>;
+export type ArrivalPrecission = z.infer<typeof ArrivalPrecissionSchema>;
+export type ArrivalDetails = z.infer<typeof ArrivalDetailsSchema>;
+export type DelayBadge = z.infer<typeof DelayBadgeSchema>;
+export type ShiftBadge = z.infer<typeof ShiftBadgeSchema>;
+export type Arrival = z.infer<typeof ArrivalSchema>;
+export type StopArrivalsResponse = z.infer<typeof StopArrivalsResponseSchema>;
+
+// 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<ArrivalCardProps> = ({
+ 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 (
+ <div
+ className={`
+ flex-none flex items-center gap-2.5 min-h-12
+ bg-(--message-background-color) border border-(--border-color)
+ rounded-xl px-3 py-2.5 transition-all
+ ${reduced ? "reduced" : ""}
+ `.trim()}
+ >
+ <div className="shrink-0 min-w-[7ch]">
+ <LineIcon
+ line={route.shortName}
+ colour={route.colour}
+ textColour={route.textColour}
+ mode="pill"
+ />
+ </div>
+ <div className="flex-1 min-w-0 flex flex-col gap-1">
+ <strong className="text-base text-(--text-color) overflow-hidden text-ellipsis line-clamp-2 leading-tight">
+ {headsign.destination}
+ </strong>
+ </div>
+ <div
+ className={`
+ inline-flex items-center justify-center px-2 py-1.5 rounded-xl shrink-0
+ ${timeClass}
+ `.trim()}
+ >
+ <div className="flex flex-col items-center leading-none">
+ <span className="text-lg font-bold leading-none">{etaValue}</span>
+ <span className="text-[0.65rem] uppercase tracking-wider mt-0.5 opacity-90">
+ {etaUnit}
+ </span>
+ </div>
+ </div>
+ </div>
+ );
+};
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<ArrivalListProps> = ({
+ arrivals,
+ reduced,
+}) => {
+ return (
+ <div className="flex flex-col gap-3">
+ {arrivals.map((arrival, index) => (
+ <ArrivalCard
+ key={`${arrival.route.shortName}-${index}`}
+ arrival={arrival}
+ reduced={reduced}
+ />
+ ))}
+ </div>
+ );
+};
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<ConsolidatedCirculation[]> => {
- 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<StopSheetProps> = ({
isOpen,
onClose,
stop,
}) => {
const { t } = useTranslation();
- const [data, setData] = useState<ConsolidatedCirculation[] | null>(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState<ErrorInfo | null>(null);
- const [lastUpdated, setLastUpdated] = useState<Date | null>(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 (
<Sheet isOpen={isOpen} onClose={onClose} detent="content">
@@ -147,8 +75,11 @@ export const StopSheet: React.FC<StopSheetProps> = ({
<StopSummarySheetSkeleton />
) : error ? (
<ErrorDisplay
- error={error}
- onRetry={loadData}
+ error={{
+ type: error.message.includes("HTTP") ? "server" : "network",
+ message: error.message,
+ }}
+ onRetry={() => loadData()}
title={t(
"errors.estimates_title",
"Error al cargar estimaciones"
@@ -167,11 +98,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
{t("estimates.none", "No hay estimaciones disponibles")}
</div>
) : (
- <ConsolidatedCirculationList
- data={data.slice(0, 4)}
- driver={stop.stopFeed}
- reduced
- />
+ <ArrivalList arrivals={limitedEstimates} reduced />
)}
</div>
</>
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 (
- <AppProvider>
- <AppShell />
- </AppProvider>
+ <QueryClientProvider client={queryClient}>
+ <AppProvider>
+ <AppShell />
+ </AppProvider>
+ </QueryClientProvider>
);
}
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",