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:
Trey t
2026-01-07 19:39:53 -06:00
parent 40a6f879e3
commit 4184af60b5
29 changed files with 140675 additions and 144310 deletions

View File

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

View File

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

View File

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

View File

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

View File

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