diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-29 00:41:52 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-29 00:41:52 +0100 |
| commit | a304c24b32c0327436bbd8c2853e60668e161b42 (patch) | |
| tree | 08f65c05daca134cf4d2e4f779bd15d98fd66370 /src/Enmarcha.Backend/Controllers/ArrivalsController.cs | |
| parent | 120a3c6bddd0fb8d9fa05df4763596956554c025 (diff) | |
Rename a lot of stuff, add Santiago real time
Diffstat (limited to 'src/Enmarcha.Backend/Controllers/ArrivalsController.cs')
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/ArrivalsController.cs | 249 |
1 files changed, 249 insertions, 0 deletions
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs new file mode 100644 index 0000000..7260fb4 --- /dev/null +++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs @@ -0,0 +1,249 @@ +using System.Net; +using Enmarcha.Sources.OpenTripPlannerGql; +using Enmarcha.Sources.OpenTripPlannerGql.Queries; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Helpers; +using Enmarcha.Backend.Services; +using Enmarcha.Backend.Types; +using Enmarcha.Backend.Types.Arrivals; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Controllers; + +[ApiController] +[Route("api/stops")] +public partial class ArrivalsController : ControllerBase +{ + private readonly ILogger<ArrivalsController> _logger; + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + private readonly ArrivalsPipeline _pipeline; + private readonly FeedService _feedService; + private readonly AppConfiguration _config; + + public ArrivalsController( + ILogger<ArrivalsController> logger, + IMemoryCache cache, + HttpClient httpClient, + ArrivalsPipeline pipeline, + FeedService feedService, + IOptions<AppConfiguration> configOptions + ) + { + _logger = logger; + _cache = cache; + _httpClient = httpClient; + _pipeline = pipeline; + _feedService = feedService; + _config = configOptions.Value; + } + + [HttpGet("arrivals")] + public async Task<IActionResult> GetArrivals( + [FromQuery] string id, + [FromQuery] bool reduced + ) + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); + var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); + var todayLocal = nowLocal.Date; + + var requestContent = ArrivalsAtStopContent.Query( + new ArrivalsAtStopContent.Args(id, reduced) + ); + + 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<ArrivalsAtStopResponse>>(); + + if (responseBody is not { IsSuccess: true } || responseBody.Data?.Stop == null) + { + LogErrorFetchingStopData(response.StatusCode, await response.Content.ReadAsStringAsync()); + return StatusCode(500, "Error fetching stop data"); + } + + var stop = responseBody.Data.Stop; + _logger.LogInformation("Fetched {Count} arrivals for stop {StopName} ({StopId})", stop.Arrivals.Count, stop.Name, id); + + 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); + } + + // Calculate departure time using the service day in the feed's timezone (Europe/Madrid) + // This ensures we treat ScheduledDepartureSeconds as relative to the local midnight of the service day + var serviceDayLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(item.ServiceDay), tz); + var departureTime = serviceDayLocal.Date.AddSeconds(item.ScheduledDepartureSeconds); + + var minutesToArrive = (int)(departureTime - nowLocal).TotalMinutes; + //var isRunning = departureTime < nowLocal; + + Arrival arrival = new() + { + TripId = item.Trip.GtfsId, + Route = new RouteInfo + { + GtfsId = item.Trip.Route.GtfsId, + ShortName = item.Trip.RouteShortName, + Colour = item.Trip.Route.Color ?? "FFFFFF", + TextColour = item.Trip.Route.TextColor ?? "000000" + }, + Headsign = new HeadsignInfo + { + Destination = item.Headsign + }, + Estimate = new ArrivalDetails + { + Minutes = minutesToArrive, + Precision = departureTime < nowLocal.AddMinutes(-1) ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled + }, + RawOtpTrip = item + }; + + arrivals.Add(arrival); + } + + await _pipeline.ExecuteAsync(new ArrivalsContext + { + StopId = id, + StopCode = stop.Code, + IsReduced = reduced, + Arrivals = arrivals, + NowLocal = nowLocal, + StopLocation = new Position { Latitude = stop.Lat, Longitude = stop.Lon } + }); + + var feedId = id.Split(':')[0]; + + // 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), + StopName = _feedService.NormalizeStopName(feedId, stop.Name), + StopLocation = new Position + { + Latitude = stop.Lat, + Longitude = stop.Lon + }, + Routes = [.. stop.Routes + .OrderBy( + r => r.ShortName, + Comparer<string?>.Create(SortingHelper.SortRouteShortNames) + ) + .Select(r => new RouteInfo + { + GtfsId = r.GtfsId, + ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), + 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)] + }); + + } + + private static int GetThresholdForFeed(string id) + { + string feedId = id.Split(':', 2)[0]; + + if (feedId is "vitrasa" or "tranvias" or "tussa") + { + return 0; + } + + return -30; + } + + [HttpGet] + public async Task<IActionResult> GetStops([FromQuery] string ids) + { + if (string.IsNullOrWhiteSpace(ids)) + { + return BadRequest("Ids parameter is required"); + } + + var stopIds = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var requestContent = StopsInfoContent.Query(new StopsInfoContent.Args(stopIds)); + + 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<StopsInfoResponse>>(); + + if (responseBody is not { IsSuccess: true } || responseBody.Data?.Stops == null) + { + return StatusCode(500, "Error fetching stops data"); + } + + var result = responseBody.Data.Stops.ToDictionary( + s => s.GtfsId, + s => + { + var feedId = s.GtfsId.Split(':', 2)[0]; + var (fallbackColor, _) = _feedService.GetFallbackColourForFeed(feedId); + + return new + { + id = s.GtfsId, + code = _feedService.NormalizeStopCode(feedId, s.Code ?? ""), + name = s.Name, + routes = s.Routes + .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames)) + .Select(r => new + { + shortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), + colour = r.Color ?? fallbackColor, + textColour = r.TextColor is null or "000000" ? + ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) : + r.TextColor + }) + .ToList() + }; + } + ); + + return Ok(result); + } + + [HttpGet("search")] + public IActionResult SearchStops([FromQuery] string q) + { + // Placeholder for future implementation with Postgres and fuzzy searching + return Ok(new List<object>()); + } + + [LoggerMessage(LogLevel.Error, "Error fetching stop data, received {statusCode} {responseBody}")] + partial void LogErrorFetchingStopData(HttpStatusCode statusCode, string responseBody); +} |
