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,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] ?? ""
}
}