From 594b106010c0a5f9de38f6f0f3bda9b5f92f6699 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 20 Apr 2026 15:33:59 +0200 Subject: Implementar vista de horario por parada y día MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #155 --- .../Controllers/ArrivalsController.cs | 107 +++++++++++++++++++++ .../Types/Schedule/StopScheduleResponse.cs | 62 ++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs (limited to 'src/Enmarcha.Backend') diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs index 5608723..9c7c9b9 100644 --- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs +++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs @@ -6,10 +6,12 @@ using Enmarcha.Backend.Helpers; using Enmarcha.Backend.Services; using Enmarcha.Backend.Types; using Enmarcha.Backend.Types.Arrivals; +using Enmarcha.Backend.Types.Schedule; using FuzzySharp; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; +using System.Globalization; namespace Enmarcha.Backend.Controllers; @@ -414,4 +416,109 @@ public partial class ArrivalsController : ControllerBase [LoggerMessage(LogLevel.Error, "Error fetching stop data, received {statusCode} {responseBody}")] partial void LogErrorFetchingStopData(HttpStatusCode statusCode, string responseBody); + + [HttpGet("schedule")] + public async Task GetSchedule( + [FromQuery] string id, + [FromQuery] string? date + ) + { + using var activity = Telemetry.Source.StartActivity("GetSchedule"); + activity?.SetTag("stop.id", id); + + if (string.IsNullOrWhiteSpace(id)) + return BadRequest("'id' is required."); + + string serviceDate; + if (!string.IsNullOrWhiteSpace(date)) + { + if (!DateOnly.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedDate)) + return BadRequest("Invalid date. Use yyyy-MM-dd."); + + serviceDate = parsedDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + } + else + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); + var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); + serviceDate = nowLocal.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + } + + var cacheKey = $"stop_schedule_{id}_{serviceDate}"; + if (_cache.TryGetValue(cacheKey, out StopScheduleResponse? cached) && cached != null) + return Ok(cached); + + var rawStop = await GetStopScheduleFromOtpAsync(id, serviceDate); + if (rawStop == null) + return StatusCode(500, "Error fetching stop schedule from OTP"); + + var feedId = id.Split(':')[0]; + var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); + var showOperator = feedId == "xunta"; + + var trips = rawStop.StoptimesForServiceDate + .Where(p => p.Stoptimes.Count > 0) + .SelectMany(p => + { + var color = !string.IsNullOrWhiteSpace(p.Pattern.Route.Color) ? p.Pattern.Route.Color : fallbackColor; + var textColor = p.Pattern.Route.TextColor is null or "000000" + ? ContrastHelper.GetBestTextColour(color) + : p.Pattern.Route.TextColor; + var shortName = _feedService.NormalizeRouteShortName(feedId, p.Pattern.Route.ShortName ?? ""); + + return p.Stoptimes.Select(s => new ScheduledTripDto + { + ScheduledDeparture = s.ScheduledDepartureSeconds, + RouteId = p.Pattern.Route.GtfsId, + RouteShortName = shortName, + RouteColor = color, + RouteTextColor = textColor, + Headsign = s.Trip?.TripHeadsign ?? p.Pattern.Headsign, + OriginStop = s.Trip?.DepartureStoptime?.Stop?.Name, + DestinationStop = s.Trip?.ArrivalStoptime?.Stop?.Name, + Operator = showOperator ? s.Trip?.Route?.Agency?.Name : null, + PickupType = s.PickupType, + DropOffType = s.DropoffType, + IsFirstStop = s.Trip?.DepartureStoptime?.Stop?.GtfsId == id, + IsLastStop = s.Trip?.ArrivalStoptime?.Stop?.GtfsId == id + }); + }) + .OrderBy(t => t.ScheduledDeparture) + .ToList(); + + var result = new StopScheduleResponse + { + StopCode = _feedService.NormalizeStopCode(feedId, rawStop.Code), + StopName = FeedService.NormalizeStopName(feedId, rawStop.Name), + StopLocation = new Position { Latitude = rawStop.Lat, Longitude = rawStop.Lon }, + Trips = trips + }; + + var tz2 = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); + var todayKey = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz2).ToString("yyyyMMdd", CultureInfo.InvariantCulture); + var cacheDuration = serviceDate == todayKey ? TimeSpan.FromHours(1) : TimeSpan.FromHours(6); + _cache.Set(cacheKey, result, cacheDuration); + + return Ok(result); + } + + private async Task GetStopScheduleFromOtpAsync(string id, string serviceDate) + { + var query = StopScheduleContent.Query(new StopScheduleContent.Args(id, serviceDate)); + var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1") + { + Content = JsonContent.Create(new GraphClientRequest { Query = query }) + }; + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadFromJsonAsync>(); + + if (responseBody is not { IsSuccess: true } || responseBody.Data?.Stop == null) + { + LogErrorFetchingStopData(response.StatusCode, await response.Content.ReadAsStringAsync()); + return null; + } + + return responseBody.Data.Stop; + } } diff --git a/src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs b/src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs new file mode 100644 index 0000000..47b1f4a --- /dev/null +++ b/src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; +using Enmarcha.Backend.Types; + +namespace Enmarcha.Backend.Types.Schedule; + +public class StopScheduleResponse +{ + [JsonPropertyName("stopCode")] + public required string StopCode { get; set; } + + [JsonPropertyName("stopName")] + public required string StopName { get; set; } + + [JsonPropertyName("stopLocation")] + public Position? StopLocation { get; set; } + + [JsonPropertyName("trips")] + public List Trips { get; set; } = []; +} + +public class ScheduledTripDto +{ + /// Seconds from midnight of the service day. + [JsonPropertyName("scheduledDeparture")] + public int ScheduledDeparture { get; set; } + + [JsonPropertyName("routeId")] + public required string RouteId { get; set; } + + [JsonPropertyName("routeShortName")] + public string? RouteShortName { get; set; } + + [JsonPropertyName("routeColor")] + public required string RouteColor { get; set; } + + [JsonPropertyName("routeTextColor")] + public required string RouteTextColor { get; set; } + + [JsonPropertyName("headsign")] + public string? Headsign { get; set; } + + [JsonPropertyName("originStop")] + public string? OriginStop { get; set; } + + [JsonPropertyName("destinationStop")] + public string? DestinationStop { get; set; } + + [JsonPropertyName("operator")] + public string? Operator { get; set; } + + [JsonPropertyName("pickupType")] + public string? PickupType { get; set; } + + [JsonPropertyName("dropOffType")] + public string? DropOffType { get; set; } + + [JsonPropertyName("isFirstStop")] + public bool IsFirstStop { get; set; } + + [JsonPropertyName("isLastStop")] + public bool IsLastStop { get; set; } +} -- cgit v1.3