Refactor travel segments and simplify trip options
Travel segment architecture: - Remove departureTime/arrivalTime from TravelSegment (location-based, not date-based) - Fix travel sections appearing after destination instead of between cities - Fix missing travel segments when revisiting same city (consecutive grouping) - Remove unwanted rest day at end of trip Planning engine fixes: - All three planners now group only consecutive games at same stadium - Visiting A → B → A creates 3 stops with proper travel between UI simplification: - Remove redundant sort options (mostDriving/leastDriving, mostCities/leastCities) - Remove unused "Find Other Sports Along Route" toggle (was dead code) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -111,21 +111,30 @@ enum ItineraryBuilder {
|
||||
|
||||
// MARK: - Common Validators
|
||||
|
||||
/// Validator that ensures arrival time is before game start (with buffer).
|
||||
/// Validator that ensures travel duration allows arrival before game start.
|
||||
/// Used by Scenario B where selected games have fixed start times.
|
||||
///
|
||||
/// This checks if the travel duration is short enough that the user could
|
||||
/// theoretically leave after the previous game and arrive before the next.
|
||||
///
|
||||
/// - Parameter bufferSeconds: Time buffer before game start (default 1 hour)
|
||||
/// - Returns: Validator closure
|
||||
///
|
||||
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
|
||||
return { segment, _, toStop in
|
||||
return { segment, fromStop, toStop in
|
||||
guard let gameStart = toStop.firstGameStart else {
|
||||
return true // No game = no constraint
|
||||
}
|
||||
|
||||
// Check if there's enough time between departure point and game start
|
||||
// Departure assumed after previous day's activities (use departure date as baseline)
|
||||
let earliestDeparture = fromStop.departureDate
|
||||
let travelDuration = segment.durationSeconds
|
||||
let earliestArrival = earliestDeparture.addingTimeInterval(travelDuration)
|
||||
let deadline = gameStart.addingTimeInterval(-bufferSeconds)
|
||||
if segment.arrivalTime > deadline {
|
||||
print("[ItineraryBuilder] Cannot arrive in time: arrival \(segment.arrivalTime) > deadline \(deadline)")
|
||||
|
||||
if earliestArrival > deadline {
|
||||
print("[ItineraryBuilder] Cannot arrive in time: earliest arrival \(earliestArrival) > deadline \(deadline)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -226,63 +226,84 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
||||
/// Stop 1: Los Angeles (contains game 1 and 2)
|
||||
/// Stop 2: San Francisco (contains game 3)
|
||||
///
|
||||
/// Note: If you visit the same city, leave, and come back, that creates
|
||||
/// separate stops (one for each visit).
|
||||
///
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
guard !games.isEmpty else { return [] }
|
||||
|
||||
// Step 1: Group all games by their stadium
|
||||
// This lets us find ALL games at a stadium when we create that stop
|
||||
// Result: { stadiumId: [game1, game2, ...], ... }
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
// Sort games chronologically
|
||||
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Group consecutive games at the same stadium into stops
|
||||
// If you visit A, then B, then A again, that's 3 stops (A, B, A)
|
||||
var stops: [ItineraryStop] = []
|
||||
var currentStadiumId: UUID? = nil
|
||||
var currentGames: [Game] = []
|
||||
|
||||
for game in sortedGames {
|
||||
if game.stadiumId == currentStadiumId {
|
||||
// Same stadium as previous game - add to current group
|
||||
currentGames.append(game)
|
||||
} else {
|
||||
// Different stadium - finalize previous stop (if any) and start new one
|
||||
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||
stops.append(stop)
|
||||
}
|
||||
}
|
||||
currentStadiumId = game.stadiumId
|
||||
currentGames = [game]
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Walk through games in chronological order
|
||||
// When we hit a stadium for the first time, create a stop with ALL games at that stadium
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = [] // Track which stadiums we've already made stops for
|
||||
|
||||
for game in games {
|
||||
// Skip if we already created a stop for this stadium
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
// Get ALL games at this stadium (not just this one)
|
||||
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Look up stadium info for location data
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
// Create the stop
|
||||
// - arrivalDate: when we need to arrive (first game at this stop)
|
||||
// - departureDate: when we can leave (after last game at this stop)
|
||||
// - games: IDs of all games we'll attend at this stop
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
// Don't forget the last group
|
||||
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||
stops.append(stop)
|
||||
}
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
/// Creates an ItineraryStop from a group of games at the same stadium.
|
||||
private func createStop(
|
||||
from games: [Game],
|
||||
stadiumId: UUID,
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> ItineraryStop? {
|
||||
guard !games.isEmpty else { return nil }
|
||||
|
||||
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||
let stadium = stadiums[stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
// departureDate is day AFTER last game (we leave the next morning)
|
||||
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
||||
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
||||
|
||||
return ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: departureDateValue,
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -296,54 +296,82 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
// MARK: - Stop Building
|
||||
|
||||
/// Converts a list of games into itinerary stops.
|
||||
/// Groups games by stadium, creates one stop per unique stadium.
|
||||
/// Groups consecutive games at the same stadium into one stop.
|
||||
/// Creates separate stops when visiting the same city with other cities in between.
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
guard !games.isEmpty else { return [] }
|
||||
|
||||
// Group games by stadium
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
// Sort games chronologically
|
||||
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Group consecutive games at the same stadium
|
||||
var stops: [ItineraryStop] = []
|
||||
var currentStadiumId: UUID? = nil
|
||||
var currentGames: [Game] = []
|
||||
|
||||
for game in sortedGames {
|
||||
if game.stadiumId == currentStadiumId {
|
||||
// Same stadium as previous game - add to current group
|
||||
currentGames.append(game)
|
||||
} else {
|
||||
// Different stadium - finalize previous stop (if any) and start new one
|
||||
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||
stops.append(stop)
|
||||
}
|
||||
}
|
||||
currentStadiumId = game.stadiumId
|
||||
currentGames = [game]
|
||||
}
|
||||
}
|
||||
|
||||
// Create stops in chronological order (first game at each stadium)
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = []
|
||||
|
||||
for game in games {
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
// Don't forget the last group
|
||||
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||
stops.append(stop)
|
||||
}
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
/// Creates an ItineraryStop from a group of games at the same stadium.
|
||||
private func createStop(
|
||||
from games: [Game],
|
||||
stadiumId: UUID,
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> ItineraryStop? {
|
||||
guard !games.isEmpty else { return nil }
|
||||
|
||||
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||
let stadium = stadiums[stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
// departureDate is day AFTER last game (we leave the next morning)
|
||||
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
||||
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
||||
|
||||
return ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: departureDateValue,
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -432,53 +432,84 @@ final class ScenarioCPlanner: ScenarioPlanner {
|
||||
// MARK: - Stop Building
|
||||
|
||||
/// Converts games to stops (used by GeographicRouteExplorer callback).
|
||||
/// Groups consecutive games at the same stadium into one stop.
|
||||
/// Creates separate stops when visiting the same city with other cities in between.
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
guard !games.isEmpty else { return [] }
|
||||
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
// Sort games chronologically
|
||||
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Group consecutive games at the same stadium
|
||||
var stops: [ItineraryStop] = []
|
||||
var currentStadiumId: UUID? = nil
|
||||
var currentGames: [Game] = []
|
||||
|
||||
for game in sortedGames {
|
||||
if game.stadiumId == currentStadiumId {
|
||||
// Same stadium as previous game - add to current group
|
||||
currentGames.append(game)
|
||||
} else {
|
||||
// Different stadium - finalize previous stop (if any) and start new one
|
||||
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||
stops.append(stop)
|
||||
}
|
||||
}
|
||||
currentStadiumId = game.stadiumId
|
||||
currentGames = [game]
|
||||
}
|
||||
}
|
||||
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = []
|
||||
|
||||
for game in games {
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
// Don't forget the last group
|
||||
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||
stops.append(stop)
|
||||
}
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
/// Creates an ItineraryStop from a group of games at the same stadium.
|
||||
private func createStop(
|
||||
from games: [Game],
|
||||
stadiumId: UUID,
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> ItineraryStop? {
|
||||
guard !games.isEmpty else { return nil }
|
||||
|
||||
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||
let stadium = stadiums[stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
// departureDate is day AFTER last game (we leave the next morning)
|
||||
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
||||
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
||||
|
||||
return ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: departureDateValue,
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds stops with start and end location endpoints.
|
||||
private func buildStopsWithEndpoints(
|
||||
start: LocationInput,
|
||||
|
||||
@@ -36,18 +36,12 @@ enum TravelEstimator {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Calculate times (assume 8 AM departure)
|
||||
let departureTime = from.departureDate.addingTimeInterval(8 * 3600)
|
||||
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
||||
|
||||
return TravelSegment(
|
||||
fromLocation: from.location,
|
||||
toLocation: to.location,
|
||||
travelMode: .drive,
|
||||
distanceMeters: distanceMiles * 1609.34,
|
||||
durationSeconds: drivingHours * 3600,
|
||||
departureTime: departureTime,
|
||||
arrivalTime: arrivalTime
|
||||
durationSeconds: drivingHours * 3600
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,17 +67,12 @@ enum TravelEstimator {
|
||||
return nil
|
||||
}
|
||||
|
||||
let departureTime = Date()
|
||||
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
||||
|
||||
return TravelSegment(
|
||||
fromLocation: from,
|
||||
toLocation: to,
|
||||
travelMode: .drive,
|
||||
distanceMeters: distanceMeters * roadRoutingFactor,
|
||||
durationSeconds: drivingHours * 3600,
|
||||
departureTime: departureTime,
|
||||
arrivalTime: arrivalTime
|
||||
durationSeconds: drivingHours * 3600
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user