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:
396
SportsTime/Planning/Engine/ScheduleMatcher.swift
Normal file
396
SportsTime/Planning/Engine/ScheduleMatcher.swift
Normal file
@@ -0,0 +1,396 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user