aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCopilot <198982749+Copilot@users.noreply.github.com>2025-11-18 00:27:51 +0100
committerGitHub <noreply@github.com>2025-11-18 00:27:51 +0100
commit133db456a4bd069daecb60b3ec6fa147868493a3 (patch)
tree23c3ec3aea66a0012c00b2456490ebc0eedd77fb /src
parent276e73412abef28c222c52a84334d49f5e414f3c (diff)
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 <ariel@costas.dev>
Diffstat (limited to 'src')
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs132
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/VigoSchedules.cs56
-rw-r--r--src/frontend/app/components/SchedulesTable.tsx9
-rw-r--r--src/frontend/app/routes/estimates-$id.tsx4
-rw-r--r--src/frontend/app/routes/timetable-$id.tsx13
-rw-r--r--src/gtfs_vigo_stops/stop_report.py89
6 files changed, 244 insertions, 59 deletions
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<IActionResult> 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<List<ScheduledStop>>(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<StopArrivals> LoadStopArrivalsProto(string stopId, string dateString)
+ private async Task<StopArrivals?> 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<List<ScheduledStop>> 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<List<ScheduledStop>>(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);
+ }
+
+ /// <summary>
+ /// Parse GTFS time format (HH:MM:SS) which can have hours >= 24 for services past midnight
+ /// </summary>
+ 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; }
+
+ /// <summary>
+ /// Parse GTFS time format (HH:MM:SS) which can have hours >= 24 for services past midnight
+ /// </summary>
+ 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<TimetableTableProps> = ({
<div className="time-info">
<span className="departure-time">
- {entry.calling_time.slice(0, 5)}
+ {formatTimeForDisplay(entry.calling_time)}
</span>
</div>
</div>
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<{
<div className="time-info">
<span className="departure-time">
- {entry.calling_time.slice(0, 5)}
+ {formatTimeForDisplay(entry.calling_time)}
</span>
<div className="service-id">
{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),
}
)