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:
348
SportsTime/Planning/Engine/ScenarioBPlanner.swift
Normal file
348
SportsTime/Planning/Engine/ScenarioBPlanner.swift
Normal file
@@ -0,0 +1,348 @@
|
||||
//
|
||||
// ScenarioBPlanner.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Scenario B: Selected games planning.
|
||||
// User selects specific games they MUST see. Those are fixed anchors that cannot be removed.
|
||||
//
|
||||
// Key Features:
|
||||
// - Selected games are "anchors" - they MUST appear in every valid route
|
||||
// - Sliding window logic when only trip duration (no specific dates) is provided
|
||||
// - Additional games from date range can be added if they fit geographically
|
||||
//
|
||||
// Sliding Window Algorithm:
|
||||
// When user provides selected games + day span (e.g., 10 days) without specific dates:
|
||||
// 1. Find first and last selected game dates
|
||||
// 2. Generate all possible windows of the given duration that contain ALL selected games
|
||||
// 3. Window 1: Last selected game is on last day
|
||||
// 4. Window N: First selected game is on first day
|
||||
// 5. Explore routes for each window, return best options
|
||||
//
|
||||
// Example:
|
||||
// Selected games on Jan 5, Jan 8, Jan 12. Day span = 10 days.
|
||||
// - Window 1: Jan 3-12 (Jan 12 is last day)
|
||||
// - Window 2: Jan 4-13
|
||||
// - Window 3: Jan 5-14 (Jan 5 is first day)
|
||||
// For each window, find all games and explore routes with selected as anchors.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Scenario B: Selected games planning
|
||||
/// Input: selected_games, date_range (or trip_duration), optional must_stop
|
||||
/// Output: Itinerary options connecting all selected games with possible bonus games
|
||||
final class ScenarioBPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - ScenarioPlanner Protocol
|
||||
|
||||
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||
|
||||
let selectedGames = request.selectedGames
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 1: Validate selected games exist
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if selectedGames.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noValidRoutes,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .selectedGames,
|
||||
description: "No games selected",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2: Generate date ranges (sliding window or single range)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let dateRanges = generateDateRanges(
|
||||
selectedGames: selectedGames,
|
||||
request: request
|
||||
)
|
||||
|
||||
if dateRanges.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingDateRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .dateRange,
|
||||
description: "Cannot determine valid date range for selected games",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 3: For each date range, find routes with anchors
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let anchorGameIds = Set(selectedGames.map { $0.id })
|
||||
var allItineraryOptions: [ItineraryOption] = []
|
||||
|
||||
for dateRange in dateRanges {
|
||||
// Find all games in this date range
|
||||
let gamesInRange = request.allGames
|
||||
.filter { dateRange.contains($0.startTime) }
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Skip if no games (shouldn't happen if date range is valid)
|
||||
guard !gamesInRange.isEmpty else { continue }
|
||||
|
||||
// Verify all selected games are in range
|
||||
let selectedInRange = selectedGames.allSatisfy { game in
|
||||
dateRange.contains(game.startTime)
|
||||
}
|
||||
guard selectedInRange else { continue }
|
||||
|
||||
// Find all sensible routes that include the anchor games
|
||||
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
|
||||
from: gamesInRange,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
// Build itineraries for each valid route
|
||||
for routeGames in validRoutes {
|
||||
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||
guard !stops.isEmpty else { continue }
|
||||
|
||||
// Use shared ItineraryBuilder with arrival time validator
|
||||
guard let itinerary = ItineraryBuilder.build(
|
||||
stops: stops,
|
||||
constraints: request.drivingConstraints,
|
||||
logPrefix: "[ScenarioB]",
|
||||
segmentValidator: ItineraryBuilder.arrivalBeforeGameStart()
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let selectedCount = routeGames.filter { anchorGameIds.contains($0.id) }.count
|
||||
let bonusCount = routeGames.count - selectedCount
|
||||
let cities = stops.map { $0.city }.joined(separator: " → ")
|
||||
|
||||
let option = ItineraryOption(
|
||||
rank: 0, // Will re-rank later
|
||||
stops: itinerary.stops,
|
||||
travelSegments: itinerary.travelSegments,
|
||||
totalDrivingHours: itinerary.totalDrivingHours,
|
||||
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||
geographicRationale: "\(selectedCount) selected + \(bonusCount) bonus games: \(cities)"
|
||||
)
|
||||
allItineraryOptions.append(option)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 4: Return ranked results
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if allItineraryOptions.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .constraintsUnsatisfiable,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .geographicSanity,
|
||||
description: "Cannot create a geographically sensible route connecting selected games",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort by total games (most first), then by driving hours (less first)
|
||||
let sorted = allItineraryOptions.sorted { a, b in
|
||||
if a.stops.flatMap({ $0.games }).count != b.stops.flatMap({ $0.games }).count {
|
||||
return a.stops.flatMap({ $0.games }).count > b.stops.flatMap({ $0.games }).count
|
||||
}
|
||||
return a.totalDrivingHours < b.totalDrivingHours
|
||||
}
|
||||
|
||||
// Re-rank and limit
|
||||
let rankedOptions = sorted.prefix(10).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("[ScenarioB] Returning \(rankedOptions.count) itinerary options")
|
||||
return .success(Array(rankedOptions))
|
||||
}
|
||||
|
||||
// MARK: - Date Range Generation (Sliding Window)
|
||||
|
||||
/// Generates all valid date ranges for the selected games.
|
||||
///
|
||||
/// Two modes:
|
||||
/// 1. If explicit date range provided: Use it directly (validate selected games fit)
|
||||
/// 2. If only trip duration provided: Generate sliding windows
|
||||
///
|
||||
/// Sliding Window Logic:
|
||||
/// Selected games: Jan 5, Jan 8, Jan 12. Duration: 10 days.
|
||||
/// - Window must contain all selected games
|
||||
/// - First window: ends on last selected game date (Jan 3-12)
|
||||
/// - Slide forward one day at a time
|
||||
/// - Last window: starts on first selected game date (Jan 5-14)
|
||||
///
|
||||
private func generateDateRanges(
|
||||
selectedGames: [Game],
|
||||
request: PlanningRequest
|
||||
) -> [DateInterval] {
|
||||
|
||||
// If explicit date range exists, use it
|
||||
if let dateRange = request.dateRange {
|
||||
return [dateRange]
|
||||
}
|
||||
|
||||
// Otherwise, use trip duration to create sliding windows
|
||||
let duration = request.preferences.effectiveTripDuration
|
||||
guard duration > 0 else { return [] }
|
||||
|
||||
// Find the span of selected games
|
||||
let sortedGames = selectedGames.sorted { $0.startTime < $1.startTime }
|
||||
guard let firstGame = sortedGames.first,
|
||||
let lastGame = sortedGames.last else {
|
||||
return []
|
||||
}
|
||||
|
||||
let firstGameDate = Calendar.current.startOfDay(for: firstGame.startTime)
|
||||
let lastGameDate = Calendar.current.startOfDay(for: lastGame.startTime)
|
||||
|
||||
// Calculate how many days the selected games span
|
||||
let gameSpanDays = Calendar.current.dateComponents(
|
||||
[.day],
|
||||
from: firstGameDate,
|
||||
to: lastGameDate
|
||||
).day ?? 0
|
||||
|
||||
// If selected games span more days than trip duration, can't fit
|
||||
if gameSpanDays >= duration {
|
||||
// Just return one window that exactly covers the games
|
||||
let start = firstGameDate
|
||||
let end = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: gameSpanDays + 1,
|
||||
to: start
|
||||
) ?? lastGameDate
|
||||
return [DateInterval(start: start, end: end)]
|
||||
}
|
||||
|
||||
// Generate sliding windows
|
||||
var dateRanges: [DateInterval] = []
|
||||
|
||||
// First window: last selected game is on last day of window
|
||||
// Window end = lastGameDate + 1 day (to include the game)
|
||||
// Window start = end - duration days
|
||||
let firstWindowEnd = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: 1,
|
||||
to: lastGameDate
|
||||
)!
|
||||
let firstWindowStart = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: -duration,
|
||||
to: firstWindowEnd
|
||||
)!
|
||||
|
||||
// Last window: first selected game is on first day of window
|
||||
// Window start = firstGameDate
|
||||
// Window end = start + duration days
|
||||
let lastWindowStart = firstGameDate
|
||||
let lastWindowEnd = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: duration,
|
||||
to: lastWindowStart
|
||||
)!
|
||||
|
||||
// Slide from first window to last window
|
||||
var currentStart = firstWindowStart
|
||||
while currentStart <= lastWindowStart {
|
||||
let windowEnd = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: duration,
|
||||
to: currentStart
|
||||
)!
|
||||
|
||||
let window = DateInterval(start: currentStart, end: windowEnd)
|
||||
dateRanges.append(window)
|
||||
|
||||
// Slide forward one day
|
||||
currentStart = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: 1,
|
||||
to: currentStart
|
||||
)!
|
||||
}
|
||||
|
||||
print("[ScenarioB] Generated \(dateRanges.count) sliding windows for \(duration)-day trip")
|
||||
return dateRanges
|
||||
}
|
||||
|
||||
// MARK: - Stop Building
|
||||
|
||||
/// Converts a list of games into itinerary stops.
|
||||
/// Groups games by stadium, creates one stop per unique stadium.
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
|
||||
// Group games by stadium
|
||||
var stadiumGames: [UUID: [Game]] = [:]
|
||||
for game in games {
|
||||
stadiumGames[game.stadiumId, default: []].append(game)
|
||||
}
|
||||
|
||||
// Create stops in chronological order (first game at each stadium)
|
||||
var stops: [ItineraryStop] = []
|
||||
var processedStadiums: Set<UUID> = []
|
||||
|
||||
for game in games {
|
||||
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||
processedStadiums.insert(game.stadiumId)
|
||||
|
||||
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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