Files
Sportstime/SportsTime/Planning/Engine/ScenarioBPlanner.swift
Trey t f5e509a9ae Add region-based filtering and route length diversity
- Add RegionMapSelector UI for geographic trip filtering (East/Central/West)
- Add RouteFilters module for allowRepeatCities preference
- Improve GameDAGRouter to preserve route length diversity
  - Routes now grouped by city count before scoring
  - Ensures 2-city trips appear alongside longer trips
  - Increased beam width and max options for better coverage
- Add TripOptionsView filters (max cities slider, pace filter)
- Remove TravelStyle section from trip creation (replaced by region selector)
- Clean up debug logging from DataProvider and ScenarioAPlanner

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

468 lines
19 KiB
Swift

//
// 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 })
let selectedRegions = request.preferences.selectedRegions
var allItineraryOptions: [ItineraryOption] = []
for dateRange in dateRanges {
// Find all games in this date range and selected regions
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)
// Note: Anchor games are always included regardless of region
if !selectedRegions.isEmpty && !anchorGameIds.contains(game.id) {
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 }
// 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
// Uses GameDAGRouter for polynomial-time beam search
// Run BOTH global and per-region search for diverse routes
var validRoutes: [[Game]] = []
// Global beam search (finds cross-region routes)
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: anchorGameIds,
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,
anchorGameIds: anchorGameIds,
allowRepeatCities: request.preferences.allowRepeatCities
)
validRoutes.append(contentsOf: regionalRoutes)
// Deduplicate
validRoutes = deduplicateRoutes(validRoutes)
// 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 and rank based on leisure level
let leisureLevel = request.preferences.leisureLevel
let rankedOptions = ItineraryOption.sortByLeisure(
allItineraryOptions,
leisureLevel: leisureLevel
)
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
let lastWindowStart = firstGameDate
// 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
)!
}
return dateRanges
}
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
/// Groups consecutive games at the same stadium into one stop.
/// Creates separate stops when visiting the same city with other cities in between.
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
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 day AFTER last game (we leave the next morning)
let lastGameDate = sortedGames.last?.gameDate ?? Date()
let departureDateValue = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
return ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: departureDateValue,
location: location,
firstGameStart: sortedGames.first?.startTime
)
}
// 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.
/// For Scenario B, routes must still contain all anchor games.
private func findRoutesPerRegion(
games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID>,
allowRepeatCities: Bool
) -> [[Game]] {
// First, determine which region(s) the anchor games are in
var anchorRegions = Set<Region>()
for game in games where anchorGameIds.contains(game.id) {
guard let stadium = stadiums[game.stadiumId] else { continue }
let coord = stadium.coordinate
let region = Region.classify(longitude: coord.longitude)
if region != .crossCountry {
anchorRegions.insert(region)
}
}
// Partition all 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)
if region != .crossCountry {
gamesByRegion[region, default: []].append(game)
}
}
print("🔍 ScenarioB Regional: Anchor games in regions: \(anchorRegions.map { $0.shortName })")
// Run beam search for each region that has anchor games
// (Other regions without anchor games would produce routes that don't satisfy anchors)
var allRoutes: [[Game]] = []
for region in anchorRegions {
guard let regionGames = gamesByRegion[region], !regionGames.isEmpty else { continue }
// Get anchor games in this region
let regionAnchorIds = anchorGameIds.filter { anchorId in
regionGames.contains { $0.id == anchorId }
}
let regionRoutes = GameDAGRouter.findAllSensibleRoutes(
from: regionGames,
stadiums: stadiums,
anchorGameIds: regionAnchorIds,
allowRepeatCities: allowRepeatCities,
stopBuilder: buildStops
)
print("🔍 ScenarioB Regional: \(region.shortName) produced \(regionRoutes.count) routes")
allRoutes.append(contentsOf: regionRoutes)
}
return allRoutes
}
// 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
}
}