diff options
Diffstat (limited to 'src')
23 files changed, 794 insertions, 164 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs index 957668a..6096b53 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs @@ -75,11 +75,18 @@ public partial class ArrivalsController : ControllerBase List<Arrival> arrivals = []; foreach (var item in stop.Arrivals) { + // Discard trip without pickup at stop if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) { continue; } + // Discard on last stop + if (item.Trip.ArrivalStoptime.Stop.GtfsId == id) + { + continue; + } + if (item.Trip.Geometry?.Points != null) { _logger.LogDebug("Trip {TripId} has geometry", item.Trip.GtfsId); @@ -133,6 +140,8 @@ public partial class ArrivalsController : ControllerBase // Time after an arrival's time to still include it in the response. This is useful without real-time data, for delayed buses. var timeThreshold = GetThresholdForFeed(id); + var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); + return Ok(new StopArrivalsResponse { StopCode = _feedService.NormalizeStopCode(feedId, stop.Code), @@ -151,8 +160,10 @@ public partial class ArrivalsController : ControllerBase { GtfsId = r.GtfsId, ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), - Colour = r.Color ?? "FFFFFF", - TextColour = r.TextColor ?? "000000" + Colour = r.Color ?? fallbackColor, + TextColour = r.TextColor is null or "000000" ? + ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) : + r.TextColor })], Arrivals = [.. arrivals.Where(a => a.Estimate.Minutes >= timeThreshold)] }); @@ -163,7 +174,7 @@ public partial class ArrivalsController : ControllerBase { string feedId = id.Split(':', 2)[0]; - if (feedId == "vitrasa" || feedId == "coruna") + if (feedId is "vitrasa" or "coruna") { return 0; } diff --git a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs index efddf82..823cfa5 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs @@ -1,18 +1,34 @@ +using System.Net; +using Costasdev.Busurbano.Backend.Configuration; using Costasdev.Busurbano.Backend.Services; using Costasdev.Busurbano.Backend.Types.Planner; +using Costasdev.Busurbano.Sources.OpenTripPlannerGql; +using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; namespace Costasdev.Busurbano.Backend.Controllers; [ApiController] [Route("api/planner")] -public class RoutePlannerController : ControllerBase +public partial class RoutePlannerController : ControllerBase { + private readonly ILogger<RoutePlannerController> _logger; private readonly OtpService _otpService; + private readonly AppConfiguration _config; + private readonly HttpClient _httpClient; - public RoutePlannerController(OtpService otpService) + public RoutePlannerController( + ILogger<RoutePlannerController> logger, + OtpService otpService, + IOptions<AppConfiguration> config, + HttpClient httpClient + ) { + _logger = logger; _otpService = otpService; + _config = config.Value; + _httpClient = httpClient; } [HttpGet("autocomplete")] @@ -44,18 +60,43 @@ public class RoutePlannerController : ControllerBase [FromQuery] double fromLon, [FromQuery] double toLat, [FromQuery] double toLon, - [FromQuery] DateTime? time = null, + [FromQuery] DateTimeOffset time, [FromQuery] bool arriveBy = false) { try { - var plan = await _otpService.GetRoutePlanAsync(fromLat, fromLon, toLat, toLon, time, arriveBy); + var requestContent = PlanConnectionContent.Query( + new PlanConnectionContent.Args(fromLat, fromLon, toLat, toLon, time, arriveBy) + ); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); + request.Content = JsonContent.Create(new GraphClientRequest + { + Query = requestContent + }); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<PlanConnectionResponse>>(); + + Console.WriteLine(responseBody); + + if (responseBody is not { IsSuccess: true } || responseBody.Data?.PlanConnection.Edges.Length == 0) + { + LogErrorFetchingRoutes(response.StatusCode, await response.Content.ReadAsStringAsync()); + return StatusCode(500, "An error occurred while planning the route."); + } + + var plan = _otpService.MapPlanResponse(responseBody.Data!); return Ok(plan); } - catch (Exception) + catch (Exception e) { - // Log error + _logger.LogError("Exception planning route: {e}", e); return StatusCode(500, "An error occurred while planning the route."); } } + + [LoggerMessage(LogLevel.Error, "Error fetching route planning, received {statusCode} {responseBody}")] + partial void LogErrorFetchingRoutes(HttpStatusCode? statusCode, string responseBody); + } diff --git a/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs b/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs index e48660b..f9fd5f2 100644 --- a/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs +++ b/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs @@ -25,7 +25,7 @@ public static class ContrastHelper double contrastWithWhite = (1.0 + 0.05) / (luminance + 0.05); double contrastWithBlack = (luminance + 0.05) / 0.05; - if (contrastWithWhite > 3) + if (contrastWithWhite >= 2.5) { return "#FFFFFF"; } diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index 7d52c29..7651cbc 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -21,6 +21,8 @@ builder.Services.AddMemoryCache(); builder.Services.AddSingleton<ShapeTraversalService>(); builder.Services.AddSingleton<FeedService>(); +builder.Services.AddSingleton<FareService>(); +builder.Services.AddSingleton<LineFormatterService>(); builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, CorunaRealTimeProcessor>(); diff --git a/src/Costasdev.Busurbano.Backend/Services/FareService.cs b/src/Costasdev.Busurbano.Backend/Services/FareService.cs new file mode 100644 index 0000000..0e4fefc --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/FareService.cs @@ -0,0 +1,49 @@ +using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Types.Planner; +using Microsoft.Extensions.Options; + +namespace Costasdev.Busurbano.Backend.Services; + +public record FareResult(double CashFareEuro, double CardFareEuro); + +public class FareService +{ + private readonly AppConfiguration _config; + + public FareService(IOptions<AppConfiguration> config) + { + _config = config.Value; + } + + public FareResult CalculateFare(IEnumerable<Leg> legs) + { + var busLegs = legs.Where(l => l.Mode != null && l.Mode.ToUpper() != "WALK").ToList(); + + // Cash fare logic + // TODO: In the future, this should depend on the operator/feed + var cashFare = busLegs.Count * 1.63; // Defaulting to Vitrasa for now + + // Card fare logic (45-min transfer window) + int cardTicketsRequired = 0; + DateTime? lastTicketPurchased = null; + int tripsPaidWithTicket = 0; + + foreach (var leg in busLegs) + { + if (lastTicketPurchased == null || + (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45 || + tripsPaidWithTicket >= 3) + { + cardTicketsRequired++; + lastTicketPurchased = leg.StartTime; + tripsPaidWithTicket = 1; + } + else + { + tripsPaidWithTicket++; + } + } + + return new FareResult(cashFare, cardTicketsRequired * 0.67); + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs index a8710b5..3ef079c 100644 --- a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs @@ -62,14 +62,22 @@ public class FeedService public string NormalizeRouteShortName(string feedId, string shortName) { - if (feedId == "xunta" && shortName.StartsWith("XG") && shortName.Length >= 8) + if (feedId == "xunta" && shortName.StartsWith("XG")) { - // XG817014 -> 817.14 - var contract = shortName.Substring(2, 3); - var lineStr = shortName.Substring(5); - if (int.TryParse(lineStr, out int line)) + if (shortName.Length >= 8) + { + // XG817014 -> 817.14 + var contract = shortName.Substring(2, 3); + var lineStr = shortName.Substring(5); + if (int.TryParse(lineStr, out int line)) + { + return $"{contract}.{line:D2}"; + } + } + else if (shortName.Length > 2) { - return $"{contract}.{line:D2}"; + // XG883 -> 883 + return shortName.Substring(2); } } return shortName; @@ -91,6 +99,7 @@ public class FeedService if (feedId == "vitrasa") { return name + .Trim() .Replace("\"", "") .Replace(" ", ", ") .Trim(); diff --git a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs b/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs index 6ec40b1..db9f2a5 100644 --- a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs @@ -4,7 +4,7 @@ namespace Costasdev.Busurbano.Backend.Services; public class LineFormatterService { - public static ConsolidatedCirculation Format(ConsolidatedCirculation circulation) + public ConsolidatedCirculation Format(ConsolidatedCirculation circulation) { circulation.Route = circulation.Route.Replace("*", ""); diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs index eca8f50..7eba590 100644 --- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs @@ -1,8 +1,10 @@ using System.Globalization; using System.Text; using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Helpers; using Costasdev.Busurbano.Backend.Types.Otp; using Costasdev.Busurbano.Backend.Types.Planner; +using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -14,13 +16,19 @@ public class OtpService private readonly AppConfiguration _config; private readonly IMemoryCache _cache; private readonly ILogger<OtpService> _logger; + private readonly FareService _fareService; + private readonly LineFormatterService _lineFormatter; + private readonly FeedService _feedService; - public OtpService(HttpClient httpClient, IOptions<AppConfiguration> config, IMemoryCache cache, ILogger<OtpService> logger) + public OtpService(HttpClient httpClient, IOptions<AppConfiguration> config, IMemoryCache cache, ILogger<OtpService> logger, FareService fareService, LineFormatterService lineFormatter, FeedService feedService) { _httpClient = httpClient; _config = config.Value; _cache = cache; _logger = logger; + _fareService = fareService; + _lineFormatter = lineFormatter; + _feedService = feedService; } public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query) @@ -244,39 +252,17 @@ public class OtpService private PlannerPlace? MapPlace(OtpPlace? otpPlace) { if (otpPlace == null) return null; + var feedId = otpPlace.StopId?.Split(':')[0] ?? "unknown"; return new PlannerPlace { - Name = CorrectStopName(otpPlace.Name), + Name = _feedService.NormalizeStopName(feedId, otpPlace.Name), Lat = otpPlace.Lat, Lon = otpPlace.Lon, StopId = otpPlace.StopId, // Use string directly - StopCode = CorrectStopCode(otpPlace.StopCode) + StopCode = _feedService.NormalizeStopCode(feedId, otpPlace.StopCode ?? string.Empty) }; } - private string CorrectStopCode(string? stopId) - { - if (string.IsNullOrEmpty(stopId)) return stopId ?? string.Empty; - - var sb = new StringBuilder(); - foreach (var c in stopId) - { - if (char.IsNumber(c)) - { - sb.Append(c); - } - } - - return int.Parse(sb.ToString()).ToString(); - } - - private string CorrectStopName(string? stopName) - { - if (string.IsNullOrEmpty(stopName)) return stopName ?? string.Empty; - - return stopName!.Replace(" ", ", ").Replace("\"", ""); - } - private Step MapStep(OtpWalkStep otpStep) { return new Step @@ -351,4 +337,153 @@ public class OtpService return poly; } + + public RoutePlan MapPlanResponse(PlanConnectionResponse response) + { + var itineraries = response.PlanConnection.Edges + .Select(e => MapItinerary(e.Node)) + .ToList(); + + return new RoutePlan + { + Itineraries = itineraries + }; + } + + private Itinerary MapItinerary(PlanConnectionResponse.Node node) + { + var legs = node.Legs.Select(MapLeg).ToList(); + var fares = _fareService.CalculateFare(legs); + + return new Itinerary + { + DurationSeconds = node.DurationSeconds, + StartTime = DateTime.Parse(node.Start8601, null, DateTimeStyles.RoundtripKind), + EndTime = DateTime.Parse(node.End8601, null, DateTimeStyles.RoundtripKind), + WalkDistanceMeters = node.WalkDistance, + WalkTimeSeconds = node.WalkSeconds, + TransitTimeSeconds = node.DurationSeconds - node.WalkSeconds - node.WaitingSeconds, + WaitingTimeSeconds = node.WaitingSeconds, + Legs = legs, + CashFareEuro = fares.CashFareEuro, + CardFareEuro = fares.CardFareEuro + }; + } + + private Leg MapLeg(PlanConnectionResponse.Leg leg) + { + var feedId = leg.From.Stop?.GtfsId?.Split(':')[0] ?? "unknown"; + var shortName = _feedService.NormalizeRouteShortName(feedId, leg.Route?.ShortName ?? string.Empty); + var headsign = leg.Headsign; + + if (feedId == "vitrasa") + { + headsign = headsign?.Replace("*", ""); + if (headsign == "FORA DE SERVIZO.G.B.") + { + headsign = "García Barbón, 7 (fora de servizo)"; + } + + switch (shortName) + { + case "A" when headsign != null && headsign.StartsWith("\"1\""): + shortName = "A1"; + headsign = headsign.Replace("\"1\"", ""); + break; + case "6": + headsign = headsign?.Replace("\"", ""); + break; + case "FUT": + if (headsign == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO") + { + shortName = "MAR"; + headsign = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO"; + } + else if (headsign == "P. ESPAÑA-T.VIGO-S.BADÍA") + { + shortName = "RIO"; + headsign = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA"; + } + else if (headsign == "NAVIA-BOUZAS-URZAIZ-G. ESPINO") + { + shortName = "GOL"; + headsign = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO"; + } + break; + } + } + + var color = leg.Route?.Color; + var textColor = leg.Route?.TextColor; + + if (string.IsNullOrEmpty(color) || color == "FFFFFF") + { + var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); + color = fallbackColor.Replace("#", ""); + textColor = fallbackTextColor.Replace("#", ""); + } + else if (string.IsNullOrEmpty(textColor) || textColor == "000000") + { + textColor = ContrastHelper.GetBestTextColour(color).Replace("#", ""); + } + + return new Leg + { + Mode = leg.Mode, + RouteName = leg.Route?.LongName, + RouteShortName = shortName, + RouteLongName = leg.Route?.LongName, + Headsign = headsign, + AgencyName = leg.Route?.Agency?.Name, + RouteColor = color, + RouteTextColor = textColor, + From = MapPlace(leg.From), + To = MapPlace(leg.To), + StartTime = DateTime.Parse(leg.Start.ScheduledTime8601, null, DateTimeStyles.RoundtripKind), + EndTime = DateTime.Parse(leg.End.ScheduledTime8601, null, DateTimeStyles.RoundtripKind), + DistanceMeters = leg.Distance, + Geometry = DecodePolyline(leg.LegGeometry?.Points), + Steps = leg.Steps.Select(MapStep).ToList(), + IntermediateStops = leg.StopCalls.Select(sc => MapPlace(sc.StopLocation)).ToList() + }; + } + + private PlannerPlace MapPlace(PlanConnectionResponse.LegPosition pos) + { + var feedId = pos.Stop?.GtfsId?.Split(':')[0] ?? "unknown"; + return new PlannerPlace + { + Name = _feedService.NormalizeStopName(feedId, pos.Name), + Lat = pos.Latitude, + Lon = pos.Longitude, + StopId = pos.Stop?.GtfsId, + StopCode = _feedService.NormalizeStopCode(feedId, pos.Stop?.Code ?? string.Empty) + }; + } + + private PlannerPlace MapPlace(PlanConnectionResponse.StopLocation stop) + { + var feedId = stop.GtfsId?.Split(':')[0] ?? "unknown"; + return new PlannerPlace + { + Name = _feedService.NormalizeStopName(feedId, stop.Name), + Lat = stop.Latitude, + Lon = stop.Longitude, + StopId = stop.GtfsId, + StopCode = _feedService.NormalizeStopCode(feedId, stop.Code ?? string.Empty) + }; + } + + private Step MapStep(PlanConnectionResponse.Step step) + { + return new Step + { + DistanceMeters = step.Distance, + RelativeDirection = step.RelativeDirection, + AbsoluteDirection = step.AbsoluteDirection, + StreetName = step.StreetName, + Lat = step.Latitude, + Lon = step.Longitude + }; + } } diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs index ee98a1f..587917e 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs @@ -73,8 +73,6 @@ public class CorunaRealTimeProcessor : AbstractRealTimeProcessor } var arrival = bestMatch.Arrival; - _logger.LogInformation("Matched Coruña real-time for line {Line}: {Scheduled}m -> {RealTime}m (diff: {Diff}m)", - arrival.Route.ShortName, arrival.Estimate.Minutes, estimate.Minutes, bestMatch.TimeDiff); var scheduledMinutes = arrival.Estimate.Minutes; arrival.Estimate.Minutes = estimate.Minutes; diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs index 5e0783d..f3a8d91 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs @@ -111,8 +111,6 @@ public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor if (bestMatch != null) { var arrival = bestMatch.Arrival; - _logger.LogInformation("Matched Vitrasa real-time for line {Line}: {Scheduled}m -> {RealTime}m (diff: {Diff}m)", - arrival.Route.ShortName, arrival.Estimate.Minutes, estimate.Minutes, bestMatch.TimeDiff); var scheduledMinutes = arrival.Estimate.Minutes; arrival.Estimate.Minutes = estimate.Minutes; diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs index d245cd8..e54b66e 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs @@ -15,13 +15,15 @@ public class VitrasaTransitProvider : ITransitProvider private readonly VigoTransitApiClient _api; private readonly AppConfiguration _configuration; private readonly ShapeTraversalService _shapeService; + private readonly LineFormatterService _lineFormatter; private readonly ILogger<VitrasaTransitProvider> _logger; - public VitrasaTransitProvider(HttpClient http, IOptions<AppConfiguration> options, ShapeTraversalService shapeService, ILogger<VitrasaTransitProvider> logger) + public VitrasaTransitProvider(HttpClient http, IOptions<AppConfiguration> options, ShapeTraversalService shapeService, LineFormatterService lineFormatter, ILogger<VitrasaTransitProvider> logger) { _api = new VigoTransitApiClient(http); _configuration = options.Value; _shapeService = shapeService; + _lineFormatter = lineFormatter; _logger = logger; } @@ -261,7 +263,7 @@ public class VitrasaTransitProvider : ITransitProvider // Sort by ETA (RealTime minutes if present; otherwise Schedule minutes) var sorted = consolidatedCirculations .OrderBy(c => c.RealTime?.Minutes ?? c.Schedule!.Minutes) - .Select(LineFormatterService.Format) + .Select(_lineFormatter.Format) .ToList(); return sorted; diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs index c31d12a..b2f4d6a 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs @@ -15,7 +15,7 @@ public class Itinerary public double WalkTimeSeconds { get; set; } public double TransitTimeSeconds { get; set; } public double WaitingTimeSeconds { get; set; } - public List<Leg> Legs { get; set; } = new(); + public List<Leg> Legs { get; set; } = []; public double? CashFareEuro { get; set; } public double? CardFareEuro { get; set; } } diff --git a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs index bbf2c08..bce35a2 100644 --- a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs +++ b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs @@ -46,6 +46,11 @@ public class ArrivalsAtStopContent : IGraphRequest<ArrivalsAtStopContent.Args> departureStoptime {{ scheduledDeparture }} + arrivalStoptime {{ + stop {{ + gtfsId + }} + }} {geometryField} stoptimes {{ stop {{ @@ -110,6 +115,9 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse [JsonPropertyName("departureStoptime")] public required DepartureStoptime DepartureStoptime { get; set; } + [JsonPropertyName("arrivalStoptime")] + public required ArrivalStoptime ArrivalStoptime { get; set; } + [JsonPropertyName("route")] public required RouteDetails Route { get; set; } [JsonPropertyName("tripGeometry")] public GeometryDetails? Geometry { get; set; } @@ -141,6 +149,16 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse public int ScheduledDeparture { get; set; } } + public class ArrivalStoptime + { + [JsonPropertyName("stop")] public ArrivalStoptimeStop Stop { get; set; } + } + + public class ArrivalStoptimeStop + { + [JsonPropertyName("gtfsId")] public required string GtfsId { get; set; } + } + public class RouteDetails { [JsonPropertyName("gtfsId")] public required string GtfsId { get; set; } diff --git a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs new file mode 100644 index 0000000..a4bf8d1 --- /dev/null +++ b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs @@ -0,0 +1,238 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; + +#pragma warning disable CS8618 + +public class PlanConnectionContent : IGraphRequest<PlanConnectionContent.Args> +{ + public record Args( + double StartLatitude, + double StartLongitude, + double EndLatitude, + double EndLongitude, + DateTimeOffset ReferenceTime, + bool ReferenceIsArrival = false + ); + + public static string Query(Args args) + { + var madridTz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); + + // Treat incoming DateTime as Madrid local wall-clock time + var localMadridTime = + DateTime.SpecifyKind(args.ReferenceTime.UtcDateTime, DateTimeKind.Unspecified); + + var offset = madridTz.GetUtcOffset(localMadridTime); + var actualTimeOfQuery = new DateTimeOffset(localMadridTime, offset); + + var dateTimeParameter = args.ReferenceIsArrival ? $"latestArrival:\"{actualTimeOfQuery:O}\"" : $"earliestDeparture:\"{actualTimeOfQuery:O}\""; + + return string.Create(CultureInfo.InvariantCulture, + $$""" + query Query { + planConnection( + first: 4 + origin: { + location:{ + coordinate:{ + latitude:{{args.StartLatitude}} + longitude:{{args.StartLongitude}} + } + } + } + destination:{ + location:{ + coordinate:{ + latitude:{{args.EndLatitude}} + longitude:{{args.EndLongitude}} + } + } + } + dateTime:{ + {{dateTimeParameter}} + } + ) { + edges { + node { + duration + start + end + walkTime + walkDistance + waitingTime + legs { + start { + scheduledTime + } + end { + scheduledTime + } + mode + route { + shortName + longName + agency { + name + } + color + textColor + } + from { + name + lat + lon + stop { + gtfsId + code + name + lat + lon + } + } + to { + name + lat + lon + stop { + gtfsId + code + name + lat + lon + } + } + stopCalls { + stopLocation { + ... on Stop { + gtfsId + code + name + lat + lon + } + } + } + legGeometry { + points + } + steps { + distance + relativeDirection + streetName + absoluteDirection + lat + lon + } + headsign + distance + } + } + } + } + } + """); + } +} + +public class PlanConnectionResponse : AbstractGraphResponse +{ + public PlanConnectionItem PlanConnection { get; set; } + + public class PlanConnectionItem + { + [JsonPropertyName("edges")] + public Edge[] Edges { get; set; } + } + + public class Edge + { + [JsonPropertyName("node")] + public Node Node { get; set; } + } + + public class Node + { + [JsonPropertyName("duration")] public int DurationSeconds { get; set; } + [JsonPropertyName("start")] public string Start8601 { get; set; } + [JsonPropertyName("end")] public string End8601 { get; set; } + [JsonPropertyName("walkTime")] public int WalkSeconds { get; set; } + [JsonPropertyName("walkDistance")] public double WalkDistance { get; set; } + [JsonPropertyName("waitingTime")] public int WaitingSeconds { get; set; } + [JsonPropertyName("legs")] public Leg[] Legs { get; set; } + } + + public class Leg + { + [JsonPropertyName("start")] public ScheduledTimeContainer Start { get; set; } + [JsonPropertyName("end")] public ScheduledTimeContainer End { get; set; } + [JsonPropertyName("mode")] public string Mode { get; set; } // TODO: Make enum, maybe + [JsonPropertyName("route")] public TransitRoute? Route { get; set; } + [JsonPropertyName("from")] public LegPosition From { get; set; } + [JsonPropertyName("to")] public LegPosition To { get; set; } + [JsonPropertyName("stopCalls")] public StopCall[] StopCalls { get; set; } + [JsonPropertyName("legGeometry")] public LegGeometry LegGeometry { get; set; } + [JsonPropertyName("steps")] public Step[] Steps { get; set; } + [JsonPropertyName("headsign")] public string? Headsign { get; set; } + [JsonPropertyName("distance")] public double Distance { get; set; } + } + + public class TransitRoute + { + [JsonPropertyName("shortName")] public string ShortName { get; set; } + [JsonPropertyName("longName")] public string LongName { get; set; } + [JsonPropertyName("agency")] public AgencyNameContainer Agency { get; set; } + [JsonPropertyName("color")] public string Color { get; set; } + [JsonPropertyName("textColor")] public string TextColor { get; set; } + } + + public class LegPosition + { + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("lat")] public double Latitude { get; set; } + [JsonPropertyName("lon")] public double Longitude { get; set; } + [JsonPropertyName("stop")] public StopLocation Stop { get; set; } + } + + public class ScheduledTimeContainer + { + [JsonPropertyName("scheduledTime")] + public string ScheduledTime8601 { get; set; } + } + + public class AgencyNameContainer + { + [JsonPropertyName("name")] public string Name { get; set; } + } + + public class StopCall + { + [JsonPropertyName("stopLocation")] + public StopLocation StopLocation { get; set; } + } + + public class StopLocation + { + [JsonPropertyName("gtfsId")] public string GtfsId { get; set; } + [JsonPropertyName("code")] public string Code { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("lat")] public double Latitude { get; set; } + [JsonPropertyName("lon")] public double Longitude { get; set; } + } + + public class Step + { + [JsonPropertyName("distance")] public double Distance { get; set; } + [JsonPropertyName("relativeDirection")] public string RelativeDirection { get; set; } + [JsonPropertyName("streetName")] public string StreetName { get; set; } // TODO: "sidewalk", "path" or actual street name + [JsonPropertyName("absoluteDirection")] public string AbsoluteDirection { get; set; } + [JsonPropertyName("lat")] public double Latitude { get; set; } + [JsonPropertyName("lon")] public double Longitude { get; set; } + } + + public class LegGeometry + { + [JsonPropertyName("points")] public string? Points { get; set; } + } +} diff --git a/src/frontend/app/api/planner.ts b/src/frontend/app/api/planner.ts new file mode 100644 index 0000000..86f44f0 --- /dev/null +++ b/src/frontend/app/api/planner.ts @@ -0,0 +1,34 @@ +import { RoutePlanSchema, type RoutePlan } from "./schema"; + +export const fetchPlan = async ( + fromLat: number, + fromLon: number, + toLat: number, + toLon: number, + time?: Date, + arriveBy: boolean = false +): Promise<RoutePlan> => { + let url = `/api/planner/plan?fromLat=${fromLat}&fromLon=${fromLon}&toLat=${toLat}&toLon=${toLon}&arriveBy=${arriveBy}`; + if (time) { + url += `&time=${time.toISOString()}`; + } + + const resp = await fetch(url, { + headers: { + Accept: "application/json", + }, + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + try { + return RoutePlanSchema.parse(data); + } catch (e) { + console.error("Zod parsing failed for route plan:", e); + console.log("Received data:", data); + throw e; + } +}; diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index 9cc5bd4..05f3a87 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -8,7 +8,7 @@ export const RouteInfoSchema = z.object({ export const HeadsignInfoSchema = z.object({ badge: z.string().optional().nullable(), - destination: z.string(), + destination: z.string().nullable(), marquee: z.string().optional().nullable(), }); @@ -108,3 +108,70 @@ export const ConsolidatedCirculationSchema = z.object({ export type ConsolidatedCirculation = z.infer< typeof ConsolidatedCirculationSchema >; + +// Route Planner +export const PlannerPlaceSchema = z.object({ + name: z.string().optional().nullable(), + lat: z.number(), + lon: z.number(), + stopId: z.string().optional().nullable(), + stopCode: z.string().optional().nullable(), +}); + +export const PlannerGeometrySchema = z.object({ + type: z.string(), + coordinates: z.array(z.array(z.number())), +}); + +export const PlannerStepSchema = z.object({ + distanceMeters: z.number(), + relativeDirection: z.string().optional().nullable(), + absoluteDirection: z.string().optional().nullable(), + streetName: z.string().optional().nullable(), + lat: z.number(), + lon: z.number(), +}); + +export const PlannerLegSchema = z.object({ + mode: z.string().optional().nullable(), + routeName: z.string().optional().nullable(), + routeShortName: z.string().optional().nullable(), + routeLongName: z.string().optional().nullable(), + routeColor: z.string().optional().nullable(), + routeTextColor: z.string().optional().nullable(), + headsign: z.string().optional().nullable(), + agencyName: z.string().optional().nullable(), + from: PlannerPlaceSchema.optional().nullable(), + to: PlannerPlaceSchema.optional().nullable(), + startTime: z.string(), + endTime: z.string(), + distanceMeters: z.number(), + geometry: PlannerGeometrySchema.optional().nullable(), + steps: z.array(PlannerStepSchema), + intermediateStops: z.array(PlannerPlaceSchema), +}); + +export const ItinerarySchema = z.object({ + durationSeconds: z.number(), + startTime: z.string(), + endTime: z.string(), + walkDistanceMeters: z.number(), + walkTimeSeconds: z.number(), + transitTimeSeconds: z.number(), + waitingTimeSeconds: z.number(), + legs: z.array(PlannerLegSchema), + cashFareEuro: z.number().optional().nullable(), + cardFareEuro: z.number().optional().nullable(), +}); + +export const RoutePlanSchema = z.object({ + itineraries: z.array(ItinerarySchema), + timeOffsetSeconds: z.number().optional().nullable(), +}); + +export type PlannerPlace = z.infer<typeof PlannerPlaceSchema>; +export type PlannerGeometry = z.infer<typeof PlannerGeometrySchema>; +export type PlannerStep = z.infer<typeof PlannerStepSchema>; +export type PlannerLeg = z.infer<typeof PlannerLegSchema>; +export type Itinerary = z.infer<typeof ItinerarySchema>; +export type RoutePlan = z.infer<typeof RoutePlanSchema>; diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index 448b5fd..a8a413c 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -127,18 +127,20 @@ } .line-icon-rounded { - display: block; - width: 4.25ch; + display: flex; + align-items: center; + justify-content: center; + min-width: 4.25ch; height: 4.25ch; box-sizing: border-box; background-color: var(--line-colour); color: var(--line-text-colour); - padding: 1.4ch 0.8ch; + padding: 0 0.8ch; text-align: center; - border-radius: 50%; + border-radius: 2.125ch; font: 600 13px / 1 monospace; letter-spacing: 0.05em; - text-wrap: nowrap; + white-space: nowrap; } diff --git a/src/frontend/app/hooks/usePlanQuery.ts b/src/frontend/app/hooks/usePlanQuery.ts new file mode 100644 index 0000000..103f5f4 --- /dev/null +++ b/src/frontend/app/hooks/usePlanQuery.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchPlan } from "../api/planner"; + +export const usePlanQuery = ( + fromLat: number | undefined, + fromLon: number | undefined, + toLat: number | undefined, + toLon: number | undefined, + time?: Date, + arriveBy: boolean = false, + enabled: boolean = true +) => { + return useQuery({ + queryKey: [ + "plan", + fromLat, + fromLon, + toLat, + toLon, + time?.toISOString(), + arriveBy, + ], + queryFn: () => + fetchPlan(fromLat!, fromLon!, toLat!, toLon!, time, arriveBy), + enabled: !!(fromLat && fromLon && toLat && toLon) && enabled, + staleTime: 60000, // 1 minute + retry: false, + }); +}; diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts index 6123f8a..a28167a 100644 --- a/src/frontend/app/hooks/usePlanner.ts +++ b/src/frontend/app/hooks/usePlanner.ts @@ -1,9 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import { - type PlannerSearchResult, - type RoutePlan, - planRoute, -} from "../data/PlannerApi"; +import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi"; +import { usePlanQuery } from "./usePlanQuery"; const STORAGE_KEY = "planner_last_route"; const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours @@ -32,6 +29,48 @@ export function usePlanner() { number | null >(null); + const { + data: queryPlan, + isLoading: queryLoading, + error: queryError, + isFetching, + } = usePlanQuery( + origin?.lat, + origin?.lon, + destination?.lat, + destination?.lon, + searchTime ?? undefined, + arriveBy, + !!(origin && destination) + ); + + // Sync query result to local state and storage + useEffect(() => { + if (queryPlan) { + setPlan(queryPlan as any); // Cast because of slight type differences if any, but they should match now + + if (origin && destination) { + const toStore: StoredRoute = { + timestamp: Date.now(), + origin, + destination, + plan: queryPlan as any, + searchTime: searchTime ?? new Date(), + arriveBy, + selectedItineraryIndex: selectedItineraryIndex ?? undefined, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + } + } + }, [ + queryPlan, + origin, + destination, + searchTime, + arriveBy, + selectedItineraryIndex, + ]); + // Load from storage on mount useEffect(() => { const stored = localStorage.getItem(STORAGE_KEY); @@ -60,41 +99,11 @@ export function usePlanner() { time?: Date, arriveByParam: boolean = false ) => { - setLoading(true); - setError(null); - try { - const result = await planRoute( - from.lat, - from.lon, - to.lat, - to.lon, - time, - arriveByParam - ); - setPlan(result); - setOrigin(from); - setDestination(to); - setSearchTime(time ?? new Date()); - setArriveBy(arriveByParam); - setSelectedItineraryIndex(null); // Reset when doing new search - - // Save to storage - const toStore: StoredRoute = { - timestamp: Date.now(), - origin: from, - destination: to, - plan: result, - searchTime: time ?? new Date(), - arriveBy: arriveByParam, - selectedItineraryIndex: undefined, - }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); - } catch (err) { - setError("Failed to calculate route. Please try again."); - setPlan(null); - } finally { - setLoading(false); - } + setOrigin(from); + setDestination(to); + setSearchTime(time ?? new Date()); + setArriveBy(arriveByParam); + setSelectedItineraryIndex(null); }; const clearRoute = () => { @@ -145,8 +154,8 @@ export function usePlanner() { destination, setDestination, plan, - loading, - error, + loading: queryLoading || (isFetching && !plan), + error: queryError ? "Failed to calculate route. Please try again." : null, searchTime, arriveBy, selectedItineraryIndex, diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index a8f3f52..2c58ebe 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -134,9 +134,11 @@ "walk_to": "Walk {{distance}} to {{destination}}", "from_to": "From {{from}} to {{to}}", "itinerary_details": "Itinerary Details", + "direction": "Direction", + "operator": "Operator", "back": "← Back", - "cash_fare": "€{{amount}}", - "card_fare": "€{{amount}}" + "fare": "€{{amount}}", + "free": "Free" }, "common": { "loading": "Loading...", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 2bffac9..298733e 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -134,6 +134,8 @@ "walk_to": "Caminar {{distance}} hasta {{destination}}", "from_to": "De {{from}} a {{to}}", "itinerary_details": "Detalles del itinerario", + "direction": "Dirección", + "operator": "Operador", "back": "← Atrás", "fare": "{{amount}} €", "free": "Gratuito" diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 5086feb..833279f 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -134,9 +134,11 @@ "walk_to": "Camiñar {{distance}} ata {{destination}}", "from_to": "De {{from}} a {{to}}", "itinerary_details": "Detalles do itinerario", + "direction": "Dirección", + "operator": "Operador", "back": "← Atrás", - "cash_fare": "{{amount}} €", - "card_fare": "{{amount}} €" + "fare": "{{amount}} €", + "free": "Gratuíto" }, "common": { "loading": "Cargando...", diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index e99cb03..44488c8 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -1,53 +1,24 @@ import { Coins, CreditCard, Footprints } from "lucide-react"; -import maplibregl, { type StyleSpecification } from "maplibre-gl"; +import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Layer, Source, type MapRef } from "react-map-gl/maplibre"; import { useLocation } from "react-router"; -import { useApp } from "~/AppContext"; +import { type ConsolidatedCirculation, type Itinerary } from "~/api/schema"; import LineIcon from "~/components/LineIcon"; import { PlannerOverlay } from "~/components/PlannerOverlay"; import { AppMap } from "~/components/shared/AppMap"; import { APP_CONSTANTS } from "~/config/constants"; import { usePageTitle } from "~/contexts/PageTitleContext"; -import { type Itinerary } from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; import "../tailwind-full.css"; -export interface ConsolidatedCirculation { - line: string; - route: string; - schedule?: { - running: boolean; - minutes: number; - serviceId: string; - tripId: string; - shapeId?: string; - }; - realTime?: { - minutes: number; - distance: number; - }; - currentPosition?: { - latitude: number; - longitude: number; - orientationDegrees: number; - shapeIndex?: number; - }; - isPreviousTrip?: boolean; - previousTripShapeId?: string; - nextStreets?: string[]; -} - -const FARE_CASH_PER_BUS = 1.63; -const FARE_CARD_PER_BUS = 0.67; - const formatDistance = (meters: number) => { - const intMeters = Math.round(meters); - if (intMeters >= 1000) return `${(intMeters / 1000).toFixed(1)} km`; - return `${intMeters} m`; + if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; + const rounded = Math.round(meters / 100) * 100; + return `${rounded} m`; }; const haversineMeters = (a: [number, number], b: [number, number]) => { @@ -116,12 +87,8 @@ const ItinerarySummary = ({ const busLegsCount = itinerary.legs.filter( (leg) => leg.mode !== "WALK" ).length; - const cashFare = ( - itinerary.cashFareEuro ?? busLegsCount * FARE_CASH_PER_BUS - ).toFixed(2); - const cardFare = ( - itinerary.cardFareEuro ?? busLegsCount * FARE_CARD_PER_BUS - ).toFixed(2); + const cashFare = (itinerary.cashFareEuro ?? 0).toFixed(2); + const cardFare = (itinerary.cardFareEuro ?? 0).toFixed(2); return ( <div @@ -132,9 +99,7 @@ const ItinerarySummary = ({ <div className="font-bold text-lg text-text"> {startTime} - {endTime} </div> - <div className="text-muted"> - {durationMinutes} min - </div> + <div className="text-muted">{durationMinutes} min</div> </div> <div className="flex items-center gap-2 overflow-x-auto pb-2"> @@ -168,6 +133,8 @@ const ItinerarySummary = ({ <LineIcon line={leg.routeShortName || leg.routeName || leg.mode || ""} mode="pill" + colour={leg.routeColor || undefined} + textColour={leg.routeTextColor || undefined} /> </div> )} @@ -180,7 +147,7 @@ const ItinerarySummary = ({ <span> {t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes - ? ` • ${walkTotals.minutes} {t("estimates.minutes")}` + ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` : ""} </span> <span className="flex items-center gap-3"> @@ -566,7 +533,7 @@ const ItineraryDetail = ({ </div> {/* Details Panel */} - <div className="h-1/3 md:h-full md:w-96 lg:w-md overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700"> + <div className="h-1/3 md:h-full md:w-96 lg:w-lg overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700"> <div className="px-4 py-4"> <h2 className="text-xl font-bold mb-4 text-slate-900 dark:text-slate-100"> {t("planner.itinerary_details")} @@ -575,7 +542,7 @@ const ItineraryDetail = ({ <div> {itinerary.legs.map((leg, idx) => ( <div key={idx} className="flex gap-3"> - <div className="flex flex-col items-center"> + <div className="flex flex-col items-center w-20 shrink-0"> {leg.mode === "WALK" ? ( <div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm" @@ -587,6 +554,8 @@ const ItineraryDetail = ({ <LineIcon line={leg.routeShortName || leg.routeName || ""} mode="rounded" + colour={leg.routeColor || undefined} + textColour={leg.routeTextColor || undefined} /> )} {idx < itinerary.legs.length - 1 && ( @@ -598,29 +567,42 @@ const ItineraryDetail = ({ {leg.mode === "WALK" ? ( t("planner.walk") ) : ( - <> - <span> + <div className="flex flex-col"> + <span className="text-[10px] uppercase text-muted font-bold leading-none mb-1"> + {t("planner.direction")} + </span> + <span className="leading-tight"> {leg.headsign || leg.routeLongName || leg.routeName || ""} </span> - </> + </div> )} </div> - <div className="text-sm text-gray-600 dark:text-gray-400"> - {new Date(leg.startTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - timeZone: "Europe/Madrid", - })}{" "} - -{" "} - {( - (new Date(leg.endTime).getTime() - - new Date(leg.startTime).getTime()) / - 60000 - ).toFixed(0)}{" "} - {t("estimates.minutes")} + <div className="text-sm text-gray-600 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-1 mt-1"> + <span> + {new Date(leg.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + timeZone: "Europe/Madrid", + })}{" "} + -{" "} + {( + (new Date(leg.endTime).getTime() - + new Date(leg.startTime).getTime()) / + 60000 + ).toFixed(0)}{" "} + {t("estimates.minutes")} + </span> + <span>•</span> + <span>{formatDistance(leg.distanceMeters)}</span> + {leg.agencyName && ( + <> + <span>•</span> + <span className="italic">{leg.agencyName}</span> + </> + )} </div> {leg.mode !== "WALK" && leg.from?.stopId && |
