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:
Trey t
2026-01-11 01:18:52 -06:00
parent 1bd248c255
commit 55c6d6e5e8
6 changed files with 83 additions and 14 deletions

View File

@@ -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 }

View File

@@ -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

View File

@@ -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
}
} }

View File

@@ -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
) )

View File

@@ -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
) )

View File

@@ -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,