- 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>
397 lines
14 KiB
Swift
397 lines
14 KiB
Swift
//
|
|
// ScheduleMatcher.swift
|
|
// SportsTime
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
/// Finds and scores candidate games for trip planning.
|
|
///
|
|
/// Updated for the new scenario-based planning:
|
|
/// - Scenario A (Date Range): Find games in date range, cluster by region
|
|
/// - Scenario B (Selected Games): Validate must-see games, find optional additions
|
|
/// - Scenario C (Start+End): Find games along directional corridor with progress check
|
|
struct ScheduleMatcher {
|
|
|
|
// MARK: - Find Candidate Games (Legacy + Scenario C Support)
|
|
|
|
/// Finds candidate games along a corridor between start and end.
|
|
/// Supports directional filtering for Scenario C.
|
|
///
|
|
/// - Parameters:
|
|
/// - request: Planning request with preferences and games
|
|
/// - startCoordinate: Starting location
|
|
/// - endCoordinate: Ending location
|
|
/// - enforceDirection: If true, only include games that make progress toward end
|
|
/// - Returns: Array of game candidates sorted by score
|
|
func findCandidateGames(
|
|
from request: PlanningRequest,
|
|
startCoordinate: CLLocationCoordinate2D,
|
|
endCoordinate: CLLocationCoordinate2D,
|
|
enforceDirection: Bool = false
|
|
) -> [GameCandidate] {
|
|
var candidates: [GameCandidate] = []
|
|
|
|
// Calculate the corridor between start and end
|
|
let corridor = RouteCorridorCalculator(
|
|
start: startCoordinate,
|
|
end: endCoordinate,
|
|
maxDetourFactor: detourFactorFor(request.preferences.leisureLevel)
|
|
)
|
|
|
|
for game in request.availableGames {
|
|
guard let stadium = request.stadiums[game.stadiumId],
|
|
let homeTeam = request.teams[game.homeTeamId],
|
|
let awayTeam = request.teams[game.awayTeamId] else {
|
|
continue
|
|
}
|
|
|
|
// Check if game is within date range
|
|
guard game.dateTime >= request.preferences.startDate,
|
|
game.dateTime <= request.preferences.endDate else {
|
|
continue
|
|
}
|
|
|
|
// Check sport filter
|
|
guard request.preferences.sports.contains(game.sport) else {
|
|
continue
|
|
}
|
|
|
|
// Calculate detour distance
|
|
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
|
|
|
|
// For directional routes, check if this stadium makes progress
|
|
if enforceDirection {
|
|
let distanceToEnd = corridor.distanceToEnd(from: stadium.coordinate)
|
|
let startDistanceToEnd = corridor.directDistance
|
|
|
|
// Skip if stadium is behind the start (going backwards)
|
|
if distanceToEnd > startDistanceToEnd * 1.1 { // 10% tolerance
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Skip if too far from route (unless must-see)
|
|
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
|
|
if !isMustSee && detourDistance > corridor.maxDetourDistance {
|
|
continue
|
|
}
|
|
|
|
// Score the game
|
|
let score = scoreGame(
|
|
game: game,
|
|
homeTeam: homeTeam,
|
|
awayTeam: awayTeam,
|
|
detourDistance: detourDistance,
|
|
isMustSee: isMustSee,
|
|
request: request
|
|
)
|
|
|
|
let candidate = GameCandidate(
|
|
id: game.id,
|
|
game: game,
|
|
stadium: stadium,
|
|
homeTeam: homeTeam,
|
|
awayTeam: awayTeam,
|
|
detourDistance: detourDistance,
|
|
score: score
|
|
)
|
|
|
|
candidates.append(candidate)
|
|
}
|
|
|
|
// Sort by score (highest first)
|
|
return candidates.sorted { $0.score > $1.score }
|
|
}
|
|
|
|
// MARK: - Directional Game Filtering (Scenario C)
|
|
|
|
/// Finds games along a directional route from start to end.
|
|
/// Ensures monotonic progress toward destination.
|
|
///
|
|
/// - Parameters:
|
|
/// - request: Planning request
|
|
/// - startCoordinate: Starting location
|
|
/// - endCoordinate: Destination
|
|
/// - corridorWidthPercent: Width of corridor as percentage of direct distance
|
|
/// - Returns: Games sorted by their position along the route
|
|
func findDirectionalGames(
|
|
from request: PlanningRequest,
|
|
startCoordinate: CLLocationCoordinate2D,
|
|
endCoordinate: CLLocationCoordinate2D,
|
|
corridorWidthPercent: Double = 0.3
|
|
) -> [GameCandidate] {
|
|
let corridor = RouteCorridorCalculator(
|
|
start: startCoordinate,
|
|
end: endCoordinate,
|
|
maxDetourFactor: 1.0 + corridorWidthPercent
|
|
)
|
|
|
|
var candidates: [GameCandidate] = []
|
|
|
|
for game in request.availableGames {
|
|
guard let stadium = request.stadiums[game.stadiumId],
|
|
let homeTeam = request.teams[game.homeTeamId],
|
|
let awayTeam = request.teams[game.awayTeamId] else {
|
|
continue
|
|
}
|
|
|
|
// Date and sport filter
|
|
guard game.dateTime >= request.preferences.startDate,
|
|
game.dateTime <= request.preferences.endDate,
|
|
request.preferences.sports.contains(game.sport) else {
|
|
continue
|
|
}
|
|
|
|
// Calculate progress along route (0 = start, 1 = end)
|
|
let progress = corridor.progressAlongRoute(point: stadium.coordinate)
|
|
|
|
// Only include games that are along the route (positive progress, not behind start)
|
|
guard progress >= -0.1 && progress <= 1.1 else {
|
|
continue
|
|
}
|
|
|
|
// Check corridor width
|
|
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
|
|
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
|
|
|
|
if !isMustSee && detourDistance > corridor.maxDetourDistance {
|
|
continue
|
|
}
|
|
|
|
let score = scoreGame(
|
|
game: game,
|
|
homeTeam: homeTeam,
|
|
awayTeam: awayTeam,
|
|
detourDistance: detourDistance,
|
|
isMustSee: isMustSee,
|
|
request: request
|
|
)
|
|
|
|
let candidate = GameCandidate(
|
|
id: game.id,
|
|
game: game,
|
|
stadium: stadium,
|
|
homeTeam: homeTeam,
|
|
awayTeam: awayTeam,
|
|
detourDistance: detourDistance,
|
|
score: score
|
|
)
|
|
|
|
candidates.append(candidate)
|
|
}
|
|
|
|
// Sort by date (chronological order is the primary constraint)
|
|
return candidates.sorted { $0.game.dateTime < $1.game.dateTime }
|
|
}
|
|
|
|
// MARK: - Game Scoring
|
|
|
|
private func scoreGame(
|
|
game: Game,
|
|
homeTeam: Team,
|
|
awayTeam: Team,
|
|
detourDistance: Double,
|
|
isMustSee: Bool,
|
|
request: PlanningRequest
|
|
) -> Double {
|
|
var score: Double = 50.0 // Base score
|
|
|
|
// Must-see bonus
|
|
if isMustSee {
|
|
score += 100.0
|
|
}
|
|
|
|
// Playoff bonus
|
|
if game.isPlayoff {
|
|
score += 30.0
|
|
}
|
|
|
|
// Weekend bonus (more convenient)
|
|
let weekday = Calendar.current.component(.weekday, from: game.dateTime)
|
|
if weekday == 1 || weekday == 7 { // Sunday or Saturday
|
|
score += 10.0
|
|
}
|
|
|
|
// Evening game bonus (day games harder to schedule around)
|
|
let hour = Calendar.current.component(.hour, from: game.dateTime)
|
|
if hour >= 17 { // 5 PM or later
|
|
score += 5.0
|
|
}
|
|
|
|
// Detour penalty
|
|
let detourMiles = detourDistance * 0.000621371
|
|
score -= detourMiles * 0.1 // Lose 0.1 points per mile of detour
|
|
|
|
// Preferred city bonus
|
|
if request.preferences.preferredCities.contains(homeTeam.city) {
|
|
score += 15.0
|
|
}
|
|
|
|
// Must-stop location bonus
|
|
if request.preferences.mustStopLocations.contains(where: { $0.name.lowercased() == homeTeam.city.lowercased() }) {
|
|
score += 25.0
|
|
}
|
|
|
|
return max(0, score)
|
|
}
|
|
|
|
private func detourFactorFor(_ leisureLevel: LeisureLevel) -> Double {
|
|
switch leisureLevel {
|
|
case .packed: return 1.3 // 30% detour allowed
|
|
case .moderate: return 1.5 // 50% detour allowed
|
|
case .relaxed: return 2.0 // 100% detour allowed
|
|
}
|
|
}
|
|
|
|
// MARK: - Find Games at Location
|
|
|
|
func findGames(
|
|
at stadium: Stadium,
|
|
within dateRange: ClosedRange<Date>,
|
|
from games: [Game]
|
|
) -> [Game] {
|
|
games.filter { game in
|
|
game.stadiumId == stadium.id &&
|
|
dateRange.contains(game.dateTime)
|
|
}.sorted { $0.dateTime < $1.dateTime }
|
|
}
|
|
|
|
// MARK: - Find Other Sports
|
|
|
|
func findOtherSportsGames(
|
|
along route: [CLLocationCoordinate2D],
|
|
excludingSports: Set<Sport>,
|
|
within dateRange: ClosedRange<Date>,
|
|
games: [Game],
|
|
stadiums: [UUID: Stadium],
|
|
teams: [UUID: Team],
|
|
maxDetourMiles: Double = 50
|
|
) -> [GameCandidate] {
|
|
var candidates: [GameCandidate] = []
|
|
|
|
for game in games {
|
|
// Skip if sport is already selected
|
|
if excludingSports.contains(game.sport) { continue }
|
|
|
|
// Skip if outside date range
|
|
if !dateRange.contains(game.dateTime) { continue }
|
|
|
|
guard let stadium = stadiums[game.stadiumId],
|
|
let homeTeam = teams[game.homeTeamId],
|
|
let awayTeam = teams[game.awayTeamId] else {
|
|
continue
|
|
}
|
|
|
|
// Check if stadium is near the route
|
|
let minDistance = route.map { coord in
|
|
CLLocation(latitude: coord.latitude, longitude: coord.longitude)
|
|
.distance(from: stadium.location)
|
|
}.min() ?? .greatestFiniteMagnitude
|
|
|
|
let distanceMiles = minDistance * 0.000621371
|
|
|
|
if distanceMiles <= maxDetourMiles {
|
|
let candidate = GameCandidate(
|
|
id: game.id,
|
|
game: game,
|
|
stadium: stadium,
|
|
homeTeam: homeTeam,
|
|
awayTeam: awayTeam,
|
|
detourDistance: minDistance,
|
|
score: 50.0 - distanceMiles // Score inversely proportional to detour
|
|
)
|
|
candidates.append(candidate)
|
|
}
|
|
}
|
|
|
|
return candidates.sorted { $0.score > $1.score }
|
|
}
|
|
|
|
// MARK: - Validate Games for Scenarios
|
|
|
|
/// Validates that all must-see games are within the date range.
|
|
/// Used for Scenario B validation.
|
|
func validateMustSeeGamesInRange(
|
|
mustSeeGameIds: Set<UUID>,
|
|
allGames: [Game],
|
|
dateRange: ClosedRange<Date>
|
|
) -> (valid: Bool, outOfRange: [UUID]) {
|
|
var outOfRange: [UUID] = []
|
|
|
|
for gameId in mustSeeGameIds {
|
|
guard let game = allGames.first(where: { $0.id == gameId }) else {
|
|
continue
|
|
}
|
|
if !dateRange.contains(game.dateTime) {
|
|
outOfRange.append(gameId)
|
|
}
|
|
}
|
|
|
|
return (outOfRange.isEmpty, outOfRange)
|
|
}
|
|
}
|
|
|
|
// MARK: - Route Corridor Calculator
|
|
|
|
struct RouteCorridorCalculator {
|
|
let start: CLLocationCoordinate2D
|
|
let end: CLLocationCoordinate2D
|
|
let maxDetourFactor: Double
|
|
|
|
var directDistance: CLLocationDistance {
|
|
CLLocation(latitude: start.latitude, longitude: start.longitude)
|
|
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
|
}
|
|
|
|
var maxDetourDistance: CLLocationDistance {
|
|
directDistance * (maxDetourFactor - 1.0)
|
|
}
|
|
|
|
func detourDistance(to point: CLLocationCoordinate2D) -> CLLocationDistance {
|
|
let startToPoint = CLLocation(latitude: start.latitude, longitude: start.longitude)
|
|
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
|
|
|
|
let pointToEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
|
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
|
|
|
let totalViaPoint = startToPoint + pointToEnd
|
|
|
|
return max(0, totalViaPoint - directDistance)
|
|
}
|
|
|
|
func isWithinCorridor(_ point: CLLocationCoordinate2D) -> Bool {
|
|
detourDistance(to: point) <= maxDetourDistance
|
|
}
|
|
|
|
/// Returns the distance from a point to the end location.
|
|
func distanceToEnd(from point: CLLocationCoordinate2D) -> CLLocationDistance {
|
|
CLLocation(latitude: point.latitude, longitude: point.longitude)
|
|
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
|
}
|
|
|
|
/// Calculates progress along the route (0 = at start, 1 = at end).
|
|
/// Can be negative (behind start) or > 1 (past end).
|
|
func progressAlongRoute(point: CLLocationCoordinate2D) -> Double {
|
|
guard directDistance > 0 else { return 0 }
|
|
|
|
let distFromStart = CLLocation(latitude: start.latitude, longitude: start.longitude)
|
|
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
|
|
|
|
let distFromEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
|
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
|
|
|
// Use the law of cosines to project onto the line
|
|
// progress = (d_start² + d_total² - d_end²) / (2 * d_total²)
|
|
let dStart = distFromStart
|
|
let dEnd = distFromEnd
|
|
let dTotal = directDistance
|
|
|
|
let numerator = (dStart * dStart) + (dTotal * dTotal) - (dEnd * dEnd)
|
|
let denominator = 2 * dTotal * dTotal
|
|
|
|
return numerator / denominator
|
|
}
|
|
}
|