feat(planning): add trip filtering and fix departure date logic
- Add Trip.status property for status tracking - Add RouteFilters trip list methods (filterBySport, filterByDateRange, filterByStatus, applyFilters) - Add TravelEstimator max driving hours validation - Fix ScenarioA/B departureDate to use last game day (not day after) - Update GameDAGRouter comments for buffer logic Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -519,15 +519,17 @@ enum GameDAGRouter {
|
||||
to: calendar.startOfDay(for: to.startTime)
|
||||
).day ?? 0
|
||||
|
||||
// For same-day games (doubleheaders), use shorter buffers
|
||||
// People leave earlier and arrive closer to game time
|
||||
// Buffer logic:
|
||||
// - Same stadium same day: 2hr post-game (doubleheader)
|
||||
// - Different stadiums same day: 2hr post-game (regional day trip)
|
||||
// - Different days: 3hr post-game (standard multi-day trip)
|
||||
let postGameBuffer: Double
|
||||
let preGameBuffer: Double
|
||||
|
||||
if daysBetween == 0 {
|
||||
// Same-day doubleheader: leave during game, arrive at game time
|
||||
postGameBuffer = 2.0 // Leave during/right after game
|
||||
preGameBuffer = 0.5 // Arrive closer to start time
|
||||
// Same-day games: use shorter buffers (doubleheader or day trip)
|
||||
postGameBuffer = 2.0 // Leave 2 hours after first game
|
||||
preGameBuffer = 0.5 // Arrive 30min before next game
|
||||
} else {
|
||||
// Different days: use standard buffers
|
||||
postGameBuffer = gameEndBufferHours // 3.0 hours
|
||||
|
||||
@@ -57,4 +57,58 @@ enum RouteFilters {
|
||||
return Array(violatingCities).sorted()
|
||||
}
|
||||
|
||||
// MARK: - Trip List Filters
|
||||
|
||||
/// Filter trips by sport. Returns trips containing ANY of the specified sports.
|
||||
static func filterBySport(_ trips: [Trip], sports: Set<Sport>) -> [Trip] {
|
||||
guard !sports.isEmpty else { return trips }
|
||||
return trips.filter { trip in
|
||||
!trip.preferences.sports.isDisjoint(with: sports)
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter trips by date range. Returns trips that overlap with the specified range.
|
||||
static func filterByDateRange(_ trips: [Trip], start: Date, end: Date) -> [Trip] {
|
||||
let calendar = Calendar.current
|
||||
let rangeStart = calendar.startOfDay(for: start)
|
||||
let rangeEnd = calendar.startOfDay(for: end)
|
||||
|
||||
return trips.filter { trip in
|
||||
let tripStart = calendar.startOfDay(for: trip.startDate)
|
||||
let tripEnd = calendar.startOfDay(for: trip.endDate)
|
||||
|
||||
// Trip overlaps if it starts before range ends AND ends after range starts
|
||||
return tripStart <= rangeEnd && tripEnd >= rangeStart
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter trips by status. Returns trips matching the specified status.
|
||||
static func filterByStatus(_ trips: [Trip], status: TripStatus) -> [Trip] {
|
||||
trips.filter { $0.status == status }
|
||||
}
|
||||
|
||||
/// Apply multiple filters. Returns intersection of all filter criteria.
|
||||
static func applyFilters(
|
||||
_ trips: [Trip],
|
||||
sports: Set<Sport>? = nil,
|
||||
dateRange: (start: Date, end: Date)? = nil,
|
||||
status: TripStatus? = nil
|
||||
) -> [Trip] {
|
||||
var result = trips
|
||||
|
||||
if let sports = sports, !sports.isEmpty {
|
||||
result = filterBySport(result, sports: sports)
|
||||
}
|
||||
|
||||
if let range = dateRange {
|
||||
result = filterByDateRange(result, start: range.start, end: range.end)
|
||||
}
|
||||
|
||||
if let status = status {
|
||||
result = filterByStatus(result, status: status)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -326,9 +326,8 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
// departureDate is day AFTER last game (we leave the next morning)
|
||||
// departureDate is same day as last game
|
||||
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
||||
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
||||
|
||||
return ItineraryStop(
|
||||
city: city,
|
||||
@@ -336,7 +335,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: departureDateValue,
|
||||
departureDate: lastGameDate,
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
|
||||
@@ -368,9 +368,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
// departureDate is day AFTER last game (we leave the next morning)
|
||||
// departureDate is same day as last game
|
||||
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
||||
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
||||
|
||||
return ItineraryStop(
|
||||
city: city,
|
||||
@@ -378,7 +377,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: departureDateValue,
|
||||
departureDate: lastGameDate,
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ enum TravelEstimator {
|
||||
// MARK: - Travel Estimation
|
||||
|
||||
/// Estimates a travel segment between two stops.
|
||||
/// Always creates a segment - feasibility is checked by GameDAGRouter.
|
||||
/// Returns nil if trip exceeds maximum allowed driving hours (2 days worth).
|
||||
static func estimate(
|
||||
from: ItineraryStop,
|
||||
to: ItineraryStop,
|
||||
@@ -30,6 +30,12 @@ enum TravelEstimator {
|
||||
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
||||
let drivingHours = distanceMiles / averageSpeedMph
|
||||
|
||||
// Maximum allowed: 2 days of driving
|
||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0
|
||||
if drivingHours > maxAllowedHours {
|
||||
return nil
|
||||
}
|
||||
|
||||
return TravelSegment(
|
||||
fromLocation: from.location,
|
||||
toLocation: to.location,
|
||||
@@ -40,7 +46,7 @@ enum TravelEstimator {
|
||||
}
|
||||
|
||||
/// Estimates a travel segment between two LocationInputs.
|
||||
/// Returns nil only if coordinates are missing.
|
||||
/// Returns nil if coordinates are missing or if trip exceeds maximum allowed driving hours (2 days worth).
|
||||
static func estimate(
|
||||
from: LocationInput,
|
||||
to: LocationInput,
|
||||
@@ -56,6 +62,12 @@ enum TravelEstimator {
|
||||
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
|
||||
let drivingHours = distanceMiles / averageSpeedMph
|
||||
|
||||
// Maximum allowed: 2 days of driving
|
||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0
|
||||
if drivingHours > maxAllowedHours {
|
||||
return nil
|
||||
}
|
||||
|
||||
return TravelSegment(
|
||||
fromLocation: from,
|
||||
toLocation: to,
|
||||
|
||||
Reference in New Issue
Block a user