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 totalDrivingSeconds: Double
var score: TripScore?
var status: TripStatus
init(
id: UUID = UUID(),
@@ -29,7 +30,8 @@ struct Trip: Identifiable, Codable, Hashable {
totalGames: Int = 0,
totalDistanceMeters: Double = 0,
totalDrivingSeconds: Double = 0,
score: TripScore? = nil
score: TripScore? = nil,
status: TripStatus = .planned
) {
self.id = id
self.name = name
@@ -42,6 +44,7 @@ struct Trip: Identifiable, Codable, Hashable {
self.totalDistanceMeters = totalDistanceMeters
self.totalDrivingSeconds = totalDrivingSeconds
self.score = score
self.status = status
}
var totalDistanceMiles: Double { totalDistanceMeters * 0.000621371 }

View File

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

View File

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

View File

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

View File

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

View File

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