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

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