aboutsummaryrefslogtreecommitdiff
path: root/src/Enmarcha.Backend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-04-20 15:33:59 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2026-04-20 15:43:27 +0200
commit594b106010c0a5f9de38f6f0f3bda9b5f92f6699 (patch)
tree53dd28558e39a8fcbd7bac84f76c63b32637679f /src/Enmarcha.Backend
parentf2a37bc6366beccce247f834adee752b8e6323ae (diff)
Implementar vista de horario por parada y día
Closes #155
Diffstat (limited to 'src/Enmarcha.Backend')
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs107
-rw-r--r--src/Enmarcha.Backend/Types/Schedule/StopScheduleResponse.cs62
2 files changed, 169 insertions, 0 deletions
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<IActionResult> 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<StopScheduleOtpResponse.StopItem?> 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<GraphClientResponse<StopScheduleOtpResponse>>();
+
+ 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<ScheduledTripDto> Trips { get; set; } = [];
+}
+
+public class ScheduledTripDto
+{
+ /// <summary>Seconds from midnight of the service day.</summary>
+ [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; }
+}