From cd0551534d8aaf3d3d5cc1727dfcc01d0637c6b2 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 12 Apr 2026 18:02:56 +0200 Subject: Run formatter, split code in backend --- .../Controllers/AlertsController.cs | 1 - .../Controllers/Backoffice/LoginController.cs | 2 +- .../Data/Migrations/20260319113819_Initial.cs | 1 - .../20260401135403_AddPushNotifications.cs | 1 - src/Enmarcha.Backend/Data/Models/ServiceAlert.cs | 2 +- .../Enmarcha.Backend.csproj.lscache | 17 +- .../Helpers/TransitKindClassifier.cs | 2 +- src/Enmarcha.Backend/Program.cs | 10 +- .../Services/Processors/CorunaRealTimeProcessor.cs | 231 ---------------- .../Processors/CtagShuttleRealTimeProcessor.cs | 307 --------------------- .../Services/Processors/FeedConfigProcessor.cs | 101 ------- .../Services/Processors/NextStopsProcessor.cs | 1 - .../Processors/Normalisation/ColourProcessor.cs | 34 +++ .../TranviasNormalizationProcessor.cs | 17 ++ .../Normalisation/VitrasaNormalizationProcessor.cs | 77 ++++++ .../Normalisation/XuntaNormalizationProcessor.cs | 24 ++ .../Processors/RealTime/CorunaRealTimeProcessor.cs | 231 ++++++++++++++++ .../RealTime/CtagShuttleRealTimeProcessor.cs | 307 +++++++++++++++++++++ .../Processors/RealTime/RenfeRealTimeProcessor.cs | 84 ++++++ .../Processors/RealTime/TussaRealTimeProcessor.cs | 94 +++++++ .../RealTime/VitrasaRealTimeProcessor.cs | 295 ++++++++++++++++++++ .../Services/Processors/RenfeRealTimeProcessor.cs | 84 ------ .../Services/Processors/TussaRealTimeProcessor.cs | 94 ------- .../Processors/VitrasaRealTimeProcessor.cs | 295 -------------------- .../ViewModels/AlertFormViewModel.cs | 1 - src/Enmarcha.Sources.TranviasCoruna/Response.cs | 10 +- src/Enmarcha.Sources.Tussa/Response.cs | 16 +- src/frontend/app/api/schema.ts | 5 +- .../app/components/arrivals/ArrivalList.tsx | 5 +- src/frontend/app/contexts/JourneyContext.tsx | 8 +- src/frontend/app/hooks/useJourneyTracker.ts | 5 +- 31 files changed, 1206 insertions(+), 1156 deletions(-) delete mode 100644 src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs delete mode 100644 src/Enmarcha.Backend/Services/Processors/CtagShuttleRealTimeProcessor.cs delete mode 100644 src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/Normalisation/ColourProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/Normalisation/TranviasNormalizationProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/Normalisation/VitrasaNormalizationProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/Normalisation/XuntaNormalizationProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/RealTime/CorunaRealTimeProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/RealTime/CtagShuttleRealTimeProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/RealTime/RenfeRealTimeProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/RealTime/TussaRealTimeProcessor.cs create mode 100644 src/Enmarcha.Backend/Services/Processors/RealTime/VitrasaRealTimeProcessor.cs delete mode 100644 src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs delete mode 100644 src/Enmarcha.Backend/Services/Processors/TussaRealTimeProcessor.cs delete mode 100644 src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs (limited to 'src') diff --git a/src/Enmarcha.Backend/Controllers/AlertsController.cs b/src/Enmarcha.Backend/Controllers/AlertsController.cs index 4860399..7d19894 100644 --- a/src/Enmarcha.Backend/Controllers/AlertsController.cs +++ b/src/Enmarcha.Backend/Controllers/AlertsController.cs @@ -1,5 +1,4 @@ using Enmarcha.Backend.Data; -using Enmarcha.Backend.Data.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; diff --git a/src/Enmarcha.Backend/Controllers/Backoffice/LoginController.cs b/src/Enmarcha.Backend/Controllers/Backoffice/LoginController.cs index 1e9f12f..2a5a515 100644 --- a/src/Enmarcha.Backend/Controllers/Backoffice/LoginController.cs +++ b/src/Enmarcha.Backend/Controllers/Backoffice/LoginController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.cs b/src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.cs index 2548aa9..b3b8dba 100644 --- a/src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.cs +++ b/src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; diff --git a/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs index 964a86f..c21c754 100644 --- a/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs +++ b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs b/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs index 8551117..2a6a48a 100644 --- a/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs +++ b/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.ComponentModel.DataAnnotations.Schema; namespace Enmarcha.Backend.Data.Models; diff --git a/src/Enmarcha.Backend/Enmarcha.Backend.csproj.lscache b/src/Enmarcha.Backend/Enmarcha.Backend.csproj.lscache index 0b75fe1..fa4e2b5 100644 --- a/src/Enmarcha.Backend/Enmarcha.Backend.csproj.lscache +++ b/src/Enmarcha.Backend/Enmarcha.Backend.csproj.lscache @@ -109,15 +109,20 @@ Services/ OtpService.cs Processors/ AbstractProcessor.cs - CorunaRealTimeProcessor.cs - CtagShuttleRealTimeProcessor.cs - FeedConfigProcessor.cs FilterAndSortProcessor.cs NextStopsProcessor.cs - RenfeRealTimeProcessor.cs + Normalisation/ + ColourProcessor.cs + TranviasNormalizationProcessor.cs + VitrasaNormalizationProcessor.cs + XuntaNormalizationProcessor.cs + RealTime/ + CorunaRealTimeProcessor.cs + CtagShuttleRealTimeProcessor.cs + RenfeRealTimeProcessor.cs + TussaRealTimeProcessor.cs + VitrasaRealTimeProcessor.cs ShapeProcessor.cs - TussaRealTimeProcessor.cs - VitrasaRealTimeProcessor.cs VitrasaUsageProcessor.cs Providers/XuntaFareProvider.cs PushNotificationService.cs diff --git a/src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs b/src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs index b1fad84..fbda36c 100644 --- a/src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs +++ b/src/Enmarcha.Backend/Helpers/TransitKindClassifier.cs @@ -1,4 +1,4 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; namespace Enmarcha.Backend.Helpers; diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs index 9bc0eb3..7f8574b 100644 --- a/src/Enmarcha.Backend/Program.cs +++ b/src/Enmarcha.Backend/Program.cs @@ -13,6 +13,8 @@ using Microsoft.EntityFrameworkCore; using OpenTelemetry.Logs; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using Enmarcha.Backend.Services.Processors.Normalisation; +using Enmarcha.Backend.Services.Processors.RealTime; var builder = WebApplication.CreateBuilder(args); @@ -153,7 +155,8 @@ builder.Services.AddAuthentication(options => options.DefaultScheme = "Backoffice"; options.DefaultChallengeScheme = "Auth0"; }) - .AddCookie("Backoffice", options => { + .AddCookie("Backoffice", options => + { options.LoginPath = "/backoffice/auth/login"; options.Cookie.SameSite = SameSiteMode.None; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; @@ -216,7 +219,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); // builder.Services.AddKeyedScoped("Nominatim"); diff --git a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs deleted file mode 100644 index 797221f..0000000 --- a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs +++ /dev/null @@ -1,231 +0,0 @@ -using Enmarcha.Sources.OpenTripPlannerGql.Queries; -using Enmarcha.Sources.TranviasCoruna; -using Enmarcha.Backend.Types; -using Enmarcha.Backend.Types.Arrivals; - -namespace Enmarcha.Backend.Services.Processors; - -public class CorunaRealTimeProcessor : AbstractRealTimeProcessor -{ - private readonly CorunaRealtimeEstimatesProvider _realtime; - private readonly FeedService _feedService; - private readonly ILogger _logger; - private readonly ShapeTraversalService _shapeService; - - public CorunaRealTimeProcessor( - CorunaRealtimeEstimatesProvider realtime, - FeedService feedService, - ILogger logger, - ShapeTraversalService shapeService) - { - _realtime = realtime; - _feedService = feedService; - _logger = logger; - _shapeService = shapeService; - } - - public override async Task ProcessAsync(ArrivalsContext context) - { - if (!context.StopId.StartsWith("tranvias:")) return; - - var normalizedCode = _feedService.NormalizeStopCode("tranvias", context.StopCode); - if (!int.TryParse(normalizedCode, out var numericStopId)) return; - - try - { - Epsg25829? stopLocation = null; - if (context.StopLocation != null) - { - stopLocation = - _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); - } - - var realtime = await _realtime.GetEstimatesForStop(numericStopId); - System.Diagnostics.Activity.Current?.SetTag("realtime.count", realtime.Count); - - var usedTripIds = new HashSet(); - - 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 => new - { - Arrival = a, - TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule - RouteMatch = true - }) - .Where(x => x.RouteMatch) // Strict route matching - .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) - { - continue; - } - - var arrival = bestMatch.Arrival; - - var scheduledMinutes = arrival.Estimate.Minutes; - arrival.Estimate.Minutes = estimate.Minutes; - arrival.Estimate.Precision = ArrivalPrecision.Confident; - - // Calculate delay badge - var delayMinutes = estimate.Minutes - scheduledMinutes; - if (delayMinutes != 0) - { - 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) - { - Position? currentPosition = null; - - if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && - otpArrival.Trip.Geometry?.Points != null) - { - var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) - .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) - .ToList(); - - var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); - - // Ensure meters is positive - var meters = Math.Max(0, estimate.Metres); - var result = _shapeService.GetBusPosition(shape, stopLocation, meters); - - currentPosition = result.BusPosition; - - if (currentPosition != null) - { - _logger.LogInformation( - "Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, - currentPosition.Latitude, currentPosition.Longitude); - } - - // Populate Shape GeoJSON - if (!context.IsReduced && currentPosition != null) - { - var features = new List(); - features.Add(new - { - type = "Feature", - geometry = new - { - type = "LineString", - coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() - }, - properties = new { type = "route" } - }); - - // Add stops if available - if (otpArrival.Trip.Stoptimes != null) - { - foreach (var stoptime in otpArrival.Trip.Stoptimes) - { - features.Add(new - { - type = "Feature", - geometry = new - { - type = "Point", - coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } - }, - properties = new - { - type = "stop", - name = stoptime.Stop.Name - } - }); - } - } - - arrival.Shape = new - { - type = "FeatureCollection", - features - }; - } - } - - if (currentPosition != null) - { - arrival.CurrentPosition = currentPosition; - } - } - - usedTripIds.Add(arrival.TripId); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching Tranvías real-time data for stop {StopId}", context.StopId); - } - } - - private static bool IsRouteMatch(string a, string b) - { - 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/CtagShuttleRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CtagShuttleRealTimeProcessor.cs deleted file mode 100644 index ce2651f..0000000 --- a/src/Enmarcha.Backend/Services/Processors/CtagShuttleRealTimeProcessor.cs +++ /dev/null @@ -1,307 +0,0 @@ -using Enmarcha.Sources.CtagShuttle; -using Enmarcha.Sources.OpenTripPlannerGql.Queries; -using Enmarcha.Backend.Types; -using Enmarcha.Backend.Types.Arrivals; - -namespace Enmarcha.Backend.Services.Processors; - -public class CtagShuttleRealTimeProcessor : AbstractRealTimeProcessor -{ - private readonly CtagShuttleRealtimeEstimatesProvider _shuttleProvider; - private readonly ShapeTraversalService _shapeService; - private readonly ILogger _logger; - - // Maximum distance (in meters) a GPS coordinate can be from the route shape to be considered valid - private const double MaxDistanceFromShape = 100.0; - - // Maximum age (in minutes) for position data to be considered fresh - private const double MaxPositionAgeMinutes = 3.0; - - public CtagShuttleRealTimeProcessor( - CtagShuttleRealtimeEstimatesProvider shuttleProvider, - ShapeTraversalService shapeService, - ILogger logger) - { - _shuttleProvider = shuttleProvider; - _shapeService = shapeService; - _logger = logger; - } - - public override async Task ProcessAsync(ArrivalsContext context) - { - // Only process shuttle stops - if (!context.StopId.StartsWith("shuttle:")) - { - return; - } - - try - { - // Fetch current shuttle status - var status = await _shuttleProvider.GetShuttleStatus(); - System.Diagnostics.Activity.Current?.SetTag("shuttle.status", status.StatusValue); - - // Validate position timestamp - skip if data is stale (>3 minutes old) - var positionAge = (context.NowLocal - status.LastPositionAt).TotalMinutes; - if (positionAge > MaxPositionAgeMinutes) - { - _logger.LogInformation( - "Shuttle position is stale ({Age:F1} minutes old), skipping real-time update", - positionAge); - return; - } - - // Skip processing if shuttle is idle - if (status.StatusValue.Equals("idle", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Shuttle is idle, skipping real-time update"); - return; - } - - // No arrivals to process - if (context.Arrivals.Count == 0) - { - _logger.LogWarning("No scheduled arrivals found for shuttle stop {StopId}", context.StopId); - return; - } - - // Transform shuttle GPS position to EPSG:25829 (meters) - var shuttlePosition = _shapeService.TransformToEpsg25829(status.Latitude, status.Longitude); - _logger.LogDebug("Shuttle position: Lat={Lat}, Lon={Lon} -> X={X}, Y={Y}", - status.Latitude, status.Longitude, shuttlePosition.X, shuttlePosition.Y); - - // Get the shape from the first arrival (assuming single circular route) - var firstArrival = context.Arrivals.First(); - if (firstArrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival || - otpArrival.Trip.Geometry?.Points == null) - { - _logger.LogWarning("No shape geometry available for shuttle trip"); - return; - } - - // Decode polyline and create shape - var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) - .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) - .ToList(); - var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); - - if (shape.Points.Count == 0) - { - _logger.LogWarning("Shape has no points"); - return; - } - - // Find closest point on shape to shuttle's current position - var (closestPointIndex, distanceToShape) = FindClosestPointOnShape(shape.Points.ToList(), shuttlePosition); - - // Validate that shuttle is reasonably close to the route - if (distanceToShape > MaxDistanceFromShape) - { - _logger.LogWarning( - "Shuttle position is {Distance:F1}m from route (threshold: {Threshold}m), skipping update", - distanceToShape, MaxDistanceFromShape); - return; - } - - // Calculate distance from shape start to shuttle's current position - var shuttleDistanceAlongShape = CalculateTotalDistanceToPoint(shape.Points.ToArray(), closestPointIndex); - _logger.LogDebug("Shuttle is {Distance:F1}m along the shape", shuttleDistanceAlongShape); - - // Calculate total shape length - var totalShapeLength = CalculateTotalShapeLength(shape.Points.ToArray()); - - if (context.StopLocation == null) - { - _logger.LogWarning("Stop location not available for shuttle stop {StopId}", context.StopId); - return; - } - - // Transform stop location to EPSG:25829 - var stopLocation = _shapeService.TransformToEpsg25829( - context.StopLocation.Latitude, - context.StopLocation.Longitude); - - // Find closest point on shape to this stop - var (stopPointIndex, _) = FindClosestPointOnShape(shape.Points.ToList(), stopLocation); - var stopDistanceAlongShape = CalculateTotalDistanceToPoint(shape.Points.ToArray(), stopPointIndex); - - // Calculate remaining distance from shuttle to stop - var remainingDistance = stopDistanceAlongShape - shuttleDistanceAlongShape; - - // Handle circular route wraparound (if shuttle is past the stop on the loop) - if (remainingDistance < 0) - { - remainingDistance += totalShapeLength; - } - - _logger.LogDebug("Remaining distance to stop: {Distance:F1}m", remainingDistance); - - // Calculate estimated minutes based on distance and reasonable shuttle speed - // Assume average urban shuttle speed of 20 km/h = 333 meters/minute - const double metersPerMinute = 333.0; - int estimatedMinutesForActive; - - if (remainingDistance < 50) // Within 50 meters - { - estimatedMinutesForActive = 0; - } - else - { - // Calculate time based on distance - var minutesFromDistance = remainingDistance / metersPerMinute; - estimatedMinutesForActive = (int)Math.Ceiling(minutesFromDistance); - } - - _logger.LogDebug("Calculated ETA: {Minutes} min for {Distance:F1}m", estimatedMinutesForActive, remainingDistance); - - // Find the active trip - should be one where: - // 1. Scheduled time is in the future (or very recent past, max -2 min for "arriving now" scenarios) - // 2. Scheduled time is reasonably close to our calculated ETA - var activeArrival = context.Arrivals - .Where(a => a.Estimate.Minutes >= -2) // Only consider upcoming or very recent arrivals - .Select(a => new - { - Arrival = a, - TimeDiff = Math.Abs(a.Estimate.Minutes - estimatedMinutesForActive) - }) - .Where(x => x.TimeDiff < 45) // Only consider if within 45 minutes difference from our estimate - .OrderBy(x => x.TimeDiff) - .FirstOrDefault()?.Arrival; - - // Fallback: if no good match, use the next upcoming arrival - if (activeArrival == null) - { - activeArrival = context.Arrivals - .Where(a => a.Estimate.Minutes >= 0) - .OrderBy(a => a.Estimate.Minutes) - .FirstOrDefault(); - - _logger.LogDebug("No matching arrival found, using next upcoming trip"); - } - - // If we found an active trip, update it with real-time data - if (activeArrival != null) - { - var scheduledMinutes = activeArrival.Estimate.Minutes; - activeArrival.Estimate.Minutes = estimatedMinutesForActive; - activeArrival.Estimate.Precision = ArrivalPrecision.Confident; - - // Calculate delay badge - var delayMinutes = estimatedMinutesForActive - scheduledMinutes; - if (delayMinutes != 0) - { - activeArrival.Delay = new DelayBadge { Minutes = delayMinutes }; - } - - // Set current position for visualization - var shuttleWgs84 = new Position - { - Latitude = status.Latitude, - Longitude = status.Longitude - }; - - // Calculate bearing from shuttle to next point on shape - if (closestPointIndex < shape.Points.Count - 1) - { - var currentPoint = shape.Points[closestPointIndex]; - var nextPoint = shape.Points[closestPointIndex + 1]; - var dx = nextPoint.X - currentPoint.X; - var dy = nextPoint.Y - currentPoint.Y; - var bearing = Math.Atan2(dx, dy) * 180.0 / Math.PI; - if (bearing < 0) bearing += 360.0; - shuttleWgs84.Bearing = (int)Math.Round(bearing); - } - - activeArrival.CurrentPosition = shuttleWgs84; - - _logger.LogInformation( - "Updated active trip {TripId}: {Minutes} min (was {Scheduled} min, delay: {Delay} min, distance: {Distance:F1}m)", - activeArrival.TripId, estimatedMinutesForActive, scheduledMinutes, delayMinutes, remainingDistance); - - _logger.LogInformation( - "Shuttle position set: Lat={Lat}, Lon={Lon}, Bearing={Bearing}°", - shuttleWgs84.Latitude, shuttleWgs84.Longitude, shuttleWgs84.Bearing); - } - else - { - _logger.LogWarning("Could not determine active trip for shuttle"); - } - - System.Diagnostics.Activity.Current?.SetTag("shuttle.active_trip_updated", activeArrival != null); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing shuttle real-time data for stop {StopId}", context.StopId); - // Don't throw - allow scheduled data to be returned - } - } - - /// - /// Finds the closest point on the shape to the given location and returns the index and distance - /// - private (int Index, double Distance) FindClosestPointOnShape(List shapePoints, Epsg25829 location) - { - var minDistance = double.MaxValue; - var closestIndex = 0; - - for (int i = 0; i < shapePoints.Count; i++) - { - var distance = CalculateDistance(shapePoints[i], location); - if (distance < minDistance) - { - minDistance = distance; - closestIndex = i; - } - } - - return (closestIndex, minDistance); - } - - /// - /// Calculates Euclidean distance between two points in meters - /// - private double CalculateDistance(Epsg25829 p1, Epsg25829 p2) - { - var dx = p1.X - p2.X; - var dy = p1.Y - p2.Y; - return Math.Sqrt(dx * dx + dy * dy); - } - - /// - /// Calculates the total distance along the shape from the start to a given index - /// - private double CalculateTotalDistanceToPoint(Epsg25829[] shapePoints, int endIndex) - { - if (endIndex <= 0 || shapePoints.Length == 0) - { - return 0; - } - - double totalDistance = 0; - for (int i = 1; i <= endIndex && i < shapePoints.Length; i++) - { - totalDistance += CalculateDistance(shapePoints[i - 1], shapePoints[i]); - } - - return totalDistance; - } - - /// - /// Calculates the total length of the entire shape - /// - private double CalculateTotalShapeLength(Epsg25829[] shapePoints) - { - if (shapePoints.Length <= 1) - { - return 0; - } - - double totalDistance = 0; - for (int i = 1; i < shapePoints.Length; i++) - { - totalDistance += CalculateDistance(shapePoints[i - 1], shapePoints[i]); - } - - return totalDistance; - } -} diff --git a/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs deleted file mode 100644 index 9fc46c7..0000000 --- a/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Enmarcha.Backend.Helpers; -using Enmarcha.Backend.Types.Arrivals; - -namespace Enmarcha.Backend.Services.Processors; - -public class FeedConfigProcessor : IArrivalsProcessor -{ - private readonly FeedService _feedService; - - public FeedConfigProcessor(FeedService feedService) - { - _feedService = feedService; - } - - public Task ProcessAsync(ArrivalsContext context) - { - var feedId = context.StopId.Split(':')[0]; - var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); - - foreach (var arrival in context.Arrivals) - { - arrival.Route.ShortName = _feedService.NormalizeRouteShortName(feedId, arrival.Route.ShortName); - arrival.Headsign.Destination = FeedService.NormalizeStopName(feedId, arrival.Headsign.Destination); - - // Apply Vitrasa-specific line formatting - if (feedId == "vitrasa") - { - FormatVitrasaLine(arrival); - } - - arrival.Shift = FeedService.GetShiftBadge(feedId, arrival.TripId); - - if (string.IsNullOrEmpty(arrival.Route.Colour) || arrival.Route.Colour == "FFFFFF") - { - arrival.Route.Colour = fallbackColor; - arrival.Route.TextColour = fallbackTextColor; - } - else if (string.IsNullOrEmpty(arrival.Route.TextColour) || arrival.Route.TextColour == "000000") - { - arrival.Route.TextColour = ContrastHelper.GetBestTextColour(arrival.Route.Colour); - } - } - - return Task.CompletedTask; - } - - private static void FormatVitrasaLine(Arrival arrival) - { - arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("*", ""); - - var destinationTrimmed = arrival.Headsign.Destination.TrimStart(); - - if (arrival.Headsign.Destination == "FORA DE SERVIZO.G.B.") - { - arrival.Headsign.Destination = "García Barbón, 7 (fora de servizo)"; - return; - } - - switch (arrival.Route.ShortName) - { - case "A" when destinationTrimmed.StartsWith("\"1\"", StringComparison.Ordinal) || - (destinationTrimmed.Length >= 1 && destinationTrimmed[0] == '1' && - (destinationTrimmed.Length == 1 || !char.IsDigit(destinationTrimmed[1]))): - arrival.Route.ShortName = "A1"; - // NormalizeStopName() removes quotes for Vitrasa, so handle both "\"1\"" and leading "1". - if (destinationTrimmed.StartsWith("\"1\"", StringComparison.Ordinal)) - { - destinationTrimmed = destinationTrimmed.Substring(3); - } - else - { - destinationTrimmed = destinationTrimmed.Substring(1); - } - - arrival.Headsign.Destination = destinationTrimmed.TrimStart(' ', '-', '.', ':'); - break; - case "6": - arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"", ""); - break; - case "FUT": - if (arrival.Headsign.Destination == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO") - { - arrival.Route.ShortName = "MAR"; - arrival.Headsign.Destination = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO"; - } - else if (arrival.Headsign.Destination == "P. ESPAÑA-T.VIGO-S.BADÍA") - { - arrival.Route.ShortName = "RIO"; - arrival.Headsign.Destination = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA"; - } - else if (arrival.Headsign.Destination == "NAVIA-BOUZAS-URZAIZ-G. ESPINO") - { - arrival.Route.ShortName = "GOL"; - arrival.Headsign.Destination = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO"; - } - arrival.Route.Colour = "6CACE4"; - arrival.Route.TextColour = "000000"; - break; - } - } -} diff --git a/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs b/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs index 1db215b..4c0b8ac 100644 --- a/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs @@ -1,4 +1,3 @@ -using System.Text; using Enmarcha.Sources.OpenTripPlannerGql.Queries; namespace Enmarcha.Backend.Services.Processors; diff --git a/src/Enmarcha.Backend/Services/Processors/Normalisation/ColourProcessor.cs b/src/Enmarcha.Backend/Services/Processors/Normalisation/ColourProcessor.cs new file mode 100644 index 0000000..ee4379b --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/Normalisation/ColourProcessor.cs @@ -0,0 +1,34 @@ +using Enmarcha.Backend.Helpers; + +namespace Enmarcha.Backend.Services.Processors.Normalisation; + +public class ColourProcessor : IArrivalsProcessor +{ + private readonly FeedService _feedService; + + public ColourProcessor(FeedService feedService) + { + _feedService = feedService; + } + + public Task ProcessAsync(ArrivalsContext context) + { + var feedId = context.StopId.Split(':')[0]; + var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); + + foreach (var arrival in context.Arrivals) + { + if (string.IsNullOrEmpty(arrival.Route.Colour) || arrival.Route.Colour == "FFFFFF") + { + arrival.Route.Colour = fallbackColor; + arrival.Route.TextColour = fallbackTextColor; + } + else if (string.IsNullOrEmpty(arrival.Route.TextColour) || arrival.Route.TextColour == "000000") + { + arrival.Route.TextColour = ContrastHelper.GetBestTextColour(arrival.Route.Colour); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/Normalisation/TranviasNormalizationProcessor.cs b/src/Enmarcha.Backend/Services/Processors/Normalisation/TranviasNormalizationProcessor.cs new file mode 100644 index 0000000..3d17a37 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/Normalisation/TranviasNormalizationProcessor.cs @@ -0,0 +1,17 @@ +namespace Enmarcha.Backend.Services.Processors.Normalisation; + +public class TranviasNormalizationProcessor : IArrivalsProcessor +{ + public Task ProcessAsync(ArrivalsContext context) + { + if (context.StopId.Split(':')[0] != "tranvias") + return Task.CompletedTask; + + foreach (var arrival in context.Arrivals) + { + arrival.Shift = FeedService.GetShiftBadge("tranvias", arrival.TripId); + } + + return Task.CompletedTask; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/Normalisation/VitrasaNormalizationProcessor.cs b/src/Enmarcha.Backend/Services/Processors/Normalisation/VitrasaNormalizationProcessor.cs new file mode 100644 index 0000000..2cca672 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/Normalisation/VitrasaNormalizationProcessor.cs @@ -0,0 +1,77 @@ +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services.Processors.Normalisation; + +public class VitrasaNormalizationProcessor : IArrivalsProcessor +{ + public Task ProcessAsync(ArrivalsContext context) + { + if (context.StopId.Split(':')[0] != "vitrasa") + return Task.CompletedTask; + + foreach (var arrival in context.Arrivals) + { + arrival.Headsign.Destination = FeedService.NormalizeStopName("vitrasa", arrival.Headsign.Destination); + FormatVitrasaLine(arrival); + arrival.Shift = FeedService.GetShiftBadge("vitrasa", arrival.TripId); + } + + return Task.CompletedTask; + } + + private static void FormatVitrasaLine(Arrival arrival) + { + arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("*", ""); + + var destinationTrimmed = arrival.Headsign.Destination.TrimStart(); + + if (arrival.Headsign.Destination == "FORA DE SERVIZO.G.B.") + { + arrival.Headsign.Destination = "García Barbón, 7 (fora de servizo)"; + return; + } + + switch (arrival.Route.ShortName) + { + case "A" when destinationTrimmed.StartsWith("\"1\"", StringComparison.Ordinal) || + (destinationTrimmed.Length >= 1 && destinationTrimmed[0] == '1' && + (destinationTrimmed.Length == 1 || !char.IsDigit(destinationTrimmed[1]))): + arrival.Route.ShortName = "A1"; + // NormalizeStopName() removes quotes for Vitrasa, so handle both "\"1\"" and leading "1". + if (destinationTrimmed.StartsWith("\"1\"", StringComparison.Ordinal)) + { + destinationTrimmed = destinationTrimmed.Substring(3); + } + else + { + destinationTrimmed = destinationTrimmed.Substring(1); + } + + arrival.Headsign.Destination = destinationTrimmed.TrimStart(' ', '-', '.', ':'); + break; + case "6": + arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"", ""); + break; + case "FUT": + if (arrival.Headsign.Destination == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO") + { + arrival.Route.ShortName = "MAR"; + arrival.Headsign.Destination = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO"; + } + else if (arrival.Headsign.Destination == "P. ESPAÑA-T.VIGO-S.BADÍA") + { + arrival.Route.ShortName = "RIO"; + arrival.Headsign.Destination = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA"; + } + else if (arrival.Headsign.Destination == "NAVIA-BOUZAS-URZAIZ-G. ESPINO") + { + arrival.Route.ShortName = "GOL"; + arrival.Headsign.Destination = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO"; + } + + arrival.Route.Colour = "6CACE4"; + arrival.Route.TextColour = "000000"; + break; + } + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/Normalisation/XuntaNormalizationProcessor.cs b/src/Enmarcha.Backend/Services/Processors/Normalisation/XuntaNormalizationProcessor.cs new file mode 100644 index 0000000..9b7e498 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/Normalisation/XuntaNormalizationProcessor.cs @@ -0,0 +1,24 @@ +namespace Enmarcha.Backend.Services.Processors.Normalisation; + +public class XuntaNormalizationProcessor : IArrivalsProcessor +{ + private readonly FeedService _feedService; + + public XuntaNormalizationProcessor(FeedService feedService) + { + _feedService = feedService; + } + + public Task ProcessAsync(ArrivalsContext context) + { + if (context.StopId.Split(':')[0] != "xunta") + return Task.CompletedTask; + + foreach (var arrival in context.Arrivals) + { + arrival.Route.ShortName = _feedService.NormalizeRouteShortName("xunta", arrival.Route.ShortName); + } + + return Task.CompletedTask; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/RealTime/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/CorunaRealTimeProcessor.cs new file mode 100644 index 0000000..20dc356 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/CorunaRealTimeProcessor.cs @@ -0,0 +1,231 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Sources.TranviasCoruna; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public class CorunaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly CorunaRealtimeEstimatesProvider _realtime; + private readonly FeedService _feedService; + private readonly ILogger _logger; + private readonly ShapeTraversalService _shapeService; + + public CorunaRealTimeProcessor( + CorunaRealtimeEstimatesProvider realtime, + FeedService feedService, + ILogger logger, + ShapeTraversalService shapeService) + { + _realtime = realtime; + _feedService = feedService; + _logger = logger; + _shapeService = shapeService; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("tranvias:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("tranvias", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + Epsg25829? stopLocation = null; + if (context.StopLocation != null) + { + stopLocation = + _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); + } + + var realtime = await _realtime.GetEstimatesForStop(numericStopId); + System.Diagnostics.Activity.Current?.SetTag("realtime.count", realtime.Count); + + var usedTripIds = new HashSet(); + + 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 => new + { + Arrival = a, + TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = true + }) + .Where(x => x.RouteMatch) // Strict route matching + .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) + { + continue; + } + + var arrival = bestMatch.Arrival; + + var scheduledMinutes = arrival.Estimate.Minutes; + arrival.Estimate.Minutes = estimate.Minutes; + arrival.Estimate.Precision = ArrivalPrecision.Confident; + + // Calculate delay badge + var delayMinutes = estimate.Minutes - scheduledMinutes; + if (delayMinutes != 0) + { + 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) + { + Position? currentPosition = null; + + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && + otpArrival.Trip.Geometry?.Points != null) + { + var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) + .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) + .ToList(); + + var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); + + // Ensure meters is positive + var meters = Math.Max(0, estimate.Metres); + var result = _shapeService.GetBusPosition(shape, stopLocation, meters); + + currentPosition = result.BusPosition; + + if (currentPosition != null) + { + _logger.LogInformation( + "Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, + currentPosition.Latitude, currentPosition.Longitude); + } + + // Populate Shape GeoJSON + if (!context.IsReduced && currentPosition != null) + { + var features = new List(); + features.Add(new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() + }, + properties = new { type = "route" } + }); + + // Add stops if available + if (otpArrival.Trip.Stoptimes != null) + { + foreach (var stoptime in otpArrival.Trip.Stoptimes) + { + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } + }, + properties = new + { + type = "stop", + name = stoptime.Stop.Name + } + }); + } + } + + arrival.Shape = new + { + type = "FeatureCollection", + features + }; + } + } + + if (currentPosition != null) + { + arrival.CurrentPosition = currentPosition; + } + } + + usedTripIds.Add(arrival.TripId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Tranvías real-time data for stop {StopId}", context.StopId); + } + } + + private static bool IsRouteMatch(string a, string b) + { + 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/RealTime/CtagShuttleRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/CtagShuttleRealTimeProcessor.cs new file mode 100644 index 0000000..804b09c --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/CtagShuttleRealTimeProcessor.cs @@ -0,0 +1,307 @@ +using Enmarcha.Sources.CtagShuttle; +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public class CtagShuttleRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly CtagShuttleRealtimeEstimatesProvider _shuttleProvider; + private readonly ShapeTraversalService _shapeService; + private readonly ILogger _logger; + + // Maximum distance (in meters) a GPS coordinate can be from the route shape to be considered valid + private const double MaxDistanceFromShape = 100.0; + + // Maximum age (in minutes) for position data to be considered fresh + private const double MaxPositionAgeMinutes = 3.0; + + public CtagShuttleRealTimeProcessor( + CtagShuttleRealtimeEstimatesProvider shuttleProvider, + ShapeTraversalService shapeService, + ILogger logger) + { + _shuttleProvider = shuttleProvider; + _shapeService = shapeService; + _logger = logger; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + // Only process shuttle stops + if (!context.StopId.StartsWith("shuttle:")) + { + return; + } + + try + { + // Fetch current shuttle status + var status = await _shuttleProvider.GetShuttleStatus(); + System.Diagnostics.Activity.Current?.SetTag("shuttle.status", status.StatusValue); + + // Validate position timestamp - skip if data is stale (>3 minutes old) + var positionAge = (context.NowLocal - status.LastPositionAt).TotalMinutes; + if (positionAge > MaxPositionAgeMinutes) + { + _logger.LogInformation( + "Shuttle position is stale ({Age:F1} minutes old), skipping real-time update", + positionAge); + return; + } + + // Skip processing if shuttle is idle + if (status.StatusValue.Equals("idle", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Shuttle is idle, skipping real-time update"); + return; + } + + // No arrivals to process + if (context.Arrivals.Count == 0) + { + _logger.LogWarning("No scheduled arrivals found for shuttle stop {StopId}", context.StopId); + return; + } + + // Transform shuttle GPS position to EPSG:25829 (meters) + var shuttlePosition = _shapeService.TransformToEpsg25829(status.Latitude, status.Longitude); + _logger.LogDebug("Shuttle position: Lat={Lat}, Lon={Lon} -> X={X}, Y={Y}", + status.Latitude, status.Longitude, shuttlePosition.X, shuttlePosition.Y); + + // Get the shape from the first arrival (assuming single circular route) + var firstArrival = context.Arrivals.First(); + if (firstArrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival || + otpArrival.Trip.Geometry?.Points == null) + { + _logger.LogWarning("No shape geometry available for shuttle trip"); + return; + } + + // Decode polyline and create shape + var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) + .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) + .ToList(); + var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); + + if (shape.Points.Count == 0) + { + _logger.LogWarning("Shape has no points"); + return; + } + + // Find closest point on shape to shuttle's current position + var (closestPointIndex, distanceToShape) = FindClosestPointOnShape(shape.Points.ToList(), shuttlePosition); + + // Validate that shuttle is reasonably close to the route + if (distanceToShape > MaxDistanceFromShape) + { + _logger.LogWarning( + "Shuttle position is {Distance:F1}m from route (threshold: {Threshold}m), skipping update", + distanceToShape, MaxDistanceFromShape); + return; + } + + // Calculate distance from shape start to shuttle's current position + var shuttleDistanceAlongShape = CalculateTotalDistanceToPoint(shape.Points.ToArray(), closestPointIndex); + _logger.LogDebug("Shuttle is {Distance:F1}m along the shape", shuttleDistanceAlongShape); + + // Calculate total shape length + var totalShapeLength = CalculateTotalShapeLength(shape.Points.ToArray()); + + if (context.StopLocation == null) + { + _logger.LogWarning("Stop location not available for shuttle stop {StopId}", context.StopId); + return; + } + + // Transform stop location to EPSG:25829 + var stopLocation = _shapeService.TransformToEpsg25829( + context.StopLocation.Latitude, + context.StopLocation.Longitude); + + // Find closest point on shape to this stop + var (stopPointIndex, _) = FindClosestPointOnShape(shape.Points.ToList(), stopLocation); + var stopDistanceAlongShape = CalculateTotalDistanceToPoint(shape.Points.ToArray(), stopPointIndex); + + // Calculate remaining distance from shuttle to stop + var remainingDistance = stopDistanceAlongShape - shuttleDistanceAlongShape; + + // Handle circular route wraparound (if shuttle is past the stop on the loop) + if (remainingDistance < 0) + { + remainingDistance += totalShapeLength; + } + + _logger.LogDebug("Remaining distance to stop: {Distance:F1}m", remainingDistance); + + // Calculate estimated minutes based on distance and reasonable shuttle speed + // Assume average urban shuttle speed of 20 km/h = 333 meters/minute + const double metersPerMinute = 333.0; + int estimatedMinutesForActive; + + if (remainingDistance < 50) // Within 50 meters + { + estimatedMinutesForActive = 0; + } + else + { + // Calculate time based on distance + var minutesFromDistance = remainingDistance / metersPerMinute; + estimatedMinutesForActive = (int)Math.Ceiling(minutesFromDistance); + } + + _logger.LogDebug("Calculated ETA: {Minutes} min for {Distance:F1}m", estimatedMinutesForActive, remainingDistance); + + // Find the active trip - should be one where: + // 1. Scheduled time is in the future (or very recent past, max -2 min for "arriving now" scenarios) + // 2. Scheduled time is reasonably close to our calculated ETA + var activeArrival = context.Arrivals + .Where(a => a.Estimate.Minutes >= -2) // Only consider upcoming or very recent arrivals + .Select(a => new + { + Arrival = a, + TimeDiff = Math.Abs(a.Estimate.Minutes - estimatedMinutesForActive) + }) + .Where(x => x.TimeDiff < 45) // Only consider if within 45 minutes difference from our estimate + .OrderBy(x => x.TimeDiff) + .FirstOrDefault()?.Arrival; + + // Fallback: if no good match, use the next upcoming arrival + if (activeArrival == null) + { + activeArrival = context.Arrivals + .Where(a => a.Estimate.Minutes >= 0) + .OrderBy(a => a.Estimate.Minutes) + .FirstOrDefault(); + + _logger.LogDebug("No matching arrival found, using next upcoming trip"); + } + + // If we found an active trip, update it with real-time data + if (activeArrival != null) + { + var scheduledMinutes = activeArrival.Estimate.Minutes; + activeArrival.Estimate.Minutes = estimatedMinutesForActive; + activeArrival.Estimate.Precision = ArrivalPrecision.Confident; + + // Calculate delay badge + var delayMinutes = estimatedMinutesForActive - scheduledMinutes; + if (delayMinutes != 0) + { + activeArrival.Delay = new DelayBadge { Minutes = delayMinutes }; + } + + // Set current position for visualization + var shuttleWgs84 = new Position + { + Latitude = status.Latitude, + Longitude = status.Longitude + }; + + // Calculate bearing from shuttle to next point on shape + if (closestPointIndex < shape.Points.Count - 1) + { + var currentPoint = shape.Points[closestPointIndex]; + var nextPoint = shape.Points[closestPointIndex + 1]; + var dx = nextPoint.X - currentPoint.X; + var dy = nextPoint.Y - currentPoint.Y; + var bearing = Math.Atan2(dx, dy) * 180.0 / Math.PI; + if (bearing < 0) bearing += 360.0; + shuttleWgs84.Bearing = (int)Math.Round(bearing); + } + + activeArrival.CurrentPosition = shuttleWgs84; + + _logger.LogInformation( + "Updated active trip {TripId}: {Minutes} min (was {Scheduled} min, delay: {Delay} min, distance: {Distance:F1}m)", + activeArrival.TripId, estimatedMinutesForActive, scheduledMinutes, delayMinutes, remainingDistance); + + _logger.LogInformation( + "Shuttle position set: Lat={Lat}, Lon={Lon}, Bearing={Bearing}°", + shuttleWgs84.Latitude, shuttleWgs84.Longitude, shuttleWgs84.Bearing); + } + else + { + _logger.LogWarning("Could not determine active trip for shuttle"); + } + + System.Diagnostics.Activity.Current?.SetTag("shuttle.active_trip_updated", activeArrival != null); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing shuttle real-time data for stop {StopId}", context.StopId); + // Don't throw - allow scheduled data to be returned + } + } + + /// + /// Finds the closest point on the shape to the given location and returns the index and distance + /// + private (int Index, double Distance) FindClosestPointOnShape(List shapePoints, Epsg25829 location) + { + var minDistance = double.MaxValue; + var closestIndex = 0; + + for (int i = 0; i < shapePoints.Count; i++) + { + var distance = CalculateDistance(shapePoints[i], location); + if (distance < minDistance) + { + minDistance = distance; + closestIndex = i; + } + } + + return (closestIndex, minDistance); + } + + /// + /// Calculates Euclidean distance between two points in meters + /// + private double CalculateDistance(Epsg25829 p1, Epsg25829 p2) + { + var dx = p1.X - p2.X; + var dy = p1.Y - p2.Y; + return Math.Sqrt(dx * dx + dy * dy); + } + + /// + /// Calculates the total distance along the shape from the start to a given index + /// + private double CalculateTotalDistanceToPoint(Epsg25829[] shapePoints, int endIndex) + { + if (endIndex <= 0 || shapePoints.Length == 0) + { + return 0; + } + + double totalDistance = 0; + for (int i = 1; i <= endIndex && i < shapePoints.Length; i++) + { + totalDistance += CalculateDistance(shapePoints[i - 1], shapePoints[i]); + } + + return totalDistance; + } + + /// + /// Calculates the total length of the entire shape + /// + private double CalculateTotalShapeLength(Epsg25829[] shapePoints) + { + if (shapePoints.Length <= 1) + { + return 0; + } + + double totalDistance = 0; + for (int i = 1; i < shapePoints.Length; i++) + { + totalDistance += CalculateDistance(shapePoints[i - 1], shapePoints[i]); + } + + return totalDistance; + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/RealTime/RenfeRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/RenfeRealTimeProcessor.cs new file mode 100644 index 0000000..26ffa24 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/RenfeRealTimeProcessor.cs @@ -0,0 +1,84 @@ +using System.Text.RegularExpressions; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Enmarcha.Sources.GtfsRealtime; +using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public partial class RenfeRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly GtfsRealtimeEstimatesProvider _realtime; + private readonly ILogger _logger; + + public RenfeRealTimeProcessor( + GtfsRealtimeEstimatesProvider realtime, + ILogger logger + ) + { + _realtime = realtime; + _logger = logger; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("renfe:")) return; + + try + { + var delays = await _realtime.GetRenfeDelays(); + var positions = await _realtime.GetRenfePositions(); + System.Diagnostics.Activity.Current?.SetTag("realtime.count", delays.Count); + + foreach (Arrival contextArrival in context.Arrivals) + { + var trainNumber = RenfeTrainNumberExpression.Match(contextArrival.TripId).Groups[1].Value; + + contextArrival.Headsign.Destination = trainNumber + " - " + contextArrival.Headsign.Destination; + + if (delays.TryGetValue(trainNumber, out var delay)) + { + if (delay is null) + { + // TODO: Indicate train got cancelled + _logger.LogDebug("Train {TrainNumber} has no delay information, skipping", trainNumber); + continue; + } + + var delayMinutes = delay.Value / 60; + contextArrival.Delay = new DelayBadge() + { + Minutes = delayMinutes + }; + + var oldEstimate = contextArrival.Estimate.Minutes; + contextArrival.Estimate.Minutes += delayMinutes; + contextArrival.Estimate.Precision = ArrivalPrecision.Confident; + + if (contextArrival.Estimate.Minutes < 0) + { + _logger.LogDebug("Train {TrainNumber} supposedly departed already ({OldEstimate} + {DelayMinutes} minutes), marking as deleted. ", trainNumber, oldEstimate, delayMinutes); + contextArrival.Delete = true; + } + } + + if (positions.TryGetValue(trainNumber, out var position)) + { + contextArrival.CurrentPosition = new Position + { + Latitude = position.Latitude, + Longitude = position.Longitude, + Bearing = null // TODO: Set the proper degrees + }; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Renfe real-time data"); + } + } + + [GeneratedRegex(@"renfe:(?:\d{4}[A-Z]|)(\d{5})")] + public partial Regex RenfeTrainNumberExpression { get; } +} diff --git a/src/Enmarcha.Backend/Services/Processors/RealTime/TussaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/TussaRealTimeProcessor.cs new file mode 100644 index 0000000..2cfdf28 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/TussaRealTimeProcessor.cs @@ -0,0 +1,94 @@ +using Enmarcha.Backend.Helpers; +using Enmarcha.Backend.Types.Arrivals; +using Enmarcha.Sources.Tussa; +using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public class TussaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly SantiagoRealtimeEstimatesProvider _realtime; + private readonly FeedService _feedService; + private readonly ILogger _logger; + + public TussaRealTimeProcessor( + SantiagoRealtimeEstimatesProvider realtime, + FeedService feedService, + ILogger logger) + { + _realtime = realtime; + _feedService = feedService; + _logger = logger; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("tussa:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("tussa", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + var realtime = await _realtime.GetEstimatesForStop(numericStopId); + System.Diagnostics.Activity.Current?.SetTag("realtime.count", realtime.Count); + + var usedTripIds = new HashSet(); + + foreach (var estimate in realtime) + { + var bestMatch = context.Arrivals + .Where(a => !usedTripIds.Contains(a.TripId)) + .Where(a => a.Route.RouteIdInGtfs.Trim() == estimate.Id.ToString()) + .Select(a => new + { + Arrival = a, + TimeDiff = estimate.MinutesToArrive - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = true + }) + .Where(x => x.RouteMatch) // Strict route matching + .Where(x => x.TimeDiff is >= -5 and <= 35) // Allow 2m early (RealTime < Schedule) or 25m late (RealTime > Schedule) + .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit + .FirstOrDefault(); + + if (bestMatch is null) + { + context.Arrivals.Add(new Arrival + { + TripId = $"tussa:rt:{estimate.Id}:{estimate.MinutesToArrive}", + Route = new RouteInfo + { + GtfsId = $"tussa:{estimate.Id}", + ShortName = estimate.Sinoptico, + Colour = estimate.Colour, + TextColour = ContrastHelper.GetBestTextColour(estimate.Colour) + }, + Headsign = new HeadsignInfo + { + Badge = "T.REAL", + Destination = estimate.Name + }, + Estimate = new ArrivalDetails + { + Minutes = estimate.MinutesToArrive, + Precision = ArrivalPrecision.Confident + } + }); + continue; + } + + var arrival = bestMatch.Arrival; + + arrival.Estimate.Minutes = estimate.MinutesToArrive; + arrival.Estimate.Precision = ArrivalPrecision.Confident; + + usedTripIds.Add(arrival.TripId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Santiago real-time data for stop {StopId}", context.StopId); + } + } + +} diff --git a/src/Enmarcha.Backend/Services/Processors/RealTime/VitrasaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RealTime/VitrasaRealTimeProcessor.cs new file mode 100644 index 0000000..0227f9c --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/RealTime/VitrasaRealTimeProcessor.cs @@ -0,0 +1,295 @@ +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Costasdev.VigoTransitApi; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services.Processors.RealTime; + +public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly VigoTransitApiClient _api; + private readonly FeedService _feedService; + private readonly ILogger _logger; + private readonly ShapeTraversalService _shapeService; + private readonly AppConfiguration _configuration; + + public VitrasaRealTimeProcessor( + VigoTransitApiClient api, + FeedService feedService, + ILogger logger, + ShapeTraversalService shapeService, + IOptions options) + { + _api = api; + _feedService = feedService; + _logger = logger; + _shapeService = shapeService; + _configuration = options.Value; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("vitrasa:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("vitrasa", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + // Load schedule + var todayDate = context.NowLocal.Date.ToString("yyyy-MM-dd"); + + Epsg25829? stopLocation = null; + if (context.StopLocation != null) + { + stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); + } + + var realtime = await _api.GetStopEstimates(numericStopId); + var estimates = realtime.Estimates + .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*')) + .ToList(); + + System.Diagnostics.Activity.Current?.SetTag("realtime.count", estimates.Count); + + var usedTripIds = new HashSet(); + var newArrivals = new List(); + + foreach (var estimate in estimates) + { + var estimateRouteNormalized = _feedService.NormalizeRouteNameForMatching(estimate.Route); + + var bestMatch = context.Arrivals + .Where(a => !usedTripIds.Contains(a.TripId)) + .Where(a => a.Route.ShortName.Trim() == estimate.Line.Trim()) + .Select(a => + { + // Use tripHeadsign from GTFS if available, otherwise fall back to stop-level headsign + string scheduleHeadsign = a.Headsign.Destination; + if (a.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArr && !string.IsNullOrWhiteSpace(otpArr.Trip.TripHeadsign)) + { + scheduleHeadsign = otpArr.Trip.TripHeadsign; + } + var arrivalRouteNormalized = _feedService.NormalizeRouteNameForMatching(scheduleHeadsign); + string? arrivalLongNameNormalized = null; + string? arrivalLastStopNormalized = null; + + if (a.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival) + { + if (otpArrival.Trip.Route.LongName != null) + { + arrivalLongNameNormalized = _feedService.NormalizeRouteNameForMatching(otpArrival.Trip.Route.LongName); + } + + var lastStop = otpArrival.Trip.Stoptimes.LastOrDefault(); + if (lastStop != null) + { + arrivalLastStopNormalized = _feedService.NormalizeRouteNameForMatching(lastStop.Stop.Name); + } + } + + // Strict route matching logic ported from VitrasaTransitProvider + // Check against Headsign, LongName, and LastStop + var routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalRouteNormalized); + + if (!routeMatch && arrivalLongNameNormalized != null) + { + routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLongNameNormalized); + } + + if (!routeMatch && arrivalLastStopNormalized != null) + { + routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLastStopNormalized); + } + + return new + { + Arrival = a, + TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = routeMatch + }; + }) + .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 + .FirstOrDefault(); + + if (bestMatch is null) + { + _logger.LogInformation("Adding unmatched Vitrasa real-time arrival for line {Line} in {Minutes}m", + estimate.Line, estimate.Minutes); + + // Try to find a "template" arrival with the same line to copy colors from + var template = context.Arrivals + .FirstOrDefault(a => a.Route.ShortName.Trim() == estimate.Line.Trim()); + + newArrivals.Add(new Arrival + { + TripId = $"vitrasa:rtonly:{estimate.Line}:{estimate.Route}:{estimate.Minutes}", + Route = new RouteInfo + { + GtfsId = $"vitrasa:{estimate.Line}", + ShortName = estimate.Line, + Colour = template?.Route.Colour ?? "FFFFFF", + TextColour = template?.Route.TextColour ?? "000000", + }, + Headsign = new HeadsignInfo + { + Destination = estimate.Route + }, + Estimate = new ArrivalDetails + { + Minutes = estimate.Minutes, + Precision = ArrivalPrecision.Confident + } + }); + + continue; + } + + var arrival = bestMatch.Arrival; + + var scheduledMinutes = arrival.Estimate.Minutes; + arrival.Estimate.Minutes = estimate.Minutes; + + // Calculate delay badge + var delayMinutes = estimate.Minutes - scheduledMinutes; + arrival.Delay = new DelayBadge { Minutes = delayMinutes }; + + string scheduledHeadsign = arrival.Headsign.Destination; + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArr && !string.IsNullOrWhiteSpace(otpArr.Trip.TripHeadsign)) + { + scheduledHeadsign = otpArr.Trip.TripHeadsign; + } + + // Prefer real-time headsign UNLESS it's just the last stop name (which is less informative) + if (!string.IsNullOrWhiteSpace(estimate.Route)) + { + bool isJustLastStop = false; + + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival) + { + var lastStop = otpArrival.Trip.Stoptimes.LastOrDefault(); + if (lastStop != null) + { + var arrivalLastStopNormalized = _feedService.NormalizeRouteNameForMatching(lastStop.Stop.Name); + isJustLastStop = estimateRouteNormalized == arrivalLastStopNormalized; + } + } + + // Use real-time headsign unless it's just the final stop name + if (!isJustLastStop) + { + arrival.Headsign.Destination = estimate.Route; + } + } + + // Calculate position + if (stopLocation != null) + { + Position? currentPosition = null; + + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && + otpArrival.Trip.Geometry?.Points != null) + { + var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) + .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) + .ToList(); + + var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); + + // Ensure meters is positive + var meters = Math.Max(0, estimate.Meters); + var result = _shapeService.GetBusPosition(shape, stopLocation, meters); + + currentPosition = result.BusPosition; + + // Populate Shape GeoJSON + if (!context.IsReduced && currentPosition != null) + { + List features = [ + new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() + }, + properties = new { type = "route" } + } + ]; + + // Add stops if available + if (otpArrival.Trip.Stoptimes != null) + { + foreach (var stoptime in otpArrival.Trip.Stoptimes) + { + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } + }, + properties = new + { + type = "stop", + name = stoptime.Stop.Name + } + }); + } + } + + arrival.Shape = new + { + type = "FeatureCollection", + features + }; + } + } + + if (currentPosition != null) + { + arrival.CurrentPosition = currentPosition; + arrival.Estimate.Precision = ArrivalPrecision.Confident; + } + else if (!context.IsNano && !context.IsReduced) // Full mode, no position means actually no position + { + // If we can't calculate a position, degrade precision to "Unsure" to indicate less confidence + arrival.Estimate.Precision = ArrivalPrecision.Unsure; + } + else + { + // In Nano/Reduced mode we don't have shape data, so we can't calculate position. Don't degrade precision since it's expected. + arrival.Estimate.Precision = ArrivalPrecision.Confident; + } + } + + 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); + } + + foreach (var arr in context.Arrivals) + { + if (arr.Estimate.Minutes < 1 && arr.Estimate.Precision == ArrivalPrecision.Scheduled) + { + arr.Delete = true; // Remove arrivals that are scheduled right now, since they are likely already departed + } + } + } + + private static bool IsRouteMatch(string a, string b) + { + return a == b || a.Contains(b) || b.Contains(a); + } +} diff --git a/src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs deleted file mode 100644 index 5e5468e..0000000 --- a/src/Enmarcha.Backend/Services/Processors/RenfeRealTimeProcessor.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Text.RegularExpressions; -using Enmarcha.Backend.Types; -using Enmarcha.Backend.Types.Arrivals; -using Enmarcha.Sources.GtfsRealtime; -using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; - -namespace Enmarcha.Backend.Services.Processors; - -public partial class RenfeRealTimeProcessor : AbstractRealTimeProcessor -{ - private readonly GtfsRealtimeEstimatesProvider _realtime; - private readonly ILogger _logger; - - public RenfeRealTimeProcessor( - GtfsRealtimeEstimatesProvider realtime, - ILogger logger - ) - { - _realtime = realtime; - _logger = logger; - } - - public override async Task ProcessAsync(ArrivalsContext context) - { - if (!context.StopId.StartsWith("renfe:")) return; - - try - { - var delays = await _realtime.GetRenfeDelays(); - var positions = await _realtime.GetRenfePositions(); - System.Diagnostics.Activity.Current?.SetTag("realtime.count", delays.Count); - - foreach (Arrival contextArrival in context.Arrivals) - { - var trainNumber = RenfeTrainNumberExpression.Match(contextArrival.TripId).Groups[1].Value; - - contextArrival.Headsign.Destination = trainNumber + " - " + contextArrival.Headsign.Destination; - - if (delays.TryGetValue(trainNumber, out var delay)) - { - if (delay is null) - { - // TODO: Indicate train got cancelled - _logger.LogDebug("Train {TrainNumber} has no delay information, skipping", trainNumber); - continue; - } - - var delayMinutes = delay.Value / 60; - contextArrival.Delay = new DelayBadge() - { - Minutes = delayMinutes - }; - - var oldEstimate = contextArrival.Estimate.Minutes; - contextArrival.Estimate.Minutes += delayMinutes; - contextArrival.Estimate.Precision = ArrivalPrecision.Confident; - - if (contextArrival.Estimate.Minutes < 0) - { - _logger.LogDebug("Train {TrainNumber} supposedly departed already ({OldEstimate} + {DelayMinutes} minutes), marking as deleted. ", trainNumber, oldEstimate, delayMinutes); - contextArrival.Delete = true; - } - } - - if (positions.TryGetValue(trainNumber, out var position)) - { - contextArrival.CurrentPosition = new Position - { - Latitude = position.Latitude, - Longitude = position.Longitude, - Bearing = null // TODO: Set the proper degrees - }; - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching Renfe real-time data"); - } - } - - [GeneratedRegex(@"renfe:(?:\d{4}[A-Z]|)(\d{5})")] - public partial Regex RenfeTrainNumberExpression { get; } -} diff --git a/src/Enmarcha.Backend/Services/Processors/TussaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/TussaRealTimeProcessor.cs deleted file mode 100644 index 7808a3f..0000000 --- a/src/Enmarcha.Backend/Services/Processors/TussaRealTimeProcessor.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Enmarcha.Backend.Helpers; -using Enmarcha.Backend.Types.Arrivals; -using Enmarcha.Sources.Tussa; -using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival; - -namespace Enmarcha.Backend.Services.Processors; - -public class TussaRealTimeProcessor : AbstractRealTimeProcessor -{ - private readonly SantiagoRealtimeEstimatesProvider _realtime; - private readonly FeedService _feedService; - private readonly ILogger _logger; - - public TussaRealTimeProcessor( - SantiagoRealtimeEstimatesProvider realtime, - FeedService feedService, - ILogger logger) - { - _realtime = realtime; - _feedService = feedService; - _logger = logger; - } - - public override async Task ProcessAsync(ArrivalsContext context) - { - if (!context.StopId.StartsWith("tussa:")) return; - - var normalizedCode = _feedService.NormalizeStopCode("tussa", context.StopCode); - if (!int.TryParse(normalizedCode, out var numericStopId)) return; - - try - { - var realtime = await _realtime.GetEstimatesForStop(numericStopId); - System.Diagnostics.Activity.Current?.SetTag("realtime.count", realtime.Count); - - var usedTripIds = new HashSet(); - - foreach (var estimate in realtime) - { - var bestMatch = context.Arrivals - .Where(a => !usedTripIds.Contains(a.TripId)) - .Where(a => a.Route.RouteIdInGtfs.Trim() == estimate.Id.ToString()) - .Select(a => new - { - Arrival = a, - TimeDiff = estimate.MinutesToArrive - a.Estimate.Minutes, // RealTime - Schedule - RouteMatch = true - }) - .Where(x => x.RouteMatch) // Strict route matching - .Where(x => x.TimeDiff is >= -5 and <= 35) // Allow 2m early (RealTime < Schedule) or 25m late (RealTime > Schedule) - .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit - .FirstOrDefault(); - - if (bestMatch is null) - { - context.Arrivals.Add(new Arrival - { - TripId = $"tussa:rt:{estimate.Id}:{estimate.MinutesToArrive}", - Route = new RouteInfo - { - GtfsId = $"tussa:{estimate.Id}", - ShortName = estimate.Sinoptico, - Colour = estimate.Colour, - TextColour = ContrastHelper.GetBestTextColour(estimate.Colour) - }, - Headsign = new HeadsignInfo - { - Badge = "T.REAL", - Destination = estimate.Name - }, - Estimate = new ArrivalDetails - { - Minutes = estimate.MinutesToArrive, - Precision = ArrivalPrecision.Confident - } - }); - continue; - } - - var arrival = bestMatch.Arrival; - - arrival.Estimate.Minutes = estimate.MinutesToArrive; - arrival.Estimate.Precision = ArrivalPrecision.Confident; - - usedTripIds.Add(arrival.TripId); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching Santiago real-time data for stop {StopId}", context.StopId); - } - } - -} diff --git a/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs deleted file mode 100644 index 5bbbfab..0000000 --- a/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs +++ /dev/null @@ -1,295 +0,0 @@ -using Enmarcha.Sources.OpenTripPlannerGql.Queries; -using Costasdev.VigoTransitApi; -using Enmarcha.Backend.Configuration; -using Enmarcha.Backend.Types; -using Enmarcha.Backend.Types.Arrivals; -using Microsoft.Extensions.Options; - -namespace Enmarcha.Backend.Services.Processors; - -public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor -{ - private readonly VigoTransitApiClient _api; - private readonly FeedService _feedService; - private readonly ILogger _logger; - private readonly ShapeTraversalService _shapeService; - private readonly AppConfiguration _configuration; - - public VitrasaRealTimeProcessor( - VigoTransitApiClient api, - FeedService feedService, - ILogger logger, - ShapeTraversalService shapeService, - IOptions options) - { - _api = api; - _feedService = feedService; - _logger = logger; - _shapeService = shapeService; - _configuration = options.Value; - } - - public override async Task ProcessAsync(ArrivalsContext context) - { - if (!context.StopId.StartsWith("vitrasa:")) return; - - var normalizedCode = _feedService.NormalizeStopCode("vitrasa", context.StopCode); - if (!int.TryParse(normalizedCode, out var numericStopId)) return; - - try - { - // Load schedule - var todayDate = context.NowLocal.Date.ToString("yyyy-MM-dd"); - - Epsg25829? stopLocation = null; - if (context.StopLocation != null) - { - stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); - } - - var realtime = await _api.GetStopEstimates(numericStopId); - var estimates = realtime.Estimates - .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*')) - .ToList(); - - System.Diagnostics.Activity.Current?.SetTag("realtime.count", estimates.Count); - - var usedTripIds = new HashSet(); - var newArrivals = new List(); - - foreach (var estimate in estimates) - { - var estimateRouteNormalized = _feedService.NormalizeRouteNameForMatching(estimate.Route); - - var bestMatch = context.Arrivals - .Where(a => !usedTripIds.Contains(a.TripId)) - .Where(a => a.Route.ShortName.Trim() == estimate.Line.Trim()) - .Select(a => - { - // Use tripHeadsign from GTFS if available, otherwise fall back to stop-level headsign - string scheduleHeadsign = a.Headsign.Destination; - if (a.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArr && !string.IsNullOrWhiteSpace(otpArr.Trip.TripHeadsign)) - { - scheduleHeadsign = otpArr.Trip.TripHeadsign; - } - var arrivalRouteNormalized = _feedService.NormalizeRouteNameForMatching(scheduleHeadsign); - string? arrivalLongNameNormalized = null; - string? arrivalLastStopNormalized = null; - - if (a.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival) - { - if (otpArrival.Trip.Route.LongName != null) - { - arrivalLongNameNormalized = _feedService.NormalizeRouteNameForMatching(otpArrival.Trip.Route.LongName); - } - - var lastStop = otpArrival.Trip.Stoptimes.LastOrDefault(); - if (lastStop != null) - { - arrivalLastStopNormalized = _feedService.NormalizeRouteNameForMatching(lastStop.Stop.Name); - } - } - - // Strict route matching logic ported from VitrasaTransitProvider - // Check against Headsign, LongName, and LastStop - var routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalRouteNormalized); - - if (!routeMatch && arrivalLongNameNormalized != null) - { - routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLongNameNormalized); - } - - if (!routeMatch && arrivalLastStopNormalized != null) - { - routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLastStopNormalized); - } - - return new - { - Arrival = a, - TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule - RouteMatch = routeMatch - }; - }) - .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 - .FirstOrDefault(); - - if (bestMatch is null) - { - _logger.LogInformation("Adding unmatched Vitrasa real-time arrival for line {Line} in {Minutes}m", - estimate.Line, estimate.Minutes); - - // Try to find a "template" arrival with the same line to copy colors from - var template = context.Arrivals - .FirstOrDefault(a => a.Route.ShortName.Trim() == estimate.Line.Trim()); - - newArrivals.Add(new Arrival - { - TripId = $"vitrasa:rtonly:{estimate.Line}:{estimate.Route}:{estimate.Minutes}", - Route = new RouteInfo - { - GtfsId = $"vitrasa:{estimate.Line}", - ShortName = estimate.Line, - Colour = template?.Route.Colour ?? "FFFFFF", - TextColour = template?.Route.TextColour ?? "000000", - }, - Headsign = new HeadsignInfo - { - Destination = estimate.Route - }, - Estimate = new ArrivalDetails - { - Minutes = estimate.Minutes, - Precision = ArrivalPrecision.Confident - } - }); - - continue; - } - - var arrival = bestMatch.Arrival; - - var scheduledMinutes = arrival.Estimate.Minutes; - arrival.Estimate.Minutes = estimate.Minutes; - - // Calculate delay badge - var delayMinutes = estimate.Minutes - scheduledMinutes; - arrival.Delay = new DelayBadge { Minutes = delayMinutes }; - - string scheduledHeadsign = arrival.Headsign.Destination; - if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArr && !string.IsNullOrWhiteSpace(otpArr.Trip.TripHeadsign)) - { - scheduledHeadsign = otpArr.Trip.TripHeadsign; - } - - // Prefer real-time headsign UNLESS it's just the last stop name (which is less informative) - if (!string.IsNullOrWhiteSpace(estimate.Route)) - { - bool isJustLastStop = false; - - if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival) - { - var lastStop = otpArrival.Trip.Stoptimes.LastOrDefault(); - if (lastStop != null) - { - var arrivalLastStopNormalized = _feedService.NormalizeRouteNameForMatching(lastStop.Stop.Name); - isJustLastStop = estimateRouteNormalized == arrivalLastStopNormalized; - } - } - - // Use real-time headsign unless it's just the final stop name - if (!isJustLastStop) - { - arrival.Headsign.Destination = estimate.Route; - } - } - - // Calculate position - if (stopLocation != null) - { - Position? currentPosition = null; - - if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && - otpArrival.Trip.Geometry?.Points != null) - { - var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) - .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) - .ToList(); - - var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); - - // Ensure meters is positive - var meters = Math.Max(0, estimate.Meters); - var result = _shapeService.GetBusPosition(shape, stopLocation, meters); - - currentPosition = result.BusPosition; - - // Populate Shape GeoJSON - if (!context.IsReduced && currentPosition != null) - { - List features = [ - new - { - type = "Feature", - geometry = new - { - type = "LineString", - coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() - }, - properties = new { type = "route" } - } - ]; - - // Add stops if available - if (otpArrival.Trip.Stoptimes != null) - { - foreach (var stoptime in otpArrival.Trip.Stoptimes) - { - features.Add(new - { - type = "Feature", - geometry = new - { - type = "Point", - coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } - }, - properties = new - { - type = "stop", - name = stoptime.Stop.Name - } - }); - } - } - - arrival.Shape = new - { - type = "FeatureCollection", - features - }; - } - } - - if (currentPosition != null) - { - arrival.CurrentPosition = currentPosition; - arrival.Estimate.Precision = ArrivalPrecision.Confident; - } - else if (!context.IsNano && !context.IsReduced) // Full mode, no position means actually no position - { - // If we can't calculate a position, degrade precision to "Unsure" to indicate less confidence - arrival.Estimate.Precision = ArrivalPrecision.Unsure; - } - else - { - // In Nano/Reduced mode we don't have shape data, so we can't calculate position. Don't degrade precision since it's expected. - arrival.Estimate.Precision = ArrivalPrecision.Confident; - } - } - - 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); - } - - foreach (var arr in context.Arrivals) - { - if (arr.Estimate.Minutes < 1 && arr.Estimate.Precision == ArrivalPrecision.Scheduled) - { - arr.Delete = true; // Remove arrivals that are scheduled right now, since they are likely already departed - } - } - } - - private static bool IsRouteMatch(string a, string b) - { - return a == b || a.Contains(b) || b.Contains(a); - } -} diff --git a/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs b/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs index 6514a5a..d27fc8d 100644 --- a/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs +++ b/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; using Enmarcha.Backend.Data.Models; namespace Enmarcha.Backend.ViewModels; diff --git a/src/Enmarcha.Sources.TranviasCoruna/Response.cs b/src/Enmarcha.Sources.TranviasCoruna/Response.cs index 88dc4e2..d94d32d 100644 --- a/src/Enmarcha.Sources.TranviasCoruna/Response.cs +++ b/src/Enmarcha.Sources.TranviasCoruna/Response.cs @@ -4,7 +4,7 @@ namespace Enmarcha.Sources.TranviasCoruna; public class QueryitrResponse { - [JsonPropertyName("buses")] public ArrivalInfo ArrivalInfo { get; set; } + [JsonPropertyName("buses")] public required ArrivalInfo ArrivalInfo { get; set; } } public class ArrivalInfo @@ -12,7 +12,7 @@ public class ArrivalInfo [JsonPropertyName("parada")] public int StopId { get; set; } [JsonPropertyName("lineas")] - public Route[] Routes { get; set; } + public required Route[] Routes { get; set; } } public class Route @@ -20,7 +20,7 @@ public class Route [JsonPropertyName("linea")] public int RouteId { get; set; } [JsonPropertyName("buses")] - public Arrival[] Arrivals { get; set; } + public required Arrival[] Arrivals { get; set; } } public class Arrival @@ -28,7 +28,7 @@ public class Arrival [JsonPropertyName("bus")] public int VehicleNumber { get; set; } [JsonPropertyName("tiempo")] - public string Minutes { get; set; } + public required string Minutes { get; set; } [JsonPropertyName("distancia")] - public string Metres { get; set; } + public required string Metres { get; set; } } diff --git a/src/Enmarcha.Sources.Tussa/Response.cs b/src/Enmarcha.Sources.Tussa/Response.cs index cbb1573..31cb698 100644 --- a/src/Enmarcha.Sources.Tussa/Response.cs +++ b/src/Enmarcha.Sources.Tussa/Response.cs @@ -5,10 +5,10 @@ namespace Enmarcha.Sources.Tussa; public class MaisbusResponse { [JsonPropertyName("id")] public int Id { get; set; } - [JsonPropertyName("codigo")] public string Code { get; set; } - [JsonPropertyName("nombre")] public string Name { get; set; } - [JsonPropertyName("coordenadas")] public Coordinates Coordinates { get; set; } - [JsonPropertyName("lineas")] public Route[] Routes { get; set; } + [JsonPropertyName("codigo")] public required string Code { get; set; } + [JsonPropertyName("nombre")] public required string Name { get; set; } + [JsonPropertyName("coordenadas")] public required Coordinates Coordinates { get; set; } + [JsonPropertyName("lineas")] public required Route[] Routes { get; set; } } public class Coordinates @@ -22,13 +22,13 @@ public class Coordinates public class Route { [JsonPropertyName("id")] public int Id { get; set; } - [JsonPropertyName("sinoptico")] public string Sinoptico { get; set; } - [JsonPropertyName("nombre")] public string Name { get; set; } - [JsonPropertyName("estilo")] public string Colour { get; set; } + [JsonPropertyName("sinoptico")] public required string Sinoptico { get; set; } + [JsonPropertyName("nombre")] public required string Name { get; set; } + [JsonPropertyName("estilo")] public required string Colour { get; set; } /// /// 2025-12-28 23:57 /// - [JsonPropertyName("proximoPaso")] public string NextArrival { get; set; } + [JsonPropertyName("proximoPaso")] public required string NextArrival { get; set; } [JsonPropertyName("minutosProximoPaso")] public int MinutesToArrive { get; set; } } diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index fe90642..64a9e94 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -173,10 +173,7 @@ export const ConsolidatedCirculationSchema = z.object({ }) .optional() .nullable(), - currentPosition: z - .object(PositionSchema) - .optional() - .nullable(), + currentPosition: z.object(PositionSchema).optional().nullable(), isPreviousTrip: z.boolean().optional().nullable(), previousTripShapeId: z.string().optional().nullable(), nextStreets: z.array(z.string()).optional().nullable(), diff --git a/src/frontend/app/components/arrivals/ArrivalList.tsx b/src/frontend/app/components/arrivals/ArrivalList.tsx index 18885c8..8fdef06 100644 --- a/src/frontend/app/components/arrivals/ArrivalList.tsx +++ b/src/frontend/app/components/arrivals/ArrivalList.tsx @@ -26,7 +26,10 @@ export const ArrivalList: React.FC = ({
{arrivals.length === 0 && (
- {t("estimates.none", "No hay llegadas próximas disponibles para esta parada.")} + {t( + "estimates.none", + "No hay llegadas próximas disponibles para esta parada." + )}
)} {arrivals.map((arrival, index) => diff --git a/src/frontend/app/contexts/JourneyContext.tsx b/src/frontend/app/contexts/JourneyContext.tsx index f513aa8..9c3c3ac 100644 --- a/src/frontend/app/contexts/JourneyContext.tsx +++ b/src/frontend/app/contexts/JourneyContext.tsx @@ -25,9 +25,7 @@ export interface ActiveJourney { interface JourneyContextValue { activeJourney: ActiveJourney | null; - startJourney: ( - journey: Omit - ) => void; + startJourney: (journey: Omit) => void; stopJourney: () => void; markNotified: () => void; } @@ -61,9 +59,7 @@ export function JourneyProvider({ children }: { children: ReactNode }) { }, []); const markNotified = useCallback(() => { - setActiveJourney((prev) => - prev ? { ...prev, hasNotified: true } : null - ); + setActiveJourney((prev) => (prev ? { ...prev, hasNotified: true } : null)); }, []); return ( diff --git a/src/frontend/app/hooks/useJourneyTracker.ts b/src/frontend/app/hooks/useJourneyTracker.ts index e9be393..97bf23d 100644 --- a/src/frontend/app/hooks/useJourneyTracker.ts +++ b/src/frontend/app/hooks/useJourneyTracker.ts @@ -57,10 +57,7 @@ export function useJourneyTracker() { ) { const title = minutes <= 0 - ? t( - "journey.notification_now_title", - "¡Tu autobús está llegando!" - ) + ? t("journey.notification_now_title", "¡Tu autobús está llegando!") : t("journey.notification_approaching_title", { defaultValue: "Tu autobús llega en {{minutes}} min", minutes, -- cgit v1.3