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

@@ -15,7 +15,7 @@ import CoreLocation
///
/// Input:
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
/// - must_stop: Optional. A location they must visit (filters to home games in that city)
/// - must_stop: Optional. One or more locations the route must include
///
/// Output:
/// - Success: Ranked list of itinerary options
@@ -30,8 +30,8 @@ import CoreLocation
/// - No date range returns .failure with .missingDateRange
/// - No games in date range returns .failure with .noGamesInRange
/// - With selectedRegions only includes games in those regions
/// - With mustStopLocation filters to home games in that city
/// - Empty games after must-stop filter .failure with .noGamesInRange
/// - With mustStopLocations route must include at least one game in each must-stop city
/// - Missing games for any must-stop city .failure with .noGamesInRange
/// - No valid routes from GameDAGRouter .failure with .noValidRoutes
/// - All routes fail ItineraryBuilder .failure with .constraintsUnsatisfiable
/// - Success returns sorted itineraries based on leisureLevel
@@ -109,33 +109,36 @@ final class ScenarioAPlanner: ScenarioPlanner {
//
// Step 2b: Filter by must-stop locations (if any)
//
// If user specified a must-stop city, filter to HOME games in that city.
// A "home game" means the stadium is in the must-stop city.
var filteredGames = gamesInRange
if let mustStop = request.mustStopLocation {
let mustStopCity = mustStop.name.lowercased()
filteredGames = gamesInRange.filter { game in
guard let stadium = request.stadiums[game.stadiumId] else { return false }
let stadiumCity = stadium.city.lowercased()
// Match if either contains the other (handles "Chicago" vs "Chicago, IL")
return stadiumCity.contains(mustStopCity) || mustStopCity.contains(stadiumCity)
// Must-stops are route constraints, not exclusive filters.
// Keep all games in range, then require routes to include each must-stop city.
let requiredMustStops = request.preferences.mustStopLocations.filter { stop in
!normalizeCityName(stop.name).isEmpty
}
if !requiredMustStops.isEmpty {
let missingMustStops = requiredMustStops.filter { mustStop in
!gamesInRange.contains { game in
gameMatchesCity(game, cityName: mustStop.name, stadiums: request.stadiums)
}
}
if filteredGames.isEmpty {
if !missingMustStops.isEmpty {
let violations = missingMustStops.map { missing in
ConstraintViolation(
type: .mustStop,
description: "No home games found in \(missing.name) during selected dates",
severity: .error
)
}
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .mustStop,
description: "No home games found in \(mustStop.name) during selected dates",
severity: .error
)
]
violations: violations
)
)
}
}
let filteredGames = gamesInRange
//
// Step 3: Find ALL geographically sensible route variations
@@ -177,6 +180,18 @@ final class ScenarioAPlanner: ScenarioPlanner {
// Deduplicate routes (same game IDs)
validRoutes = deduplicateRoutes(validRoutes)
// Enforce must-stop coverage after route generation so non-must-stop games can
// still be included as connective "bonus" cities.
if !requiredMustStops.isEmpty {
validRoutes = validRoutes.filter { route in
routeSatisfiesMustStops(
route,
mustStops: requiredMustStops,
stadiums: request.stadiums
)
}
}
print("🔍 ScenarioA: filteredGames=\(filteredGames.count), validRoutes=\(validRoutes.count)")
if let firstRoute = validRoutes.first {
print("🔍 ScenarioA: First route has \(firstRoute.count) games")
@@ -185,13 +200,16 @@ final class ScenarioAPlanner: ScenarioPlanner {
}
if validRoutes.isEmpty {
let noMustStopSatisfyingRoutes = !requiredMustStops.isEmpty
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "No geographically sensible route found for games in this date range",
type: noMustStopSatisfyingRoutes ? .mustStop : .geographicSanity,
description: noMustStopSatisfyingRoutes
? "No valid route can include all required must-stop cities"
: "No geographically sensible route found for games in this date range",
severity: .error
)
]
@@ -406,6 +424,39 @@ final class ScenarioAPlanner: ScenarioPlanner {
return unique
}
private func routeSatisfiesMustStops(
_ route: [Game],
mustStops: [LocationInput],
stadiums: [String: Stadium]
) -> Bool {
mustStops.allSatisfy { mustStop in
route.contains { game in
gameMatchesCity(game, cityName: mustStop.name, stadiums: stadiums)
}
}
}
private func gameMatchesCity(
_ game: Game,
cityName: String,
stadiums: [String: Stadium]
) -> Bool {
guard let stadium = stadiums[game.stadiumId] else { return false }
let targetCity = normalizeCityName(cityName)
let stadiumCity = normalizeCityName(stadium.city)
guard !targetCity.isEmpty, !stadiumCity.isEmpty else { return false }
return stadiumCity == targetCity || stadiumCity.contains(targetCity) || targetCity.contains(stadiumCity)
}
private func normalizeCityName(_ value: String) -> String {
let cityPart = value.split(separator: ",", maxSplits: 1).first.map(String.init) ?? value
return cityPart
.lowercased()
.replacingOccurrences(of: ".", with: "")
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
}
// MARK: - Regional Route Finding
/// Finds routes by running beam search separately for each geographic region.