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:
Trey t
2026-01-07 00:46:40 -06:00
commit 9088b46563
84 changed files with 180371 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,193 @@
//
// CKModels.swift
// SportsTime
//
// CloudKit Record Type Definitions for Public Database
//
import Foundation
import CloudKit
// MARK: - Record Type Constants
enum CKRecordType {
static let team = "Team"
static let stadium = "Stadium"
static let game = "Game"
static let sport = "Sport"
}
// MARK: - CKTeam
struct CKTeam {
static let idKey = "teamId"
static let nameKey = "name"
static let abbreviationKey = "abbreviation"
static let sportKey = "sport"
static let cityKey = "city"
static let stadiumRefKey = "stadiumRef"
static let logoURLKey = "logoURL"
static let primaryColorKey = "primaryColor"
static let secondaryColorKey = "secondaryColor"
let record: CKRecord
init(record: CKRecord) {
self.record = record
}
init(team: Team, stadiumRecordID: CKRecord.ID) {
let record = CKRecord(recordType: CKRecordType.team)
record[CKTeam.idKey] = team.id.uuidString
record[CKTeam.nameKey] = team.name
record[CKTeam.abbreviationKey] = team.abbreviation
record[CKTeam.sportKey] = team.sport.rawValue
record[CKTeam.cityKey] = team.city
record[CKTeam.stadiumRefKey] = CKRecord.Reference(recordID: stadiumRecordID, action: .none)
record[CKTeam.logoURLKey] = team.logoURL?.absoluteString
record[CKTeam.primaryColorKey] = team.primaryColor
record[CKTeam.secondaryColorKey] = team.secondaryColor
self.record = record
}
var team: Team? {
guard let idString = record[CKTeam.idKey] as? String,
let id = UUID(uuidString: idString),
let name = record[CKTeam.nameKey] as? String,
let abbreviation = record[CKTeam.abbreviationKey] as? String,
let sportRaw = record[CKTeam.sportKey] as? String,
let sport = Sport(rawValue: sportRaw),
let city = record[CKTeam.cityKey] as? String,
let stadiumRef = record[CKTeam.stadiumRefKey] as? CKRecord.Reference,
let stadiumIdString = stadiumRef.recordID.recordName.split(separator: ":").last,
let stadiumId = UUID(uuidString: String(stadiumIdString))
else { return nil }
let logoURL = (record[CKTeam.logoURLKey] as? String).flatMap { URL(string: $0) }
return Team(
id: id,
name: name,
abbreviation: abbreviation,
sport: sport,
city: city,
stadiumId: stadiumId,
logoURL: logoURL,
primaryColor: record[CKTeam.primaryColorKey] as? String,
secondaryColor: record[CKTeam.secondaryColorKey] as? String
)
}
}
// MARK: - CKStadium
struct CKStadium {
static let idKey = "stadiumId"
static let nameKey = "name"
static let cityKey = "city"
static let stateKey = "state"
static let locationKey = "location"
static let capacityKey = "capacity"
static let yearOpenedKey = "yearOpened"
static let imageURLKey = "imageURL"
let record: CKRecord
init(record: CKRecord) {
self.record = record
}
init(stadium: Stadium) {
let record = CKRecord(recordType: CKRecordType.stadium)
record[CKStadium.idKey] = stadium.id.uuidString
record[CKStadium.nameKey] = stadium.name
record[CKStadium.cityKey] = stadium.city
record[CKStadium.stateKey] = stadium.state
record[CKStadium.locationKey] = CLLocation(latitude: stadium.latitude, longitude: stadium.longitude)
record[CKStadium.capacityKey] = stadium.capacity
record[CKStadium.yearOpenedKey] = stadium.yearOpened
record[CKStadium.imageURLKey] = stadium.imageURL?.absoluteString
self.record = record
}
var stadium: Stadium? {
guard let idString = record[CKStadium.idKey] as? String,
let id = UUID(uuidString: idString),
let name = record[CKStadium.nameKey] as? String,
let city = record[CKStadium.cityKey] as? String,
let state = record[CKStadium.stateKey] as? String,
let location = record[CKStadium.locationKey] as? CLLocation,
let capacity = record[CKStadium.capacityKey] as? Int
else { return nil }
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
return Stadium(
id: id,
name: name,
city: city,
state: state,
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
capacity: capacity,
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
imageURL: imageURL
)
}
}
// MARK: - CKGame
struct CKGame {
static let idKey = "gameId"
static let homeTeamRefKey = "homeTeamRef"
static let awayTeamRefKey = "awayTeamRef"
static let stadiumRefKey = "stadiumRef"
static let dateTimeKey = "dateTime"
static let sportKey = "sport"
static let seasonKey = "season"
static let isPlayoffKey = "isPlayoff"
static let broadcastInfoKey = "broadcastInfo"
let record: CKRecord
init(record: CKRecord) {
self.record = record
}
init(game: Game, homeTeamRecordID: CKRecord.ID, awayTeamRecordID: CKRecord.ID, stadiumRecordID: CKRecord.ID) {
let record = CKRecord(recordType: CKRecordType.game)
record[CKGame.idKey] = game.id.uuidString
record[CKGame.homeTeamRefKey] = CKRecord.Reference(recordID: homeTeamRecordID, action: .none)
record[CKGame.awayTeamRefKey] = CKRecord.Reference(recordID: awayTeamRecordID, action: .none)
record[CKGame.stadiumRefKey] = CKRecord.Reference(recordID: stadiumRecordID, action: .none)
record[CKGame.dateTimeKey] = game.dateTime
record[CKGame.sportKey] = game.sport.rawValue
record[CKGame.seasonKey] = game.season
record[CKGame.isPlayoffKey] = game.isPlayoff ? 1 : 0
record[CKGame.broadcastInfoKey] = game.broadcastInfo
self.record = record
}
func game(homeTeamId: UUID, awayTeamId: UUID, stadiumId: UUID) -> Game? {
guard let idString = record[CKGame.idKey] as? String,
let id = UUID(uuidString: idString),
let dateTime = record[CKGame.dateTimeKey] as? Date,
let sportRaw = record[CKGame.sportKey] as? String,
let sport = Sport(rawValue: sportRaw),
let season = record[CKGame.seasonKey] as? String
else { return nil }
return Game(
id: id,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
stadiumId: stadiumId,
dateTime: dateTime,
sport: sport,
season: season,
isPlayoff: (record[CKGame.isPlayoffKey] as? Int) == 1,
broadcastInfo: record[CKGame.broadcastInfoKey] as? String
)
}
}

View File

@@ -0,0 +1,94 @@
//
// Game.swift
// SportsTime
//
import Foundation
struct Game: Identifiable, Codable, Hashable {
let id: UUID
let homeTeamId: UUID
let awayTeamId: UUID
let stadiumId: UUID
let dateTime: Date
let sport: Sport
let season: String
let isPlayoff: Bool
let broadcastInfo: String?
init(
id: UUID = UUID(),
homeTeamId: UUID,
awayTeamId: UUID,
stadiumId: UUID,
dateTime: Date,
sport: Sport,
season: String,
isPlayoff: Bool = false,
broadcastInfo: String? = nil
) {
self.id = id
self.homeTeamId = homeTeamId
self.awayTeamId = awayTeamId
self.stadiumId = stadiumId
self.dateTime = dateTime
self.sport = sport
self.season = season
self.isPlayoff = isPlayoff
self.broadcastInfo = broadcastInfo
}
var gameDate: Date {
Calendar.current.startOfDay(for: dateTime)
}
/// Alias for TripPlanningEngine compatibility
var startTime: Date { dateTime }
var gameTime: String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: dateTime)
}
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: dateTime)
}
var dayOfWeek: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE"
return formatter.string(from: dateTime)
}
}
extension Game: Equatable {
static func == (lhs: Game, rhs: Game) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Rich Game Model (with resolved references)
struct RichGame: Identifiable, Hashable {
let game: Game
let homeTeam: Team
let awayTeam: Team
let stadium: Stadium
var id: UUID { game.id }
var matchupDescription: String {
"\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)"
}
var fullMatchupDescription: String {
"\(awayTeam.fullName) at \(homeTeam.fullName)"
}
var venueDescription: String {
"\(stadium.name), \(stadium.city)"
}
}

View File

@@ -0,0 +1,64 @@
//
// Sport.swift
// SportsTime
//
import Foundation
enum Sport: String, Codable, CaseIterable, Identifiable {
case mlb = "MLB"
case nba = "NBA"
case nhl = "NHL"
case nfl = "NFL"
case mls = "MLS"
var id: String { rawValue }
var displayName: String {
switch self {
case .mlb: return "Major League Baseball"
case .nba: return "National Basketball Association"
case .nhl: return "National Hockey League"
case .nfl: return "National Football League"
case .mls: return "Major League Soccer"
}
}
var iconName: String {
switch self {
case .mlb: return "baseball.fill"
case .nba: return "basketball.fill"
case .nhl: return "hockey.puck.fill"
case .nfl: return "football.fill"
case .mls: return "soccerball"
}
}
var seasonMonths: ClosedRange<Int> {
switch self {
case .mlb: return 3...10 // March - October
case .nba: return 10...6 // October - June (wraps)
case .nhl: return 10...6 // October - June (wraps)
case .nfl: return 9...2 // September - February (wraps)
case .mls: return 2...12 // February - December
}
}
func isInSeason(for date: Date) -> Bool {
let calendar = Calendar.current
let month = calendar.component(.month, from: date)
let range = seasonMonths
if range.lowerBound <= range.upperBound {
return range.contains(month)
} else {
// Season wraps around year boundary
return month >= range.lowerBound || month <= range.upperBound
}
}
/// Currently supported sports for MVP
static var supported: [Sport] {
[.mlb, .nba, .nhl]
}
}

View File

@@ -0,0 +1,68 @@
//
// Stadium.swift
// SportsTime
//
import Foundation
import CoreLocation
struct Stadium: Identifiable, Codable, Hashable {
let id: UUID
let name: String
let city: String
let state: String
let latitude: Double
let longitude: Double
let capacity: Int
let yearOpened: Int?
let imageURL: URL?
init(
id: UUID = UUID(),
name: String,
city: String,
state: String,
latitude: Double,
longitude: Double,
capacity: Int,
yearOpened: Int? = nil,
imageURL: URL? = nil
) {
self.id = id
self.name = name
self.city = city
self.state = state
self.latitude = latitude
self.longitude = longitude
self.capacity = capacity
self.yearOpened = yearOpened
self.imageURL = imageURL
}
var location: CLLocation {
CLLocation(latitude: latitude, longitude: longitude)
}
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
var fullAddress: String {
"\(city), \(state)"
}
func distance(to other: Stadium) -> CLLocationDistance {
location.distance(from: other.location)
}
func distance(from coordinate: CLLocationCoordinate2D) -> CLLocationDistance {
let otherLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
return location.distance(from: otherLocation)
}
}
extension Stadium: Equatable {
static func == (lhs: Stadium, rhs: Stadium) -> Bool {
lhs.id == rhs.id
}
}

View File

@@ -0,0 +1,50 @@
//
// Team.swift
// SportsTime
//
import Foundation
struct Team: Identifiable, Codable, Hashable {
let id: UUID
let name: String
let abbreviation: String
let sport: Sport
let city: String
let stadiumId: UUID
let logoURL: URL?
let primaryColor: String?
let secondaryColor: String?
init(
id: UUID = UUID(),
name: String,
abbreviation: String,
sport: Sport,
city: String,
stadiumId: UUID,
logoURL: URL? = nil,
primaryColor: String? = nil,
secondaryColor: String? = nil
) {
self.id = id
self.name = name
self.abbreviation = abbreviation
self.sport = sport
self.city = city
self.stadiumId = stadiumId
self.logoURL = logoURL
self.primaryColor = primaryColor
self.secondaryColor = secondaryColor
}
var fullName: String {
"\(city) \(name)"
}
}
extension Team: Equatable {
static func == (lhs: Team, rhs: Team) -> Bool {
lhs.id == rhs.id
}
}

View File

@@ -0,0 +1,108 @@
//
// TravelSegment.swift
// SportsTime
//
import Foundation
import CoreLocation
struct TravelSegment: Identifiable, Codable, Hashable {
let id: UUID
let fromLocation: LocationInput
let toLocation: LocationInput
let travelMode: TravelMode
let distanceMeters: Double
let durationSeconds: Double
let departureTime: Date
let arrivalTime: Date
let scenicScore: Double
let evChargingStops: [EVChargingStop]
let routePolyline: String?
init(
id: UUID = UUID(),
fromLocation: LocationInput,
toLocation: LocationInput,
travelMode: TravelMode,
distanceMeters: Double,
durationSeconds: Double,
departureTime: Date,
arrivalTime: Date,
scenicScore: Double = 0.5,
evChargingStops: [EVChargingStop] = [],
routePolyline: String? = nil
) {
self.id = id
self.fromLocation = fromLocation
self.toLocation = toLocation
self.travelMode = travelMode
self.distanceMeters = distanceMeters
self.durationSeconds = durationSeconds
self.departureTime = departureTime
self.arrivalTime = arrivalTime
self.scenicScore = scenicScore
self.evChargingStops = evChargingStops
self.routePolyline = routePolyline
}
var distanceMiles: Double { distanceMeters * 0.000621371 }
var durationHours: Double { durationSeconds / 3600.0 }
/// Alias for TripPlanningEngine compatibility
var estimatedDrivingHours: Double { durationHours }
var estimatedDistanceMiles: Double { distanceMiles }
var formattedDistance: String {
String(format: "%.0f mi", distanceMiles)
}
var formattedDuration: String {
let hours = Int(durationHours)
let minutes = Int((durationHours - Double(hours)) * 60)
if hours > 0 && minutes > 0 { return "\(hours)h \(minutes)m" }
else if hours > 0 { return "\(hours)h" }
else { return "\(minutes)m" }
}
}
// MARK: - EV Charging Stop
struct EVChargingStop: Identifiable, Codable, Hashable {
let id: UUID
let name: String
let location: LocationInput
let chargerType: ChargerType
let estimatedChargeTime: TimeInterval
init(
id: UUID = UUID(),
name: String,
location: LocationInput,
chargerType: ChargerType = .dcFast,
estimatedChargeTime: TimeInterval = 1800
) {
self.id = id
self.name = name
self.location = location
self.chargerType = chargerType
self.estimatedChargeTime = estimatedChargeTime
}
var formattedChargeTime: String {
"\(Int(estimatedChargeTime / 60)) min"
}
}
enum ChargerType: String, Codable, CaseIterable {
case level2
case dcFast
case supercharger
var displayName: String {
switch self {
case .level2: return "Level 2"
case .dcFast: return "DC Fast"
case .supercharger: return "Supercharger"
}
}
}

View File

@@ -0,0 +1,184 @@
//
// Trip.swift
// SportsTime
//
import Foundation
struct Trip: Identifiable, Codable, Hashable {
let id: UUID
var name: String
let createdAt: Date
var updatedAt: Date
let preferences: TripPreferences
var stops: [TripStop]
var travelSegments: [TravelSegment]
var totalGames: Int
var totalDistanceMeters: Double
var totalDrivingSeconds: Double
var score: TripScore?
init(
id: UUID = UUID(),
name: String,
createdAt: Date = Date(),
updatedAt: Date = Date(),
preferences: TripPreferences,
stops: [TripStop] = [],
travelSegments: [TravelSegment] = [],
totalGames: Int = 0,
totalDistanceMeters: Double = 0,
totalDrivingSeconds: Double = 0,
score: TripScore? = nil
) {
self.id = id
self.name = name
self.createdAt = createdAt
self.updatedAt = updatedAt
self.preferences = preferences
self.stops = stops
self.travelSegments = travelSegments
self.totalGames = totalGames
self.totalDistanceMeters = totalDistanceMeters
self.totalDrivingSeconds = totalDrivingSeconds
self.score = score
}
var totalDistanceMiles: Double { totalDistanceMeters * 0.000621371 }
var totalDrivingHours: Double { totalDrivingSeconds / 3600.0 }
var tripDuration: Int {
guard let first = stops.first, let last = stops.last else { return 0 }
let days = Calendar.current.dateComponents([.day], from: first.arrivalDate, to: last.departureDate).day ?? 0
return max(1, days + 1)
}
var averageDrivingHoursPerDay: Double {
guard tripDuration > 0 else { return 0 }
return totalDrivingHours / Double(tripDuration)
}
var cities: [String] { stops.map { $0.city } }
var uniqueSports: Set<Sport> { preferences.sports }
var startDate: Date { stops.first?.arrivalDate ?? preferences.startDate }
var endDate: Date { stops.last?.departureDate ?? preferences.endDate }
var formattedDateRange: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
}
var formattedTotalDistance: String { String(format: "%.0f miles", totalDistanceMiles) }
var formattedTotalDriving: String {
let hours = Int(totalDrivingHours)
let minutes = Int((totalDrivingHours - Double(hours)) * 60)
return "\(hours)h \(minutes)m"
}
func itineraryDays() -> [ItineraryDay] {
var days: [ItineraryDay] = []
let calendar = Calendar.current
guard let firstDate = stops.first?.arrivalDate else { return days }
// Find the last day with actual activity (last game date or last arrival)
// Departure date is the day AFTER the last game, so we use day before departure
let lastActivityDate: Date
if let lastDeparture = stops.last?.departureDate {
// Last activity is day before departure (departure is when you leave)
lastActivityDate = calendar.date(byAdding: .day, value: -1, to: lastDeparture) ?? lastDeparture
} else {
lastActivityDate = stops.last?.arrivalDate ?? firstDate
}
var currentDate = calendar.startOfDay(for: firstDate)
let endDateNormalized = calendar.startOfDay(for: lastActivityDate)
var dayNumber = 1
while currentDate <= endDateNormalized {
let stopsForDay = stops.filter { stop in
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
let departureDay = calendar.startOfDay(for: stop.departureDate)
return currentDate >= arrivalDay && currentDate <= departureDay
}
// Show travel segments that depart on this day
// Travel TO the last city happens on the last game day (drive morning, watch game)
let segmentsForDay = travelSegments.filter { segment in
calendar.startOfDay(for: segment.departureTime) == currentDate
}
days.append(ItineraryDay(
dayNumber: dayNumber,
date: currentDate,
stops: stopsForDay,
travelSegments: segmentsForDay
))
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
dayNumber += 1
}
return days
}
}
// MARK: - Trip Score
struct TripScore: Codable, Hashable {
let overallScore: Double
let gameQualityScore: Double
let routeEfficiencyScore: Double
let leisureBalanceScore: Double
let preferenceAlignmentScore: Double
var formattedOverallScore: String { String(format: "%.0f", overallScore) }
var scoreGrade: String {
switch overallScore {
case 90...100: return "A+"
case 85..<90: return "A"
case 80..<85: return "A-"
case 75..<80: return "B+"
case 70..<75: return "B"
case 65..<70: return "B-"
case 60..<65: return "C+"
case 55..<60: return "C"
default: return "C-"
}
}
}
// MARK: - Itinerary Day
struct ItineraryDay: Identifiable, Hashable {
let id = UUID()
let dayNumber: Int
let date: Date
let stops: [TripStop]
let travelSegments: [TravelSegment]
var formattedDate: String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: date)
}
var isRestDay: Bool { stops.first?.isRestDay ?? false }
var hasTravelSegment: Bool { !travelSegments.isEmpty }
var gameIds: [UUID] { stops.flatMap { $0.games } }
var hasGames: Bool { !gameIds.isEmpty }
var primaryCity: String? { stops.first?.city }
var totalDrivingHours: Double { travelSegments.reduce(0) { $0 + $1.durationHours } }
}
// MARK: - Trip Status
enum TripStatus: String, Codable {
case draft
case planned
case inProgress
case completed
case cancelled
}

View File

@@ -0,0 +1,284 @@
//
// TripPreferences.swift
// SportsTime
//
import Foundation
import CoreLocation
// MARK: - Planning Mode
enum PlanningMode: String, Codable, CaseIterable, Identifiable {
case dateRange // Start date + end date, find games in range
case gameFirst // Pick games first, trip around those games
case locations // Start/end locations, optional games along route
var id: String { rawValue }
var displayName: String {
switch self {
case .dateRange: return "By Dates"
case .gameFirst: return "By Games"
case .locations: return "By Route"
}
}
var description: String {
switch self {
case .dateRange: return "Find games within a date range"
case .gameFirst: return "Build trip around specific games"
case .locations: return "Plan route between locations"
}
}
var iconName: String {
switch self {
case .dateRange: return "calendar"
case .gameFirst: return "sportscourt"
case .locations: return "map"
}
}
}
// MARK: - Travel Mode
enum TravelMode: String, Codable, CaseIterable, Identifiable {
case drive
case fly
var id: String { rawValue }
var displayName: String {
switch self {
case .drive: return "Drive"
case .fly: return "Fly"
}
}
var iconName: String {
switch self {
case .drive: return "car.fill"
case .fly: return "airplane"
}
}
}
// MARK: - Lodging Type
enum LodgingType: String, Codable, CaseIterable, Identifiable {
case hotel
case camperRV
case airbnb
case flexible
var id: String { rawValue }
var displayName: String {
switch self {
case .hotel: return "Hotels"
case .camperRV: return "Camper / RV"
case .airbnb: return "Airbnb"
case .flexible: return "Flexible"
}
}
var iconName: String {
switch self {
case .hotel: return "building.2.fill"
case .camperRV: return "bus.fill"
case .airbnb: return "house.fill"
case .flexible: return "questionmark.circle.fill"
}
}
}
// MARK: - Leisure Level
enum LeisureLevel: String, Codable, CaseIterable, Identifiable {
case packed
case moderate
case relaxed
var id: String { rawValue }
var displayName: String {
switch self {
case .packed: return "Packed"
case .moderate: return "Moderate"
case .relaxed: return "Relaxed"
}
}
var description: String {
switch self {
case .packed: return "Maximize games, minimal downtime"
case .moderate: return "Balance games with rest days"
case .relaxed: return "Prioritize comfort, fewer games"
}
}
var restDaysPerWeek: Double {
switch self {
case .packed: return 0.5
case .moderate: return 1.5
case .relaxed: return 2.5
}
}
var maxGamesPerWeek: Int {
switch self {
case .packed: return 7
case .moderate: return 5
case .relaxed: return 3
}
}
}
// MARK: - Route Preference
enum RoutePreference: String, Codable, CaseIterable, Identifiable {
case direct
case scenic
case balanced
var id: String { rawValue }
var displayName: String {
switch self {
case .direct: return "Direct"
case .scenic: return "Scenic"
case .balanced: return "Balanced"
}
}
var scenicWeight: Double {
switch self {
case .direct: return 0.0
case .scenic: return 1.0
case .balanced: return 0.5
}
}
}
// MARK: - Location Input
struct LocationInput: Codable, Hashable {
let name: String
let coordinate: CLLocationCoordinate2D?
let address: String?
init(name: String, coordinate: CLLocationCoordinate2D? = nil, address: String? = nil) {
self.name = name
self.coordinate = coordinate
self.address = address
}
var isResolved: Bool { coordinate != nil }
}
extension CLLocationCoordinate2D: Codable, Hashable {
enum CodingKeys: String, CodingKey {
case latitude, longitude
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let lat = try container.decode(Double.self, forKey: .latitude)
let lon = try container.decode(Double.self, forKey: .longitude)
self.init(latitude: lat, longitude: lon)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(latitude, forKey: .latitude)
try container.encode(longitude, forKey: .longitude)
}
public func hash(into hasher: inout Hasher) {
hasher.combine(latitude)
hasher.combine(longitude)
}
public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool {
lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
}
}
// MARK: - Trip Preferences
struct TripPreferences: Codable, Hashable {
var planningMode: PlanningMode
var startLocation: LocationInput?
var endLocation: LocationInput?
var sports: Set<Sport>
var mustSeeGameIds: Set<UUID>
var travelMode: TravelMode
var startDate: Date
var endDate: Date
var numberOfStops: Int?
var tripDuration: Int?
var leisureLevel: LeisureLevel
var mustStopLocations: [LocationInput]
var preferredCities: [String]
var routePreference: RoutePreference
var needsEVCharging: Bool
var lodgingType: LodgingType
var numberOfDrivers: Int
var maxDrivingHoursPerDriver: Double?
var catchOtherSports: Bool
init(
planningMode: PlanningMode = .dateRange,
startLocation: LocationInput? = nil,
endLocation: LocationInput? = nil,
sports: Set<Sport> = [],
mustSeeGameIds: Set<UUID> = [],
travelMode: TravelMode = .drive,
startDate: Date = Date(),
endDate: Date = Date().addingTimeInterval(86400 * 7),
numberOfStops: Int? = nil,
tripDuration: Int? = nil,
leisureLevel: LeisureLevel = .moderate,
mustStopLocations: [LocationInput] = [],
preferredCities: [String] = [],
routePreference: RoutePreference = .balanced,
needsEVCharging: Bool = false,
lodgingType: LodgingType = .hotel,
numberOfDrivers: Int = 1,
maxDrivingHoursPerDriver: Double? = nil,
catchOtherSports: Bool = false
) {
self.planningMode = planningMode
self.startLocation = startLocation
self.endLocation = endLocation
self.sports = sports
self.mustSeeGameIds = mustSeeGameIds
self.travelMode = travelMode
self.startDate = startDate
self.endDate = endDate
self.numberOfStops = numberOfStops
self.tripDuration = tripDuration
self.leisureLevel = leisureLevel
self.mustStopLocations = mustStopLocations
self.preferredCities = preferredCities
self.routePreference = routePreference
self.needsEVCharging = needsEVCharging
self.lodgingType = lodgingType
self.numberOfDrivers = numberOfDrivers
self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver
self.catchOtherSports = catchOtherSports
}
var totalDriverHoursPerDay: Double {
let maxPerDriver = maxDrivingHoursPerDriver ?? 8.0
return maxPerDriver * Double(numberOfDrivers)
}
var effectiveTripDuration: Int {
if let duration = tripDuration { return duration }
let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 7
return max(1, days)
}
}

View File

@@ -0,0 +1,167 @@
//
// TripStop.swift
// SportsTime
//
import Foundation
import CoreLocation
struct TripStop: Identifiable, Codable, Hashable {
let id: UUID
let stopNumber: Int
let city: String
let state: String
let coordinate: CLLocationCoordinate2D?
let arrivalDate: Date
let departureDate: Date
let games: [UUID]
let stadium: UUID?
let lodging: LodgingSuggestion?
let activities: [ActivitySuggestion]
let isRestDay: Bool
let notes: String?
init(
id: UUID = UUID(),
stopNumber: Int,
city: String,
state: String,
coordinate: CLLocationCoordinate2D? = nil,
arrivalDate: Date,
departureDate: Date,
games: [UUID] = [],
stadium: UUID? = nil,
lodging: LodgingSuggestion? = nil,
activities: [ActivitySuggestion] = [],
isRestDay: Bool = false,
notes: String? = nil
) {
self.id = id
self.stopNumber = stopNumber
self.city = city
self.state = state
self.coordinate = coordinate
self.arrivalDate = arrivalDate
self.departureDate = departureDate
self.games = games
self.stadium = stadium
self.lodging = lodging
self.activities = activities
self.isRestDay = isRestDay
self.notes = notes
}
var stayDuration: Int {
let days = Calendar.current.dateComponents([.day], from: arrivalDate, to: departureDate).day ?? 1
return max(1, days)
}
var locationDescription: String { "\(city), \(state)" }
var hasGames: Bool { !games.isEmpty }
var formattedDateRange: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
let start = formatter.string(from: arrivalDate)
let end = formatter.string(from: departureDate)
return stayDuration > 1 ? "\(start) - \(end)" : start
}
}
// MARK: - Lodging Suggestion
struct LodgingSuggestion: Identifiable, Codable, Hashable {
let id: UUID
let name: String
let type: LodgingType
let address: String?
let coordinate: CLLocationCoordinate2D?
let priceRange: PriceRange?
let distanceToVenue: Double?
let rating: Double?
init(
id: UUID = UUID(),
name: String,
type: LodgingType,
address: String? = nil,
coordinate: CLLocationCoordinate2D? = nil,
priceRange: PriceRange? = nil,
distanceToVenue: Double? = nil,
rating: Double? = nil
) {
self.id = id
self.name = name
self.type = type
self.address = address
self.coordinate = coordinate
self.priceRange = priceRange
self.distanceToVenue = distanceToVenue
self.rating = rating
}
}
enum PriceRange: String, Codable, CaseIterable {
case budget = "$"
case moderate = "$$"
case upscale = "$$$"
case luxury = "$$$$"
}
// MARK: - Activity Suggestion
struct ActivitySuggestion: Identifiable, Codable, Hashable {
let id: UUID
let name: String
let description: String?
let category: ActivityCategory
let estimatedDuration: TimeInterval
let location: LocationInput?
init(
id: UUID = UUID(),
name: String,
description: String? = nil,
category: ActivityCategory = .attraction,
estimatedDuration: TimeInterval = 7200,
location: LocationInput? = nil
) {
self.id = id
self.name = name
self.description = description
self.category = category
self.estimatedDuration = estimatedDuration
self.location = location
}
}
enum ActivityCategory: String, Codable, CaseIterable {
case attraction
case food
case entertainment
case outdoors
case shopping
case culture
var displayName: String {
switch self {
case .attraction: return "Attraction"
case .food: return "Food & Drink"
case .entertainment: return "Entertainment"
case .outdoors: return "Outdoors"
case .shopping: return "Shopping"
case .culture: return "Culture"
}
}
var iconName: String {
switch self {
case .attraction: return "star.fill"
case .food: return "fork.knife"
case .entertainment: return "theatermasks.fill"
case .outdoors: return "leaf.fill"
case .shopping: return "bag.fill"
case .culture: return "building.columns.fill"
}
}
}

View File

@@ -0,0 +1,198 @@
//
// SavedTrip.swift
// SportsTime
//
// SwiftData models for local persistence
//
import Foundation
import SwiftData
@Model
final class SavedTrip {
@Attribute(.unique) var id: UUID
var name: String
var createdAt: Date
var updatedAt: Date
var status: String
var tripData: Data // Encoded Trip struct
@Relationship(deleteRule: .cascade)
var votes: [TripVote]?
init(
id: UUID = UUID(),
name: String,
createdAt: Date = Date(),
updatedAt: Date = Date(),
status: TripStatus = .planned,
tripData: Data
) {
self.id = id
self.name = name
self.createdAt = createdAt
self.updatedAt = updatedAt
self.status = status.rawValue
self.tripData = tripData
}
var trip: Trip? {
try? JSONDecoder().decode(Trip.self, from: tripData)
}
var tripStatus: TripStatus {
TripStatus(rawValue: status) ?? .draft
}
static func from(_ trip: Trip, status: TripStatus = .planned) -> SavedTrip? {
guard let data = try? JSONEncoder().encode(trip) else { return nil }
return SavedTrip(
id: trip.id,
name: trip.name,
createdAt: trip.createdAt,
updatedAt: trip.updatedAt,
status: status,
tripData: data
)
}
}
// MARK: - Trip Vote (Phase 2)
@Model
final class TripVote {
@Attribute(.unique) var id: UUID
var tripId: UUID
var voterId: String
var voterName: String
var gameVotes: Data // [UUID: Bool] encoded
var routeVotes: Data // [String: Int] encoded
var leisurePreference: String
var createdAt: Date
init(
id: UUID = UUID(),
tripId: UUID,
voterId: String,
voterName: String,
gameVotes: Data,
routeVotes: Data,
leisurePreference: LeisureLevel = .moderate,
createdAt: Date = Date()
) {
self.id = id
self.tripId = tripId
self.voterId = voterId
self.voterName = voterName
self.gameVotes = gameVotes
self.routeVotes = routeVotes
self.leisurePreference = leisurePreference.rawValue
self.createdAt = createdAt
}
}
// MARK: - User Preferences
@Model
final class UserPreferences {
@Attribute(.unique) var id: UUID
var defaultSports: Data // [Sport] encoded
var defaultTravelMode: String
var defaultLeisureLevel: String
var defaultLodgingType: String
var homeLocation: Data? // LocationInput encoded
var needsEVCharging: Bool
var numberOfDrivers: Int
var maxDrivingHours: Double?
init(
id: UUID = UUID(),
defaultSports: [Sport] = Sport.supported,
defaultTravelMode: TravelMode = .drive,
defaultLeisureLevel: LeisureLevel = .moderate,
defaultLodgingType: LodgingType = .hotel,
homeLocation: LocationInput? = nil,
needsEVCharging: Bool = false,
numberOfDrivers: Int = 1,
maxDrivingHours: Double? = nil
) {
self.id = id
self.defaultSports = (try? JSONEncoder().encode(defaultSports)) ?? Data()
self.defaultTravelMode = defaultTravelMode.rawValue
self.defaultLeisureLevel = defaultLeisureLevel.rawValue
self.defaultLodgingType = defaultLodgingType.rawValue
self.homeLocation = try? JSONEncoder().encode(homeLocation)
self.needsEVCharging = needsEVCharging
self.numberOfDrivers = numberOfDrivers
self.maxDrivingHours = maxDrivingHours
}
var sports: [Sport] {
(try? JSONDecoder().decode([Sport].self, from: defaultSports)) ?? Sport.supported
}
var travelMode: TravelMode {
TravelMode(rawValue: defaultTravelMode) ?? .drive
}
var leisureLevel: LeisureLevel {
LeisureLevel(rawValue: defaultLeisureLevel) ?? .moderate
}
var lodgingType: LodgingType {
LodgingType(rawValue: defaultLodgingType) ?? .hotel
}
var home: LocationInput? {
guard let data = homeLocation else { return nil }
return try? JSONDecoder().decode(LocationInput.self, from: data)
}
}
// MARK: - Cached Schedule
@Model
final class CachedSchedule {
@Attribute(.unique) var id: UUID
var sport: String
var season: String
var lastUpdated: Date
var gamesData: Data // [Game] encoded
var teamsData: Data // [Team] encoded
var stadiumsData: Data // [Stadium] encoded
init(
id: UUID = UUID(),
sport: Sport,
season: String,
lastUpdated: Date = Date(),
games: [Game],
teams: [Team],
stadiums: [Stadium]
) {
self.id = id
self.sport = sport.rawValue
self.season = season
self.lastUpdated = lastUpdated
self.gamesData = (try? JSONEncoder().encode(games)) ?? Data()
self.teamsData = (try? JSONEncoder().encode(teams)) ?? Data()
self.stadiumsData = (try? JSONEncoder().encode(stadiums)) ?? Data()
}
var games: [Game] {
(try? JSONDecoder().decode([Game].self, from: gamesData)) ?? []
}
var teams: [Team] {
(try? JSONDecoder().decode([Team].self, from: teamsData)) ?? []
}
var stadiums: [Stadium] {
(try? JSONDecoder().decode([Stadium].self, from: stadiumsData)) ?? []
}
var isStale: Bool {
let staleThreshold: TimeInterval = 24 * 60 * 60 // 24 hours
return Date().timeIntervalSince(lastUpdated) > staleThreshold
}
}

View File

@@ -0,0 +1,108 @@
//
// CloudKitDataProvider.swift
// SportsTime
//
// Wraps CloudKitService to conform to DataProvider protocol
//
import Foundation
actor CloudKitDataProvider: DataProvider {
private let cloudKit = CloudKitService.shared
// MARK: - Availability
func checkAvailability() async throws {
try await cloudKit.checkAvailabilityWithError()
}
// MARK: - DataProvider Protocol
func fetchTeams(for sport: Sport) async throws -> [Team] {
do {
try await checkAvailability()
return try await cloudKit.fetchTeams(for: sport)
} catch {
throw CloudKitError.from(error)
}
}
func fetchAllTeams() async throws -> [Team] {
do {
try await checkAvailability()
var allTeams: [Team] = []
for sport in Sport.supported {
let teams = try await cloudKit.fetchTeams(for: sport)
allTeams.append(contentsOf: teams)
}
return allTeams
} catch {
throw CloudKitError.from(error)
}
}
func fetchStadiums() async throws -> [Stadium] {
do {
try await checkAvailability()
return try await cloudKit.fetchStadiums()
} catch {
throw CloudKitError.from(error)
}
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
do {
try await checkAvailability()
return try await cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
} catch {
throw CloudKitError.from(error)
}
}
func fetchGame(by id: UUID) async throws -> Game? {
do {
try await checkAvailability()
return try await cloudKit.fetchGame(by: id)
} catch {
throw CloudKitError.from(error)
}
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
do {
try await checkAvailability()
// Fetch all required data
async let gamesTask = cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
async let teamsTask = fetchAllTeamsInternal()
async let stadiumsTask = cloudKit.fetchStadiums()
let (games, teams, stadiums) = try await (gamesTask, teamsTask, stadiumsTask)
let teamsById = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) })
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
} catch {
throw CloudKitError.from(error)
}
}
// Internal helper to avoid duplicate availability checks
private func fetchAllTeamsInternal() async throws -> [Team] {
var allTeams: [Team] = []
for sport in Sport.supported {
let teams = try await cloudKit.fetchTeams(for: sport)
allTeams.append(contentsOf: teams)
}
return allTeams
}
}

View File

@@ -0,0 +1,211 @@
//
// CloudKitService.swift
// SportsTime
//
import Foundation
import CloudKit
// MARK: - CloudKit Errors
enum CloudKitError: Error, LocalizedError {
case notSignedIn
case networkUnavailable
case serverError(String)
case quotaExceeded
case permissionDenied
case recordNotFound
case unknown(Error)
var errorDescription: String? {
switch self {
case .notSignedIn:
return "Please sign in to iCloud in Settings to sync data."
case .networkUnavailable:
return "Unable to connect to the server. Check your internet connection."
case .serverError(let message):
return "Server error: \(message)"
case .quotaExceeded:
return "iCloud storage quota exceeded."
case .permissionDenied:
return "Permission denied. Check your iCloud settings."
case .recordNotFound:
return "Data not found."
case .unknown(let error):
return "An unexpected error occurred: \(error.localizedDescription)"
}
}
static func from(_ error: Error) -> CloudKitError {
if let ckError = error as? CKError {
switch ckError.code {
case .notAuthenticated:
return .notSignedIn
case .networkUnavailable, .networkFailure:
return .networkUnavailable
case .serverResponseLost:
return .serverError("Connection lost")
case .quotaExceeded:
return .quotaExceeded
case .permissionFailure:
return .permissionDenied
case .unknownItem:
return .recordNotFound
default:
return .serverError(ckError.localizedDescription)
}
}
return .unknown(error)
}
}
actor CloudKitService {
static let shared = CloudKitService()
private let container: CKContainer
private let publicDatabase: CKDatabase
private init() {
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
self.publicDatabase = container.publicCloudDatabase
}
// MARK: - Availability Check
func isAvailable() async -> Bool {
let status = await checkAccountStatus()
return status == .available
}
func checkAvailabilityWithError() async throws {
let status = await checkAccountStatus()
switch status {
case .available:
return
case .noAccount:
throw CloudKitError.notSignedIn
case .restricted:
throw CloudKitError.permissionDenied
case .couldNotDetermine:
throw CloudKitError.networkUnavailable
case .temporarilyUnavailable:
throw CloudKitError.networkUnavailable
@unknown default:
throw CloudKitError.networkUnavailable
}
}
// MARK: - Fetch Operations
func fetchTeams(for sport: Sport) async throws -> [Team] {
let predicate = NSPredicate(format: "sport == %@", sport.rawValue)
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKTeam(record: record).team
}
}
func fetchStadiums() async throws -> [Stadium] {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKStadium(record: record).stadium
}
}
func fetchGames(
sports: Set<Sport>,
startDate: Date,
endDate: Date
) async throws -> [Game] {
var allGames: [Game] = []
for sport in sports {
let predicate = NSPredicate(
format: "sport == %@ AND dateTime >= %@ AND dateTime <= %@",
sport.rawValue,
startDate as NSDate,
endDate as NSDate
)
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
let games = results.compactMap { result -> Game? in
guard case .success(let record) = result.1 else { return nil }
let ckGame = CKGame(record: record)
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference,
let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference,
let homeId = UUID(uuidString: homeRef.recordID.recordName),
let awayId = UUID(uuidString: awayRef.recordID.recordName),
let stadiumId = UUID(uuidString: stadiumRef.recordID.recordName)
else { return nil }
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
}
allGames.append(contentsOf: games)
}
return allGames.sorted { $0.dateTime < $1.dateTime }
}
func fetchGame(by id: UUID) async throws -> Game? {
let predicate = NSPredicate(format: "gameId == %@", id.uuidString)
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
guard let result = results.first,
case .success(let record) = result.1 else { return nil }
let ckGame = CKGame(record: record)
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference,
let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference,
let homeId = UUID(uuidString: homeRef.recordID.recordName),
let awayId = UUID(uuidString: awayRef.recordID.recordName),
let stadiumId = UUID(uuidString: stadiumRef.recordID.recordName)
else { return nil }
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
}
// MARK: - Sync Status
func checkAccountStatus() async -> CKAccountStatus {
do {
return try await container.accountStatus()
} catch {
return .couldNotDetermine
}
}
// MARK: - Subscription (for schedule updates)
func subscribeToScheduleUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.game,
predicate: NSPredicate(value: true),
subscriptionID: "game-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
}

View File

@@ -0,0 +1,132 @@
//
// DataProvider.swift
// SportsTime
//
import Foundation
import Combine
/// Protocol defining data operations for teams, stadiums, and games
protocol DataProvider: Sendable {
func fetchTeams(for sport: Sport) async throws -> [Team]
func fetchAllTeams() async throws -> [Team]
func fetchStadiums() async throws -> [Stadium]
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game]
func fetchGame(by id: UUID) async throws -> Game?
// Resolved data (with team/stadium references)
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame]
}
/// Environment-aware data provider that switches between stub and CloudKit
@MainActor
final class AppDataProvider: ObservableObject {
static let shared = AppDataProvider()
private let provider: any DataProvider
@Published private(set) var teams: [Team] = []
@Published private(set) var stadiums: [Stadium] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
@Published private(set) var errorMessage: String?
@Published private(set) var isUsingStubData: Bool
private var teamsById: [UUID: Team] = [:]
private var stadiumsById: [UUID: Stadium] = [:]
private init() {
#if targetEnvironment(simulator)
self.provider = StubDataProvider()
self.isUsingStubData = true
print("📱 Using StubDataProvider (Simulator)")
#else
self.provider = CloudKitDataProvider()
self.isUsingStubData = false
print("☁️ Using CloudKitDataProvider (Device)")
#endif
}
// MARK: - Data Loading
func loadInitialData() async {
isLoading = true
error = nil
errorMessage = nil
do {
async let teamsTask = provider.fetchAllTeams()
async let stadiumsTask = provider.fetchStadiums()
let (loadedTeams, loadedStadiums) = try await (teamsTask, stadiumsTask)
self.teams = loadedTeams
self.stadiums = loadedStadiums
// Build lookup dictionaries
self.teamsById = Dictionary(uniqueKeysWithValues: loadedTeams.map { ($0.id, $0) })
self.stadiumsById = Dictionary(uniqueKeysWithValues: loadedStadiums.map { ($0.id, $0) })
print("✅ Loaded \(teams.count) teams, \(stadiums.count) stadiums")
} catch let cloudKitError as CloudKitError {
self.error = cloudKitError
self.errorMessage = cloudKitError.errorDescription
print("❌ CloudKit error: \(cloudKitError.errorDescription ?? "Unknown")")
} catch {
self.error = error
self.errorMessage = error.localizedDescription
print("❌ Failed to load data: \(error)")
}
isLoading = false
}
func clearError() {
error = nil
errorMessage = nil
}
func retry() async {
await loadInitialData()
}
// MARK: - Data Access
func team(for id: UUID) -> Team? {
teamsById[id]
}
func stadium(for id: UUID) -> Stadium? {
stadiumsById[id]
}
func teams(for sport: Sport) -> [Team] {
teams.filter { $0.sport == sport }
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
let games = try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
func richGame(from game: Game) -> RichGame? {
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}

View File

@@ -0,0 +1,218 @@
//
// LocationPermissionManager.swift
// SportsTime
//
// Manages location permission requests and status
//
import Foundation
import CoreLocation
import SwiftUI
@MainActor
@Observable
final class LocationPermissionManager: NSObject {
static let shared = LocationPermissionManager()
private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined
private(set) var currentLocation: CLLocation?
private(set) var isRequestingPermission = false
private let locationManager = CLLocationManager()
override private init() {
super.init()
locationManager.delegate = self
authorizationStatus = locationManager.authorizationStatus
}
// MARK: - Computed Properties
var isAuthorized: Bool {
authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways
}
var needsPermission: Bool {
authorizationStatus == .notDetermined
}
var isDenied: Bool {
authorizationStatus == .denied || authorizationStatus == .restricted
}
var statusMessage: String {
switch authorizationStatus {
case .notDetermined:
return "Location access helps find nearby stadiums and optimize your route."
case .restricted:
return "Location access is restricted on this device."
case .denied:
return "Location access was denied. Enable it in Settings to use this feature."
case .authorizedAlways, .authorizedWhenInUse:
return "Location access granted."
@unknown default:
return "Unknown location status."
}
}
// MARK: - Actions
func requestPermission() {
guard authorizationStatus == .notDetermined else { return }
isRequestingPermission = true
locationManager.requestWhenInUseAuthorization()
}
func requestCurrentLocation() {
guard isAuthorized else { return }
locationManager.requestLocation()
}
func openSettings() {
guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(settingsURL)
}
}
// MARK: - CLLocationManagerDelegate
extension LocationPermissionManager: CLLocationManagerDelegate {
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in
self.authorizationStatus = manager.authorizationStatus
self.isRequestingPermission = false
// Auto-request location if newly authorized
if self.isAuthorized {
self.requestCurrentLocation()
}
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in
self.currentLocation = locations.last
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
Task { @MainActor in
print("Location error: \(error.localizedDescription)")
}
}
}
// MARK: - Location Permission View
struct LocationPermissionView: View {
@Bindable var manager = LocationPermissionManager.shared
var body: some View {
VStack(spacing: 16) {
Image(systemName: "location.circle.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
Text("Enable Location")
.font(.title2)
.fontWeight(.bold)
Text(manager.statusMessage)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if manager.needsPermission {
Button {
manager.requestPermission()
} label: {
Text("Allow Location Access")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal)
} else if manager.isDenied {
Button {
manager.openSettings()
} label: {
Text("Open Settings")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal)
} else if manager.isAuthorized {
Label("Location Enabled", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
.padding()
}
}
// MARK: - Location Permission Banner
struct LocationPermissionBanner: View {
@Bindable var manager = LocationPermissionManager.shared
@Binding var isPresented: Bool
var body: some View {
if manager.needsPermission || manager.isDenied {
HStack(spacing: 12) {
Image(systemName: "location.slash")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Location Not Available")
.font(.subheadline)
.fontWeight(.medium)
Text(manager.needsPermission ? "Enable for better route planning" : "Tap to enable in Settings")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
if manager.needsPermission {
manager.requestPermission()
} else {
manager.openSettings()
}
} label: {
Text(manager.needsPermission ? "Enable" : "Settings")
.font(.caption)
.fontWeight(.semibold)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(Capsule())
}
Button {
isPresented = false
} label: {
Image(systemName: "xmark")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
#Preview {
LocationPermissionView()
}

View File

@@ -0,0 +1,192 @@
//
// LocationService.swift
// SportsTime
//
import Foundation
import CoreLocation
import MapKit
actor LocationService {
static let shared = LocationService()
private let geocoder = CLGeocoder()
private init() {}
// MARK: - Geocoding
func geocode(_ address: String) async throws -> CLLocationCoordinate2D? {
let placemarks = try await geocoder.geocodeAddressString(address)
return placemarks.first?.location?.coordinate
}
func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async throws -> String? {
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let placemarks = try await geocoder.reverseGeocodeLocation(location)
guard let placemark = placemarks.first else { return nil }
var components: [String] = []
if let city = placemark.locality { components.append(city) }
if let state = placemark.administrativeArea { components.append(state) }
return components.isEmpty ? nil : components.joined(separator: ", ")
}
func resolveLocation(_ input: LocationInput) async throws -> LocationInput {
if input.isResolved { return input }
let searchText = input.address ?? input.name
guard let coordinate = try await geocode(searchText) else {
throw LocationError.geocodingFailed
}
return LocationInput(
name: input.name,
coordinate: coordinate,
address: input.address
)
}
// MARK: - Location Search
func searchLocations(_ query: String) async throws -> [LocationSearchResult] {
guard !query.trimmingCharacters(in: .whitespaces).isEmpty else {
return []
}
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = query
request.resultTypes = [.address, .pointOfInterest]
let search = MKLocalSearch(request: request)
let response = try await search.start()
return response.mapItems.map { item in
LocationSearchResult(
name: item.name ?? "Unknown",
address: formatAddress(item.placemark),
coordinate: item.placemark.coordinate
)
}
}
private func formatAddress(_ placemark: MKPlacemark) -> String {
var components: [String] = []
if let city = placemark.locality { components.append(city) }
if let state = placemark.administrativeArea { components.append(state) }
if let country = placemark.country, country != "United States" {
components.append(country)
}
return components.joined(separator: ", ")
}
// MARK: - Distance Calculations
func calculateDistance(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> CLLocationDistance {
let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude)
let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude)
return fromLocation.distance(from: toLocation)
}
func calculateDrivingRoute(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) async throws -> RouteInfo {
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
request.transportType = .automobile
request.requestsAlternateRoutes = false
let directions = MKDirections(request: request)
let response = try await directions.calculate()
guard let route = response.routes.first else {
throw LocationError.routeNotFound
}
return RouteInfo(
distance: route.distance,
expectedTravelTime: route.expectedTravelTime,
polyline: route.polyline
)
}
func calculateDrivingMatrix(
origins: [CLLocationCoordinate2D],
destinations: [CLLocationCoordinate2D]
) async throws -> [[RouteInfo?]] {
var matrix: [[RouteInfo?]] = []
for origin in origins {
var row: [RouteInfo?] = []
for destination in destinations {
do {
let route = try await calculateDrivingRoute(from: origin, to: destination)
row.append(route)
} catch {
row.append(nil)
}
}
matrix.append(row)
}
return matrix
}
}
// MARK: - Route Info
struct RouteInfo {
let distance: CLLocationDistance // meters
let expectedTravelTime: TimeInterval // seconds
let polyline: MKPolyline?
var distanceMiles: Double { distance * 0.000621371 }
var travelTimeHours: Double { expectedTravelTime / 3600.0 }
}
// MARK: - Location Search Result
struct LocationSearchResult: Identifiable, Hashable {
let id = UUID()
let name: String
let address: String
let coordinate: CLLocationCoordinate2D
var displayName: String {
if address.isEmpty || name == address {
return name
}
return "\(name), \(address)"
}
func toLocationInput() -> LocationInput {
LocationInput(
name: name,
coordinate: coordinate,
address: address.isEmpty ? nil : address
)
}
}
// MARK: - Errors
enum LocationError: Error, LocalizedError {
case geocodingFailed
case routeNotFound
case permissionDenied
var errorDescription: String? {
switch self {
case .geocodingFailed: return "Unable to find location"
case .routeNotFound: return "Unable to calculate route"
case .permissionDenied: return "Location permission required"
}
}
}

View File

@@ -0,0 +1,385 @@
//
// StubDataProvider.swift
// SportsTime
//
// Provides real data from bundled JSON files for Simulator testing
//
import Foundation
import CryptoKit
actor StubDataProvider: DataProvider {
// MARK: - JSON Models
private struct JSONGame: Codable {
let id: String
let sport: String
let season: String
let date: String
let time: String?
let home_team: String
let away_team: String
let home_team_abbrev: String
let away_team_abbrev: String
let venue: String
let source: String
let is_playoff: Bool
let broadcast: String?
}
private struct JSONStadium: Codable {
let id: String
let name: String
let city: String
let state: String
let latitude: Double
let longitude: Double
let capacity: Int
let sport: String
let team_abbrevs: [String]
let source: String
let year_opened: Int?
}
// MARK: - Cached Data
private var cachedGames: [Game]?
private var cachedTeams: [Team]?
private var cachedStadiums: [Stadium]?
private var teamsByAbbrev: [String: Team] = [:]
private var stadiumsByVenue: [String: Stadium] = [:]
// MARK: - DataProvider Protocol
func fetchTeams(for sport: Sport) async throws -> [Team] {
try await loadAllDataIfNeeded()
return cachedTeams?.filter { $0.sport == sport } ?? []
}
func fetchAllTeams() async throws -> [Team] {
try await loadAllDataIfNeeded()
return cachedTeams ?? []
}
func fetchStadiums() async throws -> [Stadium] {
try await loadAllDataIfNeeded()
return cachedStadiums ?? []
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
try await loadAllDataIfNeeded()
return (cachedGames ?? []).filter { game in
sports.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate
}
}
func fetchGame(by id: UUID) async throws -> Game? {
try await loadAllDataIfNeeded()
return cachedGames?.first { $0.id == id }
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
try await loadAllDataIfNeeded()
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) })
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
// MARK: - Data Loading
private func loadAllDataIfNeeded() async throws {
guard cachedGames == nil else { return }
// Load stadiums first
let jsonStadiums = try loadStadiumsJSON()
cachedStadiums = jsonStadiums.map { convertStadium($0) }
// Build stadium lookup by venue name
for stadium in cachedStadiums ?? [] {
stadiumsByVenue[stadium.name.lowercased()] = stadium
}
// Load games and extract teams
let jsonGames = try loadGamesJSON()
// Build teams from games data
var teamsDict: [String: Team] = [:]
for jsonGame in jsonGames {
let sport = parseSport(jsonGame.sport)
// Home team
let homeKey = "\(sport.rawValue)_\(jsonGame.home_team_abbrev)"
if teamsDict[homeKey] == nil {
let stadiumId = findStadiumId(venue: jsonGame.venue, sport: sport)
let team = Team(
id: deterministicUUID(from: homeKey),
name: extractTeamName(from: jsonGame.home_team),
abbreviation: jsonGame.home_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.home_team),
stadiumId: stadiumId
)
teamsDict[homeKey] = team
teamsByAbbrev[homeKey] = team
}
// Away team
let awayKey = "\(sport.rawValue)_\(jsonGame.away_team_abbrev)"
if teamsDict[awayKey] == nil {
// Away teams might not have a stadium in our data yet
let team = Team(
id: deterministicUUID(from: awayKey),
name: extractTeamName(from: jsonGame.away_team),
abbreviation: jsonGame.away_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.away_team),
stadiumId: UUID() // Placeholder, will be updated when they're home team
)
teamsDict[awayKey] = team
teamsByAbbrev[awayKey] = team
}
}
cachedTeams = Array(teamsDict.values)
// Convert games (deduplicate by ID - JSON may have duplicate entries)
var seenGameIds = Set<String>()
let uniqueJsonGames = jsonGames.filter { game in
if seenGameIds.contains(game.id) {
return false
}
seenGameIds.insert(game.id)
return true
}
cachedGames = uniqueJsonGames.compactMap { convertGame($0) }
print("StubDataProvider loaded: \(cachedGames?.count ?? 0) games, \(cachedTeams?.count ?? 0) teams, \(cachedStadiums?.count ?? 0) stadiums")
}
private func loadGamesJSON() throws -> [JSONGame] {
guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else {
print("Warning: games.json not found in bundle")
return []
}
let data = try Data(contentsOf: url)
do {
return try JSONDecoder().decode([JSONGame].self, from: data)
} catch let DecodingError.keyNotFound(key, context) {
print("❌ Games JSON missing key '\(key.stringValue)' at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
throw DecodingError.keyNotFound(key, context)
} catch let DecodingError.typeMismatch(type, context) {
print("❌ Games JSON type mismatch for \(type) at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
throw DecodingError.typeMismatch(type, context)
} catch {
print("❌ Games JSON decode error: \(error)")
throw error
}
}
private func loadStadiumsJSON() throws -> [JSONStadium] {
guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else {
print("Warning: stadiums.json not found in bundle")
return []
}
let data = try Data(contentsOf: url)
do {
return try JSONDecoder().decode([JSONStadium].self, from: data)
} catch let DecodingError.keyNotFound(key, context) {
print("❌ Stadiums JSON missing key '\(key.stringValue)' at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
throw DecodingError.keyNotFound(key, context)
} catch let DecodingError.typeMismatch(type, context) {
print("❌ Stadiums JSON type mismatch for \(type) at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
throw DecodingError.typeMismatch(type, context)
} catch {
print("❌ Stadiums JSON decode error: \(error)")
throw error
}
}
// MARK: - Conversion Helpers
private func convertStadium(_ json: JSONStadium) -> Stadium {
Stadium(
id: deterministicUUID(from: json.id),
name: json.name,
city: json.city,
state: json.state.isEmpty ? stateFromCity(json.city) : json.state,
latitude: json.latitude,
longitude: json.longitude,
capacity: json.capacity,
yearOpened: json.year_opened
)
}
private func convertGame(_ json: JSONGame) -> Game? {
let sport = parseSport(json.sport)
let homeKey = "\(sport.rawValue)_\(json.home_team_abbrev)"
let awayKey = "\(sport.rawValue)_\(json.away_team_abbrev)"
guard let homeTeam = teamsByAbbrev[homeKey],
let awayTeam = teamsByAbbrev[awayKey] else {
return nil
}
let stadiumId = findStadiumId(venue: json.venue, sport: sport)
guard let dateTime = parseDateTime(date: json.date, time: json.time ?? "7:00p") else {
return nil
}
return Game(
id: deterministicUUID(from: json.id),
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
stadiumId: stadiumId,
dateTime: dateTime,
sport: sport,
season: json.season,
isPlayoff: json.is_playoff,
broadcastInfo: json.broadcast
)
}
private func parseSport(_ sport: String) -> Sport {
switch sport.uppercased() {
case "MLB": return .mlb
case "NBA": return .nba
case "NHL": return .nhl
case "NFL": return .nfl
case "MLS": return .mls
default: return .mlb
}
}
private func parseDateTime(date: String, time: String) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
// Parse date
formatter.dateFormat = "yyyy-MM-dd"
guard let dateOnly = formatter.date(from: date) else { return nil }
// Parse time (e.g., "7:30p", "10:00p", "1:05p")
var hour = 12
var minute = 0
let cleanTime = time.lowercased().replacingOccurrences(of: " ", with: "")
let isPM = cleanTime.contains("p")
let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "")
let components = timeWithoutAMPM.split(separator: ":")
if let h = Int(components[0]) {
hour = h
if isPM && hour != 12 {
hour += 12
} else if !isPM && hour == 12 {
hour = 0
}
}
if components.count > 1, let m = Int(components[1]) {
minute = m
}
return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly)
}
private func findStadiumId(venue: String, sport: Sport) -> UUID {
let venueLower = venue.lowercased()
// Try exact match
if let stadium = stadiumsByVenue[venueLower] {
return stadium.id
}
// Try partial match
for (name, stadium) in stadiumsByVenue {
if name.contains(venueLower) || venueLower.contains(name) {
return stadium.id
}
}
// Generate deterministic ID for unknown venues
return deterministicUUID(from: "venue_\(venue)")
}
private func deterministicUUID(from string: String) -> UUID {
// Create a deterministic UUID using SHA256 (truly deterministic across launches)
let data = Data(string.utf8)
let hash = SHA256.hash(data: data)
let hashBytes = Array(hash)
// Use first 16 bytes of SHA256 hash
var bytes = Array(hashBytes.prefix(16))
// Set UUID version (4) and variant bits
bytes[6] = (bytes[6] & 0x0F) | 0x40
bytes[8] = (bytes[8] & 0x3F) | 0x80
return UUID(uuid: (
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11],
bytes[12], bytes[13], bytes[14], bytes[15]
))
}
private func extractTeamName(from fullName: String) -> String {
// "Boston Celtics" -> "Celtics"
let parts = fullName.split(separator: " ")
if parts.count > 1 {
return parts.dropFirst().joined(separator: " ")
}
return fullName
}
private func extractCity(from fullName: String) -> String {
// "Boston Celtics" -> "Boston"
// "New York Knicks" -> "New York"
// "Los Angeles Lakers" -> "Los Angeles"
let knownCities = [
"New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio",
"New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City",
"St. Louis", "St Louis"
]
for city in knownCities {
if fullName.hasPrefix(city) {
return city
}
}
// Default: first word
return String(fullName.split(separator: " ").first ?? Substring(fullName))
}
private func stateFromCity(_ city: String) -> String {
let cityToState: [String: String] = [
"Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC",
"Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO",
"Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA",
"Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN",
"New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL",
"Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA",
"San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON",
"Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA",
"Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO",
"Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA",
"Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT"
]
return cityToState[city] ?? ""
}
}

View File

@@ -0,0 +1,344 @@
//
// PDFGenerator.swift
// SportsTime
//
import Foundation
import PDFKit
import UIKit
actor PDFGenerator {
// MARK: - Generate PDF
func generatePDF(for trip: Trip, games: [UUID: RichGame]) async throws -> Data {
let pageWidth: CGFloat = 612 // Letter size
let pageHeight: CGFloat = 792
let margin: CGFloat = 50
let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight))
let data = pdfRenderer.pdfData { context in
var currentY: CGFloat = margin
// Page 1: Cover
context.beginPage()
currentY = drawCoverPage(
context: context,
trip: trip,
pageWidth: pageWidth,
margin: margin
)
// Page 2+: Itinerary
context.beginPage()
currentY = margin
currentY = drawItineraryHeader(
context: context,
y: currentY,
pageWidth: pageWidth,
margin: margin
)
for day in trip.itineraryDays() {
// Check if we need a new page
if currentY > pageHeight - 200 {
context.beginPage()
currentY = margin
}
currentY = drawDay(
context: context,
day: day,
games: games,
y: currentY,
pageWidth: pageWidth,
margin: margin
)
currentY += 20 // Space between days
}
// Summary page
context.beginPage()
drawSummaryPage(
context: context,
trip: trip,
pageWidth: pageWidth,
margin: margin
)
}
return data
}
// MARK: - Cover Page
private func drawCoverPage(
context: UIGraphicsPDFRendererContext,
trip: Trip,
pageWidth: CGFloat,
margin: CGFloat
) -> CGFloat {
var y: CGFloat = 150
// Title
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 32),
.foregroundColor: UIColor.black
]
let title = trip.name
let titleRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 50)
(title as NSString).draw(in: titleRect, withAttributes: titleAttributes)
y += 60
// Date range
let subtitleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 18),
.foregroundColor: UIColor.darkGray
]
let dateRange = trip.formattedDateRange
let dateRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 30)
(dateRange as NSString).draw(in: dateRect, withAttributes: subtitleAttributes)
y += 50
// Quick stats
let statsAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.gray
]
let stats = """
\(trip.stops.count) Cities • \(trip.totalGames) Games • \(trip.formattedTotalDistance)
\(trip.tripDuration) Days • \(trip.formattedTotalDriving) Driving
"""
let statsRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 50)
(stats as NSString).draw(in: statsRect, withAttributes: statsAttributes)
y += 80
// Cities list
let citiesTitle = "Cities Visited"
let citiesTitleRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 25)
(citiesTitle as NSString).draw(in: citiesTitleRect, withAttributes: [
.font: UIFont.boldSystemFont(ofSize: 16),
.foregroundColor: UIColor.black
])
y += 30
for city in trip.cities {
let cityRect = CGRect(x: margin + 20, y: y, width: pageWidth - margin * 2 - 20, height: 20)
("\(city)" as NSString).draw(in: cityRect, withAttributes: statsAttributes)
y += 22
}
return y
}
// MARK: - Itinerary Header
private func drawItineraryHeader(
context: UIGraphicsPDFRendererContext,
y: CGFloat,
pageWidth: CGFloat,
margin: CGFloat
) -> CGFloat {
let headerAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 24),
.foregroundColor: UIColor.black
]
let header = "Day-by-Day Itinerary"
let headerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 35)
(header as NSString).draw(in: headerRect, withAttributes: headerAttributes)
return y + 50
}
// MARK: - Day Section
private func drawDay(
context: UIGraphicsPDFRendererContext,
day: ItineraryDay,
games: [UUID: RichGame],
y: CGFloat,
pageWidth: CGFloat,
margin: CGFloat
) -> CGFloat {
var currentY = y
// Day header
let dayHeaderAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 16),
.foregroundColor: UIColor.systemBlue
]
let dayHeader = "Day \(day.dayNumber): \(day.formattedDate)"
let dayHeaderRect = CGRect(x: margin, y: currentY, width: pageWidth - margin * 2, height: 25)
(dayHeader as NSString).draw(in: dayHeaderRect, withAttributes: dayHeaderAttributes)
currentY += 28
// City
if let city = day.primaryCity {
let cityAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.darkGray
]
let cityRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
("📍 \(city)" as NSString).draw(in: cityRect, withAttributes: cityAttributes)
currentY += 24
}
// Travel segment
if day.hasTravelSegment {
for segment in day.travelSegments {
let travelText = "🚗 \(segment.fromLocation.name)\(segment.toLocation.name) (\(segment.formattedDistance), \(segment.formattedDuration))"
let travelRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
(travelText as NSString).draw(in: travelRect, withAttributes: [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor.gray
])
currentY += 22
}
}
// Games
for gameId in day.gameIds {
if let richGame = games[gameId] {
let gameText = "\(richGame.fullMatchupDescription)"
let gameRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
(gameText as NSString).draw(in: gameRect, withAttributes: [
.font: UIFont.systemFont(ofSize: 13),
.foregroundColor: UIColor.black
])
currentY += 20
let venueText = " \(richGame.venueDescription)\(richGame.game.gameTime)"
let venueRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 18)
(venueText as NSString).draw(in: venueRect, withAttributes: [
.font: UIFont.systemFont(ofSize: 11),
.foregroundColor: UIColor.gray
])
currentY += 22
}
}
// Rest day indicator
if day.isRestDay {
let restText = "😴 Rest Day"
let restRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
(restText as NSString).draw(in: restRect, withAttributes: [
.font: UIFont.italicSystemFont(ofSize: 12),
.foregroundColor: UIColor.gray
])
currentY += 22
}
// Separator line
currentY += 5
let path = UIBezierPath()
path.move(to: CGPoint(x: margin, y: currentY))
path.addLine(to: CGPoint(x: pageWidth - margin, y: currentY))
UIColor.lightGray.setStroke()
path.lineWidth = 0.5
path.stroke()
currentY += 10
return currentY
}
// MARK: - Summary Page
private func drawSummaryPage(
context: UIGraphicsPDFRendererContext,
trip: Trip,
pageWidth: CGFloat,
margin: CGFloat
) {
var y: CGFloat = margin
// Header
let headerAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 24),
.foregroundColor: UIColor.black
]
let header = "Trip Summary"
let headerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 35)
(header as NSString).draw(in: headerRect, withAttributes: headerAttributes)
y += 50
// Stats
let statAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.black
]
let stats = [
("Total Duration", "\(trip.tripDuration) days"),
("Total Distance", trip.formattedTotalDistance),
("Total Driving Time", trip.formattedTotalDriving),
("Average Daily Driving", String(format: "%.1f hours", trip.averageDrivingHoursPerDay)),
("Cities Visited", "\(trip.stops.count)"),
("Games Attended", "\(trip.totalGames)"),
("Sports", trip.uniqueSports.map { $0.rawValue }.joined(separator: ", "))
]
for (label, value) in stats {
let labelRect = CGRect(x: margin, y: y, width: 200, height: 22)
("\(label):" as NSString).draw(in: labelRect, withAttributes: [
.font: UIFont.boldSystemFont(ofSize: 13),
.foregroundColor: UIColor.darkGray
])
let valueRect = CGRect(x: margin + 200, y: y, width: pageWidth - margin * 2 - 200, height: 22)
(value as NSString).draw(in: valueRect, withAttributes: statAttributes)
y += 26
}
// Score (if available)
if let score = trip.score {
y += 20
let scoreHeader = "Trip Score: \(score.scoreGrade) (\(score.formattedOverallScore)/100)"
let scoreRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 30)
(scoreHeader as NSString).draw(in: scoreRect, withAttributes: [
.font: UIFont.boldSystemFont(ofSize: 18),
.foregroundColor: UIColor.systemGreen
])
}
// Footer
y = 720
let footerText = "Generated by Sport Travel Planner"
let footerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 20)
(footerText as NSString).draw(in: footerRect, withAttributes: [
.font: UIFont.italicSystemFont(ofSize: 10),
.foregroundColor: UIColor.lightGray
])
}
}
// MARK: - Export Service
actor ExportService {
private let pdfGenerator = PDFGenerator()
func exportToPDF(trip: Trip, games: [UUID: RichGame]) async throws -> URL {
let data = try await pdfGenerator.generatePDF(for: trip, games: games)
let fileName = "\(trip.name.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).pdf"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
try data.write(to: url)
return url
}
func shareTrip(_ trip: Trip) -> URL? {
// Generate a shareable deep link
// In production, this would create a proper share URL
let baseURL = "sportstime://trip/"
return URL(string: baseURL + trip.id.uuidString)
}
}

View File

@@ -0,0 +1,306 @@
//
// HomeView.swift
// SportsTime
//
import SwiftUI
import SwiftData
struct HomeView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \SavedTrip.updatedAt, order: .reverse) private var savedTrips: [SavedTrip]
@State private var showNewTrip = false
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
// Home Tab
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Hero Card
heroCard
// Quick Actions
quickActions
// Saved Trips
if !savedTrips.isEmpty {
savedTripsSection
}
// Featured / Tips
tipsSection
}
.padding()
}
.navigationTitle("Sport Travel Planner")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showNewTrip = true
} label: {
Image(systemName: "plus")
}
}
}
}
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
// Schedule Tab
NavigationStack {
ScheduleListView()
}
.tabItem {
Label("Schedule", systemImage: "calendar")
}
.tag(1)
// My Trips Tab
NavigationStack {
SavedTripsListView(trips: savedTrips)
}
.tabItem {
Label("My Trips", systemImage: "suitcase.fill")
}
.tag(2)
// Settings Tab
NavigationStack {
SettingsView()
}
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(3)
}
.sheet(isPresented: $showNewTrip) {
TripCreationView()
}
}
// MARK: - Hero Card
private var heroCard: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Plan Your Ultimate Sports Road Trip")
.font(.title2)
.fontWeight(.bold)
Text("Visit multiple stadiums, catch live games, and create unforgettable memories.")
.font(.subheadline)
.foregroundStyle(.secondary)
Button {
showNewTrip = true
} label: {
Text("Start Planning")
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding()
.background(
LinearGradient(
colors: [.blue.opacity(0.1), .green.opacity(0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
// MARK: - Quick Actions
private var quickActions: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Quick Start")
.font(.headline)
HStack(spacing: 12) {
ForEach(Sport.supported) { sport in
QuickSportButton(sport: sport) {
// Start trip with this sport pre-selected
showNewTrip = true
}
}
}
}
}
// MARK: - Saved Trips
private var savedTripsSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Recent Trips")
.font(.headline)
Spacer()
Button("See All") {
selectedTab = 2
}
.font(.subheadline)
}
ForEach(savedTrips.prefix(3)) { savedTrip in
if let trip = savedTrip.trip {
SavedTripCard(trip: trip)
}
}
}
}
// MARK: - Tips
private var tipsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Planning Tips")
.font(.headline)
VStack(spacing: 8) {
TipRow(icon: "calendar.badge.clock", title: "Check schedules early", subtitle: "Game times can change, sync often")
TipRow(icon: "car.fill", title: "Plan rest days", subtitle: "Don't overdo the driving")
TipRow(icon: "star.fill", title: "Mark must-sees", subtitle: "Ensure your favorite matchups are included")
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Supporting Views
struct QuickSportButton: View {
let sport: Sport
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Image(systemName: sport.iconName)
.font(.title)
Text(sport.rawValue)
.font(.caption)
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
struct SavedTripCard: View {
let trip: Trip
var body: some View {
NavigationLink {
TripDetailView(trip: trip, games: [:])
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(trip.name)
.font(.subheadline)
.fontWeight(.semibold)
Text(trip.formattedDateRange)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Label("\(trip.stops.count) cities", systemImage: "mappin")
Label("\(trip.totalGames) games", systemImage: "sportscourt")
}
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
struct TipRow: View {
let icon: String
let title: String
let subtitle: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(.blue)
.frame(width: 30)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
// MARK: - Saved Trips List View
struct SavedTripsListView: View {
let trips: [SavedTrip]
var body: some View {
List {
if trips.isEmpty {
ContentUnavailableView(
"No Saved Trips",
systemImage: "suitcase",
description: Text("Your planned trips will appear here")
)
} else {
ForEach(trips) { savedTrip in
if let trip = savedTrip.trip {
NavigationLink {
TripDetailView(trip: trip, games: [:])
} label: {
VStack(alignment: .leading) {
Text(trip.name)
.font(.headline)
Text(trip.formattedDateRange)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
}
.navigationTitle("My Trips")
}
}
#Preview {
HomeView()
.modelContainer(for: SavedTrip.self, inMemory: true)
}

View File

@@ -0,0 +1,134 @@
//
// ScheduleViewModel.swift
// SportsTime
//
import Foundation
import SwiftUI
@MainActor
@Observable
final class ScheduleViewModel {
// MARK: - Filter State
var selectedSports: Set<Sport> = Set(Sport.supported)
var startDate: Date = Date()
var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
var searchText: String = ""
// MARK: - Data State
private(set) var games: [RichGame] = []
private(set) var isLoading = false
private(set) var error: Error?
private(set) var errorMessage: String?
private let dataProvider = AppDataProvider.shared
// MARK: - Computed Properties
var filteredGames: [RichGame] {
guard !searchText.isEmpty else { return games }
let query = searchText.lowercased()
return games.filter { game in
game.homeTeam.name.lowercased().contains(query) ||
game.homeTeam.city.lowercased().contains(query) ||
game.awayTeam.name.lowercased().contains(query) ||
game.awayTeam.city.lowercased().contains(query) ||
game.stadium.name.lowercased().contains(query) ||
game.stadium.city.lowercased().contains(query)
}
}
var gamesByDate: [(date: Date, games: [RichGame])] {
let calendar = Calendar.current
let grouped = Dictionary(grouping: filteredGames) { game in
calendar.startOfDay(for: game.game.dateTime)
}
return grouped.sorted { $0.key < $1.key }.map { ($0.key, $0.value) }
}
var hasFilters: Bool {
selectedSports.count < Sport.supported.count || !searchText.isEmpty
}
// MARK: - Actions
func loadGames() async {
guard !selectedSports.isEmpty else {
games = []
return
}
isLoading = true
error = nil
errorMessage = nil
do {
// Load initial data if needed
if dataProvider.teams.isEmpty {
await dataProvider.loadInitialData()
}
// Check if data provider had an error
if let providerError = dataProvider.errorMessage {
self.errorMessage = providerError
self.error = dataProvider.error
isLoading = false
return
}
games = try await dataProvider.fetchRichGames(
sports: selectedSports,
startDate: startDate,
endDate: endDate
)
} catch let cloudKitError as CloudKitError {
self.error = cloudKitError
self.errorMessage = cloudKitError.errorDescription
print("CloudKit error loading games: \(cloudKitError.errorDescription ?? "Unknown")")
} catch {
self.error = error
self.errorMessage = error.localizedDescription
print("Failed to load games: \(error)")
}
isLoading = false
}
func clearError() {
error = nil
errorMessage = nil
}
func toggleSport(_ sport: Sport) {
if selectedSports.contains(sport) {
selectedSports.remove(sport)
} else {
selectedSports.insert(sport)
}
Task {
await loadGames()
}
}
func resetFilters() {
selectedSports = Set(Sport.supported)
searchText = ""
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
Task {
await loadGames()
}
}
func updateDateRange(start: Date, end: Date) {
startDate = start
endDate = end
Task {
await loadGames()
}
}
}

View File

@@ -0,0 +1,343 @@
//
// ScheduleListView.swift
// SportsTime
//
import SwiftUI
struct ScheduleListView: View {
@State private var viewModel = ScheduleViewModel()
@State private var showDatePicker = false
var body: some View {
Group {
if viewModel.isLoading && viewModel.games.isEmpty {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
} else if viewModel.games.isEmpty {
emptyView
} else {
gamesList
}
}
.navigationTitle("Schedule")
.searchable(text: $viewModel.searchText, prompt: "Search teams or venues")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
showDatePicker = true
} label: {
Label("Date Range", systemImage: "calendar")
}
if viewModel.hasFilters {
Button(role: .destructive) {
viewModel.resetFilters()
} label: {
Label("Clear Filters", systemImage: "xmark.circle")
}
}
} label: {
Image(systemName: viewModel.hasFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
}
}
.sheet(isPresented: $showDatePicker) {
DateRangePickerSheet(
startDate: viewModel.startDate,
endDate: viewModel.endDate
) { start, end in
viewModel.updateDateRange(start: start, end: end)
}
.presentationDetents([.medium])
}
.task {
await viewModel.loadGames()
}
}
// MARK: - Sport Filter
private var sportFilter: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Sport.supported) { sport in
SportFilterChip(
sport: sport,
isSelected: viewModel.selectedSports.contains(sport)
) {
viewModel.toggleSport(sport)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
// MARK: - Games List
private var gamesList: some View {
List {
Section {
sportFilter
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
ForEach(viewModel.gamesByDate, id: \.date) { dateGroup in
Section {
ForEach(dateGroup.games) { richGame in
GameRowView(game: richGame)
}
} header: {
Text(formatSectionDate(dateGroup.date))
.font(.headline)
}
}
}
.listStyle(.plain)
.refreshable {
await viewModel.loadGames()
}
}
// MARK: - Empty State
private var emptyView: some View {
VStack(spacing: 16) {
sportFilter
ContentUnavailableView {
Label("No Games Found", systemImage: "sportscourt")
} description: {
Text("Try adjusting your filters or date range")
} actions: {
Button("Reset Filters") {
viewModel.resetFilters()
}
.buttonStyle(.bordered)
}
}
}
// MARK: - Loading State
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
Text("Loading schedule...")
.foregroundStyle(.secondary)
}
}
// MARK: - Error State
private func errorView(message: String) -> some View {
ContentUnavailableView {
Label("Unable to Load", systemImage: "exclamationmark.icloud")
} description: {
Text(message)
} actions: {
Button {
viewModel.clearError()
Task {
await viewModel.loadGames()
}
} label: {
Text("Try Again")
}
.buttonStyle(.bordered)
}
}
// MARK: - Helpers
private func formatSectionDate(_ date: Date) -> String {
let calendar = Calendar.current
let formatter = DateFormatter()
if calendar.isDateInToday(date) {
return "Today"
} else if calendar.isDateInTomorrow(date) {
return "Tomorrow"
} else {
formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: date)
}
}
}
// MARK: - Sport Filter Chip
struct SportFilterChip: View {
let sport: Sport
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Image(systemName: sport.iconName)
.font(.caption)
Text(sport.rawValue)
.font(.caption)
.fontWeight(.medium)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isSelected ? Color.blue : Color(.secondarySystemBackground))
.foregroundStyle(isSelected ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
// MARK: - Game Row View
struct GameRowView: View {
let game: RichGame
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Teams
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TeamBadge(team: game.awayTeam, isHome: false)
Text("@")
.font(.caption)
.foregroundStyle(.secondary)
TeamBadge(team: game.homeTeam, isHome: true)
}
}
Spacer()
// Sport badge
Image(systemName: game.game.sport.iconName)
.font(.caption)
.foregroundStyle(.secondary)
}
// Game info
HStack(spacing: 12) {
Label(game.game.gameTime, systemImage: "clock")
Label(game.stadium.name, systemImage: "building.2")
if let broadcast = game.game.broadcastInfo {
Label(broadcast, systemImage: "tv")
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
// MARK: - Team Badge
struct TeamBadge: View {
let team: Team
let isHome: Bool
var body: some View {
HStack(spacing: 4) {
if let colorHex = team.primaryColor {
Circle()
.fill(Color(hex: colorHex) ?? .gray)
.frame(width: 8, height: 8)
}
Text(team.abbreviation)
.font(.subheadline)
.fontWeight(isHome ? .bold : .regular)
Text(team.city)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Date Range Picker Sheet
struct DateRangePickerSheet: View {
@Environment(\.dismiss) private var dismiss
@State var startDate: Date
@State var endDate: Date
let onApply: (Date, Date) -> Void
var body: some View {
NavigationStack {
Form {
Section("Date Range") {
DatePicker("Start", selection: $startDate, displayedComponents: .date)
DatePicker("End", selection: $endDate, in: startDate..., displayedComponents: .date)
}
Section {
Button("Next 7 Days") {
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date()
}
Button("Next 14 Days") {
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
}
Button("Next 30 Days") {
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date()
}
}
}
.navigationTitle("Select Dates")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Apply") {
onApply(startDate, endDate)
dismiss()
}
}
}
}
}
}
// MARK: - Color Extension
extension Color {
init?(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
let r = Double((rgb & 0xFF0000) >> 16) / 255.0
let g = Double((rgb & 0x00FF00) >> 8) / 255.0
let b = Double(rgb & 0x0000FF) / 255.0
self.init(red: r, green: g, blue: b)
}
}
#Preview {
NavigationStack {
ScheduleListView()
}
}

View File

@@ -0,0 +1,154 @@
//
// SettingsViewModel.swift
// SportsTime
//
import Foundation
import SwiftUI
@MainActor
@Observable
final class SettingsViewModel {
// MARK: - User Preferences (persisted via UserDefaults)
var selectedSports: Set<Sport> {
didSet { savePreferences() }
}
var maxDrivingHoursPerDay: Int {
didSet { savePreferences() }
}
var preferredGameTime: PreferredGameTime {
didSet { savePreferences() }
}
var includePlayoffGames: Bool {
didSet { savePreferences() }
}
var notificationsEnabled: Bool {
didSet { savePreferences() }
}
// MARK: - Sync State
private(set) var isSyncing = false
private(set) var lastSyncDate: Date?
private(set) var syncError: String?
// MARK: - App Info
let appVersion: String
let buildNumber: String
// MARK: - Initialization
init() {
// Load from UserDefaults using local variables first
let defaults = UserDefaults.standard
// Selected sports
if let sportStrings = defaults.stringArray(forKey: "selectedSports") {
self.selectedSports = Set(sportStrings.compactMap { Sport(rawValue: $0) })
} else {
self.selectedSports = Set(Sport.supported)
}
// Travel preferences - use local variable to avoid self access before init complete
let savedDrivingHours = defaults.integer(forKey: "maxDrivingHoursPerDay")
self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours
if let timeRaw = defaults.string(forKey: "preferredGameTime"),
let time = PreferredGameTime(rawValue: timeRaw) {
self.preferredGameTime = time
} else {
self.preferredGameTime = .evening
}
self.includePlayoffGames = defaults.object(forKey: "includePlayoffGames") as? Bool ?? true
self.notificationsEnabled = defaults.object(forKey: "notificationsEnabled") as? Bool ?? true
// Last sync
self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date
// App info
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
self.buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
}
// MARK: - Actions
func syncSchedules() async {
isSyncing = true
syncError = nil
do {
// Trigger data reload from provider
await AppDataProvider.shared.loadInitialData()
lastSyncDate = Date()
UserDefaults.standard.set(lastSyncDate, forKey: "lastSyncDate")
} catch {
syncError = error.localizedDescription
}
isSyncing = false
}
func toggleSport(_ sport: Sport) {
if selectedSports.contains(sport) {
// Don't allow removing all sports
guard selectedSports.count > 1 else { return }
selectedSports.remove(sport)
} else {
selectedSports.insert(sport)
}
}
func resetToDefaults() {
selectedSports = Set(Sport.supported)
maxDrivingHoursPerDay = 8
preferredGameTime = .evening
includePlayoffGames = true
notificationsEnabled = true
}
// MARK: - Persistence
private func savePreferences() {
let defaults = UserDefaults.standard
defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports")
defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay")
defaults.set(preferredGameTime.rawValue, forKey: "preferredGameTime")
defaults.set(includePlayoffGames, forKey: "includePlayoffGames")
defaults.set(notificationsEnabled, forKey: "notificationsEnabled")
}
}
// MARK: - Supporting Types
enum PreferredGameTime: String, CaseIterable, Identifiable {
case any = "any"
case afternoon = "afternoon"
case evening = "evening"
var id: String { rawValue }
var displayName: String {
switch self {
case .any: return "Any Time"
case .afternoon: return "Afternoon"
case .evening: return "Evening"
}
}
var description: String {
switch self {
case .any: return "No preference"
case .afternoon: return "1 PM - 5 PM"
case .evening: return "6 PM - 10 PM"
}
}
}

View File

@@ -0,0 +1,228 @@
//
// SettingsView.swift
// SportsTime
//
import SwiftUI
struct SettingsView: View {
@State private var viewModel = SettingsViewModel()
@State private var showResetConfirmation = false
var body: some View {
List {
// Sports Preferences
sportsSection
// Travel Preferences
travelSection
// Game Preferences
gamePreferencesSection
// Notifications
notificationsSection
// Data Sync
dataSection
// About
aboutSection
// Reset
resetSection
}
.navigationTitle("Settings")
.alert("Reset Settings", isPresented: $showResetConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Reset", role: .destructive) {
viewModel.resetToDefaults()
}
} message: {
Text("This will reset all settings to their default values.")
}
}
// MARK: - Sports Section
private var sportsSection: some View {
Section {
ForEach(Sport.supported) { sport in
Toggle(isOn: Binding(
get: { viewModel.selectedSports.contains(sport) },
set: { _ in viewModel.toggleSport(sport) }
)) {
Label {
Text(sport.displayName)
} icon: {
Image(systemName: sport.iconName)
.foregroundStyle(sportColor(for: sport))
}
}
}
} header: {
Text("Favorite Sports")
} footer: {
Text("Selected sports will be shown by default in schedules and trip planning.")
}
}
// MARK: - Travel Section
private var travelSection: some View {
Section {
Stepper(value: $viewModel.maxDrivingHoursPerDay, in: 2...12) {
HStack {
Text("Max Driving Per Day")
Spacer()
Text("\(viewModel.maxDrivingHoursPerDay) hours")
.foregroundStyle(.secondary)
}
}
} header: {
Text("Travel Preferences")
} footer: {
Text("Trips will be optimized to keep daily driving within this limit.")
}
}
// MARK: - Game Preferences Section
private var gamePreferencesSection: some View {
Section {
Picker("Preferred Game Time", selection: $viewModel.preferredGameTime) {
ForEach(PreferredGameTime.allCases) { time in
VStack(alignment: .leading) {
Text(time.displayName)
}
.tag(time)
}
}
Toggle("Include Playoff Games", isOn: $viewModel.includePlayoffGames)
} header: {
Text("Game Preferences")
} footer: {
Text("These preferences affect trip optimization.")
}
}
// MARK: - Notifications Section
private var notificationsSection: some View {
Section {
Toggle("Schedule Updates", isOn: $viewModel.notificationsEnabled)
} header: {
Text("Notifications")
} footer: {
Text("Get notified when games in your trips are rescheduled.")
}
}
// MARK: - Data Section
private var dataSection: some View {
Section {
Button {
Task {
await viewModel.syncSchedules()
}
} label: {
HStack {
Label("Sync Schedules", systemImage: "arrow.triangle.2.circlepath")
Spacer()
if viewModel.isSyncing {
ProgressView()
}
}
}
.disabled(viewModel.isSyncing)
if let lastSync = viewModel.lastSyncDate {
HStack {
Text("Last Sync")
Spacer()
Text(lastSync, style: .relative)
.foregroundStyle(.secondary)
}
}
if let error = viewModel.syncError {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.red)
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
} header: {
Text("Data")
} footer: {
#if targetEnvironment(simulator)
Text("Using stub data (Simulator mode)")
#else
Text("Schedule data is synced from CloudKit.")
#endif
}
}
// MARK: - About Section
private var aboutSection: some View {
Section {
HStack {
Text("Version")
Spacer()
Text("\(viewModel.appVersion) (\(viewModel.buildNumber))")
.foregroundStyle(.secondary)
}
Link(destination: URL(string: "https://sportstime.app/privacy")!) {
Label("Privacy Policy", systemImage: "hand.raised")
}
Link(destination: URL(string: "https://sportstime.app/terms")!) {
Label("Terms of Service", systemImage: "doc.text")
}
Link(destination: URL(string: "mailto:support@sportstime.app")!) {
Label("Contact Support", systemImage: "envelope")
}
} header: {
Text("About")
}
}
// MARK: - Reset Section
private var resetSection: some View {
Section {
Button(role: .destructive) {
showResetConfirmation = true
} label: {
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
}
}
}
// MARK: - Helpers
private func sportColor(for sport: Sport) -> Color {
switch sport {
case .mlb: return .red
case .nba: return .orange
case .nhl: return .blue
case .nfl: return .green
case .mls: return .purple
}
}
}
#Preview {
NavigationStack {
SettingsView()
}
}

View File

@@ -0,0 +1,467 @@
//
// TripCreationViewModel.swift
// SportsTime
//
import Foundation
import SwiftUI
import Observation
import CoreLocation
@Observable
final class TripCreationViewModel {
// MARK: - State
enum ViewState: Equatable {
case editing
case planning
case completed(Trip)
case error(String)
static func == (lhs: ViewState, rhs: ViewState) -> Bool {
switch (lhs, rhs) {
case (.editing, .editing): return true
case (.planning, .planning): return true
case (.completed(let t1), .completed(let t2)): return t1.id == t2.id
case (.error(let e1), .error(let e2)): return e1 == e2
default: return false
}
}
}
var viewState: ViewState = .editing
// MARK: - Planning Mode
var planningMode: PlanningMode = .dateRange
// MARK: - Form Fields
// Locations (used in .locations mode)
var startLocationText: String = ""
var endLocationText: String = ""
var startLocation: LocationInput?
var endLocation: LocationInput?
// Sports
var selectedSports: Set<Sport> = [.mlb]
// Dates
var startDate: Date = Date()
var endDate: Date = Date().addingTimeInterval(86400 * 7)
// Trip duration for game-first mode (days before/after selected games)
var tripBufferDays: Int = 2
// Games
var mustSeeGameIds: Set<UUID> = []
var availableGames: [RichGame] = []
var isLoadingGames: Bool = false
// Travel
var travelMode: TravelMode = .drive
var routePreference: RoutePreference = .balanced
// Constraints
var useStopCount: Bool = true
var numberOfStops: Int = 5
var leisureLevel: LeisureLevel = .moderate
// Optional
var mustStopLocations: [LocationInput] = []
var preferredCities: [String] = []
var needsEVCharging: Bool = false
var lodgingType: LodgingType = .hotel
var numberOfDrivers: Int = 1
var maxDrivingHoursPerDriver: Double = 8
var catchOtherSports: Bool = false
// MARK: - Dependencies
private let planningEngine = TripPlanningEngine()
private let locationService = LocationService.shared
private let dataProvider = AppDataProvider.shared
// MARK: - Cached Data
private var teams: [UUID: Team] = [:]
private var stadiums: [UUID: Stadium] = [:]
private var games: [Game] = []
// MARK: - Computed Properties
var isFormValid: Bool {
switch planningMode {
case .dateRange:
// Need: sports + valid date range
return !selectedSports.isEmpty && endDate > startDate
case .gameFirst:
// Need: at least one selected game + sports
return !mustSeeGameIds.isEmpty && !selectedSports.isEmpty
case .locations:
// Need: start + end locations + sports
return !startLocationText.isEmpty &&
!endLocationText.isEmpty &&
!selectedSports.isEmpty
}
}
var formValidationMessage: String? {
switch planningMode {
case .dateRange:
if selectedSports.isEmpty { return "Select at least one sport" }
if endDate <= startDate { return "End date must be after start date" }
case .gameFirst:
if mustSeeGameIds.isEmpty { return "Select at least one game" }
if selectedSports.isEmpty { return "Select at least one sport" }
case .locations:
if startLocationText.isEmpty { return "Enter a starting location" }
if endLocationText.isEmpty { return "Enter an ending location" }
if selectedSports.isEmpty { return "Select at least one sport" }
}
return nil
}
var tripDurationDays: Int {
let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
return max(1, days)
}
var selectedGamesCount: Int {
mustSeeGameIds.count
}
var selectedGames: [RichGame] {
availableGames.filter { mustSeeGameIds.contains($0.game.id) }
}
/// Computed date range for game-first mode based on selected games
var gameFirstDateRange: (start: Date, end: Date)? {
guard !selectedGames.isEmpty else { return nil }
let gameDates = selectedGames.map { $0.game.dateTime }
guard let earliest = gameDates.min(),
let latest = gameDates.max() else { return nil }
let calendar = Calendar.current
let bufferedStart = calendar.date(byAdding: .day, value: -tripBufferDays, to: earliest) ?? earliest
let bufferedEnd = calendar.date(byAdding: .day, value: tripBufferDays, to: latest) ?? latest
return (bufferedStart, bufferedEnd)
}
// MARK: - Actions
func loadScheduleData() async {
do {
// Ensure initial data is loaded
if dataProvider.teams.isEmpty {
await dataProvider.loadInitialData()
}
// Use cached teams and stadiums from data provider
for team in dataProvider.teams {
teams[team.id] = team
}
for stadium in dataProvider.stadiums {
stadiums[stadium.id] = stadium
}
// Fetch games
games = try await dataProvider.fetchGames(
sports: selectedSports,
startDate: startDate,
endDate: endDate
)
// Build rich games for display
availableGames = games.compactMap { game -> RichGame? in
guard let homeTeam = teams[game.homeTeamId],
let awayTeam = teams[game.awayTeamId],
let stadium = stadiums[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
} catch {
viewState = .error("Failed to load schedule data: \(error.localizedDescription)")
}
}
func resolveLocations() async {
do {
if !startLocationText.isEmpty {
startLocation = try await locationService.resolveLocation(
LocationInput(name: startLocationText, address: startLocationText)
)
}
if !endLocationText.isEmpty {
endLocation = try await locationService.resolveLocation(
LocationInput(name: endLocationText, address: endLocationText)
)
}
} catch {
viewState = .error("Failed to resolve locations: \(error.localizedDescription)")
}
}
func planTrip() async {
guard isFormValid else { return }
viewState = .planning
do {
// Mode-specific setup
var effectiveStartDate = startDate
var effectiveEndDate = endDate
var resolvedStartLocation: LocationInput?
var resolvedEndLocation: LocationInput?
switch planningMode {
case .dateRange:
// Use provided date range, no location needed
// Games will be found within the date range across all regions
effectiveStartDate = startDate
effectiveEndDate = endDate
case .gameFirst:
// Calculate date range from selected games + buffer
if let dateRange = gameFirstDateRange {
effectiveStartDate = dateRange.start
effectiveEndDate = dateRange.end
}
// Derive start/end locations from first/last game stadiums
if let firstGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).first,
let lastGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).last {
resolvedStartLocation = LocationInput(
name: firstGame.stadium.city,
coordinate: firstGame.stadium.coordinate,
address: "\(firstGame.stadium.city), \(firstGame.stadium.state)"
)
resolvedEndLocation = LocationInput(
name: lastGame.stadium.city,
coordinate: lastGame.stadium.coordinate,
address: "\(lastGame.stadium.city), \(lastGame.stadium.state)"
)
}
case .locations:
// Resolve provided locations
await resolveLocations()
resolvedStartLocation = startLocation
resolvedEndLocation = endLocation
guard resolvedStartLocation != nil, resolvedEndLocation != nil else {
viewState = .error("Could not resolve start or end location")
return
}
}
// Ensure we have games data
if games.isEmpty {
await loadScheduleData()
}
// Build preferences
let preferences = TripPreferences(
planningMode: planningMode,
startLocation: resolvedStartLocation,
endLocation: resolvedEndLocation,
sports: selectedSports,
mustSeeGameIds: mustSeeGameIds,
travelMode: travelMode,
startDate: effectiveStartDate,
endDate: effectiveEndDate,
numberOfStops: useStopCount ? numberOfStops : nil,
tripDuration: useStopCount ? nil : tripDurationDays,
leisureLevel: leisureLevel,
mustStopLocations: mustStopLocations,
preferredCities: preferredCities,
routePreference: routePreference,
needsEVCharging: needsEVCharging,
lodgingType: lodgingType,
numberOfDrivers: numberOfDrivers,
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
catchOtherSports: catchOtherSports
)
// Build planning request
let request = PlanningRequest(
preferences: preferences,
availableGames: games,
teams: teams,
stadiums: stadiums
)
// Plan the trip
let result = planningEngine.planItineraries(request: request)
switch result {
case .success(let options):
guard let bestOption = options.first else {
viewState = .error("No valid itinerary found")
return
}
// Convert ItineraryOption to Trip
let trip = convertToTrip(option: bestOption, preferences: preferences)
viewState = .completed(trip)
case .failure(let failure):
viewState = .error(failureMessage(for: failure))
}
} catch {
viewState = .error("Trip planning failed: \(error.localizedDescription)")
}
}
func toggleMustSeeGame(_ gameId: UUID) {
if mustSeeGameIds.contains(gameId) {
mustSeeGameIds.remove(gameId)
} else {
mustSeeGameIds.insert(gameId)
}
}
func switchPlanningMode(_ mode: PlanningMode) {
planningMode = mode
// Clear mode-specific selections when switching
switch mode {
case .dateRange:
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
case .gameFirst:
// Keep games, clear locations
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
case .locations:
// Keep locations, optionally keep selected games
break
}
}
/// Load games for browsing in game-first mode
func loadGamesForBrowsing() async {
isLoadingGames = true
do {
// Ensure initial data is loaded
if dataProvider.teams.isEmpty {
await dataProvider.loadInitialData()
}
// Use cached teams and stadiums from data provider
for team in dataProvider.teams {
teams[team.id] = team
}
for stadium in dataProvider.stadiums {
stadiums[stadium.id] = stadium
}
// Fetch games for next 90 days for browsing
let browseEndDate = Calendar.current.date(byAdding: .day, value: 90, to: Date()) ?? endDate
games = try await dataProvider.fetchGames(
sports: selectedSports,
startDate: Date(),
endDate: browseEndDate
)
// Build rich games for display
availableGames = games.compactMap { game -> RichGame? in
guard let homeTeam = teams[game.homeTeamId],
let awayTeam = teams[game.awayTeamId],
let stadium = stadiums[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}.sorted { $0.game.dateTime < $1.game.dateTime }
} catch {
viewState = .error("Failed to load games: \(error.localizedDescription)")
}
isLoadingGames = false
}
func addMustStopLocation(_ location: LocationInput) {
guard !mustStopLocations.contains(where: { $0.name == location.name }) else { return }
mustStopLocations.append(location)
}
func removeMustStopLocation(_ location: LocationInput) {
mustStopLocations.removeAll { $0.name == location.name }
}
func addPreferredCity(_ city: String) {
guard !city.isEmpty, !preferredCities.contains(city) else { return }
preferredCities.append(city)
}
func removePreferredCity(_ city: String) {
preferredCities.removeAll { $0 == city }
}
func reset() {
viewState = .editing
planningMode = .dateRange
startLocationText = ""
endLocationText = ""
startLocation = nil
endLocation = nil
selectedSports = [.mlb]
startDate = Date()
endDate = Date().addingTimeInterval(86400 * 7)
tripBufferDays = 2
mustSeeGameIds = []
numberOfStops = 5
leisureLevel = .moderate
mustStopLocations = []
preferredCities = []
availableGames = []
isLoadingGames = false
}
// MARK: - Conversion Helpers
private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip {
// Convert ItineraryStops to TripStops
let tripStops = option.stops.enumerated().map { index, stop in
TripStop(
stopNumber: index + 1,
city: stop.city,
state: stop.state,
coordinate: stop.coordinate,
arrivalDate: stop.arrivalDate,
departureDate: stop.departureDate,
games: stop.games,
isRestDay: stop.games.isEmpty
)
}
return Trip(
name: generateTripName(from: tripStops),
preferences: preferences,
stops: tripStops,
travelSegments: option.travelSegments,
totalGames: option.totalGames,
totalDistanceMeters: option.totalDistanceMiles * 1609.34,
totalDrivingSeconds: option.totalDrivingHours * 3600
)
}
private func generateTripName(from stops: [TripStop]) -> String {
let cities = stops.compactMap { $0.city }.prefix(3)
if cities.count <= 1 {
return cities.first ?? "Road Trip"
}
return cities.joined(separator: "")
}
private func failureMessage(for failure: PlanningFailure) -> String {
failure.message
}
}

View File

@@ -0,0 +1,831 @@
//
// TripCreationView.swift
// SportsTime
//
import SwiftUI
struct TripCreationView: View {
@State private var viewModel = TripCreationViewModel()
@State private var showGamePicker = false
@State private var showCityInput = false
@State private var cityInputType: CityInputType = .mustStop
@State private var showLocationBanner = true
@State private var showTripDetail = false
@State private var completedTrip: Trip?
enum CityInputType {
case mustStop
case preferred
}
var body: some View {
NavigationStack {
Form {
// Planning Mode Selector
planningModeSection
// Location Permission Banner (only for locations mode)
if viewModel.planningMode == .locations && showLocationBanner {
Section {
LocationPermissionBanner(isPresented: $showLocationBanner)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
}
// Mode-specific sections
switch viewModel.planningMode {
case .dateRange:
// Sports + Dates
sportsSection
datesSection
case .gameFirst:
// Sports + Game Picker
sportsSection
gameBrowserSection
tripBufferSection
case .locations:
// Locations + Sports + optional games
locationSection
sportsSection
datesSection
gamesSection
}
// Common sections
travelSection
constraintsSection
optionalSection
// Validation message
if let message = viewModel.formValidationMessage {
Section {
Label(message, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
}
.navigationTitle("Plan Your Trip")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Plan") {
Task {
await viewModel.planTrip()
}
}
.disabled(!viewModel.isFormValid)
}
}
.overlay {
if case .planning = viewModel.viewState {
planningOverlay
}
}
.sheet(isPresented: $showGamePicker) {
GamePickerSheet(
games: viewModel.availableGames,
selectedIds: $viewModel.mustSeeGameIds
)
}
.sheet(isPresented: $showCityInput) {
LocationSearchSheet(inputType: cityInputType) { location in
switch cityInputType {
case .mustStop:
viewModel.addMustStopLocation(location)
case .preferred:
viewModel.addPreferredCity(location.name)
}
}
}
.alert("Error", isPresented: Binding(
get: { viewModel.viewState.isError },
set: { if !$0 { viewModel.viewState = .editing } }
)) {
Button("OK") {
viewModel.viewState = .editing
}
} message: {
if case .error(let message) = viewModel.viewState {
Text(message)
}
}
.navigationDestination(isPresented: $showTripDetail) {
if let trip = completedTrip {
TripDetailView(trip: trip, games: buildGamesDictionary())
}
}
.onChange(of: viewModel.viewState) { _, newState in
if case .completed(let trip) = newState {
completedTrip = trip
showTripDetail = true
}
}
.onChange(of: showTripDetail) { _, isShowing in
if !isShowing {
// User navigated back, reset to editing state
viewModel.viewState = .editing
completedTrip = nil
}
}
.task {
await viewModel.loadScheduleData()
}
}
}
// MARK: - Sections
private var planningModeSection: some View {
Section {
Picker("Planning Mode", selection: $viewModel.planningMode) {
ForEach(PlanningMode.allCases) { mode in
Label(mode.displayName, systemImage: mode.iconName)
.tag(mode)
}
}
.pickerStyle(.segmented)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
Text(viewModel.planningMode.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var locationSection: some View {
Section("Locations") {
TextField("Start Location", text: $viewModel.startLocationText)
.textContentType(.addressCity)
TextField("End Location", text: $viewModel.endLocationText)
.textContentType(.addressCity)
}
}
private var gameBrowserSection: some View {
Section("Select Games") {
if viewModel.isLoadingGames {
HStack {
ProgressView()
Text("Loading games...")
.foregroundStyle(.secondary)
}
} else if viewModel.availableGames.isEmpty {
HStack {
ProgressView()
Text("Loading games...")
.foregroundStyle(.secondary)
}
.task {
await viewModel.loadGamesForBrowsing()
}
} else {
Button {
showGamePicker = true
} label: {
HStack {
Image(systemName: "sportscourt")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("Browse Teams & Games")
.foregroundStyle(.primary)
Text("\(viewModel.availableGames.count) games available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
// Show selected games summary
if !viewModel.mustSeeGameIds.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
.fontWeight(.medium)
}
// Show selected games preview
ForEach(viewModel.selectedGames.prefix(3)) { game in
HStack(spacing: 8) {
Image(systemName: game.game.sport.iconName)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
.font(.caption)
Spacer()
Text(game.game.formattedDate)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if viewModel.selectedGames.count > 3 {
Text("+ \(viewModel.selectedGames.count - 3) more")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
private var tripBufferSection: some View {
Section("Trip Duration") {
Stepper("Buffer Days: \(viewModel.tripBufferDays)", value: $viewModel.tripBufferDays, in: 0...7)
if let dateRange = viewModel.gameFirstDateRange {
HStack {
Text("Trip window:")
Spacer()
Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))")
.foregroundStyle(.secondary)
}
}
Text("Days before first game and after last game for travel/rest")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var sportsSection: some View {
Section("Sports") {
ForEach(Sport.supported) { sport in
Toggle(isOn: binding(for: sport)) {
Label(sport.rawValue, systemImage: sport.iconName)
}
}
}
}
private var datesSection: some View {
Section("Dates") {
DatePicker("Start Date", selection: $viewModel.startDate, displayedComponents: .date)
DatePicker("End Date", selection: $viewModel.endDate, displayedComponents: .date)
Text("\(viewModel.tripDurationDays) day trip")
.foregroundStyle(.secondary)
}
}
private var gamesSection: some View {
Section("Must-See Games") {
Button {
showGamePicker = true
} label: {
HStack {
Text("Select Games")
Spacer()
Text("\(viewModel.selectedGamesCount) selected")
.foregroundStyle(.secondary)
}
}
}
}
private var travelSection: some View {
Section("Travel") {
Picker("Travel Mode", selection: $viewModel.travelMode) {
ForEach(TravelMode.allCases) { mode in
Label(mode.displayName, systemImage: mode.iconName)
.tag(mode)
}
}
Picker("Route Preference", selection: $viewModel.routePreference) {
ForEach(RoutePreference.allCases) { pref in
Text(pref.displayName).tag(pref)
}
}
}
}
private var constraintsSection: some View {
Section("Trip Style") {
Toggle("Use Stop Count", isOn: $viewModel.useStopCount)
if viewModel.useStopCount {
Stepper("Number of Stops: \(viewModel.numberOfStops)", value: $viewModel.numberOfStops, in: 1...20)
}
Picker("Pace", selection: $viewModel.leisureLevel) {
ForEach(LeisureLevel.allCases) { level in
VStack(alignment: .leading) {
Text(level.displayName)
}
.tag(level)
}
}
Text(viewModel.leisureLevel.description)
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var optionalSection: some View {
Section("Optional") {
// Must-Stop Locations
DisclosureGroup("Must-Stop Locations (\(viewModel.mustStopLocations.count))") {
ForEach(viewModel.mustStopLocations, id: \.name) { location in
HStack {
VStack(alignment: .leading) {
Text(location.name)
if let address = location.address, !address.isEmpty {
Text(address)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Button(role: .destructive) {
viewModel.removeMustStopLocation(location)
} label: {
Image(systemName: "minus.circle.fill")
}
}
}
Button("Add Location") {
cityInputType = .mustStop
showCityInput = true
}
}
// EV Charging
if viewModel.travelMode == .drive {
Toggle("EV Charging Needed", isOn: $viewModel.needsEVCharging)
}
// Lodging
Picker("Lodging Type", selection: $viewModel.lodgingType) {
ForEach(LodgingType.allCases) { type in
Label(type.displayName, systemImage: type.iconName)
.tag(type)
}
}
// Drivers
if viewModel.travelMode == .drive {
Stepper("Drivers: \(viewModel.numberOfDrivers)", value: $viewModel.numberOfDrivers, in: 1...4)
HStack {
Text("Max Hours/Driver/Day")
Spacer()
Text("\(Int(viewModel.maxDrivingHoursPerDriver))h")
}
Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1)
}
// Other Sports
Toggle("Find Other Sports Along Route", isOn: $viewModel.catchOtherSports)
}
}
private var planningOverlay: some View {
ZStack {
Color.black.opacity(0.4)
.ignoresSafeArea()
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
Text("Planning your trip...")
.font(.headline)
.foregroundStyle(.white)
Text("Finding the best route and games")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
}
.padding(40)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
}
}
// MARK: - Helpers
private func binding(for sport: Sport) -> Binding<Bool> {
Binding(
get: { viewModel.selectedSports.contains(sport) },
set: { isSelected in
if isSelected {
viewModel.selectedSports.insert(sport)
} else {
viewModel.selectedSports.remove(sport)
}
}
)
}
private func buildGamesDictionary() -> [UUID: RichGame] {
Dictionary(uniqueKeysWithValues: viewModel.availableGames.map { ($0.id, $0) })
}
}
// MARK: - View State Extensions
extension TripCreationViewModel.ViewState {
var isError: Bool {
if case .error = self { return true }
return false
}
var isCompleted: Bool {
if case .completed = self { return true }
return false
}
}
// MARK: - Game Picker Sheet (Team-based selection)
struct GamePickerSheet: View {
let games: [RichGame]
@Binding var selectedIds: Set<UUID>
@Environment(\.dismiss) private var dismiss
// Group games by team (both home and away)
private var teamsList: [TeamWithGames] {
var teamsDict: [UUID: TeamWithGames] = [:]
for game in games {
// Add to home team
if var teamData = teamsDict[game.homeTeam.id] {
teamData.games.append(game)
teamsDict[game.homeTeam.id] = teamData
} else {
teamsDict[game.homeTeam.id] = TeamWithGames(
team: game.homeTeam,
sport: game.game.sport,
games: [game]
)
}
// Add to away team
if var teamData = teamsDict[game.awayTeam.id] {
teamData.games.append(game)
teamsDict[game.awayTeam.id] = teamData
} else {
teamsDict[game.awayTeam.id] = TeamWithGames(
team: game.awayTeam,
sport: game.game.sport,
games: [game]
)
}
}
return teamsDict.values
.sorted { $0.team.name < $1.team.name }
}
private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] {
let grouped = Dictionary(grouping: teamsList) { $0.sport }
return Sport.supported
.filter { grouped[$0] != nil }
.map { sport in
(sport, grouped[sport]!.sorted { $0.team.name < $1.team.name })
}
}
private var selectedGamesCount: Int {
selectedIds.count
}
var body: some View {
NavigationStack {
List {
// Selected games summary
if !selectedIds.isEmpty {
Section {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("\(selectedGamesCount) game(s) selected")
.fontWeight(.medium)
Spacer()
}
}
}
// Teams by sport
ForEach(teamsBySport, id: \.sport.id) { sportGroup in
Section(sportGroup.sport.rawValue) {
ForEach(sportGroup.teams) { teamData in
NavigationLink {
TeamGamesView(
teamData: teamData,
selectedIds: $selectedIds
)
} label: {
TeamRow(teamData: teamData, selectedIds: selectedIds)
}
}
}
}
}
.navigationTitle("Select Teams")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if !selectedIds.isEmpty {
Button("Reset") {
selectedIds.removeAll()
}
.foregroundStyle(.red)
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
}
}
}
// MARK: - Team With Games Model
struct TeamWithGames: Identifiable {
let team: Team
let sport: Sport
var games: [RichGame]
var id: UUID { team.id }
var sortedGames: [RichGame] {
games.sorted { $0.game.dateTime < $1.game.dateTime }
}
}
// MARK: - Team Row
struct TeamRow: View {
let teamData: TeamWithGames
let selectedIds: Set<UUID>
private var selectedCount: Int {
teamData.games.filter { selectedIds.contains($0.id) }.count
}
var body: some View {
HStack(spacing: 12) {
// Team color indicator
if let colorHex = teamData.team.primaryColor {
Circle()
.fill(Color(hex: colorHex) ?? .gray)
.frame(width: 12, height: 12)
}
VStack(alignment: .leading, spacing: 2) {
Text("\(teamData.team.city) \(teamData.team.name)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(teamData.games.count) game(s) available")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if selectedCount > 0 {
Text("\(selectedCount)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.blue)
.clipShape(Capsule())
}
}
}
}
// MARK: - Team Games View
struct TeamGamesView: View {
let teamData: TeamWithGames
@Binding var selectedIds: Set<UUID>
var body: some View {
List {
ForEach(teamData.sortedGames) { game in
GameRow(game: game, isSelected: selectedIds.contains(game.id)) {
if selectedIds.contains(game.id) {
selectedIds.remove(game.id)
} else {
selectedIds.insert(game.id)
}
}
}
}
.navigationTitle("\(teamData.team.city) \(teamData.team.name)")
.navigationBarTitleDisplayMode(.inline)
}
}
struct GameRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(game.matchupDescription)
.font(.headline)
Text(game.venueDescription)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? .blue : .gray)
.font(.title2)
}
}
.buttonStyle(.plain)
}
}
struct GameSelectRow: View {
let game: RichGame
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
// Sport icon
Image(systemName: game.game.sport.iconName)
.font(.title3)
.foregroundStyle(isSelected ? .blue : .secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
.font(.subheadline)
.fontWeight(.medium)
Text("\(game.game.formattedDate)\(game.game.gameTime)")
.font(.caption)
.foregroundStyle(.secondary)
Text(game.stadium.city)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isSelected ? .blue : .gray.opacity(0.5))
.font(.title3)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Location Search Sheet
struct LocationSearchSheet: View {
let inputType: TripCreationView.CityInputType
let onAdd: (LocationInput) -> Void
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
@State private var searchResults: [LocationSearchResult] = []
@State private var isSearching = false
@State private var searchTask: Task<Void, Never>?
private let locationService = LocationService.shared
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search field
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search cities, addresses, places...", text: $searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if isSearching {
ProgressView()
.scaleEffect(0.8)
} else if !searchText.isEmpty {
Button {
searchText = ""
searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding()
// Results list
if searchResults.isEmpty && !searchText.isEmpty && !isSearching {
ContentUnavailableView(
"No Results",
systemImage: "mappin.slash",
description: Text("Try a different search term")
)
} else {
List(searchResults) { result in
Button {
onAdd(result.toLocationInput())
dismiss()
} label: {
HStack {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.red)
.font(.title2)
VStack(alignment: .leading) {
Text(result.name)
.foregroundStyle(.primary)
if !result.address.isEmpty {
Text(result.address)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "plus.circle")
.foregroundStyle(.blue)
}
}
.buttonStyle(.plain)
}
.listStyle(.plain)
}
Spacer()
}
.navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
.presentationDetents([.large])
.onChange(of: searchText) { _, newValue in
// Debounce search
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await performSearch(query: newValue)
}
}
}
private func performSearch(query: String) async {
guard !query.isEmpty else {
searchResults = []
return
}
isSearching = true
do {
searchResults = try await locationService.searchLocations(query)
} catch {
searchResults = []
}
isSearching = false
}
}
#Preview {
TripCreationView()
}

View File

@@ -0,0 +1,883 @@
//
// TripDetailView.swift
// SportsTime
//
import SwiftUI
import SwiftData
import MapKit
struct TripDetailView: View {
@Environment(\.modelContext) private var modelContext
let trip: Trip
let games: [UUID: RichGame]
@State private var selectedDay: ItineraryDay?
@State private var showExportSheet = false
@State private var showShareSheet = false
@State private var exportURL: URL?
@State private var shareURL: URL?
@State private var mapCameraPosition: MapCameraPosition = .automatic
@State private var isSaved = false
@State private var showSaveConfirmation = false
@State private var routePolylines: [MKPolyline] = []
@State private var isLoadingRoutes = false
private let exportService = ExportService()
private let dataProvider = AppDataProvider.shared
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Header
tripHeader
// Score Card
if let score = trip.score {
scoreCard(score)
}
// Stats
statsGrid
// Map Preview
mapPreview
// Day-by-Day Itinerary
itinerarySection
}
.padding()
}
.navigationTitle(trip.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button {
Task {
await shareTrip()
}
} label: {
Image(systemName: "square.and.arrow.up")
}
Menu {
Button {
Task {
await exportPDF()
}
} label: {
Label("Export PDF", systemImage: "doc.fill")
}
Button {
saveTrip()
} label: {
Label(isSaved ? "Saved" : "Save Trip", systemImage: isSaved ? "bookmark.fill" : "bookmark")
}
.disabled(isSaved)
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $showExportSheet) {
if let url = exportURL {
ShareSheet(items: [url])
}
}
.sheet(isPresented: $showShareSheet) {
if let url = shareURL {
ShareSheet(items: [url])
} else {
ShareSheet(items: [trip.name, trip.formattedDateRange])
}
}
.alert("Trip Saved", isPresented: $showSaveConfirmation) {
Button("OK", role: .cancel) { }
} message: {
Text("Your trip has been saved and can be accessed from My Trips.")
}
.onAppear {
checkIfSaved()
}
}
// MARK: - Header
private var tripHeader: some View {
VStack(alignment: .leading, spacing: 8) {
Text(trip.formattedDateRange)
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 16) {
ForEach(Array(trip.uniqueSports), id: \.self) { sport in
Label(sport.rawValue, systemImage: sport.iconName)
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Score Card
private func scoreCard(_ score: TripScore) -> some View {
VStack(spacing: 12) {
HStack {
Text("Trip Score")
.font(.headline)
Spacer()
Text(score.scoreGrade)
.font(.largeTitle)
.fontWeight(.bold)
.foregroundStyle(.green)
}
HStack(spacing: 20) {
scoreItem(label: "Games", value: score.gameQualityScore)
scoreItem(label: "Route", value: score.routeEfficiencyScore)
scoreItem(label: "Balance", value: score.leisureBalanceScore)
scoreItem(label: "Prefs", value: score.preferenceAlignmentScore)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
private func scoreItem(label: String, value: Double) -> some View {
VStack(spacing: 4) {
Text(String(format: "%.0f", value))
.font(.headline)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
// MARK: - Stats Grid
private var statsGrid: some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
statCell(value: "\(trip.tripDuration)", label: "Days", icon: "calendar")
statCell(value: "\(trip.stops.count)", label: "Cities", icon: "mappin.circle")
statCell(value: "\(trip.totalGames)", label: "Games", icon: "sportscourt")
statCell(value: trip.formattedTotalDistance, label: "Distance", icon: "road.lanes")
statCell(value: trip.formattedTotalDriving, label: "Driving", icon: "car")
statCell(value: String(format: "%.1fh", trip.averageDrivingHoursPerDay), label: "Avg/Day", icon: "gauge.medium")
}
}
private func statCell(value: String, label: String, icon: String) -> some View {
VStack(spacing: 6) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(.blue)
Text(value)
.font(.headline)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
// MARK: - Map Preview
private var mapPreview: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Route")
.font(.headline)
Spacer()
if isLoadingRoutes {
ProgressView()
.scaleEffect(0.7)
}
}
Map(position: $mapCameraPosition) {
// Add markers for each stop
ForEach(stopCoordinates.indices, id: \.self) { index in
let stop = stopCoordinates[index]
Marker(stop.name, coordinate: stop.coordinate)
.tint(.blue)
}
// Add actual driving route polylines
ForEach(routePolylines.indices, id: \.self) { index in
MapPolyline(routePolylines[index])
.stroke(.blue, lineWidth: 3)
}
}
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
.task {
updateMapRegion()
await fetchDrivingRoutes()
}
}
}
/// Fetch actual driving routes using MKDirections
private func fetchDrivingRoutes() async {
let stops = stopCoordinates
guard stops.count >= 2 else { return }
isLoadingRoutes = true
var polylines: [MKPolyline] = []
for i in 0..<(stops.count - 1) {
let source = stops[i]
let destination = stops[i + 1]
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: source.coordinate))
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination.coordinate))
request.transportType = .automobile
let directions = MKDirections(request: request)
do {
let response = try await directions.calculate()
if let route = response.routes.first {
polylines.append(route.polyline)
}
} catch {
// Fallback to straight line if directions fail
print("Failed to get directions from \(source.name) to \(destination.name): \(error)")
let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2)
polylines.append(straightLine)
}
}
routePolylines = polylines
isLoadingRoutes = false
}
/// Get coordinates for all stops (from stop coordinate or stadium)
private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] {
trip.stops.compactMap { stop -> (String, CLLocationCoordinate2D)? in
// First try to use the stop's stored coordinate
if let coord = stop.coordinate {
return (stop.city, coord)
}
// Fall back to stadium coordinate if available
if let stadiumId = stop.stadium,
let stadium = dataProvider.stadium(for: stadiumId) {
return (stadium.name, stadium.coordinate)
}
return nil
}
}
/// Resolved stadiums from trip stops (for markers)
private var tripStadiums: [Stadium] {
trip.stops.compactMap { stop in
guard let stadiumId = stop.stadium else { return nil }
return dataProvider.stadium(for: stadiumId)
}
}
private func updateMapRegion() {
guard !stopCoordinates.isEmpty else { return }
let coordinates = stopCoordinates.map(\.coordinate)
let lats = coordinates.map(\.latitude)
let lons = coordinates.map(\.longitude)
guard let minLat = lats.min(),
let maxLat = lats.max(),
let minLon = lons.min(),
let maxLon = lons.max() else { return }
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
// Add padding to the span
let latSpan = (maxLat - minLat) * 1.3 + 0.5
let lonSpan = (maxLon - minLon) * 1.3 + 0.5
mapCameraPosition = .region(MKCoordinateRegion(
center: center,
span: MKCoordinateSpan(latitudeDelta: max(latSpan, 1), longitudeDelta: max(lonSpan, 1))
))
}
// MARK: - Itinerary
private var itinerarySection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Route Options")
.font(.headline)
let combinations = computeRouteCombinations()
if combinations.count == 1 {
// Single route - show fully expanded
SingleRouteView(
route: combinations[0],
days: trip.itineraryDays(),
games: games
)
} else {
// Multiple combinations - show each as expandable row
ForEach(Array(combinations.enumerated()), id: \.offset) { index, route in
RouteCombinationRow(
routeNumber: index + 1,
route: route,
days: trip.itineraryDays(),
games: games,
totalRoutes: combinations.count
)
}
}
}
}
/// Computes all possible route combinations across days
private func computeRouteCombinations() -> [[DayChoice]] {
let days = trip.itineraryDays()
let calendar = Calendar.current
// Build options for each day
var dayOptions: [[DayChoice]] = []
for day in days {
let dayStart = calendar.startOfDay(for: day.date)
// Find stops with games on this day
let stopsWithGames = day.stops.filter { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
if stopsWithGames.isEmpty {
// Rest day or travel day - use first stop or create empty
if let firstStop = day.stops.first {
dayOptions.append([DayChoice(dayNumber: day.dayNumber, stop: firstStop, game: nil)])
}
} else {
// Create choices for each stop with games
let choices = stopsWithGames.compactMap { stop -> DayChoice? in
let gamesAtStop = stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
return DayChoice(dayNumber: day.dayNumber, stop: stop, game: gamesAtStop.first)
}
if !choices.isEmpty {
dayOptions.append(choices)
}
}
}
// Compute cartesian product of all day options
return cartesianProduct(dayOptions)
}
/// Computes cartesian product of arrays
private func cartesianProduct(_ arrays: [[DayChoice]]) -> [[DayChoice]] {
guard !arrays.isEmpty else { return [[]] }
var result: [[DayChoice]] = [[]]
for array in arrays {
var newResult: [[DayChoice]] = []
for existing in result {
for element in array {
newResult.append(existing + [element])
}
}
result = newResult
}
return result
}
/// Detects if there are games in different cities on the same day
private func detectConflicts(for day: ItineraryDay) -> DayConflictInfo {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
// Find all stops that have games on this specific day
let stopsWithGamesToday = day.stops.filter { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
// Get unique cities with games today
let citiesWithGames = Set(stopsWithGamesToday.map { $0.city })
if citiesWithGames.count > 1 {
return DayConflictInfo(
hasConflict: true,
conflictingStops: stopsWithGamesToday,
conflictingCities: Array(citiesWithGames)
)
}
return DayConflictInfo(hasConflict: false, conflictingStops: [], conflictingCities: [])
}
// MARK: - Actions
private func exportPDF() async {
do {
let url = try await exportService.exportToPDF(trip: trip, games: games)
exportURL = url
showExportSheet = true
} catch {
print("Failed to export PDF: \(error)")
}
}
private func shareTrip() async {
shareURL = await exportService.shareTrip(trip)
showShareSheet = true
}
private func saveTrip() {
guard let savedTrip = SavedTrip.from(trip, status: .planned) else {
print("Failed to create SavedTrip")
return
}
modelContext.insert(savedTrip)
do {
try modelContext.save()
isSaved = true
showSaveConfirmation = true
} catch {
print("Failed to save trip: \(error)")
}
}
private func checkIfSaved() {
let tripId = trip.id
let descriptor = FetchDescriptor<SavedTrip>(
predicate: #Predicate { $0.id == tripId }
)
if let count = try? modelContext.fetchCount(descriptor), count > 0 {
isSaved = true
}
}
}
// MARK: - Day Conflict Info
struct DayConflictInfo {
let hasConflict: Bool
let conflictingStops: [TripStop]
let conflictingCities: [String]
var warningMessage: String {
guard hasConflict else { return "" }
let otherCities = conflictingCities.joined(separator: ", ")
return "Scheduling conflict: Games in \(otherCities) on the same day"
}
}
// MARK: - Day Choice (Route Option)
/// Represents a choice for a single day in a route
struct DayChoice: Hashable {
let dayNumber: Int
let stop: TripStop
let game: RichGame?
func hash(into hasher: inout Hasher) {
hasher.combine(dayNumber)
hasher.combine(stop.city)
}
static func == (lhs: DayChoice, rhs: DayChoice) -> Bool {
lhs.dayNumber == rhs.dayNumber && lhs.stop.city == rhs.stop.city
}
}
// MARK: - Route Combination Row (Expandable full route)
struct RouteCombinationRow: View {
let routeNumber: Int
let route: [DayChoice]
let days: [ItineraryDay]
let games: [UUID: RichGame]
let totalRoutes: Int
@State private var isExpanded = false
/// Summary string like "CLE @ SD CHC @ ATH ATL @ LAD"
private var routeSummary: String {
route.compactMap { choice -> String? in
guard let game = choice.game else { return nil }
return game.matchupDescription
}.joined(separator: "")
}
/// Cities in the route
private var routeCities: String {
route.map { $0.stop.city }.joined(separator: "")
}
var body: some View {
VStack(spacing: 0) {
// Header (always visible, tappable)
Button {
withAnimation(.easeInOut(duration: 0.25)) {
isExpanded.toggle()
}
} label: {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
// Route number badge
Text("Route \(routeNumber)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue)
.clipShape(Capsule())
// Game sequence summary
Text(routeSummary)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.primary)
.lineLimit(2)
.multilineTextAlignment(.leading)
// Cities
Text(routeCities)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.padding(8)
.background(Color(.tertiarySystemFill))
.clipShape(Circle())
}
.padding()
.background(Color(.secondarySystemBackground))
}
.buttonStyle(.plain)
// Expanded content - full day-by-day itinerary
if isExpanded {
VStack(spacing: 8) {
ForEach(route, id: \.dayNumber) { choice in
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
RouteDayCard(day: day, choice: choice, games: games)
}
}
}
.padding(12)
.background(Color(.secondarySystemBackground))
}
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.blue.opacity(0.2), lineWidth: 1)
)
}
}
// MARK: - Single Route View (Auto-expanded when only one option)
struct SingleRouteView: View {
let route: [DayChoice]
let days: [ItineraryDay]
let games: [UUID: RichGame]
var body: some View {
VStack(spacing: 12) {
ForEach(route, id: \.dayNumber) { choice in
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
RouteDayCard(day: day, choice: choice, games: games)
}
}
}
}
}
// MARK: - Route Day Card (Individual day within a route)
struct RouteDayCard: View {
let day: ItineraryDay
let choice: DayChoice
let games: [UUID: RichGame]
private var gamesOnThisDay: [RichGame] {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
return choice.stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Day header
HStack {
Text("Day \(day.dayNumber)")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.blue)
Text(day.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if gamesOnThisDay.isEmpty {
Text("Rest Day")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.green.opacity(0.2))
.clipShape(Capsule())
}
}
// City
Label(choice.stop.city, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
// Travel
if day.hasTravelSegment {
ForEach(day.travelSegments) { segment in
HStack(spacing: 4) {
Image(systemName: segment.travelMode.iconName)
Text("\(segment.formattedDistance)\(segment.formattedDuration)")
}
.font(.caption)
.foregroundStyle(.orange)
}
}
// Games
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
HStack {
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(.blue)
Text(richGame.matchupDescription)
.font(.subheadline)
Spacer()
Text(richGame.game.gameTime)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
// MARK: - Day Card
struct DayCard: View {
let day: ItineraryDay
let games: [UUID: RichGame]
var specificStop: TripStop? = nil
var conflictInfo: DayConflictInfo? = nil
/// The city to display for this card
var primaryCityForDay: String? {
// If a specific stop is provided (conflict mode), use that stop's city
if let stop = specificStop {
return stop.city
}
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
// Find the stop with a game on this day
let primaryStop = day.stops.first { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
} ?? day.stops.first
return primaryStop?.city
}
/// Games to display on this card
var gamesOnThisDay: [RichGame] {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: day.date)
// If a specific stop is provided (conflict mode), only show that stop's games
if let stop = specificStop {
return stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
// Find the stop where we're actually located on this day
let primaryStop = day.stops.first { stop in
stop.games.compactMap { games[$0] }.contains { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
} ?? day.stops.first
guard let stop = primaryStop else { return [] }
return stop.games.compactMap { games[$0] }.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}
}
/// Whether this card has a scheduling conflict
var hasConflict: Bool {
conflictInfo?.hasConflict ?? false
}
/// Other cities with conflicting games (excluding current city)
var otherConflictingCities: [String] {
guard let info = conflictInfo, let currentCity = primaryCityForDay else { return [] }
return info.conflictingCities.filter { $0 != currentCity }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Conflict warning banner
if hasConflict {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Conflict: Also scheduled in \(otherConflictingCities.joined(separator: ", "))")
.font(.caption)
.fontWeight(.medium)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
// Day header
HStack {
Text("Day \(day.dayNumber)")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.blue)
Text(day.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if day.isRestDay && !hasConflict {
Text("Rest Day")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.green.opacity(0.2))
.clipShape(Capsule())
}
}
// City
if let city = primaryCityForDay {
Label(city, systemImage: "mappin")
.font(.caption)
.foregroundStyle(.secondary)
}
// Travel (only show if not in conflict mode, to avoid duplication)
if day.hasTravelSegment && specificStop == nil {
ForEach(day.travelSegments) { segment in
HStack(spacing: 4) {
Image(systemName: segment.travelMode.iconName)
Text("\(segment.formattedDistance)\(segment.formattedDuration)")
}
.font(.caption)
.foregroundStyle(.orange)
}
}
// Games
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
HStack {
Image(systemName: richGame.game.sport.iconName)
.foregroundStyle(.blue)
Text(richGame.matchupDescription)
.font(.subheadline)
Spacer()
Text(richGame.game.gameTime)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
.padding()
.background(hasConflict ? Color.orange.opacity(0.05) : Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(hasConflict ? Color.orange.opacity(0.3) : Color.clear, lineWidth: 1)
)
}
}
// MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#Preview {
NavigationStack {
TripDetailView(
trip: Trip(
name: "MLB Road Trip",
preferences: TripPreferences(
startLocation: LocationInput(name: "New York"),
endLocation: LocationInput(name: "Chicago")
)
),
games: [:]
)
}
}

10
SportsTime/Info.plist Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,278 @@
//
// GeographicRouteExplorer.swift
// SportsTime
//
// Shared logic for finding geographically sensible route variations.
// Used by all scenario planners to explore and prune route combinations.
//
// Key Features:
// - Tree exploration with pruning for route combinations
// - Geographic sanity check using bounding box diagonal vs actual travel ratio
// - Support for "anchor" games that cannot be removed from routes (Scenario B)
//
// Algorithm Overview:
// Given games [A, B, C, D, E] in chronological order, we build a decision tree
// where at each node we can either include or skip a game. Routes that would
// create excessive zig-zagging are pruned. When anchors are specified, any
// route that doesn't include ALL anchors is automatically discarded.
//
import Foundation
import CoreLocation
enum GeographicRouteExplorer {
// MARK: - Configuration
/// Maximum ratio of actual travel to bounding box diagonal.
/// Routes exceeding this are considered zig-zags.
/// - 1.0x = perfectly linear route
/// - 1.5x = some detours, normal
/// - 2.0x = significant detours, borderline
/// - 2.5x+ = excessive zig-zag, reject
private static let maxZigZagRatio = 2.5
/// Minimum bounding box diagonal (miles) to apply zig-zag check.
/// Routes within a small area are always considered sane.
private static let minDiagonalForCheck = 100.0
/// Maximum number of route options to return.
private static let maxOptions = 10
// MARK: - Public API
/// Finds ALL geographically sensible subsets of games.
///
/// The problem: Games in a date range might be scattered across the country.
/// Visiting all of them in chronological order could mean crazy zig-zags.
///
/// The solution: Explore all possible subsets, keeping those that pass
/// geographic sanity. Return multiple options for the user to choose from.
///
/// Algorithm (tree exploration with pruning):
///
/// Input: [NY, TX, SC, DEN, NM, CA] (chronological order)
///
/// Build a decision tree:
/// [NY]
/// / \
/// +TX / \ skip TX
/// / \
/// [NY,TX] [NY]
/// / \ / \
/// +SC / \ +SC / \
/// | | |
/// (prune) +DEN [NY,SC] ...
///
/// Each path that reaches the end = one valid option
/// Pruning: If adding a game breaks sanity, don't explore that branch
///
/// - Parameters:
/// - games: All games to consider, should be in chronological order
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
/// - anchorGameIds: Game IDs that MUST be included in every route (for Scenario B)
/// - stopBuilder: Closure that converts games to ItineraryStops
///
/// - Returns: Array of valid game combinations, sorted by number of games (most first)
///
static func findAllSensibleRoutes(
from games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID> = [],
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
) -> [[Game]] {
// 0-2 games = always sensible, only one option
// But still verify anchors are present
guard games.count > 2 else {
// Verify all anchors are in the game list
let gameIds = Set(games.map { $0.id })
if anchorGameIds.isSubset(of: gameIds) {
return games.isEmpty ? [] : [games]
} else {
// Missing anchors - no valid routes
return []
}
}
// First, check if all games already form a sensible route
let allStops = stopBuilder(games, stadiums)
if isGeographicallySane(stops: allStops) {
print("[GeographicExplorer] All \(games.count) games form a sensible route")
return [games]
}
print("[GeographicExplorer] Exploring route variations (anchors: \(anchorGameIds.count))...")
// Explore all valid subsets using recursive tree traversal
var validRoutes: [[Game]] = []
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: [],
index: 0,
validRoutes: &validRoutes
)
// Filter routes that don't contain all anchors
let routesWithAnchors = validRoutes.filter { route in
let routeGameIds = Set(route.map { $0.id })
return anchorGameIds.isSubset(of: routeGameIds)
}
// Sort by number of games (most games first = best options)
let sorted = routesWithAnchors.sorted { $0.count > $1.count }
// Limit to top options to avoid overwhelming the user
let topRoutes = Array(sorted.prefix(maxOptions))
print("[GeographicExplorer] Found \(routesWithAnchors.count) valid routes with anchors, returning top \(topRoutes.count)")
return topRoutes
}
// MARK: - Geographic Sanity Check
/// Determines if a route is geographically sensible or zig-zags excessively.
///
/// The goal: Reject routes that oscillate back and forth across large distances.
/// We want routes that make generally linear progress, not cross-country ping-pong.
///
/// Algorithm:
/// 1. Calculate the "bounding box" of all stops (geographic spread)
/// 2. Calculate total travel distance if we visit stops in order
/// 3. Compare actual travel to the bounding box diagonal
/// 4. If actual travel is WAY more than the diagonal, it's zig-zagging
///
/// Example VALID:
/// Stops: LA, SF, Portland, Seattle
/// Bounding box diagonal: ~1,100 miles
/// Actual travel: ~1,200 miles (reasonable, mostly linear)
/// Ratio: 1.1x PASS
///
/// Example INVALID:
/// Stops: NY, TX, SC, CA (zig-zag)
/// Bounding box diagonal: ~2,500 miles
/// Actual travel: ~6,000 miles (back and forth)
/// Ratio: 2.4x FAIL
///
static func isGeographicallySane(stops: [ItineraryStop]) -> Bool {
// Single stop or two stops = always valid (no zig-zag possible)
guard stops.count > 2 else { return true }
// Collect all coordinates
let coordinates = stops.compactMap { $0.coordinate }
guard coordinates.count == stops.count else {
// Missing coordinates - can't validate, assume valid
return true
}
// Calculate bounding box
let lats = coordinates.map { $0.latitude }
let lons = coordinates.map { $0.longitude }
guard let minLat = lats.min(), let maxLat = lats.max(),
let minLon = lons.min(), let maxLon = lons.max() else {
return true
}
// Calculate bounding box diagonal distance
let corner1 = CLLocationCoordinate2D(latitude: minLat, longitude: minLon)
let corner2 = CLLocationCoordinate2D(latitude: maxLat, longitude: maxLon)
let diagonalMiles = TravelEstimator.haversineDistanceMiles(from: corner1, to: corner2)
// Tiny bounding box = all games are close together = always valid
if diagonalMiles < minDiagonalForCheck {
return true
}
// Calculate actual travel distance through all stops in order
var actualTravelMiles: Double = 0
for i in 0..<(stops.count - 1) {
let from = stops[i]
let to = stops[i + 1]
actualTravelMiles += TravelEstimator.calculateDistanceMiles(from: from, to: to)
}
// Compare: is actual travel reasonable compared to the geographic spread?
let ratio = actualTravelMiles / diagonalMiles
if ratio > maxZigZagRatio {
print("[GeographicExplorer] Sanity FAILED: travel=\(Int(actualTravelMiles))mi, diagonal=\(Int(diagonalMiles))mi, ratio=\(String(format: "%.1f", ratio))x")
return false
}
return true
}
// MARK: - Private Helpers
/// Recursive helper to explore all valid route combinations.
///
/// At each game, we have two choices:
/// 1. Include the game (if it doesn't break sanity)
/// 2. Skip the game (only if it's not an anchor)
///
/// We explore BOTH branches when possible, building up all valid combinations.
/// Anchor games MUST be included - we cannot skip them.
///
private static func exploreRoutes(
games: [Game],
stadiums: [UUID: Stadium],
anchorGameIds: Set<UUID>,
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop],
currentRoute: [Game],
index: Int,
validRoutes: inout [[Game]]
) {
// Base case: we've processed all games
if index >= games.count {
// Only save routes with at least 1 game
if !currentRoute.isEmpty {
validRoutes.append(currentRoute)
}
return
}
let game = games[index]
let isAnchor = anchorGameIds.contains(game.id)
// Option 1: Try INCLUDING this game
var routeWithGame = currentRoute
routeWithGame.append(game)
let stopsWithGame = stopBuilder(routeWithGame, stadiums)
if isGeographicallySane(stops: stopsWithGame) {
// This branch is valid, continue exploring
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: routeWithGame,
index: index + 1,
validRoutes: &validRoutes
)
} else if isAnchor {
// Anchor game breaks sanity - this entire branch is invalid
// Don't explore further, don't add to valid routes
// (We can't skip an anchor, and including it breaks sanity)
return
}
// Option 2: Try SKIPPING this game (only if it's not an anchor)
if !isAnchor {
exploreRoutes(
games: games,
stadiums: stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: stopBuilder,
currentRoute: currentRoute,
index: index + 1,
validRoutes: &validRoutes
)
}
}
}

View File

@@ -0,0 +1,134 @@
//
// ItineraryBuilder.swift
// SportsTime
//
// Shared utility for building itineraries with travel segments.
// Used by all scenario planners to convert stops into complete itineraries.
//
import Foundation
/// Result of building an itinerary from stops.
struct BuiltItinerary {
let stops: [ItineraryStop]
let travelSegments: [TravelSegment]
let totalDrivingHours: Double
let totalDistanceMiles: Double
}
/// Shared logic for building itineraries across all scenario planners.
enum ItineraryBuilder {
/// Validation that can be performed on each travel segment.
/// Return `true` if the segment is valid, `false` to reject the itinerary.
typealias SegmentValidator = (TravelSegment, _ fromStop: ItineraryStop, _ toStop: ItineraryStop) -> Bool
/// Builds a complete itinerary with travel segments between consecutive stops.
///
/// Algorithm:
/// 1. Handle edge case: single stop = no travel needed
/// 2. For each consecutive pair of stops, estimate travel
/// 3. Optionally validate each segment with custom validator
/// 4. Accumulate driving hours and distance
/// 5. Verify invariant: travelSegments.count == stops.count - 1
///
/// - Parameters:
/// - stops: The stops to connect with travel segments
/// - constraints: Driving constraints (drivers, max hours per day)
/// - logPrefix: Prefix for log messages (e.g., "[ScenarioA]")
/// - segmentValidator: Optional validation for each segment
///
/// - Returns: Built itinerary if successful, nil if any segment fails
///
static func build(
stops: [ItineraryStop],
constraints: DrivingConstraints,
logPrefix: String = "[ItineraryBuilder]",
segmentValidator: SegmentValidator? = nil
) -> BuiltItinerary? {
//
// Edge case: Single stop or empty = no travel needed
//
if stops.count <= 1 {
return BuiltItinerary(
stops: stops,
travelSegments: [],
totalDrivingHours: 0,
totalDistanceMiles: 0
)
}
//
// Build travel segments between consecutive stops
//
var travelSegments: [TravelSegment] = []
var totalDrivingHours: Double = 0
var totalDistance: Double = 0
for index in 0..<(stops.count - 1) {
let fromStop = stops[index]
let toStop = stops[index + 1]
// Estimate travel for this segment
guard let segment = TravelEstimator.estimate(
from: fromStop,
to: toStop,
constraints: constraints
) else {
print("\(logPrefix) Failed to estimate travel: \(fromStop.city) -> \(toStop.city)")
return nil
}
// Run optional validator (e.g., arrival time check for Scenario B)
if let validator = segmentValidator {
if !validator(segment, fromStop, toStop) {
print("\(logPrefix) Segment validation failed: \(fromStop.city) -> \(toStop.city)")
return nil
}
}
travelSegments.append(segment)
totalDrivingHours += segment.estimatedDrivingHours
totalDistance += segment.estimatedDistanceMiles
}
//
// Verify invariant: segments = stops - 1
//
guard travelSegments.count == stops.count - 1 else {
print("\(logPrefix) Invariant violated: \(travelSegments.count) segments for \(stops.count) stops")
return nil
}
return BuiltItinerary(
stops: stops,
travelSegments: travelSegments,
totalDrivingHours: totalDrivingHours,
totalDistanceMiles: totalDistance
)
}
// MARK: - Common Validators
/// Validator that ensures arrival time is before game start (with buffer).
/// Used by Scenario B where selected games have fixed start times.
///
/// - Parameter bufferSeconds: Time buffer before game start (default 1 hour)
/// - Returns: Validator closure
///
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
return { segment, _, toStop in
guard let gameStart = toStop.firstGameStart else {
return true // No game = no constraint
}
let deadline = gameStart.addingTimeInterval(-bufferSeconds)
if segment.arrivalTime > deadline {
print("[ItineraryBuilder] Cannot arrive in time: arrival \(segment.arrivalTime) > deadline \(deadline)")
return false
}
return true
}
}
}

View File

@@ -0,0 +1,232 @@
//
// RouteCandidateBuilder.swift
// SportsTime
//
import Foundation
import CoreLocation
/// Builds route candidates for different planning scenarios
enum RouteCandidateBuilder {
// MARK: - Scenario A: Linear Candidates (Date Range)
/// Builds linear route candidates from games sorted chronologically
/// - Parameters:
/// - games: Available games sorted by start time
/// - mustStop: Optional must-stop location
/// - Returns: Array of route candidates
static func buildLinearCandidates(
games: [Game],
stadiums: [UUID: Stadium],
mustStop: LocationInput?
) -> [RouteCandidate] {
guard !games.isEmpty else {
return []
}
// Group games by stadium
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
}
// Build stops from chronological game order
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = []
for game in games {
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
let gamesAtStop = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStop.sorted { $0.startTime < $1.startTime }
// Look up stadium for coordinates and city info
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
}
guard !stops.isEmpty else {
return []
}
return [RouteCandidate(
stops: stops,
rationale: "Linear route through \(stops.count) cities"
)]
}
// MARK: - Scenario B: Expand Around Anchors (Selected Games)
/// Expands route around user-selected anchor games
/// - Parameters:
/// - anchors: User-selected games (must-see)
/// - allGames: All available games
/// - dateRange: Trip date range
/// - mustStop: Optional must-stop location
/// - Returns: Array of route candidates
static func expandAroundAnchors(
anchors: [Game],
allGames: [Game],
stadiums: [UUID: Stadium],
dateRange: DateInterval,
mustStop: LocationInput?
) -> [RouteCandidate] {
guard !anchors.isEmpty else {
return []
}
// Start with anchor games as the core route
let sortedAnchors = anchors.sorted { $0.startTime < $1.startTime }
// Build stops from anchor games
var stops: [ItineraryStop] = []
for game in sortedAnchors {
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: [game.id],
arrivalDate: game.gameDate,
departureDate: game.gameDate,
location: location,
firstGameStart: game.startTime
)
stops.append(stop)
}
guard !stops.isEmpty else {
return []
}
return [RouteCandidate(
stops: stops,
rationale: "Route connecting \(anchors.count) selected games"
)]
}
// MARK: - Scenario C: Directional Routes (Start + End)
/// Builds directional routes from start to end location
/// - Parameters:
/// - start: Start location
/// - end: End location
/// - games: Available games
/// - dateRange: Optional trip date range
/// - Returns: Array of route candidates
static func buildDirectionalRoutes(
start: LocationInput,
end: LocationInput,
games: [Game],
stadiums: [UUID: Stadium],
dateRange: DateInterval?
) -> [RouteCandidate] {
// Filter games by date range if provided
let filteredGames: [Game]
if let range = dateRange {
filteredGames = games.filter { range.contains($0.startTime) }
} else {
filteredGames = games
}
guard !filteredGames.isEmpty else {
return []
}
// Sort games chronologically
let sortedGames = filteredGames.sorted { $0.startTime < $1.startTime }
// Build stops: start -> games -> end
var stops: [ItineraryStop] = []
// Start stop (no games)
let startStop = ItineraryStop(
city: start.name,
state: "",
coordinate: start.coordinate,
games: [],
arrivalDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
departureDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
location: start,
firstGameStart: nil
)
stops.append(startStop)
// Game stops
for game in sortedGames {
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: [game.id],
arrivalDate: game.gameDate,
departureDate: game.gameDate,
location: location,
firstGameStart: game.startTime
)
stops.append(stop)
}
// End stop (no games)
let endStop = ItineraryStop(
city: end.name,
state: "",
coordinate: end.coordinate,
games: [],
arrivalDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
departureDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
location: end,
firstGameStart: nil
)
stops.append(endStop)
return [RouteCandidate(
stops: stops,
rationale: "Directional route from \(start.name) to \(end.name)"
)]
}
}

View File

@@ -0,0 +1,283 @@
//
// RouteOptimizer.swift
// SportsTime
//
import Foundation
import CoreLocation
/// Optimization strategy for ranking itinerary options.
enum OptimizationStrategy {
case balanced // Balance games vs driving
case maximizeGames // Prioritize seeing more games
case minimizeDriving // Prioritize shorter routes
case scenic // Prioritize scenic routes
}
/// Route optimizer for ranking and scoring itinerary options.
///
/// The TSP-solving logic has been moved to scenario-specific candidate
/// generation in TripPlanningEngine. This optimizer now focuses on:
/// - Ranking multiple route options
/// - Scoring routes based on optimization strategy
/// - Reordering games within constraints
struct RouteOptimizer {
// MARK: - Route Ranking
/// Ranks a list of itinerary options based on the optimization strategy.
///
/// - Parameters:
/// - options: Unranked itinerary options
/// - strategy: Optimization strategy for scoring
/// - request: Planning request for context
/// - Returns: Options sorted by score (best first) with rank assigned
func rankOptions(
_ options: [ItineraryOption],
strategy: OptimizationStrategy = .balanced,
request: PlanningRequest
) -> [ItineraryOption] {
// Score each option
let scoredOptions = options.map { option -> (ItineraryOption, Double) in
let score = scoreOption(option, strategy: strategy, request: request)
return (option, score)
}
// Sort by score (lower is better for our scoring system)
let sorted = scoredOptions.sorted { $0.1 < $1.1 }
// Assign ranks
return sorted.enumerated().map { index, scored in
ItineraryOption(
rank: index + 1,
stops: scored.0.stops,
travelSegments: scored.0.travelSegments,
totalDrivingHours: scored.0.totalDrivingHours,
totalDistanceMiles: scored.0.totalDistanceMiles,
geographicRationale: scored.0.geographicRationale
)
}
}
// MARK: - Scoring
/// Scores an itinerary option based on the optimization strategy.
/// Lower scores are better.
///
/// - Parameters:
/// - option: Itinerary option to score
/// - strategy: Optimization strategy
/// - request: Planning request for context
/// - Returns: Score value (lower is better)
func scoreOption(
_ option: ItineraryOption,
strategy: OptimizationStrategy,
request: PlanningRequest
) -> Double {
switch strategy {
case .balanced:
return scoreBalanced(option, request: request)
case .maximizeGames:
return scoreMaximizeGames(option, request: request)
case .minimizeDriving:
return scoreMinimizeDriving(option, request: request)
case .scenic:
return scoreScenic(option, request: request)
}
}
/// Balanced scoring: trade-off between games and driving.
private func scoreBalanced(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// Each game "saves" 2 hours of driving in value
let gameValue = Double(option.totalGames) * 2.0
let drivingPenalty = option.totalDrivingHours
// Also factor in must-see games coverage
let mustSeeCoverage = calculateMustSeeCoverage(option, request: request)
let mustSeeBonus = mustSeeCoverage * 10.0 // Strong bonus for must-see coverage
return drivingPenalty - gameValue - mustSeeBonus
}
/// Maximize games scoring: prioritize number of games.
private func scoreMaximizeGames(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// Heavily weight game count
let gameScore = -Double(option.totalGames) * 100.0
let drivingPenalty = option.totalDrivingHours * 0.1 // Minimal driving penalty
return gameScore + drivingPenalty
}
/// Minimize driving scoring: prioritize shorter routes.
private func scoreMinimizeDriving(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// Primarily driving time
let drivingScore = option.totalDrivingHours
let gameBonus = Double(option.totalGames) * 0.5 // Small bonus for games
return drivingScore - gameBonus
}
/// Scenic scoring: balance games with route pleasantness.
private func scoreScenic(_ option: ItineraryOption, request: PlanningRequest) -> Double {
// More relaxed pacing is better
let gamesPerDay = Double(option.totalGames) / Double(max(1, option.stops.count))
let pacingScore = abs(gamesPerDay - 1.5) * 5.0 // Ideal is ~1.5 games per day
let drivingScore = option.totalDrivingHours * 0.3
let gameBonus = Double(option.totalGames) * 2.0
return pacingScore + drivingScore - gameBonus
}
/// Calculates what percentage of must-see games are covered.
private func calculateMustSeeCoverage(
_ option: ItineraryOption,
request: PlanningRequest
) -> Double {
let mustSeeIds = request.preferences.mustSeeGameIds
if mustSeeIds.isEmpty { return 1.0 }
let coveredGames = option.stops.flatMap { $0.games }
let coveredMustSee = Set(coveredGames).intersection(mustSeeIds)
return Double(coveredMustSee.count) / Double(mustSeeIds.count)
}
// MARK: - Route Improvement
/// Attempts to improve a route by swapping non-essential stops.
/// Only applies to stops without must-see games.
///
/// - Parameters:
/// - option: Itinerary option to improve
/// - request: Planning request for context
/// - Returns: Improved itinerary option, or original if no improvement found
func improveRoute(
_ option: ItineraryOption,
request: PlanningRequest
) -> ItineraryOption {
// For now, return as-is since games must remain in chronological order
// Future: implement swap logic for same-day games in different cities
return option
}
// MARK: - Validation Helpers
/// Checks if all must-see games are included in the option.
func includesAllMustSeeGames(
_ option: ItineraryOption,
request: PlanningRequest
) -> Bool {
let includedGames = Set(option.stops.flatMap { $0.games })
return request.preferences.mustSeeGameIds.isSubset(of: includedGames)
}
/// Returns must-see games that are missing from the option.
func missingMustSeeGames(
_ option: ItineraryOption,
request: PlanningRequest
) -> Set<UUID> {
let includedGames = Set(option.stops.flatMap { $0.games })
return request.preferences.mustSeeGameIds.subtracting(includedGames)
}
// MARK: - Distance Calculations
/// Calculates total route distance for a sequence of coordinates.
func totalDistance(for coordinates: [CLLocationCoordinate2D]) -> Double {
guard coordinates.count >= 2 else { return 0 }
var total: Double = 0
for i in 0..<(coordinates.count - 1) {
total += distance(from: coordinates[i], to: coordinates[i + 1])
}
return total
}
/// Calculates distance between two coordinates in meters.
private func distance(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
return fromLoc.distance(from: toLoc)
}
// MARK: - Legacy Support
/// Legacy optimization method for backward compatibility.
/// Delegates to the new TripPlanningEngine for actual routing.
func optimize(
graph: RouteGraph,
request: PlanningRequest,
candidates: [GameCandidate],
strategy: OptimizationStrategy = .balanced
) -> CandidateRoute {
// Build a simple chronological route from candidates
let sortedCandidates = candidates.sorted { $0.game.dateTime < $1.game.dateTime }
var route = CandidateRoute()
// Add start node
if let startNode = graph.nodes.first(where: { $0.type == .start }) {
route.nodeSequence.append(startNode.id)
}
// Add stadium nodes in chronological order
var visitedStadiums = Set<UUID>()
for candidate in sortedCandidates {
// Find the node for this stadium
for node in graph.nodes {
if case .stadium(let stadiumId) = node.type,
stadiumId == candidate.stadium.id,
!visitedStadiums.contains(stadiumId) {
route.nodeSequence.append(node.id)
route.games.append(candidate.game.id)
visitedStadiums.insert(stadiumId)
break
}
}
}
// Add end node
if let endNode = graph.nodes.first(where: { $0.type == .end }) {
route.nodeSequence.append(endNode.id)
}
// Calculate totals
for i in 0..<(route.nodeSequence.count - 1) {
if let edge = graph.edges(from: route.nodeSequence[i])
.first(where: { $0.toNodeId == route.nodeSequence[i + 1] }) {
route.totalDistance += edge.distanceMeters
route.totalDuration += edge.durationSeconds
}
}
// Score the route
route.score = scoreRoute(route, strategy: strategy, graph: graph)
return route
}
/// Legacy route scoring for CandidateRoute.
private func scoreRoute(
_ route: CandidateRoute,
strategy: OptimizationStrategy,
graph: RouteGraph
) -> Double {
switch strategy {
case .balanced:
return route.totalDuration - Double(route.games.count) * 3600 * 2
case .maximizeGames:
return -Double(route.games.count) * 10000 + route.totalDuration
case .minimizeDriving:
return route.totalDuration
case .scenic:
return route.totalDuration * 0.5 - Double(route.games.count) * 3600
}
}
}

View File

@@ -0,0 +1,265 @@
//
// ScenarioAPlanner.swift
// SportsTime
//
// Scenario A: Date range only planning.
// User provides a date range, we find all games and build chronological routes.
//
import Foundation
import CoreLocation
/// Scenario A: Date range planning
///
/// This is the simplest scenario - user just picks a date range and we find games.
///
/// Input:
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
/// - must_stop: Optional. A location they must visit (not yet implemented)
///
/// Output:
/// - Success: Ranked list of itinerary options
/// - Failure: Explicit error with reason (no games, dates invalid, etc.)
///
/// Example:
/// User selects Jan 5-10, 2026
/// We find: Lakers (Jan 5), Warriors (Jan 7), Kings (Jan 9)
/// Output: Single itinerary visiting LA SF Sacramento in order
///
final class ScenarioAPlanner: ScenarioPlanner {
// MARK: - ScenarioPlanner Protocol
/// Main entry point for Scenario A planning.
///
/// Flow:
/// 1. Validate inputs (date range must exist)
/// 2. Find all games within the date range
/// 3. Convert games to stops (grouping by stadium)
/// 4. Calculate travel between stops
/// 5. Return the complete itinerary
///
/// Failure cases:
/// - No date range provided .missingDateRange
/// - No games in date range .noGamesInRange
/// - Can't build valid route .constraintsUnsatisfiable
///
func plan(request: PlanningRequest) -> ItineraryResult {
//
// Step 1: Validate date range exists
//
// Scenario A requires a date range. Without it, we can't filter games.
guard let dateRange = request.dateRange else {
return .failure(
PlanningFailure(
reason: .missingDateRange,
violations: []
)
)
}
//
// Step 2: Filter games within date range
//
// Get all games that fall within the user's travel dates.
// Sort by start time so we visit them in chronological order.
let gamesInRange = request.allGames
.filter { dateRange.contains($0.startTime) }
.sorted { $0.startTime < $1.startTime }
// No games? Nothing to plan.
if gamesInRange.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: []
)
)
}
//
// Step 3: Find ALL geographically sensible route variations
//
// Not all games in the date range may form a sensible route.
// Example: NY (Jan 5), TX (Jan 6), SC (Jan 7), CA (Jan 8)
// - Including all = zig-zag nightmare
// - Option 1: NY, TX, DEN, NM, CA (skip SC)
// - Option 2: NY, SC, DEN, NM, CA (skip TX)
// - etc.
//
// We explore ALL valid combinations and return multiple options.
// Uses shared GeographicRouteExplorer for tree exploration.
//
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
stopBuilder: buildStops
)
if validRoutes.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "No geographically sensible route found for games in this date range",
severity: .error
)
]
)
)
}
//
// Step 4: Build itineraries for each valid route
//
// For each valid game combination, build stops and calculate travel.
// Some routes may fail driving constraints - filter those out.
//
var itineraryOptions: [ItineraryOption] = []
for (index, routeGames) in validRoutes.enumerated() {
// Build stops for this route
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
guard !stops.isEmpty else { continue }
// Calculate travel segments using shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
stops: stops,
constraints: request.drivingConstraints,
logPrefix: "[ScenarioA]"
) else {
// This route fails driving constraints, skip it
print("[ScenarioA] Route \(index + 1) failed driving constraints, skipping")
continue
}
// Create the option
let cities = stops.map { $0.city }.joined(separator: "")
let option = ItineraryOption(
rank: index + 1,
stops: itinerary.stops,
travelSegments: itinerary.travelSegments,
totalDrivingHours: itinerary.totalDrivingHours,
totalDistanceMiles: itinerary.totalDistanceMiles,
geographicRationale: "\(stops.count) games: \(cities)"
)
itineraryOptions.append(option)
}
//
// Step 5: Return ranked results
//
// If no routes passed all constraints, fail.
// Otherwise, return all valid options for the user to choose from.
//
if itineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .constraintsUnsatisfiable,
violations: [
ConstraintViolation(
type: .drivingTime,
description: "No routes satisfy driving constraints",
severity: .error
)
]
)
)
}
// Re-rank by number of games (already sorted, but update rank numbers)
let rankedOptions = itineraryOptions.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("[ScenarioA] Returning \(rankedOptions.count) itinerary options")
return .success(rankedOptions)
}
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
///
/// The goal: Create one stop per stadium, in the order we first encounter each stadium
/// when walking through games chronologically.
///
/// Example:
/// Input games (already sorted by date):
/// 1. Jan 5 - Lakers @ Staples Center (LA)
/// 2. Jan 6 - Clippers @ Staples Center (LA) <- same stadium as #1
/// 3. Jan 8 - Warriors @ Chase Center (SF)
///
/// Output stops:
/// Stop 1: Los Angeles (contains game 1 and 2)
/// Stop 2: San Francisco (contains game 3)
///
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
// Step 1: Group all games by their stadium
// This lets us find ALL games at a stadium when we create that stop
// Result: { stadiumId: [game1, game2, ...], ... }
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
}
// Step 2: Walk through games in chronological order
// When we hit a stadium for the first time, create a stop with ALL games at that stadium
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = [] // Track which stadiums we've already made stops for
for game in games {
// Skip if we already created a stop for this stadium
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
// Get ALL games at this stadium (not just this one)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
// Look up stadium info for location data
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
// Create the stop
// - arrivalDate: when we need to arrive (first game at this stop)
// - departureDate: when we can leave (after last game at this stop)
// - games: IDs of all games we'll attend at this stop
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
}
return stops
}
}

View File

@@ -0,0 +1,348 @@
//
// 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 })
var allItineraryOptions: [ItineraryOption] = []
for dateRange in dateRanges {
// Find all games in this date range
let gamesInRange = request.allGames
.filter { dateRange.contains($0.startTime) }
.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
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: buildStops
)
// 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 by total games (most first), then by driving hours (less first)
let sorted = allItineraryOptions.sorted { a, b in
if a.stops.flatMap({ $0.games }).count != b.stops.flatMap({ $0.games }).count {
return a.stops.flatMap({ $0.games }).count > b.stops.flatMap({ $0.games }).count
}
return a.totalDrivingHours < b.totalDrivingHours
}
// Re-rank and limit
let rankedOptions = sorted.prefix(10).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("[ScenarioB] Returning \(rankedOptions.count) itinerary options")
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
// Window end = start + duration days
let lastWindowStart = firstGameDate
let lastWindowEnd = Calendar.current.date(
byAdding: .day,
value: duration,
to: lastWindowStart
)!
// 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
)!
}
print("[ScenarioB] Generated \(dateRanges.count) sliding windows for \(duration)-day trip")
return dateRanges
}
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
/// Groups games by stadium, creates one stop per unique stadium.
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
// Group games by stadium
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
}
// Create stops in chronological order (first game at each stadium)
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = []
for game in games {
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
}
return stops
}
}

View File

@@ -0,0 +1,582 @@
//
// ScenarioCPlanner.swift
// SportsTime
//
// Scenario C: Start + End location planning.
// User specifies starting city and ending city. We find games along the route.
//
// Key Features:
// - Start/End are cities with stadiums from user's selected sports
// - Directional filtering: stadiums that "generally move toward" the end
// - When only day span provided (no dates): generate date ranges from games at start/end
// - Uses GeographicRouteExplorer for sensible route exploration
// - Returns top 5 options with most games
//
// Date Range Generation (when only day span provided):
// 1. Find all games at start city's stadiums
// 2. Find all games at end city's stadiums
// 3. For each start game + end game combo within day span: create a date range
// 4. Explore routes for each date range
//
// Directional Filtering:
// - Find stadiums that make forward progress from start to end
// - "Forward progress" = distance to end decreases (with tolerance)
// - Filter games to only those at directional stadiums
//
// Example:
// Start: Chicago, End: New York, Day span: 7 days
// Start game: Jan 5 at Chicago
// End game: Jan 10 at New York
// Date range: Jan 5-10
// Directional stadiums: Detroit, Cleveland, Pittsburgh (moving east)
// NOT directional: Minneapolis, St. Louis (moving away from NY)
//
import Foundation
import CoreLocation
/// Scenario C: Directional route planning from start city to end city
/// Input: start_location, end_location, day_span (or date_range)
/// Output: Top 5 itinerary options with games along the directional route
final class ScenarioCPlanner: ScenarioPlanner {
// MARK: - Configuration
/// Maximum number of itinerary options to return
private let maxOptions = 5
/// Tolerance for "forward progress" - allow small increases in distance to end
/// A stadium is "directional" if it doesn't increase distance to end by more than this ratio
private let forwardProgressTolerance = 0.15 // 15% tolerance
// MARK: - ScenarioPlanner Protocol
func plan(request: PlanningRequest) -> ItineraryResult {
//
// Step 1: Validate start and end locations
//
guard let startLocation = request.startLocation else {
return .failure(
PlanningFailure(
reason: .missingLocations,
violations: [
ConstraintViolation(
type: .general,
description: "Start location is required for Scenario C",
severity: .error
)
]
)
)
}
guard let endLocation = request.endLocation else {
return .failure(
PlanningFailure(
reason: .missingLocations,
violations: [
ConstraintViolation(
type: .general,
description: "End location is required for Scenario C",
severity: .error
)
]
)
)
}
guard let startCoord = startLocation.coordinate,
let endCoord = endLocation.coordinate else {
return .failure(
PlanningFailure(
reason: .missingLocations,
violations: [
ConstraintViolation(
type: .general,
description: "Start and end locations must have coordinates",
severity: .error
)
]
)
)
}
//
// Step 2: Find stadiums at start and end cities
//
let startStadiums = findStadiumsInCity(
cityName: startLocation.name,
stadiums: request.stadiums
)
let endStadiums = findStadiumsInCity(
cityName: endLocation.name,
stadiums: request.stadiums
)
if startStadiums.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .general,
description: "No stadiums found in start city: \(startLocation.name)",
severity: .error
)
]
)
)
}
if endStadiums.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .general,
description: "No stadiums found in end city: \(endLocation.name)",
severity: .error
)
]
)
)
}
//
// Step 3: Generate date ranges
//
let dateRanges = generateDateRanges(
startStadiumIds: Set(startStadiums.map { $0.id }),
endStadiumIds: Set(endStadiums.map { $0.id }),
allGames: request.allGames,
request: request
)
if dateRanges.isEmpty {
return .failure(
PlanningFailure(
reason: .missingDateRange,
violations: [
ConstraintViolation(
type: .dateRange,
description: "No valid date ranges found with games at both start and end cities",
severity: .error
)
]
)
)
}
//
// Step 4: Find directional stadiums (moving from start toward end)
//
let directionalStadiums = findDirectionalStadiums(
from: startCoord,
to: endCoord,
stadiums: request.stadiums
)
print("[ScenarioC] Found \(directionalStadiums.count) directional stadiums from \(startLocation.name) to \(endLocation.name)")
//
// Step 5: For each date range, explore routes
//
var allItineraryOptions: [ItineraryOption] = []
for dateRange in dateRanges {
// Find games at directional stadiums within date range
let gamesInRange = request.allGames
.filter { dateRange.contains($0.startTime) }
.filter { directionalStadiums.contains($0.stadiumId) }
.sorted { $0.startTime < $1.startTime }
guard !gamesInRange.isEmpty else { continue }
// Use GeographicRouteExplorer to find sensible routes
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: [], // No anchors in Scenario C
stopBuilder: buildStops
)
// Build itineraries for each valid route
for routeGames in validRoutes {
let stops = buildStopsWithEndpoints(
start: startLocation,
end: endLocation,
games: routeGames,
stadiums: request.stadiums
)
guard !stops.isEmpty else { continue }
// Validate monotonic progress toward end
guard validateMonotonicProgress(
stops: stops,
toward: endCoord
) else {
continue
}
// Use shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
stops: stops,
constraints: request.drivingConstraints,
logPrefix: "[ScenarioC]"
) else {
continue
}
let gameCount = routeGames.count
let cities = stops.compactMap { $0.games.isEmpty ? nil : $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: "\(startLocation.name)\(gameCount) games → \(endLocation.name): \(cities)"
)
allItineraryOptions.append(option)
}
}
//
// Step 6: Return top 5 ranked results
//
if allItineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "No valid directional routes found from \(startLocation.name) to \(endLocation.name)",
severity: .error
)
]
)
)
}
// Sort by game count (most first), then by driving hours (less first)
let sorted = allItineraryOptions.sorted { a, b in
let aGames = a.stops.flatMap { $0.games }.count
let bGames = b.stops.flatMap { $0.games }.count
if aGames != bGames {
return aGames > bGames
}
return a.totalDrivingHours < b.totalDrivingHours
}
// Take top N and re-rank
let rankedOptions = sorted.prefix(maxOptions).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("[ScenarioC] Returning \(rankedOptions.count) itinerary options")
return .success(Array(rankedOptions))
}
// MARK: - Stadium Finding
/// Finds all stadiums in a given city (case-insensitive match).
private func findStadiumsInCity(
cityName: String,
stadiums: [UUID: Stadium]
) -> [Stadium] {
let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces)
return stadiums.values.filter { stadium in
stadium.city.lowercased().trimmingCharacters(in: .whitespaces) == normalizedCity
}
}
/// Finds stadiums that make forward progress from start to end.
///
/// A stadium is "directional" if visiting it doesn't significantly increase
/// the distance to the end point. This filters out stadiums that would
/// require backtracking.
///
/// Algorithm:
/// 1. Calculate distance from start to end
/// 2. For each stadium, calculate: distance(start, stadium) + distance(stadium, end)
/// 3. If this "detour distance" is reasonable (within tolerance), include it
///
/// The tolerance allows for stadiums slightly off the direct path.
///
private func findDirectionalStadiums(
from start: CLLocationCoordinate2D,
to end: CLLocationCoordinate2D,
stadiums: [UUID: Stadium]
) -> Set<UUID> {
let directDistance = distanceBetween(start, end)
// Allow detours up to 50% longer than direct distance
let maxDetourDistance = directDistance * 1.5
var directionalIds: Set<UUID> = []
for (id, stadium) in stadiums {
let stadiumCoord = stadium.coordinate
// Calculate the detour: start stadium end
let toStadium = distanceBetween(start, stadiumCoord)
let fromStadium = distanceBetween(stadiumCoord, end)
let detourDistance = toStadium + fromStadium
// Also check that stadium is making progress (closer to end than start is)
let distanceFromStart = distanceBetween(start, stadiumCoord)
let distanceToEnd = distanceBetween(stadiumCoord, end)
// Stadium should be within the "cone" from start to end
// Either closer to end than start, or the detour is acceptable
if detourDistance <= maxDetourDistance {
// Additional check: don't include if it's behind the start point
// (i.e., distance to end is greater than original distance)
if distanceToEnd <= directDistance * (1 + forwardProgressTolerance) {
directionalIds.insert(id)
}
}
}
return directionalIds
}
// MARK: - Date Range Generation
/// Generates date ranges for Scenario C.
///
/// Two modes:
/// 1. Explicit date range provided: Use it directly
/// 2. Only day span provided: Find game combinations at start/end cities
///
/// For mode 2:
/// - Find all games at start city stadiums
/// - Find all games at end city stadiums
/// - For each (start_game, end_game) pair where end_game - start_game <= day_span:
/// Create a date range from start_game.date to end_game.date
///
private func generateDateRanges(
startStadiumIds: Set<UUID>,
endStadiumIds: Set<UUID>,
allGames: [Game],
request: PlanningRequest
) -> [DateInterval] {
// If explicit date range exists, use it
if let dateRange = request.dateRange {
return [dateRange]
}
// Otherwise, use day span to find valid combinations
let daySpan = request.preferences.effectiveTripDuration
guard daySpan > 0 else { return [] }
// Find games at start and end cities
let startGames = allGames
.filter { startStadiumIds.contains($0.stadiumId) }
.sorted { $0.startTime < $1.startTime }
let endGames = allGames
.filter { endStadiumIds.contains($0.stadiumId) }
.sorted { $0.startTime < $1.startTime }
if startGames.isEmpty || endGames.isEmpty {
return []
}
// Generate all valid (start_game, end_game) combinations
var dateRanges: [DateInterval] = []
let calendar = Calendar.current
for startGame in startGames {
let startDate = calendar.startOfDay(for: startGame.startTime)
for endGame in endGames {
let endDate = calendar.startOfDay(for: endGame.startTime)
// End must be after start
guard endDate >= startDate else { continue }
// Calculate days between
let daysBetween = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
// Must be within day span
guard daysBetween < daySpan else { continue }
// Create date range (end date + 1 day to include the end game)
let rangeEnd = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate
let range = DateInterval(start: startDate, end: rangeEnd)
// Avoid duplicate ranges
if !dateRanges.contains(where: { $0.start == range.start && $0.end == range.end }) {
dateRanges.append(range)
}
}
}
print("[ScenarioC] Generated \(dateRanges.count) date ranges for \(daySpan)-day trip")
return dateRanges
}
// MARK: - Stop Building
/// Converts games to stops (used by GeographicRouteExplorer callback).
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
}
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = []
for game in games {
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
}
return stops
}
/// Builds stops with start and end location endpoints.
private func buildStopsWithEndpoints(
start: LocationInput,
end: LocationInput,
games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
var stops: [ItineraryStop] = []
// Start stop (no games)
let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date()
let startStop = ItineraryStop(
city: start.name,
state: "",
coordinate: start.coordinate,
games: [],
arrivalDate: startArrival,
departureDate: startArrival,
location: start,
firstGameStart: nil
)
stops.append(startStop)
// Game stops
let gameStops = buildStops(from: games, stadiums: stadiums)
stops.append(contentsOf: gameStops)
// End stop (no games)
let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date()
let endStop = ItineraryStop(
city: end.name,
state: "",
coordinate: end.coordinate,
games: [],
arrivalDate: endArrival,
departureDate: endArrival,
location: end,
firstGameStart: nil
)
stops.append(endStop)
return stops
}
// MARK: - Monotonic Progress Validation
/// Validates that the route makes generally forward progress toward the end.
///
/// Each stop should be closer to (or not significantly farther from) the end
/// than the previous stop. Small detours are allowed within tolerance.
///
private func validateMonotonicProgress(
stops: [ItineraryStop],
toward end: CLLocationCoordinate2D
) -> Bool {
var previousDistance: Double?
for stop in stops {
guard let stopCoord = stop.coordinate else { continue }
let currentDistance = distanceBetween(stopCoord, end)
if let prev = previousDistance {
// Allow increases up to tolerance percentage
let allowedIncrease = prev * forwardProgressTolerance
if currentDistance > prev + allowedIncrease {
print("[ScenarioC] Backtracking: \(stop.city) increases distance to end (\(Int(currentDistance))mi vs \(Int(prev))mi)")
return false
}
}
previousDistance = currentDistance
}
return true
}
// MARK: - Geometry Helpers
/// Distance between two coordinates in miles using Haversine formula.
private func distanceBetween(
_ coord1: CLLocationCoordinate2D,
_ coord2: CLLocationCoordinate2D
) -> Double {
let earthRadiusMiles = 3958.8
let lat1 = coord1.latitude * .pi / 180
let lat2 = coord2.latitude * .pi / 180
let deltaLat = (coord2.latitude - coord1.latitude) * .pi / 180
let deltaLon = (coord2.longitude - coord1.longitude) * .pi / 180
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
cos(lat1) * cos(lat2) *
sin(deltaLon / 2) * sin(deltaLon / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadiusMiles * c
}
}

View File

@@ -0,0 +1,49 @@
//
// ScenarioPlanner.swift
// SportsTime
//
// Protocol for scenario-based trip planning.
//
import Foundation
/// Protocol that all scenario planners must implement.
/// Each scenario (A, B, C) has its own isolated implementation.
protocol ScenarioPlanner {
/// Plan itineraries for this scenario.
/// - Parameter request: The planning request with all inputs
/// - Returns: Success with ranked itineraries, or explicit failure
func plan(request: PlanningRequest) -> ItineraryResult
}
/// Factory for creating the appropriate scenario planner
enum ScenarioPlannerFactory {
/// Creates the appropriate planner based on the request inputs
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
// Scenario B: User selected specific games
if !request.selectedGames.isEmpty {
return ScenarioBPlanner()
}
// Scenario C: User specified start and end locations
if request.startLocation != nil && request.endLocation != nil {
return ScenarioCPlanner()
}
// Scenario A: Date range only (default)
return ScenarioAPlanner()
}
/// Classifies which scenario applies to this request
static func classify(_ request: PlanningRequest) -> PlanningScenario {
if !request.selectedGames.isEmpty {
return .scenarioB
}
if request.startLocation != nil && request.endLocation != nil {
return .scenarioC
}
return .scenarioA
}
}

View 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
}
}

View File

@@ -0,0 +1,180 @@
//
// TravelEstimator.swift
// SportsTime
//
// Shared travel estimation logic used by all scenario planners.
// Estimating travel from A to B is the same regardless of planning scenario.
//
import Foundation
import CoreLocation
enum TravelEstimator {
// MARK: - Constants
private static let averageSpeedMph: Double = 60.0
private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance
private static let fallbackDistanceMiles: Double = 300.0
// MARK: - Travel Estimation
/// Estimates a travel segment between two stops.
/// Returns nil only if the segment exceeds maximum driving time.
static func estimate(
from: ItineraryStop,
to: ItineraryStop,
constraints: DrivingConstraints
) -> TravelSegment? {
let distanceMiles = calculateDistanceMiles(from: from, to: to)
let drivingHours = distanceMiles / averageSpeedMph
// Reject if segment requires more than 2 days of driving
let maxDailyHours = constraints.maxDailyDrivingHours
if drivingHours > maxDailyHours * 2 {
return nil
}
// Calculate times (assume 8 AM departure)
let departureTime = from.departureDate.addingTimeInterval(8 * 3600)
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
return TravelSegment(
fromLocation: from.location,
toLocation: to.location,
travelMode: .drive,
distanceMeters: distanceMiles * 1609.34,
durationSeconds: drivingHours * 3600,
departureTime: departureTime,
arrivalTime: arrivalTime
)
}
/// Estimates a travel segment between two LocationInputs.
/// Returns nil if coordinates are missing or segment exceeds max driving time.
static func estimate(
from: LocationInput,
to: LocationInput,
constraints: DrivingConstraints
) -> TravelSegment? {
guard let fromCoord = from.coordinate,
let toCoord = to.coordinate else {
return nil
}
let distanceMeters = haversineDistanceMeters(from: fromCoord, to: toCoord)
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
let drivingHours = distanceMiles / averageSpeedMph
// Reject if > 2 days of driving
if drivingHours > constraints.maxDailyDrivingHours * 2 {
return nil
}
let departureTime = Date()
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
return TravelSegment(
fromLocation: from,
toLocation: to,
travelMode: .drive,
distanceMeters: distanceMeters * roadRoutingFactor,
durationSeconds: drivingHours * 3600,
departureTime: departureTime,
arrivalTime: arrivalTime
)
}
// MARK: - Distance Calculations
/// Calculates distance in miles between two stops.
/// Uses Haversine formula if coordinates available, fallback otherwise.
static func calculateDistanceMiles(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if let fromCoord = from.coordinate,
let toCoord = to.coordinate {
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
}
return estimateFallbackDistance(from: from, to: to)
}
/// Calculates distance in miles between two coordinates using Haversine.
static func haversineDistanceMiles(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let earthRadiusMiles = 3958.8
let lat1 = from.latitude * .pi / 180
let lat2 = to.latitude * .pi / 180
let deltaLat = (to.latitude - from.latitude) * .pi / 180
let deltaLon = (to.longitude - from.longitude) * .pi / 180
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
cos(lat1) * cos(lat2) *
sin(deltaLon / 2) * sin(deltaLon / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadiusMiles * c
}
/// Calculates distance in meters between two coordinates using Haversine.
static func haversineDistanceMeters(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let earthRadiusMeters = 6371000.0
let lat1 = from.latitude * .pi / 180
let lat2 = to.latitude * .pi / 180
let deltaLat = (to.latitude - from.latitude) * .pi / 180
let deltaLon = (to.longitude - from.longitude) * .pi / 180
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
cos(lat1) * cos(lat2) *
sin(deltaLon / 2) * sin(deltaLon / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return earthRadiusMeters * c
}
/// Fallback distance when coordinates aren't available.
static func estimateFallbackDistance(
from: ItineraryStop,
to: ItineraryStop
) -> Double {
if from.city == to.city {
return 0
}
return fallbackDistanceMiles
}
// MARK: - Travel Days
/// Calculates which calendar days travel spans.
static func calculateTravelDays(
departure: Date,
drivingHours: Double
) -> [Date] {
var days: [Date] = []
let calendar = Calendar.current
let startDay = calendar.startOfDay(for: departure)
days.append(startDay)
// Add days if driving takes multiple days (8 hrs/day max)
let daysOfDriving = Int(ceil(drivingHours / 8.0))
for dayOffset in 1..<daysOfDriving {
if let nextDay = calendar.date(byAdding: .day, value: dayOffset, to: startDay) {
days.append(nextDay)
}
}
return days
}
}

View File

@@ -0,0 +1,41 @@
//
// TripPlanningEngine.swift
// SportsTime
//
// Thin orchestrator that delegates to scenario-specific planners.
//
import Foundation
/// Main entry point for trip planning.
/// Delegates to scenario-specific planners via the ScenarioPlanner protocol.
final class TripPlanningEngine {
/// Plans itineraries based on the request inputs.
/// Automatically detects which scenario applies and delegates to the appropriate planner.
///
/// - Parameter request: The planning request containing all inputs
/// - Returns: Ranked itineraries on success, or explicit failure with reason
func planItineraries(request: PlanningRequest) -> ItineraryResult {
// Detect scenario and get the appropriate planner
let scenario = ScenarioPlannerFactory.classify(request)
let planner = ScenarioPlannerFactory.planner(for: request)
print("[TripPlanningEngine] Detected scenario: \(scenario)")
print("[TripPlanningEngine] Using planner: \(type(of: planner))")
// Delegate to the scenario planner
let result = planner.plan(request: request)
// Log result
switch result {
case .success(let options):
print("[TripPlanningEngine] Success: \(options.count) itinerary options")
case .failure(let failure):
print("[TripPlanningEngine] Failure: \(failure.reason)")
}
return result
}
}

View File

@@ -0,0 +1,99 @@
//
// LegacyPlanningTypes.swift
// SportsTime
//
// Supporting types for legacy planning components.
// These are used by ScheduleMatcher and RouteOptimizer.
//
import Foundation
import CoreLocation
// MARK: - Game Candidate
/// A game candidate with scoring information for route planning.
struct GameCandidate: Identifiable {
let id: UUID
let game: Game
let stadium: Stadium
let homeTeam: Team
let awayTeam: Team
let detourDistance: Double
let score: Double
}
// MARK: - Route Graph
/// Graph representation of possible routes for optimization.
struct RouteGraph {
var nodes: [RouteNode]
var edgesByFromNode: [UUID: [RouteEdge]]
init(nodes: [RouteNode] = [], edges: [RouteEdge] = []) {
self.nodes = nodes
self.edgesByFromNode = [:]
for edge in edges {
edgesByFromNode[edge.fromNodeId, default: []].append(edge)
}
}
func edges(from nodeId: UUID) -> [RouteEdge] {
edgesByFromNode[nodeId] ?? []
}
}
// MARK: - Route Node
struct RouteNode: Identifiable {
let id: UUID
let type: RouteNodeType
let coordinate: CLLocationCoordinate2D?
init(id: UUID = UUID(), type: RouteNodeType, coordinate: CLLocationCoordinate2D? = nil) {
self.id = id
self.type = type
self.coordinate = coordinate
}
}
enum RouteNodeType: Equatable {
case start
case end
case stadium(UUID)
case waypoint
}
// MARK: - Route Edge
struct RouteEdge: Identifiable {
let id: UUID
let fromNodeId: UUID
let toNodeId: UUID
let distanceMeters: Double
let durationSeconds: Double
init(
id: UUID = UUID(),
fromNodeId: UUID,
toNodeId: UUID,
distanceMeters: Double,
durationSeconds: Double
) {
self.id = id
self.fromNodeId = fromNodeId
self.toNodeId = toNodeId
self.distanceMeters = distanceMeters
self.durationSeconds = durationSeconds
}
}
// MARK: - Candidate Route
/// A candidate route for optimization.
struct CandidateRoute {
var nodeSequence: [UUID] = []
var games: [UUID] = []
var totalDistance: Double = 0
var totalDuration: Double = 0
var score: Double = 0
}

View File

@@ -0,0 +1,281 @@
//
// PlanningModels.swift
// SportsTime
//
// Clean model types for trip planning.
//
import Foundation
import CoreLocation
// MARK: - Planning Scenario
/// Exactly one scenario per request. No blending.
enum PlanningScenario: Equatable {
case scenarioA // Date range only
case scenarioB // Selected games + date range
case scenarioC // Start + end locations
}
// MARK: - Planning Failure
/// Explicit failure with reason. No silent failures.
struct PlanningFailure: Error {
let reason: FailureReason
let violations: [ConstraintViolation]
enum FailureReason: Equatable {
case noGamesInRange
case noValidRoutes
case missingDateRange
case missingLocations
case dateRangeViolation(games: [Game])
case drivingExceedsLimit
case cannotArriveInTime
case travelSegmentMissing
case constraintsUnsatisfiable
case geographicBacktracking
static func == (lhs: FailureReason, rhs: FailureReason) -> Bool {
switch (lhs, rhs) {
case (.noGamesInRange, .noGamesInRange),
(.noValidRoutes, .noValidRoutes),
(.missingDateRange, .missingDateRange),
(.missingLocations, .missingLocations),
(.drivingExceedsLimit, .drivingExceedsLimit),
(.cannotArriveInTime, .cannotArriveInTime),
(.travelSegmentMissing, .travelSegmentMissing),
(.constraintsUnsatisfiable, .constraintsUnsatisfiable),
(.geographicBacktracking, .geographicBacktracking):
return true
case (.dateRangeViolation(let g1), .dateRangeViolation(let g2)):
return g1.map { $0.id } == g2.map { $0.id }
default:
return false
}
}
}
init(reason: FailureReason, violations: [ConstraintViolation] = []) {
self.reason = reason
self.violations = violations
}
var message: String {
switch reason {
case .noGamesInRange: return "No games found within the date range"
case .noValidRoutes: return "No valid routes could be constructed"
case .missingDateRange: return "Date range is required"
case .missingLocations: return "Start and end locations are required"
case .dateRangeViolation(let games):
return "\(games.count) selected game(s) fall outside the date range"
case .drivingExceedsLimit: return "Driving time exceeds daily limit"
case .cannotArriveInTime: return "Cannot arrive before game starts"
case .travelSegmentMissing: return "Travel segment could not be created"
case .constraintsUnsatisfiable: return "Cannot satisfy all trip constraints"
case .geographicBacktracking: return "Route requires excessive backtracking"
}
}
}
// MARK: - Constraint Violation
struct ConstraintViolation: Equatable {
let type: ConstraintType
let description: String
let severity: ViolationSeverity
init(constraint: String, detail: String) {
self.type = .general
self.description = "\(constraint): \(detail)"
self.severity = .error
}
init(type: ConstraintType, description: String, severity: ViolationSeverity) {
self.type = type
self.description = description
self.severity = severity
}
}
enum ConstraintType: String, Equatable {
case dateRange
case drivingTime
case geographicSanity
case mustStop
case selectedGames
case gameReachability
case general
}
enum ViolationSeverity: Equatable {
case warning
case error
}
// MARK: - Must Stop Config
struct MustStopConfig {
static let defaultProximityMiles: Double = 25
let proximityMiles: Double
init(proximityMiles: Double = MustStopConfig.defaultProximityMiles) {
self.proximityMiles = proximityMiles
}
}
// MARK: - Itinerary Result
/// Either success with ranked options, or explicit failure.
enum ItineraryResult {
case success([ItineraryOption])
case failure(PlanningFailure)
var isSuccess: Bool {
if case .success = self { return true }
return false
}
var options: [ItineraryOption] {
if case .success(let opts) = self { return opts }
return []
}
var failure: PlanningFailure? {
if case .failure(let f) = self { return f }
return nil
}
}
// MARK: - Route Candidate
/// Intermediate structure during planning.
struct RouteCandidate {
let stops: [ItineraryStop]
let rationale: String
}
// MARK: - Itinerary Option
/// A valid, ranked itinerary option.
struct ItineraryOption: Identifiable {
let id = UUID()
let rank: Int
let stops: [ItineraryStop]
let travelSegments: [TravelSegment]
let totalDrivingHours: Double
let totalDistanceMiles: Double
let geographicRationale: String
/// INVARIANT: travelSegments.count == stops.count - 1 (or 0 if single stop)
var isValid: Bool {
if stops.count <= 1 { return travelSegments.isEmpty }
return travelSegments.count == stops.count - 1
}
var totalGames: Int {
stops.reduce(0) { $0 + $1.games.count }
}
}
// MARK: - Itinerary Stop
/// A stop in the itinerary.
struct ItineraryStop: Identifiable, Hashable {
let id = UUID()
let city: String
let state: String
let coordinate: CLLocationCoordinate2D?
let games: [UUID]
let arrivalDate: Date
let departureDate: Date
let location: LocationInput
let firstGameStart: Date?
var hasGames: Bool { !games.isEmpty }
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: ItineraryStop, rhs: ItineraryStop) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Driving Constraints
/// Driving feasibility constraints.
struct DrivingConstraints {
let numberOfDrivers: Int
let maxHoursPerDriverPerDay: Double
static let `default` = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
var maxDailyDrivingHours: Double {
Double(numberOfDrivers) * maxHoursPerDriverPerDay
}
init(numberOfDrivers: Int = 1, maxHoursPerDriverPerDay: Double = 8.0) {
self.numberOfDrivers = max(1, numberOfDrivers)
self.maxHoursPerDriverPerDay = max(1.0, maxHoursPerDriverPerDay)
}
init(from preferences: TripPreferences) {
self.numberOfDrivers = max(1, preferences.numberOfDrivers)
self.maxHoursPerDriverPerDay = preferences.maxDrivingHoursPerDriver ?? 8.0
}
}
// MARK: - Planning Request
/// Input to the planning engine.
struct PlanningRequest {
let preferences: TripPreferences
let availableGames: [Game]
let teams: [UUID: Team]
let stadiums: [UUID: Stadium]
// MARK: - Computed Properties for Engine
/// Games the user explicitly selected (anchors for Scenario B)
var selectedGames: [Game] {
availableGames.filter { preferences.mustSeeGameIds.contains($0.id) }
}
/// All available games
var allGames: [Game] {
availableGames
}
/// Start location (for Scenario C)
var startLocation: LocationInput? {
preferences.startLocation
}
/// End location (for Scenario C)
var endLocation: LocationInput? {
preferences.endLocation
}
/// Date range as DateInterval
var dateRange: DateInterval? {
guard preferences.endDate > preferences.startDate else { return nil }
return DateInterval(start: preferences.startDate, end: preferences.endDate)
}
/// First must-stop location (if any)
var mustStopLocation: LocationInput? {
preferences.mustStopLocations.first
}
/// Driving constraints
var drivingConstraints: DrivingConstraints {
DrivingConstraints(from: preferences)
}
/// Get stadium for a game
func stadium(for game: Game) -> Stadium? {
stadiums[game.stadiumId]
}
}

View File

@@ -0,0 +1,196 @@
//
// TripScorer.swift
// SportsTime
//
import Foundation
struct TripScorer {
// MARK: - Score Trip
func score(trip: Trip, request: PlanningRequest) -> Trip {
let gameQuality = calculateGameQualityScore(trip: trip, request: request)
let routeEfficiency = calculateRouteEfficiencyScore(trip: trip, request: request)
let leisureBalance = calculateLeisureBalanceScore(trip: trip, request: request)
let preferenceAlignment = calculatePreferenceAlignmentScore(trip: trip, request: request)
// Weighted average
let weights = (game: 0.35, route: 0.25, leisure: 0.20, preference: 0.20)
let overall = (
gameQuality * weights.game +
routeEfficiency * weights.route +
leisureBalance * weights.leisure +
preferenceAlignment * weights.preference
)
let score = TripScore(
overallScore: overall,
gameQualityScore: gameQuality,
routeEfficiencyScore: routeEfficiency,
leisureBalanceScore: leisureBalance,
preferenceAlignmentScore: preferenceAlignment
)
var scoredTrip = trip
scoredTrip.score = score
return scoredTrip
}
// MARK: - Game Quality Score
private func calculateGameQualityScore(trip: Trip, request: PlanningRequest) -> Double {
var score: Double = 0
let totalPossibleGames = Double(max(1, request.availableGames.count))
let gamesAttended = Double(trip.totalGames)
// Base score from number of games
let gameRatio = gamesAttended / min(totalPossibleGames, Double(trip.tripDuration))
score += gameRatio * 50
// Bonus for including must-see games
let mustSeeIncluded = trip.stops.flatMap { $0.games }
.filter { request.preferences.mustSeeGameIds.contains($0) }
.count
let mustSeeRatio = Double(mustSeeIncluded) / Double(max(1, request.preferences.mustSeeGameIds.count))
score += mustSeeRatio * 30
// Bonus for sport variety
let sportsAttended = Set(request.availableGames
.filter { trip.stops.flatMap { $0.games }.contains($0.id) }
.map { $0.sport }
)
let varietyBonus = Double(sportsAttended.count) / Double(max(1, request.preferences.sports.count)) * 20
score += varietyBonus
return min(100, score)
}
// MARK: - Route Efficiency Score
private func calculateRouteEfficiencyScore(trip: Trip, request: PlanningRequest) -> Double {
guard let startLocation = request.preferences.startLocation,
let endLocation = request.preferences.endLocation,
let startCoord = startLocation.coordinate,
let endCoord = endLocation.coordinate else {
return 50.0
}
// Calculate direct distance
let directDistance = CLLocation(latitude: startCoord.latitude, longitude: startCoord.longitude)
.distance(from: CLLocation(latitude: endCoord.latitude, longitude: endCoord.longitude))
guard trip.totalDistanceMeters > 0, directDistance > 0 else {
return 50.0
}
// Efficiency ratio (direct / actual)
let efficiency = directDistance / trip.totalDistanceMeters
// Score: 100 for perfect efficiency, lower for longer routes
// Allow up to 3x direct distance before score drops significantly
let normalizedEfficiency = min(1.0, efficiency * 2)
return normalizedEfficiency * 100
}
// MARK: - Leisure Balance Score
private func calculateLeisureBalanceScore(trip: Trip, request: PlanningRequest) -> Double {
let leisureLevel = request.preferences.leisureLevel
var score: Double = 100
// Check average driving hours
let avgDrivingHours = trip.averageDrivingHoursPerDay
let targetDrivingHours: Double = switch leisureLevel {
case .packed: 8.0
case .moderate: 6.0
case .relaxed: 4.0
}
if avgDrivingHours > targetDrivingHours {
let excess = avgDrivingHours - targetDrivingHours
score -= excess * 10
}
// Check rest day ratio
let restDays = trip.stops.filter { $0.isRestDay }.count
let targetRestRatio = leisureLevel.restDaysPerWeek / 7.0
let actualRestRatio = Double(restDays) / Double(max(1, trip.tripDuration))
let restDifference = abs(actualRestRatio - targetRestRatio)
score -= restDifference * 50
// Check games per day vs target
let gamesPerDay = Double(trip.totalGames) / Double(max(1, trip.tripDuration))
let targetGamesPerDay = Double(leisureLevel.maxGamesPerWeek) / 7.0
if gamesPerDay > targetGamesPerDay {
let excess = gamesPerDay - targetGamesPerDay
score -= excess * 20
}
return max(0, min(100, score))
}
// MARK: - Preference Alignment Score
private func calculatePreferenceAlignmentScore(trip: Trip, request: PlanningRequest) -> Double {
var score: Double = 100
// Check if must-stop locations are visited
let visitedCities = Set(trip.stops.map { $0.city.lowercased() })
for location in request.preferences.mustStopLocations {
if !visitedCities.contains(location.name.lowercased()) {
score -= 15
}
}
// Bonus for preferred cities
for city in request.preferences.preferredCities {
if visitedCities.contains(city.lowercased()) {
score += 5
}
}
// Check EV charging if needed
if request.preferences.needsEVCharging {
let hasEVStops = trip.travelSegments.contains { !$0.evChargingStops.isEmpty }
if !hasEVStops && trip.travelSegments.contains(where: { $0.distanceMiles > 200 }) {
score -= 20 // Long drive without EV stops
}
}
// Check lodging type alignment
let lodgingMatches = trip.stops.filter { stop in
stop.lodging?.type == request.preferences.lodgingType
}.count
let lodgingRatio = Double(lodgingMatches) / Double(max(1, trip.stops.count))
score = score * (0.5 + lodgingRatio * 0.5)
// Check if within stop limit
if let maxStops = request.preferences.numberOfStops {
if trip.stops.count > maxStops {
score -= Double(trip.stops.count - maxStops) * 10
}
}
return max(0, min(100, score))
}
}
// MARK: - CoreML Integration (Placeholder)
extension TripScorer {
/// Score using CoreML model if available
func scoreWithML(trip: Trip, request: PlanningRequest) -> Trip {
// In production, this would use a CoreML model for personalized scoring
// For now, fall back to rule-based scoring
return score(trip: trip, request: request)
}
}
import CoreLocation

View File

@@ -0,0 +1,127 @@
//
// DateRangeValidator.swift
// SportsTime
//
import Foundation
/// Validates that all games fall within the specified date range.
/// Priority 1 in the rule hierarchy - checked first before any other constraints.
struct DateRangeValidator {
// MARK: - Validation Result
struct ValidationResult {
let isValid: Bool
let violations: [ConstraintViolation]
let gamesOutsideRange: [UUID]
static let valid = ValidationResult(isValid: true, violations: [], gamesOutsideRange: [])
static func invalid(games: [UUID]) -> ValidationResult {
let violations = games.map { gameId in
ConstraintViolation(
type: .dateRange,
description: "Game \(gameId.uuidString.prefix(8)) falls outside the specified date range",
severity: .error
)
}
return ValidationResult(isValid: false, violations: violations, gamesOutsideRange: games)
}
}
// MARK: - Validation
/// Validates that ALL selected games (must-see games) fall within the date range.
/// This is a HARD constraint - if any selected game is outside the range, planning fails.
///
/// - Parameters:
/// - mustSeeGameIds: Set of game IDs that MUST be included in the trip
/// - allGames: All available games to check against
/// - startDate: Start of the valid date range (inclusive)
/// - endDate: End of the valid date range (inclusive)
/// - Returns: ValidationResult indicating success or failure with specific violations
func validate(
mustSeeGameIds: Set<UUID>,
allGames: [Game],
startDate: Date,
endDate: Date
) -> ValidationResult {
// If no must-see games, validation passes
guard !mustSeeGameIds.isEmpty else {
return .valid
}
// Find all must-see games that fall outside the range
let gamesOutsideRange = allGames
.filter { mustSeeGameIds.contains($0.id) }
.filter { game in
game.dateTime < startDate || game.dateTime > endDate
}
.map { $0.id }
if gamesOutsideRange.isEmpty {
return .valid
} else {
return .invalid(games: gamesOutsideRange)
}
}
/// Validates games for Scenario B (Selected Games mode).
/// ALL selected games MUST be within the date range - no exceptions.
///
/// - Parameters:
/// - request: The planning request containing preferences and games
/// - Returns: ValidationResult with explicit failure if any selected game is out of range
func validateForScenarioB(_ request: PlanningRequest) -> ValidationResult {
return validate(
mustSeeGameIds: request.preferences.mustSeeGameIds,
allGames: request.availableGames,
startDate: request.preferences.startDate,
endDate: request.preferences.endDate
)
}
/// Checks if there are any games available within the date range.
/// Used to determine if planning can proceed at all.
///
/// - Parameters:
/// - games: All available games
/// - startDate: Start of the valid date range
/// - endDate: End of the valid date range
/// - sports: Sports to filter by
/// - Returns: True if at least one game exists in the range
func hasGamesInRange(
games: [Game],
startDate: Date,
endDate: Date,
sports: Set<Sport>
) -> Bool {
games.contains { game in
game.dateTime >= startDate &&
game.dateTime <= endDate &&
sports.contains(game.sport)
}
}
/// Returns all games that fall within the specified date range.
///
/// - Parameters:
/// - games: All available games
/// - startDate: Start of the valid date range
/// - endDate: End of the valid date range
/// - sports: Sports to filter by
/// - Returns: Array of games within the range
func gamesInRange(
games: [Game],
startDate: Date,
endDate: Date,
sports: Set<Sport>
) -> [Game] {
games.filter { game in
game.dateTime >= startDate &&
game.dateTime <= endDate &&
sports.contains(game.sport)
}
}
}

View File

@@ -0,0 +1,200 @@
//
// DrivingFeasibilityValidator.swift
// SportsTime
//
import Foundation
/// Validates driving feasibility based on daily hour limits.
/// Priority 4 in the rule hierarchy.
///
/// A route is valid ONLY IF:
/// - Daily driving time maxDailyDrivingHours for EVERY day
/// - Games are reachable between scheduled times
struct DrivingFeasibilityValidator {
// MARK: - Validation Result
struct ValidationResult {
let isValid: Bool
let violations: [ConstraintViolation]
let failedSegment: SegmentFailure?
static let valid = ValidationResult(isValid: true, violations: [], failedSegment: nil)
static func drivingExceeded(
segment: String,
requiredHours: Double,
limitHours: Double
) -> ValidationResult {
let violation = ConstraintViolation(
type: .drivingTime,
description: "\(segment) requires \(String(format: "%.1f", requiredHours)) hours driving (limit: \(String(format: "%.1f", limitHours)) hours)",
severity: .error
)
let failure = SegmentFailure(
segmentDescription: segment,
requiredHours: requiredHours,
limitHours: limitHours
)
return ValidationResult(isValid: false, violations: [violation], failedSegment: failure)
}
static func gameUnreachable(
gameId: UUID,
arrivalTime: Date,
gameTime: Date
) -> ValidationResult {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
let violation = ConstraintViolation(
type: .gameReachability,
description: "Cannot arrive (\(formatter.string(from: arrivalTime))) before game starts (\(formatter.string(from: gameTime)))",
severity: .error
)
return ValidationResult(isValid: false, violations: [violation], failedSegment: nil)
}
}
struct SegmentFailure {
let segmentDescription: String
let requiredHours: Double
let limitHours: Double
}
// MARK: - Properties
let constraints: DrivingConstraints
// MARK: - Initialization
init(constraints: DrivingConstraints = .default) {
self.constraints = constraints
}
init(from preferences: TripPreferences) {
self.constraints = DrivingConstraints(from: preferences)
}
// MARK: - Validation
/// Validates that a single travel segment is feasible within daily driving limits.
///
/// - Parameters:
/// - drivingHours: Required driving hours for this segment
/// - origin: Description of the origin location
/// - destination: Description of the destination location
/// - Returns: ValidationResult indicating if the segment is feasible
func validateSegment(
drivingHours: Double,
origin: String,
destination: String
) -> ValidationResult {
let maxDaily = constraints.maxDailyDrivingHours
// A segment is valid if it can be completed in one day OR
// can be split across multiple days with overnight stops
if drivingHours <= maxDaily {
return .valid
}
// Check if it can be reasonably split across multiple days
// We allow up to 2 driving days for a single segment
let maxTwoDayDriving = maxDaily * 2
if drivingHours <= maxTwoDayDriving {
return .valid
}
// Segment requires more than 2 days of driving - too long
return .drivingExceeded(
segment: "\(origin)\(destination)",
requiredHours: drivingHours,
limitHours: maxDaily
)
}
/// Validates that a game can be reached in time given departure time and driving duration.
///
/// - Parameters:
/// - gameId: ID of the game to reach
/// - gameTime: When the game starts
/// - departureTime: When we leave the previous stop
/// - drivingHours: Hours of driving required
/// - bufferHours: Buffer time needed before game (default 1 hour for parking, etc.)
/// - Returns: ValidationResult indicating if we can reach the game in time
func validateGameReachability(
gameId: UUID,
gameTime: Date,
departureTime: Date,
drivingHours: Double,
bufferHours: Double = 1.0
) -> ValidationResult {
// Calculate arrival time
let drivingSeconds = drivingHours * 3600
let bufferSeconds = bufferHours * 3600
let arrivalTime = departureTime.addingTimeInterval(drivingSeconds)
let requiredArrivalTime = gameTime.addingTimeInterval(-bufferSeconds)
if arrivalTime <= requiredArrivalTime {
return .valid
} else {
return .gameUnreachable(
gameId: gameId,
arrivalTime: arrivalTime,
gameTime: gameTime
)
}
}
/// Validates an entire itinerary's travel segments for driving feasibility.
///
/// - Parameter segments: Array of travel segments to validate
/// - Returns: ValidationResult with first failure found, or valid if all pass
func validateItinerary(segments: [TravelSegment]) -> ValidationResult {
for segment in segments {
let result = validateSegment(
drivingHours: segment.estimatedDrivingHours,
origin: segment.fromLocation.name,
destination: segment.toLocation.name
)
if !result.isValid {
return result
}
}
return .valid
}
/// Calculates how many travel days are needed for a given driving distance.
///
/// - Parameter drivingHours: Total hours of driving required
/// - Returns: Number of calendar days the travel will span
func travelDaysRequired(for drivingHours: Double) -> Int {
let maxDaily = constraints.maxDailyDrivingHours
if drivingHours <= maxDaily {
return 1
}
return Int(ceil(drivingHours / maxDaily))
}
/// Determines if an overnight stop is needed between two points.
///
/// - Parameters:
/// - drivingHours: Hours of driving between points
/// - departureTime: When we plan to leave
/// - Returns: True if an overnight stop is recommended
func needsOvernightStop(drivingHours: Double, departureTime: Date) -> Bool {
// If driving exceeds daily limit, we need an overnight
if drivingHours > constraints.maxDailyDrivingHours {
return true
}
// Also check if arrival would be unreasonably late
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
let calendar = Calendar.current
let arrivalHour = calendar.component(.hour, from: arrivalTime)
// Arriving after 11 PM suggests we should have stopped
return arrivalHour >= 23
}
}

View File

@@ -0,0 +1,229 @@
//
// GeographicSanityChecker.swift
// SportsTime
//
import Foundation
import CoreLocation
/// Validates geographic sanity of routes - no zig-zagging or excessive backtracking.
/// Priority 5 in the rule hierarchy.
///
/// For Scenario C (directional routes with start+end):
/// - Route MUST make net progress toward the end location
/// - Temporary increases in distance are allowed only if minor and followed by progress
/// - Large backtracking or oscillation is prohibited
///
/// For all scenarios:
/// - Detects obvious zig-zag patterns (e.g., Chicago Dallas San Diego Minnesota NY)
struct GeographicSanityChecker {
// MARK: - Validation Result
struct ValidationResult {
let isValid: Bool
let violations: [ConstraintViolation]
let backtrackingDetails: BacktrackingInfo?
static let valid = ValidationResult(isValid: true, violations: [], backtrackingDetails: nil)
static func backtracking(info: BacktrackingInfo) -> ValidationResult {
let violation = ConstraintViolation(
type: .geographicSanity,
description: info.description,
severity: .error
)
return ValidationResult(isValid: false, violations: [violation], backtrackingDetails: info)
}
}
struct BacktrackingInfo {
let fromCity: String
let toCity: String
let distanceIncreasePercent: Double
let description: String
init(fromCity: String, toCity: String, distanceIncreasePercent: Double) {
self.fromCity = fromCity
self.toCity = toCity
self.distanceIncreasePercent = distanceIncreasePercent
self.description = "Route backtracks from \(fromCity) to \(toCity) (distance to destination increased by \(String(format: "%.0f", distanceIncreasePercent))%)"
}
}
// MARK: - Configuration
/// Maximum allowed distance increase before flagging as backtracking (percentage)
private let maxAllowedDistanceIncrease: Double = 0.15 // 15%
/// Number of consecutive distance increases before flagging as zig-zag
private let maxConsecutiveIncreases: Int = 2
// MARK: - Scenario C: Directional Route Validation
/// Validates that a route makes monotonic progress toward the end location.
/// This is the primary validation for Scenario C (start + end location).
///
/// - Parameters:
/// - stops: Ordered array of stops in the route
/// - endCoordinate: The target destination coordinate
/// - Returns: ValidationResult indicating if route has valid directional progress
func validateDirectionalProgress(
stops: [ItineraryStop],
endCoordinate: CLLocationCoordinate2D
) -> ValidationResult {
guard stops.count >= 2 else {
return .valid // Single stop or empty route is trivially valid
}
var consecutiveIncreases = 0
var previousDistance: CLLocationDistance?
var previousCity: String?
for stop in stops {
guard let coordinate = stop.coordinate else { continue }
let currentDistance = distance(from: coordinate, to: endCoordinate)
if let prevDist = previousDistance, let prevCity = previousCity {
if currentDistance > prevDist {
// Distance to end increased - potential backtracking
let increasePercent = (currentDistance - prevDist) / prevDist
consecutiveIncreases += 1
// Check if this increase is too large
if increasePercent > maxAllowedDistanceIncrease {
return .backtracking(info: BacktrackingInfo(
fromCity: prevCity,
toCity: stop.city,
distanceIncreasePercent: increasePercent * 100
))
}
// Check for oscillation (too many consecutive increases)
if consecutiveIncreases >= maxConsecutiveIncreases {
return .backtracking(info: BacktrackingInfo(
fromCity: prevCity,
toCity: stop.city,
distanceIncreasePercent: increasePercent * 100
))
}
} else {
// Making progress - reset counter
consecutiveIncreases = 0
}
}
previousDistance = currentDistance
previousCity = stop.city
}
return .valid
}
// MARK: - General Geographic Sanity
/// Validates that a route doesn't have obvious zig-zag patterns.
/// Uses compass bearing analysis to detect direction reversals.
///
/// - Parameter stops: Ordered array of stops in the route
/// - Returns: ValidationResult indicating if route is geographically sane
func validateNoZigZag(stops: [ItineraryStop]) -> ValidationResult {
guard stops.count >= 3 else {
return .valid // Need at least 3 stops to detect zig-zag
}
var bearingReversals = 0
var previousBearing: Double?
for i in 0..<(stops.count - 1) {
guard let from = stops[i].coordinate,
let to = stops[i + 1].coordinate else { continue }
let currentBearing = bearing(from: from, to: to)
if let prevBearing = previousBearing {
// Check if we've reversed direction (>90 degree change)
let bearingChange = abs(normalizedBearingDifference(prevBearing, currentBearing))
if bearingChange > 90 {
bearingReversals += 1
}
}
previousBearing = currentBearing
}
// Allow at most one major direction change (e.g., going east then north is fine)
// But multiple reversals indicate zig-zagging
if bearingReversals > 1 {
return .backtracking(info: BacktrackingInfo(
fromCity: stops.first?.city ?? "Start",
toCity: stops.last?.city ?? "End",
distanceIncreasePercent: Double(bearingReversals) * 30 // Rough estimate
))
}
return .valid
}
/// Validates a complete route for both directional progress (if end is specified)
/// and general geographic sanity.
///
/// - Parameters:
/// - stops: Ordered array of stops
/// - endCoordinate: Optional end coordinate for directional validation
/// - Returns: Combined validation result
func validate(
stops: [ItineraryStop],
endCoordinate: CLLocationCoordinate2D?
) -> ValidationResult {
// If we have an end coordinate, validate directional progress
if let end = endCoordinate {
let directionalResult = validateDirectionalProgress(stops: stops, endCoordinate: end)
if !directionalResult.isValid {
return directionalResult
}
}
// Always check for zig-zag patterns
return validateNoZigZag(stops: stops)
}
// MARK: - Helper Methods
/// Calculates distance between two coordinates in meters.
private func distance(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> CLLocationDistance {
let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude)
let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude)
return fromLocation.distance(from: toLocation)
}
/// Calculates bearing (direction) from one coordinate to another in degrees.
private func bearing(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let lat1 = from.latitude * .pi / 180
let lat2 = to.latitude * .pi / 180
let dLon = (to.longitude - from.longitude) * .pi / 180
let y = sin(dLon) * cos(lat2)
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
var bearing = atan2(y, x) * 180 / .pi
bearing = (bearing + 360).truncatingRemainder(dividingBy: 360)
return bearing
}
/// Calculates the normalized difference between two bearings (-180 to 180).
private func normalizedBearingDifference(_ bearing1: Double, _ bearing2: Double) -> Double {
var diff = bearing2 - bearing1
while diff > 180 { diff -= 360 }
while diff < -180 { diff += 360 }
return diff
}
}

View File

@@ -0,0 +1,253 @@
//
// MustStopValidator.swift
// SportsTime
//
import Foundation
import CoreLocation
/// Validates that must-stop locations are reachable by the route.
/// Priority 6 in the rule hierarchy (lowest priority).
///
/// A route "passes" a must-stop location if:
/// - Any travel segment comes within the proximity threshold (default 25 miles)
/// - The must-stop does NOT require a separate overnight stay
struct MustStopValidator {
// MARK: - Validation Result
struct ValidationResult {
let isValid: Bool
let violations: [ConstraintViolation]
let unreachableLocations: [String]
static let valid = ValidationResult(isValid: true, violations: [], unreachableLocations: [])
static func unreachable(locations: [String]) -> ValidationResult {
let violations = locations.map { location in
ConstraintViolation(
type: .mustStop,
description: "Required stop '\(location)' is not reachable within \(Int(MustStopConfig.defaultProximityMiles)) miles of any route segment",
severity: .error
)
}
return ValidationResult(isValid: false, violations: violations, unreachableLocations: locations)
}
}
// MARK: - Properties
let config: MustStopConfig
// MARK: - Initialization
init(config: MustStopConfig = MustStopConfig()) {
self.config = config
}
// MARK: - Validation
/// Validates that all must-stop locations can be reached by the route.
///
/// - Parameters:
/// - mustStopLocations: Array of locations that must be visited/passed
/// - stops: The planned stops in the itinerary
/// - segments: The travel segments between stops
/// - Returns: ValidationResult indicating if all must-stops are reachable
func validate(
mustStopLocations: [LocationInput],
stops: [ItineraryStop],
segments: [TravelSegment]
) -> ValidationResult {
guard !mustStopLocations.isEmpty else {
return .valid
}
var unreachable: [String] = []
for mustStop in mustStopLocations {
if !isReachable(mustStop: mustStop, stops: stops, segments: segments) {
unreachable.append(mustStop.name)
}
}
if unreachable.isEmpty {
return .valid
} else {
return .unreachable(locations: unreachable)
}
}
/// Validates must-stop locations from a planning request.
///
/// - Parameters:
/// - request: The planning request with must-stop preferences
/// - stops: The planned stops
/// - segments: The travel segments
/// - Returns: ValidationResult
func validate(
request: PlanningRequest,
stops: [ItineraryStop],
segments: [TravelSegment]
) -> ValidationResult {
return validate(
mustStopLocations: request.preferences.mustStopLocations,
stops: stops,
segments: segments
)
}
// MARK: - Reachability Check
/// Checks if a must-stop location is reachable by the route.
/// A location is reachable if:
/// 1. It's within proximity of any stop, OR
/// 2. It's within proximity of any travel segment path
///
/// - Parameters:
/// - mustStop: The location to check
/// - stops: Planned stops
/// - segments: Travel segments
/// - Returns: True if the location is reachable
private func isReachable(
mustStop: LocationInput,
stops: [ItineraryStop],
segments: [TravelSegment]
) -> Bool {
guard let mustStopCoord = mustStop.coordinate else {
// If we don't have coordinates, we can't validate - assume reachable
return true
}
// Check if any stop is within proximity
for stop in stops {
if let stopCoord = stop.coordinate {
let distance = distanceInMiles(from: mustStopCoord, to: stopCoord)
if distance <= config.proximityMiles {
return true
}
}
}
// Check if any segment passes within proximity
for segment in segments {
if isNearSegment(point: mustStopCoord, segment: segment) {
return true
}
}
return false
}
/// Checks if a point is near a travel segment.
/// Uses perpendicular distance to the segment line.
///
/// - Parameters:
/// - point: The point to check
/// - segment: The travel segment
/// - Returns: True if within proximity
private func isNearSegment(
point: CLLocationCoordinate2D,
segment: TravelSegment
) -> Bool {
guard let originCoord = segment.fromLocation.coordinate,
let destCoord = segment.toLocation.coordinate else {
return false
}
// Calculate perpendicular distance from point to segment
let distance = perpendicularDistance(
point: point,
lineStart: originCoord,
lineEnd: destCoord
)
return distance <= config.proximityMiles
}
/// Calculates the minimum distance from a point to a line segment in miles.
/// Uses the perpendicular distance if the projection falls on the segment,
/// otherwise uses the distance to the nearest endpoint.
private func perpendicularDistance(
point: CLLocationCoordinate2D,
lineStart: CLLocationCoordinate2D,
lineEnd: CLLocationCoordinate2D
) -> Double {
let pointLoc = CLLocation(latitude: point.latitude, longitude: point.longitude)
let startLoc = CLLocation(latitude: lineStart.latitude, longitude: lineStart.longitude)
let endLoc = CLLocation(latitude: lineEnd.latitude, longitude: lineEnd.longitude)
let lineLength = startLoc.distance(from: endLoc)
// Handle degenerate case where start == end
if lineLength < 1 {
return pointLoc.distance(from: startLoc) / 1609.34 // meters to miles
}
// Calculate projection parameter t
// t = ((P - A) · (B - A)) / |B - A|²
let dx = endLoc.coordinate.longitude - startLoc.coordinate.longitude
let dy = endLoc.coordinate.latitude - startLoc.coordinate.latitude
let px = point.longitude - lineStart.longitude
let py = point.latitude - lineStart.latitude
let t = max(0, min(1, (px * dx + py * dy) / (dx * dx + dy * dy)))
// Calculate closest point on segment
let closestLat = lineStart.latitude + t * dy
let closestLon = lineStart.longitude + t * dx
let closestLoc = CLLocation(latitude: closestLat, longitude: closestLon)
// Return distance in miles
return pointLoc.distance(from: closestLoc) / 1609.34
}
/// Calculates distance between two coordinates in miles.
private func distanceInMiles(
from: CLLocationCoordinate2D,
to: CLLocationCoordinate2D
) -> Double {
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
return fromLoc.distance(from: toLoc) / 1609.34 // meters to miles
}
// MARK: - Route Modification
/// Finds the best position to insert a must-stop location into an itinerary.
/// Used when we need to add an explicit stop for a must-stop location.
///
/// - Parameters:
/// - mustStop: The location to insert
/// - stops: Current stops in order
/// - Returns: The index where the stop should be inserted (1-based, between existing stops)
func bestInsertionIndex(
for mustStop: LocationInput,
in stops: [ItineraryStop]
) -> Int {
guard let mustStopCoord = mustStop.coordinate, stops.count >= 2 else {
return 1 // Insert after first stop
}
var bestIndex = 1
var minDetour = Double.greatestFiniteMagnitude
for i in 0..<(stops.count - 1) {
guard let fromCoord = stops[i].coordinate,
let toCoord = stops[i + 1].coordinate else { continue }
// Calculate detour: (frommustStop + mustStopto) - (fromto)
let direct = distanceInMiles(from: fromCoord, to: toCoord)
let via = distanceInMiles(from: fromCoord, to: mustStopCoord) +
distanceInMiles(from: mustStopCoord, to: toCoord)
let detour = via - direct
if detour < minDetour {
minDetour = detour
bestIndex = i + 1
}
}
return bestIndex
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.sportstime.app</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,39 @@
//
// SportsTimeApp.swift
// SportsTime
//
// Created by Trey Tartt on 1/6/26.
//
import SwiftUI
import SwiftData
@main
struct SportsTimeApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
SavedTrip.self,
TripVote.self,
UserPreferences.self,
CachedSchedule.self,
])
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .none // Local only; CloudKit used separately for schedules
)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
HomeView()
}
.modelContainer(sharedModelContainer)
}
}