From bed48c3d7e49b1736d50ce42d92bb6c18cf02504 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 22 Dec 2025 22:06:06 +0100 Subject: Refactor arrivals handling and improve type definitions; reorganise components --- global.json | 7 ++ .../Controllers/ArrivalsController.cs | 29 +++++- .../Controllers/TileController.cs | 102 +++++++++++++-------- .../GraphClient/App/ArrivalsAtStop.cs | 20 +++- .../GraphClient/App/StopTile.cs | 20 ++-- .../Types/Arrivals/Arrival.cs | 9 +- .../appsettings.Development.json | 8 ++ src/frontend/app/api/schema.ts | 8 +- 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/arrivals/ArrivalCard.css | 17 ++++ .../app/components/arrivals/ArrivalCard.tsx | 74 +++++++++++++++ .../app/components/arrivals/ArrivalList.tsx | 25 +++++ .../app/components/map/StopSummarySheet.tsx | 2 +- 15 files changed, 257 insertions(+), 178 deletions(-) create mode 100644 global.json create mode 100644 src/Costasdev.Busurbano.Backend/appsettings.Development.json delete mode 100644 src/frontend/app/components/Stops/ArrivalCard.css delete mode 100644 src/frontend/app/components/Stops/ArrivalCard.tsx delete mode 100644 src/frontend/app/components/Stops/ArrivalList.tsx create mode 100644 src/frontend/app/components/arrivals/ArrivalCard.css create mode 100644 src/frontend/app/components/arrivals/ArrivalCard.tsx create mode 100644 src/frontend/app/components/arrivals/ArrivalList.tsx diff --git a/global.json b/global.json new file mode 100644 index 0000000..a11f48e --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.0", + "rollForward": "latestMajor", + "allowPrerelease": true + } +} \ No newline at end of file diff --git a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs index 5dee48d..7158137 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs @@ -1,7 +1,6 @@ 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; @@ -37,7 +36,14 @@ public partial class ArrivalsController : ControllerBase var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); var todayLocal = nowLocal.Date; - var requestContent = ArrivalsAtStopContent.Query(new(id, reduced ? 4 : 10)); + var requestContent = ArrivalsAtStopContent.Query( + new ArrivalsAtStopContent.Args( + id, + reduced ? 4 : 10, + ShouldFetchPastArrivals(id) + ) + ); + var request = new HttpRequestMessage(HttpMethod.Post, "http://100.67.54.115:3957/otp/gtfs/v1"); request.Content = JsonContent.Create(new GraphClientRequest { @@ -61,13 +67,20 @@ public partial class ArrivalsController : ControllerBase var minutesToArrive = (int)(departureTime - nowLocal).TotalMinutes; //var isRunning = departureTime < nowLocal; + // TODO: Handle this properly, since many times it's "tomorrow" but not handled properly + if (minutesToArrive < ArrivalsAtStopContent.PastArrivalMinutesIncluded) + { + continue; + } + Arrival arrival = new() { + TripId = item.Trip.GtfsId, Route = new RouteInfo { ShortName = item.Trip.RouteShortName, - Colour = item.Trip.Route.Color, - TextColour = item.Trip.Route.TextColor + Colour = item.Trip.Route.Color ?? "FFFFFF", + TextColour = item.Trip.Route.TextColor ?? "000000" }, Headsign = new HeadsignInfo { @@ -76,7 +89,7 @@ public partial class ArrivalsController : ControllerBase Estimate = new ArrivalDetails { Minutes = minutesToArrive, - Precission = departureTime < nowLocal ? ArrivalPrecission.Past : ArrivalPrecission.Scheduled + Precision = departureTime < nowLocal ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled } }; @@ -91,6 +104,12 @@ public partial class ArrivalsController : ControllerBase }); } + private static bool ShouldFetchPastArrivals(string id) + { + string feedId = id.Split(':', 2)[0]; + return feedId == "xunta"; + } + [LoggerMessage(LogLevel.Error, "Error fetching stop data, received {statusCode} {responseBody}")] partial void LogErrorFetchingStopData(HttpStatusCode statusCode, string responseBody); } diff --git a/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs index fad18e7..6354d67 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs @@ -46,9 +46,9 @@ public class TileController : ControllerBase [HttpGet("stops/{z:int}/{x:int}/{y:int}")] public async Task Stops(int z, int x, int y) { - if (z < 9 || z > 16) + if (z is < 9 or > 20) { - return BadRequest("Zoom level out of range (9-16)"); + return BadRequest("Zoom level out of range (9-20)"); } var cacheHit = _cache.TryGetValue($"stops-tile-{z}-{x}-{y}", out byte[]? cachedTile); @@ -96,10 +96,11 @@ public class TileController : ControllerBase responseBody.Data?.StopsByBbox?.ForEach(stop => { var idParts = stop.GtfsId.Split(':', 2); + string feedId = idParts[0]; string codeWithinFeed = stop.Code ?? string.Empty; // TODO: Refactor this, maybe do it client-side or smth - if (idParts[0] == "vitrasa") + if (feedId == "vitrasa") { var digits = new string(codeWithinFeed.Where(char.IsDigit).ToArray()); if (int.TryParse(digits, out int code)) @@ -108,12 +109,13 @@ public class TileController : ControllerBase } } - if (HiddenStops.Contains($"{idParts[0]}:{codeWithinFeed}")) + if (HiddenStops.Contains($"{feedId}:{codeWithinFeed}")) { return; } - var fallbackColours = GetFallbackColourForFeed(idParts[0]); + var (Color, TextColor) = GetFallbackColourForFeed(idParts[0]); + var distinctRoutes = GetDistinctRoutes(feedId, stop.Routes ?? []); Feature feature = new() { @@ -129,36 +131,34 @@ public class TileController : ControllerBase // The name of the stop { "name", stop.Name }, // Routes - { "routes", JsonSerializer.Serialize(stop.Routes? - .DistinctBy(r => r.ShortName) - .OrderBy( - r => r.ShortName, - Comparer.Create(SortingHelper.SortRouteShortNames) - ).Select(r => { - var colour = r.Color ?? fallbackColours.Color; - string textColour; - - if (r.Color is null) // None is present, use fallback - { - textColour = fallbackColours.TextColor; - } - else if (r.TextColor is null || r.TextColor.EndsWith("000000")) - { - // Text colour not provided, or default-black; check the better contrasting - textColour = ContrastHelper.GetBestTextColour(colour); - } - else - { - // Use provided text colour - textColour = r.TextColor; - } - - return new { - shortName = r.ShortName, - colour, - textColour - }; - })) } + { "routes", JsonSerializer + .Serialize( + distinctRoutes.Select(r => { + var colour = r.Color ?? Color; + string textColour; + + if (r.Color is null) // None is present, use fallback + { + textColour = TextColor; + } + else if (r.TextColor is null || r.TextColor.EndsWith("000000")) + { + // Text colour not provided, or default-black; check the better contrasting + textColour = ContrastHelper.GetBestTextColour(colour); + } + else + { + // Use provided text colour + textColour = r.TextColor; + } + + return new { + shortName = r.ShortName, + colour, + textColour + }; + })) + } } }; @@ -176,6 +176,37 @@ public class TileController : ControllerBase return File(ms.ToArray(), "application/x-protobuf"); } + private static List GetDistinctRoutes(string feedId, List routes) + { + List distinctRoutes = []; + HashSet seen = new(); + + foreach (var route in routes) + { + var seenId = route.ShortName; + if (feedId == "xunta") + { + // For Xunta routes we take only the contract number (XG123, for example) + seenId = seenId.Substring(0, 5); + + route.ShortName = seenId; + } + + if (seen.Contains(seenId)) + { + continue; + } + + seen.Add(seenId); + distinctRoutes.Add(route); + } + + return [.. distinctRoutes.OrderBy( + r => r.ShortName, + Comparer.Create(SortingHelper.SortRouteShortNames) + )]; + } + private static (string Color, string TextColor) GetFallbackColourForFeed(string feed) { return feed switch @@ -190,5 +221,4 @@ public class TileController : ControllerBase }; } - } diff --git a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs index 53c1165..2c34784 100644 --- a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs +++ b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs @@ -5,16 +5,26 @@ namespace Costasdev.Busurbano.Backend.GraphClient.App; public class ArrivalsAtStopContent : IGraphRequest { - public record Args(string Id, int DepartureCount); + public const int PastArrivalMinutesIncluded = -15; + + public record Args(string Id, int DepartureCount, bool PastArrivals); public static string Query(Args args) { + var startTime = DateTimeOffset.Now; + if (args.PastArrivals) + { + startTime = DateTimeOffset.Now.AddMinutes(PastArrivalMinutesIncluded); + } + + var startTimeUnix = startTime.ToUnixTimeSeconds(); + return string.Create(CultureInfo.InvariantCulture, $@" query Query {{ stop(id:""{args.Id}"") {{ code name - arrivals: stoptimesWithoutPatterns(numberOfDepartures:{args.DepartureCount}) {{ + arrivals: stoptimesWithoutPatterns(numberOfDepartures:{args.DepartureCount}, startTime: {startTimeUnix}) {{ headsign scheduledDeparture pickupType @@ -40,7 +50,7 @@ public class ArrivalsAtStopContent : IGraphRequest public class ArrivalsAtStopResponse : AbstractGraphResponse { - [JsonPropertyName("stop")] public StopItem Stop { get; set; } + [JsonPropertyName("stop")] public required StopItem Stop { get; set; } public class StopItem { @@ -87,9 +97,9 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse public class RouteDetails { - [JsonPropertyName("color")] public required string Color { get; set; } + [JsonPropertyName("color")] public string? Color { get; set; } - [JsonPropertyName("textColor")] public required string TextColor { get; set; } + [JsonPropertyName("textColor")] public string? TextColor { get; set; } } public class PickupType diff --git a/src/Costasdev.Busurbano.Backend/GraphClient/App/StopTile.cs b/src/Costasdev.Busurbano.Backend/GraphClient/App/StopTile.cs index 8a271f2..802de9a 100644 --- a/src/Costasdev.Busurbano.Backend/GraphClient/App/StopTile.cs +++ b/src/Costasdev.Busurbano.Backend/GraphClient/App/StopTile.cs @@ -42,35 +42,35 @@ public class StopTileResponse : AbstractGraphResponse public record Stop { [JsonPropertyName("gtfsId")] - public required string GtfsId { get; init; } + public required string GtfsId { get; set; } [JsonPropertyName("code")] - public string? Code { get; init; } + public string? Code { get; set; } [JsonPropertyName("name")] - public required string Name { get; init; } + public required string Name { get; set; } [JsonPropertyName("lat")] - public required double Lat { get; init; } + public required double Lat { get; set; } [JsonPropertyName("lon")] - public required double Lon { get; init; } + public required double Lon { get; set; } [JsonPropertyName("routes")] - public List? Routes { get; init; } + public List? Routes { get; set; } } public record Route { [JsonPropertyName("gtfsId")] - public required string GtfsId { get; init; } + public required string GtfsId { get; set; } [JsonPropertyName("shortName")] - public required string ShortName { get; init; } + public required string ShortName { get; set; } [JsonPropertyName("color")] - public string? Color { get; init; } + public string? Color { get; set; } [JsonPropertyName("textColor")] - public string? TextColor { get; init; } + public string? TextColor { get; set; } } } diff --git a/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs index c813ccf..516a1c5 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs @@ -4,6 +4,9 @@ namespace Costasdev.Busurbano.Backend.Types.Arrivals; public class Arrival { + [JsonPropertyName("tripId")] + public required string TripId { get; set; } + [JsonPropertyName("route")] public required RouteInfo Route { get; set; } @@ -49,12 +52,12 @@ public class ArrivalDetails [JsonPropertyName("minutes")] public required int Minutes { get; set; } - [JsonPropertyName("precission")] - public ArrivalPrecission Precission { get; set; } = ArrivalPrecission.Scheduled; + [JsonPropertyName("precision")] + public ArrivalPrecision Precision { get; set; } = ArrivalPrecision.Scheduled; } [JsonConverter(typeof(JsonStringEnumConverter))] -public enum ArrivalPrecission +public enum ArrivalPrecision { [JsonStringEnumMemberName("confident")] Confident = 0, diff --git a/src/Costasdev.Busurbano.Backend/appsettings.Development.json b/src/Costasdev.Busurbano.Backend/appsettings.Development.json new file mode 100644 index 0000000..2b38630 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Costasdev.Busurbano.Backend": "Debug" + } + }, + "AllowedHosts": "*" +} diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index 60e2d97..bb1e96c 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -12,7 +12,7 @@ export const HeadsignInfoSchema = z.object({ marquee: z.string().optional().nullable(), }); -export const ArrivalPrecissionSchema = z.enum([ +export const ArrivalPrecisionSchema = z.enum([ "confident", "unsure", "scheduled", @@ -20,8 +20,8 @@ export const ArrivalPrecissionSchema = z.enum([ ]); export const ArrivalDetailsSchema = z.object({ - minutes: z.number(), - precission: ArrivalPrecissionSchema, + minutes: z.number().int(), + precision: ArrivalPrecisionSchema, }); export const DelayBadgeSchema = z.object({ @@ -49,7 +49,7 @@ export const StopArrivalsResponseSchema = z.object({ export type RouteInfo = z.infer; export type HeadsignInfo = z.infer; -export type ArrivalPrecission = z.infer; +export type ArrivalPrecision = z.infer; export type ArrivalDetails = z.infer; export type DelayBadge = z.infer; export type ShiftBadge = z.infer; diff --git a/src/frontend/app/components/Stops/ArrivalCard.css b/src/frontend/app/components/Stops/ArrivalCard.css deleted file mode 100644 index 5835352..0000000 --- a/src/frontend/app/components/Stops/ArrivalCard.css +++ /dev/null @@ -1,17 +0,0 @@ -@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 deleted file mode 100644 index 96d0af0..0000000 --- a/src/frontend/app/components/Stops/ArrivalCard.tsx +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index a1210d5..0000000 --- a/src/frontend/app/components/Stops/ArrivalList.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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/arrivals/ArrivalCard.css b/src/frontend/app/components/arrivals/ArrivalCard.css new file mode 100644 index 0000000..5835352 --- /dev/null +++ b/src/frontend/app/components/arrivals/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/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx new file mode 100644 index 0000000..de4fcc7 --- /dev/null +++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx @@ -0,0 +1,74 @@ +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 = estimate.minutes.toString(); + const etaUnit = t("estimates.minutes", "min"); + + const timeClass = useMemo(() => { + switch (estimate.precision) { + case "confident": + return "time-running"; + case "unsure": + return "time-delayed"; + case "past": + return "time-past"; + default: + return "time-scheduled"; + } + }, [estimate.precision]); + + return ( +
+
+ +
+
+ + {headsign.destination} + +
+
+
+ {etaValue} + + {etaUnit} + +
+
+
+ ); +}; diff --git a/src/frontend/app/components/arrivals/ArrivalList.tsx b/src/frontend/app/components/arrivals/ArrivalList.tsx new file mode 100644 index 0000000..a1210d5 --- /dev/null +++ b/src/frontend/app/components/arrivals/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 16a9cbe..e318bee 100644 --- a/src/frontend/app/components/map/StopSummarySheet.tsx +++ b/src/frontend/app/components/map/StopSummarySheet.tsx @@ -3,7 +3,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Sheet } from "react-modal-sheet"; import { Link } from "react-router"; -import { ArrivalList } from "~/components/Stops/ArrivalList"; +import { ArrivalList } from "~/components/arrivals/ArrivalList"; import { useStopArrivals } from "../../hooks/useArrivals"; import { ErrorDisplay } from "../ErrorDisplay"; import LineIcon from "../LineIcon"; -- cgit v1.3