Initial commit: SportsTime trip planning app
- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
265
SportsTime/Planning/Engine/ScenarioAPlanner.swift
Normal file
265
SportsTime/Planning/Engine/ScenarioAPlanner.swift
Normal file
@@ -0,0 +1,265 @@
|
||||
//
|
||||
// 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
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Get all games that fall within the user's travel dates.
|
||||
// Sort by start time so we visit them in chronological order.
|
||||
let gamesInRange = request.allGames
|
||||
.filter { dateRange.contains($0.startTime) }
|
||||
.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 shared GeographicRouteExplorer for tree exploration.
|
||||
//
|
||||
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
|
||||
from: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
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] = []
|
||||
|
||||
for (index, routeGames) in validRoutes.enumerated() {
|
||||
// Build stops for this route
|
||||
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||
guard !stops.isEmpty else { continue }
|
||||
|
||||
// Calculate travel segments using shared ItineraryBuilder
|
||||
guard let itinerary = ItineraryBuilder.build(
|
||||
stops: stops,
|
||||
constraints: request.drivingConstraints,
|
||||
logPrefix: "[ScenarioA]"
|
||||
) else {
|
||||
// This route fails driving constraints, skip it
|
||||
print("[ScenarioA] Route \(index + 1) failed driving constraints, skipping")
|
||||
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
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Re-rank by number of games (already sorted, but update rank numbers)
|
||||
let rankedOptions = itineraryOptions.enumerated().map { index, option in
|
||||
ItineraryOption(
|
||||
rank: index + 1,
|
||||
stops: option.stops,
|
||||
travelSegments: option.travelSegments,
|
||||
totalDrivingHours: option.totalDrivingHours,
|
||||
totalDistanceMiles: option.totalDistanceMiles,
|
||||
geographicRationale: option.geographicRationale
|
||||
)
|
||||
}
|
||||
|
||||
print("[ScenarioA] Returning \(rankedOptions.count) itinerary options")
|
||||
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)
|
||||
///
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
|
||||
// Step 1: Group all games by their stadium
|
||||
// This lets us find ALL games at a stadium when we create that stop
|
||||
// Result: { stadiumId: [game1, game2, ...], ... }
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
}
|
||||
|
||||
// Step 2: Walk through games in chronological order
|
||||
// When we hit a stadium for the first time, create a stop with ALL games at that stadium
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = [] // Track which stadiums we've already made stops for
|
||||
|
||||
for game in games {
|
||||
// Skip if we already created a stop for this stadium
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
// Get ALL games at this stadium (not just this one)
|
||||
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Look up stadium info for location data
|
||||
let stadium = stadiums[game.stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
// Create the stop
|
||||
// - arrivalDate: when we need to arrive (first game at this stop)
|
||||
// - departureDate: when we can leave (after last game at this stop)
|
||||
// - games: IDs of all games we'll attend at this stop
|
||||
let stop = ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
stops.append(stop)
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user