From 133db456a4bd069daecb60b3ec6fa147868493a3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:27:51 +0100 Subject: Handle GTFS times exceeding 24 hours for night services (#98) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> Co-authored-by: Ariel Costas Guerrero --- .../Controllers/VigoController.cs | 132 +++++++++++++++------ .../Types/VigoSchedules.cs | 56 +++++++-- src/frontend/app/components/SchedulesTable.tsx | 9 +- src/frontend/app/routes/estimates-$id.tsx | 4 +- src/frontend/app/routes/timetable-$id.tsx | 13 +- src/gtfs_vigo_stops/stop_report.py | 89 ++++++++++++-- 6 files changed, 244 insertions(+), 59 deletions(-) (limited to 'src') diff --git a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs index 7cc3fcb..cd75f90 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs @@ -49,25 +49,45 @@ public class VigoController : ControllerBase [HttpGet("GetStopTimetable")] public async Task GetStopTimetable( [FromQuery] int stopId, - [FromQuery] string date + [FromQuery] string? date = null ) { - // Validate date format - if (!DateTime.TryParseExact(date, "yyyy-MM-dd", null, DateTimeStyles.None, out _)) + // Use Europe/Madrid timezone to determine the correct date + var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); + var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); + + // If no date provided or date is "today", use Madrid timezone's current date + string effectiveDate; + if (string.IsNullOrEmpty(date) || date == "today") + { + effectiveDate = nowLocal.Date.ToString("yyyy-MM-dd"); + } + else { - return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); + // Validate provided date format + if (!DateTime.TryParseExact(date, "yyyy-MM-dd", null, DateTimeStyles.None, out _)) + { + return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); + } + effectiveDate = date; } try { - var timetableData = await LoadTimetable(stopId.ToString(), date); + var file = Path.Combine(_configuration.ScheduleBasePath, effectiveDate, stopId + ".json"); + if (!SysFile.Exists(file)) + { + throw new FileNotFoundException(); + } + + var contents = await SysFile.ReadAllTextAsync(file); - return new OkObjectResult(timetableData); + return new OkObjectResult(JsonSerializer.Deserialize>(contents)!); } catch (FileNotFoundException ex) { - _logger.LogError(ex, "Stop data not found for stop {StopId} on date {Date}", stopId, date); - return StatusCode(404, $"Stop data not found for stop {stopId} on date {date}"); + _logger.LogError(ex, "Stop data not found for stop {StopId} on date {Date}", stopId, effectiveDate); + return StatusCode(404, $"Stop data not found for stop {stopId} on date {effectiveDate}"); } catch (Exception ex) { @@ -86,12 +106,37 @@ public class VigoController : ControllerBase var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); var realtimeTask = _api.GetStopEstimates(stopId); - var timetableTask = LoadStopArrivalsProto(stopId.ToString(), nowLocal.Date.ToString("yyyy-MM-dd")); + var todayDate = nowLocal.Date.ToString("yyyy-MM-dd"); + var tomorrowDate = nowLocal.Date.AddDays(1).ToString("yyyy-MM-dd"); + // Load both today's and tomorrow's schedules to handle night services + var timetableTask = LoadStopArrivalsProto(stopId.ToString(), todayDate); + + // Wait for real-time data and today's schedule (required) await Task.WhenAll(realtimeTask, timetableTask); var realTimeEstimates = realtimeTask.Result.Estimates; - // Filter out records with unparseable times (e.g., hours >= 24) + + // Handle case where schedule file doesn't exist - return realtime-only data + if (timetableTask.Result == null) + { + _logger.LogWarning("No schedule data available for stop {StopId} on {Date}, returning realtime-only data", stopId, todayDate); + + var realtimeOnlyCirculations = realTimeEstimates.Select(estimate => new ConsolidatedCirculation + { + Line = estimate.Line, + Route = estimate.Route, + Schedule = null, + RealTime = new RealTimeData + { + Minutes = estimate.Minutes, + Distance = estimate.Meters + } + }).OrderBy(c => c.RealTime!.Minutes).ToList(); + + return Ok(realtimeOnlyCirculations); + } + var timetable = timetableTask.Result.Arrivals .Where(c => c.StartingDateTime() != null && c.CallingDateTime() != null) .ToList(); @@ -285,12 +330,13 @@ public class VigoController : ControllerBase return Ok(sorted); } - private async Task LoadStopArrivalsProto(string stopId, string dateString) + private async Task LoadStopArrivalsProto(string stopId, string dateString) { var file = Path.Combine(_configuration.ScheduleBasePath, dateString, stopId + ".pb"); if (!SysFile.Exists(file)) { - throw new FileNotFoundException(); + _logger.LogWarning("Stop arrivals proto file not found: {File}", file); + return null; } var contents = await SysFile.ReadAllBytesAsync(file); @@ -311,18 +357,6 @@ public class VigoController : ControllerBase return shape; } - private async Task> LoadTimetable(string stopId, string dateString) - { - var file = Path.Combine(_configuration.ScheduleBasePath, dateString, stopId + ".json"); - if (!SysFile.Exists(file)) - { - throw new FileNotFoundException(); - } - - var contents = await SysFile.ReadAllTextAsync(file); - return JsonSerializer.Deserialize>(contents)!; - } - private static string NormalizeRouteName(string route) { var normalized = route.Trim().ToLowerInvariant(); @@ -353,21 +387,53 @@ public static class StopScheduleExtensions { public static DateTime? StartingDateTime(this ScheduledArrival stop) { - if (!TimeOnly.TryParse(stop.StartingTime, out var time)) - { - return null; - } - var dt = DateTime.Today + time.ToTimeSpan(); - return dt.AddSeconds(60 - dt.Second); + return ParseGtfsTime(stop.StartingTime); } public static DateTime? CallingDateTime(this ScheduledArrival stop) { - if (!TimeOnly.TryParse(stop.CallingTime, out var time)) + return ParseGtfsTime(stop.CallingTime); + } + + /// + /// Parse GTFS time format (HH:MM:SS) which can have hours >= 24 for services past midnight + /// + private static DateTime? ParseGtfsTime(string timeStr) + { + if (string.IsNullOrWhiteSpace(timeStr)) + { + return null; + } + + var parts = timeStr.Split(':'); + if (parts.Length != 3) + { + return null; + } + + if (!int.TryParse(parts[0], out var hours) || + !int.TryParse(parts[1], out var minutes) || + !int.TryParse(parts[2], out var seconds)) + { + return null; + } + + // Handle GTFS times that exceed 24 hours (e.g., 25:30:00 for 1:30 AM next day) + var days = hours / 24; + var normalizedHours = hours % 24; + + try + { + var dt = DateTime.Today + .AddDays(days) + .AddHours(normalizedHours) + .AddMinutes(minutes) + .AddSeconds(seconds); + return dt.AddSeconds(60 - dt.Second); + } + catch { return null; } - var dt = DateTime.Today + time.ToTimeSpan(); - return dt.AddSeconds(60 - dt.Second); } } diff --git a/src/Costasdev.Busurbano.Backend/Types/VigoSchedules.cs b/src/Costasdev.Busurbano.Backend/Types/VigoSchedules.cs index 76c8fa1..f3a6727 100644 --- a/src/Costasdev.Busurbano.Backend/Types/VigoSchedules.cs +++ b/src/Costasdev.Busurbano.Backend/Types/VigoSchedules.cs @@ -20,27 +20,59 @@ public class ScheduledStop [JsonPropertyName("starting_time")] public required string StartingTime { get; set; } public DateTime? StartingDateTime() { - if (!TimeOnly.TryParse(StartingTime, out var time)) - { - return null; - } - var dt = DateTime.Today + time.ToTimeSpan(); - return dt.AddSeconds(60 - dt.Second); + return ParseGtfsTime(StartingTime); } [JsonPropertyName("calling_ssm")] public required int CallingSsm { get; set; } [JsonPropertyName("calling_time")] public required string CallingTime { get; set; } public DateTime? CallingDateTime() { - if (!TimeOnly.TryParse(CallingTime, out var time)) - { - return null; - } - var dt = DateTime.Today + time.ToTimeSpan(); - return dt.AddSeconds(60 - dt.Second); + return ParseGtfsTime(CallingTime); } [JsonPropertyName("terminus_code")] public required string TerminusCode { get; set; } [JsonPropertyName("terminus_name")] public required string TerminusName { get; set; } [JsonPropertyName("terminus_time")] public required string TerminusTime { get; set; } + + /// + /// Parse GTFS time format (HH:MM:SS) which can have hours >= 24 for services past midnight + /// + private static DateTime? ParseGtfsTime(string timeStr) + { + if (string.IsNullOrWhiteSpace(timeStr)) + { + return null; + } + + var parts = timeStr.Split(':'); + if (parts.Length != 3) + { + return null; + } + + if (!int.TryParse(parts[0], out var hours) || + !int.TryParse(parts[1], out var minutes) || + !int.TryParse(parts[2], out var seconds)) + { + return null; + } + + // Handle GTFS times that exceed 24 hours (e.g., 25:30:00 for 1:30 AM next day) + var days = hours / 24; + var normalizedHours = hours % 24; + + try + { + var dt = DateTime.Today + .AddDays(days) + .AddHours(normalizedHours) + .AddMinutes(minutes) + .AddSeconds(seconds); + return dt.AddSeconds(60 - dt.Second); + } + catch + { + return null; + } + } } diff --git a/src/frontend/app/components/SchedulesTable.tsx b/src/frontend/app/components/SchedulesTable.tsx index 60e7ab0..5df01e5 100644 --- a/src/frontend/app/components/SchedulesTable.tsx +++ b/src/frontend/app/components/SchedulesTable.tsx @@ -95,6 +95,13 @@ const timeToMinutes = (time: string): number => { return hours * 60 + minutes; }; +// Utility function to format GTFS time for display (handle hours >= 24) +const formatTimeForDisplay = (time: string): string => { + const [hours, minutes] = time.split(":").map(Number); + const normalizedHours = hours % 24; + return `${normalizedHours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; +}; + // Utility function to find nearby entries const findNearbyEntries = ( entries: ScheduledTable[], @@ -178,7 +185,7 @@ export const SchedulesTable: React.FC = ({
- {entry.calling_time.slice(0, 5)} + {formatTimeForDisplay(entry.calling_time)}
diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx index e4006ef..74f24e6 100644 --- a/src/frontend/app/routes/estimates-$id.tsx +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -65,9 +65,9 @@ const loadTimetableData = async ( throw new Error("Timetable not available for this region"); } - const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format + // Use "today" to let server determine date based on Europe/Madrid timezone const resp = await fetch( - `${regionConfig.timetableEndpoint}?date=${today}&stopId=${stopId}`, + `${regionConfig.timetableEndpoint}?date=today&stopId=${stopId}`, { headers: { Accept: "application/json", diff --git a/src/frontend/app/routes/timetable-$id.tsx b/src/frontend/app/routes/timetable-$id.tsx index af5e42a..8a1cba7 100644 --- a/src/frontend/app/routes/timetable-$id.tsx +++ b/src/frontend/app/routes/timetable-$id.tsx @@ -38,9 +38,9 @@ const loadTimetableData = async ( // Add delay to see skeletons in action (remove in production) await new Promise((resolve) => setTimeout(resolve, 1000)); - const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD format + // Use "today" to let server determine date based on Europe/Madrid timezone const resp = await fetch( - `${regionConfig.timetableEndpoint}?date=${today}&stopId=${stopId}`, + `${regionConfig.timetableEndpoint}?date=today&stopId=${stopId}`, { headers: { Accept: "application/json", @@ -61,6 +61,13 @@ const timeToMinutes = (time: string): number => { return hours * 60 + minutes; }; +// Utility function to format GTFS time for display (handle hours >= 24) +const formatTimeForDisplay = (time: string): string => { + const [hours, minutes] = time.split(":").map(Number); + const normalizedHours = hours % 24; + return `${normalizedHours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; +}; + // Filter past entries (keep only a few recent past ones) const filterTimetableData = ( data: ScheduledTable[], @@ -402,7 +409,7 @@ const TimetableTableWithScroll: React.FC<{
- {entry.calling_time.slice(0, 5)} + {formatTimeForDisplay(entry.calling_time)}
{parseServiceId(entry.service_id)} diff --git a/src/gtfs_vigo_stops/stop_report.py b/src/gtfs_vigo_stops/stop_report.py index 8a36e60..da3a5d7 100644 --- a/src/gtfs_vigo_stops/stop_report.py +++ b/src/gtfs_vigo_stops/stop_report.py @@ -58,6 +58,7 @@ def parse_args(): def time_to_seconds(time_str: str) -> int: """ Convert HH:MM:SS to seconds since midnight. + Handles GTFS times that can exceed 24 hours (e.g., 25:30:00 for 1:30 AM next day). """ if not time_str: return 0 @@ -73,29 +74,91 @@ def time_to_seconds(time_str: str) -> int: return 0 +def normalize_gtfs_time(time_str: str) -> str: + """ + Normalize GTFS time format to standard HH:MM:SS (0-23 hours). + Converts times like 25:30:00 to 01:30:00. + + Args: + time_str: Time in HH:MM:SS format, possibly with hours >= 24 + + Returns: + Normalized time string in HH:MM:SS format + """ + if not time_str: + return time_str + + parts = time_str.split(":") + if len(parts) != 3: + return time_str + + try: + hours, minutes, seconds = map(int, parts) + normalized_hours = hours % 24 + return f"{normalized_hours:02d}:{minutes:02d}:{seconds:02d}" + except ValueError: + return time_str + + +def is_next_day_service(time_str: str) -> bool: + """ + Check if a GTFS time represents a service on the next day (hours >= 24). + + Args: + time_str: Time in HH:MM:SS format + + Returns: + True if the time is >= 24:00:00, False otherwise + """ + if not time_str: + return False + + parts = time_str.split(":") + if len(parts) != 3: + return False + + try: + hours = int(parts[0]) + return hours >= 24 + except ValueError: + return False + + def get_stop_arrivals(feed_dir: str, date: str) -> Dict[str, List[Dict[str, Any]]]: """ Process trips for the given date and organize stop arrivals. + Also includes night services from the previous day (times >= 24:00:00). Args: feed_dir: Path to the GTFS feed directory date: Date in YYYY-MM-DD format - numeric_stop_code: If True, strip non-numeric characters from stop codes Returns: Dictionary mapping stop_code to lists of arrival information. """ + from datetime import datetime, timedelta + stops = get_all_stops(feed_dir) logger.info(f"Found {len(stops)} stops in the feed.") active_services = get_active_services(feed_dir, date) if not active_services: logger.info("No active services found for the given date.") - return {} - + logger.info(f"Found {len(active_services)} active services for date {date}.") + + # Also get services from the previous day to include night services (times >= 24:00) + prev_date = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d") + prev_services = get_active_services(feed_dir, prev_date) + logger.info(f"Found {len(prev_services)} active services for previous date {prev_date} (for night services).") + + all_services = list(set(active_services + prev_services)) + + if not all_services: + logger.info("No active services found for current or previous date.") + return {} - trips = get_trips_for_services(feed_dir, active_services) + trips = get_trips_for_services(feed_dir, all_services) total_trip_count = sum(len(trip_list) for trip_list in trips.values()) logger.info(f"Found {total_trip_count} trips for active services.") @@ -120,6 +183,9 @@ def get_stop_arrivals(feed_dir: str, date: str) -> Dict[str, List[Dict[str, Any] stop_arrivals = {} for service_id, trip_list in trips.items(): + # Determine if this service is from the previous day + is_prev_day_service = service_id in prev_services and service_id not in active_services + for trip in trip_list: # Get route information once per trip route_info = routes.get(trip.route_id, {}) @@ -188,6 +254,13 @@ def get_stop_arrivals(feed_dir: str, date: str) -> Dict[str, List[Dict[str, Any] if not stop_code: continue # Skip stops without a code + + # Filter based on whether this is from previous day's service + # For previous day services: only include if calling_time >= 24:00:00 (night services rolling to this day) + # For current day services: include ALL times (both < 24:00 and >= 24:00) + if is_prev_day_service: + if not is_next_day_service(stop_time.departure_time): + continue # Skip times < 24:00 from previous day if stop_code not in stop_arrivals: stop_arrivals[stop_code] = [] @@ -218,12 +291,12 @@ def get_stop_arrivals(feed_dir: str, date: str) -> Dict[str, List[Dict[str, Any] "next_streets": next_streets, "starting_code": starting_code, "starting_name": starting_name, - "starting_time": starting_time, - "calling_time": stop_time.departure_time, - "calling_ssm": time_to_seconds(stop_time.departure_time), + "starting_time": normalize_gtfs_time(starting_time), + "calling_time": normalize_gtfs_time(stop_time.departure_time), + "calling_ssm": time_to_seconds(normalize_gtfs_time(stop_time.departure_time)), "terminus_code": terminus_code, "terminus_name": terminus_name, - "terminus_time": terminus_time, + "terminus_time": normalize_gtfs_time(terminus_time), } ) -- cgit v1.3