Files
Sportstime/SportsTime/Planning/Engine/ScenarioEPlanner.swift
Trey t dbb0099776 chore: remove scraper, add docs, add marketing-videos gitignore
- Remove Scripts/ directory (scraper no longer needed)
- Add themed background documentation to CLAUDE.md
- Add .gitignore for marketing-videos to prevent node_modules tracking

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

499 lines
20 KiB
Swift

//
// ScenarioEPlanner.swift
// SportsTime
//
// Scenario E: Team-First planning.
// User selects teams (not dates), we find optimal trip windows across the season.
//
// Key Features:
// - Selected teams determine which home games to visit
// - Sliding window logic generates all N-day windows across the season
// - Windows are filtered to those where ALL selected teams have home games
// - Routes are built and ranked by duration + miles
//
// Sliding Window Algorithm:
// 1. Fetch all home games for selected teams (full season)
// 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
// 6. Rank by shortest duration + minimal miles
// 7. Return top 10 results
//
// Example:
// User selects: Yankees, Red Sox, Phillies
// Window duration: 3 * 2 = 6 days
// Valid windows: Any 6-day span where all 3 teams have a home game
// Output: Top 10 trip options, ranked by efficiency
//
import Foundation
import CoreLocation
/// Scenario E: Team-First planning
/// Input: selectedTeamIds, sports (for fetching games)
/// Output: Top 10 trip windows with routes visiting all selected teams' home stadiums
///
/// - Expected Behavior:
/// - No selected teams returns .failure with .missingTeamSelection
/// - Fewer than 2 teams returns .failure with .missingTeamSelection
/// - No home games found returns .failure with .noGamesInRange
/// - No valid windows (no overlap) returns .failure with .noValidRoutes
/// - Each returned route visits ALL selected teams' home stadiums
/// - Routes ranked by duration + miles (shorter is better)
/// - Returns maximum of 10 options
///
/// - Invariants:
/// - All routes contain at least one home game per selected team
/// - Window duration = selectedTeamIds.count * 2 days
/// - Maximum 50 windows evaluated (sampled if more exist)
/// - Maximum 10 results returned
///
final class ScenarioEPlanner: ScenarioPlanner {
// MARK: - Configuration
/// Maximum number of windows to evaluate (to prevent combinatorial explosion)
private let maxWindowsToEvaluate = 50
/// Maximum number of results to return
private let maxResultsToReturn = 10
// MARK: - ScenarioPlanner Protocol
func plan(request: PlanningRequest) -> ItineraryResult {
//
// Step 1: Validate team selection
//
let selectedTeamIds = request.preferences.selectedTeamIds
if selectedTeamIds.count < 2 {
return .failure(
PlanningFailure(
reason: .missingTeamSelection,
violations: [
ConstraintViolation(
type: .general,
description: "Team-First mode requires at least 2 teams selected",
severity: .error
)
]
)
)
}
//
// Step 2: Collect all HOME games for selected teams
//
// For Team-First mode, we only care about home games because
// the user wants to visit each team's home stadium.
var homeGamesByTeam: [String: [Game]] = [:]
var allHomeGames: [Game] = []
for game in request.allGames {
if selectedTeamIds.contains(game.homeTeamId) {
homeGamesByTeam[game.homeTeamId, default: []].append(game)
allHomeGames.append(game)
}
}
// Verify each team has at least one home game
for teamId in selectedTeamIds {
if homeGamesByTeam[teamId]?.isEmpty ?? true {
let teamName = request.teams[teamId]?.fullName ?? teamId
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .selectedGames,
description: "No home games found for \(teamName)",
severity: .error
)
]
)
)
}
}
print("🔍 ScenarioE Step 2: Found \(allHomeGames.count) total home games across \(selectedTeamIds.count) teams")
for (teamId, games) in homeGamesByTeam {
let teamName = request.teams[teamId]?.fullName ?? teamId
print("🔍 \(teamName): \(games.count) home games")
}
//
// Step 3: Generate sliding windows across the season
//
let windowDuration = request.preferences.teamFirstMaxDays
print("🔍 ScenarioE Step 3: Window duration = \(windowDuration) days")
let validWindows = generateValidWindows(
homeGamesByTeam: homeGamesByTeam,
windowDurationDays: windowDuration,
selectedTeamIds: selectedTeamIds
)
if validWindows.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .dateRange,
description: "No \(windowDuration)-day windows found where all selected teams have home games",
severity: .error
)
]
)
)
}
print("🔍 ScenarioE Step 3: Found \(validWindows.count) valid windows")
// Sample windows if too many exist
let windowsToEvaluate: [DateInterval]
if validWindows.count > maxWindowsToEvaluate {
windowsToEvaluate = sampleWindows(validWindows, count: maxWindowsToEvaluate)
print("🔍 ScenarioE Step 3: Sampled down to \(windowsToEvaluate.count) windows")
} else {
windowsToEvaluate = validWindows
}
//
// Step 4: For each valid window, find routes
//
var allItineraryOptions: [ItineraryOption] = []
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>()
for teamId in selectedTeamIds {
guard let teamGames = homeGamesByTeam[teamId] else { continue }
// Get all home games for this team in the window
let teamGamesInWindow = teamGames.filter { window.contains($0.startTime) }
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
}
// 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)
}
}
// Skip if we don't have anchors for all teams
guard anchorGameIds.count == selectedTeamIds.count else { continue }
// Remove duplicate games (same game could be added multiple times if team plays multiple home games)
let uniqueGames = Array(Set(gamesInWindow)).sorted { $0.startTime < $1.startTime }
// Find routes using GameDAGRouter with anchor games
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
from: uniqueGames,
stadiums: request.stadiums,
anchorGameIds: anchorGameIds,
allowRepeatCities: request.preferences.allowRepeatCities,
stopBuilder: buildStops
)
// Build itineraries for valid routes
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: "[ScenarioE]",
segmentValidator: ItineraryBuilder.arrivalBeforeGameStart()
) else {
continue
}
// Format window dates for rationale
let dateFormatter = DateFormatter()
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 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: "\(windowDesc): \(teamsVisited) - \(cities)"
)
allItineraryOptions.append(option)
}
// Early exit if we have enough options
if allItineraryOptions.count >= maxResultsToReturn * 5 {
print("🔍 ScenarioE: Early exit at window \(windowIndex + 1) with \(allItineraryOptions.count) options")
break
}
}
//
// Step 5: Rank and return top results
//
if allItineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .constraintsUnsatisfiable,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "No routes found that can visit all selected teams within driving constraints",
severity: .error
)
]
)
)
}
// Deduplicate options (same stops in same order)
let uniqueOptions = deduplicateOptions(allItineraryOptions)
// Sort by: shortest duration first, then fewest miles
let sortedOptions = uniqueOptions.sorted { optionA, optionB in
// Calculate trip duration in days
let daysA = tripDurationDays(for: optionA)
let daysB = tripDurationDays(for: optionB)
if daysA != daysB {
return daysA < daysB // Shorter trips first
}
return optionA.totalDistanceMiles < optionB.totalDistanceMiles // Fewer miles second
}
// Take top N and re-rank
let topOptions = Array(sortedOptions.prefix(maxResultsToReturn))
let rankedOptions = topOptions.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("🔍 ScenarioE: Returning \(rankedOptions.count) options")
return .success(rankedOptions)
}
// MARK: - Window Generation
/// Generates all valid N-day windows across the season where ALL selected teams have home games.
///
/// Algorithm:
/// 1. Find the overall date range (earliest to latest game across all teams)
/// 2. Slide a window across this range, one day at a time
/// 3. Keep windows where each team has at least one home game
///
private func generateValidWindows(
homeGamesByTeam: [String: [Game]],
windowDurationDays: Int,
selectedTeamIds: Set<String>
) -> [DateInterval] {
// Find overall date range
let allGames = homeGamesByTeam.values.flatMap { $0 }
guard let earliest = allGames.map({ $0.startTime }).min(),
let latest = allGames.map({ $0.startTime }).max() else {
return []
}
let calendar = Calendar.current
let earliestDay = calendar.startOfDay(for: earliest)
let latestDay = calendar.startOfDay(for: latest)
// Generate sliding windows
var validWindows: [DateInterval] = []
var currentStart = earliestDay
while let windowEnd = calendar.date(byAdding: .day, value: windowDurationDays, to: currentStart),
windowEnd <= calendar.date(byAdding: .day, value: 1, to: latestDay)! {
let window = DateInterval(start: currentStart, end: windowEnd)
// Check if all teams have at least one home game in this window
let allTeamsHaveGame = selectedTeamIds.allSatisfy { teamId in
guard let teamGames = homeGamesByTeam[teamId] else { return false }
return teamGames.contains { window.contains($0.startTime) }
}
if allTeamsHaveGame {
validWindows.append(window)
}
// Slide window by one day
guard let nextStart = calendar.date(byAdding: .day, value: 1, to: currentStart) else {
break
}
currentStart = nextStart
}
return validWindows
}
/// Samples windows evenly across the season to avoid clustering.
private func sampleWindows(_ windows: [DateInterval], count: Int) -> [DateInterval] {
guard windows.count > count else { return windows }
// Take evenly spaced samples
var sampled: [DateInterval] = []
let step = Double(windows.count) / Double(count)
for i in 0..<count {
let index = Int(Double(i) * step)
if index < windows.count {
sampled.append(windows[index])
}
}
return sampled
}
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
/// Groups consecutive games at the same stadium into one stop.
private func buildStops(
from games: [Game],
stadiums: [String: 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
var stops: [ItineraryStop] = []
var currentStadiumId: String? = 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: String,
stadiums: [String: 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: - Deduplication
/// Removes duplicate itinerary options (same stops in same order).
private func deduplicateOptions(_ options: [ItineraryOption]) -> [ItineraryOption] {
var seen = Set<String>()
var unique: [ItineraryOption] = []
for option in options {
// Create key from stop cities in order
let key = option.stops.map { $0.city }.joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
unique.append(option)
}
}
return unique
}
// MARK: - Trip Duration Calculation
/// Calculates trip duration in days for an itinerary option.
private func tripDurationDays(for option: ItineraryOption) -> Int {
guard let firstStop = option.stops.first,
let lastStop = option.stops.last else {
return 0
}
let calendar = Calendar.current
let days = calendar.dateComponents(
[.day],
from: calendar.startOfDay(for: firstStop.arrivalDate),
to: calendar.startOfDay(for: lastStop.departureDate)
).day ?? 0
return max(1, days + 1)
}
}