Files
Sportstime/SportsTime/Planning/Engine/ScenarioAPlanner.swift
Trey t 55c6d6e5e8 feat(planning): add trip filtering and fix departure date logic
- Add Trip.status property for status tracking
- Add RouteFilters trip list methods (filterBySport, filterByDateRange, filterByStatus, applyFilters)
- Add TravelEstimator max driving hours validation
- Fix ScenarioA/B departureDate to use last game day (not day after)
- Update GameDAGRouter comments for buffer logic

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 01:18:52 -06:00

410 lines
17 KiB
Swift

//
// ScenarioAPlanner.swift
// SportsTime
//
// Scenario A: Date range only planning.
// User provides a date range, we find all games and build chronological routes.
//
import Foundation
import CoreLocation
/// Scenario A: Date range planning
///
/// This is the simplest scenario - user just picks a date range and we find games.
///
/// Input:
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
/// - must_stop: Optional. A location they must visit (not yet implemented)
///
/// Output:
/// - Success: Ranked list of itinerary options
/// - Failure: Explicit error with reason (no games, dates invalid, etc.)
///
/// Example:
/// User selects Jan 5-10, 2026
/// We find: Lakers (Jan 5), Warriors (Jan 7), Kings (Jan 9)
/// Output: Single itinerary visiting LA SF Sacramento in order
///
final class ScenarioAPlanner: ScenarioPlanner {
// MARK: - ScenarioPlanner Protocol
/// Main entry point for Scenario A planning.
///
/// Flow:
/// 1. Validate inputs (date range must exist)
/// 2. Find all games within the date range
/// 3. Convert games to stops (grouping by stadium)
/// 4. Calculate travel between stops
/// 5. Return the complete itinerary
///
/// Failure cases:
/// - No date range provided .missingDateRange
/// - No games in date range .noGamesInRange
/// - Can't build valid route .constraintsUnsatisfiable
///
func plan(request: PlanningRequest) -> ItineraryResult {
//
// Step 1: Validate date range exists
//
// Scenario A requires a date range. Without it, we can't filter games.
guard let dateRange = request.dateRange else {
return .failure(
PlanningFailure(
reason: .missingDateRange,
violations: []
)
)
}
//
// Step 2: Filter games within date range and selected regions
//
// Get all games that fall within the user's travel dates.
// Sort by start time so we visit them in chronological order.
let selectedRegions = request.preferences.selectedRegions
let gamesInRange = request.allGames
.filter { game in
// Must be in date range
guard dateRange.contains(game.startTime) else { return false }
// Must be in selected region (if regions specified)
if !selectedRegions.isEmpty {
guard let stadium = request.stadiums[game.stadiumId] else { return false }
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
return selectedRegions.contains(gameRegion)
}
return true
}
.sorted { $0.startTime < $1.startTime }
// No games? Nothing to plan.
if gamesInRange.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: []
)
)
}
//
// Step 3: Find ALL geographically sensible route variations
//
// Not all games in the date range may form a sensible route.
// Example: NY (Jan 5), TX (Jan 6), SC (Jan 7), CA (Jan 8)
// - Including all = zig-zag nightmare
// - Option 1: NY, TX, DEN, NM, CA (skip SC)
// - Option 2: NY, SC, DEN, NM, CA (skip TX)
// - etc.
//
// We explore ALL valid combinations and return multiple options.
// Uses GameDAGRouter for polynomial-time beam search.
//
// Run beam search BOTH globally AND per-region to get diverse routes:
// - Global search finds cross-region routes
// - Per-region search ensures we have good regional options too
// Travel style filtering happens at UI layer.
//
var validRoutes: [[Game]] = []
// Global beam search (finds cross-region routes)
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
allowRepeatCities: request.preferences.allowRepeatCities,
stopBuilder: buildStops
)
validRoutes.append(contentsOf: globalRoutes)
// Per-region beam search (ensures good regional options)
let regionalRoutes = findRoutesPerRegion(
games: gamesInRange,
stadiums: request.stadiums,
allowRepeatCities: request.preferences.allowRepeatCities
)
validRoutes.append(contentsOf: regionalRoutes)
// Deduplicate routes (same game IDs)
validRoutes = deduplicateRoutes(validRoutes)
print("🔍 ScenarioA: gamesInRange=\(gamesInRange.count), validRoutes=\(validRoutes.count)")
if let firstRoute = validRoutes.first {
print("🔍 ScenarioA: First route has \(firstRoute.count) games")
let cities = firstRoute.compactMap { request.stadiums[$0.stadiumId]?.city }
print("🔍 ScenarioA: Route cities: \(cities)")
}
if validRoutes.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "No geographically sensible route found for games in this date range",
severity: .error
)
]
)
)
}
//
// Step 4: Build itineraries for each valid route
//
// For each valid game combination, build stops and calculate travel.
// Some routes may fail driving constraints - filter those out.
//
var itineraryOptions: [ItineraryOption] = []
var routesAttempted = 0
var routesFailed = 0
for (index, routeGames) in validRoutes.enumerated() {
routesAttempted += 1
// Build stops for this route
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
// Debug: show stops created from games
let stopCities = stops.map { "\($0.city)(\($0.games.count)g)" }
print("🔍 ScenarioA: Route \(index) - \(routeGames.count) games → \(stops.count) stops: \(stopCities)")
guard !stops.isEmpty else {
routesFailed += 1
print("⚠️ ScenarioA: Route \(index) - buildStops returned empty")
continue
}
// Calculate travel segments using shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
stops: stops,
constraints: request.drivingConstraints
) else {
// This route fails driving constraints, skip it
routesFailed += 1
print("⚠️ ScenarioA: Route \(index) - ItineraryBuilder.build failed")
continue
}
// Create the option
let cities = stops.map { $0.city }.joined(separator: "")
let option = ItineraryOption(
rank: index + 1,
stops: itinerary.stops,
travelSegments: itinerary.travelSegments,
totalDrivingHours: itinerary.totalDrivingHours,
totalDistanceMiles: itinerary.totalDistanceMiles,
geographicRationale: "\(stops.count) games: \(cities)"
)
itineraryOptions.append(option)
}
//
// Step 5: Return ranked results
//
// If no routes passed all constraints, fail.
// Otherwise, return all valid options for the user to choose from.
//
if itineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .constraintsUnsatisfiable,
violations: [
ConstraintViolation(
type: .drivingTime,
description: "No routes satisfy driving constraints",
severity: .error
)
]
)
)
}
// Sort and rank based on leisure level
let leisureLevel = request.preferences.leisureLevel
// Debug: show all options before sorting
print("🔍 ScenarioA: \(itineraryOptions.count) itinerary options before sorting:")
for (i, opt) in itineraryOptions.enumerated() {
print(" Option \(i): \(opt.stops.count) stops, \(opt.totalGames) games, \(String(format: "%.1f", opt.totalDrivingHours))h driving")
}
let rankedOptions = ItineraryOption.sortByLeisure(
itineraryOptions,
leisureLevel: leisureLevel
)
print("🔍 ScenarioA: Returning \(rankedOptions.count) options after sorting")
if let first = rankedOptions.first {
print("🔍 ScenarioA: First option has \(first.stops.count) stops")
}
return .success(rankedOptions)
}
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
///
/// The goal: Create one stop per stadium, in the order we first encounter each stadium
/// when walking through games chronologically.
///
/// Example:
/// Input games (already sorted by date):
/// 1. Jan 5 - Lakers @ Staples Center (LA)
/// 2. Jan 6 - Clippers @ Staples Center (LA) <- same stadium as #1
/// 3. Jan 8 - Warriors @ Chase Center (SF)
///
/// Output stops:
/// 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 [] }
// 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]
}
}
// 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 same day as last game
let lastGameDate = sortedGames.last?.gameDate ?? Date()
return ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: lastGameDate,
location: location,
firstGameStart: sortedGames.first?.startTime
)
}
// MARK: - Route Deduplication
/// Removes duplicate routes (routes with identical game IDs).
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
var seen = Set<String>()
var unique: [[Game]] = []
for route in routes {
let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
unique.append(route)
}
}
return unique
}
// MARK: - Regional Route Finding
/// Finds routes by running beam search separately for each geographic region.
/// This ensures we get diverse options from East, Central, and West coasts.
private func findRoutesPerRegion(
games: [Game],
stadiums: [UUID: Stadium],
allowRepeatCities: Bool
) -> [[Game]] {
// Partition games by region
var gamesByRegion: [Region: [Game]] = [:]
for game in games {
guard let stadium = stadiums[game.stadiumId] else { continue }
let coord = stadium.coordinate
let region = Region.classify(longitude: coord.longitude)
// Only consider actual regions, not cross-country
if region != .crossCountry {
gamesByRegion[region, default: []].append(game)
}
}
print("🔍 ScenarioA Regional: Partitioned \(games.count) games into \(gamesByRegion.count) regions")
for (region, regionGames) in gamesByRegion {
print(" \(region.shortName): \(regionGames.count) games")
}
// Run beam search for each region
var allRoutes: [[Game]] = []
for (region, regionGames) in gamesByRegion {
guard !regionGames.isEmpty else { continue }
let regionRoutes = GameDAGRouter.findAllSensibleRoutes(
from: regionGames,
stadiums: stadiums,
allowRepeatCities: allowRepeatCities,
stopBuilder: buildStops
)
print("🔍 ScenarioA Regional: \(region.shortName) produced \(regionRoutes.count) routes")
allRoutes.append(contentsOf: regionRoutes)
}
return allRoutes
}
}