Address 16 issues from external audit: - Move StoreKit transaction listener ownership to StoreManager singleton with proper deinit - Remove noisy VoiceOver announcements, add missing accessibility on StatPill and BootstrapLoadingView - Replace String @retroactive Identifiable with IdentifiableShareCode wrapper - Add crash guard in AchievementEngine getContributingVisitIds + cache stadium lookups - Pre-compute GamesHistoryViewModel filtered properties to avoid redundant SwiftUI recomputation - Remove force-unwraps in ProgressMapView with safe guard-let fallback - Add diff-based update gating in ItineraryTableViewWrapper to prevent unnecessary reloads - Replace deprecated UIScreen.main with UIWindowScene lookup - Add deinit task cancellation in ScheduleViewModel and SuggestedTripsGenerator - Wrap ~234 unguarded print() calls across 27 files in #if DEBUG Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
523 lines
21 KiB
Swift
523 lines
21 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
|
|
///
|
|
/// - Expected Behavior:
|
|
/// - No selected games → returns .failure with .noValidRoutes
|
|
/// - No valid date ranges → returns .failure with .missingDateRange
|
|
/// - GameFirst mode → uses sliding windows with gameFirstTripDuration
|
|
/// - Explicit dateRange (non-gameFirst) → uses that range directly
|
|
/// - All anchor games MUST appear in every valid route
|
|
/// - Uses arrivalBeforeGameStart validator for travel segments
|
|
/// - No routes satisfy constraints → .failure with .constraintsUnsatisfiable
|
|
/// - Success → returns sorted itineraries based on leisureLevel
|
|
///
|
|
/// - Invariants:
|
|
/// - Every returned route contains ALL selected (anchor) games
|
|
/// - Anchor games cannot be dropped for geographic convenience
|
|
/// - Sliding window always spans from first to last selected game
|
|
/// - Date ranges always contain all selected game dates
|
|
///
|
|
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
|
|
)
|
|
]
|
|
)
|
|
)
|
|
}
|
|
|
|
// In explicit date-range mode, fail fast if selected anchors are out of range.
|
|
let isGameFirstMode = request.preferences.planningMode == .gameFirst
|
|
if !isGameFirstMode, let explicitRange = request.dateRange {
|
|
let outOfRangeAnchors = selectedGames.filter { !explicitRange.contains($0.startTime) }
|
|
if !outOfRangeAnchors.isEmpty {
|
|
return .failure(
|
|
PlanningFailure(
|
|
reason: .dateRangeViolation(games: outOfRangeAnchors),
|
|
violations: [
|
|
ConstraintViolation(
|
|
type: .dateRange,
|
|
description: "\(outOfRangeAnchors.count) selected game(s) are outside the requested date range",
|
|
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)
|
|
|
|
// Filter routes to only include those containing ALL anchor games
|
|
// This is critical: regional search uses filtered anchors, so we must
|
|
// verify each route satisfies the original anchor constraint
|
|
validRoutes = validRoutes.filter { route in
|
|
let routeGameIds = Set(route.map { $0.id })
|
|
return anchorGameIds.isSubset(of: routeGameIds)
|
|
}
|
|
|
|
// 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] {
|
|
|
|
// For gameFirst mode, ALWAYS use sliding windows with gameFirstTripDuration
|
|
// This generates all possible N-day windows containing the selected games
|
|
let isGameFirstMode = request.preferences.planningMode == .gameFirst
|
|
let duration = isGameFirstMode
|
|
? request.preferences.gameFirstTripDuration
|
|
: request.preferences.effectiveTripDuration
|
|
|
|
// If not gameFirst and explicit date range exists, use it directly
|
|
if !isGameFirstMode, let dateRange = request.dateRange {
|
|
return [dateRange]
|
|
}
|
|
|
|
// Use trip duration to create sliding windows
|
|
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
|
|
guard let firstWindowEnd = Calendar.current.date(
|
|
byAdding: .day,
|
|
value: 1,
|
|
to: lastGameDate
|
|
) else { return [] }
|
|
guard let firstWindowStart = Calendar.current.date(
|
|
byAdding: .day,
|
|
value: -duration,
|
|
to: firstWindowEnd
|
|
) else { return [] }
|
|
|
|
// 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 {
|
|
guard let windowEnd = Calendar.current.date(
|
|
byAdding: .day,
|
|
value: duration,
|
|
to: currentStart
|
|
) else { break }
|
|
|
|
let window = DateInterval(start: currentStart, end: windowEnd)
|
|
dateRanges.append(window)
|
|
|
|
// Slide forward one day
|
|
guard let nextStart = Calendar.current.date(
|
|
byAdding: .day,
|
|
value: 1,
|
|
to: currentStart
|
|
) else { break }
|
|
currentStart = nextStart
|
|
}
|
|
|
|
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: [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: - 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: [String: Stadium],
|
|
anchorGameIds: Set<String>,
|
|
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)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioB Regional: Anchor games in regions: \(anchorRegions.map { $0.shortName })")
|
|
#endif
|
|
|
|
// 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
|
|
)
|
|
|
|
#if DEBUG
|
|
print("🔍 ScenarioB Regional: \(region.shortName) produced \(regionRoutes.count) routes")
|
|
#endif
|
|
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 }.sorted().joined(separator: "-")
|
|
if !seen.contains(key) {
|
|
seen.insert(key)
|
|
unique.append(route)
|
|
}
|
|
}
|
|
|
|
return unique
|
|
}
|
|
|
|
}
|