From 55c6d6e5e8be09a63ffef38b00bb6e7dd6c506af Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 11 Jan 2026 01:18:52 -0600 Subject: [PATCH] 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 --- SportsTime/Core/Models/Domain/Trip.swift | 5 +- .../Planning/Engine/GameDAGRouter.swift | 12 +++-- SportsTime/Planning/Engine/RouteFilters.swift | 54 +++++++++++++++++++ .../Planning/Engine/ScenarioAPlanner.swift | 5 +- .../Planning/Engine/ScenarioBPlanner.swift | 5 +- .../Planning/Engine/TravelEstimator.swift | 16 +++++- 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/SportsTime/Core/Models/Domain/Trip.swift b/SportsTime/Core/Models/Domain/Trip.swift index e0a865f..22b4d33 100644 --- a/SportsTime/Core/Models/Domain/Trip.swift +++ b/SportsTime/Core/Models/Domain/Trip.swift @@ -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 } diff --git a/SportsTime/Planning/Engine/GameDAGRouter.swift b/SportsTime/Planning/Engine/GameDAGRouter.swift index 81cc2e6..e8c17ae 100644 --- a/SportsTime/Planning/Engine/GameDAGRouter.swift +++ b/SportsTime/Planning/Engine/GameDAGRouter.swift @@ -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 diff --git a/SportsTime/Planning/Engine/RouteFilters.swift b/SportsTime/Planning/Engine/RouteFilters.swift index def90e4..e3d4873 100644 --- a/SportsTime/Planning/Engine/RouteFilters.swift +++ b/SportsTime/Planning/Engine/RouteFilters.swift @@ -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) -> [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? = 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 + } + } diff --git a/SportsTime/Planning/Engine/ScenarioAPlanner.swift b/SportsTime/Planning/Engine/ScenarioAPlanner.swift index 6bce5b7..d9768fb 100644 --- a/SportsTime/Planning/Engine/ScenarioAPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioAPlanner.swift @@ -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 ) diff --git a/SportsTime/Planning/Engine/ScenarioBPlanner.swift b/SportsTime/Planning/Engine/ScenarioBPlanner.swift index dd8a9e9..05659a3 100644 --- a/SportsTime/Planning/Engine/ScenarioBPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioBPlanner.swift @@ -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 ) diff --git a/SportsTime/Planning/Engine/TravelEstimator.swift b/SportsTime/Planning/Engine/TravelEstimator.swift index 60211fe..3c218b2 100644 --- a/SportsTime/Planning/Engine/TravelEstimator.swift +++ b/SportsTime/Planning/Engine/TravelEstimator.swift @@ -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,