feat: improve planning engine travel handling, itinerary reordering, and scenario planners

Add TravelInfo initializers and city normalization helpers to fix repeat
city-pair disambiguation. Improve drag-and-drop reordering with segment
index tracking and source-row-aware zone calculation. Enhance all five
scenario planners with better next-day departure handling and travel
segment placement. Add comprehensive tests across all planners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-13 08:55:23 -06:00
parent 1c97f35754
commit 9736773475
19 changed files with 928 additions and 171 deletions

View File

@@ -18,7 +18,7 @@ import CoreLocation
/// - date_range: Required. The trip dates.
/// - selectedRegions: Optional. Filter to specific regions.
/// - useHomeLocation: Whether to start/end from user's home.
/// - startLocation: Required if useHomeLocation is true.
/// - startLocation: Used as start/end home stop when provided.
///
/// Output:
/// - Success: Ranked list of itinerary options
@@ -173,6 +173,11 @@ final class ScenarioDPlanner: ScenarioPlanner {
//
// Step 5: Prepare for routing
//
let homeLocation: LocationInput? = {
guard request.preferences.useHomeLocation else { return nil }
return request.startLocation
}()
// NOTE: We do NOT filter by repeat city here. The GameDAGRouter handles
// allowRepeatCities internally, which allows it to pick the optimal game
// per city for route feasibility (e.g., pick July 29 Anaheim instead of
@@ -232,10 +237,13 @@ final class ScenarioDPlanner: ScenarioPlanner {
for (index, routeGames) in validRoutes.enumerated() {
// Build stops for this route
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
var stops = buildStops(from: routeGames, stadiums: request.stadiums)
guard !stops.isEmpty else { continue }
if let homeLocation {
stops = buildStopsWithHomeEndpoints(home: homeLocation, gameStops: stops)
}
// Calculate travel segments using shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
stops: stops,
@@ -401,6 +409,45 @@ final class ScenarioDPlanner: ScenarioPlanner {
)
}
/// Wraps game stops with optional home start/end waypoints.
private func buildStopsWithHomeEndpoints(
home: LocationInput,
gameStops: [ItineraryStop]
) -> [ItineraryStop] {
guard !gameStops.isEmpty else { return [] }
let calendar = Calendar.current
let firstGameDay = gameStops.first?.arrivalDate ?? Date()
let startDay = calendar.date(byAdding: .day, value: -1, to: firstGameDay) ?? firstGameDay
let startStop = ItineraryStop(
city: home.name,
state: "",
coordinate: home.coordinate,
games: [],
arrivalDate: startDay,
departureDate: startDay,
location: home,
firstGameStart: nil
)
let lastGameDay = gameStops.last?.departureDate ?? firstGameDay
let endDay = calendar.date(byAdding: .day, value: 1, to: lastGameDay) ?? lastGameDay
let endStop = ItineraryStop(
city: home.name,
state: "",
coordinate: home.coordinate,
games: [],
arrivalDate: endDay,
departureDate: endDay,
location: home,
firstGameStart: nil
)
return [startStop] + gameStops + [endStop]
}
// MARK: - Route Deduplication
/// Removes duplicate routes (routes with identical game IDs).