diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-13 16:49:10 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-13 16:49:30 +0100 |
| commit | ee69c62adc5943a1dbd154df5142c0e726bdd317 (patch) | |
| tree | 5874249173aa249d4d497733ef9fc410e64ab664 /src/Enmarcha.Backend/Controllers/ArrivalsController.cs | |
| parent | 90ad5395f6310da86fee9a29503e58ea74f3078b (diff) | |
feat(routes): add realtime estimates panel with pattern-aware styling
- New GET /api/stops/estimates endpoint (nano mode: tripId, patternId, estimate, delay only)
- useStopEstimates hook wiring estimates to routes-$id stop panel
- Pattern-aware styling: dim schedules and estimates from other patterns
- Past scheduled departures shown with strikethrough instead of hidden
- Persist selected pattern in URL hash (replace navigation, no history push)
- Fix planner arrivals using new estimates endpoint
Diffstat (limited to 'src/Enmarcha.Backend/Controllers/ArrivalsController.cs')
| -rw-r--r-- | src/Enmarcha.Backend/Controllers/ArrivalsController.cs | 165 |
1 files changed, 99 insertions, 66 deletions
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs index 50d4012..7ca3ab9 100644 --- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs +++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs @@ -54,58 +54,126 @@ public partial class ArrivalsController : ControllerBase activity?.SetTag("stop.id", id); activity?.SetTag("reduced", reduced); + var result = await FetchAndProcessArrivalsAsync(id, reduced, nano: false); + if (result is null) return StatusCode(500, "Error fetching stop data"); + var (stop, context) = result.Value; + + var feedId = id.Split(':')[0]; + 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 = [.. _feedService.ConsolidateRoutes(feedId, + stop.Routes + .OrderBy(r => SortingHelper.GetRouteSortKey(r.ShortName, r.GtfsId)) + .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 = [.. context.Arrivals.Where(a => a.Estimate.Minutes >= timeThreshold)], + Usage = context.Usage + }); + } + + [HttpGet("estimates")] + public async Task<IActionResult> GetEstimates( + [FromQuery] string stop, + [FromQuery] string route, + [FromQuery] string? via + ) + { + if (string.IsNullOrWhiteSpace(stop) || string.IsNullOrWhiteSpace(route)) + return BadRequest("'stop' and 'route' are required."); + + using var activity = Telemetry.Source.StartActivity("GetEstimates"); + activity?.SetTag("stop.id", stop); + activity?.SetTag("route.id", route); + activity?.SetTag("via.id", via); + + var result = await FetchAndProcessArrivalsAsync(stop, reduced: false, nano: true); + if (result is null) return StatusCode(500, "Error fetching stop data"); + var (_, context) = result.Value; + + // Annotate each arrival with its OTP pattern ID + foreach (var arrival in context.Arrivals) + { + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival) + arrival.PatternId = otpArrival.Trip.Pattern?.Id; + } + + // Filter by route GTFS ID + var timeThreshold = GetThresholdForFeed(stop); + var filtered = context.Arrivals + .Where(a => a.Route.GtfsId == route && a.Estimate.Minutes >= timeThreshold) + .ToList(); + + // Optionally filter by via stop: keep only trips whose remaining stoptimes include the via stop + if (!string.IsNullOrWhiteSpace(via)) + { + filtered = filtered.Where(a => + { + if (a.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) return false; + var stoptimes = otpArrival.Trip.Stoptimes; + var originIdx = stoptimes.FindIndex(s => s.Stop.GtfsId == stop); + var searchFrom = originIdx >= 0 ? originIdx + 1 : 0; + return stoptimes.Skip(searchFrom).Any(s => s.Stop.GtfsId == via); + }).ToList(); + } + + var estimates = filtered.Select(a => new ArrivalEstimate + { + TripId = a.TripId, + PatternId = a.PatternId, + Estimate = a.Estimate, + Delay = a.Delay + }).ToList(); + + return Ok(new StopEstimatesResponse { Arrivals = estimates }); + } + + private async Task<(ArrivalsAtStopResponse.StopItem Stop, ArrivalsContext Context)?> FetchAndProcessArrivalsAsync( + string id, bool reduced, bool nano) + { 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 requestContent = ArrivalsAtStopContent.Query(new ArrivalsAtStopContent.Args(id, reduced || nano)); var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); - request.Content = JsonContent.Create(new GraphClientRequest - { - Query = requestContent - }); + 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) { - activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, "Error fetching stop data from OTP"); LogErrorFetchingStopData(response.StatusCode, await response.Content.ReadAsStringAsync()); - return StatusCode(500, "Error fetching stop data"); + return null; } var stop = responseBody.Data.Stop; _logger.LogInformation("Fetched {Count} arrivals for stop {StopName} ({StopId})", stop.Arrivals.Count, stop.Name, id); - activity?.SetTag("arrivals.count", stop.Arrivals.Count); List<Arrival> arrivals = []; foreach (var item in stop.Arrivals) { - // Discard trip without pickup at stop - if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) - { - continue; - } + if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) continue; + if (item.Trip.ArrivalStoptime.Stop.GtfsId == id) continue; - // Discard on last stop - if (item.Trip.ArrivalStoptime.Stop.GtfsId == id) - { - continue; - } - - // 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() + arrivals.Add(new Arrival { TripId = item.Trip.GtfsId, Route = new RouteInfo @@ -115,19 +183,14 @@ public partial class ArrivalsController : ControllerBase Colour = item.Trip.Route.Color ?? "FFFFFF", TextColour = item.Trip.Route.TextColor ?? "000000" }, - Headsign = new HeadsignInfo - { - Destination = item.Trip.TripHeadsign ?? item.Headsign, - }, + Headsign = new HeadsignInfo { Destination = item.Trip.TripHeadsign ?? item.Headsign }, Estimate = new ArrivalDetails { Minutes = minutesToArrive, Precision = departureTime < nowLocal.AddMinutes(-1) ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled }, RawOtpTrip = item - }; - - arrivals.Add(arrival); + }); } var context = new ArrivalsContext @@ -135,6 +198,7 @@ public partial class ArrivalsController : ControllerBase StopId = id, StopCode = stop.Code, IsReduced = reduced, + IsNano = nano, Arrivals = arrivals, NowLocal = nowLocal, StopLocation = new Position { Latitude = stop.Lat, Longitude = stop.Lon } @@ -142,38 +206,7 @@ public partial class ArrivalsController : ControllerBase await _pipeline.ExecuteAsync(context); - 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 = [.. _feedService.ConsolidateRoutes(feedId, - stop.Routes - .OrderBy(r => SortingHelper.GetRouteSortKey(r.ShortName, r.GtfsId)) - .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)], - Usage = context.Usage - }); - + return (stop, context); } private static int GetThresholdForFeed(string id) |
