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:
@@ -17,6 +17,7 @@ struct Trip: Identifiable, Codable, Hashable {
|
|||||||
var totalDistanceMeters: Double
|
var totalDistanceMeters: Double
|
||||||
var totalDrivingSeconds: Double
|
var totalDrivingSeconds: Double
|
||||||
var score: TripScore?
|
var score: TripScore?
|
||||||
|
var status: TripStatus
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -29,7 +30,8 @@ struct Trip: Identifiable, Codable, Hashable {
|
|||||||
totalGames: Int = 0,
|
totalGames: Int = 0,
|
||||||
totalDistanceMeters: Double = 0,
|
totalDistanceMeters: Double = 0,
|
||||||
totalDrivingSeconds: Double = 0,
|
totalDrivingSeconds: Double = 0,
|
||||||
score: TripScore? = nil
|
score: TripScore? = nil,
|
||||||
|
status: TripStatus = .planned
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -42,6 +44,7 @@ struct Trip: Identifiable, Codable, Hashable {
|
|||||||
self.totalDistanceMeters = totalDistanceMeters
|
self.totalDistanceMeters = totalDistanceMeters
|
||||||
self.totalDrivingSeconds = totalDrivingSeconds
|
self.totalDrivingSeconds = totalDrivingSeconds
|
||||||
self.score = score
|
self.score = score
|
||||||
|
self.status = status
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalDistanceMiles: Double { totalDistanceMeters * 0.000621371 }
|
var totalDistanceMiles: Double { totalDistanceMeters * 0.000621371 }
|
||||||
|
|||||||
@@ -519,15 +519,17 @@ enum GameDAGRouter {
|
|||||||
to: calendar.startOfDay(for: to.startTime)
|
to: calendar.startOfDay(for: to.startTime)
|
||||||
).day ?? 0
|
).day ?? 0
|
||||||
|
|
||||||
// For same-day games (doubleheaders), use shorter buffers
|
// Buffer logic:
|
||||||
// People leave earlier and arrive closer to game time
|
// - 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 postGameBuffer: Double
|
||||||
let preGameBuffer: Double
|
let preGameBuffer: Double
|
||||||
|
|
||||||
if daysBetween == 0 {
|
if daysBetween == 0 {
|
||||||
// Same-day doubleheader: leave during game, arrive at game time
|
// Same-day games: use shorter buffers (doubleheader or day trip)
|
||||||
postGameBuffer = 2.0 // Leave during/right after game
|
postGameBuffer = 2.0 // Leave 2 hours after first game
|
||||||
preGameBuffer = 0.5 // Arrive closer to start time
|
preGameBuffer = 0.5 // Arrive 30min before next game
|
||||||
} else {
|
} else {
|
||||||
// Different days: use standard buffers
|
// Different days: use standard buffers
|
||||||
postGameBuffer = gameEndBufferHours // 3.0 hours
|
postGameBuffer = gameEndBufferHours // 3.0 hours
|
||||||
|
|||||||
@@ -57,4 +57,58 @@ enum RouteFilters {
|
|||||||
return Array(violatingCities).sorted()
|
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
|
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 lastGameDate = sortedGames.last?.gameDate ?? Date()
|
||||||
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
|
||||||
|
|
||||||
return ItineraryStop(
|
return ItineraryStop(
|
||||||
city: city,
|
city: city,
|
||||||
@@ -336,7 +335,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
coordinate: coordinate,
|
coordinate: coordinate,
|
||||||
games: sortedGames.map { $0.id },
|
games: sortedGames.map { $0.id },
|
||||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||||
departureDate: departureDateValue,
|
departureDate: lastGameDate,
|
||||||
location: location,
|
location: location,
|
||||||
firstGameStart: sortedGames.first?.startTime
|
firstGameStart: sortedGames.first?.startTime
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -368,9 +368,8 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
address: stadium?.fullAddress
|
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 lastGameDate = sortedGames.last?.gameDate ?? Date()
|
||||||
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
|
||||||
|
|
||||||
return ItineraryStop(
|
return ItineraryStop(
|
||||||
city: city,
|
city: city,
|
||||||
@@ -378,7 +377,7 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
coordinate: coordinate,
|
coordinate: coordinate,
|
||||||
games: sortedGames.map { $0.id },
|
games: sortedGames.map { $0.id },
|
||||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||||
departureDate: departureDateValue,
|
departureDate: lastGameDate,
|
||||||
location: location,
|
location: location,
|
||||||
firstGameStart: sortedGames.first?.startTime
|
firstGameStart: sortedGames.first?.startTime
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ enum TravelEstimator {
|
|||||||
// MARK: - Travel Estimation
|
// MARK: - Travel Estimation
|
||||||
|
|
||||||
/// Estimates a travel segment between two stops.
|
/// 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(
|
static func estimate(
|
||||||
from: ItineraryStop,
|
from: ItineraryStop,
|
||||||
to: ItineraryStop,
|
to: ItineraryStop,
|
||||||
@@ -30,6 +30,12 @@ enum TravelEstimator {
|
|||||||
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
||||||
let drivingHours = distanceMiles / averageSpeedMph
|
let drivingHours = distanceMiles / averageSpeedMph
|
||||||
|
|
||||||
|
// Maximum allowed: 2 days of driving
|
||||||
|
let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0
|
||||||
|
if drivingHours > maxAllowedHours {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return TravelSegment(
|
return TravelSegment(
|
||||||
fromLocation: from.location,
|
fromLocation: from.location,
|
||||||
toLocation: to.location,
|
toLocation: to.location,
|
||||||
@@ -40,7 +46,7 @@ enum TravelEstimator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Estimates a travel segment between two LocationInputs.
|
/// 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(
|
static func estimate(
|
||||||
from: LocationInput,
|
from: LocationInput,
|
||||||
to: LocationInput,
|
to: LocationInput,
|
||||||
@@ -56,6 +62,12 @@ enum TravelEstimator {
|
|||||||
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
|
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
|
||||||
let drivingHours = distanceMiles / averageSpeedMph
|
let drivingHours = distanceMiles / averageSpeedMph
|
||||||
|
|
||||||
|
// Maximum allowed: 2 days of driving
|
||||||
|
let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0
|
||||||
|
if drivingHours > maxAllowedHours {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return TravelSegment(
|
return TravelSegment(
|
||||||
fromLocation: from,
|
fromLocation: from,
|
||||||
toLocation: to,
|
toLocation: to,
|
||||||
|
|||||||
Reference in New Issue
Block a user