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:
@@ -16,7 +16,7 @@
|
||||
// 2. Generate all N-day windows (N = selectedTeamIds.count * 2)
|
||||
// 3. Filter to windows where each selected team has at least 1 home game
|
||||
// 4. Cap at 50 windows (sample if more exist)
|
||||
// 5. For each valid window, find routes using GameDAGRouter with team games as anchors
|
||||
// 5. For each valid window, find routes using anchor strategies + fallback search
|
||||
// 6. Rank by shortest duration + minimal miles
|
||||
// 7. Return top 10 results
|
||||
//
|
||||
@@ -168,9 +168,8 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
|
||||
for (windowIndex, window) in windowsToEvaluate.enumerated() {
|
||||
// Collect games in this window
|
||||
// Use one home game per team as anchors (the best one for route efficiency)
|
||||
var gamesInWindow: [Game] = []
|
||||
var anchorGameIds = Set<String>()
|
||||
var gamesByTeamInWindow: [String: [Game]] = [:]
|
||||
var hasAllTeamsInWindow = true
|
||||
|
||||
for teamId in selectedTeamIds {
|
||||
guard let teamGames = homeGamesByTeam[teamId] else { continue }
|
||||
@@ -181,32 +180,68 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
if teamGamesInWindow.isEmpty {
|
||||
// Window doesn't have a game for this team - skip this window
|
||||
// This shouldn't happen since we pre-filtered windows
|
||||
continue
|
||||
hasAllTeamsInWindow = false
|
||||
break
|
||||
}
|
||||
|
||||
// Add all games to the pool
|
||||
gamesInWindow.append(contentsOf: teamGamesInWindow)
|
||||
|
||||
// Mark the earliest game as anchor (must visit this team)
|
||||
if let earliestGame = teamGamesInWindow.sorted(by: { $0.startTime < $1.startTime }).first {
|
||||
anchorGameIds.insert(earliestGame.id)
|
||||
}
|
||||
gamesByTeamInWindow[teamId] = teamGamesInWindow
|
||||
}
|
||||
|
||||
// Skip if we don't have anchors for all teams
|
||||
guard anchorGameIds.count == selectedTeamIds.count else { continue }
|
||||
guard hasAllTeamsInWindow else { continue }
|
||||
|
||||
// Remove duplicate games (same game could be added multiple times if team plays multiple home games)
|
||||
let gamesInWindow = gamesByTeamInWindow.values.flatMap { $0 }
|
||||
let uniqueGames = Array(Set(gamesInWindow)).sorted { $0.startTime < $1.startTime }
|
||||
guard !uniqueGames.isEmpty else { continue }
|
||||
|
||||
// Find routes using GameDAGRouter with anchor games
|
||||
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
// Primary pass: earliest anchor set for each team.
|
||||
let earliestAnchorIds = Set(gamesByTeamInWindow.values.compactMap { games in
|
||||
games.min(by: { $0.startTime < $1.startTime })?.id
|
||||
})
|
||||
|
||||
var candidateRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: uniqueGames,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
anchorGameIds: earliestAnchorIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
var validRoutes = candidateRoutes.filter { route in
|
||||
routeCoversAllSelectedTeams(route, selectedTeamIds: selectedTeamIds)
|
||||
}
|
||||
|
||||
// Fallback pass: avoid over-constraining to earliest anchors.
|
||||
if validRoutes.isEmpty {
|
||||
let latestAnchorIds = Set(gamesByTeamInWindow.values.compactMap { games in
|
||||
games.max(by: { $0.startTime < $1.startTime })?.id
|
||||
})
|
||||
|
||||
if latestAnchorIds != earliestAnchorIds {
|
||||
let latestAnchorRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: uniqueGames,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: latestAnchorIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
candidateRoutes.append(contentsOf: latestAnchorRoutes)
|
||||
}
|
||||
|
||||
let noAnchorRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: uniqueGames,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
candidateRoutes.append(contentsOf: noAnchorRoutes)
|
||||
|
||||
candidateRoutes = deduplicateRoutes(candidateRoutes)
|
||||
validRoutes = candidateRoutes.filter { route in
|
||||
routeCoversAllSelectedTeams(route, selectedTeamIds: selectedTeamIds)
|
||||
}
|
||||
}
|
||||
|
||||
validRoutes = deduplicateRoutes(validRoutes)
|
||||
|
||||
// Build itineraries for valid routes
|
||||
for routeGames in validRoutes {
|
||||
@@ -228,12 +263,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
dateFormatter.dateFormat = "MMM d"
|
||||
let windowDesc = "\(dateFormatter.string(from: window.start)) - \(dateFormatter.string(from: window.end))"
|
||||
|
||||
let teamsVisited = routeGames.compactMap { game -> String? in
|
||||
if anchorGameIds.contains(game.id) {
|
||||
return request.teams[game.homeTeamId]?.abbreviation ?? game.homeTeamId
|
||||
}
|
||||
return nil
|
||||
}.joined(separator: ", ")
|
||||
let teamsVisited = orderedTeamLabels(for: routeGames, teams: request.teams)
|
||||
|
||||
let cities = stops.map { $0.city }.joined(separator: " -> ")
|
||||
|
||||
@@ -273,7 +303,7 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
)
|
||||
}
|
||||
|
||||
// Deduplicate options (same stops in same order)
|
||||
// Deduplicate options (same stop-day-game structure)
|
||||
let uniqueOptions = deduplicateOptions(allItineraryOptions)
|
||||
|
||||
// Sort by: shortest duration first, then fewest miles
|
||||
@@ -460,14 +490,19 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - Deduplication
|
||||
|
||||
/// Removes duplicate itinerary options (same stops in same order).
|
||||
/// Removes duplicate itinerary options (same stop-day-game structure).
|
||||
private func deduplicateOptions(_ options: [ItineraryOption]) -> [ItineraryOption] {
|
||||
var seen = Set<String>()
|
||||
var unique: [ItineraryOption] = []
|
||||
let calendar = Calendar.current
|
||||
|
||||
for option in options {
|
||||
// Create key from stop cities in order
|
||||
let key = option.stops.map { $0.city }.joined(separator: "-")
|
||||
// Key by stop city + day + game IDs to avoid collapsing distinct itineraries.
|
||||
let key = option.stops.map { stop in
|
||||
let day = Int(calendar.startOfDay(for: stop.arrivalDate).timeIntervalSince1970)
|
||||
let gameKey = stop.games.sorted().joined(separator: ",")
|
||||
return "\(stop.city)|\(day)|\(gameKey)"
|
||||
}.joined(separator: "->")
|
||||
if !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
unique.append(option)
|
||||
@@ -477,6 +512,43 @@ final class ScenarioEPlanner: ScenarioPlanner {
|
||||
return unique
|
||||
}
|
||||
|
||||
/// Removes duplicate game routes (same game IDs in any order).
|
||||
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
|
||||
var seen = Set<String>()
|
||||
var unique: [[Game]] = []
|
||||
|
||||
for route in routes {
|
||||
let key = route.map { $0.id }.sorted().joined(separator: "-")
|
||||
if !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
unique.append(route)
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
/// Returns true if a route includes at least one home game for each selected team.
|
||||
private func routeCoversAllSelectedTeams(_ route: [Game], selectedTeamIds: Set<String>) -> Bool {
|
||||
let homeTeamsInRoute = Set(route.map { $0.homeTeamId })
|
||||
return selectedTeamIds.isSubset(of: homeTeamsInRoute)
|
||||
}
|
||||
|
||||
/// Team labels in first-visit order for itinerary rationale text.
|
||||
private func orderedTeamLabels(for route: [Game], teams: [String: Team]) -> String {
|
||||
var seen = Set<String>()
|
||||
var labels: [String] = []
|
||||
|
||||
for game in route {
|
||||
let teamId = game.homeTeamId
|
||||
guard !seen.contains(teamId) else { continue }
|
||||
seen.insert(teamId)
|
||||
labels.append(teams[teamId]?.abbreviation ?? teamId)
|
||||
}
|
||||
|
||||
return labels.joined(separator: ", ")
|
||||
}
|
||||
|
||||
// MARK: - Trip Duration Calculation
|
||||
|
||||
/// Calculates trip duration in days for an itinerary option.
|
||||
|
||||
Reference in New Issue
Block a user