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:
94
SportsTime/Core/Models/Domain/Game.swift
Normal file
94
SportsTime/Core/Models/Domain/Game.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
64
SportsTime/Core/Models/Domain/Sport.swift
Normal file
64
SportsTime/Core/Models/Domain/Sport.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
68
SportsTime/Core/Models/Domain/Stadium.swift
Normal file
68
SportsTime/Core/Models/Domain/Stadium.swift
Normal 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
|
||||
}
|
||||
}
|
||||
50
SportsTime/Core/Models/Domain/Team.swift
Normal file
50
SportsTime/Core/Models/Domain/Team.swift
Normal 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
|
||||
}
|
||||
}
|
||||
108
SportsTime/Core/Models/Domain/TravelSegment.swift
Normal file
108
SportsTime/Core/Models/Domain/TravelSegment.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
184
SportsTime/Core/Models/Domain/Trip.swift
Normal file
184
SportsTime/Core/Models/Domain/Trip.swift
Normal 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
|
||||
}
|
||||
284
SportsTime/Core/Models/Domain/TripPreferences.swift
Normal file
284
SportsTime/Core/Models/Domain/TripPreferences.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
167
SportsTime/Core/Models/Domain/TripStop.swift
Normal file
167
SportsTime/Core/Models/Domain/TripStop.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user