From 5614fbc76c59a8c0bfe5cafc9af4805e43351c1c Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 31 Dec 2025 14:38:29 +0100 Subject: feat: Add vehicle information to arrival details and update related components --- .../Services/Processors/CorunaRealTimeProcessor.cs | 87 ++++++++++++++++---- .../Processors/SantiagoRealTimeProcessor.cs | 19 +---- src/Enmarcha.Backend/Types/Arrivals/Arrival.cs | 92 ++++++++++------------ .../CorunaRealtimeEstimatesProvider.cs | 3 +- src/frontend/app/api/schema.ts | 9 +++ .../app/components/arrivals/ArrivalCard.tsx | 42 ++++++++-- .../app/components/arrivals/ReducedArrivalCard.tsx | 39 +++++++-- 7 files changed, 196 insertions(+), 95 deletions(-) (limited to 'src') diff --git a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs index ca3f91d..ad5465f 100644 --- a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs @@ -37,31 +37,29 @@ public class CorunaRealTimeProcessor : AbstractRealTimeProcessor Epsg25829? stopLocation = null; if (context.StopLocation != null) { - stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); + stopLocation = + _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); } var realtime = await _realtime.GetEstimatesForStop(numericStopId); var usedTripIds = new HashSet(); - var newArrivals = new List(); foreach (var estimate in realtime) { var bestMatch = context.Arrivals .Where(a => !usedTripIds.Contains(a.TripId)) .Where(a => a.Route.RouteIdInGtfs.Trim() == estimate.RouteId.Trim()) - .Select(a => + .Select(a => new { - return new - { - Arrival = a, - TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule - RouteMatch = true - }; + Arrival = a, + TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = true }) .Where(x => x.RouteMatch) // Strict route matching - .Where(x => x.TimeDiff >= -7 && x.TimeDiff <= 75) // Allow 7m early (RealTime < Schedule) or 75m late (RealTime > Schedule) - .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit + .Where(x => x.TimeDiff is >= -5 + and <= 15) // Allow 5m early (RealTime < Schedule) or 15m late (RealTime > Schedule) + .OrderBy(x => x.TimeDiff < 0 ? Math.Abs(x.TimeDiff) * 2 : x.TimeDiff) // Best time fit .FirstOrDefault(); if (bestMatch == null) @@ -82,6 +80,17 @@ public class CorunaRealTimeProcessor : AbstractRealTimeProcessor arrival.Delay = new DelayBadge { Minutes = delayMinutes }; } + // Populate vehicle information + var busInfo = GetBusInfoByNumber(estimate.VehicleNumber); + arrival.VehicleInformation = new VehicleBadge + { + Identifier = estimate.VehicleNumber, + Make = busInfo?.Make, + Model = busInfo?.Model, + Kind = busInfo?.Kind, + Year = busInfo?.Year + }; + // Calculate position if (stopLocation != null) { @@ -106,7 +115,9 @@ public class CorunaRealTimeProcessor : AbstractRealTimeProcessor if (currentPosition != null) { - _logger.LogInformation("Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, currentPosition.Latitude, currentPosition.Longitude); + _logger.LogInformation( + "Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, + currentPosition.Latitude, currentPosition.Longitude); } // Populate Shape GeoJSON @@ -149,7 +160,7 @@ public class CorunaRealTimeProcessor : AbstractRealTimeProcessor arrival.Shape = new { type = "FeatureCollection", - features = features + features }; } } @@ -162,14 +173,11 @@ public class CorunaRealTimeProcessor : AbstractRealTimeProcessor } usedTripIds.Add(arrival.TripId); - } - - context.Arrivals.AddRange(newArrivals); } catch (Exception ex) { - _logger.LogError(ex, "Error fetching Vitrasa real-time data for stop {StopId}", context.StopId); + _logger.LogError(ex, "Error fetching Tranvías real-time data for stop {StopId}", context.StopId); } } @@ -178,4 +186,49 @@ public class CorunaRealTimeProcessor : AbstractRealTimeProcessor return a == b || a.Contains(b) || b.Contains(a); } + private (string Make, string Model, string Kind, string Year)? GetBusInfoByNumber(string identifier) + { + int number = int.Parse(identifier); + + return number switch + { + // 2000 + >= 326 and <= 336 => ("MB", "O405N2 Venus", "RIG", "2000"), + 337 => ("MB", "O405G Alce", "ART", "2000"), + // 2002-2003 + >= 340 and <= 344 => ("MAN", "NG313F Delfos Venus", "ART", "2002"), + >= 345 and <= 347 => ("MAN", "NG313F Delfos Venus", "ART", "2003"), + // 2004 + >= 348 and <= 349 => ("MAN", "NG313F Delfos Venus", "ART", "2004"), + >= 350 and <= 355 => ("MAN", "NL263F Luxor II", "RIG", "2004"), + // 2005 + >= 356 and <= 359 => ("MAN", "NL263F Luxor II", "RIG", "2005"), + >= 360 and <= 362 => ("MAN", "NG313F Delfos", "ART", "2005"), + // 2007 + >= 363 and <= 370 => ("MAN", "NL273F Luxor II", "RIG", "2007"), + // 2008 + >= 371 and <= 377 => ("MAN", "NL273F Luxor II", "RIG", "2008"), + // 2009 + >= 378 and <= 387 => ("MAN", "NL273F Luxor II", "RIG", "2009"), + // 2012 + >= 388 and <= 392 => ("MAN", "NL283F Ceres", "RIG", "2012"), + >= 393 and <= 395 => ("MAN", "NG323F Ceres", "ART", "2012"), + // 2013 + >= 396 and <= 403 => ("MAN", "NL283F Ceres", "RIG", "2013"), + // 2014 + >= 404 and <= 407 => ("MB", "Citaro C2", "RIG", "2014"), + >= 408 and <= 411 => ("MAN", "NL283F Ceres", "RIG", "2014"), + // 2015 + >= 412 and <= 414 => ("MB", "Citaro C2 G", "ART", "2015"), + >= 415 and <= 419 => ("MB", "Citaro C2", "RIG", "2015"), + // 2016 + >= 420 and <= 427 => ("MB", "Citaro C2", "RIG", "2016"), + // 2024 + 428 => ("MAN", "Lion's City 12 E", "RIG", "2024"), + // 2025 + 429 => ("MAN", "Lion's City 18", "RIG", "2025"), + >= 430 and <= 432 => ("MAN", "Lion's City 12", "RIG", "2025"), + _ => null + }; + } } diff --git a/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs index b941c6e..d14cfa0 100644 --- a/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs @@ -36,7 +36,6 @@ public class SantiagoRealTimeProcessor : AbstractRealTimeProcessor var realtime = await _realtime.GetEstimatesForStop(numericStopId); var usedTripIds = new HashSet(); - var newArrivals = new List(); foreach (var estimate in realtime) { @@ -50,11 +49,11 @@ public class SantiagoRealTimeProcessor : AbstractRealTimeProcessor RouteMatch = true }) .Where(x => x.RouteMatch) // Strict route matching - .Where(x => x.TimeDiff >= -7 && x.TimeDiff <= 75) // Allow 7m early (RealTime < Schedule) or 75m late (RealTime > Schedule) + .Where(x => x.TimeDiff is >= -5 and <= 25) // Allow 2m early (RealTime < Schedule) or 25m late (RealTime > Schedule) .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit .FirstOrDefault(); - if (bestMatch == null) + if (bestMatch is null) { context.Arrivals.Add(new Arrival { @@ -76,31 +75,21 @@ public class SantiagoRealTimeProcessor : AbstractRealTimeProcessor Minutes = estimate.MinutesToArrive, Precision = ArrivalPrecision.Confident } - }); + continue; } var arrival = bestMatch.Arrival; - var scheduledMinutes = arrival.Estimate.Minutes; arrival.Estimate.Minutes = estimate.MinutesToArrive; arrival.Estimate.Precision = ArrivalPrecision.Confident; - // Calculate delay badge - var delayMinutes = estimate.MinutesToArrive - scheduledMinutes; - if (delayMinutes != 0) - { - arrival.Delay = new DelayBadge { Minutes = delayMinutes }; - } - usedTripIds.Add(arrival.TripId); } - - context.Arrivals.AddRange(newArrivals); } catch (Exception ex) { - _logger.LogError(ex, "Error fetching Vitrasa real-time data for stop {StopId}", context.StopId); + _logger.LogError(ex, "Error fetching Santiago real-time data for stop {StopId}", context.StopId); } } diff --git a/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs b/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs index e99baa7..9d2ea1b 100644 --- a/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs +++ b/src/Enmarcha.Backend/Types/Arrivals/Arrival.cs @@ -1,106 +1,100 @@ using System.Text.Json.Serialization; using Enmarcha.Backend.Types; +using Newtonsoft.Json; namespace Enmarcha.Backend.Types.Arrivals; public class Arrival { - [JsonPropertyName("tripId")] - public required string TripId { get; set; } + [JsonPropertyName("tripId")] public required string TripId { get; set; } - [JsonPropertyName("route")] - public required RouteInfo Route { get; set; } + [JsonPropertyName("route")] public required RouteInfo Route { get; set; } - [JsonPropertyName("headsign")] - public required HeadsignInfo Headsign { get; set; } + [JsonPropertyName("headsign")] public required HeadsignInfo Headsign { get; set; } - [JsonPropertyName("estimate")] - public required ArrivalDetails Estimate { get; set; } + [JsonPropertyName("estimate")] public required ArrivalDetails Estimate { get; set; } - [JsonPropertyName("delay")] - public DelayBadge? Delay { get; set; } + [JsonPropertyName("delay")] public DelayBadge? Delay { get; set; } - [JsonPropertyName("shift")] - public ShiftBadge? Shift { get; set; } + [JsonPropertyName("shift")] public ShiftBadge? Shift { get; set; } - [JsonPropertyName("shape")] - public object? Shape { get; set; } + [JsonPropertyName("shape")] public object? Shape { get; set; } - [JsonPropertyName("currentPosition")] - public Position? CurrentPosition { get; set; } + [JsonPropertyName("currentPosition")] public Position? CurrentPosition { get; set; } - [JsonPropertyName("stopShapeIndex")] - public int? StopShapeIndex { get; set; } + [JsonPropertyName("stopShapeIndex")] public int? StopShapeIndex { get; set; } - [JsonIgnore] + [JsonPropertyName("vehicleInformation")] + public VehicleBadge? VehicleInformation { get; set; } + + [System.Text.Json.Serialization.JsonIgnore] public List NextStops { get; set; } = []; - [JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] public object? RawOtpTrip { get; set; } } public class RouteInfo { - [JsonPropertyName("gtfsId")] - public required string GtfsId { get; set; } + [JsonPropertyName("gtfsId")] public required string GtfsId { get; set; } public string RouteIdInGtfs => GtfsId.Split(':', 2)[1]; - [JsonPropertyName("shortName")] - public required string ShortName { get; set; } + [JsonPropertyName("shortName")] public required string ShortName { get; set; } - [JsonPropertyName("colour")] - public required string Colour { get; set; } + [JsonPropertyName("colour")] public required string Colour { get; set; } - [JsonPropertyName("textColour")] - public required string TextColour { get; set; } + [JsonPropertyName("textColour")] public required string TextColour { get; set; } } public class HeadsignInfo { - [JsonPropertyName("badge")] - public string? Badge { get; set; } + [JsonPropertyName("badge")] public string? Badge { get; set; } - [JsonPropertyName("destination")] - public required string Destination { get; set; } + [JsonPropertyName("destination")] public required string Destination { get; set; } - [JsonPropertyName("marquee")] - public string? Marquee { get; set; } + [JsonPropertyName("marquee")] public string? Marquee { get; set; } } public class ArrivalDetails { - [JsonPropertyName("minutes")] - public required int Minutes { get; set; } + [JsonPropertyName("minutes")] public required int Minutes { get; set; } - [JsonPropertyName("precision")] - public ArrivalPrecision Precision { get; set; } = ArrivalPrecision.Scheduled; + [JsonPropertyName("precision")] public ArrivalPrecision Precision { get; set; } = ArrivalPrecision.Scheduled; } -[JsonConverter(typeof(JsonStringEnumConverter))] +[System.Text.Json.Serialization.JsonConverter(typeof(JsonStringEnumConverter))] public enum ArrivalPrecision { [JsonStringEnumMemberName("confident")] Confident = 0, - [JsonStringEnumMemberName("unsure")] - Unsure = 1, + [JsonStringEnumMemberName("unsure")] Unsure = 1, + [JsonStringEnumMemberName("scheduled")] Scheduled = 2, - [JsonStringEnumMemberName("past")] - Past = 3 + [JsonStringEnumMemberName("past")] Past = 3 } public class DelayBadge { - [JsonPropertyName("minutes")] - public int Minutes { get; set; } + [JsonPropertyName("minutes")] public int Minutes { get; set; } } public class ShiftBadge { - [JsonPropertyName("shiftName")] - public required string ShiftName { get; set; } + [JsonPropertyName("shiftName")] public required string ShiftName { get; set; } + + [JsonPropertyName("shiftTrip")] public required string ShiftTrip { get; set; } +} + +public class VehicleBadge +{ + [JsonPropertyName("identifier")] public required string Identifier { get; set; } + + [JsonPropertyName("make")] public string? Make { get; set; } + [JsonPropertyName("model")] public string? Model { get; set; } + [JsonPropertyName("kind")] public string? Kind { get; set; } + [JsonPropertyName("year")] public string? Year { get; set; } + - [JsonPropertyName("shiftTrip")] - public required string ShiftTrip { get; set; } } diff --git a/src/Enmarcha.Sources.TranviasCoruna/CorunaRealtimeEstimatesProvider.cs b/src/Enmarcha.Sources.TranviasCoruna/CorunaRealtimeEstimatesProvider.cs index 70449ce..43debba 100644 --- a/src/Enmarcha.Sources.TranviasCoruna/CorunaRealtimeEstimatesProvider.cs +++ b/src/Enmarcha.Sources.TranviasCoruna/CorunaRealtimeEstimatesProvider.cs @@ -29,12 +29,13 @@ public class CorunaRealtimeEstimatesProvider return r.Arrivals.Select(arrival => { var minutes = arrival.Minutes == "<1" ? 0 : int.Parse(arrival.Minutes); + var metres = arrival.Metres == "--" ? 0 : int.Parse(arrival.Metres); return new CorunaEstimate ( r.RouteId.ToString(), minutes, - int.Parse(arrival.Metres), + metres, arrival.VehicleNumber.ToString() ); }).ToList(); diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index 95c7b6f..d9aec89 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -40,6 +40,14 @@ export const PositionSchema = z.object({ shapeIndex: z.number(), }); +export const VehicleInformationSchema = z.object({ + identifier: z.string(), + make: z.string().optional().nullable(), + model: z.string().optional().nullable(), + kind: z.string().optional().nullable(), + year: z.string().optional().nullable(), +}); + export const ArrivalSchema = z.object({ tripId: z.string(), route: RouteInfoSchema, @@ -50,6 +58,7 @@ export const ArrivalSchema = z.object({ shape: z.any().optional().nullable(), currentPosition: PositionSchema.optional().nullable(), stopShapeIndex: z.number().optional().nullable(), + vehicleInformation: VehicleInformationSchema.optional().nullable(), }); export const StopArrivalsResponseSchema = z.object({ diff --git a/src/frontend/app/components/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx index 6952f8f..f1fc1a5 100644 --- a/src/frontend/app/components/arrivals/ArrivalCard.tsx +++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, LocateIcon } from "lucide-react"; +import { AlertTriangle, BusFront, LocateIcon } from "lucide-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; import Marquee from "react-fast-marquee"; import { useTranslation } from "react-i18next"; @@ -59,7 +59,8 @@ export const ArrivalCard: React.FC = ({ onClick, }) => { const { t } = useTranslation(); - const { route, headsign, estimate, delay, shift } = arrival; + const { route, headsign, estimate, delay, shift, vehicleInformation } = + arrival; const etaValue = estimate.minutes.toString(); const etaUnit = t("estimates.minutes", "min"); @@ -81,7 +82,7 @@ export const ArrivalCard: React.FC = ({ const chips: Array<{ label: string; tone?: string; - kind?: "regular" | "gps" | "delay" | "warning"; + kind?: "regular" | "gps" | "delay" | "warning" | "vehicle"; }> = []; // Badge/Shift info as a chip @@ -140,7 +141,10 @@ export const ArrivalCard: React.FC = ({ tone: "warning", kind: "warning", }); - } else if (estimate.precision === "confident") { + } else if ( + estimate.precision === "confident" && + arrival.currentPosition !== null + ) { chips.push({ label: t("estimates.bus_gps_position"), kind: "gps", @@ -155,8 +159,27 @@ export const ArrivalCard: React.FC = ({ }); } + // Vehicle information if available + if (vehicleInformation) { + let label = vehicleInformation.identifier; + if (vehicleInformation.make) { + label += ` (${vehicleInformation.make}`; + if (vehicleInformation.model) { + label += ` ${vehicleInformation.model}`; + } + if (vehicleInformation.year) { + label += ` - ${vehicleInformation.year}`; + } + label += `)`; + } + chips.push({ + label, + kind: "vehicle", + }); + } + return chips; - }, [delay, shift, estimate.precision, t, headsign.badge]); + }, [delay, shift, estimate.precision, t, headsign.badge, vehicleInformation]); const isClickable = !!onClick && estimate.precision !== "past"; const Tag = isClickable ? "button" : "div"; @@ -208,7 +231,7 @@ export const ArrivalCard: React.FC = ({ -
+
{metaChips.map((chip, idx) => { let chipColourClasses = ""; switch (chip.tone) { @@ -243,10 +266,13 @@ export const ArrivalCard: React.FC = ({ className={`text-xs px-2.5 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 font-medium tracking-wide ${chipColourClasses}`} > {chip.kind === "gps" && ( - + )} {chip.kind === "warning" && ( - + + )} + {chip.kind === "vehicle" && ( + )} {chip.label} diff --git a/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx b/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx index 2c1ea20..44c8eda 100644 --- a/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx +++ b/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, LocateIcon } from "lucide-react"; +import { AlertTriangle, BusFront, LocateIcon } from "lucide-react"; import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import LineIcon from "~/components/LineIcon"; @@ -15,7 +15,8 @@ export const ReducedArrivalCard: React.FC = ({ onClick, }) => { const { t } = useTranslation(); - const { route, headsign, estimate, delay, shift } = arrival; + const { route, headsign, estimate, delay, shift, vehicleInformation } = + arrival; const etaValue = estimate.minutes.toString(); const etaUnit = t("estimates.minutes", "min"); @@ -37,7 +38,7 @@ export const ReducedArrivalCard: React.FC = ({ const chips: Array<{ label: string; tone?: string; - kind?: "regular" | "gps" | "delay" | "warning"; + kind?: "regular" | "gps" | "delay" | "warning" | "vehicle"; }> = []; // Badge/Shift info as a chip @@ -96,15 +97,40 @@ export const ReducedArrivalCard: React.FC = ({ tone: "warning", kind: "warning", }); - } else if (estimate.precision === "confident") { + } else if ( + estimate.precision === "confident" && + arrival.currentPosition !== null + ) { chips.push({ label: "", // Just the icon for reduced kind: "gps", }); } + // Vehicle information if available + if (vehicleInformation) { + let label = vehicleInformation.identifier; + if (vehicleInformation.make) { + label += ` (${vehicleInformation.make}`; + if (vehicleInformation.kind) { + const kindLabel = + vehicleInformation.kind.charAt(0).toUpperCase() + + vehicleInformation.kind.slice(1).toLowerCase(); + label += ` ${kindLabel}.`; + } + if (vehicleInformation.year) { + label += ` ${vehicleInformation.year}`; + } + label += `)`; + } + chips.push({ + label, + kind: "vehicle", + }); + } + return chips; - }, [delay, shift, estimate.precision, headsign.badge]); + }, [delay, shift, estimate.precision, headsign.badge, vehicleInformation]); const isClickable = !!onClick && estimate.precision !== "past"; const Tag = isClickable ? "button" : "div"; @@ -173,6 +199,9 @@ export const ReducedArrivalCard: React.FC = ({ {chip.kind === "warning" && ( )} + {chip.kind === "vehicle" && ( + + )} {chip.label} ); -- cgit v1.3