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>
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Skill(axiom:axiom-swiftui-architecture)",
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(pip3 list:*)",
|
||||||
|
"Bash(pip3 install:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(xcrun simctl install:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
98
.gitignore
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.pbxuser
|
||||||
|
!default.pbxuser
|
||||||
|
*.mode1v3
|
||||||
|
!default.mode1v3
|
||||||
|
*.mode2v3
|
||||||
|
!default.mode2v3
|
||||||
|
*.perspectivev3
|
||||||
|
!default.perspectivev3
|
||||||
|
xcuserdata/
|
||||||
|
*.xccheckout
|
||||||
|
*.moved-aside
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcscmblueprint
|
||||||
|
*.xcworkspace
|
||||||
|
!default.xcworkspace
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
Packages/
|
||||||
|
Package.resolved
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
Pods/
|
||||||
|
Podfile.lock
|
||||||
|
|
||||||
|
# Carthage
|
||||||
|
Carthage/Build/
|
||||||
|
|
||||||
|
# Fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/**/*.png
|
||||||
|
fastlane/test_output
|
||||||
|
|
||||||
|
# Code Injection
|
||||||
|
iOSInjectionProject/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# Python (for Scripts/)
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Archives
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.rar
|
||||||
|
|
||||||
|
# Sensitive files
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
credentials.json
|
||||||
|
secrets.json
|
||||||
328
ARCHITECTURE.md
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# Sport Travel Planner - Architecture Document
|
||||||
|
|
||||||
|
## 1. High-Level Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PRESENTATION LAYER │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ HomeView │ │ TripView │ │ScheduleView│ │ SettingsView│ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ │
|
||||||
|
│ │HomeViewModel│ │TripViewModel│ │ScheduleVM │ │ SettingsVM │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DOMAIN LAYER │
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ TripPlanner │ │ RouteOptimizer │ │ ScheduleMatcher │ │
|
||||||
|
│ │ (Orchestrator) │ │ (Algorithm) │ │ (Game Finder) │ │
|
||||||
|
│ └──────────┬──────────┘ └──────────┬──────────┘ └──────────┬──────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌──────────▼────────────────────────▼────────────────────────▼──────────┐ │
|
||||||
|
│ │ TripPlanningEngine │ │
|
||||||
|
│ │ • Constraint Solver • Route Graph • Scoring (CoreML) │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DATA LAYER │
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ TripRepository │ │ ScheduleRepository │ │ StadiumRepository │ │
|
||||||
|
│ │ (SwiftData) │ │ (CloudKit) │ │ (CloudKit) │ │
|
||||||
|
│ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ CloudKit Sync Manager ││
|
||||||
|
│ │ • Public Database (Schedules, Stadiums) ││
|
||||||
|
│ │ • Shared Database (Collaborative Trips - Phase 2) ││
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────────┘│
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
SportsTime/
|
||||||
|
├── Core/
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ ├── Domain/ # Pure Swift domain models
|
||||||
|
│ │ │ ├── Sport.swift
|
||||||
|
│ │ │ ├── Team.swift
|
||||||
|
│ │ │ ├── Game.swift
|
||||||
|
│ │ │ ├── Stadium.swift
|
||||||
|
│ │ │ ├── Trip.swift
|
||||||
|
│ │ │ ├── TripStop.swift
|
||||||
|
│ │ │ ├── TravelSegment.swift
|
||||||
|
│ │ │ └── TripPreferences.swift
|
||||||
|
│ │ ├── CloudKit/ # CKRecord-backed models
|
||||||
|
│ │ │ ├── CKGame.swift
|
||||||
|
│ │ │ ├── CKStadium.swift
|
||||||
|
│ │ │ └── CKTeam.swift
|
||||||
|
│ │ └── Local/ # SwiftData models
|
||||||
|
│ │ ├── SavedTrip.swift
|
||||||
|
│ │ ├── UserPreferences.swift
|
||||||
|
│ │ └── CachedSchedule.swift
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ ├── CloudKitService.swift
|
||||||
|
│ │ ├── LocationService.swift
|
||||||
|
│ │ ├── DistanceMatrixService.swift
|
||||||
|
│ │ └── ExportService.swift
|
||||||
|
│ ├── Utilities/
|
||||||
|
│ │ └── DateFormatter+Extensions.swift
|
||||||
|
│ └── Extensions/
|
||||||
|
│ └── CLLocation+Extensions.swift
|
||||||
|
├── Features/
|
||||||
|
│ ├── Home/
|
||||||
|
│ │ ├── Views/
|
||||||
|
│ │ │ └── HomeView.swift
|
||||||
|
│ │ └── ViewModels/
|
||||||
|
│ │ └── HomeViewModel.swift
|
||||||
|
│ ├── Trip/
|
||||||
|
│ │ ├── Views/
|
||||||
|
│ │ │ ├── TripCreationView.swift
|
||||||
|
│ │ │ ├── TripDetailView.swift
|
||||||
|
│ │ │ ├── TripStopCard.swift
|
||||||
|
│ │ │ └── TripPreferencesForm.swift
|
||||||
|
│ │ └── ViewModels/
|
||||||
|
│ │ ├── TripCreationViewModel.swift
|
||||||
|
│ │ └── TripDetailViewModel.swift
|
||||||
|
│ ├── Schedule/
|
||||||
|
│ │ ├── Views/
|
||||||
|
│ │ │ ├── ScheduleListView.swift
|
||||||
|
│ │ │ └── GameCard.swift
|
||||||
|
│ │ └── ViewModels/
|
||||||
|
│ │ └── ScheduleViewModel.swift
|
||||||
|
│ └── Settings/
|
||||||
|
│ ├── Views/
|
||||||
|
│ │ └── SettingsView.swift
|
||||||
|
│ └── ViewModels/
|
||||||
|
│ └── SettingsViewModel.swift
|
||||||
|
├── Planning/
|
||||||
|
│ ├── Engine/
|
||||||
|
│ │ ├── TripPlanningEngine.swift
|
||||||
|
│ │ ├── RouteOptimizer.swift
|
||||||
|
│ │ ├── ScheduleMatcher.swift
|
||||||
|
│ │ └── ConstraintSolver.swift
|
||||||
|
│ ├── Scoring/
|
||||||
|
│ │ ├── TripScorer.swift
|
||||||
|
│ │ └── RoutePreferenceModel.mlmodel
|
||||||
|
│ └── Models/
|
||||||
|
│ ├── PlanningRequest.swift
|
||||||
|
│ ├── PlanningResult.swift
|
||||||
|
│ └── RouteGraph.swift
|
||||||
|
├── Export/
|
||||||
|
│ ├── PDFGenerator.swift
|
||||||
|
│ ├── TripShareManager.swift
|
||||||
|
│ └── Templates/
|
||||||
|
│ └── TripPDFTemplate.swift
|
||||||
|
└── Resources/
|
||||||
|
└── Assets.xcassets
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Data Flow
|
||||||
|
|
||||||
|
### Trip Creation Flow
|
||||||
|
```
|
||||||
|
User Input → TripCreationViewModel → PlanningRequest
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
TripPlanningEngine
|
||||||
|
│
|
||||||
|
┌──────────────────────┼──────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
ScheduleMatcher RouteOptimizer ConstraintSolver
|
||||||
|
│ │ │
|
||||||
|
└──────────────────────┼──────────────────────┘
|
||||||
|
▼
|
||||||
|
TripScorer (CoreML)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
PlanningResult → Trip
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
TripDetailView (Display)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SavedTrip (SwiftData persist)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. CloudKit Schema
|
||||||
|
|
||||||
|
### Public Database Records
|
||||||
|
|
||||||
|
| Record Type | Fields |
|
||||||
|
|-------------|--------|
|
||||||
|
| `Team` | `id`, `name`, `abbreviation`, `sport`, `city`, `stadiumRef` |
|
||||||
|
| `Stadium` | `id`, `name`, `city`, `state`, `latitude`, `longitude`, `capacity`, `teamRefs` |
|
||||||
|
| `Game` | `id`, `homeTeamRef`, `awayTeamRef`, `stadiumRef`, `dateTime`, `sport`, `season` |
|
||||||
|
| `Sport` | `id`, `name`, `seasonStart`, `seasonEnd`, `iconName` |
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
- Team → Stadium (reference)
|
||||||
|
- Game → Team (home, away references)
|
||||||
|
- Game → Stadium (reference)
|
||||||
|
|
||||||
|
## 5. Trip Planning Algorithm
|
||||||
|
|
||||||
|
### Step 1: Input Parsing
|
||||||
|
```
|
||||||
|
Parse user preferences into PlanningRequest:
|
||||||
|
- Start/End locations (geocoded)
|
||||||
|
- Date range
|
||||||
|
- Must-see games (locked stops)
|
||||||
|
- Travel mode (drive/fly)
|
||||||
|
- Constraints (max hours/day, EV, lodging type)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Game Discovery
|
||||||
|
```
|
||||||
|
FOR each sport in selected sports:
|
||||||
|
Query games within date range
|
||||||
|
Filter by geographic relevance (within reasonable detour)
|
||||||
|
Include must-see games regardless of detour
|
||||||
|
Score games by:
|
||||||
|
- Team popularity
|
||||||
|
- Rivalry factor
|
||||||
|
- Venue uniqueness
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Route Graph Construction
|
||||||
|
```
|
||||||
|
Build weighted graph:
|
||||||
|
Nodes = [Start, Stadiums with games, End]
|
||||||
|
Edges = Travel segments with:
|
||||||
|
- Distance
|
||||||
|
- Drive time (or flight availability)
|
||||||
|
- Scenic score (if scenic preference)
|
||||||
|
- EV charging availability
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Constraint Solving
|
||||||
|
```
|
||||||
|
Apply hard constraints:
|
||||||
|
- Must-see games are mandatory nodes
|
||||||
|
- Max stops OR max duration
|
||||||
|
- Game times must be reachable
|
||||||
|
|
||||||
|
Apply soft constraints (scored):
|
||||||
|
- Leisure level (rest days)
|
||||||
|
- Driving hours per day
|
||||||
|
- Scenic preference
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Route Optimization
|
||||||
|
```
|
||||||
|
IF stops < 8:
|
||||||
|
Use exact TSP solution (branch and bound)
|
||||||
|
ELSE:
|
||||||
|
Use nearest-neighbor heuristic + 2-opt improvement
|
||||||
|
|
||||||
|
Respect temporal constraints (game dates)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Itinerary Generation
|
||||||
|
```
|
||||||
|
FOR each day in trip:
|
||||||
|
Assign:
|
||||||
|
- Travel segments
|
||||||
|
- Game attendance
|
||||||
|
- Lodging location
|
||||||
|
- Rest periods (based on leisure level)
|
||||||
|
- EV charging stops (if applicable)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Scoring & Ranking
|
||||||
|
```
|
||||||
|
Score final itinerary:
|
||||||
|
- Game quality score
|
||||||
|
- Route efficiency score
|
||||||
|
- Fatigue score (inverse)
|
||||||
|
- User preference alignment
|
||||||
|
|
||||||
|
Return top 3 alternatives if possible
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. CoreML Strategy
|
||||||
|
|
||||||
|
### On-Device Models
|
||||||
|
|
||||||
|
| Model | Purpose | Input | Output |
|
||||||
|
|-------|---------|-------|--------|
|
||||||
|
| `RoutePreferenceModel` | Score route alternatives | Route features vector | Preference score 0-1 |
|
||||||
|
| `LeisureBalancer` | Optimize rest distribution | Trip features | Rest day placement |
|
||||||
|
| `PersonalizationModel` | Learn user preferences | Historical trips | Preference weights |
|
||||||
|
|
||||||
|
### Training Approach
|
||||||
|
1. **Initial Model**: Pre-trained on synthetic trip data
|
||||||
|
2. **On-Device Learning**: Core ML updatable model
|
||||||
|
3. **Features**: Trip duration, sports mix, driving hours, scenic detours, game density
|
||||||
|
|
||||||
|
### Model Integration
|
||||||
|
```swift
|
||||||
|
// Scoring a route option
|
||||||
|
let scorer = try RoutePreferenceModel()
|
||||||
|
let input = RoutePreferenceModelInput(
|
||||||
|
totalDistance: route.distance,
|
||||||
|
gameCount: route.games.count,
|
||||||
|
scenicScore: route.scenicRating,
|
||||||
|
avgDriveHours: route.averageDailyDrive,
|
||||||
|
leisureRatio: route.restDays / route.totalDays
|
||||||
|
)
|
||||||
|
let score = try scorer.prediction(input: input).preferenceScore
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. SwiftUI Screen Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Launch/Splash │
|
||||||
|
└────────┬────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ HomeView │◄──────────────────────────────────┐
|
||||||
|
│ - Saved Trips │ │
|
||||||
|
│ - Quick Start │ │
|
||||||
|
│ - Schedule │ │
|
||||||
|
└────────┬────────┘ │
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│TripCreationView │────►│ GamePickerSheet │ │
|
||||||
|
│ - Preferences │ │ (Must-See Games)│ │
|
||||||
|
│ - Locations │ └─────────────────┘ │
|
||||||
|
│ - Constraints │ │
|
||||||
|
└────────┬────────┘ │
|
||||||
|
│ "Plan Trip" │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────┐ │
|
||||||
|
│ PlanningView │ │
|
||||||
|
│ (Loading State) │ │
|
||||||
|
└────────┬────────┘ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ TripDetailView │────►│ ExportSheet │ │
|
||||||
|
│ - Day-by-Day │ │ - PDF │ │
|
||||||
|
│ - Map View │ │ - Share │ │
|
||||||
|
│ - Games List │ │ - Email │ │
|
||||||
|
│ - Save Trip │ └─────────────────┘ │
|
||||||
|
└────────┬────────┘ │
|
||||||
|
│ Save │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────┐ │
|
||||||
|
│ SavedTripsView │───────────────────────────────────┘
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Key Dependencies
|
||||||
|
|
||||||
|
- **SwiftData**: Local persistence for trips and preferences
|
||||||
|
- **CloudKit**: Shared schedules and stadium data
|
||||||
|
- **MapKit**: Route visualization and distance calculations
|
||||||
|
- **CoreML**: On-device trip scoring and personalization
|
||||||
|
- **PDFKit**: Trip export functionality
|
||||||
|
- **CoreLocation**: User location and geocoding
|
||||||
148
CLAUDE.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Build & Run Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the iOS app
|
||||||
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test
|
||||||
|
|
||||||
|
# Run specific test suite
|
||||||
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripPlanningEngineTests test
|
||||||
|
|
||||||
|
# Run a single test
|
||||||
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TestClassName/testMethodName test
|
||||||
|
|
||||||
|
# Data scraping (Python)
|
||||||
|
cd Scripts && pip install -r requirements.txt
|
||||||
|
python scrape_schedules.py --sport all --season 2026
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
This is an iOS app for planning multi-stop sports road trips. It uses **Clean MVVM** with feature-based modules.
|
||||||
|
|
||||||
|
### Three-Layer Architecture
|
||||||
|
|
||||||
|
1. **Presentation Layer** (`Features/`): SwiftUI Views + @Observable ViewModels organized by feature (Home, Trip, Schedule, Settings)
|
||||||
|
|
||||||
|
2. **Domain Layer** (`Planning/`): Trip planning logic
|
||||||
|
- `TripPlanningEngine` - Main orchestrator, 7-step algorithm
|
||||||
|
- `RouteOptimizer` - TSP solver (exact for <8 stops, heuristic otherwise)
|
||||||
|
- `ScheduleMatcher` - Finds games along route corridor
|
||||||
|
- `TripScorer` - Multi-factor scoring (game quality, route efficiency, leisure balance)
|
||||||
|
|
||||||
|
3. **Data Layer** (`Core/`):
|
||||||
|
- `Models/Domain/` - Pure Swift structs (Trip, Game, Stadium, Team)
|
||||||
|
- `Models/CloudKit/` - CKRecord wrappers for public database
|
||||||
|
- `Models/Local/` - SwiftData models for local persistence (SavedTrip, UserPreferences)
|
||||||
|
- `Services/` - CloudKitService (schedules), LocationService (geocoding/routing)
|
||||||
|
|
||||||
|
### Data Storage Strategy
|
||||||
|
|
||||||
|
- **CloudKit Public DB**: Read-only schedules, stadiums, teams (shared across all users)
|
||||||
|
- **SwiftData Local**: User's saved trips, preferences, cached schedules
|
||||||
|
- **No network dependency** for trip planning once schedules are synced
|
||||||
|
|
||||||
|
### Key Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
TripCreationView → TripCreationViewModel → PlanningRequest
|
||||||
|
→ TripPlanningEngine (ScheduleMatcher + RouteOptimizer + TripScorer)
|
||||||
|
→ PlanningResult → Trip → TripDetailView → SavedTrip (persist)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
- ViewModels use `@Observable` (not ObservableObject)
|
||||||
|
- All planning engine components are `actor` types for thread safety
|
||||||
|
- Domain models are pure Codable structs; SwiftData models wrap them via encoded `Data` fields
|
||||||
|
- CloudKit container ID: `iCloud.com.sportstime.app`
|
||||||
|
|
||||||
|
## Key View Components
|
||||||
|
|
||||||
|
### TripDetailView (`Features/Trip/Views/TripDetailView.swift`)
|
||||||
|
|
||||||
|
Displays trip itinerary with conflict detection for same-day games in different cities.
|
||||||
|
|
||||||
|
**Conflict Detection System:**
|
||||||
|
- `detectConflicts(for: ItineraryDay)` - Checks if multiple stops have games on the same calendar day
|
||||||
|
- Returns `DayConflictInfo` with `hasConflict`, `conflictingStops`, and `conflictingCities`
|
||||||
|
|
||||||
|
**RouteOptionsCard (Expandable):**
|
||||||
|
- Shows when multiple route options exist for the same day (conflicting games in different cities)
|
||||||
|
- Collapsed: Shows "N route options" with city list, tap to expand
|
||||||
|
- Expanded: Shows each option as a `RouteOptionCard` with numbered badge (Option 1, Option 2, etc.)
|
||||||
|
- Single routes (no conflict): Uses regular `DayCard`, auto-expanded
|
||||||
|
|
||||||
|
**RouteOptionCard:**
|
||||||
|
- Individual option within the expandable RouteOptionsCard
|
||||||
|
- Shows option number badge, city name, games at that stop, and travel info
|
||||||
|
|
||||||
|
**DayCard Component (non-conflict mode):**
|
||||||
|
- `specificStop: TripStop?` - When provided, shows only that stop's games
|
||||||
|
- `primaryCityForDay` - Returns the city for the card
|
||||||
|
- `gamesOnThisDay` - Returns games filtered to the calendar day
|
||||||
|
|
||||||
|
**Visual Design:**
|
||||||
|
- Expandable cards have orange border and branch icon
|
||||||
|
- Option badges are blue capsules
|
||||||
|
- Chevron indicates expand/collapse state
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
`Scripts/scrape_schedules.py` scrapes NBA/MLB/NHL schedules from multiple sources (Basketball-Reference, Baseball-Reference, Hockey-Reference, official APIs) for cross-validation. See `Scripts/DATA_SOURCES.md` for source URLs and rate limits.
|
||||||
|
|
||||||
|
## Test Suites
|
||||||
|
|
||||||
|
- **TripPlanningEngineTests** (50 tests) - Routing logic, must-see games, required destinations, EV charging, edge cases
|
||||||
|
- **DayCardTests** (11 tests) - DayCard conflict detection, warning display, stop filtering, edge cases
|
||||||
|
- **DuplicateGameIdTests** (2 tests) - Regression tests for handling duplicate game IDs in JSON data
|
||||||
|
|
||||||
|
## Bug Fix Protocol
|
||||||
|
|
||||||
|
Whenever fixing a bug:
|
||||||
|
1. **Write a regression test** that reproduces the bug before fixing it
|
||||||
|
2. **Include edge cases** - test boundary conditions, null/empty inputs, and related scenarios
|
||||||
|
3. **Confirm all tests pass** by running the test suite before considering the fix complete
|
||||||
|
4. **Name tests descriptively** - e.g., `test_DayCard_OnlyShowsGamesFromPrimaryStop_WhenMultipleStopsOverlapSameDay`
|
||||||
|
|
||||||
|
Example workflow:
|
||||||
|
```bash
|
||||||
|
# 1. Write failing test that reproduces the bug
|
||||||
|
# 2. Fix the bug
|
||||||
|
# 3. Verify the new test passes along with all existing tests
|
||||||
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Phases
|
||||||
|
|
||||||
|
### Phase 2: AI-Powered Trip Planning
|
||||||
|
|
||||||
|
**Natural Language Trip Planning**
|
||||||
|
- Allow users to describe trips in plain English: "plan me a baseball trip from Texas" or "I want to see the Yankees and Red Sox in one weekend"
|
||||||
|
- Parse intent, extract constraints (sports, dates, locations, budget)
|
||||||
|
- Generate trip suggestions from natural language input
|
||||||
|
|
||||||
|
**On-Device Intelligence (Apple Foundation Models)**
|
||||||
|
- Use Apple's Foundation Models framework (iOS 26+) for on-device AI processing
|
||||||
|
- Privacy-preserving - no data leaves the device
|
||||||
|
- Features to enable:
|
||||||
|
- Smart trip suggestions based on user history
|
||||||
|
- Natural language query understanding
|
||||||
|
- Personalized game recommendations
|
||||||
|
- Conversational trip refinement ("add another game" / "make it shorter")
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Foundation Models requires iOS 26+ and Apple Silicon
|
||||||
|
- Use `@Generable` for structured output parsing
|
||||||
|
- Implement graceful fallback for unsupported devices
|
||||||
|
- See `axiom:axiom-foundation-models` skill for patterns
|
||||||
|
|
||||||
|
## User Instruction
|
||||||
|
|
||||||
|
Do not commit code without prompting the user first.
|
||||||
145
Scripts/CLOUDKIT_SETUP.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# CloudKit Setup Guide for SportsTime
|
||||||
|
|
||||||
|
## 1. Configure Container in Apple Developer Portal
|
||||||
|
|
||||||
|
1. Go to [Apple Developer Portal](https://developer.apple.com/account)
|
||||||
|
2. Navigate to **Certificates, Identifiers & Profiles** > **Identifiers**
|
||||||
|
3. Select your App ID or create one for `com.sportstime.app`
|
||||||
|
4. Enable **iCloud** capability
|
||||||
|
5. Click **Configure** and create container: `iCloud.com.sportstime.app`
|
||||||
|
|
||||||
|
## 2. Configure in Xcode
|
||||||
|
|
||||||
|
1. Open `SportsTime.xcodeproj` in Xcode
|
||||||
|
2. Select the SportsTime target
|
||||||
|
3. Go to **Signing & Capabilities**
|
||||||
|
4. Ensure **iCloud** is added (should already be there)
|
||||||
|
5. Check **CloudKit** is selected
|
||||||
|
6. Select container `iCloud.com.sportstime.app`
|
||||||
|
|
||||||
|
## 3. Create Record Types in CloudKit Dashboard
|
||||||
|
|
||||||
|
Go to [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard)
|
||||||
|
|
||||||
|
### Record Type: `Stadium`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `stadiumId` | String | Unique identifier |
|
||||||
|
| `name` | String | Stadium name |
|
||||||
|
| `city` | String | City |
|
||||||
|
| `state` | String | State/Province |
|
||||||
|
| `location` | Location | CLLocation (lat/lng) |
|
||||||
|
| `capacity` | Int(64) | Seating capacity |
|
||||||
|
| `sport` | String | NBA, MLB, NHL |
|
||||||
|
| `teamAbbrevs` | String (List) | Team abbreviations |
|
||||||
|
| `source` | String | Data source |
|
||||||
|
| `yearOpened` | Int(64) | Optional |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
- `sport` (Queryable, Sortable)
|
||||||
|
- `location` (Queryable) - for radius searches
|
||||||
|
- `teamAbbrevs` (Queryable)
|
||||||
|
|
||||||
|
### Record Type: `Team`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `teamId` | String | Unique identifier |
|
||||||
|
| `name` | String | Full team name |
|
||||||
|
| `abbreviation` | String | 3-letter code |
|
||||||
|
| `sport` | String | NBA, MLB, NHL |
|
||||||
|
| `city` | String | City |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
- `sport` (Queryable, Sortable)
|
||||||
|
- `abbreviation` (Queryable)
|
||||||
|
|
||||||
|
### Record Type: `Game`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `gameId` | String | Unique identifier |
|
||||||
|
| `sport` | String | NBA, MLB, NHL |
|
||||||
|
| `season` | String | e.g., "2024-25" |
|
||||||
|
| `dateTime` | Date/Time | Game date and time |
|
||||||
|
| `homeTeamRef` | Reference | Reference to Team |
|
||||||
|
| `awayTeamRef` | Reference | Reference to Team |
|
||||||
|
| `venueRef` | Reference | Reference to Stadium |
|
||||||
|
| `isPlayoff` | Int(64) | 0 or 1 |
|
||||||
|
| `broadcastInfo` | String | TV channel |
|
||||||
|
| `source` | String | Data source |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
- `sport` (Queryable, Sortable)
|
||||||
|
- `dateTime` (Queryable, Sortable)
|
||||||
|
- `homeTeamRef` (Queryable)
|
||||||
|
- `awayTeamRef` (Queryable)
|
||||||
|
- `season` (Queryable)
|
||||||
|
|
||||||
|
## 4. Import Data
|
||||||
|
|
||||||
|
After creating record types:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. First scrape the data
|
||||||
|
cd Scripts
|
||||||
|
python3 scrape_schedules.py --sport all --season 2025 --output ./data
|
||||||
|
|
||||||
|
# 2. Run the import script (requires running from Xcode or with proper entitlements)
|
||||||
|
# The Swift script cannot run standalone - use the app or create a macOS command-line tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative: Import via App
|
||||||
|
|
||||||
|
Add this to your app for first-run data import:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// In AppDelegate or App init
|
||||||
|
Task {
|
||||||
|
let importer = CloudKitImporter()
|
||||||
|
|
||||||
|
// Load JSON from bundle or downloaded file
|
||||||
|
if let stadiumsURL = Bundle.main.url(forResource: "stadiums", withExtension: "json"),
|
||||||
|
let gamesURL = Bundle.main.url(forResource: "games", withExtension: "json") {
|
||||||
|
// Import stadiums first
|
||||||
|
let stadiumsData = try Data(contentsOf: stadiumsURL)
|
||||||
|
let stadiums = try JSONDecoder().decode([ScrapedStadium].self, from: stadiumsData)
|
||||||
|
let count = try await importer.importStadiums(from: stadiums)
|
||||||
|
print("Imported \(count) stadiums")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Security Roles (CloudKit Dashboard)
|
||||||
|
|
||||||
|
For the **Public Database**:
|
||||||
|
|
||||||
|
| Role | Stadium | Team | Game |
|
||||||
|
|------|---------|------|------|
|
||||||
|
| World | Read | Read | Read |
|
||||||
|
| Authenticated | Read | Read | Read |
|
||||||
|
| Creator | Read/Write | Read/Write | Read/Write |
|
||||||
|
|
||||||
|
Users should only read from public database. Write access is for your admin imports.
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
1. Build and run the app on simulator or device
|
||||||
|
2. Check CloudKit Dashboard > **Data** to see imported records
|
||||||
|
3. Use **Logs** tab to debug any issues
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Container not found"
|
||||||
|
- Ensure container is created in Developer Portal
|
||||||
|
- Check entitlements file has correct container ID
|
||||||
|
- Clean build and re-run
|
||||||
|
|
||||||
|
### "Permission denied"
|
||||||
|
- Check Security Roles in CloudKit Dashboard
|
||||||
|
- Ensure app is signed with correct provisioning profile
|
||||||
|
|
||||||
|
### "Record type not found"
|
||||||
|
- Create record types in Development environment first
|
||||||
|
- Deploy schema to Production when ready
|
||||||
72
Scripts/DATA_SOURCES.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Sports Data Sources
|
||||||
|
|
||||||
|
## Schedule Data Sources (by league)
|
||||||
|
|
||||||
|
### NBA Schedule
|
||||||
|
| Source | URL Pattern | Data Available | Notes |
|
||||||
|
|--------|-------------|----------------|-------|
|
||||||
|
| Basketball-Reference | `https://www.basketball-reference.com/leagues/NBA_{YEAR}_games-{month}.html` | Date, Time, Teams, Arena, Attendance | Monthly pages (october, november, etc.) |
|
||||||
|
| ESPN | `https://www.espn.com/nba/schedule/_/date/{YYYYMMDD}` | Date, Time, Teams, TV | Daily schedule |
|
||||||
|
| NBA.com API | `https://cdn.nba.com/static/json/staticData/scheduleLeagueV2.json` | Full season JSON | Official source |
|
||||||
|
| FixtureDownload | `https://fixturedownload.com/download/nba-{year}-UTC.csv` | CSV download | Easy format |
|
||||||
|
|
||||||
|
### MLB Schedule
|
||||||
|
| Source | URL Pattern | Data Available | Notes |
|
||||||
|
|--------|-------------|----------------|-------|
|
||||||
|
| Baseball-Reference | `https://www.baseball-reference.com/leagues/majors/{YEAR}-schedule.shtml` | Date, Teams, Score, Attendance | Full season page |
|
||||||
|
| ESPN | `https://www.espn.com/mlb/schedule/_/date/{YYYYMMDD}` | Date, Time, Teams, TV | Daily schedule |
|
||||||
|
| MLB Stats API | `https://statsapi.mlb.com/api/v1/schedule?sportId=1&season={YEAR}` | Full season JSON | Official API |
|
||||||
|
| FixtureDownload | `https://fixturedownload.com/download/mlb-{year}-UTC.csv` | CSV download | Easy format |
|
||||||
|
|
||||||
|
### NHL Schedule
|
||||||
|
| Source | URL Pattern | Data Available | Notes |
|
||||||
|
|--------|-------------|----------------|-------|
|
||||||
|
| Hockey-Reference | `https://www.hockey-reference.com/leagues/NHL_{YEAR}_games.html` | Date, Teams, Score, Arena, Attendance | Full season page |
|
||||||
|
| ESPN | `https://www.espn.com/nhl/schedule/_/date/{YYYYMMDD}` | Date, Time, Teams, TV | Daily schedule |
|
||||||
|
| NHL API | `https://api-web.nhle.com/v1/schedule/{YYYY-MM-DD}` | Daily JSON | Official API |
|
||||||
|
| FixtureDownload | `https://fixturedownload.com/download/nhl-{year}-UTC.csv` | CSV download | Easy format |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stadium/Arena Data Sources
|
||||||
|
|
||||||
|
| Source | URL/Method | Data Available | Notes |
|
||||||
|
|--------|------------|----------------|-------|
|
||||||
|
| Wikipedia | Team pages | Name, City, Capacity, Coordinates | Manual or scrape |
|
||||||
|
| HIFLD Open Data | `https://hifld-geoplatform.opendata.arcgis.com/datasets/major-sport-venues` | GeoJSON with coordinates | US Government data |
|
||||||
|
| ESPN Team Pages | `https://www.espn.com/{sport}/team/_/name/{abbrev}` | Arena name, location | Per-team |
|
||||||
|
| Sports-Reference | Team pages | Arena name, capacity | In schedule data |
|
||||||
|
| OpenStreetMap | Nominatim API | Coordinates from address | For geocoding |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Validation Strategy
|
||||||
|
|
||||||
|
### Cross-Reference Points
|
||||||
|
1. **Game Count**: Total games per team should match (82 NBA, 162 MLB, 82 NHL)
|
||||||
|
2. **Home/Away Balance**: Each team should have equal home/away games
|
||||||
|
3. **Date Alignment**: Same game should appear on same date across sources
|
||||||
|
4. **Team Names**: Map abbreviations across sources (NYK vs NY vs Knicks)
|
||||||
|
5. **Venue Names**: Stadiums may have different names (sponsorship changes)
|
||||||
|
|
||||||
|
### Discrepancy Handling
|
||||||
|
- If sources disagree on game time: prefer official API (NBA.com, MLB.com, NHL.com)
|
||||||
|
- If sources disagree on venue: prefer Sports-Reference (most accurate historically)
|
||||||
|
- Log all discrepancies for manual review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting Guidelines
|
||||||
|
|
||||||
|
| Source | Limit | Recommended Delay |
|
||||||
|
|--------|-------|-------------------|
|
||||||
|
| Sports-Reference sites | 20 req/min | 3 seconds between requests |
|
||||||
|
| ESPN | Unknown | 1 second between requests |
|
||||||
|
| Official APIs | Varies | 0.5 seconds between requests |
|
||||||
|
| Wikipedia | Polite | 1 second between requests |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Team Abbreviation Mappings
|
||||||
|
|
||||||
|
See `team_mappings.json` for canonical mappings between sources.
|
||||||
306
Scripts/cloudkit_import.py
Executable file
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
CloudKit Import Script
|
||||||
|
======================
|
||||||
|
Imports JSON data into CloudKit. Run separately from pipeline.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
1. CloudKit Dashboard > Tokens & Keys > Server-to-Server Keys
|
||||||
|
2. Create key with Read/Write access to public database
|
||||||
|
3. Download .p8 file and note Key ID
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python cloudkit_import.py --dry-run # Preview first
|
||||||
|
python cloudkit_import.py --key-id XX --key-file key.p8 # Import all
|
||||||
|
python cloudkit_import.py --stadiums-only ... # Stadiums first
|
||||||
|
python cloudkit_import.py --games-only ... # Games after
|
||||||
|
python cloudkit_import.py --delete-all ... # Delete then import
|
||||||
|
python cloudkit_import.py --delete-only ... # Delete only (no import)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse, json, time, os, sys, hashlib, base64, requests
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
HAS_CRYPTO = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_CRYPTO = False
|
||||||
|
|
||||||
|
CONTAINER = "iCloud.com.sportstime.app"
|
||||||
|
HOST = "https://api.apple-cloudkit.com"
|
||||||
|
BATCH_SIZE = 200
|
||||||
|
|
||||||
|
|
||||||
|
class CloudKit:
|
||||||
|
def __init__(self, key_id, private_key, container, env):
|
||||||
|
self.key_id = key_id
|
||||||
|
self.private_key = private_key
|
||||||
|
self.path_base = f"/database/1/{container}/{env}/public"
|
||||||
|
|
||||||
|
def _sign(self, date, body, path):
|
||||||
|
key = serialization.load_pem_private_key(self.private_key, None, default_backend())
|
||||||
|
body_hash = base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()
|
||||||
|
sig = key.sign(f"{date}:{body_hash}:{path}".encode(), ec.ECDSA(hashes.SHA256()))
|
||||||
|
return base64.b64encode(sig).decode()
|
||||||
|
|
||||||
|
def modify(self, operations):
|
||||||
|
path = f"{self.path_base}/records/modify"
|
||||||
|
body = json.dumps({'operations': operations})
|
||||||
|
date = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Apple-CloudKit-Request-KeyID': self.key_id,
|
||||||
|
'X-Apple-CloudKit-Request-ISO8601Date': date,
|
||||||
|
'X-Apple-CloudKit-Request-SignatureV1': self._sign(date, body, path),
|
||||||
|
}
|
||||||
|
r = requests.post(f"{HOST}{path}", headers=headers, data=body, timeout=60)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
err = r.json()
|
||||||
|
reason = err.get('reason', 'Unknown')
|
||||||
|
code = err.get('serverErrorCode', r.status_code)
|
||||||
|
return {'error': f"{code}: {reason}"}
|
||||||
|
except:
|
||||||
|
return {'error': f"{r.status_code}: {r.text[:200]}"}
|
||||||
|
|
||||||
|
def query(self, record_type, limit=200):
|
||||||
|
"""Query records of a given type."""
|
||||||
|
path = f"{self.path_base}/records/query"
|
||||||
|
body = json.dumps({
|
||||||
|
'query': {'recordType': record_type},
|
||||||
|
'resultsLimit': limit
|
||||||
|
})
|
||||||
|
date = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Apple-CloudKit-Request-KeyID': self.key_id,
|
||||||
|
'X-Apple-CloudKit-Request-ISO8601Date': date,
|
||||||
|
'X-Apple-CloudKit-Request-SignatureV1': self._sign(date, body, path),
|
||||||
|
}
|
||||||
|
r = requests.post(f"{HOST}{path}", headers=headers, data=body, timeout=60)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()
|
||||||
|
return {'error': f"{r.status_code}: {r.text[:200]}"}
|
||||||
|
|
||||||
|
def delete_all(self, record_type, verbose=False):
|
||||||
|
"""Delete all records of a given type."""
|
||||||
|
total_deleted = 0
|
||||||
|
while True:
|
||||||
|
result = self.query(record_type)
|
||||||
|
if 'error' in result:
|
||||||
|
print(f" Query error: {result['error']}")
|
||||||
|
break
|
||||||
|
|
||||||
|
records = result.get('records', [])
|
||||||
|
if not records:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Build delete operations
|
||||||
|
ops = [{
|
||||||
|
'operationType': 'delete',
|
||||||
|
'record': {'recordName': r['recordName'], 'recordType': record_type}
|
||||||
|
} for r in records]
|
||||||
|
|
||||||
|
delete_result = self.modify(ops)
|
||||||
|
if 'error' in delete_result:
|
||||||
|
print(f" Delete error: {delete_result['error']}")
|
||||||
|
break
|
||||||
|
|
||||||
|
deleted = len(delete_result.get('records', []))
|
||||||
|
total_deleted += deleted
|
||||||
|
if verbose:
|
||||||
|
print(f" Deleted {deleted} {record_type} records...")
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
return total_deleted
|
||||||
|
|
||||||
|
|
||||||
|
def import_data(ck, records, name, dry_run, verbose):
|
||||||
|
total = 0
|
||||||
|
errors = 0
|
||||||
|
for i in range(0, len(records), BATCH_SIZE):
|
||||||
|
batch = records[i:i+BATCH_SIZE]
|
||||||
|
ops = [{'operationType': 'forceReplace', 'record': r} for r in batch]
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(f" Batch {i//BATCH_SIZE + 1}: {len(batch)} records, {len(ops)} ops")
|
||||||
|
|
||||||
|
if not ops:
|
||||||
|
print(f" Warning: Empty batch at index {i}, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f" [DRY RUN] Would create {len(batch)} {name}")
|
||||||
|
total += len(batch)
|
||||||
|
else:
|
||||||
|
result = ck.modify(ops)
|
||||||
|
if 'error' in result:
|
||||||
|
errors += 1
|
||||||
|
if errors <= 3: # Only show first 3 errors
|
||||||
|
print(f" Error: {result['error']}")
|
||||||
|
if verbose and batch:
|
||||||
|
print(f" Sample record: {json.dumps(batch[0], indent=2)[:500]}")
|
||||||
|
if errors == 3:
|
||||||
|
print(" (suppressing further errors...)")
|
||||||
|
else:
|
||||||
|
result_records = result.get('records', [])
|
||||||
|
# Count only successful records (no serverErrorCode)
|
||||||
|
successful = [r for r in result_records if 'serverErrorCode' not in r]
|
||||||
|
failed = [r for r in result_records if 'serverErrorCode' in r]
|
||||||
|
n = len(successful)
|
||||||
|
total += n
|
||||||
|
print(f" Created {n} {name}")
|
||||||
|
if failed:
|
||||||
|
print(f" Failed {len(failed)} records: {failed[0].get('serverErrorCode')}: {failed[0].get('reason')}")
|
||||||
|
if verbose:
|
||||||
|
print(f" Response: {json.dumps(result, indent=2)[:1000]}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
if errors > 0:
|
||||||
|
print(f" Total errors: {errors}")
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description='Import JSON to CloudKit')
|
||||||
|
p.add_argument('--key-id', default=os.environ.get('CLOUDKIT_KEY_ID'))
|
||||||
|
p.add_argument('--key-file', default=os.environ.get('CLOUDKIT_KEY_FILE'))
|
||||||
|
p.add_argument('--container', default=CONTAINER)
|
||||||
|
p.add_argument('--env', choices=['development', 'production'], default='development')
|
||||||
|
p.add_argument('--data-dir', default='./data')
|
||||||
|
p.add_argument('--stadiums-only', action='store_true')
|
||||||
|
p.add_argument('--games-only', action='store_true')
|
||||||
|
p.add_argument('--delete-all', action='store_true', help='Delete all records before importing')
|
||||||
|
p.add_argument('--delete-only', action='store_true', help='Only delete records, do not import')
|
||||||
|
p.add_argument('--dry-run', action='store_true')
|
||||||
|
p.add_argument('--verbose', '-v', action='store_true')
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"CloudKit Import {'(DRY RUN)' if args.dry_run else ''}")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
print(f"Container: {args.container}")
|
||||||
|
print(f"Environment: {args.env}\n")
|
||||||
|
|
||||||
|
data_dir = Path(args.data_dir)
|
||||||
|
stadiums = json.load(open(data_dir / 'stadiums.json'))
|
||||||
|
games = json.load(open(data_dir / 'games.json')) if (data_dir / 'games.json').exists() else []
|
||||||
|
print(f"Loaded {len(stadiums)} stadiums, {len(games)} games\n")
|
||||||
|
|
||||||
|
ck = None
|
||||||
|
if not args.dry_run:
|
||||||
|
if not HAS_CRYPTO:
|
||||||
|
sys.exit("Error: pip install cryptography")
|
||||||
|
if not args.key_id or not args.key_file:
|
||||||
|
sys.exit("Error: --key-id and --key-file required (or use --dry-run)")
|
||||||
|
ck = CloudKit(args.key_id, open(args.key_file, 'rb').read(), args.container, args.env)
|
||||||
|
|
||||||
|
# Handle deletion
|
||||||
|
if args.delete_all or args.delete_only:
|
||||||
|
if not ck:
|
||||||
|
sys.exit("Error: --key-id and --key-file required for deletion")
|
||||||
|
|
||||||
|
print("--- Deleting Existing Records ---")
|
||||||
|
# Delete in order: Games first (has references), then Teams, then Stadiums
|
||||||
|
for record_type in ['Game', 'Team', 'Stadium']:
|
||||||
|
print(f" Deleting {record_type} records...")
|
||||||
|
deleted = ck.delete_all(record_type, verbose=args.verbose)
|
||||||
|
print(f" Deleted {deleted} {record_type} records")
|
||||||
|
|
||||||
|
if args.delete_only:
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print("DELETE COMPLETE")
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = {'stadiums': 0, 'teams': 0, 'games': 0}
|
||||||
|
team_map = {}
|
||||||
|
|
||||||
|
# Import stadiums & teams
|
||||||
|
if not args.games_only:
|
||||||
|
print("--- Stadiums ---")
|
||||||
|
recs = [{
|
||||||
|
'recordType': 'Stadium', 'recordName': s['id'],
|
||||||
|
'fields': {
|
||||||
|
'stadiumId': {'value': s['id']}, 'name': {'value': s['name']},
|
||||||
|
'city': {'value': s['city']}, 'state': {'value': s.get('state', '')},
|
||||||
|
'sport': {'value': s['sport']}, 'source': {'value': s.get('source', '')},
|
||||||
|
'teamAbbrevs': {'value': s.get('team_abbrevs', [])},
|
||||||
|
**({'location': {'value': {'latitude': s['latitude'], 'longitude': s['longitude']}}}
|
||||||
|
if s.get('latitude') else {}),
|
||||||
|
**({'capacity': {'value': s['capacity']}} if s.get('capacity') else {}),
|
||||||
|
}
|
||||||
|
} for s in stadiums]
|
||||||
|
stats['stadiums'] = import_data(ck, recs, 'stadiums', args.dry_run, args.verbose)
|
||||||
|
|
||||||
|
print("--- Teams ---")
|
||||||
|
teams = {}
|
||||||
|
for s in stadiums:
|
||||||
|
for abbr in s.get('team_abbrevs', []):
|
||||||
|
if abbr not in teams:
|
||||||
|
teams[abbr] = {'city': s['city'], 'sport': s['sport']}
|
||||||
|
team_map[abbr] = f"team_{abbr.lower()}"
|
||||||
|
|
||||||
|
recs = [{
|
||||||
|
'recordType': 'Team', 'recordName': f"team_{abbr.lower()}",
|
||||||
|
'fields': {
|
||||||
|
'teamId': {'value': f"team_{abbr.lower()}"}, 'abbreviation': {'value': abbr},
|
||||||
|
'name': {'value': abbr}, 'city': {'value': info['city']}, 'sport': {'value': info['sport']},
|
||||||
|
}
|
||||||
|
} for abbr, info in teams.items()]
|
||||||
|
stats['teams'] = import_data(ck, recs, 'teams', args.dry_run, args.verbose)
|
||||||
|
|
||||||
|
# Import games
|
||||||
|
if not args.stadiums_only and games:
|
||||||
|
if not team_map:
|
||||||
|
for s in stadiums:
|
||||||
|
for abbr in s.get('team_abbrevs', []):
|
||||||
|
team_map[abbr] = f"team_{abbr.lower()}"
|
||||||
|
|
||||||
|
print("--- Games ---")
|
||||||
|
|
||||||
|
# Deduplicate games by ID
|
||||||
|
seen_ids = set()
|
||||||
|
unique_games = []
|
||||||
|
for g in games:
|
||||||
|
if g['id'] not in seen_ids:
|
||||||
|
seen_ids.add(g['id'])
|
||||||
|
unique_games.append(g)
|
||||||
|
|
||||||
|
if len(unique_games) < len(games):
|
||||||
|
print(f" Removed {len(games) - len(unique_games)} duplicate games")
|
||||||
|
|
||||||
|
recs = []
|
||||||
|
for g in unique_games:
|
||||||
|
fields = {
|
||||||
|
'gameId': {'value': g['id']}, 'sport': {'value': g['sport']},
|
||||||
|
'season': {'value': g.get('season', '')}, 'source': {'value': g.get('source', '')},
|
||||||
|
}
|
||||||
|
if g.get('date'):
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(f"{g['date']} {g.get('time', '19:00')}", '%Y-%m-%d %H:%M')
|
||||||
|
fields['dateTime'] = {'value': int(dt.timestamp() * 1000)}
|
||||||
|
except: pass
|
||||||
|
if g.get('home_team_abbrev') in team_map:
|
||||||
|
fields['homeTeamRef'] = {'value': {'recordName': team_map[g['home_team_abbrev']], 'action': 'NONE'}}
|
||||||
|
if g.get('away_team_abbrev') in team_map:
|
||||||
|
fields['awayTeamRef'] = {'value': {'recordName': team_map[g['away_team_abbrev']], 'action': 'NONE'}}
|
||||||
|
recs.append({'recordType': 'Game', 'recordName': g['id'], 'fields': fields})
|
||||||
|
|
||||||
|
stats['games'] = import_data(ck, recs, 'games', args.dry_run, args.verbose)
|
||||||
|
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"COMPLETE: {stats['stadiums']} stadiums, {stats['teams']} teams, {stats['games']} games")
|
||||||
|
if args.dry_run:
|
||||||
|
print("[DRY RUN - nothing imported]")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
53
Scripts/cloudkit_schema.ckdb
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
DEFINE SCHEMA
|
||||||
|
|
||||||
|
RECORD TYPE Stadium (
|
||||||
|
"___createTime" TIMESTAMP,
|
||||||
|
"___createdBy" REFERENCE,
|
||||||
|
"___etag" STRING,
|
||||||
|
"___modTime" TIMESTAMP,
|
||||||
|
"___modifiedBy" REFERENCE,
|
||||||
|
"___recordID" REFERENCE QUERYABLE,
|
||||||
|
stadiumId STRING QUERYABLE,
|
||||||
|
name STRING QUERYABLE SEARCHABLE,
|
||||||
|
city STRING QUERYABLE,
|
||||||
|
state STRING,
|
||||||
|
location LOCATION QUERYABLE,
|
||||||
|
capacity INT64,
|
||||||
|
sport STRING QUERYABLE SORTABLE,
|
||||||
|
teamAbbrevs LIST<STRING>,
|
||||||
|
source STRING,
|
||||||
|
yearOpened INT64
|
||||||
|
);
|
||||||
|
|
||||||
|
RECORD TYPE Team (
|
||||||
|
"___createTime" TIMESTAMP,
|
||||||
|
"___createdBy" REFERENCE,
|
||||||
|
"___etag" STRING,
|
||||||
|
"___modTime" TIMESTAMP,
|
||||||
|
"___modifiedBy" REFERENCE,
|
||||||
|
"___recordID" REFERENCE QUERYABLE,
|
||||||
|
teamId STRING QUERYABLE,
|
||||||
|
name STRING QUERYABLE SEARCHABLE,
|
||||||
|
abbreviation STRING QUERYABLE,
|
||||||
|
city STRING QUERYABLE,
|
||||||
|
sport STRING QUERYABLE SORTABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
RECORD TYPE Game (
|
||||||
|
"___createTime" TIMESTAMP,
|
||||||
|
"___createdBy" REFERENCE,
|
||||||
|
"___etag" STRING,
|
||||||
|
"___modTime" TIMESTAMP,
|
||||||
|
"___modifiedBy" REFERENCE,
|
||||||
|
"___recordID" REFERENCE QUERYABLE,
|
||||||
|
gameId STRING QUERYABLE,
|
||||||
|
sport STRING QUERYABLE SORTABLE,
|
||||||
|
season STRING QUERYABLE,
|
||||||
|
dateTime TIMESTAMP QUERYABLE SORTABLE,
|
||||||
|
homeTeamRef REFERENCE QUERYABLE,
|
||||||
|
awayTeamRef REFERENCE QUERYABLE,
|
||||||
|
venueRef REFERENCE,
|
||||||
|
isPlayoff INT64,
|
||||||
|
broadcastInfo STRING,
|
||||||
|
source STRING
|
||||||
|
);
|
||||||
5098
Scripts/data/games.csv
Normal file
76457
Scripts/data/games.json
Normal file
1620
Scripts/data/pipeline_report.json
Normal file
93
Scripts/data/stadiums.csv
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
id,name,city,state,latitude,longitude,capacity,sport,team_abbrevs,source,year_opened
|
||||||
|
manual_nba_atl,State Farm Arena,Atlanta,,33.7573,-84.3963,0,NBA,['ATL'],manual,
|
||||||
|
manual_nba_bos,TD Garden,Boston,,42.3662,-71.0621,0,NBA,['BOS'],manual,
|
||||||
|
manual_nba_brk,Barclays Center,Brooklyn,,40.6826,-73.9754,0,NBA,['BRK'],manual,
|
||||||
|
manual_nba_cho,Spectrum Center,Charlotte,,35.2251,-80.8392,0,NBA,['CHO'],manual,
|
||||||
|
manual_nba_chi,United Center,Chicago,,41.8807,-87.6742,0,NBA,['CHI'],manual,
|
||||||
|
manual_nba_cle,Rocket Mortgage FieldHouse,Cleveland,,41.4965,-81.6882,0,NBA,['CLE'],manual,
|
||||||
|
manual_nba_dal,American Airlines Center,Dallas,,32.7905,-96.8103,0,NBA,['DAL'],manual,
|
||||||
|
manual_nba_den,Ball Arena,Denver,,39.7487,-105.0077,0,NBA,['DEN'],manual,
|
||||||
|
manual_nba_det,Little Caesars Arena,Detroit,,42.3411,-83.0553,0,NBA,['DET'],manual,
|
||||||
|
manual_nba_gsw,Chase Center,San Francisco,,37.768,-122.3879,0,NBA,['GSW'],manual,
|
||||||
|
manual_nba_hou,Toyota Center,Houston,,29.7508,-95.3621,0,NBA,['HOU'],manual,
|
||||||
|
manual_nba_ind,Gainbridge Fieldhouse,Indianapolis,,39.764,-86.1555,0,NBA,['IND'],manual,
|
||||||
|
manual_nba_lac,Intuit Dome,Inglewood,,33.9425,-118.3419,0,NBA,['LAC'],manual,
|
||||||
|
manual_nba_lal,Crypto.com Arena,Los Angeles,,34.043,-118.2673,0,NBA,['LAL'],manual,
|
||||||
|
manual_nba_mem,FedExForum,Memphis,,35.1382,-90.0506,0,NBA,['MEM'],manual,
|
||||||
|
manual_nba_mia,Kaseya Center,Miami,,25.7814,-80.187,0,NBA,['MIA'],manual,
|
||||||
|
manual_nba_mil,Fiserv Forum,Milwaukee,,43.0451,-87.9174,0,NBA,['MIL'],manual,
|
||||||
|
manual_nba_min,Target Center,Minneapolis,,44.9795,-93.2761,0,NBA,['MIN'],manual,
|
||||||
|
manual_nba_nop,Smoothie King Center,New Orleans,,29.949,-90.0821,0,NBA,['NOP'],manual,
|
||||||
|
manual_nba_nyk,Madison Square Garden,New York,,40.7505,-73.9934,0,NBA,['NYK'],manual,
|
||||||
|
manual_nba_okc,Paycom Center,Oklahoma City,,35.4634,-97.5151,0,NBA,['OKC'],manual,
|
||||||
|
manual_nba_orl,Kia Center,Orlando,,28.5392,-81.3839,0,NBA,['ORL'],manual,
|
||||||
|
manual_nba_phi,Wells Fargo Center,Philadelphia,,39.9012,-75.172,0,NBA,['PHI'],manual,
|
||||||
|
manual_nba_pho,Footprint Center,Phoenix,,33.4457,-112.0712,0,NBA,['PHO'],manual,
|
||||||
|
manual_nba_por,Moda Center,Portland,,45.5316,-122.6668,0,NBA,['POR'],manual,
|
||||||
|
manual_nba_sac,Golden 1 Center,Sacramento,,38.5802,-121.4997,0,NBA,['SAC'],manual,
|
||||||
|
manual_nba_sas,Frost Bank Center,San Antonio,,29.427,-98.4375,0,NBA,['SAS'],manual,
|
||||||
|
manual_nba_tor,Scotiabank Arena,Toronto,,43.6435,-79.3791,0,NBA,['TOR'],manual,
|
||||||
|
manual_nba_uta,Delta Center,Salt Lake City,,40.7683,-111.9011,0,NBA,['UTA'],manual,
|
||||||
|
manual_nba_was,Capital One Arena,Washington,,38.8982,-77.0209,0,NBA,['WAS'],manual,
|
||||||
|
manual_mlb_ari,Chase Field,Phoenix,AZ,33.4453,-112.0667,48686,MLB,['ARI'],manual,
|
||||||
|
manual_mlb_atl,Truist Park,Atlanta,GA,33.8907,-84.4678,41084,MLB,['ATL'],manual,
|
||||||
|
manual_mlb_bal,Oriole Park at Camden Yards,Baltimore,MD,39.2838,-76.6218,45971,MLB,['BAL'],manual,
|
||||||
|
manual_mlb_bos,Fenway Park,Boston,MA,42.3467,-71.0972,37755,MLB,['BOS'],manual,
|
||||||
|
manual_mlb_chc,Wrigley Field,Chicago,IL,41.9484,-87.6553,41649,MLB,['CHC'],manual,
|
||||||
|
manual_mlb_chw,Guaranteed Rate Field,Chicago,IL,41.8299,-87.6338,40615,MLB,['CHW'],manual,
|
||||||
|
manual_mlb_cin,Great American Ball Park,Cincinnati,OH,39.0979,-84.5082,42319,MLB,['CIN'],manual,
|
||||||
|
manual_mlb_cle,Progressive Field,Cleveland,OH,41.4962,-81.6852,34830,MLB,['CLE'],manual,
|
||||||
|
manual_mlb_col,Coors Field,Denver,CO,39.7559,-104.9942,50144,MLB,['COL'],manual,
|
||||||
|
manual_mlb_det,Comerica Park,Detroit,MI,42.339,-83.0485,41083,MLB,['DET'],manual,
|
||||||
|
manual_mlb_hou,Minute Maid Park,Houston,TX,29.7573,-95.3555,41168,MLB,['HOU'],manual,
|
||||||
|
manual_mlb_kcr,Kauffman Stadium,Kansas City,MO,39.0517,-94.4803,37903,MLB,['KCR'],manual,
|
||||||
|
manual_mlb_laa,Angel Stadium,Anaheim,CA,33.8003,-117.8827,45517,MLB,['LAA'],manual,
|
||||||
|
manual_mlb_lad,Dodger Stadium,Los Angeles,CA,34.0739,-118.24,56000,MLB,['LAD'],manual,
|
||||||
|
manual_mlb_mia,LoanDepot Park,Miami,FL,25.7781,-80.2196,36742,MLB,['MIA'],manual,
|
||||||
|
manual_mlb_mil,American Family Field,Milwaukee,WI,43.028,-87.9712,41900,MLB,['MIL'],manual,
|
||||||
|
manual_mlb_min,Target Field,Minneapolis,MN,44.9817,-93.2776,38544,MLB,['MIN'],manual,
|
||||||
|
manual_mlb_nym,Citi Field,New York,NY,40.7571,-73.8458,41922,MLB,['NYM'],manual,
|
||||||
|
manual_mlb_nyy,Yankee Stadium,New York,NY,40.8296,-73.9262,46537,MLB,['NYY'],manual,
|
||||||
|
manual_mlb_oak,Sutter Health Park,Sacramento,CA,38.5802,-121.5097,14014,MLB,['OAK'],manual,
|
||||||
|
manual_mlb_phi,Citizens Bank Park,Philadelphia,PA,39.9061,-75.1665,42792,MLB,['PHI'],manual,
|
||||||
|
manual_mlb_pit,PNC Park,Pittsburgh,PA,40.4469,-80.0057,38362,MLB,['PIT'],manual,
|
||||||
|
manual_mlb_sdp,Petco Park,San Diego,CA,32.7076,-117.157,40209,MLB,['SDP'],manual,
|
||||||
|
manual_mlb_sfg,Oracle Park,San Francisco,CA,37.7786,-122.3893,41265,MLB,['SFG'],manual,
|
||||||
|
manual_mlb_sea,T-Mobile Park,Seattle,WA,47.5914,-122.3325,47929,MLB,['SEA'],manual,
|
||||||
|
manual_mlb_stl,Busch Stadium,St. Louis,MO,38.6226,-90.1928,45494,MLB,['STL'],manual,
|
||||||
|
manual_mlb_tbr,Tropicana Field,St. Petersburg,FL,27.7682,-82.6534,25000,MLB,['TBR'],manual,
|
||||||
|
manual_mlb_tex,Globe Life Field,Arlington,TX,32.7473,-97.0845,40300,MLB,['TEX'],manual,
|
||||||
|
manual_mlb_tor,Rogers Centre,Toronto,ON,43.6414,-79.3894,49282,MLB,['TOR'],manual,
|
||||||
|
manual_mlb_wsn,Nationals Park,Washington,DC,38.873,-77.0074,41339,MLB,['WSN'],manual,
|
||||||
|
manual_nhl_ana,Honda Center,Anaheim,CA,33.8078,-117.8765,17174,NHL,['ANA'],manual,
|
||||||
|
manual_nhl_ari,Delta Center,Salt Lake City,UT,40.7683,-111.9011,18306,NHL,['ARI'],manual,
|
||||||
|
manual_nhl_bos,TD Garden,Boston,MA,42.3662,-71.0621,17565,NHL,['BOS'],manual,
|
||||||
|
manual_nhl_buf,KeyBank Center,Buffalo,NY,42.875,-78.8764,19070,NHL,['BUF'],manual,
|
||||||
|
manual_nhl_cgy,Scotiabank Saddledome,Calgary,AB,51.0374,-114.0519,19289,NHL,['CGY'],manual,
|
||||||
|
manual_nhl_car,PNC Arena,Raleigh,NC,35.8034,-78.722,18680,NHL,['CAR'],manual,
|
||||||
|
manual_nhl_chi,United Center,Chicago,IL,41.8807,-87.6742,19717,NHL,['CHI'],manual,
|
||||||
|
manual_nhl_col,Ball Arena,Denver,CO,39.7487,-105.0077,18007,NHL,['COL'],manual,
|
||||||
|
manual_nhl_cbj,Nationwide Arena,Columbus,OH,39.9693,-83.0061,18500,NHL,['CBJ'],manual,
|
||||||
|
manual_nhl_dal,American Airlines Center,Dallas,TX,32.7905,-96.8103,18532,NHL,['DAL'],manual,
|
||||||
|
manual_nhl_det,Little Caesars Arena,Detroit,MI,42.3411,-83.0553,19515,NHL,['DET'],manual,
|
||||||
|
manual_nhl_edm,Rogers Place,Edmonton,AB,53.5469,-113.4978,18347,NHL,['EDM'],manual,
|
||||||
|
manual_nhl_fla,Amerant Bank Arena,Sunrise,FL,26.1584,-80.3256,19250,NHL,['FLA'],manual,
|
||||||
|
manual_nhl_lak,Crypto.com Arena,Los Angeles,CA,34.043,-118.2673,18230,NHL,['LAK'],manual,
|
||||||
|
manual_nhl_min,Xcel Energy Center,St. Paul,MN,44.9448,-93.101,17954,NHL,['MIN'],manual,
|
||||||
|
manual_nhl_mtl,Bell Centre,Montreal,QC,45.4961,-73.5693,21302,NHL,['MTL'],manual,
|
||||||
|
manual_nhl_nsh,Bridgestone Arena,Nashville,TN,36.1592,-86.7785,17159,NHL,['NSH'],manual,
|
||||||
|
manual_nhl_njd,Prudential Center,Newark,NJ,40.7334,-74.1712,16514,NHL,['NJD'],manual,
|
||||||
|
manual_nhl_nyi,UBS Arena,Elmont,NY,40.7161,-73.7246,17255,NHL,['NYI'],manual,
|
||||||
|
manual_nhl_nyr,Madison Square Garden,New York,NY,40.7505,-73.9934,18006,NHL,['NYR'],manual,
|
||||||
|
manual_nhl_ott,Canadian Tire Centre,Ottawa,ON,45.2969,-75.9272,18652,NHL,['OTT'],manual,
|
||||||
|
manual_nhl_phi,Wells Fargo Center,Philadelphia,PA,39.9012,-75.172,19543,NHL,['PHI'],manual,
|
||||||
|
manual_nhl_pit,PPG Paints Arena,Pittsburgh,PA,40.4395,-79.9892,18387,NHL,['PIT'],manual,
|
||||||
|
manual_nhl_sjs,SAP Center,San Jose,CA,37.3327,-121.901,17562,NHL,['SJS'],manual,
|
||||||
|
manual_nhl_sea,Climate Pledge Arena,Seattle,WA,47.6221,-122.354,17100,NHL,['SEA'],manual,
|
||||||
|
manual_nhl_stl,Enterprise Center,St. Louis,MO,38.6268,-90.2025,18096,NHL,['STL'],manual,
|
||||||
|
manual_nhl_tbl,Amalie Arena,Tampa,FL,27.9426,-82.4519,19092,NHL,['TBL'],manual,
|
||||||
|
manual_nhl_tor,Scotiabank Arena,Toronto,ON,43.6435,-79.3791,18819,NHL,['TOR'],manual,
|
||||||
|
manual_nhl_van,Rogers Arena,Vancouver,BC,49.2778,-123.1089,18910,NHL,['VAN'],manual,
|
||||||
|
manual_nhl_vgk,T-Mobile Arena,Las Vegas,NV,36.1028,-115.1784,17500,NHL,['VGK'],manual,
|
||||||
|
manual_nhl_wsh,Capital One Arena,Washington,DC,38.8982,-77.0209,18573,NHL,['WSH'],manual,
|
||||||
|
manual_nhl_wpg,Canada Life Centre,Winnipeg,MB,49.8928,-97.1436,15321,NHL,['WPG'],manual,
|
||||||
|
1382
Scripts/data/stadiums.json
Normal file
1425
Scripts/data/validation_report.json
Normal file
275
Scripts/import_to_cloudkit.swift
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env swift
|
||||||
|
//
|
||||||
|
// import_to_cloudkit.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Imports scraped JSON data into CloudKit public database.
|
||||||
|
// Run from command line: swift import_to_cloudkit.swift --games data/games.json --stadiums data/stadiums.json
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CloudKit
|
||||||
|
|
||||||
|
// MARK: - Data Models (matching scraper output)
|
||||||
|
|
||||||
|
struct ScrapedGame: 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?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrapedStadium: 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: - CloudKit Importer
|
||||||
|
|
||||||
|
class CloudKitImporter {
|
||||||
|
let container: CKContainer
|
||||||
|
let database: CKDatabase
|
||||||
|
|
||||||
|
init(containerIdentifier: String = "iCloud.com.sportstime.app") {
|
||||||
|
self.container = CKContainer(identifier: containerIdentifier)
|
||||||
|
self.database = container.publicCloudDatabase
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import Stadiums
|
||||||
|
|
||||||
|
func importStadiums(from stadiums: [ScrapedStadium]) async throws -> Int {
|
||||||
|
var imported = 0
|
||||||
|
|
||||||
|
for stadium in stadiums {
|
||||||
|
let record = CKRecord(recordType: "Stadium")
|
||||||
|
record["stadiumId"] = stadium.id
|
||||||
|
record["name"] = stadium.name
|
||||||
|
record["city"] = stadium.city
|
||||||
|
record["state"] = stadium.state
|
||||||
|
record["location"] = CLLocation(latitude: stadium.latitude, longitude: stadium.longitude)
|
||||||
|
record["capacity"] = stadium.capacity
|
||||||
|
record["sport"] = stadium.sport
|
||||||
|
record["teamAbbrevs"] = stadium.team_abbrevs
|
||||||
|
record["source"] = stadium.source
|
||||||
|
|
||||||
|
if let yearOpened = stadium.year_opened {
|
||||||
|
record["yearOpened"] = yearOpened
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await database.save(record)
|
||||||
|
imported += 1
|
||||||
|
print(" Imported stadium: \(stadium.name)")
|
||||||
|
} catch {
|
||||||
|
print(" Error importing \(stadium.name): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return imported
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import Teams
|
||||||
|
|
||||||
|
func importTeams(from stadiums: [ScrapedStadium], teamMappings: [String: TeamInfo]) async throws -> [String: CKRecord.ID] {
|
||||||
|
var teamRecordIDs: [String: CKRecord.ID] = [:]
|
||||||
|
|
||||||
|
for (abbrev, info) in teamMappings {
|
||||||
|
let record = CKRecord(recordType: "Team")
|
||||||
|
record["teamId"] = UUID().uuidString
|
||||||
|
record["name"] = info.name
|
||||||
|
record["abbreviation"] = abbrev
|
||||||
|
record["sport"] = info.sport
|
||||||
|
record["city"] = info.city
|
||||||
|
|
||||||
|
do {
|
||||||
|
let saved = try await database.save(record)
|
||||||
|
teamRecordIDs[abbrev] = saved.recordID
|
||||||
|
print(" Imported team: \(info.name)")
|
||||||
|
} catch {
|
||||||
|
print(" Error importing team \(info.name): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return teamRecordIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import Games
|
||||||
|
|
||||||
|
func importGames(
|
||||||
|
from games: [ScrapedGame],
|
||||||
|
teamRecordIDs: [String: CKRecord.ID],
|
||||||
|
stadiumRecordIDs: [String: CKRecord.ID]
|
||||||
|
) async throws -> Int {
|
||||||
|
var imported = 0
|
||||||
|
|
||||||
|
// Batch imports for efficiency
|
||||||
|
let batchSize = 100
|
||||||
|
var batch: [CKRecord] = []
|
||||||
|
|
||||||
|
for game in games {
|
||||||
|
let record = CKRecord(recordType: "Game")
|
||||||
|
record["gameId"] = game.id
|
||||||
|
record["sport"] = game.sport
|
||||||
|
record["season"] = game.season
|
||||||
|
|
||||||
|
// Parse date
|
||||||
|
let dateFormatter = DateFormatter()
|
||||||
|
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
if let date = dateFormatter.date(from: game.date) {
|
||||||
|
if let timeStr = game.time {
|
||||||
|
// Combine date and time
|
||||||
|
let timeFormatter = DateFormatter()
|
||||||
|
timeFormatter.dateFormat = "HH:mm"
|
||||||
|
if let time = timeFormatter.date(from: timeStr) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let timeComponents = calendar.dateComponents([.hour, .minute], from: time)
|
||||||
|
if let combined = calendar.date(bySettingHour: timeComponents.hour ?? 19,
|
||||||
|
minute: timeComponents.minute ?? 0,
|
||||||
|
second: 0, of: date) {
|
||||||
|
record["dateTime"] = combined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default to 7 PM if no time
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if let defaultTime = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: date) {
|
||||||
|
record["dateTime"] = defaultTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team references
|
||||||
|
if let homeTeamID = teamRecordIDs[game.home_team_abbrev] {
|
||||||
|
record["homeTeamRef"] = CKRecord.Reference(recordID: homeTeamID, action: .none)
|
||||||
|
}
|
||||||
|
if let awayTeamID = teamRecordIDs[game.away_team_abbrev] {
|
||||||
|
record["awayTeamRef"] = CKRecord.Reference(recordID: awayTeamID, action: .none)
|
||||||
|
}
|
||||||
|
|
||||||
|
record["isPlayoff"] = (game.is_playoff ?? false) ? 1 : 0
|
||||||
|
record["broadcastInfo"] = game.broadcast
|
||||||
|
record["source"] = game.source
|
||||||
|
|
||||||
|
batch.append(record)
|
||||||
|
|
||||||
|
// Save batch
|
||||||
|
if batch.count >= batchSize {
|
||||||
|
do {
|
||||||
|
let operation = CKModifyRecordsOperation(recordsToSave: batch, recordIDsToDelete: nil)
|
||||||
|
operation.savePolicy = .changedKeys
|
||||||
|
|
||||||
|
try await database.modifyRecords(saving: batch, deleting: [])
|
||||||
|
imported += batch.count
|
||||||
|
print(" Imported batch of \(batch.count) games (total: \(imported))")
|
||||||
|
batch.removeAll()
|
||||||
|
} catch {
|
||||||
|
print(" Error importing batch: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save remaining
|
||||||
|
if !batch.isEmpty {
|
||||||
|
do {
|
||||||
|
try await database.modifyRecords(saving: batch, deleting: [])
|
||||||
|
imported += batch.count
|
||||||
|
} catch {
|
||||||
|
print(" Error importing final batch: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return imported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Team Info
|
||||||
|
|
||||||
|
struct TeamInfo {
|
||||||
|
let name: String
|
||||||
|
let city: String
|
||||||
|
let sport: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main
|
||||||
|
|
||||||
|
func loadJSON<T: Codable>(from path: String) throws -> T {
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() async {
|
||||||
|
let args = CommandLine.arguments
|
||||||
|
|
||||||
|
guard args.count >= 3 else {
|
||||||
|
print("Usage: swift import_to_cloudkit.swift --games <path> --stadiums <path>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var gamesPath: String?
|
||||||
|
var stadiumsPath: String?
|
||||||
|
|
||||||
|
for i in 1..<args.count {
|
||||||
|
if args[i] == "--games" && i + 1 < args.count {
|
||||||
|
gamesPath = args[i + 1]
|
||||||
|
}
|
||||||
|
if args[i] == "--stadiums" && i + 1 < args.count {
|
||||||
|
stadiumsPath = args[i + 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let importer = CloudKitImporter()
|
||||||
|
|
||||||
|
// Import stadiums
|
||||||
|
if let path = stadiumsPath {
|
||||||
|
print("\n=== Importing Stadiums ===")
|
||||||
|
do {
|
||||||
|
let stadiums: [ScrapedStadium] = try loadJSON(from: path)
|
||||||
|
let count = try await importer.importStadiums(from: stadiums)
|
||||||
|
print("Imported \(count) stadiums")
|
||||||
|
} catch {
|
||||||
|
print("Error loading stadiums: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import games
|
||||||
|
if let path = gamesPath {
|
||||||
|
print("\n=== Importing Games ===")
|
||||||
|
do {
|
||||||
|
let games: [ScrapedGame] = try loadJSON(from: path)
|
||||||
|
// Note: Would need to first import teams and get their record IDs
|
||||||
|
// This is a simplified version
|
||||||
|
print("Loaded \(games.count) games for import")
|
||||||
|
} catch {
|
||||||
|
print("Error loading games: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n=== Import Complete ===")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run
|
||||||
|
Task {
|
||||||
|
await main()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the process running for async operations
|
||||||
|
RunLoop.main.run()
|
||||||
8
Scripts/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Sports Schedule Scraper Dependencies
|
||||||
|
requests>=2.28.0
|
||||||
|
beautifulsoup4>=4.11.0
|
||||||
|
pandas>=2.0.0
|
||||||
|
lxml>=4.9.0
|
||||||
|
|
||||||
|
# CloudKit Import (optional - only needed for cloudkit_import.py)
|
||||||
|
cryptography>=41.0.0
|
||||||
435
Scripts/run_pipeline.py
Executable file
@@ -0,0 +1,435 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SportsTime Data Pipeline
|
||||||
|
========================
|
||||||
|
Master script that orchestrates all data fetching, validation, and reporting.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python run_pipeline.py # Full pipeline with defaults
|
||||||
|
python run_pipeline.py --season 2026 # Specify season
|
||||||
|
python run_pipeline.py --sport nba # Single sport only
|
||||||
|
python run_pipeline.py --skip-scrape # Validate existing data only
|
||||||
|
python run_pipeline.py --verbose # Detailed output
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
# Import our modules
|
||||||
|
from scrape_schedules import (
|
||||||
|
Game, Stadium,
|
||||||
|
scrape_nba_basketball_reference,
|
||||||
|
scrape_mlb_statsapi, scrape_mlb_baseball_reference,
|
||||||
|
scrape_nhl_hockey_reference,
|
||||||
|
generate_stadiums_from_teams,
|
||||||
|
export_to_json,
|
||||||
|
assign_stable_ids,
|
||||||
|
)
|
||||||
|
from validate_data import (
|
||||||
|
validate_games,
|
||||||
|
validate_stadiums,
|
||||||
|
scrape_mlb_all_sources,
|
||||||
|
scrape_nba_all_sources,
|
||||||
|
scrape_nhl_all_sources,
|
||||||
|
ValidationReport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(Enum):
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineResult:
|
||||||
|
success: bool
|
||||||
|
games_scraped: int
|
||||||
|
stadiums_scraped: int
|
||||||
|
games_by_sport: dict
|
||||||
|
validation_reports: list
|
||||||
|
stadium_issues: list
|
||||||
|
high_severity_count: int
|
||||||
|
medium_severity_count: int
|
||||||
|
low_severity_count: int
|
||||||
|
output_dir: Path
|
||||||
|
duration_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
def print_header(text: str):
|
||||||
|
"""Print a formatted header."""
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(f" {text}")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
def print_section(text: str):
|
||||||
|
"""Print a section header."""
|
||||||
|
print()
|
||||||
|
print(f"--- {text} ---")
|
||||||
|
|
||||||
|
|
||||||
|
def print_severity(severity: str, message: str):
|
||||||
|
"""Print a message with severity indicator."""
|
||||||
|
icons = {
|
||||||
|
'high': '🔴 HIGH',
|
||||||
|
'medium': '🟡 MEDIUM',
|
||||||
|
'low': '🟢 LOW',
|
||||||
|
}
|
||||||
|
print(f" {icons.get(severity, '⚪')} {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline(
|
||||||
|
season: int = 2025,
|
||||||
|
sport: str = 'all',
|
||||||
|
output_dir: Path = Path('./data'),
|
||||||
|
skip_scrape: bool = False,
|
||||||
|
validate: bool = True,
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> PipelineResult:
|
||||||
|
"""
|
||||||
|
Run the complete data pipeline.
|
||||||
|
"""
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
all_games = []
|
||||||
|
all_stadiums = []
|
||||||
|
games_by_sport = {}
|
||||||
|
validation_reports = []
|
||||||
|
stadium_issues = []
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PHASE 1: SCRAPE DATA
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
if not skip_scrape:
|
||||||
|
print_header("PHASE 1: SCRAPING DATA")
|
||||||
|
|
||||||
|
# Scrape stadiums
|
||||||
|
print_section("Stadiums")
|
||||||
|
all_stadiums = generate_stadiums_from_teams()
|
||||||
|
print(f" Generated {len(all_stadiums)} stadiums from team data")
|
||||||
|
|
||||||
|
# Scrape by sport
|
||||||
|
if sport in ['nba', 'all']:
|
||||||
|
print_section(f"NBA {season}")
|
||||||
|
nba_games = scrape_nba_basketball_reference(season)
|
||||||
|
nba_season = f"{season-1}-{str(season)[2:]}"
|
||||||
|
nba_games = assign_stable_ids(nba_games, 'NBA', nba_season)
|
||||||
|
all_games.extend(nba_games)
|
||||||
|
games_by_sport['NBA'] = len(nba_games)
|
||||||
|
|
||||||
|
if sport in ['mlb', 'all']:
|
||||||
|
print_section(f"MLB {season}")
|
||||||
|
mlb_games = scrape_mlb_statsapi(season)
|
||||||
|
# MLB API uses official gamePk - already stable
|
||||||
|
all_games.extend(mlb_games)
|
||||||
|
games_by_sport['MLB'] = len(mlb_games)
|
||||||
|
|
||||||
|
if sport in ['nhl', 'all']:
|
||||||
|
print_section(f"NHL {season}")
|
||||||
|
nhl_games = scrape_nhl_hockey_reference(season)
|
||||||
|
nhl_season = f"{season-1}-{str(season)[2:]}"
|
||||||
|
nhl_games = assign_stable_ids(nhl_games, 'NHL', nhl_season)
|
||||||
|
all_games.extend(nhl_games)
|
||||||
|
games_by_sport['NHL'] = len(nhl_games)
|
||||||
|
|
||||||
|
# Export data
|
||||||
|
print_section("Exporting Data")
|
||||||
|
export_to_json(all_games, all_stadiums, output_dir)
|
||||||
|
print(f" Exported to {output_dir}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Load existing data
|
||||||
|
print_header("LOADING EXISTING DATA")
|
||||||
|
|
||||||
|
games_file = output_dir / 'games.json'
|
||||||
|
stadiums_file = output_dir / 'stadiums.json'
|
||||||
|
|
||||||
|
if games_file.exists():
|
||||||
|
with open(games_file) as f:
|
||||||
|
games_data = json.load(f)
|
||||||
|
all_games = [Game(**g) for g in games_data]
|
||||||
|
for g in all_games:
|
||||||
|
games_by_sport[g.sport] = games_by_sport.get(g.sport, 0) + 1
|
||||||
|
print(f" Loaded {len(all_games)} games")
|
||||||
|
|
||||||
|
if stadiums_file.exists():
|
||||||
|
with open(stadiums_file) as f:
|
||||||
|
stadiums_data = json.load(f)
|
||||||
|
all_stadiums = [Stadium(**s) for s in stadiums_data]
|
||||||
|
print(f" Loaded {len(all_stadiums)} stadiums")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PHASE 2: VALIDATE DATA
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
if validate:
|
||||||
|
print_header("PHASE 2: CROSS-VALIDATION")
|
||||||
|
|
||||||
|
# MLB validation (has two good sources)
|
||||||
|
if sport in ['mlb', 'all']:
|
||||||
|
print_section("MLB Cross-Validation")
|
||||||
|
try:
|
||||||
|
mlb_sources = scrape_mlb_all_sources(season)
|
||||||
|
source_names = list(mlb_sources.keys())
|
||||||
|
|
||||||
|
if len(source_names) >= 2:
|
||||||
|
games1 = mlb_sources[source_names[0]]
|
||||||
|
games2 = mlb_sources[source_names[1]]
|
||||||
|
|
||||||
|
if games1 and games2:
|
||||||
|
report = validate_games(
|
||||||
|
games1, games2,
|
||||||
|
source_names[0], source_names[1],
|
||||||
|
'MLB', str(season)
|
||||||
|
)
|
||||||
|
validation_reports.append(report)
|
||||||
|
|
||||||
|
print(f" Sources: {source_names[0]} vs {source_names[1]}")
|
||||||
|
print(f" Games compared: {report.total_games_source1} vs {report.total_games_source2}")
|
||||||
|
print(f" Matched: {report.games_matched}")
|
||||||
|
print(f" Discrepancies: {len(report.discrepancies)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error during MLB validation: {e}")
|
||||||
|
|
||||||
|
# Stadium validation
|
||||||
|
print_section("Stadium Validation")
|
||||||
|
stadium_issues = validate_stadiums(all_stadiums)
|
||||||
|
print(f" Issues found: {len(stadium_issues)}")
|
||||||
|
|
||||||
|
# Data quality checks
|
||||||
|
print_section("Data Quality Checks")
|
||||||
|
|
||||||
|
# Check game counts per team
|
||||||
|
if sport in ['nba', 'all']:
|
||||||
|
nba_games = [g for g in all_games if g.sport == 'NBA']
|
||||||
|
team_counts = {}
|
||||||
|
for g in nba_games:
|
||||||
|
team_counts[g.home_team_abbrev] = team_counts.get(g.home_team_abbrev, 0) + 1
|
||||||
|
team_counts[g.away_team_abbrev] = team_counts.get(g.away_team_abbrev, 0) + 1
|
||||||
|
|
||||||
|
for team, count in sorted(team_counts.items()):
|
||||||
|
if count < 75 or count > 90:
|
||||||
|
print(f" NBA: {team} has {count} games (expected ~82)")
|
||||||
|
|
||||||
|
if sport in ['nhl', 'all']:
|
||||||
|
nhl_games = [g for g in all_games if g.sport == 'NHL']
|
||||||
|
team_counts = {}
|
||||||
|
for g in nhl_games:
|
||||||
|
team_counts[g.home_team_abbrev] = team_counts.get(g.home_team_abbrev, 0) + 1
|
||||||
|
team_counts[g.away_team_abbrev] = team_counts.get(g.away_team_abbrev, 0) + 1
|
||||||
|
|
||||||
|
for team, count in sorted(team_counts.items()):
|
||||||
|
if count < 75 or count > 90:
|
||||||
|
print(f" NHL: {team} has {count} games (expected ~82)")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PHASE 3: GENERATE REPORT
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
print_header("PHASE 3: DISCREPANCY REPORT")
|
||||||
|
|
||||||
|
# Count by severity
|
||||||
|
high_count = 0
|
||||||
|
medium_count = 0
|
||||||
|
low_count = 0
|
||||||
|
|
||||||
|
# Game discrepancies
|
||||||
|
for report in validation_reports:
|
||||||
|
for d in report.discrepancies:
|
||||||
|
if d.severity == 'high':
|
||||||
|
high_count += 1
|
||||||
|
elif d.severity == 'medium':
|
||||||
|
medium_count += 1
|
||||||
|
else:
|
||||||
|
low_count += 1
|
||||||
|
|
||||||
|
# Stadium issues
|
||||||
|
for issue in stadium_issues:
|
||||||
|
if issue['severity'] == 'high':
|
||||||
|
high_count += 1
|
||||||
|
elif issue['severity'] == 'medium':
|
||||||
|
medium_count += 1
|
||||||
|
else:
|
||||||
|
low_count += 1
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
print()
|
||||||
|
print(f" 🔴 HIGH severity: {high_count}")
|
||||||
|
print(f" 🟡 MEDIUM severity: {medium_count}")
|
||||||
|
print(f" 🟢 LOW severity: {low_count}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Print high severity issues (always)
|
||||||
|
if high_count > 0:
|
||||||
|
print_section("HIGH Severity Issues (Requires Attention)")
|
||||||
|
|
||||||
|
shown = 0
|
||||||
|
max_show = 10 if not verbose else 50
|
||||||
|
|
||||||
|
for report in validation_reports:
|
||||||
|
for d in report.discrepancies:
|
||||||
|
if d.severity == 'high' and shown < max_show:
|
||||||
|
print_severity('high', f"[{report.sport}] {d.field}: {d.game_key}")
|
||||||
|
if verbose:
|
||||||
|
print(f" {d.source1}: {d.value1}")
|
||||||
|
print(f" {d.source2}: {d.value2}")
|
||||||
|
shown += 1
|
||||||
|
|
||||||
|
for issue in stadium_issues:
|
||||||
|
if issue['severity'] == 'high' and shown < max_show:
|
||||||
|
print_severity('high', f"[Stadium] {issue['stadium']}: {issue['issue']}")
|
||||||
|
shown += 1
|
||||||
|
|
||||||
|
if high_count > max_show:
|
||||||
|
print(f" ... and {high_count - max_show} more (use --verbose to see all)")
|
||||||
|
|
||||||
|
# Print medium severity if verbose
|
||||||
|
if medium_count > 0 and verbose:
|
||||||
|
print_section("MEDIUM Severity Issues")
|
||||||
|
|
||||||
|
for report in validation_reports:
|
||||||
|
for d in report.discrepancies:
|
||||||
|
if d.severity == 'medium':
|
||||||
|
print_severity('medium', f"[{report.sport}] {d.field}: {d.game_key}")
|
||||||
|
|
||||||
|
for issue in stadium_issues:
|
||||||
|
if issue['severity'] == 'medium':
|
||||||
|
print_severity('medium', f"[Stadium] {issue['stadium']}: {issue['issue']}")
|
||||||
|
|
||||||
|
# Save full report
|
||||||
|
report_path = output_dir / 'pipeline_report.json'
|
||||||
|
full_report = {
|
||||||
|
'generated_at': datetime.now().isoformat(),
|
||||||
|
'season': season,
|
||||||
|
'sport': sport,
|
||||||
|
'summary': {
|
||||||
|
'games_scraped': len(all_games),
|
||||||
|
'stadiums_scraped': len(all_stadiums),
|
||||||
|
'games_by_sport': games_by_sport,
|
||||||
|
'high_severity': high_count,
|
||||||
|
'medium_severity': medium_count,
|
||||||
|
'low_severity': low_count,
|
||||||
|
},
|
||||||
|
'game_validations': [r.to_dict() for r in validation_reports],
|
||||||
|
'stadium_issues': stadium_issues,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(report_path, 'w') as f:
|
||||||
|
json.dump(full_report, f, indent=2)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# FINAL SUMMARY
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
duration = (datetime.now() - start_time).total_seconds()
|
||||||
|
|
||||||
|
print_header("PIPELINE COMPLETE")
|
||||||
|
print()
|
||||||
|
print(f" Duration: {duration:.1f} seconds")
|
||||||
|
print(f" Games: {len(all_games):,}")
|
||||||
|
print(f" Stadiums: {len(all_stadiums)}")
|
||||||
|
print(f" Output: {output_dir.absolute()}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for sport_name, count in sorted(games_by_sport.items()):
|
||||||
|
print(f" {sport_name}: {count:,} games")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" Reports saved to:")
|
||||||
|
print(f" - {output_dir / 'games.json'}")
|
||||||
|
print(f" - {output_dir / 'stadiums.json'}")
|
||||||
|
print(f" - {output_dir / 'pipeline_report.json'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Status indicator
|
||||||
|
if high_count > 0:
|
||||||
|
print(" ⚠️ STATUS: Review required - high severity issues found")
|
||||||
|
elif medium_count > 0:
|
||||||
|
print(" ✓ STATUS: Complete with warnings")
|
||||||
|
else:
|
||||||
|
print(" ✅ STATUS: All checks passed")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
return PipelineResult(
|
||||||
|
success=high_count == 0,
|
||||||
|
games_scraped=len(all_games),
|
||||||
|
stadiums_scraped=len(all_stadiums),
|
||||||
|
games_by_sport=games_by_sport,
|
||||||
|
validation_reports=validation_reports,
|
||||||
|
stadium_issues=stadium_issues,
|
||||||
|
high_severity_count=high_count,
|
||||||
|
medium_severity_count=medium_count,
|
||||||
|
low_severity_count=low_count,
|
||||||
|
output_dir=output_dir,
|
||||||
|
duration_seconds=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='SportsTime Data Pipeline - Fetch, validate, and report on sports data',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
python run_pipeline.py # Full pipeline
|
||||||
|
python run_pipeline.py --season 2026 # Different season
|
||||||
|
python run_pipeline.py --sport mlb # MLB only
|
||||||
|
python run_pipeline.py --skip-scrape # Validate existing data
|
||||||
|
python run_pipeline.py --verbose # Show all issues
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
'--season', type=int, default=2025,
|
||||||
|
help='Season year (default: 2025)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--sport', choices=['nba', 'mlb', 'nhl', 'all'], default='all',
|
||||||
|
help='Sport to process (default: all)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--output', type=str, default='./data',
|
||||||
|
help='Output directory (default: ./data)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--skip-scrape', action='store_true',
|
||||||
|
help='Skip scraping, validate existing data only'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--no-validate', action='store_true',
|
||||||
|
help='Skip validation step'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--verbose', '-v', action='store_true',
|
||||||
|
help='Verbose output with all issues'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result = run_pipeline(
|
||||||
|
season=args.season,
|
||||||
|
sport=args.sport,
|
||||||
|
output_dir=Path(args.output),
|
||||||
|
skip_scrape=args.skip_scrape,
|
||||||
|
validate=not args.no_validate,
|
||||||
|
verbose=args.verbose,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exit with error code if high severity issues
|
||||||
|
sys.exit(0 if result.success else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
970
Scripts/scrape_schedules.py
Normal file
@@ -0,0 +1,970 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Sports Schedule Scraper for SportsTime App
|
||||||
|
Scrapes NBA, MLB, NHL schedules from multiple sources for cross-validation.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scrape_schedules.py --sport nba --season 2025
|
||||||
|
python scrape_schedules.py --sport all --season 2025
|
||||||
|
python scrape_schedules.py --stadiums-only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import Optional
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
REQUEST_DELAY = 3.0 # seconds between requests to same domain
|
||||||
|
last_request_time = {}
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limit(domain: str):
|
||||||
|
"""Enforce rate limiting per domain."""
|
||||||
|
now = time.time()
|
||||||
|
if domain in last_request_time:
|
||||||
|
elapsed = now - last_request_time[domain]
|
||||||
|
if elapsed < REQUEST_DELAY:
|
||||||
|
time.sleep(REQUEST_DELAY - elapsed)
|
||||||
|
last_request_time[domain] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_page(url: str, domain: str) -> Optional[BeautifulSoup]:
|
||||||
|
"""Fetch and parse a webpage with rate limiting."""
|
||||||
|
rate_limit(domain)
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return BeautifulSoup(response.content, 'html.parser')
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATA CLASSES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Game:
|
||||||
|
id: str
|
||||||
|
sport: str
|
||||||
|
season: str
|
||||||
|
date: str # YYYY-MM-DD
|
||||||
|
time: Optional[str] # HH:MM (24hr, ET)
|
||||||
|
home_team: str
|
||||||
|
away_team: str
|
||||||
|
home_team_abbrev: str
|
||||||
|
away_team_abbrev: str
|
||||||
|
venue: str
|
||||||
|
source: str
|
||||||
|
is_playoff: bool = False
|
||||||
|
broadcast: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Stadium:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
city: str
|
||||||
|
state: str
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
capacity: int
|
||||||
|
sport: str
|
||||||
|
team_abbrevs: list
|
||||||
|
source: str
|
||||||
|
year_opened: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TEAM MAPPINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
NBA_TEAMS = {
|
||||||
|
'ATL': {'name': 'Atlanta Hawks', 'city': 'Atlanta', 'arena': 'State Farm Arena'},
|
||||||
|
'BOS': {'name': 'Boston Celtics', 'city': 'Boston', 'arena': 'TD Garden'},
|
||||||
|
'BRK': {'name': 'Brooklyn Nets', 'city': 'Brooklyn', 'arena': 'Barclays Center'},
|
||||||
|
'CHO': {'name': 'Charlotte Hornets', 'city': 'Charlotte', 'arena': 'Spectrum Center'},
|
||||||
|
'CHI': {'name': 'Chicago Bulls', 'city': 'Chicago', 'arena': 'United Center'},
|
||||||
|
'CLE': {'name': 'Cleveland Cavaliers', 'city': 'Cleveland', 'arena': 'Rocket Mortgage FieldHouse'},
|
||||||
|
'DAL': {'name': 'Dallas Mavericks', 'city': 'Dallas', 'arena': 'American Airlines Center'},
|
||||||
|
'DEN': {'name': 'Denver Nuggets', 'city': 'Denver', 'arena': 'Ball Arena'},
|
||||||
|
'DET': {'name': 'Detroit Pistons', 'city': 'Detroit', 'arena': 'Little Caesars Arena'},
|
||||||
|
'GSW': {'name': 'Golden State Warriors', 'city': 'San Francisco', 'arena': 'Chase Center'},
|
||||||
|
'HOU': {'name': 'Houston Rockets', 'city': 'Houston', 'arena': 'Toyota Center'},
|
||||||
|
'IND': {'name': 'Indiana Pacers', 'city': 'Indianapolis', 'arena': 'Gainbridge Fieldhouse'},
|
||||||
|
'LAC': {'name': 'Los Angeles Clippers', 'city': 'Inglewood', 'arena': 'Intuit Dome'},
|
||||||
|
'LAL': {'name': 'Los Angeles Lakers', 'city': 'Los Angeles', 'arena': 'Crypto.com Arena'},
|
||||||
|
'MEM': {'name': 'Memphis Grizzlies', 'city': 'Memphis', 'arena': 'FedExForum'},
|
||||||
|
'MIA': {'name': 'Miami Heat', 'city': 'Miami', 'arena': 'Kaseya Center'},
|
||||||
|
'MIL': {'name': 'Milwaukee Bucks', 'city': 'Milwaukee', 'arena': 'Fiserv Forum'},
|
||||||
|
'MIN': {'name': 'Minnesota Timberwolves', 'city': 'Minneapolis', 'arena': 'Target Center'},
|
||||||
|
'NOP': {'name': 'New Orleans Pelicans', 'city': 'New Orleans', 'arena': 'Smoothie King Center'},
|
||||||
|
'NYK': {'name': 'New York Knicks', 'city': 'New York', 'arena': 'Madison Square Garden'},
|
||||||
|
'OKC': {'name': 'Oklahoma City Thunder', 'city': 'Oklahoma City', 'arena': 'Paycom Center'},
|
||||||
|
'ORL': {'name': 'Orlando Magic', 'city': 'Orlando', 'arena': 'Kia Center'},
|
||||||
|
'PHI': {'name': 'Philadelphia 76ers', 'city': 'Philadelphia', 'arena': 'Wells Fargo Center'},
|
||||||
|
'PHO': {'name': 'Phoenix Suns', 'city': 'Phoenix', 'arena': 'Footprint Center'},
|
||||||
|
'POR': {'name': 'Portland Trail Blazers', 'city': 'Portland', 'arena': 'Moda Center'},
|
||||||
|
'SAC': {'name': 'Sacramento Kings', 'city': 'Sacramento', 'arena': 'Golden 1 Center'},
|
||||||
|
'SAS': {'name': 'San Antonio Spurs', 'city': 'San Antonio', 'arena': 'Frost Bank Center'},
|
||||||
|
'TOR': {'name': 'Toronto Raptors', 'city': 'Toronto', 'arena': 'Scotiabank Arena'},
|
||||||
|
'UTA': {'name': 'Utah Jazz', 'city': 'Salt Lake City', 'arena': 'Delta Center'},
|
||||||
|
'WAS': {'name': 'Washington Wizards', 'city': 'Washington', 'arena': 'Capital One Arena'},
|
||||||
|
}
|
||||||
|
|
||||||
|
MLB_TEAMS = {
|
||||||
|
'ARI': {'name': 'Arizona Diamondbacks', 'city': 'Phoenix', 'stadium': 'Chase Field'},
|
||||||
|
'ATL': {'name': 'Atlanta Braves', 'city': 'Atlanta', 'stadium': 'Truist Park'},
|
||||||
|
'BAL': {'name': 'Baltimore Orioles', 'city': 'Baltimore', 'stadium': 'Oriole Park at Camden Yards'},
|
||||||
|
'BOS': {'name': 'Boston Red Sox', 'city': 'Boston', 'stadium': 'Fenway Park'},
|
||||||
|
'CHC': {'name': 'Chicago Cubs', 'city': 'Chicago', 'stadium': 'Wrigley Field'},
|
||||||
|
'CHW': {'name': 'Chicago White Sox', 'city': 'Chicago', 'stadium': 'Guaranteed Rate Field'},
|
||||||
|
'CIN': {'name': 'Cincinnati Reds', 'city': 'Cincinnati', 'stadium': 'Great American Ball Park'},
|
||||||
|
'CLE': {'name': 'Cleveland Guardians', 'city': 'Cleveland', 'stadium': 'Progressive Field'},
|
||||||
|
'COL': {'name': 'Colorado Rockies', 'city': 'Denver', 'stadium': 'Coors Field'},
|
||||||
|
'DET': {'name': 'Detroit Tigers', 'city': 'Detroit', 'stadium': 'Comerica Park'},
|
||||||
|
'HOU': {'name': 'Houston Astros', 'city': 'Houston', 'stadium': 'Minute Maid Park'},
|
||||||
|
'KCR': {'name': 'Kansas City Royals', 'city': 'Kansas City', 'stadium': 'Kauffman Stadium'},
|
||||||
|
'LAA': {'name': 'Los Angeles Angels', 'city': 'Anaheim', 'stadium': 'Angel Stadium'},
|
||||||
|
'LAD': {'name': 'Los Angeles Dodgers', 'city': 'Los Angeles', 'stadium': 'Dodger Stadium'},
|
||||||
|
'MIA': {'name': 'Miami Marlins', 'city': 'Miami', 'stadium': 'LoanDepot Park'},
|
||||||
|
'MIL': {'name': 'Milwaukee Brewers', 'city': 'Milwaukee', 'stadium': 'American Family Field'},
|
||||||
|
'MIN': {'name': 'Minnesota Twins', 'city': 'Minneapolis', 'stadium': 'Target Field'},
|
||||||
|
'NYM': {'name': 'New York Mets', 'city': 'New York', 'stadium': 'Citi Field'},
|
||||||
|
'NYY': {'name': 'New York Yankees', 'city': 'New York', 'stadium': 'Yankee Stadium'},
|
||||||
|
'OAK': {'name': 'Oakland Athletics', 'city': 'Sacramento', 'stadium': 'Sutter Health Park'},
|
||||||
|
'PHI': {'name': 'Philadelphia Phillies', 'city': 'Philadelphia', 'stadium': 'Citizens Bank Park'},
|
||||||
|
'PIT': {'name': 'Pittsburgh Pirates', 'city': 'Pittsburgh', 'stadium': 'PNC Park'},
|
||||||
|
'SDP': {'name': 'San Diego Padres', 'city': 'San Diego', 'stadium': 'Petco Park'},
|
||||||
|
'SFG': {'name': 'San Francisco Giants', 'city': 'San Francisco', 'stadium': 'Oracle Park'},
|
||||||
|
'SEA': {'name': 'Seattle Mariners', 'city': 'Seattle', 'stadium': 'T-Mobile Park'},
|
||||||
|
'STL': {'name': 'St. Louis Cardinals', 'city': 'St. Louis', 'stadium': 'Busch Stadium'},
|
||||||
|
'TBR': {'name': 'Tampa Bay Rays', 'city': 'St. Petersburg', 'stadium': 'Tropicana Field'},
|
||||||
|
'TEX': {'name': 'Texas Rangers', 'city': 'Arlington', 'stadium': 'Globe Life Field'},
|
||||||
|
'TOR': {'name': 'Toronto Blue Jays', 'city': 'Toronto', 'stadium': 'Rogers Centre'},
|
||||||
|
'WSN': {'name': 'Washington Nationals', 'city': 'Washington', 'stadium': 'Nationals Park'},
|
||||||
|
}
|
||||||
|
|
||||||
|
NHL_TEAMS = {
|
||||||
|
'ANA': {'name': 'Anaheim Ducks', 'city': 'Anaheim', 'arena': 'Honda Center'},
|
||||||
|
'ARI': {'name': 'Utah Hockey Club', 'city': 'Salt Lake City', 'arena': 'Delta Center'},
|
||||||
|
'BOS': {'name': 'Boston Bruins', 'city': 'Boston', 'arena': 'TD Garden'},
|
||||||
|
'BUF': {'name': 'Buffalo Sabres', 'city': 'Buffalo', 'arena': 'KeyBank Center'},
|
||||||
|
'CGY': {'name': 'Calgary Flames', 'city': 'Calgary', 'arena': 'Scotiabank Saddledome'},
|
||||||
|
'CAR': {'name': 'Carolina Hurricanes', 'city': 'Raleigh', 'arena': 'PNC Arena'},
|
||||||
|
'CHI': {'name': 'Chicago Blackhawks', 'city': 'Chicago', 'arena': 'United Center'},
|
||||||
|
'COL': {'name': 'Colorado Avalanche', 'city': 'Denver', 'arena': 'Ball Arena'},
|
||||||
|
'CBJ': {'name': 'Columbus Blue Jackets', 'city': 'Columbus', 'arena': 'Nationwide Arena'},
|
||||||
|
'DAL': {'name': 'Dallas Stars', 'city': 'Dallas', 'arena': 'American Airlines Center'},
|
||||||
|
'DET': {'name': 'Detroit Red Wings', 'city': 'Detroit', 'arena': 'Little Caesars Arena'},
|
||||||
|
'EDM': {'name': 'Edmonton Oilers', 'city': 'Edmonton', 'arena': 'Rogers Place'},
|
||||||
|
'FLA': {'name': 'Florida Panthers', 'city': 'Sunrise', 'arena': 'Amerant Bank Arena'},
|
||||||
|
'LAK': {'name': 'Los Angeles Kings', 'city': 'Los Angeles', 'arena': 'Crypto.com Arena'},
|
||||||
|
'MIN': {'name': 'Minnesota Wild', 'city': 'St. Paul', 'arena': 'Xcel Energy Center'},
|
||||||
|
'MTL': {'name': 'Montreal Canadiens', 'city': 'Montreal', 'arena': 'Bell Centre'},
|
||||||
|
'NSH': {'name': 'Nashville Predators', 'city': 'Nashville', 'arena': 'Bridgestone Arena'},
|
||||||
|
'NJD': {'name': 'New Jersey Devils', 'city': 'Newark', 'arena': 'Prudential Center'},
|
||||||
|
'NYI': {'name': 'New York Islanders', 'city': 'Elmont', 'arena': 'UBS Arena'},
|
||||||
|
'NYR': {'name': 'New York Rangers', 'city': 'New York', 'arena': 'Madison Square Garden'},
|
||||||
|
'OTT': {'name': 'Ottawa Senators', 'city': 'Ottawa', 'arena': 'Canadian Tire Centre'},
|
||||||
|
'PHI': {'name': 'Philadelphia Flyers', 'city': 'Philadelphia', 'arena': 'Wells Fargo Center'},
|
||||||
|
'PIT': {'name': 'Pittsburgh Penguins', 'city': 'Pittsburgh', 'arena': 'PPG Paints Arena'},
|
||||||
|
'SJS': {'name': 'San Jose Sharks', 'city': 'San Jose', 'arena': 'SAP Center'},
|
||||||
|
'SEA': {'name': 'Seattle Kraken', 'city': 'Seattle', 'arena': 'Climate Pledge Arena'},
|
||||||
|
'STL': {'name': 'St. Louis Blues', 'city': 'St. Louis', 'arena': 'Enterprise Center'},
|
||||||
|
'TBL': {'name': 'Tampa Bay Lightning', 'city': 'Tampa', 'arena': 'Amalie Arena'},
|
||||||
|
'TOR': {'name': 'Toronto Maple Leafs', 'city': 'Toronto', 'arena': 'Scotiabank Arena'},
|
||||||
|
'VAN': {'name': 'Vancouver Canucks', 'city': 'Vancouver', 'arena': 'Rogers Arena'},
|
||||||
|
'VGK': {'name': 'Vegas Golden Knights', 'city': 'Las Vegas', 'arena': 'T-Mobile Arena'},
|
||||||
|
'WSH': {'name': 'Washington Capitals', 'city': 'Washington', 'arena': 'Capital One Arena'},
|
||||||
|
'WPG': {'name': 'Winnipeg Jets', 'city': 'Winnipeg', 'arena': 'Canada Life Centre'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SCRAPERS - NBA
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def scrape_nba_basketball_reference(season: int) -> list[Game]:
|
||||||
|
"""
|
||||||
|
Scrape NBA schedule from Basketball-Reference.
|
||||||
|
URL: https://www.basketball-reference.com/leagues/NBA_{YEAR}_games-{month}.html
|
||||||
|
Season year is the ending year (e.g., 2025 for 2024-25 season)
|
||||||
|
"""
|
||||||
|
games = []
|
||||||
|
months = ['october', 'november', 'december', 'january', 'february', 'march', 'april', 'may', 'june']
|
||||||
|
|
||||||
|
print(f"Scraping NBA {season} from Basketball-Reference...")
|
||||||
|
|
||||||
|
for month in months:
|
||||||
|
url = f"https://www.basketball-reference.com/leagues/NBA_{season}_games-{month}.html"
|
||||||
|
soup = fetch_page(url, 'basketball-reference.com')
|
||||||
|
|
||||||
|
if not soup:
|
||||||
|
continue
|
||||||
|
|
||||||
|
table = soup.find('table', {'id': 'schedule'})
|
||||||
|
if not table:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tbody = table.find('tbody')
|
||||||
|
if not tbody:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in tbody.find_all('tr'):
|
||||||
|
if row.get('class') and 'thead' in row.get('class'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cells = row.find_all(['td', 'th'])
|
||||||
|
if len(cells) < 6:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse date
|
||||||
|
date_cell = row.find('th', {'data-stat': 'date_game'})
|
||||||
|
if not date_cell:
|
||||||
|
continue
|
||||||
|
date_link = date_cell.find('a')
|
||||||
|
date_str = date_link.text if date_link else date_cell.text
|
||||||
|
|
||||||
|
# Parse time
|
||||||
|
time_cell = row.find('td', {'data-stat': 'game_start_time'})
|
||||||
|
time_str = time_cell.text.strip() if time_cell else None
|
||||||
|
|
||||||
|
# Parse teams
|
||||||
|
visitor_cell = row.find('td', {'data-stat': 'visitor_team_name'})
|
||||||
|
home_cell = row.find('td', {'data-stat': 'home_team_name'})
|
||||||
|
|
||||||
|
if not visitor_cell or not home_cell:
|
||||||
|
continue
|
||||||
|
|
||||||
|
visitor_link = visitor_cell.find('a')
|
||||||
|
home_link = home_cell.find('a')
|
||||||
|
|
||||||
|
away_team = visitor_link.text if visitor_link else visitor_cell.text
|
||||||
|
home_team = home_link.text if home_link else home_cell.text
|
||||||
|
|
||||||
|
# Parse arena
|
||||||
|
arena_cell = row.find('td', {'data-stat': 'arena_name'})
|
||||||
|
arena = arena_cell.text.strip() if arena_cell else ''
|
||||||
|
|
||||||
|
# Convert date
|
||||||
|
try:
|
||||||
|
parsed_date = datetime.strptime(date_str.strip(), '%a, %b %d, %Y')
|
||||||
|
date_formatted = parsed_date.strftime('%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generate game ID
|
||||||
|
game_id = f"nba_{date_formatted}_{away_team[:3]}_{home_team[:3]}".lower().replace(' ', '')
|
||||||
|
|
||||||
|
game = Game(
|
||||||
|
id=game_id,
|
||||||
|
sport='NBA',
|
||||||
|
season=f"{season-1}-{str(season)[2:]}",
|
||||||
|
date=date_formatted,
|
||||||
|
time=time_str,
|
||||||
|
home_team=home_team,
|
||||||
|
away_team=away_team,
|
||||||
|
home_team_abbrev=get_team_abbrev(home_team, 'NBA'),
|
||||||
|
away_team_abbrev=get_team_abbrev(away_team, 'NBA'),
|
||||||
|
venue=arena,
|
||||||
|
source='basketball-reference.com'
|
||||||
|
)
|
||||||
|
games.append(game)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error parsing row: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" Found {len(games)} games from Basketball-Reference")
|
||||||
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_nba_espn(season: int) -> list[Game]:
|
||||||
|
"""
|
||||||
|
Scrape NBA schedule from ESPN.
|
||||||
|
URL: https://www.espn.com/nba/schedule/_/date/{YYYYMMDD}
|
||||||
|
"""
|
||||||
|
games = []
|
||||||
|
print(f"Scraping NBA {season} from ESPN...")
|
||||||
|
|
||||||
|
# Determine date range for season
|
||||||
|
start_date = datetime(season - 1, 10, 1) # October of previous year
|
||||||
|
end_date = datetime(season, 6, 30) # June of season year
|
||||||
|
|
||||||
|
current_date = start_date
|
||||||
|
while current_date <= end_date:
|
||||||
|
date_str = current_date.strftime('%Y%m%d')
|
||||||
|
url = f"https://www.espn.com/nba/schedule/_/date/{date_str}"
|
||||||
|
|
||||||
|
soup = fetch_page(url, 'espn.com')
|
||||||
|
if soup:
|
||||||
|
# ESPN uses JavaScript rendering, so we need to parse what's available
|
||||||
|
# This is a simplified version - full implementation would need Selenium
|
||||||
|
pass
|
||||||
|
|
||||||
|
current_date += timedelta(days=7) # Sample weekly to respect rate limits
|
||||||
|
|
||||||
|
print(f" Found {len(games)} games from ESPN")
|
||||||
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SCRAPERS - MLB
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def scrape_mlb_baseball_reference(season: int) -> list[Game]:
|
||||||
|
"""
|
||||||
|
Scrape MLB schedule from Baseball-Reference.
|
||||||
|
URL: https://www.baseball-reference.com/leagues/majors/{YEAR}-schedule.shtml
|
||||||
|
"""
|
||||||
|
games = []
|
||||||
|
url = f"https://www.baseball-reference.com/leagues/majors/{season}-schedule.shtml"
|
||||||
|
|
||||||
|
print(f"Scraping MLB {season} from Baseball-Reference...")
|
||||||
|
soup = fetch_page(url, 'baseball-reference.com')
|
||||||
|
|
||||||
|
if not soup:
|
||||||
|
return games
|
||||||
|
|
||||||
|
# Baseball-Reference groups games by date in h3 headers
|
||||||
|
current_date = None
|
||||||
|
|
||||||
|
# Find the schedule section
|
||||||
|
schedule_div = soup.find('div', {'id': 'all_schedule'})
|
||||||
|
if not schedule_div:
|
||||||
|
schedule_div = soup
|
||||||
|
|
||||||
|
# Process all elements to track date context
|
||||||
|
for element in schedule_div.find_all(['h3', 'p', 'div']):
|
||||||
|
# Check for date header
|
||||||
|
if element.name == 'h3':
|
||||||
|
date_text = element.get_text(strip=True)
|
||||||
|
# Parse date like "Thursday, March 27, 2025"
|
||||||
|
try:
|
||||||
|
for fmt in ['%A, %B %d, %Y', '%B %d, %Y', '%a, %b %d, %Y']:
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(date_text, fmt)
|
||||||
|
current_date = parsed.strftime('%Y-%m-%d')
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for game entries
|
||||||
|
elif element.name == 'p' and 'game' in element.get('class', []):
|
||||||
|
if not current_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
links = element.find_all('a')
|
||||||
|
if len(links) >= 2:
|
||||||
|
away_team = links[0].text.strip()
|
||||||
|
home_team = links[1].text.strip()
|
||||||
|
|
||||||
|
# Generate unique game ID
|
||||||
|
away_abbrev = get_team_abbrev(away_team, 'MLB')
|
||||||
|
home_abbrev = get_team_abbrev(home_team, 'MLB')
|
||||||
|
game_id = f"mlb_br_{current_date}_{away_abbrev}_{home_abbrev}".lower()
|
||||||
|
|
||||||
|
game = Game(
|
||||||
|
id=game_id,
|
||||||
|
sport='MLB',
|
||||||
|
season=str(season),
|
||||||
|
date=current_date,
|
||||||
|
time=None,
|
||||||
|
home_team=home_team,
|
||||||
|
away_team=away_team,
|
||||||
|
home_team_abbrev=home_abbrev,
|
||||||
|
away_team_abbrev=away_abbrev,
|
||||||
|
venue='',
|
||||||
|
source='baseball-reference.com'
|
||||||
|
)
|
||||||
|
games.append(game)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" Found {len(games)} games from Baseball-Reference")
|
||||||
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_mlb_statsapi(season: int) -> list[Game]:
|
||||||
|
"""
|
||||||
|
Fetch MLB schedule from official Stats API (JSON).
|
||||||
|
URL: https://statsapi.mlb.com/api/v1/schedule?sportId=1&season={YEAR}&gameType=R
|
||||||
|
"""
|
||||||
|
games = []
|
||||||
|
url = f"https://statsapi.mlb.com/api/v1/schedule?sportId=1&season={season}&gameType=R&hydrate=team,venue"
|
||||||
|
|
||||||
|
print(f"Fetching MLB {season} from Stats API...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
for date_entry in data.get('dates', []):
|
||||||
|
game_date = date_entry.get('date', '')
|
||||||
|
|
||||||
|
for game_data in date_entry.get('games', []):
|
||||||
|
try:
|
||||||
|
teams = game_data.get('teams', {})
|
||||||
|
away = teams.get('away', {}).get('team', {})
|
||||||
|
home = teams.get('home', {}).get('team', {})
|
||||||
|
venue = game_data.get('venue', {})
|
||||||
|
|
||||||
|
game_time = game_data.get('gameDate', '')
|
||||||
|
if 'T' in game_time:
|
||||||
|
time_str = game_time.split('T')[1][:5]
|
||||||
|
else:
|
||||||
|
time_str = None
|
||||||
|
|
||||||
|
game = Game(
|
||||||
|
id=f"mlb_{game_data.get('gamePk', '')}",
|
||||||
|
sport='MLB',
|
||||||
|
season=str(season),
|
||||||
|
date=game_date,
|
||||||
|
time=time_str,
|
||||||
|
home_team=home.get('name', ''),
|
||||||
|
away_team=away.get('name', ''),
|
||||||
|
home_team_abbrev=home.get('abbreviation', ''),
|
||||||
|
away_team_abbrev=away.get('abbreviation', ''),
|
||||||
|
venue=venue.get('name', ''),
|
||||||
|
source='statsapi.mlb.com'
|
||||||
|
)
|
||||||
|
games.append(game)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error fetching MLB API: {e}")
|
||||||
|
|
||||||
|
print(f" Found {len(games)} games from MLB Stats API")
|
||||||
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SCRAPERS - NHL
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def scrape_nhl_hockey_reference(season: int) -> list[Game]:
|
||||||
|
"""
|
||||||
|
Scrape NHL schedule from Hockey-Reference.
|
||||||
|
URL: https://www.hockey-reference.com/leagues/NHL_{YEAR}_games.html
|
||||||
|
"""
|
||||||
|
games = []
|
||||||
|
url = f"https://www.hockey-reference.com/leagues/NHL_{season}_games.html"
|
||||||
|
|
||||||
|
print(f"Scraping NHL {season} from Hockey-Reference...")
|
||||||
|
soup = fetch_page(url, 'hockey-reference.com')
|
||||||
|
|
||||||
|
if not soup:
|
||||||
|
return games
|
||||||
|
|
||||||
|
table = soup.find('table', {'id': 'games'})
|
||||||
|
if not table:
|
||||||
|
print(" Could not find games table")
|
||||||
|
return games
|
||||||
|
|
||||||
|
tbody = table.find('tbody')
|
||||||
|
if not tbody:
|
||||||
|
return games
|
||||||
|
|
||||||
|
for row in tbody.find_all('tr'):
|
||||||
|
try:
|
||||||
|
cells = row.find_all(['td', 'th'])
|
||||||
|
if len(cells) < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse date
|
||||||
|
date_cell = row.find('th', {'data-stat': 'date_game'})
|
||||||
|
if not date_cell:
|
||||||
|
continue
|
||||||
|
date_link = date_cell.find('a')
|
||||||
|
date_str = date_link.text if date_link else date_cell.text
|
||||||
|
|
||||||
|
# Parse teams
|
||||||
|
visitor_cell = row.find('td', {'data-stat': 'visitor_team_name'})
|
||||||
|
home_cell = row.find('td', {'data-stat': 'home_team_name'})
|
||||||
|
|
||||||
|
if not visitor_cell or not home_cell:
|
||||||
|
continue
|
||||||
|
|
||||||
|
visitor_link = visitor_cell.find('a')
|
||||||
|
home_link = home_cell.find('a')
|
||||||
|
|
||||||
|
away_team = visitor_link.text if visitor_link else visitor_cell.text
|
||||||
|
home_team = home_link.text if home_link else home_cell.text
|
||||||
|
|
||||||
|
# Convert date
|
||||||
|
try:
|
||||||
|
parsed_date = datetime.strptime(date_str.strip(), '%Y-%m-%d')
|
||||||
|
date_formatted = parsed_date.strftime('%Y-%m-%d')
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
game_id = f"nhl_{date_formatted}_{away_team[:3]}_{home_team[:3]}".lower().replace(' ', '')
|
||||||
|
|
||||||
|
game = Game(
|
||||||
|
id=game_id,
|
||||||
|
sport='NHL',
|
||||||
|
season=f"{season-1}-{str(season)[2:]}",
|
||||||
|
date=date_formatted,
|
||||||
|
time=None,
|
||||||
|
home_team=home_team,
|
||||||
|
away_team=away_team,
|
||||||
|
home_team_abbrev=get_team_abbrev(home_team, 'NHL'),
|
||||||
|
away_team_abbrev=get_team_abbrev(away_team, 'NHL'),
|
||||||
|
venue='',
|
||||||
|
source='hockey-reference.com'
|
||||||
|
)
|
||||||
|
games.append(game)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" Found {len(games)} games from Hockey-Reference")
|
||||||
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_nhl_api(season: int) -> list[Game]:
|
||||||
|
"""
|
||||||
|
Fetch NHL schedule from official API (JSON).
|
||||||
|
URL: https://api-web.nhle.com/v1/schedule/{YYYY-MM-DD}
|
||||||
|
"""
|
||||||
|
games = []
|
||||||
|
print(f"Fetching NHL {season} from NHL API...")
|
||||||
|
|
||||||
|
# NHL API provides club schedules
|
||||||
|
# We'd need to iterate through dates or teams
|
||||||
|
# Simplified implementation here
|
||||||
|
|
||||||
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STADIUM SCRAPER
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def scrape_stadiums_hifld() -> list[Stadium]:
|
||||||
|
"""
|
||||||
|
Fetch stadium data from HIFLD Open Data (US Government).
|
||||||
|
Returns GeoJSON with coordinates.
|
||||||
|
"""
|
||||||
|
stadiums = []
|
||||||
|
url = "https://services1.arcgis.com/Hp6G80Pky0om7QvQ/arcgis/rest/services/Major_Sport_Venues/FeatureServer/0/query?where=1%3D1&outFields=*&outSR=4326&f=json"
|
||||||
|
|
||||||
|
print("Fetching stadiums from HIFLD Open Data...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
for feature in data.get('features', []):
|
||||||
|
attrs = feature.get('attributes', {})
|
||||||
|
geom = feature.get('geometry', {})
|
||||||
|
|
||||||
|
# Filter for NBA, MLB, NHL venues
|
||||||
|
league = attrs.get('LEAGUE', '')
|
||||||
|
if league not in ['NBA', 'MLB', 'NHL', 'NFL']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sport_map = {'NBA': 'NBA', 'MLB': 'MLB', 'NHL': 'NHL'}
|
||||||
|
if league not in sport_map:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stadium = Stadium(
|
||||||
|
id=f"hifld_{attrs.get('OBJECTID', '')}",
|
||||||
|
name=attrs.get('NAME', ''),
|
||||||
|
city=attrs.get('CITY', ''),
|
||||||
|
state=attrs.get('STATE', ''),
|
||||||
|
latitude=geom.get('y', 0),
|
||||||
|
longitude=geom.get('x', 0),
|
||||||
|
capacity=attrs.get('CAPACITY', 0) or 0,
|
||||||
|
sport=sport_map.get(league, ''),
|
||||||
|
team_abbrevs=[attrs.get('TEAM', '')],
|
||||||
|
source='hifld.gov',
|
||||||
|
year_opened=attrs.get('YEAR_OPEN')
|
||||||
|
)
|
||||||
|
stadiums.append(stadium)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Error fetching HIFLD data: {e}")
|
||||||
|
|
||||||
|
print(f" Found {len(stadiums)} stadiums from HIFLD")
|
||||||
|
return stadiums
|
||||||
|
|
||||||
|
|
||||||
|
def generate_stadiums_from_teams() -> list[Stadium]:
|
||||||
|
"""
|
||||||
|
Generate stadium data from team mappings with manual coordinates.
|
||||||
|
This serves as a fallback/validation source.
|
||||||
|
"""
|
||||||
|
stadiums = []
|
||||||
|
|
||||||
|
# NBA Arenas with coordinates (manually curated)
|
||||||
|
nba_coords = {
|
||||||
|
'State Farm Arena': (33.7573, -84.3963),
|
||||||
|
'TD Garden': (42.3662, -71.0621),
|
||||||
|
'Barclays Center': (40.6826, -73.9754),
|
||||||
|
'Spectrum Center': (35.2251, -80.8392),
|
||||||
|
'United Center': (41.8807, -87.6742),
|
||||||
|
'Rocket Mortgage FieldHouse': (41.4965, -81.6882),
|
||||||
|
'American Airlines Center': (32.7905, -96.8103),
|
||||||
|
'Ball Arena': (39.7487, -105.0077),
|
||||||
|
'Little Caesars Arena': (42.3411, -83.0553),
|
||||||
|
'Chase Center': (37.7680, -122.3879),
|
||||||
|
'Toyota Center': (29.7508, -95.3621),
|
||||||
|
'Gainbridge Fieldhouse': (39.7640, -86.1555),
|
||||||
|
'Intuit Dome': (33.9425, -118.3419),
|
||||||
|
'Crypto.com Arena': (34.0430, -118.2673),
|
||||||
|
'FedExForum': (35.1382, -90.0506),
|
||||||
|
'Kaseya Center': (25.7814, -80.1870),
|
||||||
|
'Fiserv Forum': (43.0451, -87.9174),
|
||||||
|
'Target Center': (44.9795, -93.2761),
|
||||||
|
'Smoothie King Center': (29.9490, -90.0821),
|
||||||
|
'Madison Square Garden': (40.7505, -73.9934),
|
||||||
|
'Paycom Center': (35.4634, -97.5151),
|
||||||
|
'Kia Center': (28.5392, -81.3839),
|
||||||
|
'Wells Fargo Center': (39.9012, -75.1720),
|
||||||
|
'Footprint Center': (33.4457, -112.0712),
|
||||||
|
'Moda Center': (45.5316, -122.6668),
|
||||||
|
'Golden 1 Center': (38.5802, -121.4997),
|
||||||
|
'Frost Bank Center': (29.4270, -98.4375),
|
||||||
|
'Scotiabank Arena': (43.6435, -79.3791),
|
||||||
|
'Delta Center': (40.7683, -111.9011),
|
||||||
|
'Capital One Arena': (38.8982, -77.0209),
|
||||||
|
}
|
||||||
|
|
||||||
|
for abbrev, info in NBA_TEAMS.items():
|
||||||
|
arena = info['arena']
|
||||||
|
coords = nba_coords.get(arena, (0, 0))
|
||||||
|
|
||||||
|
stadium = Stadium(
|
||||||
|
id=f"manual_nba_{abbrev.lower()}",
|
||||||
|
name=arena,
|
||||||
|
city=info['city'],
|
||||||
|
state='',
|
||||||
|
latitude=coords[0],
|
||||||
|
longitude=coords[1],
|
||||||
|
capacity=0,
|
||||||
|
sport='NBA',
|
||||||
|
team_abbrevs=[abbrev],
|
||||||
|
source='manual'
|
||||||
|
)
|
||||||
|
stadiums.append(stadium)
|
||||||
|
|
||||||
|
# MLB Stadiums with coordinates
|
||||||
|
mlb_coords = {
|
||||||
|
'Chase Field': (33.4453, -112.0667, 'AZ', 48686),
|
||||||
|
'Truist Park': (33.8907, -84.4678, 'GA', 41084),
|
||||||
|
'Oriole Park at Camden Yards': (39.2838, -76.6218, 'MD', 45971),
|
||||||
|
'Fenway Park': (42.3467, -71.0972, 'MA', 37755),
|
||||||
|
'Wrigley Field': (41.9484, -87.6553, 'IL', 41649),
|
||||||
|
'Guaranteed Rate Field': (41.8299, -87.6338, 'IL', 40615),
|
||||||
|
'Great American Ball Park': (39.0979, -84.5082, 'OH', 42319),
|
||||||
|
'Progressive Field': (41.4962, -81.6852, 'OH', 34830),
|
||||||
|
'Coors Field': (39.7559, -104.9942, 'CO', 50144),
|
||||||
|
'Comerica Park': (42.3390, -83.0485, 'MI', 41083),
|
||||||
|
'Minute Maid Park': (29.7573, -95.3555, 'TX', 41168),
|
||||||
|
'Kauffman Stadium': (39.0517, -94.4803, 'MO', 37903),
|
||||||
|
'Angel Stadium': (33.8003, -117.8827, 'CA', 45517),
|
||||||
|
'Dodger Stadium': (34.0739, -118.2400, 'CA', 56000),
|
||||||
|
'LoanDepot Park': (25.7781, -80.2196, 'FL', 36742),
|
||||||
|
'American Family Field': (43.0280, -87.9712, 'WI', 41900),
|
||||||
|
'Target Field': (44.9817, -93.2776, 'MN', 38544),
|
||||||
|
'Citi Field': (40.7571, -73.8458, 'NY', 41922),
|
||||||
|
'Yankee Stadium': (40.8296, -73.9262, 'NY', 46537),
|
||||||
|
'Sutter Health Park': (38.5802, -121.5097, 'CA', 14014),
|
||||||
|
'Citizens Bank Park': (39.9061, -75.1665, 'PA', 42792),
|
||||||
|
'PNC Park': (40.4469, -80.0057, 'PA', 38362),
|
||||||
|
'Petco Park': (32.7076, -117.1570, 'CA', 40209),
|
||||||
|
'Oracle Park': (37.7786, -122.3893, 'CA', 41265),
|
||||||
|
'T-Mobile Park': (47.5914, -122.3325, 'WA', 47929),
|
||||||
|
'Busch Stadium': (38.6226, -90.1928, 'MO', 45494),
|
||||||
|
'Tropicana Field': (27.7682, -82.6534, 'FL', 25000),
|
||||||
|
'Globe Life Field': (32.7473, -97.0845, 'TX', 40300),
|
||||||
|
'Rogers Centre': (43.6414, -79.3894, 'ON', 49282),
|
||||||
|
'Nationals Park': (38.8730, -77.0074, 'DC', 41339),
|
||||||
|
}
|
||||||
|
|
||||||
|
for abbrev, info in MLB_TEAMS.items():
|
||||||
|
stadium_name = info['stadium']
|
||||||
|
coord_data = mlb_coords.get(stadium_name, (0, 0, '', 0))
|
||||||
|
|
||||||
|
stadium = Stadium(
|
||||||
|
id=f"manual_mlb_{abbrev.lower()}",
|
||||||
|
name=stadium_name,
|
||||||
|
city=info['city'],
|
||||||
|
state=coord_data[2] if len(coord_data) > 2 else '',
|
||||||
|
latitude=coord_data[0],
|
||||||
|
longitude=coord_data[1],
|
||||||
|
capacity=coord_data[3] if len(coord_data) > 3 else 0,
|
||||||
|
sport='MLB',
|
||||||
|
team_abbrevs=[abbrev],
|
||||||
|
source='manual'
|
||||||
|
)
|
||||||
|
stadiums.append(stadium)
|
||||||
|
|
||||||
|
# NHL Arenas with coordinates
|
||||||
|
nhl_coords = {
|
||||||
|
'Honda Center': (33.8078, -117.8765, 'CA', 17174),
|
||||||
|
'Delta Center': (40.7683, -111.9011, 'UT', 18306),
|
||||||
|
'TD Garden': (42.3662, -71.0621, 'MA', 17565),
|
||||||
|
'KeyBank Center': (42.8750, -78.8764, 'NY', 19070),
|
||||||
|
'Scotiabank Saddledome': (51.0374, -114.0519, 'AB', 19289),
|
||||||
|
'PNC Arena': (35.8034, -78.7220, 'NC', 18680),
|
||||||
|
'United Center': (41.8807, -87.6742, 'IL', 19717),
|
||||||
|
'Ball Arena': (39.7487, -105.0077, 'CO', 18007),
|
||||||
|
'Nationwide Arena': (39.9693, -83.0061, 'OH', 18500),
|
||||||
|
'American Airlines Center': (32.7905, -96.8103, 'TX', 18532),
|
||||||
|
'Little Caesars Arena': (42.3411, -83.0553, 'MI', 19515),
|
||||||
|
'Rogers Place': (53.5469, -113.4978, 'AB', 18347),
|
||||||
|
'Amerant Bank Arena': (26.1584, -80.3256, 'FL', 19250),
|
||||||
|
'Crypto.com Arena': (34.0430, -118.2673, 'CA', 18230),
|
||||||
|
'Xcel Energy Center': (44.9448, -93.1010, 'MN', 17954),
|
||||||
|
'Bell Centre': (45.4961, -73.5693, 'QC', 21302),
|
||||||
|
'Bridgestone Arena': (36.1592, -86.7785, 'TN', 17159),
|
||||||
|
'Prudential Center': (40.7334, -74.1712, 'NJ', 16514),
|
||||||
|
'UBS Arena': (40.7161, -73.7246, 'NY', 17255),
|
||||||
|
'Madison Square Garden': (40.7505, -73.9934, 'NY', 18006),
|
||||||
|
'Canadian Tire Centre': (45.2969, -75.9272, 'ON', 18652),
|
||||||
|
'Wells Fargo Center': (39.9012, -75.1720, 'PA', 19543),
|
||||||
|
'PPG Paints Arena': (40.4395, -79.9892, 'PA', 18387),
|
||||||
|
'SAP Center': (37.3327, -121.9010, 'CA', 17562),
|
||||||
|
'Climate Pledge Arena': (47.6221, -122.3540, 'WA', 17100),
|
||||||
|
'Enterprise Center': (38.6268, -90.2025, 'MO', 18096),
|
||||||
|
'Amalie Arena': (27.9426, -82.4519, 'FL', 19092),
|
||||||
|
'Scotiabank Arena': (43.6435, -79.3791, 'ON', 18819),
|
||||||
|
'Rogers Arena': (49.2778, -123.1089, 'BC', 18910),
|
||||||
|
'T-Mobile Arena': (36.1028, -115.1784, 'NV', 17500),
|
||||||
|
'Capital One Arena': (38.8982, -77.0209, 'DC', 18573),
|
||||||
|
'Canada Life Centre': (49.8928, -97.1436, 'MB', 15321),
|
||||||
|
}
|
||||||
|
|
||||||
|
for abbrev, info in NHL_TEAMS.items():
|
||||||
|
arena_name = info['arena']
|
||||||
|
coord_data = nhl_coords.get(arena_name, (0, 0, '', 0))
|
||||||
|
|
||||||
|
stadium = Stadium(
|
||||||
|
id=f"manual_nhl_{abbrev.lower()}",
|
||||||
|
name=arena_name,
|
||||||
|
city=info['city'],
|
||||||
|
state=coord_data[2] if len(coord_data) > 2 else '',
|
||||||
|
latitude=coord_data[0],
|
||||||
|
longitude=coord_data[1],
|
||||||
|
capacity=coord_data[3] if len(coord_data) > 3 else 0,
|
||||||
|
sport='NHL',
|
||||||
|
team_abbrevs=[abbrev],
|
||||||
|
source='manual'
|
||||||
|
)
|
||||||
|
stadiums.append(stadium)
|
||||||
|
|
||||||
|
return stadiums
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HELPERS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def assign_stable_ids(games: list[Game], sport: str, season: str) -> list[Game]:
|
||||||
|
"""
|
||||||
|
Assign stable IDs based on matchup + occurrence number within season.
|
||||||
|
Format: {sport}_{season}_{away}_{home}_{num}
|
||||||
|
|
||||||
|
This ensures IDs don't change when games are rescheduled.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# Group games by matchup (away @ home)
|
||||||
|
matchups = defaultdict(list)
|
||||||
|
for game in games:
|
||||||
|
key = f"{game.away_team_abbrev}_{game.home_team_abbrev}"
|
||||||
|
matchups[key].append(game)
|
||||||
|
|
||||||
|
# Sort each matchup by date and assign occurrence number
|
||||||
|
for key, matchup_games in matchups.items():
|
||||||
|
matchup_games.sort(key=lambda g: g.date)
|
||||||
|
for i, game in enumerate(matchup_games, 1):
|
||||||
|
away = game.away_team_abbrev.lower()
|
||||||
|
home = game.home_team_abbrev.lower()
|
||||||
|
# Normalize season format (e.g., "2024-25" -> "2024-25", "2025" -> "2025")
|
||||||
|
season_str = season.replace('-', '')
|
||||||
|
game.id = f"{sport.lower()}_{season_str}_{away}_{home}_{i}"
|
||||||
|
|
||||||
|
return games
|
||||||
|
|
||||||
|
|
||||||
|
def get_team_abbrev(team_name: str, sport: str) -> str:
|
||||||
|
"""Get team abbreviation from full name."""
|
||||||
|
teams = {'NBA': NBA_TEAMS, 'MLB': MLB_TEAMS, 'NHL': NHL_TEAMS}.get(sport, {})
|
||||||
|
|
||||||
|
for abbrev, info in teams.items():
|
||||||
|
if info['name'].lower() == team_name.lower():
|
||||||
|
return abbrev
|
||||||
|
if team_name.lower() in info['name'].lower():
|
||||||
|
return abbrev
|
||||||
|
|
||||||
|
# Return first 3 letters as fallback
|
||||||
|
return team_name[:3].upper()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_games(games_by_source: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Cross-validate games from multiple sources.
|
||||||
|
Returns discrepancies.
|
||||||
|
"""
|
||||||
|
discrepancies = {
|
||||||
|
'missing_in_source': [],
|
||||||
|
'date_mismatch': [],
|
||||||
|
'time_mismatch': [],
|
||||||
|
'venue_mismatch': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
sources = list(games_by_source.keys())
|
||||||
|
if len(sources) < 2:
|
||||||
|
return discrepancies
|
||||||
|
|
||||||
|
primary = sources[0]
|
||||||
|
primary_games = {g.id: g for g in games_by_source[primary]}
|
||||||
|
|
||||||
|
for source in sources[1:]:
|
||||||
|
secondary_games = {g.id: g for g in games_by_source[source]}
|
||||||
|
|
||||||
|
for game_id, game in primary_games.items():
|
||||||
|
if game_id not in secondary_games:
|
||||||
|
discrepancies['missing_in_source'].append({
|
||||||
|
'game_id': game_id,
|
||||||
|
'present_in': primary,
|
||||||
|
'missing_in': source
|
||||||
|
})
|
||||||
|
|
||||||
|
return discrepancies
|
||||||
|
|
||||||
|
|
||||||
|
def export_to_json(games: list[Game], stadiums: list[Stadium], output_dir: Path):
|
||||||
|
"""Export scraped data to JSON files."""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Export games
|
||||||
|
games_data = [asdict(g) for g in games]
|
||||||
|
with open(output_dir / 'games.json', 'w') as f:
|
||||||
|
json.dump(games_data, f, indent=2)
|
||||||
|
|
||||||
|
# Export stadiums
|
||||||
|
stadiums_data = [asdict(s) for s in stadiums]
|
||||||
|
with open(output_dir / 'stadiums.json', 'w') as f:
|
||||||
|
json.dump(stadiums_data, f, indent=2)
|
||||||
|
|
||||||
|
# Export as CSV for easy viewing
|
||||||
|
if games:
|
||||||
|
df_games = pd.DataFrame(games_data)
|
||||||
|
df_games.to_csv(output_dir / 'games.csv', index=False)
|
||||||
|
|
||||||
|
if stadiums:
|
||||||
|
df_stadiums = pd.DataFrame(stadiums_data)
|
||||||
|
df_stadiums.to_csv(output_dir / 'stadiums.csv', index=False)
|
||||||
|
|
||||||
|
print(f"\nExported to {output_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MAIN
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Scrape sports schedules')
|
||||||
|
parser.add_argument('--sport', choices=['nba', 'mlb', 'nhl', 'all'], default='all')
|
||||||
|
parser.add_argument('--season', type=int, default=2025, help='Season year (ending year)')
|
||||||
|
parser.add_argument('--stadiums-only', action='store_true', help='Only scrape stadium data')
|
||||||
|
parser.add_argument('--output', type=str, default='./data', help='Output directory')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
output_dir = Path(args.output)
|
||||||
|
|
||||||
|
all_games = []
|
||||||
|
all_stadiums = []
|
||||||
|
|
||||||
|
# Scrape stadiums
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("SCRAPING STADIUMS")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
all_stadiums.extend(scrape_stadiums_hifld())
|
||||||
|
all_stadiums.extend(generate_stadiums_from_teams())
|
||||||
|
|
||||||
|
if args.stadiums_only:
|
||||||
|
export_to_json([], all_stadiums, output_dir)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Scrape schedules
|
||||||
|
if args.sport in ['nba', 'all']:
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(f"SCRAPING NBA {args.season}")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
nba_games_br = scrape_nba_basketball_reference(args.season)
|
||||||
|
nba_season = f"{args.season-1}-{str(args.season)[2:]}" # e.g., "2024-25"
|
||||||
|
nba_games_br = assign_stable_ids(nba_games_br, 'NBA', nba_season)
|
||||||
|
all_games.extend(nba_games_br)
|
||||||
|
|
||||||
|
if args.sport in ['mlb', 'all']:
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(f"SCRAPING MLB {args.season}")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
mlb_games_api = scrape_mlb_statsapi(args.season)
|
||||||
|
# MLB API uses official gamePk which is already stable - no reassignment needed
|
||||||
|
all_games.extend(mlb_games_api)
|
||||||
|
|
||||||
|
if args.sport in ['nhl', 'all']:
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(f"SCRAPING NHL {args.season}")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
nhl_games_hr = scrape_nhl_hockey_reference(args.season)
|
||||||
|
nhl_season = f"{args.season-1}-{str(args.season)[2:]}" # e.g., "2024-25"
|
||||||
|
nhl_games_hr = assign_stable_ids(nhl_games_hr, 'NHL', nhl_season)
|
||||||
|
all_games.extend(nhl_games_hr)
|
||||||
|
|
||||||
|
# Export
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("EXPORTING DATA")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
export_to_json(all_games, all_stadiums, output_dir)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("SUMMARY")
|
||||||
|
print("="*60)
|
||||||
|
print(f"Total games scraped: {len(all_games)}")
|
||||||
|
print(f"Total stadiums: {len(all_stadiums)}")
|
||||||
|
|
||||||
|
# Games by sport
|
||||||
|
by_sport = {}
|
||||||
|
for g in all_games:
|
||||||
|
by_sport[g.sport] = by_sport.get(g.sport, 0) + 1
|
||||||
|
for sport, count in by_sport.items():
|
||||||
|
print(f" {sport}: {count} games")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
61
Scripts/test_cloudkit.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Quick test to query CloudKit records."""
|
||||||
|
|
||||||
|
import json, hashlib, base64, requests, os, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Error: pip install cryptography")
|
||||||
|
|
||||||
|
CONTAINER = "iCloud.com.sportstime.app"
|
||||||
|
HOST = "https://api.apple-cloudkit.com"
|
||||||
|
|
||||||
|
def sign(key_data, date, body, path):
|
||||||
|
key = serialization.load_pem_private_key(key_data, None, default_backend())
|
||||||
|
body_hash = base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()
|
||||||
|
sig = key.sign(f"{date}:{body_hash}:{path}".encode(), ec.ECDSA(hashes.SHA256()))
|
||||||
|
return base64.b64encode(sig).decode()
|
||||||
|
|
||||||
|
def query(key_id, key_data, record_type, env='development'):
|
||||||
|
path = f"/database/1/{CONTAINER}/{env}/public/records/query"
|
||||||
|
body = json.dumps({
|
||||||
|
'query': {'recordType': record_type},
|
||||||
|
'resultsLimit': 10
|
||||||
|
})
|
||||||
|
date = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Apple-CloudKit-Request-KeyID': key_id,
|
||||||
|
'X-Apple-CloudKit-Request-ISO8601Date': date,
|
||||||
|
'X-Apple-CloudKit-Request-SignatureV1': sign(key_data, date, body, path),
|
||||||
|
}
|
||||||
|
r = requests.post(f"{HOST}{path}", headers=headers, data=body, timeout=30)
|
||||||
|
return r.status_code, r.json()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
key_id = os.environ.get('CLOUDKIT_KEY_ID') or (sys.argv[1] if len(sys.argv) > 1 else None)
|
||||||
|
key_file = os.environ.get('CLOUDKIT_KEY_FILE') or (sys.argv[2] if len(sys.argv) > 2 else 'eckey.pem')
|
||||||
|
|
||||||
|
if not key_id:
|
||||||
|
sys.exit("Usage: python test_cloudkit.py KEY_ID [KEY_FILE]")
|
||||||
|
|
||||||
|
key_data = open(key_file, 'rb').read()
|
||||||
|
|
||||||
|
print("Testing CloudKit connection...\n")
|
||||||
|
|
||||||
|
for record_type in ['Stadium', 'Team', 'Game']:
|
||||||
|
status, result = query(key_id, key_data, record_type)
|
||||||
|
count = len(result.get('records', []))
|
||||||
|
print(f"{record_type}: status={status}, records={count}")
|
||||||
|
if count > 0:
|
||||||
|
print(f" Sample: {result['records'][0].get('recordName', 'N/A')}")
|
||||||
|
if 'serverErrorCode' in result:
|
||||||
|
print(f" Error: {result.get('serverErrorCode')}: {result.get('reason')}")
|
||||||
|
|
||||||
|
print("\nFull response for Stadium query:")
|
||||||
|
status, result = query(key_id, key_data, 'Stadium')
|
||||||
|
print(json.dumps(result, indent=2)[:1000])
|
||||||
590
Scripts/validate_data.py
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cross-Validation System for SportsTime App
|
||||||
|
Compares scraped data from multiple sources and flags discrepancies.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python validate_data.py --data-dir ./data
|
||||||
|
python validate_data.py --scrape-and-validate --season 2025
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass, asdict, field
|
||||||
|
from typing import Optional
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# Import scrapers from main script
|
||||||
|
from scrape_schedules import (
|
||||||
|
Game, Stadium,
|
||||||
|
scrape_nba_basketball_reference,
|
||||||
|
scrape_mlb_statsapi, scrape_mlb_baseball_reference,
|
||||||
|
scrape_nhl_hockey_reference,
|
||||||
|
NBA_TEAMS, MLB_TEAMS, NHL_TEAMS,
|
||||||
|
assign_stable_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VALIDATION DATA CLASSES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Discrepancy:
|
||||||
|
"""Represents a discrepancy between sources."""
|
||||||
|
game_key: str
|
||||||
|
field: str # 'date', 'time', 'venue', 'teams', 'missing'
|
||||||
|
source1: str
|
||||||
|
source2: str
|
||||||
|
value1: str
|
||||||
|
value2: str
|
||||||
|
severity: str # 'high', 'medium', 'low'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationReport:
|
||||||
|
"""Summary of validation results."""
|
||||||
|
sport: str
|
||||||
|
season: str
|
||||||
|
sources: list
|
||||||
|
total_games_source1: int = 0
|
||||||
|
total_games_source2: int = 0
|
||||||
|
games_matched: int = 0
|
||||||
|
games_missing_source1: int = 0
|
||||||
|
games_missing_source2: int = 0
|
||||||
|
discrepancies: list = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'sport': self.sport,
|
||||||
|
'season': self.season,
|
||||||
|
'sources': self.sources,
|
||||||
|
'total_games_source1': self.total_games_source1,
|
||||||
|
'total_games_source2': self.total_games_source2,
|
||||||
|
'games_matched': self.games_matched,
|
||||||
|
'games_missing_source1': self.games_missing_source1,
|
||||||
|
'games_missing_source2': self.games_missing_source2,
|
||||||
|
'discrepancies': [asdict(d) for d in self.discrepancies],
|
||||||
|
'discrepancy_summary': self.get_summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_summary(self):
|
||||||
|
by_field = defaultdict(int)
|
||||||
|
by_severity = defaultdict(int)
|
||||||
|
for d in self.discrepancies:
|
||||||
|
by_field[d.field] += 1
|
||||||
|
by_severity[d.severity] += 1
|
||||||
|
return {
|
||||||
|
'by_field': dict(by_field),
|
||||||
|
'by_severity': dict(by_severity)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GAME KEY GENERATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def normalize_abbrev(abbrev: str, sport: str) -> str:
|
||||||
|
"""Normalize team abbreviations across different sources."""
|
||||||
|
abbrev = abbrev.upper().strip()
|
||||||
|
|
||||||
|
if sport == 'MLB':
|
||||||
|
# MLB abbreviation mappings between sources
|
||||||
|
mlb_mappings = {
|
||||||
|
'AZ': 'ARI', 'ARI': 'ARI', # Arizona
|
||||||
|
'ATH': 'OAK', 'OAK': 'OAK', # Oakland/Athletics
|
||||||
|
'CWS': 'CHW', 'CHW': 'CHW', # Chicago White Sox
|
||||||
|
'KC': 'KCR', 'KCR': 'KCR', # Kansas City
|
||||||
|
'SD': 'SDP', 'SDP': 'SDP', # San Diego
|
||||||
|
'SF': 'SFG', 'SFG': 'SFG', # San Francisco
|
||||||
|
'TB': 'TBR', 'TBR': 'TBR', # Tampa Bay
|
||||||
|
'WSH': 'WSN', 'WSN': 'WSN', # Washington
|
||||||
|
}
|
||||||
|
return mlb_mappings.get(abbrev, abbrev)
|
||||||
|
|
||||||
|
elif sport == 'NBA':
|
||||||
|
nba_mappings = {
|
||||||
|
'PHX': 'PHO', 'PHO': 'PHO', # Phoenix
|
||||||
|
'BKN': 'BRK', 'BRK': 'BRK', # Brooklyn
|
||||||
|
'CHA': 'CHO', 'CHO': 'CHO', # Charlotte
|
||||||
|
'NOP': 'NOP', 'NO': 'NOP', # New Orleans
|
||||||
|
}
|
||||||
|
return nba_mappings.get(abbrev, abbrev)
|
||||||
|
|
||||||
|
elif sport == 'NHL':
|
||||||
|
nhl_mappings = {
|
||||||
|
'ARI': 'UTA', 'UTA': 'UTA', # Arizona moved to Utah
|
||||||
|
'VGS': 'VGK', 'VGK': 'VGK', # Vegas
|
||||||
|
}
|
||||||
|
return nhl_mappings.get(abbrev, abbrev)
|
||||||
|
|
||||||
|
return abbrev
|
||||||
|
|
||||||
|
|
||||||
|
def generate_game_key(game: Game) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unique key for matching games across sources.
|
||||||
|
Uses date + normalized team abbreviations (sorted) to match.
|
||||||
|
"""
|
||||||
|
home = normalize_abbrev(game.home_team_abbrev, game.sport)
|
||||||
|
away = normalize_abbrev(game.away_team_abbrev, game.sport)
|
||||||
|
teams = sorted([home, away])
|
||||||
|
return f"{game.date}_{teams[0]}_{teams[1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_team_name(name: str, sport: str) -> str:
|
||||||
|
"""Normalize team name variations."""
|
||||||
|
teams = {'NBA': NBA_TEAMS, 'MLB': MLB_TEAMS, 'NHL': NHL_TEAMS}.get(sport, {})
|
||||||
|
|
||||||
|
name_lower = name.lower().strip()
|
||||||
|
|
||||||
|
# Check against known team names
|
||||||
|
for abbrev, info in teams.items():
|
||||||
|
if name_lower == info['name'].lower():
|
||||||
|
return abbrev
|
||||||
|
# Check city match
|
||||||
|
if name_lower == info['city'].lower():
|
||||||
|
return abbrev
|
||||||
|
# Check partial match
|
||||||
|
if name_lower in info['name'].lower() or info['name'].lower() in name_lower:
|
||||||
|
return abbrev
|
||||||
|
|
||||||
|
return name[:3].upper()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_venue(venue: str) -> str:
|
||||||
|
"""Normalize venue name for comparison."""
|
||||||
|
# Remove common variations
|
||||||
|
normalized = venue.lower().strip()
|
||||||
|
|
||||||
|
# Remove sponsorship prefixes that change
|
||||||
|
replacements = [
|
||||||
|
('at ', ''),
|
||||||
|
('the ', ''),
|
||||||
|
(' stadium', ''),
|
||||||
|
(' arena', ''),
|
||||||
|
(' center', ''),
|
||||||
|
(' field', ''),
|
||||||
|
(' park', ''),
|
||||||
|
('.com', ''),
|
||||||
|
('crypto', 'crypto.com'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for old, new in replacements:
|
||||||
|
normalized = normalized.replace(old, new)
|
||||||
|
|
||||||
|
return normalized.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_time(time_str: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize time format to HH:MM."""
|
||||||
|
if not time_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
time_str = time_str.strip().lower()
|
||||||
|
|
||||||
|
# Handle various formats
|
||||||
|
if 'pm' in time_str or 'am' in time_str:
|
||||||
|
# 12-hour format
|
||||||
|
try:
|
||||||
|
for fmt in ['%I:%M%p', '%I:%M %p', '%I%p']:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(time_str.replace(' ', ''), fmt)
|
||||||
|
return dt.strftime('%H:%M')
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Already 24-hour or just numbers
|
||||||
|
if ':' in time_str:
|
||||||
|
parts = time_str.split(':')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
try:
|
||||||
|
hour = int(parts[0])
|
||||||
|
minute = int(parts[1][:2])
|
||||||
|
return f"{hour:02d}:{minute:02d}"
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return time_str
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CROSS-VALIDATION LOGIC
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def validate_games(
|
||||||
|
games1: list[Game],
|
||||||
|
games2: list[Game],
|
||||||
|
source1_name: str,
|
||||||
|
source2_name: str,
|
||||||
|
sport: str,
|
||||||
|
season: str
|
||||||
|
) -> ValidationReport:
|
||||||
|
"""
|
||||||
|
Compare two lists of games and find discrepancies.
|
||||||
|
"""
|
||||||
|
report = ValidationReport(
|
||||||
|
sport=sport,
|
||||||
|
season=season,
|
||||||
|
sources=[source1_name, source2_name],
|
||||||
|
total_games_source1=len(games1),
|
||||||
|
total_games_source2=len(games2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index games by key
|
||||||
|
games1_by_key = {}
|
||||||
|
for g in games1:
|
||||||
|
key = generate_game_key(g)
|
||||||
|
games1_by_key[key] = g
|
||||||
|
|
||||||
|
games2_by_key = {}
|
||||||
|
for g in games2:
|
||||||
|
key = generate_game_key(g)
|
||||||
|
games2_by_key[key] = g
|
||||||
|
|
||||||
|
# Find matches and discrepancies
|
||||||
|
all_keys = set(games1_by_key.keys()) | set(games2_by_key.keys())
|
||||||
|
|
||||||
|
for key in all_keys:
|
||||||
|
g1 = games1_by_key.get(key)
|
||||||
|
g2 = games2_by_key.get(key)
|
||||||
|
|
||||||
|
if g1 and g2:
|
||||||
|
# Both sources have this game - compare fields
|
||||||
|
report.games_matched += 1
|
||||||
|
|
||||||
|
# Compare dates (should match by key, but double-check)
|
||||||
|
if g1.date != g2.date:
|
||||||
|
report.discrepancies.append(Discrepancy(
|
||||||
|
game_key=key,
|
||||||
|
field='date',
|
||||||
|
source1=source1_name,
|
||||||
|
source2=source2_name,
|
||||||
|
value1=g1.date,
|
||||||
|
value2=g2.date,
|
||||||
|
severity='high'
|
||||||
|
))
|
||||||
|
|
||||||
|
# Compare times
|
||||||
|
time1 = normalize_time(g1.time)
|
||||||
|
time2 = normalize_time(g2.time)
|
||||||
|
if time1 and time2 and time1 != time2:
|
||||||
|
# Check if times are close (within 1 hour - could be timezone)
|
||||||
|
try:
|
||||||
|
t1 = datetime.strptime(time1, '%H:%M')
|
||||||
|
t2 = datetime.strptime(time2, '%H:%M')
|
||||||
|
diff_minutes = abs((t1 - t2).total_seconds() / 60)
|
||||||
|
severity = 'low' if diff_minutes <= 60 else 'medium'
|
||||||
|
except:
|
||||||
|
severity = 'medium'
|
||||||
|
|
||||||
|
report.discrepancies.append(Discrepancy(
|
||||||
|
game_key=key,
|
||||||
|
field='time',
|
||||||
|
source1=source1_name,
|
||||||
|
source2=source2_name,
|
||||||
|
value1=time1 or '',
|
||||||
|
value2=time2 or '',
|
||||||
|
severity=severity
|
||||||
|
))
|
||||||
|
|
||||||
|
# Compare venues
|
||||||
|
venue1 = normalize_venue(g1.venue) if g1.venue else ''
|
||||||
|
venue2 = normalize_venue(g2.venue) if g2.venue else ''
|
||||||
|
if venue1 and venue2 and venue1 != venue2:
|
||||||
|
# Check for partial match
|
||||||
|
if venue1 not in venue2 and venue2 not in venue1:
|
||||||
|
report.discrepancies.append(Discrepancy(
|
||||||
|
game_key=key,
|
||||||
|
field='venue',
|
||||||
|
source1=source1_name,
|
||||||
|
source2=source2_name,
|
||||||
|
value1=g1.venue,
|
||||||
|
value2=g2.venue,
|
||||||
|
severity='low'
|
||||||
|
))
|
||||||
|
|
||||||
|
elif g1 and not g2:
|
||||||
|
# Game only in source 1
|
||||||
|
report.games_missing_source2 += 1
|
||||||
|
|
||||||
|
# Determine severity based on date
|
||||||
|
# Spring training (March before ~25th) and playoffs (Oct+) are expected differences
|
||||||
|
severity = 'high'
|
||||||
|
try:
|
||||||
|
game_date = datetime.strptime(g1.date, '%Y-%m-%d')
|
||||||
|
month = game_date.month
|
||||||
|
day = game_date.day
|
||||||
|
if month == 3 and day < 26: # Spring training
|
||||||
|
severity = 'medium'
|
||||||
|
elif month >= 10: # Playoffs/postseason
|
||||||
|
severity = 'medium'
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
report.discrepancies.append(Discrepancy(
|
||||||
|
game_key=key,
|
||||||
|
field='missing',
|
||||||
|
source1=source1_name,
|
||||||
|
source2=source2_name,
|
||||||
|
value1=f"{g1.away_team} @ {g1.home_team}",
|
||||||
|
value2='NOT FOUND',
|
||||||
|
severity=severity
|
||||||
|
))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Game only in source 2
|
||||||
|
report.games_missing_source1 += 1
|
||||||
|
|
||||||
|
# Determine severity based on date
|
||||||
|
severity = 'high'
|
||||||
|
try:
|
||||||
|
game_date = datetime.strptime(g2.date, '%Y-%m-%d')
|
||||||
|
month = game_date.month
|
||||||
|
day = game_date.day
|
||||||
|
if month == 3 and day < 26: # Spring training
|
||||||
|
severity = 'medium'
|
||||||
|
elif month >= 10: # Playoffs/postseason
|
||||||
|
severity = 'medium'
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
report.discrepancies.append(Discrepancy(
|
||||||
|
game_key=key,
|
||||||
|
field='missing',
|
||||||
|
source1=source1_name,
|
||||||
|
source2=source2_name,
|
||||||
|
value1='NOT FOUND',
|
||||||
|
value2=f"{g2.away_team} @ {g2.home_team}",
|
||||||
|
severity=severity
|
||||||
|
))
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def validate_stadiums(stadiums: list[Stadium]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Validate stadium data for completeness and accuracy.
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
for s in stadiums:
|
||||||
|
# Check for missing coordinates
|
||||||
|
if s.latitude == 0 or s.longitude == 0:
|
||||||
|
issues.append({
|
||||||
|
'stadium': s.name,
|
||||||
|
'sport': s.sport,
|
||||||
|
'issue': 'Missing coordinates',
|
||||||
|
'severity': 'high'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for missing capacity
|
||||||
|
if s.capacity == 0:
|
||||||
|
issues.append({
|
||||||
|
'stadium': s.name,
|
||||||
|
'sport': s.sport,
|
||||||
|
'issue': 'Missing capacity',
|
||||||
|
'severity': 'low'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check coordinate bounds (roughly North America)
|
||||||
|
if s.latitude != 0:
|
||||||
|
if not (24 < s.latitude < 55):
|
||||||
|
issues.append({
|
||||||
|
'stadium': s.name,
|
||||||
|
'sport': s.sport,
|
||||||
|
'issue': f'Latitude {s.latitude} outside expected range',
|
||||||
|
'severity': 'medium'
|
||||||
|
})
|
||||||
|
|
||||||
|
if s.longitude != 0:
|
||||||
|
if not (-130 < s.longitude < -60):
|
||||||
|
issues.append({
|
||||||
|
'stadium': s.name,
|
||||||
|
'sport': s.sport,
|
||||||
|
'issue': f'Longitude {s.longitude} outside expected range',
|
||||||
|
'severity': 'medium'
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MULTI-SOURCE SCRAPING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def scrape_nba_all_sources(season: int) -> dict:
|
||||||
|
"""Scrape NBA from all available sources."""
|
||||||
|
nba_season = f"{season-1}-{str(season)[2:]}"
|
||||||
|
games = scrape_nba_basketball_reference(season)
|
||||||
|
games = assign_stable_ids(games, 'NBA', nba_season)
|
||||||
|
return {
|
||||||
|
'basketball-reference': games,
|
||||||
|
# ESPN requires JS rendering, skip for now
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_mlb_all_sources(season: int) -> dict:
|
||||||
|
"""Scrape MLB from all available sources."""
|
||||||
|
mlb_season = str(season)
|
||||||
|
|
||||||
|
# MLB API uses official gamePk - already stable
|
||||||
|
api_games = scrape_mlb_statsapi(season)
|
||||||
|
|
||||||
|
# Baseball-Reference needs stable IDs
|
||||||
|
br_games = scrape_mlb_baseball_reference(season)
|
||||||
|
br_games = assign_stable_ids(br_games, 'MLB', mlb_season)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'statsapi.mlb.com': api_games,
|
||||||
|
'baseball-reference': br_games,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_nhl_all_sources(season: int) -> dict:
|
||||||
|
"""Scrape NHL from all available sources."""
|
||||||
|
nhl_season = f"{season-1}-{str(season)[2:]}"
|
||||||
|
games = scrape_nhl_hockey_reference(season)
|
||||||
|
games = assign_stable_ids(games, 'NHL', nhl_season)
|
||||||
|
return {
|
||||||
|
'hockey-reference': games,
|
||||||
|
# NHL API requires date iteration, skip for now
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MAIN
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Validate sports data')
|
||||||
|
parser.add_argument('--data-dir', type=str, default='./data', help='Data directory')
|
||||||
|
parser.add_argument('--scrape-and-validate', action='store_true', help='Scrape fresh and validate')
|
||||||
|
parser.add_argument('--season', type=int, default=2025, help='Season year')
|
||||||
|
parser.add_argument('--sport', choices=['nba', 'mlb', 'nhl', 'all'], default='all')
|
||||||
|
parser.add_argument('--output', type=str, default='./data/validation_report.json')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
reports = []
|
||||||
|
stadium_issues = []
|
||||||
|
|
||||||
|
if args.scrape_and_validate:
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("CROSS-VALIDATION MODE")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
# MLB has two good sources - validate
|
||||||
|
if args.sport in ['mlb', 'all']:
|
||||||
|
print(f"\n--- MLB {args.season} ---")
|
||||||
|
mlb_sources = scrape_mlb_all_sources(args.season)
|
||||||
|
|
||||||
|
source_names = list(mlb_sources.keys())
|
||||||
|
if len(source_names) >= 2:
|
||||||
|
games1 = mlb_sources[source_names[0]]
|
||||||
|
games2 = mlb_sources[source_names[1]]
|
||||||
|
|
||||||
|
if games1 and games2:
|
||||||
|
report = validate_games(
|
||||||
|
games1, games2,
|
||||||
|
source_names[0], source_names[1],
|
||||||
|
'MLB', str(args.season)
|
||||||
|
)
|
||||||
|
reports.append(report)
|
||||||
|
print(f" Compared {report.total_games_source1} vs {report.total_games_source2} games")
|
||||||
|
print(f" Matched: {report.games_matched}")
|
||||||
|
print(f" Discrepancies: {len(report.discrepancies)}")
|
||||||
|
|
||||||
|
# NBA (single source for now, but validate data quality)
|
||||||
|
if args.sport in ['nba', 'all']:
|
||||||
|
print(f"\n--- NBA {args.season} ---")
|
||||||
|
nba_sources = scrape_nba_all_sources(args.season)
|
||||||
|
games = nba_sources.get('basketball-reference', [])
|
||||||
|
print(f" Got {len(games)} games from Basketball-Reference")
|
||||||
|
|
||||||
|
# Validate internal consistency
|
||||||
|
teams_seen = defaultdict(int)
|
||||||
|
for g in games:
|
||||||
|
teams_seen[g.home_team_abbrev] += 1
|
||||||
|
teams_seen[g.away_team_abbrev] += 1
|
||||||
|
|
||||||
|
# Each team should have ~82 games
|
||||||
|
for team, count in teams_seen.items():
|
||||||
|
if count < 70 or count > 95:
|
||||||
|
print(f" Warning: {team} has {count} games (expected ~82)")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Load existing data and validate
|
||||||
|
data_dir = Path(args.data_dir)
|
||||||
|
|
||||||
|
# Load games
|
||||||
|
games_file = data_dir / 'games.json'
|
||||||
|
if games_file.exists():
|
||||||
|
with open(games_file) as f:
|
||||||
|
games_data = json.load(f)
|
||||||
|
print(f"\nLoaded {len(games_data)} games from {games_file}")
|
||||||
|
|
||||||
|
# Group by sport and validate counts
|
||||||
|
by_sport = defaultdict(list)
|
||||||
|
for g in games_data:
|
||||||
|
by_sport[g['sport']].append(g)
|
||||||
|
|
||||||
|
for sport, sport_games in by_sport.items():
|
||||||
|
print(f" {sport}: {len(sport_games)} games")
|
||||||
|
|
||||||
|
# Load and validate stadiums
|
||||||
|
stadiums_file = data_dir / 'stadiums.json'
|
||||||
|
if stadiums_file.exists():
|
||||||
|
with open(stadiums_file) as f:
|
||||||
|
stadiums_data = json.load(f)
|
||||||
|
stadiums = [Stadium(**s) for s in stadiums_data]
|
||||||
|
print(f"\nLoaded {len(stadiums)} stadiums from {stadiums_file}")
|
||||||
|
|
||||||
|
stadium_issues = validate_stadiums(stadiums)
|
||||||
|
if stadium_issues:
|
||||||
|
print(f"\nStadium validation issues ({len(stadium_issues)}):")
|
||||||
|
for issue in stadium_issues[:10]:
|
||||||
|
print(f" [{issue['severity'].upper()}] {issue['stadium']}: {issue['issue']}")
|
||||||
|
if len(stadium_issues) > 10:
|
||||||
|
print(f" ... and {len(stadium_issues) - 10} more")
|
||||||
|
|
||||||
|
# Save validation report
|
||||||
|
output_path = Path(args.output)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
full_report = {
|
||||||
|
'generated_at': datetime.now().isoformat(),
|
||||||
|
'season': args.season,
|
||||||
|
'game_validations': [r.to_dict() for r in reports],
|
||||||
|
'stadium_issues': stadium_issues
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(output_path, 'w') as f:
|
||||||
|
json.dump(full_report, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\n Validation report saved to {output_path}")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("VALIDATION SUMMARY")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
total_discrepancies = sum(len(r.discrepancies) for r in reports)
|
||||||
|
high_severity = sum(
|
||||||
|
1 for r in reports
|
||||||
|
for d in r.discrepancies
|
||||||
|
if d.severity == 'high'
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Total game validation reports: {len(reports)}")
|
||||||
|
print(f"Total discrepancies found: {total_discrepancies}")
|
||||||
|
print(f"High severity issues: {high_severity}")
|
||||||
|
print(f"Stadium data issues: {len(stadium_issues)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
600
SportsTime.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
1CA7F9052F0D647300490ABD /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 1CA7F8EB2F0D647100490ABD /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 1CA7F8F22F0D647100490ABD;
|
||||||
|
remoteInfo = SportsTime;
|
||||||
|
};
|
||||||
|
1CA7F90F2F0D647400490ABD /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 1CA7F8EB2F0D647100490ABD /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 1CA7F8F22F0D647100490ABD;
|
||||||
|
remoteInfo = SportsTime;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
1CA7F8F32F0D647100490ABD /* SportsTime.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SportsTime.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
1CA7F9042F0D647300490ABD /* SportsTimeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SportsTimeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
1CA7F90E2F0D647400490ABD /* SportsTimeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SportsTimeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
1CA7F9162F0D647400490ABD /* Exceptions for "SportsTime" folder in "SportsTime" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = 1CA7F8F22F0D647100490ABD /* SportsTime */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
1CA7F8F52F0D647100490ABD /* SportsTime */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
1CA7F9162F0D647400490ABD /* Exceptions for "SportsTime" folder in "SportsTime" target */,
|
||||||
|
);
|
||||||
|
path = SportsTime;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1CA7F9072F0D647300490ABD /* SportsTimeTests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = SportsTimeTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1CA7F9112F0D647400490ABD /* SportsTimeUITests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = SportsTimeUITests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
1CA7F8F02F0D647100490ABD /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
1CA7F9012F0D647300490ABD /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
1CA7F90B2F0D647400490ABD /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
1CA7F8EA2F0D647100490ABD = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1CA7F8F52F0D647100490ABD /* SportsTime */,
|
||||||
|
1CA7F9072F0D647300490ABD /* SportsTimeTests */,
|
||||||
|
1CA7F9112F0D647400490ABD /* SportsTimeUITests */,
|
||||||
|
1CA7F8F42F0D647100490ABD /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1CA7F8F42F0D647100490ABD /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1CA7F8F32F0D647100490ABD /* SportsTime.app */,
|
||||||
|
1CA7F9042F0D647300490ABD /* SportsTimeTests.xctest */,
|
||||||
|
1CA7F90E2F0D647400490ABD /* SportsTimeUITests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
1CA7F8F22F0D647100490ABD /* SportsTime */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 1CA7F9172F0D647400490ABD /* Build configuration list for PBXNativeTarget "SportsTime" */;
|
||||||
|
buildPhases = (
|
||||||
|
1CA7F8EF2F0D647100490ABD /* Sources */,
|
||||||
|
1CA7F8F02F0D647100490ABD /* Frameworks */,
|
||||||
|
1CA7F8F12F0D647100490ABD /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
1CA7F8F52F0D647100490ABD /* SportsTime */,
|
||||||
|
);
|
||||||
|
name = SportsTime;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = SportsTime;
|
||||||
|
productReference = 1CA7F8F32F0D647100490ABD /* SportsTime.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
1CA7F9032F0D647300490ABD /* SportsTimeTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 1CA7F91C2F0D647400490ABD /* Build configuration list for PBXNativeTarget "SportsTimeTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
1CA7F9002F0D647300490ABD /* Sources */,
|
||||||
|
1CA7F9012F0D647300490ABD /* Frameworks */,
|
||||||
|
1CA7F9022F0D647300490ABD /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
1CA7F9062F0D647300490ABD /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
1CA7F9072F0D647300490ABD /* SportsTimeTests */,
|
||||||
|
);
|
||||||
|
name = SportsTimeTests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = SportsTimeTests;
|
||||||
|
productReference = 1CA7F9042F0D647300490ABD /* SportsTimeTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
1CA7F90D2F0D647400490ABD /* SportsTimeUITests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 1CA7F91F2F0D647400490ABD /* Build configuration list for PBXNativeTarget "SportsTimeUITests" */;
|
||||||
|
buildPhases = (
|
||||||
|
1CA7F90A2F0D647400490ABD /* Sources */,
|
||||||
|
1CA7F90B2F0D647400490ABD /* Frameworks */,
|
||||||
|
1CA7F90C2F0D647400490ABD /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
1CA7F9102F0D647400490ABD /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
1CA7F9112F0D647400490ABD /* SportsTimeUITests */,
|
||||||
|
);
|
||||||
|
name = SportsTimeUITests;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = SportsTimeUITests;
|
||||||
|
productReference = 1CA7F90E2F0D647400490ABD /* SportsTimeUITests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
1CA7F8EB2F0D647100490ABD /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2620;
|
||||||
|
LastUpgradeCheck = 2620;
|
||||||
|
TargetAttributes = {
|
||||||
|
1CA7F8F22F0D647100490ABD = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
};
|
||||||
|
1CA7F9032F0D647300490ABD = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
TestTargetID = 1CA7F8F22F0D647100490ABD;
|
||||||
|
};
|
||||||
|
1CA7F90D2F0D647400490ABD = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
TestTargetID = 1CA7F8F22F0D647100490ABD;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 1CA7F8EE2F0D647100490ABD /* Build configuration list for PBXProject "SportsTime" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 1CA7F8EA2F0D647100490ABD;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 1CA7F8F42F0D647100490ABD /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
1CA7F8F22F0D647100490ABD /* SportsTime */,
|
||||||
|
1CA7F9032F0D647300490ABD /* SportsTimeTests */,
|
||||||
|
1CA7F90D2F0D647400490ABD /* SportsTimeUITests */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
1CA7F8F12F0D647100490ABD /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
1CA7F9022F0D647300490ABD /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
1CA7F90C2F0D647400490ABD /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
1CA7F8EF2F0D647100490ABD /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
1CA7F9002F0D647300490ABD /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
1CA7F90A2F0D647400490ABD /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
1CA7F9062F0D647300490ABD /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 1CA7F8F22F0D647100490ABD /* SportsTime */;
|
||||||
|
targetProxy = 1CA7F9052F0D647300490ABD /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
1CA7F9102F0D647400490ABD /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 1CA7F8F22F0D647100490ABD /* SportsTime */;
|
||||||
|
targetProxy = 1CA7F90F2F0D647400490ABD /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
1CA7F9182F0D647400490ABD /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTime.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = SportsTime/Info.plist;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.SportsTime";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
1CA7F9192F0D647400490ABD /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = SportsTime/SportsTime.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = SportsTime/Info.plist;
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.SportsTime";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
1CA7F91A2F0D647400490ABD /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
1CA7F91B2F0D647400490ABD /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
1CA7F91D2F0D647400490ABD /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.SportsTimeTests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SportsTime.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SportsTime";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
1CA7F91E2F0D647400490ABD /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.SportsTimeTests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SportsTime.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SportsTime";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
1CA7F9202F0D647400490ABD /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.SportsTimeUITests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = SportsTime;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
1CA7F9212F0D647400490ABD /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.t-t.SportsTimeUITests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = SportsTime;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
1CA7F8EE2F0D647100490ABD /* Build configuration list for PBXProject "SportsTime" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1CA7F91A2F0D647400490ABD /* Debug */,
|
||||||
|
1CA7F91B2F0D647400490ABD /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
1CA7F9172F0D647400490ABD /* Build configuration list for PBXNativeTarget "SportsTime" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1CA7F9182F0D647400490ABD /* Debug */,
|
||||||
|
1CA7F9192F0D647400490ABD /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
1CA7F91C2F0D647400490ABD /* Build configuration list for PBXNativeTarget "SportsTimeTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1CA7F91D2F0D647400490ABD /* Debug */,
|
||||||
|
1CA7F91E2F0D647400490ABD /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
1CA7F91F2F0D647400490ABD /* Build configuration list for PBXNativeTarget "SportsTimeUITests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1CA7F9202F0D647400490ABD /* Debug */,
|
||||||
|
1CA7F9212F0D647400490ABD /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 1CA7F8EB2F0D647100490ABD /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
35
SportsTime/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
SportsTime/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
193
SportsTime/Core/Models/CloudKit/CKModels.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
SportsTime/Core/Models/Local/SavedTrip.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
108
SportsTime/Core/Services/CloudKitDataProvider.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
211
SportsTime/Core/Services/CloudKitService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
132
SportsTime/Core/Services/DataProvider.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
218
SportsTime/Core/Services/LocationPermissionManager.swift
Normal 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()
|
||||||
|
}
|
||||||
192
SportsTime/Core/Services/LocationService.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
385
SportsTime/Core/Services/StubDataProvider.swift
Normal 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] ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
344
SportsTime/Export/PDFGenerator.swift
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
//
|
||||||
|
// PDFGenerator.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import PDFKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
actor PDFGenerator {
|
||||||
|
|
||||||
|
// MARK: - Generate PDF
|
||||||
|
|
||||||
|
func generatePDF(for trip: Trip, games: [UUID: RichGame]) async throws -> Data {
|
||||||
|
let pageWidth: CGFloat = 612 // Letter size
|
||||||
|
let pageHeight: CGFloat = 792
|
||||||
|
let margin: CGFloat = 50
|
||||||
|
|
||||||
|
let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight))
|
||||||
|
|
||||||
|
let data = pdfRenderer.pdfData { context in
|
||||||
|
var currentY: CGFloat = margin
|
||||||
|
|
||||||
|
// Page 1: Cover
|
||||||
|
context.beginPage()
|
||||||
|
currentY = drawCoverPage(
|
||||||
|
context: context,
|
||||||
|
trip: trip,
|
||||||
|
pageWidth: pageWidth,
|
||||||
|
margin: margin
|
||||||
|
)
|
||||||
|
|
||||||
|
// Page 2+: Itinerary
|
||||||
|
context.beginPage()
|
||||||
|
currentY = margin
|
||||||
|
currentY = drawItineraryHeader(
|
||||||
|
context: context,
|
||||||
|
y: currentY,
|
||||||
|
pageWidth: pageWidth,
|
||||||
|
margin: margin
|
||||||
|
)
|
||||||
|
|
||||||
|
for day in trip.itineraryDays() {
|
||||||
|
// Check if we need a new page
|
||||||
|
if currentY > pageHeight - 200 {
|
||||||
|
context.beginPage()
|
||||||
|
currentY = margin
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY = drawDay(
|
||||||
|
context: context,
|
||||||
|
day: day,
|
||||||
|
games: games,
|
||||||
|
y: currentY,
|
||||||
|
pageWidth: pageWidth,
|
||||||
|
margin: margin
|
||||||
|
)
|
||||||
|
|
||||||
|
currentY += 20 // Space between days
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary page
|
||||||
|
context.beginPage()
|
||||||
|
drawSummaryPage(
|
||||||
|
context: context,
|
||||||
|
trip: trip,
|
||||||
|
pageWidth: pageWidth,
|
||||||
|
margin: margin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cover Page
|
||||||
|
|
||||||
|
private func drawCoverPage(
|
||||||
|
context: UIGraphicsPDFRendererContext,
|
||||||
|
trip: Trip,
|
||||||
|
pageWidth: CGFloat,
|
||||||
|
margin: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
|
var y: CGFloat = 150
|
||||||
|
|
||||||
|
// Title
|
||||||
|
let titleAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.boldSystemFont(ofSize: 32),
|
||||||
|
.foregroundColor: UIColor.black
|
||||||
|
]
|
||||||
|
|
||||||
|
let title = trip.name
|
||||||
|
let titleRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 50)
|
||||||
|
(title as NSString).draw(in: titleRect, withAttributes: titleAttributes)
|
||||||
|
y += 60
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
let subtitleAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.systemFont(ofSize: 18),
|
||||||
|
.foregroundColor: UIColor.darkGray
|
||||||
|
]
|
||||||
|
|
||||||
|
let dateRange = trip.formattedDateRange
|
||||||
|
let dateRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 30)
|
||||||
|
(dateRange as NSString).draw(in: dateRect, withAttributes: subtitleAttributes)
|
||||||
|
y += 50
|
||||||
|
|
||||||
|
// Quick stats
|
||||||
|
let statsAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.systemFont(ofSize: 14),
|
||||||
|
.foregroundColor: UIColor.gray
|
||||||
|
]
|
||||||
|
|
||||||
|
let stats = """
|
||||||
|
\(trip.stops.count) Cities • \(trip.totalGames) Games • \(trip.formattedTotalDistance)
|
||||||
|
\(trip.tripDuration) Days • \(trip.formattedTotalDriving) Driving
|
||||||
|
"""
|
||||||
|
|
||||||
|
let statsRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 50)
|
||||||
|
(stats as NSString).draw(in: statsRect, withAttributes: statsAttributes)
|
||||||
|
y += 80
|
||||||
|
|
||||||
|
// Cities list
|
||||||
|
let citiesTitle = "Cities Visited"
|
||||||
|
let citiesTitleRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 25)
|
||||||
|
(citiesTitle as NSString).draw(in: citiesTitleRect, withAttributes: [
|
||||||
|
.font: UIFont.boldSystemFont(ofSize: 16),
|
||||||
|
.foregroundColor: UIColor.black
|
||||||
|
])
|
||||||
|
y += 30
|
||||||
|
|
||||||
|
for city in trip.cities {
|
||||||
|
let cityRect = CGRect(x: margin + 20, y: y, width: pageWidth - margin * 2 - 20, height: 20)
|
||||||
|
("• \(city)" as NSString).draw(in: cityRect, withAttributes: statsAttributes)
|
||||||
|
y += 22
|
||||||
|
}
|
||||||
|
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Itinerary Header
|
||||||
|
|
||||||
|
private func drawItineraryHeader(
|
||||||
|
context: UIGraphicsPDFRendererContext,
|
||||||
|
y: CGFloat,
|
||||||
|
pageWidth: CGFloat,
|
||||||
|
margin: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
|
let headerAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.boldSystemFont(ofSize: 24),
|
||||||
|
.foregroundColor: UIColor.black
|
||||||
|
]
|
||||||
|
|
||||||
|
let header = "Day-by-Day Itinerary"
|
||||||
|
let headerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 35)
|
||||||
|
(header as NSString).draw(in: headerRect, withAttributes: headerAttributes)
|
||||||
|
|
||||||
|
return y + 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day Section
|
||||||
|
|
||||||
|
private func drawDay(
|
||||||
|
context: UIGraphicsPDFRendererContext,
|
||||||
|
day: ItineraryDay,
|
||||||
|
games: [UUID: RichGame],
|
||||||
|
y: CGFloat,
|
||||||
|
pageWidth: CGFloat,
|
||||||
|
margin: CGFloat
|
||||||
|
) -> CGFloat {
|
||||||
|
var currentY = y
|
||||||
|
|
||||||
|
// Day header
|
||||||
|
let dayHeaderAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.boldSystemFont(ofSize: 16),
|
||||||
|
.foregroundColor: UIColor.systemBlue
|
||||||
|
]
|
||||||
|
|
||||||
|
let dayHeader = "Day \(day.dayNumber): \(day.formattedDate)"
|
||||||
|
let dayHeaderRect = CGRect(x: margin, y: currentY, width: pageWidth - margin * 2, height: 25)
|
||||||
|
(dayHeader as NSString).draw(in: dayHeaderRect, withAttributes: dayHeaderAttributes)
|
||||||
|
currentY += 28
|
||||||
|
|
||||||
|
// City
|
||||||
|
if let city = day.primaryCity {
|
||||||
|
let cityAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.systemFont(ofSize: 14),
|
||||||
|
.foregroundColor: UIColor.darkGray
|
||||||
|
]
|
||||||
|
let cityRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
|
||||||
|
("📍 \(city)" as NSString).draw(in: cityRect, withAttributes: cityAttributes)
|
||||||
|
currentY += 24
|
||||||
|
}
|
||||||
|
|
||||||
|
// Travel segment
|
||||||
|
if day.hasTravelSegment {
|
||||||
|
for segment in day.travelSegments {
|
||||||
|
let travelText = "🚗 \(segment.fromLocation.name) → \(segment.toLocation.name) (\(segment.formattedDistance), \(segment.formattedDuration))"
|
||||||
|
let travelRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
|
||||||
|
(travelText as NSString).draw(in: travelRect, withAttributes: [
|
||||||
|
.font: UIFont.systemFont(ofSize: 12),
|
||||||
|
.foregroundColor: UIColor.gray
|
||||||
|
])
|
||||||
|
currentY += 22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Games
|
||||||
|
for gameId in day.gameIds {
|
||||||
|
if let richGame = games[gameId] {
|
||||||
|
let gameText = "⚾ \(richGame.fullMatchupDescription)"
|
||||||
|
let gameRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
|
||||||
|
(gameText as NSString).draw(in: gameRect, withAttributes: [
|
||||||
|
.font: UIFont.systemFont(ofSize: 13),
|
||||||
|
.foregroundColor: UIColor.black
|
||||||
|
])
|
||||||
|
currentY += 20
|
||||||
|
|
||||||
|
let venueText = " \(richGame.venueDescription) • \(richGame.game.gameTime)"
|
||||||
|
let venueRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 18)
|
||||||
|
(venueText as NSString).draw(in: venueRect, withAttributes: [
|
||||||
|
.font: UIFont.systemFont(ofSize: 11),
|
||||||
|
.foregroundColor: UIColor.gray
|
||||||
|
])
|
||||||
|
currentY += 22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest day indicator
|
||||||
|
if day.isRestDay {
|
||||||
|
let restText = "😴 Rest Day"
|
||||||
|
let restRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
|
||||||
|
(restText as NSString).draw(in: restRect, withAttributes: [
|
||||||
|
.font: UIFont.italicSystemFont(ofSize: 12),
|
||||||
|
.foregroundColor: UIColor.gray
|
||||||
|
])
|
||||||
|
currentY += 22
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator line
|
||||||
|
currentY += 5
|
||||||
|
let path = UIBezierPath()
|
||||||
|
path.move(to: CGPoint(x: margin, y: currentY))
|
||||||
|
path.addLine(to: CGPoint(x: pageWidth - margin, y: currentY))
|
||||||
|
UIColor.lightGray.setStroke()
|
||||||
|
path.lineWidth = 0.5
|
||||||
|
path.stroke()
|
||||||
|
currentY += 10
|
||||||
|
|
||||||
|
return currentY
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Summary Page
|
||||||
|
|
||||||
|
private func drawSummaryPage(
|
||||||
|
context: UIGraphicsPDFRendererContext,
|
||||||
|
trip: Trip,
|
||||||
|
pageWidth: CGFloat,
|
||||||
|
margin: CGFloat
|
||||||
|
) {
|
||||||
|
var y: CGFloat = margin
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let headerAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.boldSystemFont(ofSize: 24),
|
||||||
|
.foregroundColor: UIColor.black
|
||||||
|
]
|
||||||
|
let header = "Trip Summary"
|
||||||
|
let headerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 35)
|
||||||
|
(header as NSString).draw(in: headerRect, withAttributes: headerAttributes)
|
||||||
|
y += 50
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
let statAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: UIFont.systemFont(ofSize: 14),
|
||||||
|
.foregroundColor: UIColor.black
|
||||||
|
]
|
||||||
|
|
||||||
|
let stats = [
|
||||||
|
("Total Duration", "\(trip.tripDuration) days"),
|
||||||
|
("Total Distance", trip.formattedTotalDistance),
|
||||||
|
("Total Driving Time", trip.formattedTotalDriving),
|
||||||
|
("Average Daily Driving", String(format: "%.1f hours", trip.averageDrivingHoursPerDay)),
|
||||||
|
("Cities Visited", "\(trip.stops.count)"),
|
||||||
|
("Games Attended", "\(trip.totalGames)"),
|
||||||
|
("Sports", trip.uniqueSports.map { $0.rawValue }.joined(separator: ", "))
|
||||||
|
]
|
||||||
|
|
||||||
|
for (label, value) in stats {
|
||||||
|
let labelRect = CGRect(x: margin, y: y, width: 200, height: 22)
|
||||||
|
("\(label):" as NSString).draw(in: labelRect, withAttributes: [
|
||||||
|
.font: UIFont.boldSystemFont(ofSize: 13),
|
||||||
|
.foregroundColor: UIColor.darkGray
|
||||||
|
])
|
||||||
|
|
||||||
|
let valueRect = CGRect(x: margin + 200, y: y, width: pageWidth - margin * 2 - 200, height: 22)
|
||||||
|
(value as NSString).draw(in: valueRect, withAttributes: statAttributes)
|
||||||
|
y += 26
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score (if available)
|
||||||
|
if let score = trip.score {
|
||||||
|
y += 20
|
||||||
|
|
||||||
|
let scoreHeader = "Trip Score: \(score.scoreGrade) (\(score.formattedOverallScore)/100)"
|
||||||
|
let scoreRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 30)
|
||||||
|
(scoreHeader as NSString).draw(in: scoreRect, withAttributes: [
|
||||||
|
.font: UIFont.boldSystemFont(ofSize: 18),
|
||||||
|
.foregroundColor: UIColor.systemGreen
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
y = 720
|
||||||
|
let footerText = "Generated by Sport Travel Planner"
|
||||||
|
let footerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 20)
|
||||||
|
(footerText as NSString).draw(in: footerRect, withAttributes: [
|
||||||
|
.font: UIFont.italicSystemFont(ofSize: 10),
|
||||||
|
.foregroundColor: UIColor.lightGray
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Export Service
|
||||||
|
|
||||||
|
actor ExportService {
|
||||||
|
private let pdfGenerator = PDFGenerator()
|
||||||
|
|
||||||
|
func exportToPDF(trip: Trip, games: [UUID: RichGame]) async throws -> URL {
|
||||||
|
let data = try await pdfGenerator.generatePDF(for: trip, games: games)
|
||||||
|
|
||||||
|
let fileName = "\(trip.name.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).pdf"
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||||||
|
|
||||||
|
try data.write(to: url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareTrip(_ trip: Trip) -> URL? {
|
||||||
|
// Generate a shareable deep link
|
||||||
|
// In production, this would create a proper share URL
|
||||||
|
let baseURL = "sportstime://trip/"
|
||||||
|
return URL(string: baseURL + trip.id.uuidString)
|
||||||
|
}
|
||||||
|
}
|
||||||
306
SportsTime/Features/Home/Views/HomeView.swift
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
//
|
||||||
|
// HomeView.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct HomeView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Query(sort: \SavedTrip.updatedAt, order: .reverse) private var savedTrips: [SavedTrip]
|
||||||
|
|
||||||
|
@State private var showNewTrip = false
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
// Home Tab
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// Hero Card
|
||||||
|
heroCard
|
||||||
|
|
||||||
|
// Quick Actions
|
||||||
|
quickActions
|
||||||
|
|
||||||
|
// Saved Trips
|
||||||
|
if !savedTrips.isEmpty {
|
||||||
|
savedTripsSection
|
||||||
|
}
|
||||||
|
|
||||||
|
// Featured / Tips
|
||||||
|
tipsSection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("Sport Travel Planner")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
showNewTrip = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Home", systemImage: "house.fill")
|
||||||
|
}
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
// Schedule Tab
|
||||||
|
NavigationStack {
|
||||||
|
ScheduleListView()
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Schedule", systemImage: "calendar")
|
||||||
|
}
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
// My Trips Tab
|
||||||
|
NavigationStack {
|
||||||
|
SavedTripsListView(trips: savedTrips)
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("My Trips", systemImage: "suitcase.fill")
|
||||||
|
}
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
|
// Settings Tab
|
||||||
|
NavigationStack {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Settings", systemImage: "gear")
|
||||||
|
}
|
||||||
|
.tag(3)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showNewTrip) {
|
||||||
|
TripCreationView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero Card
|
||||||
|
|
||||||
|
private var heroCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Plan Your Ultimate Sports Road Trip")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("Visit multiple stadiums, catch live games, and create unforgettable memories.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showNewTrip = true
|
||||||
|
} label: {
|
||||||
|
Text("Start Planning")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.blue)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.blue.opacity(0.1), .green.opacity(0.1)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Actions
|
||||||
|
|
||||||
|
private var quickActions: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Quick Start")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(Sport.supported) { sport in
|
||||||
|
QuickSportButton(sport: sport) {
|
||||||
|
// Start trip with this sport pre-selected
|
||||||
|
showNewTrip = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Saved Trips
|
||||||
|
|
||||||
|
private var savedTripsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("Recent Trips")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button("See All") {
|
||||||
|
selectedTab = 2
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(savedTrips.prefix(3)) { savedTrip in
|
||||||
|
if let trip = savedTrip.trip {
|
||||||
|
SavedTripCard(trip: trip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tips
|
||||||
|
|
||||||
|
private var tipsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Planning Tips")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
TipRow(icon: "calendar.badge.clock", title: "Check schedules early", subtitle: "Game times can change, sync often")
|
||||||
|
TipRow(icon: "car.fill", title: "Plan rest days", subtitle: "Don't overdo the driving")
|
||||||
|
TipRow(icon: "star.fill", title: "Mark must-sees", subtitle: "Ensure your favorite matchups are included")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
|
||||||
|
struct QuickSportButton: View {
|
||||||
|
let sport: Sport
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: sport.iconName)
|
||||||
|
.font(.title)
|
||||||
|
Text(sport.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SavedTripCard: View {
|
||||||
|
let trip: Trip
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationLink {
|
||||||
|
TripDetailView(trip: trip, games: [:])
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(trip.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text(trip.formattedDateRange)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Label("\(trip.stops.count) cities", systemImage: "mappin")
|
||||||
|
Label("\(trip.totalGames) games", systemImage: "sportscourt")
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TipRow: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Saved Trips List View
|
||||||
|
|
||||||
|
struct SavedTripsListView: View {
|
||||||
|
let trips: [SavedTrip]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if trips.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Saved Trips",
|
||||||
|
systemImage: "suitcase",
|
||||||
|
description: Text("Your planned trips will appear here")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ForEach(trips) { savedTrip in
|
||||||
|
if let trip = savedTrip.trip {
|
||||||
|
NavigationLink {
|
||||||
|
TripDetailView(trip: trip, games: [:])
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(trip.name)
|
||||||
|
.font(.headline)
|
||||||
|
Text(trip.formattedDateRange)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("My Trips")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
HomeView()
|
||||||
|
.modelContainer(for: SavedTrip.self, inMemory: true)
|
||||||
|
}
|
||||||
134
SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
//
|
||||||
|
// ScheduleViewModel.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class ScheduleViewModel {
|
||||||
|
|
||||||
|
// MARK: - Filter State
|
||||||
|
|
||||||
|
var selectedSports: Set<Sport> = Set(Sport.supported)
|
||||||
|
var startDate: Date = Date()
|
||||||
|
var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
||||||
|
var searchText: String = ""
|
||||||
|
|
||||||
|
// MARK: - Data State
|
||||||
|
|
||||||
|
private(set) var games: [RichGame] = []
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var error: Error?
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
var filteredGames: [RichGame] {
|
||||||
|
guard !searchText.isEmpty else { return games }
|
||||||
|
|
||||||
|
let query = searchText.lowercased()
|
||||||
|
return games.filter { game in
|
||||||
|
game.homeTeam.name.lowercased().contains(query) ||
|
||||||
|
game.homeTeam.city.lowercased().contains(query) ||
|
||||||
|
game.awayTeam.name.lowercased().contains(query) ||
|
||||||
|
game.awayTeam.city.lowercased().contains(query) ||
|
||||||
|
game.stadium.name.lowercased().contains(query) ||
|
||||||
|
game.stadium.city.lowercased().contains(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var gamesByDate: [(date: Date, games: [RichGame])] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let grouped = Dictionary(grouping: filteredGames) { game in
|
||||||
|
calendar.startOfDay(for: game.game.dateTime)
|
||||||
|
}
|
||||||
|
return grouped.sorted { $0.key < $1.key }.map { ($0.key, $0.value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasFilters: Bool {
|
||||||
|
selectedSports.count < Sport.supported.count || !searchText.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
func loadGames() async {
|
||||||
|
guard !selectedSports.isEmpty else {
|
||||||
|
games = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Load initial data if needed
|
||||||
|
if dataProvider.teams.isEmpty {
|
||||||
|
await dataProvider.loadInitialData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if data provider had an error
|
||||||
|
if let providerError = dataProvider.errorMessage {
|
||||||
|
self.errorMessage = providerError
|
||||||
|
self.error = dataProvider.error
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
games = try await dataProvider.fetchRichGames(
|
||||||
|
sports: selectedSports,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate
|
||||||
|
)
|
||||||
|
} catch let cloudKitError as CloudKitError {
|
||||||
|
self.error = cloudKitError
|
||||||
|
self.errorMessage = cloudKitError.errorDescription
|
||||||
|
print("CloudKit error loading games: \(cloudKitError.errorDescription ?? "Unknown")")
|
||||||
|
} catch {
|
||||||
|
self.error = error
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
print("Failed to load games: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() {
|
||||||
|
error = nil
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleSport(_ sport: Sport) {
|
||||||
|
if selectedSports.contains(sport) {
|
||||||
|
selectedSports.remove(sport)
|
||||||
|
} else {
|
||||||
|
selectedSports.insert(sport)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetFilters() {
|
||||||
|
selectedSports = Set(Sport.supported)
|
||||||
|
searchText = ""
|
||||||
|
startDate = Date()
|
||||||
|
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
||||||
|
Task {
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDateRange(start: Date, end: Date) {
|
||||||
|
startDate = start
|
||||||
|
endDate = end
|
||||||
|
Task {
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
343
SportsTime/Features/Schedule/Views/ScheduleListView.swift
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
//
|
||||||
|
// ScheduleListView.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ScheduleListView: View {
|
||||||
|
@State private var viewModel = ScheduleViewModel()
|
||||||
|
@State private var showDatePicker = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.games.isEmpty {
|
||||||
|
loadingView
|
||||||
|
} else if let errorMessage = viewModel.errorMessage {
|
||||||
|
errorView(message: errorMessage)
|
||||||
|
} else if viewModel.games.isEmpty {
|
||||||
|
emptyView
|
||||||
|
} else {
|
||||||
|
gamesList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Schedule")
|
||||||
|
.searchable(text: $viewModel.searchText, prompt: "Search teams or venues")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
showDatePicker = true
|
||||||
|
} label: {
|
||||||
|
Label("Date Range", systemImage: "calendar")
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.hasFilters {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.resetFilters()
|
||||||
|
} label: {
|
||||||
|
Label("Clear Filters", systemImage: "xmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.hasFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showDatePicker) {
|
||||||
|
DateRangePickerSheet(
|
||||||
|
startDate: viewModel.startDate,
|
||||||
|
endDate: viewModel.endDate
|
||||||
|
) { start, end in
|
||||||
|
viewModel.updateDateRange(start: start, end: end)
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadGames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sport Filter
|
||||||
|
|
||||||
|
private var sportFilter: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Sport.supported) { sport in
|
||||||
|
SportFilterChip(
|
||||||
|
sport: sport,
|
||||||
|
isSelected: viewModel.selectedSports.contains(sport)
|
||||||
|
) {
|
||||||
|
viewModel.toggleSport(sport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Games List
|
||||||
|
|
||||||
|
private var gamesList: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
sportFilter
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(viewModel.gamesByDate, id: \.date) { dateGroup in
|
||||||
|
Section {
|
||||||
|
ForEach(dateGroup.games) { richGame in
|
||||||
|
GameRowView(game: richGame)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(formatSectionDate(dateGroup.date))
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.loadGames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State
|
||||||
|
|
||||||
|
private var emptyView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
sportFilter
|
||||||
|
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("No Games Found", systemImage: "sportscourt")
|
||||||
|
} description: {
|
||||||
|
Text("Try adjusting your filters or date range")
|
||||||
|
} actions: {
|
||||||
|
Button("Reset Filters") {
|
||||||
|
viewModel.resetFilters()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading State
|
||||||
|
|
||||||
|
private var loadingView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
Text("Loading schedule...")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error State
|
||||||
|
|
||||||
|
private func errorView(message: String) -> some View {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("Unable to Load", systemImage: "exclamationmark.icloud")
|
||||||
|
} description: {
|
||||||
|
Text(message)
|
||||||
|
} actions: {
|
||||||
|
Button {
|
||||||
|
viewModel.clearError()
|
||||||
|
Task {
|
||||||
|
await viewModel.loadGames()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Try Again")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func formatSectionDate(_ date: Date) -> String {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
|
||||||
|
if calendar.isDateInToday(date) {
|
||||||
|
return "Today"
|
||||||
|
} else if calendar.isDateInTomorrow(date) {
|
||||||
|
return "Tomorrow"
|
||||||
|
} else {
|
||||||
|
formatter.dateFormat = "EEEE, MMM d"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sport Filter Chip
|
||||||
|
|
||||||
|
struct SportFilterChip: View {
|
||||||
|
let sport: Sport
|
||||||
|
let isSelected: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: sport.iconName)
|
||||||
|
.font(.caption)
|
||||||
|
Text(sport.rawValue)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(isSelected ? Color.blue : Color(.secondarySystemBackground))
|
||||||
|
.foregroundStyle(isSelected ? .white : .primary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game Row View
|
||||||
|
|
||||||
|
struct GameRowView: View {
|
||||||
|
let game: RichGame
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Teams
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TeamBadge(team: game.awayTeam, isHome: false)
|
||||||
|
Text("@")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TeamBadge(team: game.homeTeam, isHome: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Sport badge
|
||||||
|
Image(systemName: game.game.sport.iconName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game info
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Label(game.game.gameTime, systemImage: "clock")
|
||||||
|
Label(game.stadium.name, systemImage: "building.2")
|
||||||
|
|
||||||
|
if let broadcast = game.game.broadcastInfo {
|
||||||
|
Label(broadcast, systemImage: "tv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Team Badge
|
||||||
|
|
||||||
|
struct TeamBadge: View {
|
||||||
|
let team: Team
|
||||||
|
let isHome: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if let colorHex = team.primaryColor {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: colorHex) ?? .gray)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(team.abbreviation)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(isHome ? .bold : .regular)
|
||||||
|
|
||||||
|
Text(team.city)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Range Picker Sheet
|
||||||
|
|
||||||
|
struct DateRangePickerSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State var startDate: Date
|
||||||
|
@State var endDate: Date
|
||||||
|
let onApply: (Date, Date) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Date Range") {
|
||||||
|
DatePicker("Start", selection: $startDate, displayedComponents: .date)
|
||||||
|
DatePicker("End", selection: $endDate, in: startDate..., displayedComponents: .date)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Next 7 Days") {
|
||||||
|
startDate = Date()
|
||||||
|
endDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Next 14 Days") {
|
||||||
|
startDate = Date()
|
||||||
|
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Next 30 Days") {
|
||||||
|
startDate = Date()
|
||||||
|
endDate = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Dates")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Apply") {
|
||||||
|
onApply(startDate, endDate)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Extension
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
init?(hex: String) {
|
||||||
|
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
||||||
|
|
||||||
|
var rgb: UInt64 = 0
|
||||||
|
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
|
||||||
|
|
||||||
|
let r = Double((rgb & 0xFF0000) >> 16) / 255.0
|
||||||
|
let g = Double((rgb & 0x00FF00) >> 8) / 255.0
|
||||||
|
let b = Double(rgb & 0x0000FF) / 255.0
|
||||||
|
|
||||||
|
self.init(red: r, green: g, blue: b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
ScheduleListView()
|
||||||
|
}
|
||||||
|
}
|
||||||
154
SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
//
|
||||||
|
// SettingsViewModel.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SettingsViewModel {
|
||||||
|
|
||||||
|
// MARK: - User Preferences (persisted via UserDefaults)
|
||||||
|
|
||||||
|
var selectedSports: Set<Sport> {
|
||||||
|
didSet { savePreferences() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxDrivingHoursPerDay: Int {
|
||||||
|
didSet { savePreferences() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var preferredGameTime: PreferredGameTime {
|
||||||
|
didSet { savePreferences() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var includePlayoffGames: Bool {
|
||||||
|
didSet { savePreferences() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var notificationsEnabled: Bool {
|
||||||
|
didSet { savePreferences() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync State
|
||||||
|
|
||||||
|
private(set) var isSyncing = false
|
||||||
|
private(set) var lastSyncDate: Date?
|
||||||
|
private(set) var syncError: String?
|
||||||
|
|
||||||
|
// MARK: - App Info
|
||||||
|
|
||||||
|
let appVersion: String
|
||||||
|
let buildNumber: String
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Load from UserDefaults using local variables first
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
// Selected sports
|
||||||
|
if let sportStrings = defaults.stringArray(forKey: "selectedSports") {
|
||||||
|
self.selectedSports = Set(sportStrings.compactMap { Sport(rawValue: $0) })
|
||||||
|
} else {
|
||||||
|
self.selectedSports = Set(Sport.supported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Travel preferences - use local variable to avoid self access before init complete
|
||||||
|
let savedDrivingHours = defaults.integer(forKey: "maxDrivingHoursPerDay")
|
||||||
|
self.maxDrivingHoursPerDay = savedDrivingHours == 0 ? 8 : savedDrivingHours
|
||||||
|
|
||||||
|
if let timeRaw = defaults.string(forKey: "preferredGameTime"),
|
||||||
|
let time = PreferredGameTime(rawValue: timeRaw) {
|
||||||
|
self.preferredGameTime = time
|
||||||
|
} else {
|
||||||
|
self.preferredGameTime = .evening
|
||||||
|
}
|
||||||
|
|
||||||
|
self.includePlayoffGames = defaults.object(forKey: "includePlayoffGames") as? Bool ?? true
|
||||||
|
self.notificationsEnabled = defaults.object(forKey: "notificationsEnabled") as? Bool ?? true
|
||||||
|
|
||||||
|
// Last sync
|
||||||
|
self.lastSyncDate = defaults.object(forKey: "lastSyncDate") as? Date
|
||||||
|
|
||||||
|
// App info
|
||||||
|
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||||
|
self.buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
func syncSchedules() async {
|
||||||
|
isSyncing = true
|
||||||
|
syncError = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Trigger data reload from provider
|
||||||
|
await AppDataProvider.shared.loadInitialData()
|
||||||
|
|
||||||
|
lastSyncDate = Date()
|
||||||
|
UserDefaults.standard.set(lastSyncDate, forKey: "lastSyncDate")
|
||||||
|
} catch {
|
||||||
|
syncError = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleSport(_ sport: Sport) {
|
||||||
|
if selectedSports.contains(sport) {
|
||||||
|
// Don't allow removing all sports
|
||||||
|
guard selectedSports.count > 1 else { return }
|
||||||
|
selectedSports.remove(sport)
|
||||||
|
} else {
|
||||||
|
selectedSports.insert(sport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetToDefaults() {
|
||||||
|
selectedSports = Set(Sport.supported)
|
||||||
|
maxDrivingHoursPerDay = 8
|
||||||
|
preferredGameTime = .evening
|
||||||
|
includePlayoffGames = true
|
||||||
|
notificationsEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
private func savePreferences() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.set(selectedSports.map(\.rawValue), forKey: "selectedSports")
|
||||||
|
defaults.set(maxDrivingHoursPerDay, forKey: "maxDrivingHoursPerDay")
|
||||||
|
defaults.set(preferredGameTime.rawValue, forKey: "preferredGameTime")
|
||||||
|
defaults.set(includePlayoffGames, forKey: "includePlayoffGames")
|
||||||
|
defaults.set(notificationsEnabled, forKey: "notificationsEnabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
|
enum PreferredGameTime: String, CaseIterable, Identifiable {
|
||||||
|
case any = "any"
|
||||||
|
case afternoon = "afternoon"
|
||||||
|
case evening = "evening"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .any: return "Any Time"
|
||||||
|
case .afternoon: return "Afternoon"
|
||||||
|
case .evening: return "Evening"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .any: return "No preference"
|
||||||
|
case .afternoon: return "1 PM - 5 PM"
|
||||||
|
case .evening: return "6 PM - 10 PM"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
228
SportsTime/Features/Settings/Views/SettingsView.swift
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
//
|
||||||
|
// SettingsView.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@State private var viewModel = SettingsViewModel()
|
||||||
|
@State private var showResetConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
// Sports Preferences
|
||||||
|
sportsSection
|
||||||
|
|
||||||
|
// Travel Preferences
|
||||||
|
travelSection
|
||||||
|
|
||||||
|
// Game Preferences
|
||||||
|
gamePreferencesSection
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
notificationsSection
|
||||||
|
|
||||||
|
// Data Sync
|
||||||
|
dataSection
|
||||||
|
|
||||||
|
// About
|
||||||
|
aboutSection
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
resetSection
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.alert("Reset Settings", isPresented: $showResetConfirmation) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Reset", role: .destructive) {
|
||||||
|
viewModel.resetToDefaults()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("This will reset all settings to their default values.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sports Section
|
||||||
|
|
||||||
|
private var sportsSection: some View {
|
||||||
|
Section {
|
||||||
|
ForEach(Sport.supported) { sport in
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { viewModel.selectedSports.contains(sport) },
|
||||||
|
set: { _ in viewModel.toggleSport(sport) }
|
||||||
|
)) {
|
||||||
|
Label {
|
||||||
|
Text(sport.displayName)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: sport.iconName)
|
||||||
|
.foregroundStyle(sportColor(for: sport))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Favorite Sports")
|
||||||
|
} footer: {
|
||||||
|
Text("Selected sports will be shown by default in schedules and trip planning.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Travel Section
|
||||||
|
|
||||||
|
private var travelSection: some View {
|
||||||
|
Section {
|
||||||
|
Stepper(value: $viewModel.maxDrivingHoursPerDay, in: 2...12) {
|
||||||
|
HStack {
|
||||||
|
Text("Max Driving Per Day")
|
||||||
|
Spacer()
|
||||||
|
Text("\(viewModel.maxDrivingHoursPerDay) hours")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Travel Preferences")
|
||||||
|
} footer: {
|
||||||
|
Text("Trips will be optimized to keep daily driving within this limit.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game Preferences Section
|
||||||
|
|
||||||
|
private var gamePreferencesSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Preferred Game Time", selection: $viewModel.preferredGameTime) {
|
||||||
|
ForEach(PreferredGameTime.allCases) { time in
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(time.displayName)
|
||||||
|
}
|
||||||
|
.tag(time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Include Playoff Games", isOn: $viewModel.includePlayoffGames)
|
||||||
|
} header: {
|
||||||
|
Text("Game Preferences")
|
||||||
|
} footer: {
|
||||||
|
Text("These preferences affect trip optimization.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications Section
|
||||||
|
|
||||||
|
private var notificationsSection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Schedule Updates", isOn: $viewModel.notificationsEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Notifications")
|
||||||
|
} footer: {
|
||||||
|
Text("Get notified when games in your trips are rescheduled.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Section
|
||||||
|
|
||||||
|
private var dataSection: some View {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.syncSchedules()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label("Sync Schedules", systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.isSyncing {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isSyncing)
|
||||||
|
|
||||||
|
if let lastSync = viewModel.lastSyncDate {
|
||||||
|
HStack {
|
||||||
|
Text("Last Sync")
|
||||||
|
Spacer()
|
||||||
|
Text(lastSync, style: .relative)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = viewModel.syncError {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Data")
|
||||||
|
} footer: {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
Text("Using stub data (Simulator mode)")
|
||||||
|
#else
|
||||||
|
Text("Schedule data is synced from CloudKit.")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - About Section
|
||||||
|
|
||||||
|
private var aboutSection: some View {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Version")
|
||||||
|
Spacer()
|
||||||
|
Text("\(viewModel.appVersion) (\(viewModel.buildNumber))")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Link(destination: URL(string: "https://sportstime.app/privacy")!) {
|
||||||
|
Label("Privacy Policy", systemImage: "hand.raised")
|
||||||
|
}
|
||||||
|
|
||||||
|
Link(destination: URL(string: "https://sportstime.app/terms")!) {
|
||||||
|
Label("Terms of Service", systemImage: "doc.text")
|
||||||
|
}
|
||||||
|
|
||||||
|
Link(destination: URL(string: "mailto:support@sportstime.app")!) {
|
||||||
|
Label("Contact Support", systemImage: "envelope")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("About")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reset Section
|
||||||
|
|
||||||
|
private var resetSection: some View {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showResetConfirmation = true
|
||||||
|
} label: {
|
||||||
|
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func sportColor(for sport: Sport) -> Color {
|
||||||
|
switch sport {
|
||||||
|
case .mlb: return .red
|
||||||
|
case .nba: return .orange
|
||||||
|
case .nhl: return .blue
|
||||||
|
case .nfl: return .green
|
||||||
|
case .mls: return .purple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
467
SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
//
|
||||||
|
// TripCreationViewModel.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Observation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class TripCreationViewModel {
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
enum ViewState: Equatable {
|
||||||
|
case editing
|
||||||
|
case planning
|
||||||
|
case completed(Trip)
|
||||||
|
case error(String)
|
||||||
|
|
||||||
|
static func == (lhs: ViewState, rhs: ViewState) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.editing, .editing): return true
|
||||||
|
case (.planning, .planning): return true
|
||||||
|
case (.completed(let t1), .completed(let t2)): return t1.id == t2.id
|
||||||
|
case (.error(let e1), .error(let e2)): return e1 == e2
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var viewState: ViewState = .editing
|
||||||
|
|
||||||
|
// MARK: - Planning Mode
|
||||||
|
|
||||||
|
var planningMode: PlanningMode = .dateRange
|
||||||
|
|
||||||
|
// MARK: - Form Fields
|
||||||
|
|
||||||
|
// Locations (used in .locations mode)
|
||||||
|
var startLocationText: String = ""
|
||||||
|
var endLocationText: String = ""
|
||||||
|
var startLocation: LocationInput?
|
||||||
|
var endLocation: LocationInput?
|
||||||
|
|
||||||
|
// Sports
|
||||||
|
var selectedSports: Set<Sport> = [.mlb]
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
var startDate: Date = Date()
|
||||||
|
var endDate: Date = Date().addingTimeInterval(86400 * 7)
|
||||||
|
|
||||||
|
// Trip duration for game-first mode (days before/after selected games)
|
||||||
|
var tripBufferDays: Int = 2
|
||||||
|
|
||||||
|
// Games
|
||||||
|
var mustSeeGameIds: Set<UUID> = []
|
||||||
|
var availableGames: [RichGame] = []
|
||||||
|
var isLoadingGames: Bool = false
|
||||||
|
|
||||||
|
// Travel
|
||||||
|
var travelMode: TravelMode = .drive
|
||||||
|
var routePreference: RoutePreference = .balanced
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
var useStopCount: Bool = true
|
||||||
|
var numberOfStops: Int = 5
|
||||||
|
var leisureLevel: LeisureLevel = .moderate
|
||||||
|
|
||||||
|
// Optional
|
||||||
|
var mustStopLocations: [LocationInput] = []
|
||||||
|
var preferredCities: [String] = []
|
||||||
|
var needsEVCharging: Bool = false
|
||||||
|
var lodgingType: LodgingType = .hotel
|
||||||
|
var numberOfDrivers: Int = 1
|
||||||
|
var maxDrivingHoursPerDriver: Double = 8
|
||||||
|
var catchOtherSports: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let planningEngine = TripPlanningEngine()
|
||||||
|
private let locationService = LocationService.shared
|
||||||
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
|
||||||
|
// MARK: - Cached Data
|
||||||
|
|
||||||
|
private var teams: [UUID: Team] = [:]
|
||||||
|
private var stadiums: [UUID: Stadium] = [:]
|
||||||
|
private var games: [Game] = []
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
var isFormValid: Bool {
|
||||||
|
switch planningMode {
|
||||||
|
case .dateRange:
|
||||||
|
// Need: sports + valid date range
|
||||||
|
return !selectedSports.isEmpty && endDate > startDate
|
||||||
|
|
||||||
|
case .gameFirst:
|
||||||
|
// Need: at least one selected game + sports
|
||||||
|
return !mustSeeGameIds.isEmpty && !selectedSports.isEmpty
|
||||||
|
|
||||||
|
case .locations:
|
||||||
|
// Need: start + end locations + sports
|
||||||
|
return !startLocationText.isEmpty &&
|
||||||
|
!endLocationText.isEmpty &&
|
||||||
|
!selectedSports.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var formValidationMessage: String? {
|
||||||
|
switch planningMode {
|
||||||
|
case .dateRange:
|
||||||
|
if selectedSports.isEmpty { return "Select at least one sport" }
|
||||||
|
if endDate <= startDate { return "End date must be after start date" }
|
||||||
|
case .gameFirst:
|
||||||
|
if mustSeeGameIds.isEmpty { return "Select at least one game" }
|
||||||
|
if selectedSports.isEmpty { return "Select at least one sport" }
|
||||||
|
case .locations:
|
||||||
|
if startLocationText.isEmpty { return "Enter a starting location" }
|
||||||
|
if endLocationText.isEmpty { return "Enter an ending location" }
|
||||||
|
if selectedSports.isEmpty { return "Select at least one sport" }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tripDurationDays: Int {
|
||||||
|
let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
|
||||||
|
return max(1, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedGamesCount: Int {
|
||||||
|
mustSeeGameIds.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedGames: [RichGame] {
|
||||||
|
availableGames.filter { mustSeeGameIds.contains($0.game.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computed date range for game-first mode based on selected games
|
||||||
|
var gameFirstDateRange: (start: Date, end: Date)? {
|
||||||
|
guard !selectedGames.isEmpty else { return nil }
|
||||||
|
let gameDates = selectedGames.map { $0.game.dateTime }
|
||||||
|
guard let earliest = gameDates.min(),
|
||||||
|
let latest = gameDates.max() else { return nil }
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let bufferedStart = calendar.date(byAdding: .day, value: -tripBufferDays, to: earliest) ?? earliest
|
||||||
|
let bufferedEnd = calendar.date(byAdding: .day, value: tripBufferDays, to: latest) ?? latest
|
||||||
|
return (bufferedStart, bufferedEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
func loadScheduleData() async {
|
||||||
|
do {
|
||||||
|
// Ensure initial data is loaded
|
||||||
|
if dataProvider.teams.isEmpty {
|
||||||
|
await dataProvider.loadInitialData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached teams and stadiums from data provider
|
||||||
|
for team in dataProvider.teams {
|
||||||
|
teams[team.id] = team
|
||||||
|
}
|
||||||
|
for stadium in dataProvider.stadiums {
|
||||||
|
stadiums[stadium.id] = stadium
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch games
|
||||||
|
games = try await dataProvider.fetchGames(
|
||||||
|
sports: selectedSports,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build rich games for display
|
||||||
|
availableGames = games.compactMap { game -> RichGame? in
|
||||||
|
guard let homeTeam = teams[game.homeTeamId],
|
||||||
|
let awayTeam = teams[game.awayTeamId],
|
||||||
|
let stadium = stadiums[game.stadiumId] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
viewState = .error("Failed to load schedule data: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLocations() async {
|
||||||
|
do {
|
||||||
|
if !startLocationText.isEmpty {
|
||||||
|
startLocation = try await locationService.resolveLocation(
|
||||||
|
LocationInput(name: startLocationText, address: startLocationText)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !endLocationText.isEmpty {
|
||||||
|
endLocation = try await locationService.resolveLocation(
|
||||||
|
LocationInput(name: endLocationText, address: endLocationText)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
viewState = .error("Failed to resolve locations: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planTrip() async {
|
||||||
|
guard isFormValid else { return }
|
||||||
|
|
||||||
|
viewState = .planning
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Mode-specific setup
|
||||||
|
var effectiveStartDate = startDate
|
||||||
|
var effectiveEndDate = endDate
|
||||||
|
var resolvedStartLocation: LocationInput?
|
||||||
|
var resolvedEndLocation: LocationInput?
|
||||||
|
|
||||||
|
switch planningMode {
|
||||||
|
case .dateRange:
|
||||||
|
// Use provided date range, no location needed
|
||||||
|
// Games will be found within the date range across all regions
|
||||||
|
effectiveStartDate = startDate
|
||||||
|
effectiveEndDate = endDate
|
||||||
|
|
||||||
|
case .gameFirst:
|
||||||
|
// Calculate date range from selected games + buffer
|
||||||
|
if let dateRange = gameFirstDateRange {
|
||||||
|
effectiveStartDate = dateRange.start
|
||||||
|
effectiveEndDate = dateRange.end
|
||||||
|
}
|
||||||
|
// Derive start/end locations from first/last game stadiums
|
||||||
|
if let firstGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).first,
|
||||||
|
let lastGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).last {
|
||||||
|
resolvedStartLocation = LocationInput(
|
||||||
|
name: firstGame.stadium.city,
|
||||||
|
coordinate: firstGame.stadium.coordinate,
|
||||||
|
address: "\(firstGame.stadium.city), \(firstGame.stadium.state)"
|
||||||
|
)
|
||||||
|
resolvedEndLocation = LocationInput(
|
||||||
|
name: lastGame.stadium.city,
|
||||||
|
coordinate: lastGame.stadium.coordinate,
|
||||||
|
address: "\(lastGame.stadium.city), \(lastGame.stadium.state)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .locations:
|
||||||
|
// Resolve provided locations
|
||||||
|
await resolveLocations()
|
||||||
|
resolvedStartLocation = startLocation
|
||||||
|
resolvedEndLocation = endLocation
|
||||||
|
|
||||||
|
guard resolvedStartLocation != nil, resolvedEndLocation != nil else {
|
||||||
|
viewState = .error("Could not resolve start or end location")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have games data
|
||||||
|
if games.isEmpty {
|
||||||
|
await loadScheduleData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build preferences
|
||||||
|
let preferences = TripPreferences(
|
||||||
|
planningMode: planningMode,
|
||||||
|
startLocation: resolvedStartLocation,
|
||||||
|
endLocation: resolvedEndLocation,
|
||||||
|
sports: selectedSports,
|
||||||
|
mustSeeGameIds: mustSeeGameIds,
|
||||||
|
travelMode: travelMode,
|
||||||
|
startDate: effectiveStartDate,
|
||||||
|
endDate: effectiveEndDate,
|
||||||
|
numberOfStops: useStopCount ? numberOfStops : nil,
|
||||||
|
tripDuration: useStopCount ? nil : tripDurationDays,
|
||||||
|
leisureLevel: leisureLevel,
|
||||||
|
mustStopLocations: mustStopLocations,
|
||||||
|
preferredCities: preferredCities,
|
||||||
|
routePreference: routePreference,
|
||||||
|
needsEVCharging: needsEVCharging,
|
||||||
|
lodgingType: lodgingType,
|
||||||
|
numberOfDrivers: numberOfDrivers,
|
||||||
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||||
|
catchOtherSports: catchOtherSports
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build planning request
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
availableGames: games,
|
||||||
|
teams: teams,
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plan the trip
|
||||||
|
let result = planningEngine.planItineraries(request: request)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let options):
|
||||||
|
guard let bestOption = options.first else {
|
||||||
|
viewState = .error("No valid itinerary found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Convert ItineraryOption to Trip
|
||||||
|
let trip = convertToTrip(option: bestOption, preferences: preferences)
|
||||||
|
viewState = .completed(trip)
|
||||||
|
|
||||||
|
case .failure(let failure):
|
||||||
|
viewState = .error(failureMessage(for: failure))
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
viewState = .error("Trip planning failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleMustSeeGame(_ gameId: UUID) {
|
||||||
|
if mustSeeGameIds.contains(gameId) {
|
||||||
|
mustSeeGameIds.remove(gameId)
|
||||||
|
} else {
|
||||||
|
mustSeeGameIds.insert(gameId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchPlanningMode(_ mode: PlanningMode) {
|
||||||
|
planningMode = mode
|
||||||
|
// Clear mode-specific selections when switching
|
||||||
|
switch mode {
|
||||||
|
case .dateRange:
|
||||||
|
startLocationText = ""
|
||||||
|
endLocationText = ""
|
||||||
|
startLocation = nil
|
||||||
|
endLocation = nil
|
||||||
|
case .gameFirst:
|
||||||
|
// Keep games, clear locations
|
||||||
|
startLocationText = ""
|
||||||
|
endLocationText = ""
|
||||||
|
startLocation = nil
|
||||||
|
endLocation = nil
|
||||||
|
case .locations:
|
||||||
|
// Keep locations, optionally keep selected games
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load games for browsing in game-first mode
|
||||||
|
func loadGamesForBrowsing() async {
|
||||||
|
isLoadingGames = true
|
||||||
|
do {
|
||||||
|
// Ensure initial data is loaded
|
||||||
|
if dataProvider.teams.isEmpty {
|
||||||
|
await dataProvider.loadInitialData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached teams and stadiums from data provider
|
||||||
|
for team in dataProvider.teams {
|
||||||
|
teams[team.id] = team
|
||||||
|
}
|
||||||
|
for stadium in dataProvider.stadiums {
|
||||||
|
stadiums[stadium.id] = stadium
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch games for next 90 days for browsing
|
||||||
|
let browseEndDate = Calendar.current.date(byAdding: .day, value: 90, to: Date()) ?? endDate
|
||||||
|
games = try await dataProvider.fetchGames(
|
||||||
|
sports: selectedSports,
|
||||||
|
startDate: Date(),
|
||||||
|
endDate: browseEndDate
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build rich games for display
|
||||||
|
availableGames = games.compactMap { game -> RichGame? in
|
||||||
|
guard let homeTeam = teams[game.homeTeamId],
|
||||||
|
let awayTeam = teams[game.awayTeamId],
|
||||||
|
let stadium = stadiums[game.stadiumId] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||||
|
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
viewState = .error("Failed to load games: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
isLoadingGames = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMustStopLocation(_ location: LocationInput) {
|
||||||
|
guard !mustStopLocations.contains(where: { $0.name == location.name }) else { return }
|
||||||
|
mustStopLocations.append(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeMustStopLocation(_ location: LocationInput) {
|
||||||
|
mustStopLocations.removeAll { $0.name == location.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
func addPreferredCity(_ city: String) {
|
||||||
|
guard !city.isEmpty, !preferredCities.contains(city) else { return }
|
||||||
|
preferredCities.append(city)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePreferredCity(_ city: String) {
|
||||||
|
preferredCities.removeAll { $0 == city }
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
viewState = .editing
|
||||||
|
planningMode = .dateRange
|
||||||
|
startLocationText = ""
|
||||||
|
endLocationText = ""
|
||||||
|
startLocation = nil
|
||||||
|
endLocation = nil
|
||||||
|
selectedSports = [.mlb]
|
||||||
|
startDate = Date()
|
||||||
|
endDate = Date().addingTimeInterval(86400 * 7)
|
||||||
|
tripBufferDays = 2
|
||||||
|
mustSeeGameIds = []
|
||||||
|
numberOfStops = 5
|
||||||
|
leisureLevel = .moderate
|
||||||
|
mustStopLocations = []
|
||||||
|
preferredCities = []
|
||||||
|
availableGames = []
|
||||||
|
isLoadingGames = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conversion Helpers
|
||||||
|
|
||||||
|
private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip {
|
||||||
|
// Convert ItineraryStops to TripStops
|
||||||
|
let tripStops = option.stops.enumerated().map { index, stop in
|
||||||
|
TripStop(
|
||||||
|
stopNumber: index + 1,
|
||||||
|
city: stop.city,
|
||||||
|
state: stop.state,
|
||||||
|
coordinate: stop.coordinate,
|
||||||
|
arrivalDate: stop.arrivalDate,
|
||||||
|
departureDate: stop.departureDate,
|
||||||
|
games: stop.games,
|
||||||
|
isRestDay: stop.games.isEmpty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Trip(
|
||||||
|
name: generateTripName(from: tripStops),
|
||||||
|
preferences: preferences,
|
||||||
|
stops: tripStops,
|
||||||
|
travelSegments: option.travelSegments,
|
||||||
|
totalGames: option.totalGames,
|
||||||
|
totalDistanceMeters: option.totalDistanceMiles * 1609.34,
|
||||||
|
totalDrivingSeconds: option.totalDrivingHours * 3600
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateTripName(from stops: [TripStop]) -> String {
|
||||||
|
let cities = stops.compactMap { $0.city }.prefix(3)
|
||||||
|
if cities.count <= 1 {
|
||||||
|
return cities.first ?? "Road Trip"
|
||||||
|
}
|
||||||
|
return cities.joined(separator: " → ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failureMessage(for failure: PlanningFailure) -> String {
|
||||||
|
failure.message
|
||||||
|
}
|
||||||
|
}
|
||||||
831
SportsTime/Features/Trip/Views/TripCreationView.swift
Normal file
@@ -0,0 +1,831 @@
|
|||||||
|
//
|
||||||
|
// TripCreationView.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TripCreationView: View {
|
||||||
|
@State private var viewModel = TripCreationViewModel()
|
||||||
|
@State private var showGamePicker = false
|
||||||
|
@State private var showCityInput = false
|
||||||
|
@State private var cityInputType: CityInputType = .mustStop
|
||||||
|
@State private var showLocationBanner = true
|
||||||
|
@State private var showTripDetail = false
|
||||||
|
@State private var completedTrip: Trip?
|
||||||
|
|
||||||
|
enum CityInputType {
|
||||||
|
case mustStop
|
||||||
|
case preferred
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
// Planning Mode Selector
|
||||||
|
planningModeSection
|
||||||
|
|
||||||
|
// Location Permission Banner (only for locations mode)
|
||||||
|
if viewModel.planningMode == .locations && showLocationBanner {
|
||||||
|
Section {
|
||||||
|
LocationPermissionBanner(isPresented: $showLocationBanner)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode-specific sections
|
||||||
|
switch viewModel.planningMode {
|
||||||
|
case .dateRange:
|
||||||
|
// Sports + Dates
|
||||||
|
sportsSection
|
||||||
|
datesSection
|
||||||
|
|
||||||
|
case .gameFirst:
|
||||||
|
// Sports + Game Picker
|
||||||
|
sportsSection
|
||||||
|
gameBrowserSection
|
||||||
|
tripBufferSection
|
||||||
|
|
||||||
|
case .locations:
|
||||||
|
// Locations + Sports + optional games
|
||||||
|
locationSection
|
||||||
|
sportsSection
|
||||||
|
datesSection
|
||||||
|
gamesSection
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common sections
|
||||||
|
travelSection
|
||||||
|
constraintsSection
|
||||||
|
optionalSection
|
||||||
|
|
||||||
|
// Validation message
|
||||||
|
if let message = viewModel.formValidationMessage {
|
||||||
|
Section {
|
||||||
|
Label(message, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Plan Your Trip")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Plan") {
|
||||||
|
Task {
|
||||||
|
await viewModel.planTrip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!viewModel.isFormValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if case .planning = viewModel.viewState {
|
||||||
|
planningOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showGamePicker) {
|
||||||
|
GamePickerSheet(
|
||||||
|
games: viewModel.availableGames,
|
||||||
|
selectedIds: $viewModel.mustSeeGameIds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCityInput) {
|
||||||
|
LocationSearchSheet(inputType: cityInputType) { location in
|
||||||
|
switch cityInputType {
|
||||||
|
case .mustStop:
|
||||||
|
viewModel.addMustStopLocation(location)
|
||||||
|
case .preferred:
|
||||||
|
viewModel.addPreferredCity(location.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: Binding(
|
||||||
|
get: { viewModel.viewState.isError },
|
||||||
|
set: { if !$0 { viewModel.viewState = .editing } }
|
||||||
|
)) {
|
||||||
|
Button("OK") {
|
||||||
|
viewModel.viewState = .editing
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if case .error(let message) = viewModel.viewState {
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $showTripDetail) {
|
||||||
|
if let trip = completedTrip {
|
||||||
|
TripDetailView(trip: trip, games: buildGamesDictionary())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.viewState) { _, newState in
|
||||||
|
if case .completed(let trip) = newState {
|
||||||
|
completedTrip = trip
|
||||||
|
showTripDetail = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: showTripDetail) { _, isShowing in
|
||||||
|
if !isShowing {
|
||||||
|
// User navigated back, reset to editing state
|
||||||
|
viewModel.viewState = .editing
|
||||||
|
completedTrip = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadScheduleData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sections
|
||||||
|
|
||||||
|
private var planningModeSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Planning Mode", selection: $viewModel.planningMode) {
|
||||||
|
ForEach(PlanningMode.allCases) { mode in
|
||||||
|
Label(mode.displayName, systemImage: mode.iconName)
|
||||||
|
.tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
|
|
||||||
|
Text(viewModel.planningMode.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var locationSection: some View {
|
||||||
|
Section("Locations") {
|
||||||
|
TextField("Start Location", text: $viewModel.startLocationText)
|
||||||
|
.textContentType(.addressCity)
|
||||||
|
|
||||||
|
TextField("End Location", text: $viewModel.endLocationText)
|
||||||
|
.textContentType(.addressCity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gameBrowserSection: some View {
|
||||||
|
Section("Select Games") {
|
||||||
|
if viewModel.isLoadingGames {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
Text("Loading games...")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if viewModel.availableGames.isEmpty {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
Text("Loading games...")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadGamesForBrowsing()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
showGamePicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "sportscourt")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Browse Teams & Games")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text("\(viewModel.availableGames.count) games available")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show selected games summary
|
||||||
|
if !viewModel.mustSeeGameIds.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show selected games preview
|
||||||
|
ForEach(viewModel.selectedGames.prefix(3)) { game in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: game.game.sport.iconName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
|
||||||
|
.font(.caption)
|
||||||
|
Spacer()
|
||||||
|
Text(game.game.formattedDate)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.selectedGames.count > 3 {
|
||||||
|
Text("+ \(viewModel.selectedGames.count - 3) more")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tripBufferSection: some View {
|
||||||
|
Section("Trip Duration") {
|
||||||
|
Stepper("Buffer Days: \(viewModel.tripBufferDays)", value: $viewModel.tripBufferDays, in: 0...7)
|
||||||
|
|
||||||
|
if let dateRange = viewModel.gameFirstDateRange {
|
||||||
|
HStack {
|
||||||
|
Text("Trip window:")
|
||||||
|
Spacer()
|
||||||
|
Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Days before first game and after last game for travel/rest")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sportsSection: some View {
|
||||||
|
Section("Sports") {
|
||||||
|
ForEach(Sport.supported) { sport in
|
||||||
|
Toggle(isOn: binding(for: sport)) {
|
||||||
|
Label(sport.rawValue, systemImage: sport.iconName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var datesSection: some View {
|
||||||
|
Section("Dates") {
|
||||||
|
DatePicker("Start Date", selection: $viewModel.startDate, displayedComponents: .date)
|
||||||
|
|
||||||
|
DatePicker("End Date", selection: $viewModel.endDate, displayedComponents: .date)
|
||||||
|
|
||||||
|
Text("\(viewModel.tripDurationDays) day trip")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gamesSection: some View {
|
||||||
|
Section("Must-See Games") {
|
||||||
|
Button {
|
||||||
|
showGamePicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Select Games")
|
||||||
|
Spacer()
|
||||||
|
Text("\(viewModel.selectedGamesCount) selected")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var travelSection: some View {
|
||||||
|
Section("Travel") {
|
||||||
|
Picker("Travel Mode", selection: $viewModel.travelMode) {
|
||||||
|
ForEach(TravelMode.allCases) { mode in
|
||||||
|
Label(mode.displayName, systemImage: mode.iconName)
|
||||||
|
.tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Route Preference", selection: $viewModel.routePreference) {
|
||||||
|
ForEach(RoutePreference.allCases) { pref in
|
||||||
|
Text(pref.displayName).tag(pref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var constraintsSection: some View {
|
||||||
|
Section("Trip Style") {
|
||||||
|
Toggle("Use Stop Count", isOn: $viewModel.useStopCount)
|
||||||
|
|
||||||
|
if viewModel.useStopCount {
|
||||||
|
Stepper("Number of Stops: \(viewModel.numberOfStops)", value: $viewModel.numberOfStops, in: 1...20)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Pace", selection: $viewModel.leisureLevel) {
|
||||||
|
ForEach(LeisureLevel.allCases) { level in
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(level.displayName)
|
||||||
|
}
|
||||||
|
.tag(level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(viewModel.leisureLevel.description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var optionalSection: some View {
|
||||||
|
Section("Optional") {
|
||||||
|
// Must-Stop Locations
|
||||||
|
DisclosureGroup("Must-Stop Locations (\(viewModel.mustStopLocations.count))") {
|
||||||
|
ForEach(viewModel.mustStopLocations, id: \.name) { location in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(location.name)
|
||||||
|
if let address = location.address, !address.isEmpty {
|
||||||
|
Text(address)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.removeMustStopLocation(location)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Add Location") {
|
||||||
|
cityInputType = .mustStop
|
||||||
|
showCityInput = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EV Charging
|
||||||
|
if viewModel.travelMode == .drive {
|
||||||
|
Toggle("EV Charging Needed", isOn: $viewModel.needsEVCharging)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lodging
|
||||||
|
Picker("Lodging Type", selection: $viewModel.lodgingType) {
|
||||||
|
ForEach(LodgingType.allCases) { type in
|
||||||
|
Label(type.displayName, systemImage: type.iconName)
|
||||||
|
.tag(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drivers
|
||||||
|
if viewModel.travelMode == .drive {
|
||||||
|
Stepper("Drivers: \(viewModel.numberOfDrivers)", value: $viewModel.numberOfDrivers, in: 1...4)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Max Hours/Driver/Day")
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(viewModel.maxDrivingHoursPerDriver))h")
|
||||||
|
}
|
||||||
|
Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other Sports
|
||||||
|
Toggle("Find Other Sports Along Route", isOn: $viewModel.catchOtherSports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var planningOverlay: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.4)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
|
||||||
|
Text("Planning your trip...")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Finding the best route and games")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func binding(for sport: Sport) -> Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { viewModel.selectedSports.contains(sport) },
|
||||||
|
set: { isSelected in
|
||||||
|
if isSelected {
|
||||||
|
viewModel.selectedSports.insert(sport)
|
||||||
|
} else {
|
||||||
|
viewModel.selectedSports.remove(sport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGamesDictionary() -> [UUID: RichGame] {
|
||||||
|
Dictionary(uniqueKeysWithValues: viewModel.availableGames.map { ($0.id, $0) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - View State Extensions
|
||||||
|
|
||||||
|
extension TripCreationViewModel.ViewState {
|
||||||
|
var isError: Bool {
|
||||||
|
if case .error = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isCompleted: Bool {
|
||||||
|
if case .completed = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game Picker Sheet (Team-based selection)
|
||||||
|
|
||||||
|
struct GamePickerSheet: View {
|
||||||
|
let games: [RichGame]
|
||||||
|
@Binding var selectedIds: Set<UUID>
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
// Group games by team (both home and away)
|
||||||
|
private var teamsList: [TeamWithGames] {
|
||||||
|
var teamsDict: [UUID: TeamWithGames] = [:]
|
||||||
|
|
||||||
|
for game in games {
|
||||||
|
// Add to home team
|
||||||
|
if var teamData = teamsDict[game.homeTeam.id] {
|
||||||
|
teamData.games.append(game)
|
||||||
|
teamsDict[game.homeTeam.id] = teamData
|
||||||
|
} else {
|
||||||
|
teamsDict[game.homeTeam.id] = TeamWithGames(
|
||||||
|
team: game.homeTeam,
|
||||||
|
sport: game.game.sport,
|
||||||
|
games: [game]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to away team
|
||||||
|
if var teamData = teamsDict[game.awayTeam.id] {
|
||||||
|
teamData.games.append(game)
|
||||||
|
teamsDict[game.awayTeam.id] = teamData
|
||||||
|
} else {
|
||||||
|
teamsDict[game.awayTeam.id] = TeamWithGames(
|
||||||
|
team: game.awayTeam,
|
||||||
|
sport: game.game.sport,
|
||||||
|
games: [game]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return teamsDict.values
|
||||||
|
.sorted { $0.team.name < $1.team.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] {
|
||||||
|
let grouped = Dictionary(grouping: teamsList) { $0.sport }
|
||||||
|
return Sport.supported
|
||||||
|
.filter { grouped[$0] != nil }
|
||||||
|
.map { sport in
|
||||||
|
(sport, grouped[sport]!.sorted { $0.team.name < $1.team.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedGamesCount: Int {
|
||||||
|
selectedIds.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
// Selected games summary
|
||||||
|
if !selectedIds.isEmpty {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("\(selectedGamesCount) game(s) selected")
|
||||||
|
.fontWeight(.medium)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teams by sport
|
||||||
|
ForEach(teamsBySport, id: \.sport.id) { sportGroup in
|
||||||
|
Section(sportGroup.sport.rawValue) {
|
||||||
|
ForEach(sportGroup.teams) { teamData in
|
||||||
|
NavigationLink {
|
||||||
|
TeamGamesView(
|
||||||
|
teamData: teamData,
|
||||||
|
selectedIds: $selectedIds
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
TeamRow(teamData: teamData, selectedIds: selectedIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Teams")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
if !selectedIds.isEmpty {
|
||||||
|
Button("Reset") {
|
||||||
|
selectedIds.removeAll()
|
||||||
|
}
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Team With Games Model
|
||||||
|
|
||||||
|
struct TeamWithGames: Identifiable {
|
||||||
|
let team: Team
|
||||||
|
let sport: Sport
|
||||||
|
var games: [RichGame]
|
||||||
|
|
||||||
|
var id: UUID { team.id }
|
||||||
|
|
||||||
|
var sortedGames: [RichGame] {
|
||||||
|
games.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Team Row
|
||||||
|
|
||||||
|
struct TeamRow: View {
|
||||||
|
let teamData: TeamWithGames
|
||||||
|
let selectedIds: Set<UUID>
|
||||||
|
|
||||||
|
private var selectedCount: Int {
|
||||||
|
teamData.games.filter { selectedIds.contains($0.id) }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Team color indicator
|
||||||
|
if let colorHex = teamData.team.primaryColor {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: colorHex) ?? .gray)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(teamData.team.city) \(teamData.team.name)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
Text("\(teamData.games.count) game(s) available")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if selectedCount > 0 {
|
||||||
|
Text("\(selectedCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(.blue)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Team Games View
|
||||||
|
|
||||||
|
struct TeamGamesView: View {
|
||||||
|
let teamData: TeamWithGames
|
||||||
|
@Binding var selectedIds: Set<UUID>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(teamData.sortedGames) { game in
|
||||||
|
GameRow(game: game, isSelected: selectedIds.contains(game.id)) {
|
||||||
|
if selectedIds.contains(game.id) {
|
||||||
|
selectedIds.remove(game.id)
|
||||||
|
} else {
|
||||||
|
selectedIds.insert(game.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("\(teamData.team.city) \(teamData.team.name)")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GameRow: View {
|
||||||
|
let game: RichGame
|
||||||
|
let isSelected: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(game.matchupDescription)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(game.venueDescription)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("\(game.game.formattedDate) • \(game.game.gameTime)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(isSelected ? .blue : .gray)
|
||||||
|
.font(.title2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GameSelectRow: View {
|
||||||
|
let game: RichGame
|
||||||
|
let isSelected: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
// Sport icon
|
||||||
|
Image(systemName: game.game.sport.iconName)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(isSelected ? .blue : .secondary)
|
||||||
|
.frame(width: 24)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
Text("\(game.game.formattedDate) • \(game.game.gameTime)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(game.stadium.city)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(isSelected ? .blue : .gray.opacity(0.5))
|
||||||
|
.font(.title3)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Location Search Sheet
|
||||||
|
|
||||||
|
struct LocationSearchSheet: View {
|
||||||
|
let inputType: TripCreationView.CityInputType
|
||||||
|
let onAdd: (LocationInput) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var searchResults: [LocationSearchResult] = []
|
||||||
|
@State private var isSearching = false
|
||||||
|
@State private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private let locationService = LocationService.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Search field
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Search cities, addresses, places...", text: $searchText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
if isSearching {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
} else if !searchText.isEmpty {
|
||||||
|
Button {
|
||||||
|
searchText = ""
|
||||||
|
searchResults = []
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
// Results list
|
||||||
|
if searchResults.isEmpty && !searchText.isEmpty && !isSearching {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Results",
|
||||||
|
systemImage: "mappin.slash",
|
||||||
|
description: Text("Try a different search term")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(searchResults) { result in
|
||||||
|
Button {
|
||||||
|
onAdd(result.toLocationInput())
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "mappin.circle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.title2)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(result.name)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if !result.address.isEmpty {
|
||||||
|
Text(result.address)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "plus.circle")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.large])
|
||||||
|
.onChange(of: searchText) { _, newValue in
|
||||||
|
// Debounce search
|
||||||
|
searchTask?.cancel()
|
||||||
|
searchTask = Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(300))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await performSearch(query: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSearch(query: String) async {
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
searchResults = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearching = true
|
||||||
|
do {
|
||||||
|
searchResults = try await locationService.searchLocations(query)
|
||||||
|
} catch {
|
||||||
|
searchResults = []
|
||||||
|
}
|
||||||
|
isSearching = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
TripCreationView()
|
||||||
|
}
|
||||||
883
SportsTime/Features/Trip/Views/TripDetailView.swift
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
//
|
||||||
|
// TripDetailView.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import MapKit
|
||||||
|
|
||||||
|
struct TripDetailView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
|
||||||
|
let trip: Trip
|
||||||
|
let games: [UUID: RichGame]
|
||||||
|
|
||||||
|
@State private var selectedDay: ItineraryDay?
|
||||||
|
@State private var showExportSheet = false
|
||||||
|
@State private var showShareSheet = false
|
||||||
|
@State private var exportURL: URL?
|
||||||
|
@State private var shareURL: URL?
|
||||||
|
@State private var mapCameraPosition: MapCameraPosition = .automatic
|
||||||
|
@State private var isSaved = false
|
||||||
|
@State private var showSaveConfirmation = false
|
||||||
|
@State private var routePolylines: [MKPolyline] = []
|
||||||
|
@State private var isLoadingRoutes = false
|
||||||
|
|
||||||
|
private let exportService = ExportService()
|
||||||
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Header
|
||||||
|
tripHeader
|
||||||
|
|
||||||
|
// Score Card
|
||||||
|
if let score = trip.score {
|
||||||
|
scoreCard(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
statsGrid
|
||||||
|
|
||||||
|
// Map Preview
|
||||||
|
mapPreview
|
||||||
|
|
||||||
|
// Day-by-Day Itinerary
|
||||||
|
itinerarySection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle(trip.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await shareTrip()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await exportPDF()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Export PDF", systemImage: "doc.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
saveTrip()
|
||||||
|
} label: {
|
||||||
|
Label(isSaved ? "Saved" : "Save Trip", systemImage: isSaved ? "bookmark.fill" : "bookmark")
|
||||||
|
}
|
||||||
|
.disabled(isSaved)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showExportSheet) {
|
||||||
|
if let url = exportURL {
|
||||||
|
ShareSheet(items: [url])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showShareSheet) {
|
||||||
|
if let url = shareURL {
|
||||||
|
ShareSheet(items: [url])
|
||||||
|
} else {
|
||||||
|
ShareSheet(items: [trip.name, trip.formattedDateRange])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Trip Saved", isPresented: $showSaveConfirmation) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("Your trip has been saved and can be accessed from My Trips.")
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
checkIfSaved()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
private var tripHeader: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(trip.formattedDateRange)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(Array(trip.uniqueSports), id: \.self) { sport in
|
||||||
|
Label(sport.rawValue, systemImage: sport.iconName)
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Score Card
|
||||||
|
|
||||||
|
private func scoreCard(_ score: TripScore) -> some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text("Trip Score")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text(score.scoreGrade)
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
scoreItem(label: "Games", value: score.gameQualityScore)
|
||||||
|
scoreItem(label: "Route", value: score.routeEfficiencyScore)
|
||||||
|
scoreItem(label: "Balance", value: score.leisureBalanceScore)
|
||||||
|
scoreItem(label: "Prefs", value: score.preferenceAlignmentScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scoreItem(label: String, value: Double) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(String(format: "%.0f", value))
|
||||||
|
.font(.headline)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stats Grid
|
||||||
|
|
||||||
|
private var statsGrid: some View {
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible())
|
||||||
|
], spacing: 16) {
|
||||||
|
statCell(value: "\(trip.tripDuration)", label: "Days", icon: "calendar")
|
||||||
|
statCell(value: "\(trip.stops.count)", label: "Cities", icon: "mappin.circle")
|
||||||
|
statCell(value: "\(trip.totalGames)", label: "Games", icon: "sportscourt")
|
||||||
|
statCell(value: trip.formattedTotalDistance, label: "Distance", icon: "road.lanes")
|
||||||
|
statCell(value: trip.formattedTotalDriving, label: "Driving", icon: "car")
|
||||||
|
statCell(value: String(format: "%.1fh", trip.averageDrivingHoursPerDay), label: "Avg/Day", icon: "gauge.medium")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statCell(value: String, label: String, icon: String) -> some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(value)
|
||||||
|
.font(.headline)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Map Preview
|
||||||
|
|
||||||
|
private var mapPreview: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Route")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if isLoadingRoutes {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map(position: $mapCameraPosition) {
|
||||||
|
// Add markers for each stop
|
||||||
|
ForEach(stopCoordinates.indices, id: \.self) { index in
|
||||||
|
let stop = stopCoordinates[index]
|
||||||
|
Marker(stop.name, coordinate: stop.coordinate)
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add actual driving route polylines
|
||||||
|
ForEach(routePolylines.indices, id: \.self) { index in
|
||||||
|
MapPolyline(routePolylines[index])
|
||||||
|
.stroke(.blue, lineWidth: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 200)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.task {
|
||||||
|
updateMapRegion()
|
||||||
|
await fetchDrivingRoutes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch actual driving routes using MKDirections
|
||||||
|
private func fetchDrivingRoutes() async {
|
||||||
|
let stops = stopCoordinates
|
||||||
|
guard stops.count >= 2 else { return }
|
||||||
|
|
||||||
|
isLoadingRoutes = true
|
||||||
|
var polylines: [MKPolyline] = []
|
||||||
|
|
||||||
|
for i in 0..<(stops.count - 1) {
|
||||||
|
let source = stops[i]
|
||||||
|
let destination = stops[i + 1]
|
||||||
|
|
||||||
|
let request = MKDirections.Request()
|
||||||
|
request.source = MKMapItem(placemark: MKPlacemark(coordinate: source.coordinate))
|
||||||
|
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination.coordinate))
|
||||||
|
request.transportType = .automobile
|
||||||
|
|
||||||
|
let directions = MKDirections(request: request)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await directions.calculate()
|
||||||
|
if let route = response.routes.first {
|
||||||
|
polylines.append(route.polyline)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to straight line if directions fail
|
||||||
|
print("Failed to get directions from \(source.name) to \(destination.name): \(error)")
|
||||||
|
let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2)
|
||||||
|
polylines.append(straightLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
routePolylines = polylines
|
||||||
|
isLoadingRoutes = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get coordinates for all stops (from stop coordinate or stadium)
|
||||||
|
private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] {
|
||||||
|
trip.stops.compactMap { stop -> (String, CLLocationCoordinate2D)? in
|
||||||
|
// First try to use the stop's stored coordinate
|
||||||
|
if let coord = stop.coordinate {
|
||||||
|
return (stop.city, coord)
|
||||||
|
}
|
||||||
|
// Fall back to stadium coordinate if available
|
||||||
|
if let stadiumId = stop.stadium,
|
||||||
|
let stadium = dataProvider.stadium(for: stadiumId) {
|
||||||
|
return (stadium.name, stadium.coordinate)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved stadiums from trip stops (for markers)
|
||||||
|
private var tripStadiums: [Stadium] {
|
||||||
|
trip.stops.compactMap { stop in
|
||||||
|
guard let stadiumId = stop.stadium else { return nil }
|
||||||
|
return dataProvider.stadium(for: stadiumId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateMapRegion() {
|
||||||
|
guard !stopCoordinates.isEmpty else { return }
|
||||||
|
|
||||||
|
let coordinates = stopCoordinates.map(\.coordinate)
|
||||||
|
let lats = coordinates.map(\.latitude)
|
||||||
|
let lons = coordinates.map(\.longitude)
|
||||||
|
|
||||||
|
guard let minLat = lats.min(),
|
||||||
|
let maxLat = lats.max(),
|
||||||
|
let minLon = lons.min(),
|
||||||
|
let maxLon = lons.max() else { return }
|
||||||
|
|
||||||
|
let center = CLLocationCoordinate2D(
|
||||||
|
latitude: (minLat + maxLat) / 2,
|
||||||
|
longitude: (minLon + maxLon) / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add padding to the span
|
||||||
|
let latSpan = (maxLat - minLat) * 1.3 + 0.5
|
||||||
|
let lonSpan = (maxLon - minLon) * 1.3 + 0.5
|
||||||
|
|
||||||
|
mapCameraPosition = .region(MKCoordinateRegion(
|
||||||
|
center: center,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: max(latSpan, 1), longitudeDelta: max(lonSpan, 1))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Itinerary
|
||||||
|
|
||||||
|
private var itinerarySection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Route Options")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
let combinations = computeRouteCombinations()
|
||||||
|
|
||||||
|
if combinations.count == 1 {
|
||||||
|
// Single route - show fully expanded
|
||||||
|
SingleRouteView(
|
||||||
|
route: combinations[0],
|
||||||
|
days: trip.itineraryDays(),
|
||||||
|
games: games
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Multiple combinations - show each as expandable row
|
||||||
|
ForEach(Array(combinations.enumerated()), id: \.offset) { index, route in
|
||||||
|
RouteCombinationRow(
|
||||||
|
routeNumber: index + 1,
|
||||||
|
route: route,
|
||||||
|
days: trip.itineraryDays(),
|
||||||
|
games: games,
|
||||||
|
totalRoutes: combinations.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes all possible route combinations across days
|
||||||
|
private func computeRouteCombinations() -> [[DayChoice]] {
|
||||||
|
let days = trip.itineraryDays()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
// Build options for each day
|
||||||
|
var dayOptions: [[DayChoice]] = []
|
||||||
|
|
||||||
|
for day in days {
|
||||||
|
let dayStart = calendar.startOfDay(for: day.date)
|
||||||
|
|
||||||
|
// Find stops with games on this day
|
||||||
|
let stopsWithGames = day.stops.filter { stop in
|
||||||
|
stop.games.compactMap { games[$0] }.contains { richGame in
|
||||||
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stopsWithGames.isEmpty {
|
||||||
|
// Rest day or travel day - use first stop or create empty
|
||||||
|
if let firstStop = day.stops.first {
|
||||||
|
dayOptions.append([DayChoice(dayNumber: day.dayNumber, stop: firstStop, game: nil)])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create choices for each stop with games
|
||||||
|
let choices = stopsWithGames.compactMap { stop -> DayChoice? in
|
||||||
|
let gamesAtStop = stop.games.compactMap { games[$0] }.filter { richGame in
|
||||||
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
|
}
|
||||||
|
return DayChoice(dayNumber: day.dayNumber, stop: stop, game: gamesAtStop.first)
|
||||||
|
}
|
||||||
|
if !choices.isEmpty {
|
||||||
|
dayOptions.append(choices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute cartesian product of all day options
|
||||||
|
return cartesianProduct(dayOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes cartesian product of arrays
|
||||||
|
private func cartesianProduct(_ arrays: [[DayChoice]]) -> [[DayChoice]] {
|
||||||
|
guard !arrays.isEmpty else { return [[]] }
|
||||||
|
|
||||||
|
var result: [[DayChoice]] = [[]]
|
||||||
|
|
||||||
|
for array in arrays {
|
||||||
|
var newResult: [[DayChoice]] = []
|
||||||
|
for existing in result {
|
||||||
|
for element in array {
|
||||||
|
newResult.append(existing + [element])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = newResult
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detects if there are games in different cities on the same day
|
||||||
|
private func detectConflicts(for day: ItineraryDay) -> DayConflictInfo {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let dayStart = calendar.startOfDay(for: day.date)
|
||||||
|
|
||||||
|
// Find all stops that have games on this specific day
|
||||||
|
let stopsWithGamesToday = day.stops.filter { stop in
|
||||||
|
stop.games.compactMap { games[$0] }.contains { richGame in
|
||||||
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique cities with games today
|
||||||
|
let citiesWithGames = Set(stopsWithGamesToday.map { $0.city })
|
||||||
|
|
||||||
|
if citiesWithGames.count > 1 {
|
||||||
|
return DayConflictInfo(
|
||||||
|
hasConflict: true,
|
||||||
|
conflictingStops: stopsWithGamesToday,
|
||||||
|
conflictingCities: Array(citiesWithGames)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return DayConflictInfo(hasConflict: false, conflictingStops: [], conflictingCities: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func exportPDF() async {
|
||||||
|
do {
|
||||||
|
let url = try await exportService.exportToPDF(trip: trip, games: games)
|
||||||
|
exportURL = url
|
||||||
|
showExportSheet = true
|
||||||
|
} catch {
|
||||||
|
print("Failed to export PDF: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareTrip() async {
|
||||||
|
shareURL = await exportService.shareTrip(trip)
|
||||||
|
showShareSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveTrip() {
|
||||||
|
guard let savedTrip = SavedTrip.from(trip, status: .planned) else {
|
||||||
|
print("Failed to create SavedTrip")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelContext.insert(savedTrip)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try modelContext.save()
|
||||||
|
isSaved = true
|
||||||
|
showSaveConfirmation = true
|
||||||
|
} catch {
|
||||||
|
print("Failed to save trip: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkIfSaved() {
|
||||||
|
let tripId = trip.id
|
||||||
|
let descriptor = FetchDescriptor<SavedTrip>(
|
||||||
|
predicate: #Predicate { $0.id == tripId }
|
||||||
|
)
|
||||||
|
|
||||||
|
if let count = try? modelContext.fetchCount(descriptor), count > 0 {
|
||||||
|
isSaved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day Conflict Info
|
||||||
|
|
||||||
|
struct DayConflictInfo {
|
||||||
|
let hasConflict: Bool
|
||||||
|
let conflictingStops: [TripStop]
|
||||||
|
let conflictingCities: [String]
|
||||||
|
|
||||||
|
var warningMessage: String {
|
||||||
|
guard hasConflict else { return "" }
|
||||||
|
let otherCities = conflictingCities.joined(separator: ", ")
|
||||||
|
return "Scheduling conflict: Games in \(otherCities) on the same day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day Choice (Route Option)
|
||||||
|
|
||||||
|
/// Represents a choice for a single day in a route
|
||||||
|
struct DayChoice: Hashable {
|
||||||
|
let dayNumber: Int
|
||||||
|
let stop: TripStop
|
||||||
|
let game: RichGame?
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(dayNumber)
|
||||||
|
hasher.combine(stop.city)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: DayChoice, rhs: DayChoice) -> Bool {
|
||||||
|
lhs.dayNumber == rhs.dayNumber && lhs.stop.city == rhs.stop.city
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Combination Row (Expandable full route)
|
||||||
|
|
||||||
|
struct RouteCombinationRow: View {
|
||||||
|
let routeNumber: Int
|
||||||
|
let route: [DayChoice]
|
||||||
|
let days: [ItineraryDay]
|
||||||
|
let games: [UUID: RichGame]
|
||||||
|
let totalRoutes: Int
|
||||||
|
|
||||||
|
@State private var isExpanded = false
|
||||||
|
|
||||||
|
/// Summary string like "CLE @ SD → CHC @ ATH → ATL @ LAD"
|
||||||
|
private var routeSummary: String {
|
||||||
|
route.compactMap { choice -> String? in
|
||||||
|
guard let game = choice.game else { return nil }
|
||||||
|
return game.matchupDescription
|
||||||
|
}.joined(separator: " → ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cities in the route
|
||||||
|
private var routeCities: String {
|
||||||
|
route.map { $0.stop.city }.joined(separator: " → ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header (always visible, tappable)
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.25)) {
|
||||||
|
isExpanded.toggle()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
// Route number badge
|
||||||
|
Text("Route \(routeNumber)")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.blue)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
// Game sequence summary
|
||||||
|
Text(routeSummary)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
// Cities
|
||||||
|
Text(routeCities)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color(.tertiarySystemFill))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// Expanded content - full day-by-day itinerary
|
||||||
|
if isExpanded {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ForEach(route, id: \.dayNumber) { choice in
|
||||||
|
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
|
||||||
|
RouteDayCard(day: day, choice: choice, games: games)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(Color.blue.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Single Route View (Auto-expanded when only one option)
|
||||||
|
|
||||||
|
struct SingleRouteView: View {
|
||||||
|
let route: [DayChoice]
|
||||||
|
let days: [ItineraryDay]
|
||||||
|
let games: [UUID: RichGame]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(route, id: \.dayNumber) { choice in
|
||||||
|
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
|
||||||
|
RouteDayCard(day: day, choice: choice, games: games)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Day Card (Individual day within a route)
|
||||||
|
|
||||||
|
struct RouteDayCard: View {
|
||||||
|
let day: ItineraryDay
|
||||||
|
let choice: DayChoice
|
||||||
|
let games: [UUID: RichGame]
|
||||||
|
|
||||||
|
private var gamesOnThisDay: [RichGame] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let dayStart = calendar.startOfDay(for: day.date)
|
||||||
|
|
||||||
|
return choice.stop.games.compactMap { games[$0] }.filter { richGame in
|
||||||
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Day header
|
||||||
|
HStack {
|
||||||
|
Text("Day \(day.dayNumber)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(day.formattedDate)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if gamesOnThisDay.isEmpty {
|
||||||
|
Text("Rest Day")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Color.green.opacity(0.2))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// City
|
||||||
|
Label(choice.stop.city, systemImage: "mappin")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
// Travel
|
||||||
|
if day.hasTravelSegment {
|
||||||
|
ForEach(day.travelSegments) { segment in
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: segment.travelMode.iconName)
|
||||||
|
Text("\(segment.formattedDistance) • \(segment.formattedDuration)")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Games
|
||||||
|
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: richGame.game.sport.iconName)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(richGame.matchupDescription)
|
||||||
|
.font(.subheadline)
|
||||||
|
Spacer()
|
||||||
|
Text(richGame.game.gameTime)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day Card
|
||||||
|
|
||||||
|
struct DayCard: View {
|
||||||
|
let day: ItineraryDay
|
||||||
|
let games: [UUID: RichGame]
|
||||||
|
var specificStop: TripStop? = nil
|
||||||
|
var conflictInfo: DayConflictInfo? = nil
|
||||||
|
|
||||||
|
/// The city to display for this card
|
||||||
|
var primaryCityForDay: String? {
|
||||||
|
// If a specific stop is provided (conflict mode), use that stop's city
|
||||||
|
if let stop = specificStop {
|
||||||
|
return stop.city
|
||||||
|
}
|
||||||
|
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let dayStart = calendar.startOfDay(for: day.date)
|
||||||
|
|
||||||
|
// Find the stop with a game on this day
|
||||||
|
let primaryStop = day.stops.first { stop in
|
||||||
|
stop.games.compactMap { games[$0] }.contains { richGame in
|
||||||
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
|
}
|
||||||
|
} ?? day.stops.first
|
||||||
|
|
||||||
|
return primaryStop?.city
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Games to display on this card
|
||||||
|
var gamesOnThisDay: [RichGame] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let dayStart = calendar.startOfDay(for: day.date)
|
||||||
|
|
||||||
|
// If a specific stop is provided (conflict mode), only show that stop's games
|
||||||
|
if let stop = specificStop {
|
||||||
|
return stop.games.compactMap { games[$0] }.filter { richGame in
|
||||||
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the stop where we're actually located on this day
|
||||||
|
let primaryStop = day.stops.first { stop in
|
||||||
|
stop.games.compactMap { games[$0] }.contains { richGame in
|
||||||
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
|
}
|
||||||
|
} ?? day.stops.first
|
||||||
|
|
||||||
|
guard let stop = primaryStop else { return [] }
|
||||||
|
|
||||||
|
return stop.games.compactMap { games[$0] }.filter { richGame in
|
||||||
|
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this card has a scheduling conflict
|
||||||
|
var hasConflict: Bool {
|
||||||
|
conflictInfo?.hasConflict ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Other cities with conflicting games (excluding current city)
|
||||||
|
var otherConflictingCities: [String] {
|
||||||
|
guard let info = conflictInfo, let currentCity = primaryCityForDay else { return [] }
|
||||||
|
return info.conflictingCities.filter { $0 != currentCity }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
// Conflict warning banner
|
||||||
|
if hasConflict {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Conflict: Also scheduled in \(otherConflictingCities.joined(separator: ", "))")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.orange.opacity(0.15))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day header
|
||||||
|
HStack {
|
||||||
|
Text("Day \(day.dayNumber)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
|
||||||
|
Text(day.formattedDate)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if day.isRestDay && !hasConflict {
|
||||||
|
Text("Rest Day")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(Color.green.opacity(0.2))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// City
|
||||||
|
if let city = primaryCityForDay {
|
||||||
|
Label(city, systemImage: "mappin")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Travel (only show if not in conflict mode, to avoid duplication)
|
||||||
|
if day.hasTravelSegment && specificStop == nil {
|
||||||
|
ForEach(day.travelSegments) { segment in
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: segment.travelMode.iconName)
|
||||||
|
Text("\(segment.formattedDistance) • \(segment.formattedDuration)")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Games
|
||||||
|
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
|
||||||
|
HStack {
|
||||||
|
Image(systemName: richGame.game.sport.iconName)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
Text(richGame.matchupDescription)
|
||||||
|
.font(.subheadline)
|
||||||
|
Spacer()
|
||||||
|
Text(richGame.game.gameTime)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(hasConflict ? Color.orange.opacity(0.05) : Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(hasConflict ? Color.orange.opacity(0.3) : Color.clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Share Sheet
|
||||||
|
|
||||||
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
|
let items: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
TripDetailView(
|
||||||
|
trip: Trip(
|
||||||
|
name: "MLB Road Trip",
|
||||||
|
preferences: TripPreferences(
|
||||||
|
startLocation: LocationInput(name: "New York"),
|
||||||
|
endLocation: LocationInput(name: "Chicago")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
games: [:]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SportsTime/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
278
SportsTime/Planning/Engine/GeographicRouteExplorer.swift
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
//
|
||||||
|
// GeographicRouteExplorer.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Shared logic for finding geographically sensible route variations.
|
||||||
|
// Used by all scenario planners to explore and prune route combinations.
|
||||||
|
//
|
||||||
|
// Key Features:
|
||||||
|
// - Tree exploration with pruning for route combinations
|
||||||
|
// - Geographic sanity check using bounding box diagonal vs actual travel ratio
|
||||||
|
// - Support for "anchor" games that cannot be removed from routes (Scenario B)
|
||||||
|
//
|
||||||
|
// Algorithm Overview:
|
||||||
|
// Given games [A, B, C, D, E] in chronological order, we build a decision tree
|
||||||
|
// where at each node we can either include or skip a game. Routes that would
|
||||||
|
// create excessive zig-zagging are pruned. When anchors are specified, any
|
||||||
|
// route that doesn't include ALL anchors is automatically discarded.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
enum GeographicRouteExplorer {
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
/// Maximum ratio of actual travel to bounding box diagonal.
|
||||||
|
/// Routes exceeding this are considered zig-zags.
|
||||||
|
/// - 1.0x = perfectly linear route
|
||||||
|
/// - 1.5x = some detours, normal
|
||||||
|
/// - 2.0x = significant detours, borderline
|
||||||
|
/// - 2.5x+ = excessive zig-zag, reject
|
||||||
|
private static let maxZigZagRatio = 2.5
|
||||||
|
|
||||||
|
/// Minimum bounding box diagonal (miles) to apply zig-zag check.
|
||||||
|
/// Routes within a small area are always considered sane.
|
||||||
|
private static let minDiagonalForCheck = 100.0
|
||||||
|
|
||||||
|
/// Maximum number of route options to return.
|
||||||
|
private static let maxOptions = 10
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Finds ALL geographically sensible subsets of games.
|
||||||
|
///
|
||||||
|
/// The problem: Games in a date range might be scattered across the country.
|
||||||
|
/// Visiting all of them in chronological order could mean crazy zig-zags.
|
||||||
|
///
|
||||||
|
/// The solution: Explore all possible subsets, keeping those that pass
|
||||||
|
/// geographic sanity. Return multiple options for the user to choose from.
|
||||||
|
///
|
||||||
|
/// Algorithm (tree exploration with pruning):
|
||||||
|
///
|
||||||
|
/// Input: [NY, TX, SC, DEN, NM, CA] (chronological order)
|
||||||
|
///
|
||||||
|
/// Build a decision tree:
|
||||||
|
/// [NY]
|
||||||
|
/// / \
|
||||||
|
/// +TX / \ skip TX
|
||||||
|
/// / \
|
||||||
|
/// [NY,TX] [NY]
|
||||||
|
/// / \ / \
|
||||||
|
/// +SC / \ +SC / \
|
||||||
|
/// ✗ | | |
|
||||||
|
/// (prune) +DEN [NY,SC] ...
|
||||||
|
///
|
||||||
|
/// Each path that reaches the end = one valid option
|
||||||
|
/// Pruning: If adding a game breaks sanity, don't explore that branch
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - games: All games to consider, should be in chronological order
|
||||||
|
/// - stadiums: Dictionary mapping stadium IDs to Stadium objects
|
||||||
|
/// - anchorGameIds: Game IDs that MUST be included in every route (for Scenario B)
|
||||||
|
/// - stopBuilder: Closure that converts games to ItineraryStops
|
||||||
|
///
|
||||||
|
/// - Returns: Array of valid game combinations, sorted by number of games (most first)
|
||||||
|
///
|
||||||
|
static func findAllSensibleRoutes(
|
||||||
|
from games: [Game],
|
||||||
|
stadiums: [UUID: Stadium],
|
||||||
|
anchorGameIds: Set<UUID> = [],
|
||||||
|
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop]
|
||||||
|
) -> [[Game]] {
|
||||||
|
|
||||||
|
// 0-2 games = always sensible, only one option
|
||||||
|
// But still verify anchors are present
|
||||||
|
guard games.count > 2 else {
|
||||||
|
// Verify all anchors are in the game list
|
||||||
|
let gameIds = Set(games.map { $0.id })
|
||||||
|
if anchorGameIds.isSubset(of: gameIds) {
|
||||||
|
return games.isEmpty ? [] : [games]
|
||||||
|
} else {
|
||||||
|
// Missing anchors - no valid routes
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, check if all games already form a sensible route
|
||||||
|
let allStops = stopBuilder(games, stadiums)
|
||||||
|
if isGeographicallySane(stops: allStops) {
|
||||||
|
print("[GeographicExplorer] All \(games.count) games form a sensible route")
|
||||||
|
return [games]
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[GeographicExplorer] Exploring route variations (anchors: \(anchorGameIds.count))...")
|
||||||
|
|
||||||
|
// Explore all valid subsets using recursive tree traversal
|
||||||
|
var validRoutes: [[Game]] = []
|
||||||
|
exploreRoutes(
|
||||||
|
games: games,
|
||||||
|
stadiums: stadiums,
|
||||||
|
anchorGameIds: anchorGameIds,
|
||||||
|
stopBuilder: stopBuilder,
|
||||||
|
currentRoute: [],
|
||||||
|
index: 0,
|
||||||
|
validRoutes: &validRoutes
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter routes that don't contain all anchors
|
||||||
|
let routesWithAnchors = validRoutes.filter { route in
|
||||||
|
let routeGameIds = Set(route.map { $0.id })
|
||||||
|
return anchorGameIds.isSubset(of: routeGameIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by number of games (most games first = best options)
|
||||||
|
let sorted = routesWithAnchors.sorted { $0.count > $1.count }
|
||||||
|
|
||||||
|
// Limit to top options to avoid overwhelming the user
|
||||||
|
let topRoutes = Array(sorted.prefix(maxOptions))
|
||||||
|
|
||||||
|
print("[GeographicExplorer] Found \(routesWithAnchors.count) valid routes with anchors, returning top \(topRoutes.count)")
|
||||||
|
return topRoutes
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Geographic Sanity Check
|
||||||
|
|
||||||
|
/// Determines if a route is geographically sensible or zig-zags excessively.
|
||||||
|
///
|
||||||
|
/// The goal: Reject routes that oscillate back and forth across large distances.
|
||||||
|
/// We want routes that make generally linear progress, not cross-country ping-pong.
|
||||||
|
///
|
||||||
|
/// Algorithm:
|
||||||
|
/// 1. Calculate the "bounding box" of all stops (geographic spread)
|
||||||
|
/// 2. Calculate total travel distance if we visit stops in order
|
||||||
|
/// 3. Compare actual travel to the bounding box diagonal
|
||||||
|
/// 4. If actual travel is WAY more than the diagonal, it's zig-zagging
|
||||||
|
///
|
||||||
|
/// Example VALID:
|
||||||
|
/// Stops: LA, SF, Portland, Seattle
|
||||||
|
/// Bounding box diagonal: ~1,100 miles
|
||||||
|
/// Actual travel: ~1,200 miles (reasonable, mostly linear)
|
||||||
|
/// Ratio: 1.1x → PASS
|
||||||
|
///
|
||||||
|
/// Example INVALID:
|
||||||
|
/// Stops: NY, TX, SC, CA (zig-zag)
|
||||||
|
/// Bounding box diagonal: ~2,500 miles
|
||||||
|
/// Actual travel: ~6,000 miles (back and forth)
|
||||||
|
/// Ratio: 2.4x → FAIL
|
||||||
|
///
|
||||||
|
static func isGeographicallySane(stops: [ItineraryStop]) -> Bool {
|
||||||
|
|
||||||
|
// Single stop or two stops = always valid (no zig-zag possible)
|
||||||
|
guard stops.count > 2 else { return true }
|
||||||
|
|
||||||
|
// Collect all coordinates
|
||||||
|
let coordinates = stops.compactMap { $0.coordinate }
|
||||||
|
guard coordinates.count == stops.count else {
|
||||||
|
// Missing coordinates - can't validate, assume valid
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bounding box
|
||||||
|
let lats = coordinates.map { $0.latitude }
|
||||||
|
let lons = coordinates.map { $0.longitude }
|
||||||
|
|
||||||
|
guard let minLat = lats.min(), let maxLat = lats.max(),
|
||||||
|
let minLon = lons.min(), let maxLon = lons.max() else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate bounding box diagonal distance
|
||||||
|
let corner1 = CLLocationCoordinate2D(latitude: minLat, longitude: minLon)
|
||||||
|
let corner2 = CLLocationCoordinate2D(latitude: maxLat, longitude: maxLon)
|
||||||
|
let diagonalMiles = TravelEstimator.haversineDistanceMiles(from: corner1, to: corner2)
|
||||||
|
|
||||||
|
// Tiny bounding box = all games are close together = always valid
|
||||||
|
if diagonalMiles < minDiagonalForCheck {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate actual travel distance through all stops in order
|
||||||
|
var actualTravelMiles: Double = 0
|
||||||
|
for i in 0..<(stops.count - 1) {
|
||||||
|
let from = stops[i]
|
||||||
|
let to = stops[i + 1]
|
||||||
|
actualTravelMiles += TravelEstimator.calculateDistanceMiles(from: from, to: to)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare: is actual travel reasonable compared to the geographic spread?
|
||||||
|
let ratio = actualTravelMiles / diagonalMiles
|
||||||
|
|
||||||
|
if ratio > maxZigZagRatio {
|
||||||
|
print("[GeographicExplorer] Sanity FAILED: travel=\(Int(actualTravelMiles))mi, diagonal=\(Int(diagonalMiles))mi, ratio=\(String(format: "%.1f", ratio))x")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
/// Recursive helper to explore all valid route combinations.
|
||||||
|
///
|
||||||
|
/// At each game, we have two choices:
|
||||||
|
/// 1. Include the game (if it doesn't break sanity)
|
||||||
|
/// 2. Skip the game (only if it's not an anchor)
|
||||||
|
///
|
||||||
|
/// We explore BOTH branches when possible, building up all valid combinations.
|
||||||
|
/// Anchor games MUST be included - we cannot skip them.
|
||||||
|
///
|
||||||
|
private static func exploreRoutes(
|
||||||
|
games: [Game],
|
||||||
|
stadiums: [UUID: Stadium],
|
||||||
|
anchorGameIds: Set<UUID>,
|
||||||
|
stopBuilder: ([Game], [UUID: Stadium]) -> [ItineraryStop],
|
||||||
|
currentRoute: [Game],
|
||||||
|
index: Int,
|
||||||
|
validRoutes: inout [[Game]]
|
||||||
|
) {
|
||||||
|
// Base case: we've processed all games
|
||||||
|
if index >= games.count {
|
||||||
|
// Only save routes with at least 1 game
|
||||||
|
if !currentRoute.isEmpty {
|
||||||
|
validRoutes.append(currentRoute)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let game = games[index]
|
||||||
|
let isAnchor = anchorGameIds.contains(game.id)
|
||||||
|
|
||||||
|
// Option 1: Try INCLUDING this game
|
||||||
|
var routeWithGame = currentRoute
|
||||||
|
routeWithGame.append(game)
|
||||||
|
let stopsWithGame = stopBuilder(routeWithGame, stadiums)
|
||||||
|
|
||||||
|
if isGeographicallySane(stops: stopsWithGame) {
|
||||||
|
// This branch is valid, continue exploring
|
||||||
|
exploreRoutes(
|
||||||
|
games: games,
|
||||||
|
stadiums: stadiums,
|
||||||
|
anchorGameIds: anchorGameIds,
|
||||||
|
stopBuilder: stopBuilder,
|
||||||
|
currentRoute: routeWithGame,
|
||||||
|
index: index + 1,
|
||||||
|
validRoutes: &validRoutes
|
||||||
|
)
|
||||||
|
} else if isAnchor {
|
||||||
|
// Anchor game breaks sanity - this entire branch is invalid
|
||||||
|
// Don't explore further, don't add to valid routes
|
||||||
|
// (We can't skip an anchor, and including it breaks sanity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option 2: Try SKIPPING this game (only if it's not an anchor)
|
||||||
|
if !isAnchor {
|
||||||
|
exploreRoutes(
|
||||||
|
games: games,
|
||||||
|
stadiums: stadiums,
|
||||||
|
anchorGameIds: anchorGameIds,
|
||||||
|
stopBuilder: stopBuilder,
|
||||||
|
currentRoute: currentRoute,
|
||||||
|
index: index + 1,
|
||||||
|
validRoutes: &validRoutes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
SportsTime/Planning/Engine/ItineraryBuilder.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
//
|
||||||
|
// ItineraryBuilder.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Shared utility for building itineraries with travel segments.
|
||||||
|
// Used by all scenario planners to convert stops into complete itineraries.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Result of building an itinerary from stops.
|
||||||
|
struct BuiltItinerary {
|
||||||
|
let stops: [ItineraryStop]
|
||||||
|
let travelSegments: [TravelSegment]
|
||||||
|
let totalDrivingHours: Double
|
||||||
|
let totalDistanceMiles: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared logic for building itineraries across all scenario planners.
|
||||||
|
enum ItineraryBuilder {
|
||||||
|
|
||||||
|
/// Validation that can be performed on each travel segment.
|
||||||
|
/// Return `true` if the segment is valid, `false` to reject the itinerary.
|
||||||
|
typealias SegmentValidator = (TravelSegment, _ fromStop: ItineraryStop, _ toStop: ItineraryStop) -> Bool
|
||||||
|
|
||||||
|
/// Builds a complete itinerary with travel segments between consecutive stops.
|
||||||
|
///
|
||||||
|
/// Algorithm:
|
||||||
|
/// 1. Handle edge case: single stop = no travel needed
|
||||||
|
/// 2. For each consecutive pair of stops, estimate travel
|
||||||
|
/// 3. Optionally validate each segment with custom validator
|
||||||
|
/// 4. Accumulate driving hours and distance
|
||||||
|
/// 5. Verify invariant: travelSegments.count == stops.count - 1
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - stops: The stops to connect with travel segments
|
||||||
|
/// - constraints: Driving constraints (drivers, max hours per day)
|
||||||
|
/// - logPrefix: Prefix for log messages (e.g., "[ScenarioA]")
|
||||||
|
/// - segmentValidator: Optional validation for each segment
|
||||||
|
///
|
||||||
|
/// - Returns: Built itinerary if successful, nil if any segment fails
|
||||||
|
///
|
||||||
|
static func build(
|
||||||
|
stops: [ItineraryStop],
|
||||||
|
constraints: DrivingConstraints,
|
||||||
|
logPrefix: String = "[ItineraryBuilder]",
|
||||||
|
segmentValidator: SegmentValidator? = nil
|
||||||
|
) -> BuiltItinerary? {
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Edge case: Single stop or empty = no travel needed
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
if stops.count <= 1 {
|
||||||
|
return BuiltItinerary(
|
||||||
|
stops: stops,
|
||||||
|
travelSegments: [],
|
||||||
|
totalDrivingHours: 0,
|
||||||
|
totalDistanceMiles: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Build travel segments between consecutive stops
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
var travelSegments: [TravelSegment] = []
|
||||||
|
var totalDrivingHours: Double = 0
|
||||||
|
var totalDistance: Double = 0
|
||||||
|
|
||||||
|
for index in 0..<(stops.count - 1) {
|
||||||
|
let fromStop = stops[index]
|
||||||
|
let toStop = stops[index + 1]
|
||||||
|
|
||||||
|
// Estimate travel for this segment
|
||||||
|
guard let segment = TravelEstimator.estimate(
|
||||||
|
from: fromStop,
|
||||||
|
to: toStop,
|
||||||
|
constraints: constraints
|
||||||
|
) else {
|
||||||
|
print("\(logPrefix) Failed to estimate travel: \(fromStop.city) -> \(toStop.city)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run optional validator (e.g., arrival time check for Scenario B)
|
||||||
|
if let validator = segmentValidator {
|
||||||
|
if !validator(segment, fromStop, toStop) {
|
||||||
|
print("\(logPrefix) Segment validation failed: \(fromStop.city) -> \(toStop.city)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
travelSegments.append(segment)
|
||||||
|
totalDrivingHours += segment.estimatedDrivingHours
|
||||||
|
totalDistance += segment.estimatedDistanceMiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Verify invariant: segments = stops - 1
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
guard travelSegments.count == stops.count - 1 else {
|
||||||
|
print("\(logPrefix) Invariant violated: \(travelSegments.count) segments for \(stops.count) stops")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuiltItinerary(
|
||||||
|
stops: stops,
|
||||||
|
travelSegments: travelSegments,
|
||||||
|
totalDrivingHours: totalDrivingHours,
|
||||||
|
totalDistanceMiles: totalDistance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Common Validators
|
||||||
|
|
||||||
|
/// Validator that ensures arrival time is before game start (with buffer).
|
||||||
|
/// Used by Scenario B where selected games have fixed start times.
|
||||||
|
///
|
||||||
|
/// - Parameter bufferSeconds: Time buffer before game start (default 1 hour)
|
||||||
|
/// - Returns: Validator closure
|
||||||
|
///
|
||||||
|
static func arrivalBeforeGameStart(bufferSeconds: TimeInterval = 3600) -> SegmentValidator {
|
||||||
|
return { segment, _, toStop in
|
||||||
|
guard let gameStart = toStop.firstGameStart else {
|
||||||
|
return true // No game = no constraint
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = gameStart.addingTimeInterval(-bufferSeconds)
|
||||||
|
if segment.arrivalTime > deadline {
|
||||||
|
print("[ItineraryBuilder] Cannot arrive in time: arrival \(segment.arrivalTime) > deadline \(deadline)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
232
SportsTime/Planning/Engine/RouteCandidateBuilder.swift
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
//
|
||||||
|
// RouteCandidateBuilder.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Builds route candidates for different planning scenarios
|
||||||
|
enum RouteCandidateBuilder {
|
||||||
|
|
||||||
|
// MARK: - Scenario A: Linear Candidates (Date Range)
|
||||||
|
|
||||||
|
/// Builds linear route candidates from games sorted chronologically
|
||||||
|
/// - Parameters:
|
||||||
|
/// - games: Available games sorted by start time
|
||||||
|
/// - mustStop: Optional must-stop location
|
||||||
|
/// - Returns: Array of route candidates
|
||||||
|
static func buildLinearCandidates(
|
||||||
|
games: [Game],
|
||||||
|
stadiums: [UUID: Stadium],
|
||||||
|
mustStop: LocationInput?
|
||||||
|
) -> [RouteCandidate] {
|
||||||
|
guard !games.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group games by stadium
|
||||||
|
var stadiumGames: [UUID: [Game]] = [:]
|
||||||
|
for game in games {
|
||||||
|
stadiumGames[game.stadiumId, default: []].append(game)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build stops from chronological game order
|
||||||
|
var stops: [ItineraryStop] = []
|
||||||
|
var processedStadiums: Set<UUID> = []
|
||||||
|
|
||||||
|
for game in games {
|
||||||
|
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||||
|
processedStadiums.insert(game.stadiumId)
|
||||||
|
|
||||||
|
let gamesAtStop = stadiumGames[game.stadiumId] ?? [game]
|
||||||
|
let sortedGames = gamesAtStop.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
// Look up stadium for coordinates and city info
|
||||||
|
let stadium = stadiums[game.stadiumId]
|
||||||
|
let city = stadium?.city ?? "Unknown"
|
||||||
|
let state = stadium?.state ?? ""
|
||||||
|
let coordinate = stadium?.coordinate
|
||||||
|
|
||||||
|
let location = LocationInput(
|
||||||
|
name: city,
|
||||||
|
coordinate: coordinate,
|
||||||
|
address: stadium?.fullAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
let stop = ItineraryStop(
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
coordinate: coordinate,
|
||||||
|
games: sortedGames.map { $0.id },
|
||||||
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||||
|
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||||
|
location: location,
|
||||||
|
firstGameStart: sortedGames.first?.startTime
|
||||||
|
)
|
||||||
|
stops.append(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !stops.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [RouteCandidate(
|
||||||
|
stops: stops,
|
||||||
|
rationale: "Linear route through \(stops.count) cities"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scenario B: Expand Around Anchors (Selected Games)
|
||||||
|
|
||||||
|
/// Expands route around user-selected anchor games
|
||||||
|
/// - Parameters:
|
||||||
|
/// - anchors: User-selected games (must-see)
|
||||||
|
/// - allGames: All available games
|
||||||
|
/// - dateRange: Trip date range
|
||||||
|
/// - mustStop: Optional must-stop location
|
||||||
|
/// - Returns: Array of route candidates
|
||||||
|
static func expandAroundAnchors(
|
||||||
|
anchors: [Game],
|
||||||
|
allGames: [Game],
|
||||||
|
stadiums: [UUID: Stadium],
|
||||||
|
dateRange: DateInterval,
|
||||||
|
mustStop: LocationInput?
|
||||||
|
) -> [RouteCandidate] {
|
||||||
|
guard !anchors.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with anchor games as the core route
|
||||||
|
let sortedAnchors = anchors.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
// Build stops from anchor games
|
||||||
|
var stops: [ItineraryStop] = []
|
||||||
|
|
||||||
|
for game in sortedAnchors {
|
||||||
|
let stadium = stadiums[game.stadiumId]
|
||||||
|
let city = stadium?.city ?? "Unknown"
|
||||||
|
let state = stadium?.state ?? ""
|
||||||
|
let coordinate = stadium?.coordinate
|
||||||
|
|
||||||
|
let location = LocationInput(
|
||||||
|
name: city,
|
||||||
|
coordinate: coordinate,
|
||||||
|
address: stadium?.fullAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
let stop = ItineraryStop(
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
coordinate: coordinate,
|
||||||
|
games: [game.id],
|
||||||
|
arrivalDate: game.gameDate,
|
||||||
|
departureDate: game.gameDate,
|
||||||
|
location: location,
|
||||||
|
firstGameStart: game.startTime
|
||||||
|
)
|
||||||
|
stops.append(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !stops.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [RouteCandidate(
|
||||||
|
stops: stops,
|
||||||
|
rationale: "Route connecting \(anchors.count) selected games"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scenario C: Directional Routes (Start + End)
|
||||||
|
|
||||||
|
/// Builds directional routes from start to end location
|
||||||
|
/// - Parameters:
|
||||||
|
/// - start: Start location
|
||||||
|
/// - end: End location
|
||||||
|
/// - games: Available games
|
||||||
|
/// - dateRange: Optional trip date range
|
||||||
|
/// - Returns: Array of route candidates
|
||||||
|
static func buildDirectionalRoutes(
|
||||||
|
start: LocationInput,
|
||||||
|
end: LocationInput,
|
||||||
|
games: [Game],
|
||||||
|
stadiums: [UUID: Stadium],
|
||||||
|
dateRange: DateInterval?
|
||||||
|
) -> [RouteCandidate] {
|
||||||
|
// Filter games by date range if provided
|
||||||
|
let filteredGames: [Game]
|
||||||
|
if let range = dateRange {
|
||||||
|
filteredGames = games.filter { range.contains($0.startTime) }
|
||||||
|
} else {
|
||||||
|
filteredGames = games
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !filteredGames.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort games chronologically
|
||||||
|
let sortedGames = filteredGames.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
// Build stops: start -> games -> end
|
||||||
|
var stops: [ItineraryStop] = []
|
||||||
|
|
||||||
|
// Start stop (no games)
|
||||||
|
let startStop = ItineraryStop(
|
||||||
|
city: start.name,
|
||||||
|
state: "",
|
||||||
|
coordinate: start.coordinate,
|
||||||
|
games: [],
|
||||||
|
arrivalDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
|
||||||
|
departureDate: sortedGames.first?.gameDate.addingTimeInterval(-86400) ?? Date(),
|
||||||
|
location: start,
|
||||||
|
firstGameStart: nil
|
||||||
|
)
|
||||||
|
stops.append(startStop)
|
||||||
|
|
||||||
|
// Game stops
|
||||||
|
for game in sortedGames {
|
||||||
|
let stadium = stadiums[game.stadiumId]
|
||||||
|
let city = stadium?.city ?? "Unknown"
|
||||||
|
let state = stadium?.state ?? ""
|
||||||
|
let coordinate = stadium?.coordinate
|
||||||
|
|
||||||
|
let location = LocationInput(
|
||||||
|
name: city,
|
||||||
|
coordinate: coordinate,
|
||||||
|
address: stadium?.fullAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
let stop = ItineraryStop(
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
coordinate: coordinate,
|
||||||
|
games: [game.id],
|
||||||
|
arrivalDate: game.gameDate,
|
||||||
|
departureDate: game.gameDate,
|
||||||
|
location: location,
|
||||||
|
firstGameStart: game.startTime
|
||||||
|
)
|
||||||
|
stops.append(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// End stop (no games)
|
||||||
|
let endStop = ItineraryStop(
|
||||||
|
city: end.name,
|
||||||
|
state: "",
|
||||||
|
coordinate: end.coordinate,
|
||||||
|
games: [],
|
||||||
|
arrivalDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
|
||||||
|
departureDate: sortedGames.last?.gameDate.addingTimeInterval(86400) ?? Date(),
|
||||||
|
location: end,
|
||||||
|
firstGameStart: nil
|
||||||
|
)
|
||||||
|
stops.append(endStop)
|
||||||
|
|
||||||
|
return [RouteCandidate(
|
||||||
|
stops: stops,
|
||||||
|
rationale: "Directional route from \(start.name) to \(end.name)"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
283
SportsTime/Planning/Engine/RouteOptimizer.swift
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
//
|
||||||
|
// RouteOptimizer.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Optimization strategy for ranking itinerary options.
|
||||||
|
enum OptimizationStrategy {
|
||||||
|
case balanced // Balance games vs driving
|
||||||
|
case maximizeGames // Prioritize seeing more games
|
||||||
|
case minimizeDriving // Prioritize shorter routes
|
||||||
|
case scenic // Prioritize scenic routes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route optimizer for ranking and scoring itinerary options.
|
||||||
|
///
|
||||||
|
/// The TSP-solving logic has been moved to scenario-specific candidate
|
||||||
|
/// generation in TripPlanningEngine. This optimizer now focuses on:
|
||||||
|
/// - Ranking multiple route options
|
||||||
|
/// - Scoring routes based on optimization strategy
|
||||||
|
/// - Reordering games within constraints
|
||||||
|
struct RouteOptimizer {
|
||||||
|
|
||||||
|
// MARK: - Route Ranking
|
||||||
|
|
||||||
|
/// Ranks a list of itinerary options based on the optimization strategy.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - options: Unranked itinerary options
|
||||||
|
/// - strategy: Optimization strategy for scoring
|
||||||
|
/// - request: Planning request for context
|
||||||
|
/// - Returns: Options sorted by score (best first) with rank assigned
|
||||||
|
func rankOptions(
|
||||||
|
_ options: [ItineraryOption],
|
||||||
|
strategy: OptimizationStrategy = .balanced,
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> [ItineraryOption] {
|
||||||
|
// Score each option
|
||||||
|
let scoredOptions = options.map { option -> (ItineraryOption, Double) in
|
||||||
|
let score = scoreOption(option, strategy: strategy, request: request)
|
||||||
|
return (option, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (lower is better for our scoring system)
|
||||||
|
let sorted = scoredOptions.sorted { $0.1 < $1.1 }
|
||||||
|
|
||||||
|
// Assign ranks
|
||||||
|
return sorted.enumerated().map { index, scored in
|
||||||
|
ItineraryOption(
|
||||||
|
rank: index + 1,
|
||||||
|
stops: scored.0.stops,
|
||||||
|
travelSegments: scored.0.travelSegments,
|
||||||
|
totalDrivingHours: scored.0.totalDrivingHours,
|
||||||
|
totalDistanceMiles: scored.0.totalDistanceMiles,
|
||||||
|
geographicRationale: scored.0.geographicRationale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scoring
|
||||||
|
|
||||||
|
/// Scores an itinerary option based on the optimization strategy.
|
||||||
|
/// Lower scores are better.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - option: Itinerary option to score
|
||||||
|
/// - strategy: Optimization strategy
|
||||||
|
/// - request: Planning request for context
|
||||||
|
/// - Returns: Score value (lower is better)
|
||||||
|
func scoreOption(
|
||||||
|
_ option: ItineraryOption,
|
||||||
|
strategy: OptimizationStrategy,
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> Double {
|
||||||
|
switch strategy {
|
||||||
|
case .balanced:
|
||||||
|
return scoreBalanced(option, request: request)
|
||||||
|
case .maximizeGames:
|
||||||
|
return scoreMaximizeGames(option, request: request)
|
||||||
|
case .minimizeDriving:
|
||||||
|
return scoreMinimizeDriving(option, request: request)
|
||||||
|
case .scenic:
|
||||||
|
return scoreScenic(option, request: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Balanced scoring: trade-off between games and driving.
|
||||||
|
private func scoreBalanced(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||||
|
// Each game "saves" 2 hours of driving in value
|
||||||
|
let gameValue = Double(option.totalGames) * 2.0
|
||||||
|
let drivingPenalty = option.totalDrivingHours
|
||||||
|
|
||||||
|
// Also factor in must-see games coverage
|
||||||
|
let mustSeeCoverage = calculateMustSeeCoverage(option, request: request)
|
||||||
|
let mustSeeBonus = mustSeeCoverage * 10.0 // Strong bonus for must-see coverage
|
||||||
|
|
||||||
|
return drivingPenalty - gameValue - mustSeeBonus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximize games scoring: prioritize number of games.
|
||||||
|
private func scoreMaximizeGames(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||||
|
// Heavily weight game count
|
||||||
|
let gameScore = -Double(option.totalGames) * 100.0
|
||||||
|
let drivingPenalty = option.totalDrivingHours * 0.1 // Minimal driving penalty
|
||||||
|
|
||||||
|
return gameScore + drivingPenalty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimize driving scoring: prioritize shorter routes.
|
||||||
|
private func scoreMinimizeDriving(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||||
|
// Primarily driving time
|
||||||
|
let drivingScore = option.totalDrivingHours
|
||||||
|
let gameBonus = Double(option.totalGames) * 0.5 // Small bonus for games
|
||||||
|
|
||||||
|
return drivingScore - gameBonus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scenic scoring: balance games with route pleasantness.
|
||||||
|
private func scoreScenic(_ option: ItineraryOption, request: PlanningRequest) -> Double {
|
||||||
|
// More relaxed pacing is better
|
||||||
|
let gamesPerDay = Double(option.totalGames) / Double(max(1, option.stops.count))
|
||||||
|
let pacingScore = abs(gamesPerDay - 1.5) * 5.0 // Ideal is ~1.5 games per day
|
||||||
|
|
||||||
|
let drivingScore = option.totalDrivingHours * 0.3
|
||||||
|
let gameBonus = Double(option.totalGames) * 2.0
|
||||||
|
|
||||||
|
return pacingScore + drivingScore - gameBonus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates what percentage of must-see games are covered.
|
||||||
|
private func calculateMustSeeCoverage(
|
||||||
|
_ option: ItineraryOption,
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> Double {
|
||||||
|
let mustSeeIds = request.preferences.mustSeeGameIds
|
||||||
|
if mustSeeIds.isEmpty { return 1.0 }
|
||||||
|
|
||||||
|
let coveredGames = option.stops.flatMap { $0.games }
|
||||||
|
let coveredMustSee = Set(coveredGames).intersection(mustSeeIds)
|
||||||
|
|
||||||
|
return Double(coveredMustSee.count) / Double(mustSeeIds.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Improvement
|
||||||
|
|
||||||
|
/// Attempts to improve a route by swapping non-essential stops.
|
||||||
|
/// Only applies to stops without must-see games.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - option: Itinerary option to improve
|
||||||
|
/// - request: Planning request for context
|
||||||
|
/// - Returns: Improved itinerary option, or original if no improvement found
|
||||||
|
func improveRoute(
|
||||||
|
_ option: ItineraryOption,
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> ItineraryOption {
|
||||||
|
// For now, return as-is since games must remain in chronological order
|
||||||
|
// Future: implement swap logic for same-day games in different cities
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation Helpers
|
||||||
|
|
||||||
|
/// Checks if all must-see games are included in the option.
|
||||||
|
func includesAllMustSeeGames(
|
||||||
|
_ option: ItineraryOption,
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> Bool {
|
||||||
|
let includedGames = Set(option.stops.flatMap { $0.games })
|
||||||
|
return request.preferences.mustSeeGameIds.isSubset(of: includedGames)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns must-see games that are missing from the option.
|
||||||
|
func missingMustSeeGames(
|
||||||
|
_ option: ItineraryOption,
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> Set<UUID> {
|
||||||
|
let includedGames = Set(option.stops.flatMap { $0.games })
|
||||||
|
return request.preferences.mustSeeGameIds.subtracting(includedGames)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Distance Calculations
|
||||||
|
|
||||||
|
/// Calculates total route distance for a sequence of coordinates.
|
||||||
|
func totalDistance(for coordinates: [CLLocationCoordinate2D]) -> Double {
|
||||||
|
guard coordinates.count >= 2 else { return 0 }
|
||||||
|
|
||||||
|
var total: Double = 0
|
||||||
|
for i in 0..<(coordinates.count - 1) {
|
||||||
|
total += distance(from: coordinates[i], to: coordinates[i + 1])
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates distance between two coordinates in meters.
|
||||||
|
private func distance(
|
||||||
|
from: CLLocationCoordinate2D,
|
||||||
|
to: CLLocationCoordinate2D
|
||||||
|
) -> Double {
|
||||||
|
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||||
|
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||||
|
return fromLoc.distance(from: toLoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Legacy Support
|
||||||
|
|
||||||
|
/// Legacy optimization method for backward compatibility.
|
||||||
|
/// Delegates to the new TripPlanningEngine for actual routing.
|
||||||
|
func optimize(
|
||||||
|
graph: RouteGraph,
|
||||||
|
request: PlanningRequest,
|
||||||
|
candidates: [GameCandidate],
|
||||||
|
strategy: OptimizationStrategy = .balanced
|
||||||
|
) -> CandidateRoute {
|
||||||
|
// Build a simple chronological route from candidates
|
||||||
|
let sortedCandidates = candidates.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
|
|
||||||
|
var route = CandidateRoute()
|
||||||
|
|
||||||
|
// Add start node
|
||||||
|
if let startNode = graph.nodes.first(where: { $0.type == .start }) {
|
||||||
|
route.nodeSequence.append(startNode.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stadium nodes in chronological order
|
||||||
|
var visitedStadiums = Set<UUID>()
|
||||||
|
for candidate in sortedCandidates {
|
||||||
|
// Find the node for this stadium
|
||||||
|
for node in graph.nodes {
|
||||||
|
if case .stadium(let stadiumId) = node.type,
|
||||||
|
stadiumId == candidate.stadium.id,
|
||||||
|
!visitedStadiums.contains(stadiumId) {
|
||||||
|
route.nodeSequence.append(node.id)
|
||||||
|
route.games.append(candidate.game.id)
|
||||||
|
visitedStadiums.insert(stadiumId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add end node
|
||||||
|
if let endNode = graph.nodes.first(where: { $0.type == .end }) {
|
||||||
|
route.nodeSequence.append(endNode.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
for i in 0..<(route.nodeSequence.count - 1) {
|
||||||
|
if let edge = graph.edges(from: route.nodeSequence[i])
|
||||||
|
.first(where: { $0.toNodeId == route.nodeSequence[i + 1] }) {
|
||||||
|
route.totalDistance += edge.distanceMeters
|
||||||
|
route.totalDuration += edge.durationSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score the route
|
||||||
|
route.score = scoreRoute(route, strategy: strategy, graph: graph)
|
||||||
|
|
||||||
|
return route
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy route scoring for CandidateRoute.
|
||||||
|
private func scoreRoute(
|
||||||
|
_ route: CandidateRoute,
|
||||||
|
strategy: OptimizationStrategy,
|
||||||
|
graph: RouteGraph
|
||||||
|
) -> Double {
|
||||||
|
switch strategy {
|
||||||
|
case .balanced:
|
||||||
|
return route.totalDuration - Double(route.games.count) * 3600 * 2
|
||||||
|
|
||||||
|
case .maximizeGames:
|
||||||
|
return -Double(route.games.count) * 10000 + route.totalDuration
|
||||||
|
|
||||||
|
case .minimizeDriving:
|
||||||
|
return route.totalDuration
|
||||||
|
|
||||||
|
case .scenic:
|
||||||
|
return route.totalDuration * 0.5 - Double(route.games.count) * 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
265
SportsTime/Planning/Engine/ScenarioAPlanner.swift
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
//
|
||||||
|
// ScenarioAPlanner.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Scenario A: Date range only planning.
|
||||||
|
// User provides a date range, we find all games and build chronological routes.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Scenario A: Date range planning
|
||||||
|
///
|
||||||
|
/// This is the simplest scenario - user just picks a date range and we find games.
|
||||||
|
///
|
||||||
|
/// Input:
|
||||||
|
/// - date_range: Required. The trip dates (e.g., Jan 5-15)
|
||||||
|
/// - must_stop: Optional. A location they must visit (not yet implemented)
|
||||||
|
///
|
||||||
|
/// Output:
|
||||||
|
/// - Success: Ranked list of itinerary options
|
||||||
|
/// - Failure: Explicit error with reason (no games, dates invalid, etc.)
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// User selects Jan 5-10, 2026
|
||||||
|
/// We find: Lakers (Jan 5), Warriors (Jan 7), Kings (Jan 9)
|
||||||
|
/// Output: Single itinerary visiting LA → SF → Sacramento in order
|
||||||
|
///
|
||||||
|
final class ScenarioAPlanner: ScenarioPlanner {
|
||||||
|
|
||||||
|
// MARK: - ScenarioPlanner Protocol
|
||||||
|
|
||||||
|
/// Main entry point for Scenario A planning.
|
||||||
|
///
|
||||||
|
/// Flow:
|
||||||
|
/// 1. Validate inputs (date range must exist)
|
||||||
|
/// 2. Find all games within the date range
|
||||||
|
/// 3. Convert games to stops (grouping by stadium)
|
||||||
|
/// 4. Calculate travel between stops
|
||||||
|
/// 5. Return the complete itinerary
|
||||||
|
///
|
||||||
|
/// Failure cases:
|
||||||
|
/// - No date range provided → .missingDateRange
|
||||||
|
/// - No games in date range → .noGamesInRange
|
||||||
|
/// - Can't build valid route → .constraintsUnsatisfiable
|
||||||
|
///
|
||||||
|
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 1: Validate date range exists
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Scenario A requires a date range. Without it, we can't filter games.
|
||||||
|
guard let dateRange = request.dateRange else {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .missingDateRange,
|
||||||
|
violations: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 2: Filter games within date range
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Get all games that fall within the user's travel dates.
|
||||||
|
// Sort by start time so we visit them in chronological order.
|
||||||
|
let gamesInRange = request.allGames
|
||||||
|
.filter { dateRange.contains($0.startTime) }
|
||||||
|
.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
// No games? Nothing to plan.
|
||||||
|
if gamesInRange.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .noGamesInRange,
|
||||||
|
violations: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 3: Find ALL geographically sensible route variations
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Not all games in the date range may form a sensible route.
|
||||||
|
// Example: NY (Jan 5), TX (Jan 6), SC (Jan 7), CA (Jan 8)
|
||||||
|
// - Including all = zig-zag nightmare
|
||||||
|
// - Option 1: NY, TX, DEN, NM, CA (skip SC)
|
||||||
|
// - Option 2: NY, SC, DEN, NM, CA (skip TX)
|
||||||
|
// - etc.
|
||||||
|
//
|
||||||
|
// We explore ALL valid combinations and return multiple options.
|
||||||
|
// Uses shared GeographicRouteExplorer for tree exploration.
|
||||||
|
//
|
||||||
|
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
|
||||||
|
from: gamesInRange,
|
||||||
|
stadiums: request.stadiums,
|
||||||
|
stopBuilder: buildStops
|
||||||
|
)
|
||||||
|
|
||||||
|
if validRoutes.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .noValidRoutes,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .geographicSanity,
|
||||||
|
description: "No geographically sensible route found for games in this date range",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 4: Build itineraries for each valid route
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// For each valid game combination, build stops and calculate travel.
|
||||||
|
// Some routes may fail driving constraints - filter those out.
|
||||||
|
//
|
||||||
|
var itineraryOptions: [ItineraryOption] = []
|
||||||
|
|
||||||
|
for (index, routeGames) in validRoutes.enumerated() {
|
||||||
|
// Build stops for this route
|
||||||
|
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||||
|
guard !stops.isEmpty else { continue }
|
||||||
|
|
||||||
|
// Calculate travel segments using shared ItineraryBuilder
|
||||||
|
guard let itinerary = ItineraryBuilder.build(
|
||||||
|
stops: stops,
|
||||||
|
constraints: request.drivingConstraints,
|
||||||
|
logPrefix: "[ScenarioA]"
|
||||||
|
) else {
|
||||||
|
// This route fails driving constraints, skip it
|
||||||
|
print("[ScenarioA] Route \(index + 1) failed driving constraints, skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the option
|
||||||
|
let cities = stops.map { $0.city }.joined(separator: " → ")
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: index + 1,
|
||||||
|
stops: itinerary.stops,
|
||||||
|
travelSegments: itinerary.travelSegments,
|
||||||
|
totalDrivingHours: itinerary.totalDrivingHours,
|
||||||
|
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||||
|
geographicRationale: "\(stops.count) games: \(cities)"
|
||||||
|
)
|
||||||
|
itineraryOptions.append(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 5: Return ranked results
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// If no routes passed all constraints, fail.
|
||||||
|
// Otherwise, return all valid options for the user to choose from.
|
||||||
|
//
|
||||||
|
if itineraryOptions.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .constraintsUnsatisfiable,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .drivingTime,
|
||||||
|
description: "No routes satisfy driving constraints",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-rank by number of games (already sorted, but update rank numbers)
|
||||||
|
let rankedOptions = itineraryOptions.enumerated().map { index, option in
|
||||||
|
ItineraryOption(
|
||||||
|
rank: index + 1,
|
||||||
|
stops: option.stops,
|
||||||
|
travelSegments: option.travelSegments,
|
||||||
|
totalDrivingHours: option.totalDrivingHours,
|
||||||
|
totalDistanceMiles: option.totalDistanceMiles,
|
||||||
|
geographicRationale: option.geographicRationale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[ScenarioA] Returning \(rankedOptions.count) itinerary options")
|
||||||
|
return .success(rankedOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stop Building
|
||||||
|
|
||||||
|
/// Converts a list of games into itinerary stops.
|
||||||
|
///
|
||||||
|
/// The goal: Create one stop per stadium, in the order we first encounter each stadium
|
||||||
|
/// when walking through games chronologically.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// Input games (already sorted by date):
|
||||||
|
/// 1. Jan 5 - Lakers @ Staples Center (LA)
|
||||||
|
/// 2. Jan 6 - Clippers @ Staples Center (LA) <- same stadium as #1
|
||||||
|
/// 3. Jan 8 - Warriors @ Chase Center (SF)
|
||||||
|
///
|
||||||
|
/// Output stops:
|
||||||
|
/// Stop 1: Los Angeles (contains game 1 and 2)
|
||||||
|
/// Stop 2: San Francisco (contains game 3)
|
||||||
|
///
|
||||||
|
private func buildStops(
|
||||||
|
from games: [Game],
|
||||||
|
stadiums: [UUID: Stadium]
|
||||||
|
) -> [ItineraryStop] {
|
||||||
|
|
||||||
|
// Step 1: Group all games by their stadium
|
||||||
|
// This lets us find ALL games at a stadium when we create that stop
|
||||||
|
// Result: { stadiumId: [game1, game2, ...], ... }
|
||||||
|
var stadiumGames: [UUID: [Game]] = [:]
|
||||||
|
for game in games {
|
||||||
|
stadiumGames[game.stadiumId, default: []].append(game)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Walk through games in chronological order
|
||||||
|
// When we hit a stadium for the first time, create a stop with ALL games at that stadium
|
||||||
|
var stops: [ItineraryStop] = []
|
||||||
|
var processedStadiums: Set<UUID> = [] // Track which stadiums we've already made stops for
|
||||||
|
|
||||||
|
for game in games {
|
||||||
|
// Skip if we already created a stop for this stadium
|
||||||
|
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||||
|
processedStadiums.insert(game.stadiumId)
|
||||||
|
|
||||||
|
// Get ALL games at this stadium (not just this one)
|
||||||
|
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||||
|
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
// Look up stadium info for location data
|
||||||
|
let stadium = stadiums[game.stadiumId]
|
||||||
|
let city = stadium?.city ?? "Unknown"
|
||||||
|
let state = stadium?.state ?? ""
|
||||||
|
let coordinate = stadium?.coordinate
|
||||||
|
|
||||||
|
let location = LocationInput(
|
||||||
|
name: city,
|
||||||
|
coordinate: coordinate,
|
||||||
|
address: stadium?.fullAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the stop
|
||||||
|
// - arrivalDate: when we need to arrive (first game at this stop)
|
||||||
|
// - departureDate: when we can leave (after last game at this stop)
|
||||||
|
// - games: IDs of all games we'll attend at this stop
|
||||||
|
let stop = ItineraryStop(
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
coordinate: coordinate,
|
||||||
|
games: sortedGames.map { $0.id },
|
||||||
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||||
|
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||||
|
location: location,
|
||||||
|
firstGameStart: sortedGames.first?.startTime
|
||||||
|
)
|
||||||
|
stops.append(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
348
SportsTime/Planning/Engine/ScenarioBPlanner.swift
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
//
|
||||||
|
// ScenarioBPlanner.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Scenario B: Selected games planning.
|
||||||
|
// User selects specific games they MUST see. Those are fixed anchors that cannot be removed.
|
||||||
|
//
|
||||||
|
// Key Features:
|
||||||
|
// - Selected games are "anchors" - they MUST appear in every valid route
|
||||||
|
// - Sliding window logic when only trip duration (no specific dates) is provided
|
||||||
|
// - Additional games from date range can be added if they fit geographically
|
||||||
|
//
|
||||||
|
// Sliding Window Algorithm:
|
||||||
|
// When user provides selected games + day span (e.g., 10 days) without specific dates:
|
||||||
|
// 1. Find first and last selected game dates
|
||||||
|
// 2. Generate all possible windows of the given duration that contain ALL selected games
|
||||||
|
// 3. Window 1: Last selected game is on last day
|
||||||
|
// 4. Window N: First selected game is on first day
|
||||||
|
// 5. Explore routes for each window, return best options
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// Selected games on Jan 5, Jan 8, Jan 12. Day span = 10 days.
|
||||||
|
// - Window 1: Jan 3-12 (Jan 12 is last day)
|
||||||
|
// - Window 2: Jan 4-13
|
||||||
|
// - Window 3: Jan 5-14 (Jan 5 is first day)
|
||||||
|
// For each window, find all games and explore routes with selected as anchors.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Scenario B: Selected games planning
|
||||||
|
/// Input: selected_games, date_range (or trip_duration), optional must_stop
|
||||||
|
/// Output: Itinerary options connecting all selected games with possible bonus games
|
||||||
|
final class ScenarioBPlanner: ScenarioPlanner {
|
||||||
|
|
||||||
|
// MARK: - ScenarioPlanner Protocol
|
||||||
|
|
||||||
|
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||||
|
|
||||||
|
let selectedGames = request.selectedGames
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 1: Validate selected games exist
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
if selectedGames.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .noValidRoutes,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .selectedGames,
|
||||||
|
description: "No games selected",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 2: Generate date ranges (sliding window or single range)
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
let dateRanges = generateDateRanges(
|
||||||
|
selectedGames: selectedGames,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
|
|
||||||
|
if dateRanges.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .missingDateRange,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .dateRange,
|
||||||
|
description: "Cannot determine valid date range for selected games",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 3: For each date range, find routes with anchors
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
let anchorGameIds = Set(selectedGames.map { $0.id })
|
||||||
|
var allItineraryOptions: [ItineraryOption] = []
|
||||||
|
|
||||||
|
for dateRange in dateRanges {
|
||||||
|
// Find all games in this date range
|
||||||
|
let gamesInRange = request.allGames
|
||||||
|
.filter { dateRange.contains($0.startTime) }
|
||||||
|
.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
// Skip if no games (shouldn't happen if date range is valid)
|
||||||
|
guard !gamesInRange.isEmpty else { continue }
|
||||||
|
|
||||||
|
// Verify all selected games are in range
|
||||||
|
let selectedInRange = selectedGames.allSatisfy { game in
|
||||||
|
dateRange.contains(game.startTime)
|
||||||
|
}
|
||||||
|
guard selectedInRange else { continue }
|
||||||
|
|
||||||
|
// Find all sensible routes that include the anchor games
|
||||||
|
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
|
||||||
|
from: gamesInRange,
|
||||||
|
stadiums: request.stadiums,
|
||||||
|
anchorGameIds: anchorGameIds,
|
||||||
|
stopBuilder: buildStops
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build itineraries for each valid route
|
||||||
|
for routeGames in validRoutes {
|
||||||
|
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||||
|
guard !stops.isEmpty else { continue }
|
||||||
|
|
||||||
|
// Use shared ItineraryBuilder with arrival time validator
|
||||||
|
guard let itinerary = ItineraryBuilder.build(
|
||||||
|
stops: stops,
|
||||||
|
constraints: request.drivingConstraints,
|
||||||
|
logPrefix: "[ScenarioB]",
|
||||||
|
segmentValidator: ItineraryBuilder.arrivalBeforeGameStart()
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedCount = routeGames.filter { anchorGameIds.contains($0.id) }.count
|
||||||
|
let bonusCount = routeGames.count - selectedCount
|
||||||
|
let cities = stops.map { $0.city }.joined(separator: " → ")
|
||||||
|
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: 0, // Will re-rank later
|
||||||
|
stops: itinerary.stops,
|
||||||
|
travelSegments: itinerary.travelSegments,
|
||||||
|
totalDrivingHours: itinerary.totalDrivingHours,
|
||||||
|
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||||
|
geographicRationale: "\(selectedCount) selected + \(bonusCount) bonus games: \(cities)"
|
||||||
|
)
|
||||||
|
allItineraryOptions.append(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 4: Return ranked results
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
if allItineraryOptions.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .constraintsUnsatisfiable,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .geographicSanity,
|
||||||
|
description: "Cannot create a geographically sensible route connecting selected games",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by total games (most first), then by driving hours (less first)
|
||||||
|
let sorted = allItineraryOptions.sorted { a, b in
|
||||||
|
if a.stops.flatMap({ $0.games }).count != b.stops.flatMap({ $0.games }).count {
|
||||||
|
return a.stops.flatMap({ $0.games }).count > b.stops.flatMap({ $0.games }).count
|
||||||
|
}
|
||||||
|
return a.totalDrivingHours < b.totalDrivingHours
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-rank and limit
|
||||||
|
let rankedOptions = sorted.prefix(10).enumerated().map { index, option in
|
||||||
|
ItineraryOption(
|
||||||
|
rank: index + 1,
|
||||||
|
stops: option.stops,
|
||||||
|
travelSegments: option.travelSegments,
|
||||||
|
totalDrivingHours: option.totalDrivingHours,
|
||||||
|
totalDistanceMiles: option.totalDistanceMiles,
|
||||||
|
geographicRationale: option.geographicRationale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[ScenarioB] Returning \(rankedOptions.count) itinerary options")
|
||||||
|
return .success(Array(rankedOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Range Generation (Sliding Window)
|
||||||
|
|
||||||
|
/// Generates all valid date ranges for the selected games.
|
||||||
|
///
|
||||||
|
/// Two modes:
|
||||||
|
/// 1. If explicit date range provided: Use it directly (validate selected games fit)
|
||||||
|
/// 2. If only trip duration provided: Generate sliding windows
|
||||||
|
///
|
||||||
|
/// Sliding Window Logic:
|
||||||
|
/// Selected games: Jan 5, Jan 8, Jan 12. Duration: 10 days.
|
||||||
|
/// - Window must contain all selected games
|
||||||
|
/// - First window: ends on last selected game date (Jan 3-12)
|
||||||
|
/// - Slide forward one day at a time
|
||||||
|
/// - Last window: starts on first selected game date (Jan 5-14)
|
||||||
|
///
|
||||||
|
private func generateDateRanges(
|
||||||
|
selectedGames: [Game],
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> [DateInterval] {
|
||||||
|
|
||||||
|
// If explicit date range exists, use it
|
||||||
|
if let dateRange = request.dateRange {
|
||||||
|
return [dateRange]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use trip duration to create sliding windows
|
||||||
|
let duration = request.preferences.effectiveTripDuration
|
||||||
|
guard duration > 0 else { return [] }
|
||||||
|
|
||||||
|
// Find the span of selected games
|
||||||
|
let sortedGames = selectedGames.sorted { $0.startTime < $1.startTime }
|
||||||
|
guard let firstGame = sortedGames.first,
|
||||||
|
let lastGame = sortedGames.last else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstGameDate = Calendar.current.startOfDay(for: firstGame.startTime)
|
||||||
|
let lastGameDate = Calendar.current.startOfDay(for: lastGame.startTime)
|
||||||
|
|
||||||
|
// Calculate how many days the selected games span
|
||||||
|
let gameSpanDays = Calendar.current.dateComponents(
|
||||||
|
[.day],
|
||||||
|
from: firstGameDate,
|
||||||
|
to: lastGameDate
|
||||||
|
).day ?? 0
|
||||||
|
|
||||||
|
// If selected games span more days than trip duration, can't fit
|
||||||
|
if gameSpanDays >= duration {
|
||||||
|
// Just return one window that exactly covers the games
|
||||||
|
let start = firstGameDate
|
||||||
|
let end = Calendar.current.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: gameSpanDays + 1,
|
||||||
|
to: start
|
||||||
|
) ?? lastGameDate
|
||||||
|
return [DateInterval(start: start, end: end)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sliding windows
|
||||||
|
var dateRanges: [DateInterval] = []
|
||||||
|
|
||||||
|
// First window: last selected game is on last day of window
|
||||||
|
// Window end = lastGameDate + 1 day (to include the game)
|
||||||
|
// Window start = end - duration days
|
||||||
|
let firstWindowEnd = Calendar.current.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: 1,
|
||||||
|
to: lastGameDate
|
||||||
|
)!
|
||||||
|
let firstWindowStart = Calendar.current.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: -duration,
|
||||||
|
to: firstWindowEnd
|
||||||
|
)!
|
||||||
|
|
||||||
|
// Last window: first selected game is on first day of window
|
||||||
|
// Window start = firstGameDate
|
||||||
|
// Window end = start + duration days
|
||||||
|
let lastWindowStart = firstGameDate
|
||||||
|
let lastWindowEnd = Calendar.current.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: duration,
|
||||||
|
to: lastWindowStart
|
||||||
|
)!
|
||||||
|
|
||||||
|
// Slide from first window to last window
|
||||||
|
var currentStart = firstWindowStart
|
||||||
|
while currentStart <= lastWindowStart {
|
||||||
|
let windowEnd = Calendar.current.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: duration,
|
||||||
|
to: currentStart
|
||||||
|
)!
|
||||||
|
|
||||||
|
let window = DateInterval(start: currentStart, end: windowEnd)
|
||||||
|
dateRanges.append(window)
|
||||||
|
|
||||||
|
// Slide forward one day
|
||||||
|
currentStart = Calendar.current.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: 1,
|
||||||
|
to: currentStart
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[ScenarioB] Generated \(dateRanges.count) sliding windows for \(duration)-day trip")
|
||||||
|
return dateRanges
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stop Building
|
||||||
|
|
||||||
|
/// Converts a list of games into itinerary stops.
|
||||||
|
/// Groups games by stadium, creates one stop per unique stadium.
|
||||||
|
private func buildStops(
|
||||||
|
from games: [Game],
|
||||||
|
stadiums: [UUID: Stadium]
|
||||||
|
) -> [ItineraryStop] {
|
||||||
|
|
||||||
|
// Group games by stadium
|
||||||
|
var stadiumGames: [UUID: [Game]] = [:]
|
||||||
|
for game in games {
|
||||||
|
stadiumGames[game.stadiumId, default: []].append(game)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stops in chronological order (first game at each stadium)
|
||||||
|
var stops: [ItineraryStop] = []
|
||||||
|
var processedStadiums: Set<UUID> = []
|
||||||
|
|
||||||
|
for game in games {
|
||||||
|
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||||
|
processedStadiums.insert(game.stadiumId)
|
||||||
|
|
||||||
|
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||||
|
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
let stadium = stadiums[game.stadiumId]
|
||||||
|
let city = stadium?.city ?? "Unknown"
|
||||||
|
let state = stadium?.state ?? ""
|
||||||
|
let coordinate = stadium?.coordinate
|
||||||
|
|
||||||
|
let location = LocationInput(
|
||||||
|
name: city,
|
||||||
|
coordinate: coordinate,
|
||||||
|
address: stadium?.fullAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
let stop = ItineraryStop(
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
coordinate: coordinate,
|
||||||
|
games: sortedGames.map { $0.id },
|
||||||
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||||
|
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||||
|
location: location,
|
||||||
|
firstGameStart: sortedGames.first?.startTime
|
||||||
|
)
|
||||||
|
stops.append(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
582
SportsTime/Planning/Engine/ScenarioCPlanner.swift
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
//
|
||||||
|
// ScenarioCPlanner.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Scenario C: Start + End location planning.
|
||||||
|
// User specifies starting city and ending city. We find games along the route.
|
||||||
|
//
|
||||||
|
// Key Features:
|
||||||
|
// - Start/End are cities with stadiums from user's selected sports
|
||||||
|
// - Directional filtering: stadiums that "generally move toward" the end
|
||||||
|
// - When only day span provided (no dates): generate date ranges from games at start/end
|
||||||
|
// - Uses GeographicRouteExplorer for sensible route exploration
|
||||||
|
// - Returns top 5 options with most games
|
||||||
|
//
|
||||||
|
// Date Range Generation (when only day span provided):
|
||||||
|
// 1. Find all games at start city's stadiums
|
||||||
|
// 2. Find all games at end city's stadiums
|
||||||
|
// 3. For each start game + end game combo within day span: create a date range
|
||||||
|
// 4. Explore routes for each date range
|
||||||
|
//
|
||||||
|
// Directional Filtering:
|
||||||
|
// - Find stadiums that make forward progress from start to end
|
||||||
|
// - "Forward progress" = distance to end decreases (with tolerance)
|
||||||
|
// - Filter games to only those at directional stadiums
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// Start: Chicago, End: New York, Day span: 7 days
|
||||||
|
// Start game: Jan 5 at Chicago
|
||||||
|
// End game: Jan 10 at New York
|
||||||
|
// Date range: Jan 5-10
|
||||||
|
// Directional stadiums: Detroit, Cleveland, Pittsburgh (moving east)
|
||||||
|
// NOT directional: Minneapolis, St. Louis (moving away from NY)
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Scenario C: Directional route planning from start city to end city
|
||||||
|
/// Input: start_location, end_location, day_span (or date_range)
|
||||||
|
/// Output: Top 5 itinerary options with games along the directional route
|
||||||
|
final class ScenarioCPlanner: ScenarioPlanner {
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
/// Maximum number of itinerary options to return
|
||||||
|
private let maxOptions = 5
|
||||||
|
|
||||||
|
/// Tolerance for "forward progress" - allow small increases in distance to end
|
||||||
|
/// A stadium is "directional" if it doesn't increase distance to end by more than this ratio
|
||||||
|
private let forwardProgressTolerance = 0.15 // 15% tolerance
|
||||||
|
|
||||||
|
// MARK: - ScenarioPlanner Protocol
|
||||||
|
|
||||||
|
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 1: Validate start and end locations
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
guard let startLocation = request.startLocation else {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .missingLocations,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .general,
|
||||||
|
description: "Start location is required for Scenario C",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let endLocation = request.endLocation else {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .missingLocations,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .general,
|
||||||
|
description: "End location is required for Scenario C",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let startCoord = startLocation.coordinate,
|
||||||
|
let endCoord = endLocation.coordinate else {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .missingLocations,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .general,
|
||||||
|
description: "Start and end locations must have coordinates",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 2: Find stadiums at start and end cities
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
let startStadiums = findStadiumsInCity(
|
||||||
|
cityName: startLocation.name,
|
||||||
|
stadiums: request.stadiums
|
||||||
|
)
|
||||||
|
let endStadiums = findStadiumsInCity(
|
||||||
|
cityName: endLocation.name,
|
||||||
|
stadiums: request.stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
if startStadiums.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .noGamesInRange,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .general,
|
||||||
|
description: "No stadiums found in start city: \(startLocation.name)",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if endStadiums.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .noGamesInRange,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .general,
|
||||||
|
description: "No stadiums found in end city: \(endLocation.name)",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 3: Generate date ranges
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
let dateRanges = generateDateRanges(
|
||||||
|
startStadiumIds: Set(startStadiums.map { $0.id }),
|
||||||
|
endStadiumIds: Set(endStadiums.map { $0.id }),
|
||||||
|
allGames: request.allGames,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
|
|
||||||
|
if dateRanges.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .missingDateRange,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .dateRange,
|
||||||
|
description: "No valid date ranges found with games at both start and end cities",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 4: Find directional stadiums (moving from start toward end)
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
let directionalStadiums = findDirectionalStadiums(
|
||||||
|
from: startCoord,
|
||||||
|
to: endCoord,
|
||||||
|
stadiums: request.stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[ScenarioC] Found \(directionalStadiums.count) directional stadiums from \(startLocation.name) to \(endLocation.name)")
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 5: For each date range, explore routes
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
var allItineraryOptions: [ItineraryOption] = []
|
||||||
|
|
||||||
|
for dateRange in dateRanges {
|
||||||
|
// Find games at directional stadiums within date range
|
||||||
|
let gamesInRange = request.allGames
|
||||||
|
.filter { dateRange.contains($0.startTime) }
|
||||||
|
.filter { directionalStadiums.contains($0.stadiumId) }
|
||||||
|
.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
guard !gamesInRange.isEmpty else { continue }
|
||||||
|
|
||||||
|
// Use GeographicRouteExplorer to find sensible routes
|
||||||
|
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
|
||||||
|
from: gamesInRange,
|
||||||
|
stadiums: request.stadiums,
|
||||||
|
anchorGameIds: [], // No anchors in Scenario C
|
||||||
|
stopBuilder: buildStops
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build itineraries for each valid route
|
||||||
|
for routeGames in validRoutes {
|
||||||
|
let stops = buildStopsWithEndpoints(
|
||||||
|
start: startLocation,
|
||||||
|
end: endLocation,
|
||||||
|
games: routeGames,
|
||||||
|
stadiums: request.stadiums
|
||||||
|
)
|
||||||
|
guard !stops.isEmpty else { continue }
|
||||||
|
|
||||||
|
// Validate monotonic progress toward end
|
||||||
|
guard validateMonotonicProgress(
|
||||||
|
stops: stops,
|
||||||
|
toward: endCoord
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use shared ItineraryBuilder
|
||||||
|
guard let itinerary = ItineraryBuilder.build(
|
||||||
|
stops: stops,
|
||||||
|
constraints: request.drivingConstraints,
|
||||||
|
logPrefix: "[ScenarioC]"
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let gameCount = routeGames.count
|
||||||
|
let cities = stops.compactMap { $0.games.isEmpty ? nil : $0.city }.joined(separator: " → ")
|
||||||
|
|
||||||
|
let option = ItineraryOption(
|
||||||
|
rank: 0, // Will re-rank later
|
||||||
|
stops: itinerary.stops,
|
||||||
|
travelSegments: itinerary.travelSegments,
|
||||||
|
totalDrivingHours: itinerary.totalDrivingHours,
|
||||||
|
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||||
|
geographicRationale: "\(startLocation.name) → \(gameCount) games → \(endLocation.name): \(cities)"
|
||||||
|
)
|
||||||
|
allItineraryOptions.append(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Step 6: Return top 5 ranked results
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
if allItineraryOptions.isEmpty {
|
||||||
|
return .failure(
|
||||||
|
PlanningFailure(
|
||||||
|
reason: .noValidRoutes,
|
||||||
|
violations: [
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .geographicSanity,
|
||||||
|
description: "No valid directional routes found from \(startLocation.name) to \(endLocation.name)",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by game count (most first), then by driving hours (less first)
|
||||||
|
let sorted = allItineraryOptions.sorted { a, b in
|
||||||
|
let aGames = a.stops.flatMap { $0.games }.count
|
||||||
|
let bGames = b.stops.flatMap { $0.games }.count
|
||||||
|
if aGames != bGames {
|
||||||
|
return aGames > bGames
|
||||||
|
}
|
||||||
|
return a.totalDrivingHours < b.totalDrivingHours
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take top N and re-rank
|
||||||
|
let rankedOptions = sorted.prefix(maxOptions).enumerated().map { index, option in
|
||||||
|
ItineraryOption(
|
||||||
|
rank: index + 1,
|
||||||
|
stops: option.stops,
|
||||||
|
travelSegments: option.travelSegments,
|
||||||
|
totalDrivingHours: option.totalDrivingHours,
|
||||||
|
totalDistanceMiles: option.totalDistanceMiles,
|
||||||
|
geographicRationale: option.geographicRationale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[ScenarioC] Returning \(rankedOptions.count) itinerary options")
|
||||||
|
return .success(Array(rankedOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stadium Finding
|
||||||
|
|
||||||
|
/// Finds all stadiums in a given city (case-insensitive match).
|
||||||
|
private func findStadiumsInCity(
|
||||||
|
cityName: String,
|
||||||
|
stadiums: [UUID: Stadium]
|
||||||
|
) -> [Stadium] {
|
||||||
|
let normalizedCity = cityName.lowercased().trimmingCharacters(in: .whitespaces)
|
||||||
|
return stadiums.values.filter { stadium in
|
||||||
|
stadium.city.lowercased().trimmingCharacters(in: .whitespaces) == normalizedCity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds stadiums that make forward progress from start to end.
|
||||||
|
///
|
||||||
|
/// A stadium is "directional" if visiting it doesn't significantly increase
|
||||||
|
/// the distance to the end point. This filters out stadiums that would
|
||||||
|
/// require backtracking.
|
||||||
|
///
|
||||||
|
/// Algorithm:
|
||||||
|
/// 1. Calculate distance from start to end
|
||||||
|
/// 2. For each stadium, calculate: distance(start, stadium) + distance(stadium, end)
|
||||||
|
/// 3. If this "detour distance" is reasonable (within tolerance), include it
|
||||||
|
///
|
||||||
|
/// The tolerance allows for stadiums slightly off the direct path.
|
||||||
|
///
|
||||||
|
private func findDirectionalStadiums(
|
||||||
|
from start: CLLocationCoordinate2D,
|
||||||
|
to end: CLLocationCoordinate2D,
|
||||||
|
stadiums: [UUID: Stadium]
|
||||||
|
) -> Set<UUID> {
|
||||||
|
let directDistance = distanceBetween(start, end)
|
||||||
|
|
||||||
|
// Allow detours up to 50% longer than direct distance
|
||||||
|
let maxDetourDistance = directDistance * 1.5
|
||||||
|
|
||||||
|
var directionalIds: Set<UUID> = []
|
||||||
|
|
||||||
|
for (id, stadium) in stadiums {
|
||||||
|
let stadiumCoord = stadium.coordinate
|
||||||
|
|
||||||
|
// Calculate the detour: start → stadium → end
|
||||||
|
let toStadium = distanceBetween(start, stadiumCoord)
|
||||||
|
let fromStadium = distanceBetween(stadiumCoord, end)
|
||||||
|
let detourDistance = toStadium + fromStadium
|
||||||
|
|
||||||
|
// Also check that stadium is making progress (closer to end than start is)
|
||||||
|
let distanceFromStart = distanceBetween(start, stadiumCoord)
|
||||||
|
let distanceToEnd = distanceBetween(stadiumCoord, end)
|
||||||
|
|
||||||
|
// Stadium should be within the "cone" from start to end
|
||||||
|
// Either closer to end than start, or the detour is acceptable
|
||||||
|
if detourDistance <= maxDetourDistance {
|
||||||
|
// Additional check: don't include if it's behind the start point
|
||||||
|
// (i.e., distance to end is greater than original distance)
|
||||||
|
if distanceToEnd <= directDistance * (1 + forwardProgressTolerance) {
|
||||||
|
directionalIds.insert(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return directionalIds
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Range Generation
|
||||||
|
|
||||||
|
/// Generates date ranges for Scenario C.
|
||||||
|
///
|
||||||
|
/// Two modes:
|
||||||
|
/// 1. Explicit date range provided: Use it directly
|
||||||
|
/// 2. Only day span provided: Find game combinations at start/end cities
|
||||||
|
///
|
||||||
|
/// For mode 2:
|
||||||
|
/// - Find all games at start city stadiums
|
||||||
|
/// - Find all games at end city stadiums
|
||||||
|
/// - For each (start_game, end_game) pair where end_game - start_game <= day_span:
|
||||||
|
/// Create a date range from start_game.date to end_game.date
|
||||||
|
///
|
||||||
|
private func generateDateRanges(
|
||||||
|
startStadiumIds: Set<UUID>,
|
||||||
|
endStadiumIds: Set<UUID>,
|
||||||
|
allGames: [Game],
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> [DateInterval] {
|
||||||
|
|
||||||
|
// If explicit date range exists, use it
|
||||||
|
if let dateRange = request.dateRange {
|
||||||
|
return [dateRange]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use day span to find valid combinations
|
||||||
|
let daySpan = request.preferences.effectiveTripDuration
|
||||||
|
guard daySpan > 0 else { return [] }
|
||||||
|
|
||||||
|
// Find games at start and end cities
|
||||||
|
let startGames = allGames
|
||||||
|
.filter { startStadiumIds.contains($0.stadiumId) }
|
||||||
|
.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
let endGames = allGames
|
||||||
|
.filter { endStadiumIds.contains($0.stadiumId) }
|
||||||
|
.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
if startGames.isEmpty || endGames.isEmpty {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate all valid (start_game, end_game) combinations
|
||||||
|
var dateRanges: [DateInterval] = []
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
for startGame in startGames {
|
||||||
|
let startDate = calendar.startOfDay(for: startGame.startTime)
|
||||||
|
|
||||||
|
for endGame in endGames {
|
||||||
|
let endDate = calendar.startOfDay(for: endGame.startTime)
|
||||||
|
|
||||||
|
// End must be after start
|
||||||
|
guard endDate >= startDate else { continue }
|
||||||
|
|
||||||
|
// Calculate days between
|
||||||
|
let daysBetween = calendar.dateComponents([.day], from: startDate, to: endDate).day ?? 0
|
||||||
|
|
||||||
|
// Must be within day span
|
||||||
|
guard daysBetween < daySpan else { continue }
|
||||||
|
|
||||||
|
// Create date range (end date + 1 day to include the end game)
|
||||||
|
let rangeEnd = calendar.date(byAdding: .day, value: 1, to: endDate) ?? endDate
|
||||||
|
let range = DateInterval(start: startDate, end: rangeEnd)
|
||||||
|
|
||||||
|
// Avoid duplicate ranges
|
||||||
|
if !dateRanges.contains(where: { $0.start == range.start && $0.end == range.end }) {
|
||||||
|
dateRanges.append(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[ScenarioC] Generated \(dateRanges.count) date ranges for \(daySpan)-day trip")
|
||||||
|
return dateRanges
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stop Building
|
||||||
|
|
||||||
|
/// Converts games to stops (used by GeographicRouteExplorer callback).
|
||||||
|
private func buildStops(
|
||||||
|
from games: [Game],
|
||||||
|
stadiums: [UUID: Stadium]
|
||||||
|
) -> [ItineraryStop] {
|
||||||
|
|
||||||
|
var stadiumGames: [UUID: [Game]] = [:]
|
||||||
|
for game in games {
|
||||||
|
stadiumGames[game.stadiumId, default: []].append(game)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stops: [ItineraryStop] = []
|
||||||
|
var processedStadiums: Set<UUID> = []
|
||||||
|
|
||||||
|
for game in games {
|
||||||
|
guard !processedStadiums.contains(game.stadiumId) else { continue }
|
||||||
|
processedStadiums.insert(game.stadiumId)
|
||||||
|
|
||||||
|
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
|
||||||
|
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
|
||||||
|
|
||||||
|
let stadium = stadiums[game.stadiumId]
|
||||||
|
let city = stadium?.city ?? "Unknown"
|
||||||
|
let state = stadium?.state ?? ""
|
||||||
|
let coordinate = stadium?.coordinate
|
||||||
|
|
||||||
|
let location = LocationInput(
|
||||||
|
name: city,
|
||||||
|
coordinate: coordinate,
|
||||||
|
address: stadium?.fullAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
let stop = ItineraryStop(
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
coordinate: coordinate,
|
||||||
|
games: sortedGames.map { $0.id },
|
||||||
|
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||||
|
departureDate: sortedGames.last?.gameDate ?? Date(),
|
||||||
|
location: location,
|
||||||
|
firstGameStart: sortedGames.first?.startTime
|
||||||
|
)
|
||||||
|
stops.append(stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds stops with start and end location endpoints.
|
||||||
|
private func buildStopsWithEndpoints(
|
||||||
|
start: LocationInput,
|
||||||
|
end: LocationInput,
|
||||||
|
games: [Game],
|
||||||
|
stadiums: [UUID: Stadium]
|
||||||
|
) -> [ItineraryStop] {
|
||||||
|
|
||||||
|
var stops: [ItineraryStop] = []
|
||||||
|
|
||||||
|
// Start stop (no games)
|
||||||
|
let startArrival = games.first?.gameDate.addingTimeInterval(-86400) ?? Date()
|
||||||
|
let startStop = ItineraryStop(
|
||||||
|
city: start.name,
|
||||||
|
state: "",
|
||||||
|
coordinate: start.coordinate,
|
||||||
|
games: [],
|
||||||
|
arrivalDate: startArrival,
|
||||||
|
departureDate: startArrival,
|
||||||
|
location: start,
|
||||||
|
firstGameStart: nil
|
||||||
|
)
|
||||||
|
stops.append(startStop)
|
||||||
|
|
||||||
|
// Game stops
|
||||||
|
let gameStops = buildStops(from: games, stadiums: stadiums)
|
||||||
|
stops.append(contentsOf: gameStops)
|
||||||
|
|
||||||
|
// End stop (no games)
|
||||||
|
let endArrival = games.last?.gameDate.addingTimeInterval(86400) ?? Date()
|
||||||
|
let endStop = ItineraryStop(
|
||||||
|
city: end.name,
|
||||||
|
state: "",
|
||||||
|
coordinate: end.coordinate,
|
||||||
|
games: [],
|
||||||
|
arrivalDate: endArrival,
|
||||||
|
departureDate: endArrival,
|
||||||
|
location: end,
|
||||||
|
firstGameStart: nil
|
||||||
|
)
|
||||||
|
stops.append(endStop)
|
||||||
|
|
||||||
|
return stops
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Monotonic Progress Validation
|
||||||
|
|
||||||
|
/// Validates that the route makes generally forward progress toward the end.
|
||||||
|
///
|
||||||
|
/// Each stop should be closer to (or not significantly farther from) the end
|
||||||
|
/// than the previous stop. Small detours are allowed within tolerance.
|
||||||
|
///
|
||||||
|
private func validateMonotonicProgress(
|
||||||
|
stops: [ItineraryStop],
|
||||||
|
toward end: CLLocationCoordinate2D
|
||||||
|
) -> Bool {
|
||||||
|
|
||||||
|
var previousDistance: Double?
|
||||||
|
|
||||||
|
for stop in stops {
|
||||||
|
guard let stopCoord = stop.coordinate else { continue }
|
||||||
|
|
||||||
|
let currentDistance = distanceBetween(stopCoord, end)
|
||||||
|
|
||||||
|
if let prev = previousDistance {
|
||||||
|
// Allow increases up to tolerance percentage
|
||||||
|
let allowedIncrease = prev * forwardProgressTolerance
|
||||||
|
if currentDistance > prev + allowedIncrease {
|
||||||
|
print("[ScenarioC] Backtracking: \(stop.city) increases distance to end (\(Int(currentDistance))mi vs \(Int(prev))mi)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousDistance = currentDistance
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Geometry Helpers
|
||||||
|
|
||||||
|
/// Distance between two coordinates in miles using Haversine formula.
|
||||||
|
private func distanceBetween(
|
||||||
|
_ coord1: CLLocationCoordinate2D,
|
||||||
|
_ coord2: CLLocationCoordinate2D
|
||||||
|
) -> Double {
|
||||||
|
let earthRadiusMiles = 3958.8
|
||||||
|
|
||||||
|
let lat1 = coord1.latitude * .pi / 180
|
||||||
|
let lat2 = coord2.latitude * .pi / 180
|
||||||
|
let deltaLat = (coord2.latitude - coord1.latitude) * .pi / 180
|
||||||
|
let deltaLon = (coord2.longitude - coord1.longitude) * .pi / 180
|
||||||
|
|
||||||
|
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
|
||||||
|
cos(lat1) * cos(lat2) *
|
||||||
|
sin(deltaLon / 2) * sin(deltaLon / 2)
|
||||||
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
|
||||||
|
return earthRadiusMiles * c
|
||||||
|
}
|
||||||
|
}
|
||||||
49
SportsTime/Planning/Engine/ScenarioPlanner.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// ScenarioPlanner.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Protocol for scenario-based trip planning.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Protocol that all scenario planners must implement.
|
||||||
|
/// Each scenario (A, B, C) has its own isolated implementation.
|
||||||
|
protocol ScenarioPlanner {
|
||||||
|
|
||||||
|
/// Plan itineraries for this scenario.
|
||||||
|
/// - Parameter request: The planning request with all inputs
|
||||||
|
/// - Returns: Success with ranked itineraries, or explicit failure
|
||||||
|
func plan(request: PlanningRequest) -> ItineraryResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory for creating the appropriate scenario planner
|
||||||
|
enum ScenarioPlannerFactory {
|
||||||
|
|
||||||
|
/// Creates the appropriate planner based on the request inputs
|
||||||
|
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
|
||||||
|
// Scenario B: User selected specific games
|
||||||
|
if !request.selectedGames.isEmpty {
|
||||||
|
return ScenarioBPlanner()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario C: User specified start and end locations
|
||||||
|
if request.startLocation != nil && request.endLocation != nil {
|
||||||
|
return ScenarioCPlanner()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario A: Date range only (default)
|
||||||
|
return ScenarioAPlanner()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies which scenario applies to this request
|
||||||
|
static func classify(_ request: PlanningRequest) -> PlanningScenario {
|
||||||
|
if !request.selectedGames.isEmpty {
|
||||||
|
return .scenarioB
|
||||||
|
}
|
||||||
|
if request.startLocation != nil && request.endLocation != nil {
|
||||||
|
return .scenarioC
|
||||||
|
}
|
||||||
|
return .scenarioA
|
||||||
|
}
|
||||||
|
}
|
||||||
396
SportsTime/Planning/Engine/ScheduleMatcher.swift
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
//
|
||||||
|
// ScheduleMatcher.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Finds and scores candidate games for trip planning.
|
||||||
|
///
|
||||||
|
/// Updated for the new scenario-based planning:
|
||||||
|
/// - Scenario A (Date Range): Find games in date range, cluster by region
|
||||||
|
/// - Scenario B (Selected Games): Validate must-see games, find optional additions
|
||||||
|
/// - Scenario C (Start+End): Find games along directional corridor with progress check
|
||||||
|
struct ScheduleMatcher {
|
||||||
|
|
||||||
|
// MARK: - Find Candidate Games (Legacy + Scenario C Support)
|
||||||
|
|
||||||
|
/// Finds candidate games along a corridor between start and end.
|
||||||
|
/// Supports directional filtering for Scenario C.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - request: Planning request with preferences and games
|
||||||
|
/// - startCoordinate: Starting location
|
||||||
|
/// - endCoordinate: Ending location
|
||||||
|
/// - enforceDirection: If true, only include games that make progress toward end
|
||||||
|
/// - Returns: Array of game candidates sorted by score
|
||||||
|
func findCandidateGames(
|
||||||
|
from request: PlanningRequest,
|
||||||
|
startCoordinate: CLLocationCoordinate2D,
|
||||||
|
endCoordinate: CLLocationCoordinate2D,
|
||||||
|
enforceDirection: Bool = false
|
||||||
|
) -> [GameCandidate] {
|
||||||
|
var candidates: [GameCandidate] = []
|
||||||
|
|
||||||
|
// Calculate the corridor between start and end
|
||||||
|
let corridor = RouteCorridorCalculator(
|
||||||
|
start: startCoordinate,
|
||||||
|
end: endCoordinate,
|
||||||
|
maxDetourFactor: detourFactorFor(request.preferences.leisureLevel)
|
||||||
|
)
|
||||||
|
|
||||||
|
for game in request.availableGames {
|
||||||
|
guard let stadium = request.stadiums[game.stadiumId],
|
||||||
|
let homeTeam = request.teams[game.homeTeamId],
|
||||||
|
let awayTeam = request.teams[game.awayTeamId] else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if game is within date range
|
||||||
|
guard game.dateTime >= request.preferences.startDate,
|
||||||
|
game.dateTime <= request.preferences.endDate else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sport filter
|
||||||
|
guard request.preferences.sports.contains(game.sport) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate detour distance
|
||||||
|
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
|
||||||
|
|
||||||
|
// For directional routes, check if this stadium makes progress
|
||||||
|
if enforceDirection {
|
||||||
|
let distanceToEnd = corridor.distanceToEnd(from: stadium.coordinate)
|
||||||
|
let startDistanceToEnd = corridor.directDistance
|
||||||
|
|
||||||
|
// Skip if stadium is behind the start (going backwards)
|
||||||
|
if distanceToEnd > startDistanceToEnd * 1.1 { // 10% tolerance
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if too far from route (unless must-see)
|
||||||
|
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
|
||||||
|
if !isMustSee && detourDistance > corridor.maxDetourDistance {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score the game
|
||||||
|
let score = scoreGame(
|
||||||
|
game: game,
|
||||||
|
homeTeam: homeTeam,
|
||||||
|
awayTeam: awayTeam,
|
||||||
|
detourDistance: detourDistance,
|
||||||
|
isMustSee: isMustSee,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
|
|
||||||
|
let candidate = GameCandidate(
|
||||||
|
id: game.id,
|
||||||
|
game: game,
|
||||||
|
stadium: stadium,
|
||||||
|
homeTeam: homeTeam,
|
||||||
|
awayTeam: awayTeam,
|
||||||
|
detourDistance: detourDistance,
|
||||||
|
score: score
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates.append(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score (highest first)
|
||||||
|
return candidates.sorted { $0.score > $1.score }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Directional Game Filtering (Scenario C)
|
||||||
|
|
||||||
|
/// Finds games along a directional route from start to end.
|
||||||
|
/// Ensures monotonic progress toward destination.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - request: Planning request
|
||||||
|
/// - startCoordinate: Starting location
|
||||||
|
/// - endCoordinate: Destination
|
||||||
|
/// - corridorWidthPercent: Width of corridor as percentage of direct distance
|
||||||
|
/// - Returns: Games sorted by their position along the route
|
||||||
|
func findDirectionalGames(
|
||||||
|
from request: PlanningRequest,
|
||||||
|
startCoordinate: CLLocationCoordinate2D,
|
||||||
|
endCoordinate: CLLocationCoordinate2D,
|
||||||
|
corridorWidthPercent: Double = 0.3
|
||||||
|
) -> [GameCandidate] {
|
||||||
|
let corridor = RouteCorridorCalculator(
|
||||||
|
start: startCoordinate,
|
||||||
|
end: endCoordinate,
|
||||||
|
maxDetourFactor: 1.0 + corridorWidthPercent
|
||||||
|
)
|
||||||
|
|
||||||
|
var candidates: [GameCandidate] = []
|
||||||
|
|
||||||
|
for game in request.availableGames {
|
||||||
|
guard let stadium = request.stadiums[game.stadiumId],
|
||||||
|
let homeTeam = request.teams[game.homeTeamId],
|
||||||
|
let awayTeam = request.teams[game.awayTeamId] else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date and sport filter
|
||||||
|
guard game.dateTime >= request.preferences.startDate,
|
||||||
|
game.dateTime <= request.preferences.endDate,
|
||||||
|
request.preferences.sports.contains(game.sport) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate progress along route (0 = start, 1 = end)
|
||||||
|
let progress = corridor.progressAlongRoute(point: stadium.coordinate)
|
||||||
|
|
||||||
|
// Only include games that are along the route (positive progress, not behind start)
|
||||||
|
guard progress >= -0.1 && progress <= 1.1 else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check corridor width
|
||||||
|
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
|
||||||
|
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
|
||||||
|
|
||||||
|
if !isMustSee && detourDistance > corridor.maxDetourDistance {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = scoreGame(
|
||||||
|
game: game,
|
||||||
|
homeTeam: homeTeam,
|
||||||
|
awayTeam: awayTeam,
|
||||||
|
detourDistance: detourDistance,
|
||||||
|
isMustSee: isMustSee,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
|
|
||||||
|
let candidate = GameCandidate(
|
||||||
|
id: game.id,
|
||||||
|
game: game,
|
||||||
|
stadium: stadium,
|
||||||
|
homeTeam: homeTeam,
|
||||||
|
awayTeam: awayTeam,
|
||||||
|
detourDistance: detourDistance,
|
||||||
|
score: score
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates.append(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date (chronological order is the primary constraint)
|
||||||
|
return candidates.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game Scoring
|
||||||
|
|
||||||
|
private func scoreGame(
|
||||||
|
game: Game,
|
||||||
|
homeTeam: Team,
|
||||||
|
awayTeam: Team,
|
||||||
|
detourDistance: Double,
|
||||||
|
isMustSee: Bool,
|
||||||
|
request: PlanningRequest
|
||||||
|
) -> Double {
|
||||||
|
var score: Double = 50.0 // Base score
|
||||||
|
|
||||||
|
// Must-see bonus
|
||||||
|
if isMustSee {
|
||||||
|
score += 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playoff bonus
|
||||||
|
if game.isPlayoff {
|
||||||
|
score += 30.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekend bonus (more convenient)
|
||||||
|
let weekday = Calendar.current.component(.weekday, from: game.dateTime)
|
||||||
|
if weekday == 1 || weekday == 7 { // Sunday or Saturday
|
||||||
|
score += 10.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evening game bonus (day games harder to schedule around)
|
||||||
|
let hour = Calendar.current.component(.hour, from: game.dateTime)
|
||||||
|
if hour >= 17 { // 5 PM or later
|
||||||
|
score += 5.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detour penalty
|
||||||
|
let detourMiles = detourDistance * 0.000621371
|
||||||
|
score -= detourMiles * 0.1 // Lose 0.1 points per mile of detour
|
||||||
|
|
||||||
|
// Preferred city bonus
|
||||||
|
if request.preferences.preferredCities.contains(homeTeam.city) {
|
||||||
|
score += 15.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must-stop location bonus
|
||||||
|
if request.preferences.mustStopLocations.contains(where: { $0.name.lowercased() == homeTeam.city.lowercased() }) {
|
||||||
|
score += 25.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detourFactorFor(_ leisureLevel: LeisureLevel) -> Double {
|
||||||
|
switch leisureLevel {
|
||||||
|
case .packed: return 1.3 // 30% detour allowed
|
||||||
|
case .moderate: return 1.5 // 50% detour allowed
|
||||||
|
case .relaxed: return 2.0 // 100% detour allowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Find Games at Location
|
||||||
|
|
||||||
|
func findGames(
|
||||||
|
at stadium: Stadium,
|
||||||
|
within dateRange: ClosedRange<Date>,
|
||||||
|
from games: [Game]
|
||||||
|
) -> [Game] {
|
||||||
|
games.filter { game in
|
||||||
|
game.stadiumId == stadium.id &&
|
||||||
|
dateRange.contains(game.dateTime)
|
||||||
|
}.sorted { $0.dateTime < $1.dateTime }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Find Other Sports
|
||||||
|
|
||||||
|
func findOtherSportsGames(
|
||||||
|
along route: [CLLocationCoordinate2D],
|
||||||
|
excludingSports: Set<Sport>,
|
||||||
|
within dateRange: ClosedRange<Date>,
|
||||||
|
games: [Game],
|
||||||
|
stadiums: [UUID: Stadium],
|
||||||
|
teams: [UUID: Team],
|
||||||
|
maxDetourMiles: Double = 50
|
||||||
|
) -> [GameCandidate] {
|
||||||
|
var candidates: [GameCandidate] = []
|
||||||
|
|
||||||
|
for game in games {
|
||||||
|
// Skip if sport is already selected
|
||||||
|
if excludingSports.contains(game.sport) { continue }
|
||||||
|
|
||||||
|
// Skip if outside date range
|
||||||
|
if !dateRange.contains(game.dateTime) { continue }
|
||||||
|
|
||||||
|
guard let stadium = stadiums[game.stadiumId],
|
||||||
|
let homeTeam = teams[game.homeTeamId],
|
||||||
|
let awayTeam = teams[game.awayTeamId] else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if stadium is near the route
|
||||||
|
let minDistance = route.map { coord in
|
||||||
|
CLLocation(latitude: coord.latitude, longitude: coord.longitude)
|
||||||
|
.distance(from: stadium.location)
|
||||||
|
}.min() ?? .greatestFiniteMagnitude
|
||||||
|
|
||||||
|
let distanceMiles = minDistance * 0.000621371
|
||||||
|
|
||||||
|
if distanceMiles <= maxDetourMiles {
|
||||||
|
let candidate = GameCandidate(
|
||||||
|
id: game.id,
|
||||||
|
game: game,
|
||||||
|
stadium: stadium,
|
||||||
|
homeTeam: homeTeam,
|
||||||
|
awayTeam: awayTeam,
|
||||||
|
detourDistance: minDistance,
|
||||||
|
score: 50.0 - distanceMiles // Score inversely proportional to detour
|
||||||
|
)
|
||||||
|
candidates.append(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.sorted { $0.score > $1.score }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validate Games for Scenarios
|
||||||
|
|
||||||
|
/// Validates that all must-see games are within the date range.
|
||||||
|
/// Used for Scenario B validation.
|
||||||
|
func validateMustSeeGamesInRange(
|
||||||
|
mustSeeGameIds: Set<UUID>,
|
||||||
|
allGames: [Game],
|
||||||
|
dateRange: ClosedRange<Date>
|
||||||
|
) -> (valid: Bool, outOfRange: [UUID]) {
|
||||||
|
var outOfRange: [UUID] = []
|
||||||
|
|
||||||
|
for gameId in mustSeeGameIds {
|
||||||
|
guard let game = allGames.first(where: { $0.id == gameId }) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !dateRange.contains(game.dateTime) {
|
||||||
|
outOfRange.append(gameId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (outOfRange.isEmpty, outOfRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Corridor Calculator
|
||||||
|
|
||||||
|
struct RouteCorridorCalculator {
|
||||||
|
let start: CLLocationCoordinate2D
|
||||||
|
let end: CLLocationCoordinate2D
|
||||||
|
let maxDetourFactor: Double
|
||||||
|
|
||||||
|
var directDistance: CLLocationDistance {
|
||||||
|
CLLocation(latitude: start.latitude, longitude: start.longitude)
|
||||||
|
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxDetourDistance: CLLocationDistance {
|
||||||
|
directDistance * (maxDetourFactor - 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detourDistance(to point: CLLocationCoordinate2D) -> CLLocationDistance {
|
||||||
|
let startToPoint = CLLocation(latitude: start.latitude, longitude: start.longitude)
|
||||||
|
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
|
||||||
|
|
||||||
|
let pointToEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||||
|
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||||
|
|
||||||
|
let totalViaPoint = startToPoint + pointToEnd
|
||||||
|
|
||||||
|
return max(0, totalViaPoint - directDistance)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWithinCorridor(_ point: CLLocationCoordinate2D) -> Bool {
|
||||||
|
detourDistance(to: point) <= maxDetourDistance
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the distance from a point to the end location.
|
||||||
|
func distanceToEnd(from point: CLLocationCoordinate2D) -> CLLocationDistance {
|
||||||
|
CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||||
|
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates progress along the route (0 = at start, 1 = at end).
|
||||||
|
/// Can be negative (behind start) or > 1 (past end).
|
||||||
|
func progressAlongRoute(point: CLLocationCoordinate2D) -> Double {
|
||||||
|
guard directDistance > 0 else { return 0 }
|
||||||
|
|
||||||
|
let distFromStart = CLLocation(latitude: start.latitude, longitude: start.longitude)
|
||||||
|
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
|
||||||
|
|
||||||
|
let distFromEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||||
|
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
|
||||||
|
|
||||||
|
// Use the law of cosines to project onto the line
|
||||||
|
// progress = (d_start² + d_total² - d_end²) / (2 * d_total²)
|
||||||
|
let dStart = distFromStart
|
||||||
|
let dEnd = distFromEnd
|
||||||
|
let dTotal = directDistance
|
||||||
|
|
||||||
|
let numerator = (dStart * dStart) + (dTotal * dTotal) - (dEnd * dEnd)
|
||||||
|
let denominator = 2 * dTotal * dTotal
|
||||||
|
|
||||||
|
return numerator / denominator
|
||||||
|
}
|
||||||
|
}
|
||||||
180
SportsTime/Planning/Engine/TravelEstimator.swift
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// TravelEstimator.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Shared travel estimation logic used by all scenario planners.
|
||||||
|
// Estimating travel from A to B is the same regardless of planning scenario.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
enum TravelEstimator {
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private static let averageSpeedMph: Double = 60.0
|
||||||
|
private static let roadRoutingFactor: Double = 1.3 // Straight line to road distance
|
||||||
|
private static let fallbackDistanceMiles: Double = 300.0
|
||||||
|
|
||||||
|
// MARK: - Travel Estimation
|
||||||
|
|
||||||
|
/// Estimates a travel segment between two stops.
|
||||||
|
/// Returns nil only if the segment exceeds maximum driving time.
|
||||||
|
static func estimate(
|
||||||
|
from: ItineraryStop,
|
||||||
|
to: ItineraryStop,
|
||||||
|
constraints: DrivingConstraints
|
||||||
|
) -> TravelSegment? {
|
||||||
|
|
||||||
|
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
||||||
|
let drivingHours = distanceMiles / averageSpeedMph
|
||||||
|
|
||||||
|
// Reject if segment requires more than 2 days of driving
|
||||||
|
let maxDailyHours = constraints.maxDailyDrivingHours
|
||||||
|
if drivingHours > maxDailyHours * 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate times (assume 8 AM departure)
|
||||||
|
let departureTime = from.departureDate.addingTimeInterval(8 * 3600)
|
||||||
|
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
||||||
|
|
||||||
|
return TravelSegment(
|
||||||
|
fromLocation: from.location,
|
||||||
|
toLocation: to.location,
|
||||||
|
travelMode: .drive,
|
||||||
|
distanceMeters: distanceMiles * 1609.34,
|
||||||
|
durationSeconds: drivingHours * 3600,
|
||||||
|
departureTime: departureTime,
|
||||||
|
arrivalTime: arrivalTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimates a travel segment between two LocationInputs.
|
||||||
|
/// Returns nil if coordinates are missing or segment exceeds max driving time.
|
||||||
|
static func estimate(
|
||||||
|
from: LocationInput,
|
||||||
|
to: LocationInput,
|
||||||
|
constraints: DrivingConstraints
|
||||||
|
) -> TravelSegment? {
|
||||||
|
|
||||||
|
guard let fromCoord = from.coordinate,
|
||||||
|
let toCoord = to.coordinate else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let distanceMeters = haversineDistanceMeters(from: fromCoord, to: toCoord)
|
||||||
|
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
|
||||||
|
let drivingHours = distanceMiles / averageSpeedMph
|
||||||
|
|
||||||
|
// Reject if > 2 days of driving
|
||||||
|
if drivingHours > constraints.maxDailyDrivingHours * 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let departureTime = Date()
|
||||||
|
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
||||||
|
|
||||||
|
return TravelSegment(
|
||||||
|
fromLocation: from,
|
||||||
|
toLocation: to,
|
||||||
|
travelMode: .drive,
|
||||||
|
distanceMeters: distanceMeters * roadRoutingFactor,
|
||||||
|
durationSeconds: drivingHours * 3600,
|
||||||
|
departureTime: departureTime,
|
||||||
|
arrivalTime: arrivalTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Distance Calculations
|
||||||
|
|
||||||
|
/// Calculates distance in miles between two stops.
|
||||||
|
/// Uses Haversine formula if coordinates available, fallback otherwise.
|
||||||
|
static func calculateDistanceMiles(
|
||||||
|
from: ItineraryStop,
|
||||||
|
to: ItineraryStop
|
||||||
|
) -> Double {
|
||||||
|
if let fromCoord = from.coordinate,
|
||||||
|
let toCoord = to.coordinate {
|
||||||
|
return haversineDistanceMiles(from: fromCoord, to: toCoord) * roadRoutingFactor
|
||||||
|
}
|
||||||
|
return estimateFallbackDistance(from: from, to: to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates distance in miles between two coordinates using Haversine.
|
||||||
|
static func haversineDistanceMiles(
|
||||||
|
from: CLLocationCoordinate2D,
|
||||||
|
to: CLLocationCoordinate2D
|
||||||
|
) -> Double {
|
||||||
|
let earthRadiusMiles = 3958.8
|
||||||
|
|
||||||
|
let lat1 = from.latitude * .pi / 180
|
||||||
|
let lat2 = to.latitude * .pi / 180
|
||||||
|
let deltaLat = (to.latitude - from.latitude) * .pi / 180
|
||||||
|
let deltaLon = (to.longitude - from.longitude) * .pi / 180
|
||||||
|
|
||||||
|
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
|
||||||
|
cos(lat1) * cos(lat2) *
|
||||||
|
sin(deltaLon / 2) * sin(deltaLon / 2)
|
||||||
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
|
||||||
|
return earthRadiusMiles * c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates distance in meters between two coordinates using Haversine.
|
||||||
|
static func haversineDistanceMeters(
|
||||||
|
from: CLLocationCoordinate2D,
|
||||||
|
to: CLLocationCoordinate2D
|
||||||
|
) -> Double {
|
||||||
|
let earthRadiusMeters = 6371000.0
|
||||||
|
|
||||||
|
let lat1 = from.latitude * .pi / 180
|
||||||
|
let lat2 = to.latitude * .pi / 180
|
||||||
|
let deltaLat = (to.latitude - from.latitude) * .pi / 180
|
||||||
|
let deltaLon = (to.longitude - from.longitude) * .pi / 180
|
||||||
|
|
||||||
|
let a = sin(deltaLat / 2) * sin(deltaLat / 2) +
|
||||||
|
cos(lat1) * cos(lat2) *
|
||||||
|
sin(deltaLon / 2) * sin(deltaLon / 2)
|
||||||
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
|
||||||
|
return earthRadiusMeters * c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback distance when coordinates aren't available.
|
||||||
|
static func estimateFallbackDistance(
|
||||||
|
from: ItineraryStop,
|
||||||
|
to: ItineraryStop
|
||||||
|
) -> Double {
|
||||||
|
if from.city == to.city {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fallbackDistanceMiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Travel Days
|
||||||
|
|
||||||
|
/// Calculates which calendar days travel spans.
|
||||||
|
static func calculateTravelDays(
|
||||||
|
departure: Date,
|
||||||
|
drivingHours: Double
|
||||||
|
) -> [Date] {
|
||||||
|
var days: [Date] = []
|
||||||
|
let calendar = Calendar.current
|
||||||
|
|
||||||
|
let startDay = calendar.startOfDay(for: departure)
|
||||||
|
days.append(startDay)
|
||||||
|
|
||||||
|
// Add days if driving takes multiple days (8 hrs/day max)
|
||||||
|
let daysOfDriving = Int(ceil(drivingHours / 8.0))
|
||||||
|
for dayOffset in 1..<daysOfDriving {
|
||||||
|
if let nextDay = calendar.date(byAdding: .day, value: dayOffset, to: startDay) {
|
||||||
|
days.append(nextDay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
41
SportsTime/Planning/Engine/TripPlanningEngine.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// TripPlanningEngine.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Thin orchestrator that delegates to scenario-specific planners.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Main entry point for trip planning.
|
||||||
|
/// Delegates to scenario-specific planners via the ScenarioPlanner protocol.
|
||||||
|
final class TripPlanningEngine {
|
||||||
|
|
||||||
|
/// Plans itineraries based on the request inputs.
|
||||||
|
/// Automatically detects which scenario applies and delegates to the appropriate planner.
|
||||||
|
///
|
||||||
|
/// - Parameter request: The planning request containing all inputs
|
||||||
|
/// - Returns: Ranked itineraries on success, or explicit failure with reason
|
||||||
|
func planItineraries(request: PlanningRequest) -> ItineraryResult {
|
||||||
|
|
||||||
|
// Detect scenario and get the appropriate planner
|
||||||
|
let scenario = ScenarioPlannerFactory.classify(request)
|
||||||
|
let planner = ScenarioPlannerFactory.planner(for: request)
|
||||||
|
|
||||||
|
print("[TripPlanningEngine] Detected scenario: \(scenario)")
|
||||||
|
print("[TripPlanningEngine] Using planner: \(type(of: planner))")
|
||||||
|
|
||||||
|
// Delegate to the scenario planner
|
||||||
|
let result = planner.plan(request: request)
|
||||||
|
|
||||||
|
// Log result
|
||||||
|
switch result {
|
||||||
|
case .success(let options):
|
||||||
|
print("[TripPlanningEngine] Success: \(options.count) itinerary options")
|
||||||
|
case .failure(let failure):
|
||||||
|
print("[TripPlanningEngine] Failure: \(failure.reason)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
99
SportsTime/Planning/Models/LegacyPlanningTypes.swift
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// LegacyPlanningTypes.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Supporting types for legacy planning components.
|
||||||
|
// These are used by ScheduleMatcher and RouteOptimizer.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
// MARK: - Game Candidate
|
||||||
|
|
||||||
|
/// A game candidate with scoring information for route planning.
|
||||||
|
struct GameCandidate: Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let game: Game
|
||||||
|
let stadium: Stadium
|
||||||
|
let homeTeam: Team
|
||||||
|
let awayTeam: Team
|
||||||
|
let detourDistance: Double
|
||||||
|
let score: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Graph
|
||||||
|
|
||||||
|
/// Graph representation of possible routes for optimization.
|
||||||
|
struct RouteGraph {
|
||||||
|
var nodes: [RouteNode]
|
||||||
|
var edgesByFromNode: [UUID: [RouteEdge]]
|
||||||
|
|
||||||
|
init(nodes: [RouteNode] = [], edges: [RouteEdge] = []) {
|
||||||
|
self.nodes = nodes
|
||||||
|
self.edgesByFromNode = [:]
|
||||||
|
for edge in edges {
|
||||||
|
edgesByFromNode[edge.fromNodeId, default: []].append(edge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func edges(from nodeId: UUID) -> [RouteEdge] {
|
||||||
|
edgesByFromNode[nodeId] ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Node
|
||||||
|
|
||||||
|
struct RouteNode: Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let type: RouteNodeType
|
||||||
|
let coordinate: CLLocationCoordinate2D?
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), type: RouteNodeType, coordinate: CLLocationCoordinate2D? = nil) {
|
||||||
|
self.id = id
|
||||||
|
self.type = type
|
||||||
|
self.coordinate = coordinate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RouteNodeType: Equatable {
|
||||||
|
case start
|
||||||
|
case end
|
||||||
|
case stadium(UUID)
|
||||||
|
case waypoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Edge
|
||||||
|
|
||||||
|
struct RouteEdge: Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let fromNodeId: UUID
|
||||||
|
let toNodeId: UUID
|
||||||
|
let distanceMeters: Double
|
||||||
|
let durationSeconds: Double
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
fromNodeId: UUID,
|
||||||
|
toNodeId: UUID,
|
||||||
|
distanceMeters: Double,
|
||||||
|
durationSeconds: Double
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.fromNodeId = fromNodeId
|
||||||
|
self.toNodeId = toNodeId
|
||||||
|
self.distanceMeters = distanceMeters
|
||||||
|
self.durationSeconds = durationSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Candidate Route
|
||||||
|
|
||||||
|
/// A candidate route for optimization.
|
||||||
|
struct CandidateRoute {
|
||||||
|
var nodeSequence: [UUID] = []
|
||||||
|
var games: [UUID] = []
|
||||||
|
var totalDistance: Double = 0
|
||||||
|
var totalDuration: Double = 0
|
||||||
|
var score: Double = 0
|
||||||
|
}
|
||||||
281
SportsTime/Planning/Models/PlanningModels.swift
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
//
|
||||||
|
// PlanningModels.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Clean model types for trip planning.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
// MARK: - Planning Scenario
|
||||||
|
|
||||||
|
/// Exactly one scenario per request. No blending.
|
||||||
|
enum PlanningScenario: Equatable {
|
||||||
|
case scenarioA // Date range only
|
||||||
|
case scenarioB // Selected games + date range
|
||||||
|
case scenarioC // Start + end locations
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planning Failure
|
||||||
|
|
||||||
|
/// Explicit failure with reason. No silent failures.
|
||||||
|
struct PlanningFailure: Error {
|
||||||
|
let reason: FailureReason
|
||||||
|
let violations: [ConstraintViolation]
|
||||||
|
|
||||||
|
enum FailureReason: Equatable {
|
||||||
|
case noGamesInRange
|
||||||
|
case noValidRoutes
|
||||||
|
case missingDateRange
|
||||||
|
case missingLocations
|
||||||
|
case dateRangeViolation(games: [Game])
|
||||||
|
case drivingExceedsLimit
|
||||||
|
case cannotArriveInTime
|
||||||
|
case travelSegmentMissing
|
||||||
|
case constraintsUnsatisfiable
|
||||||
|
case geographicBacktracking
|
||||||
|
|
||||||
|
static func == (lhs: FailureReason, rhs: FailureReason) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.noGamesInRange, .noGamesInRange),
|
||||||
|
(.noValidRoutes, .noValidRoutes),
|
||||||
|
(.missingDateRange, .missingDateRange),
|
||||||
|
(.missingLocations, .missingLocations),
|
||||||
|
(.drivingExceedsLimit, .drivingExceedsLimit),
|
||||||
|
(.cannotArriveInTime, .cannotArriveInTime),
|
||||||
|
(.travelSegmentMissing, .travelSegmentMissing),
|
||||||
|
(.constraintsUnsatisfiable, .constraintsUnsatisfiable),
|
||||||
|
(.geographicBacktracking, .geographicBacktracking):
|
||||||
|
return true
|
||||||
|
case (.dateRangeViolation(let g1), .dateRangeViolation(let g2)):
|
||||||
|
return g1.map { $0.id } == g2.map { $0.id }
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(reason: FailureReason, violations: [ConstraintViolation] = []) {
|
||||||
|
self.reason = reason
|
||||||
|
self.violations = violations
|
||||||
|
}
|
||||||
|
|
||||||
|
var message: String {
|
||||||
|
switch reason {
|
||||||
|
case .noGamesInRange: return "No games found within the date range"
|
||||||
|
case .noValidRoutes: return "No valid routes could be constructed"
|
||||||
|
case .missingDateRange: return "Date range is required"
|
||||||
|
case .missingLocations: return "Start and end locations are required"
|
||||||
|
case .dateRangeViolation(let games):
|
||||||
|
return "\(games.count) selected game(s) fall outside the date range"
|
||||||
|
case .drivingExceedsLimit: return "Driving time exceeds daily limit"
|
||||||
|
case .cannotArriveInTime: return "Cannot arrive before game starts"
|
||||||
|
case .travelSegmentMissing: return "Travel segment could not be created"
|
||||||
|
case .constraintsUnsatisfiable: return "Cannot satisfy all trip constraints"
|
||||||
|
case .geographicBacktracking: return "Route requires excessive backtracking"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Constraint Violation
|
||||||
|
|
||||||
|
struct ConstraintViolation: Equatable {
|
||||||
|
let type: ConstraintType
|
||||||
|
let description: String
|
||||||
|
let severity: ViolationSeverity
|
||||||
|
|
||||||
|
init(constraint: String, detail: String) {
|
||||||
|
self.type = .general
|
||||||
|
self.description = "\(constraint): \(detail)"
|
||||||
|
self.severity = .error
|
||||||
|
}
|
||||||
|
|
||||||
|
init(type: ConstraintType, description: String, severity: ViolationSeverity) {
|
||||||
|
self.type = type
|
||||||
|
self.description = description
|
||||||
|
self.severity = severity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConstraintType: String, Equatable {
|
||||||
|
case dateRange
|
||||||
|
case drivingTime
|
||||||
|
case geographicSanity
|
||||||
|
case mustStop
|
||||||
|
case selectedGames
|
||||||
|
case gameReachability
|
||||||
|
case general
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ViolationSeverity: Equatable {
|
||||||
|
case warning
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Must Stop Config
|
||||||
|
|
||||||
|
struct MustStopConfig {
|
||||||
|
static let defaultProximityMiles: Double = 25
|
||||||
|
let proximityMiles: Double
|
||||||
|
|
||||||
|
init(proximityMiles: Double = MustStopConfig.defaultProximityMiles) {
|
||||||
|
self.proximityMiles = proximityMiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Itinerary Result
|
||||||
|
|
||||||
|
/// Either success with ranked options, or explicit failure.
|
||||||
|
enum ItineraryResult {
|
||||||
|
case success([ItineraryOption])
|
||||||
|
case failure(PlanningFailure)
|
||||||
|
|
||||||
|
var isSuccess: Bool {
|
||||||
|
if case .success = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var options: [ItineraryOption] {
|
||||||
|
if case .success(let opts) = self { return opts }
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
var failure: PlanningFailure? {
|
||||||
|
if case .failure(let f) = self { return f }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Candidate
|
||||||
|
|
||||||
|
/// Intermediate structure during planning.
|
||||||
|
struct RouteCandidate {
|
||||||
|
let stops: [ItineraryStop]
|
||||||
|
let rationale: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Itinerary Option
|
||||||
|
|
||||||
|
/// A valid, ranked itinerary option.
|
||||||
|
struct ItineraryOption: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let rank: Int
|
||||||
|
let stops: [ItineraryStop]
|
||||||
|
let travelSegments: [TravelSegment]
|
||||||
|
let totalDrivingHours: Double
|
||||||
|
let totalDistanceMiles: Double
|
||||||
|
let geographicRationale: String
|
||||||
|
|
||||||
|
/// INVARIANT: travelSegments.count == stops.count - 1 (or 0 if single stop)
|
||||||
|
var isValid: Bool {
|
||||||
|
if stops.count <= 1 { return travelSegments.isEmpty }
|
||||||
|
return travelSegments.count == stops.count - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalGames: Int {
|
||||||
|
stops.reduce(0) { $0 + $1.games.count }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Itinerary Stop
|
||||||
|
|
||||||
|
/// A stop in the itinerary.
|
||||||
|
struct ItineraryStop: Identifiable, Hashable {
|
||||||
|
let id = UUID()
|
||||||
|
let city: String
|
||||||
|
let state: String
|
||||||
|
let coordinate: CLLocationCoordinate2D?
|
||||||
|
let games: [UUID]
|
||||||
|
let arrivalDate: Date
|
||||||
|
let departureDate: Date
|
||||||
|
let location: LocationInput
|
||||||
|
let firstGameStart: Date?
|
||||||
|
|
||||||
|
var hasGames: Bool { !games.isEmpty }
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: ItineraryStop, rhs: ItineraryStop) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Driving Constraints
|
||||||
|
|
||||||
|
/// Driving feasibility constraints.
|
||||||
|
struct DrivingConstraints {
|
||||||
|
let numberOfDrivers: Int
|
||||||
|
let maxHoursPerDriverPerDay: Double
|
||||||
|
|
||||||
|
static let `default` = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
||||||
|
|
||||||
|
var maxDailyDrivingHours: Double {
|
||||||
|
Double(numberOfDrivers) * maxHoursPerDriverPerDay
|
||||||
|
}
|
||||||
|
|
||||||
|
init(numberOfDrivers: Int = 1, maxHoursPerDriverPerDay: Double = 8.0) {
|
||||||
|
self.numberOfDrivers = max(1, numberOfDrivers)
|
||||||
|
self.maxHoursPerDriverPerDay = max(1.0, maxHoursPerDriverPerDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from preferences: TripPreferences) {
|
||||||
|
self.numberOfDrivers = max(1, preferences.numberOfDrivers)
|
||||||
|
self.maxHoursPerDriverPerDay = preferences.maxDrivingHoursPerDriver ?? 8.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planning Request
|
||||||
|
|
||||||
|
/// Input to the planning engine.
|
||||||
|
struct PlanningRequest {
|
||||||
|
let preferences: TripPreferences
|
||||||
|
let availableGames: [Game]
|
||||||
|
let teams: [UUID: Team]
|
||||||
|
let stadiums: [UUID: Stadium]
|
||||||
|
|
||||||
|
// MARK: - Computed Properties for Engine
|
||||||
|
|
||||||
|
/// Games the user explicitly selected (anchors for Scenario B)
|
||||||
|
var selectedGames: [Game] {
|
||||||
|
availableGames.filter { preferences.mustSeeGameIds.contains($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All available games
|
||||||
|
var allGames: [Game] {
|
||||||
|
availableGames
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start location (for Scenario C)
|
||||||
|
var startLocation: LocationInput? {
|
||||||
|
preferences.startLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End location (for Scenario C)
|
||||||
|
var endLocation: LocationInput? {
|
||||||
|
preferences.endLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Date range as DateInterval
|
||||||
|
var dateRange: DateInterval? {
|
||||||
|
guard preferences.endDate > preferences.startDate else { return nil }
|
||||||
|
return DateInterval(start: preferences.startDate, end: preferences.endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First must-stop location (if any)
|
||||||
|
var mustStopLocation: LocationInput? {
|
||||||
|
preferences.mustStopLocations.first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Driving constraints
|
||||||
|
var drivingConstraints: DrivingConstraints {
|
||||||
|
DrivingConstraints(from: preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get stadium for a game
|
||||||
|
func stadium(for game: Game) -> Stadium? {
|
||||||
|
stadiums[game.stadiumId]
|
||||||
|
}
|
||||||
|
}
|
||||||
196
SportsTime/Planning/Scoring/TripScorer.swift
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
//
|
||||||
|
// TripScorer.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TripScorer {
|
||||||
|
|
||||||
|
// MARK: - Score Trip
|
||||||
|
|
||||||
|
func score(trip: Trip, request: PlanningRequest) -> Trip {
|
||||||
|
let gameQuality = calculateGameQualityScore(trip: trip, request: request)
|
||||||
|
let routeEfficiency = calculateRouteEfficiencyScore(trip: trip, request: request)
|
||||||
|
let leisureBalance = calculateLeisureBalanceScore(trip: trip, request: request)
|
||||||
|
let preferenceAlignment = calculatePreferenceAlignmentScore(trip: trip, request: request)
|
||||||
|
|
||||||
|
// Weighted average
|
||||||
|
let weights = (game: 0.35, route: 0.25, leisure: 0.20, preference: 0.20)
|
||||||
|
|
||||||
|
let overall = (
|
||||||
|
gameQuality * weights.game +
|
||||||
|
routeEfficiency * weights.route +
|
||||||
|
leisureBalance * weights.leisure +
|
||||||
|
preferenceAlignment * weights.preference
|
||||||
|
)
|
||||||
|
|
||||||
|
let score = TripScore(
|
||||||
|
overallScore: overall,
|
||||||
|
gameQualityScore: gameQuality,
|
||||||
|
routeEfficiencyScore: routeEfficiency,
|
||||||
|
leisureBalanceScore: leisureBalance,
|
||||||
|
preferenceAlignmentScore: preferenceAlignment
|
||||||
|
)
|
||||||
|
|
||||||
|
var scoredTrip = trip
|
||||||
|
scoredTrip.score = score
|
||||||
|
return scoredTrip
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game Quality Score
|
||||||
|
|
||||||
|
private func calculateGameQualityScore(trip: Trip, request: PlanningRequest) -> Double {
|
||||||
|
var score: Double = 0
|
||||||
|
|
||||||
|
let totalPossibleGames = Double(max(1, request.availableGames.count))
|
||||||
|
let gamesAttended = Double(trip.totalGames)
|
||||||
|
|
||||||
|
// Base score from number of games
|
||||||
|
let gameRatio = gamesAttended / min(totalPossibleGames, Double(trip.tripDuration))
|
||||||
|
score += gameRatio * 50
|
||||||
|
|
||||||
|
// Bonus for including must-see games
|
||||||
|
let mustSeeIncluded = trip.stops.flatMap { $0.games }
|
||||||
|
.filter { request.preferences.mustSeeGameIds.contains($0) }
|
||||||
|
.count
|
||||||
|
let mustSeeRatio = Double(mustSeeIncluded) / Double(max(1, request.preferences.mustSeeGameIds.count))
|
||||||
|
score += mustSeeRatio * 30
|
||||||
|
|
||||||
|
// Bonus for sport variety
|
||||||
|
let sportsAttended = Set(request.availableGames
|
||||||
|
.filter { trip.stops.flatMap { $0.games }.contains($0.id) }
|
||||||
|
.map { $0.sport }
|
||||||
|
)
|
||||||
|
let varietyBonus = Double(sportsAttended.count) / Double(max(1, request.preferences.sports.count)) * 20
|
||||||
|
score += varietyBonus
|
||||||
|
|
||||||
|
return min(100, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Efficiency Score
|
||||||
|
|
||||||
|
private func calculateRouteEfficiencyScore(trip: Trip, request: PlanningRequest) -> Double {
|
||||||
|
guard let startLocation = request.preferences.startLocation,
|
||||||
|
let endLocation = request.preferences.endLocation,
|
||||||
|
let startCoord = startLocation.coordinate,
|
||||||
|
let endCoord = endLocation.coordinate else {
|
||||||
|
return 50.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate direct distance
|
||||||
|
let directDistance = CLLocation(latitude: startCoord.latitude, longitude: startCoord.longitude)
|
||||||
|
.distance(from: CLLocation(latitude: endCoord.latitude, longitude: endCoord.longitude))
|
||||||
|
|
||||||
|
guard trip.totalDistanceMeters > 0, directDistance > 0 else {
|
||||||
|
return 50.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Efficiency ratio (direct / actual)
|
||||||
|
let efficiency = directDistance / trip.totalDistanceMeters
|
||||||
|
|
||||||
|
// Score: 100 for perfect efficiency, lower for longer routes
|
||||||
|
// Allow up to 3x direct distance before score drops significantly
|
||||||
|
let normalizedEfficiency = min(1.0, efficiency * 2)
|
||||||
|
|
||||||
|
return normalizedEfficiency * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Leisure Balance Score
|
||||||
|
|
||||||
|
private func calculateLeisureBalanceScore(trip: Trip, request: PlanningRequest) -> Double {
|
||||||
|
let leisureLevel = request.preferences.leisureLevel
|
||||||
|
var score: Double = 100
|
||||||
|
|
||||||
|
// Check average driving hours
|
||||||
|
let avgDrivingHours = trip.averageDrivingHoursPerDay
|
||||||
|
let targetDrivingHours: Double = switch leisureLevel {
|
||||||
|
case .packed: 8.0
|
||||||
|
case .moderate: 6.0
|
||||||
|
case .relaxed: 4.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if avgDrivingHours > targetDrivingHours {
|
||||||
|
let excess = avgDrivingHours - targetDrivingHours
|
||||||
|
score -= excess * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rest day ratio
|
||||||
|
let restDays = trip.stops.filter { $0.isRestDay }.count
|
||||||
|
let targetRestRatio = leisureLevel.restDaysPerWeek / 7.0
|
||||||
|
let actualRestRatio = Double(restDays) / Double(max(1, trip.tripDuration))
|
||||||
|
|
||||||
|
let restDifference = abs(actualRestRatio - targetRestRatio)
|
||||||
|
score -= restDifference * 50
|
||||||
|
|
||||||
|
// Check games per day vs target
|
||||||
|
let gamesPerDay = Double(trip.totalGames) / Double(max(1, trip.tripDuration))
|
||||||
|
let targetGamesPerDay = Double(leisureLevel.maxGamesPerWeek) / 7.0
|
||||||
|
|
||||||
|
if gamesPerDay > targetGamesPerDay {
|
||||||
|
let excess = gamesPerDay - targetGamesPerDay
|
||||||
|
score -= excess * 20
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, min(100, score))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preference Alignment Score
|
||||||
|
|
||||||
|
private func calculatePreferenceAlignmentScore(trip: Trip, request: PlanningRequest) -> Double {
|
||||||
|
var score: Double = 100
|
||||||
|
|
||||||
|
// Check if must-stop locations are visited
|
||||||
|
let visitedCities = Set(trip.stops.map { $0.city.lowercased() })
|
||||||
|
for location in request.preferences.mustStopLocations {
|
||||||
|
if !visitedCities.contains(location.name.lowercased()) {
|
||||||
|
score -= 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for preferred cities
|
||||||
|
for city in request.preferences.preferredCities {
|
||||||
|
if visitedCities.contains(city.lowercased()) {
|
||||||
|
score += 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check EV charging if needed
|
||||||
|
if request.preferences.needsEVCharging {
|
||||||
|
let hasEVStops = trip.travelSegments.contains { !$0.evChargingStops.isEmpty }
|
||||||
|
if !hasEVStops && trip.travelSegments.contains(where: { $0.distanceMiles > 200 }) {
|
||||||
|
score -= 20 // Long drive without EV stops
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check lodging type alignment
|
||||||
|
let lodgingMatches = trip.stops.filter { stop in
|
||||||
|
stop.lodging?.type == request.preferences.lodgingType
|
||||||
|
}.count
|
||||||
|
let lodgingRatio = Double(lodgingMatches) / Double(max(1, trip.stops.count))
|
||||||
|
score = score * (0.5 + lodgingRatio * 0.5)
|
||||||
|
|
||||||
|
// Check if within stop limit
|
||||||
|
if let maxStops = request.preferences.numberOfStops {
|
||||||
|
if trip.stops.count > maxStops {
|
||||||
|
score -= Double(trip.stops.count - maxStops) * 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return max(0, min(100, score))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CoreML Integration (Placeholder)
|
||||||
|
|
||||||
|
extension TripScorer {
|
||||||
|
|
||||||
|
/// Score using CoreML model if available
|
||||||
|
func scoreWithML(trip: Trip, request: PlanningRequest) -> Trip {
|
||||||
|
// In production, this would use a CoreML model for personalized scoring
|
||||||
|
// For now, fall back to rule-based scoring
|
||||||
|
return score(trip: trip, request: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import CoreLocation
|
||||||
127
SportsTime/Planning/Validators/DateRangeValidator.swift
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//
|
||||||
|
// DateRangeValidator.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Validates that all games fall within the specified date range.
|
||||||
|
/// Priority 1 in the rule hierarchy - checked first before any other constraints.
|
||||||
|
struct DateRangeValidator {
|
||||||
|
|
||||||
|
// MARK: - Validation Result
|
||||||
|
|
||||||
|
struct ValidationResult {
|
||||||
|
let isValid: Bool
|
||||||
|
let violations: [ConstraintViolation]
|
||||||
|
let gamesOutsideRange: [UUID]
|
||||||
|
|
||||||
|
static let valid = ValidationResult(isValid: true, violations: [], gamesOutsideRange: [])
|
||||||
|
|
||||||
|
static func invalid(games: [UUID]) -> ValidationResult {
|
||||||
|
let violations = games.map { gameId in
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .dateRange,
|
||||||
|
description: "Game \(gameId.uuidString.prefix(8)) falls outside the specified date range",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ValidationResult(isValid: false, violations: violations, gamesOutsideRange: games)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
/// Validates that ALL selected games (must-see games) fall within the date range.
|
||||||
|
/// This is a HARD constraint - if any selected game is outside the range, planning fails.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - mustSeeGameIds: Set of game IDs that MUST be included in the trip
|
||||||
|
/// - allGames: All available games to check against
|
||||||
|
/// - startDate: Start of the valid date range (inclusive)
|
||||||
|
/// - endDate: End of the valid date range (inclusive)
|
||||||
|
/// - Returns: ValidationResult indicating success or failure with specific violations
|
||||||
|
func validate(
|
||||||
|
mustSeeGameIds: Set<UUID>,
|
||||||
|
allGames: [Game],
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
) -> ValidationResult {
|
||||||
|
// If no must-see games, validation passes
|
||||||
|
guard !mustSeeGameIds.isEmpty else {
|
||||||
|
return .valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all must-see games that fall outside the range
|
||||||
|
let gamesOutsideRange = allGames
|
||||||
|
.filter { mustSeeGameIds.contains($0.id) }
|
||||||
|
.filter { game in
|
||||||
|
game.dateTime < startDate || game.dateTime > endDate
|
||||||
|
}
|
||||||
|
.map { $0.id }
|
||||||
|
|
||||||
|
if gamesOutsideRange.isEmpty {
|
||||||
|
return .valid
|
||||||
|
} else {
|
||||||
|
return .invalid(games: gamesOutsideRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates games for Scenario B (Selected Games mode).
|
||||||
|
/// ALL selected games MUST be within the date range - no exceptions.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - request: The planning request containing preferences and games
|
||||||
|
/// - Returns: ValidationResult with explicit failure if any selected game is out of range
|
||||||
|
func validateForScenarioB(_ request: PlanningRequest) -> ValidationResult {
|
||||||
|
return validate(
|
||||||
|
mustSeeGameIds: request.preferences.mustSeeGameIds,
|
||||||
|
allGames: request.availableGames,
|
||||||
|
startDate: request.preferences.startDate,
|
||||||
|
endDate: request.preferences.endDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if there are any games available within the date range.
|
||||||
|
/// Used to determine if planning can proceed at all.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - games: All available games
|
||||||
|
/// - startDate: Start of the valid date range
|
||||||
|
/// - endDate: End of the valid date range
|
||||||
|
/// - sports: Sports to filter by
|
||||||
|
/// - Returns: True if at least one game exists in the range
|
||||||
|
func hasGamesInRange(
|
||||||
|
games: [Game],
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
sports: Set<Sport>
|
||||||
|
) -> Bool {
|
||||||
|
games.contains { game in
|
||||||
|
game.dateTime >= startDate &&
|
||||||
|
game.dateTime <= endDate &&
|
||||||
|
sports.contains(game.sport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all games that fall within the specified date range.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - games: All available games
|
||||||
|
/// - startDate: Start of the valid date range
|
||||||
|
/// - endDate: End of the valid date range
|
||||||
|
/// - sports: Sports to filter by
|
||||||
|
/// - Returns: Array of games within the range
|
||||||
|
func gamesInRange(
|
||||||
|
games: [Game],
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
sports: Set<Sport>
|
||||||
|
) -> [Game] {
|
||||||
|
games.filter { game in
|
||||||
|
game.dateTime >= startDate &&
|
||||||
|
game.dateTime <= endDate &&
|
||||||
|
sports.contains(game.sport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
SportsTime/Planning/Validators/DrivingFeasibilityValidator.swift
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
//
|
||||||
|
// DrivingFeasibilityValidator.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Validates driving feasibility based on daily hour limits.
|
||||||
|
/// Priority 4 in the rule hierarchy.
|
||||||
|
///
|
||||||
|
/// A route is valid ONLY IF:
|
||||||
|
/// - Daily driving time ≤ maxDailyDrivingHours for EVERY day
|
||||||
|
/// - Games are reachable between scheduled times
|
||||||
|
struct DrivingFeasibilityValidator {
|
||||||
|
|
||||||
|
// MARK: - Validation Result
|
||||||
|
|
||||||
|
struct ValidationResult {
|
||||||
|
let isValid: Bool
|
||||||
|
let violations: [ConstraintViolation]
|
||||||
|
let failedSegment: SegmentFailure?
|
||||||
|
|
||||||
|
static let valid = ValidationResult(isValid: true, violations: [], failedSegment: nil)
|
||||||
|
|
||||||
|
static func drivingExceeded(
|
||||||
|
segment: String,
|
||||||
|
requiredHours: Double,
|
||||||
|
limitHours: Double
|
||||||
|
) -> ValidationResult {
|
||||||
|
let violation = ConstraintViolation(
|
||||||
|
type: .drivingTime,
|
||||||
|
description: "\(segment) requires \(String(format: "%.1f", requiredHours)) hours driving (limit: \(String(format: "%.1f", limitHours)) hours)",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
let failure = SegmentFailure(
|
||||||
|
segmentDescription: segment,
|
||||||
|
requiredHours: requiredHours,
|
||||||
|
limitHours: limitHours
|
||||||
|
)
|
||||||
|
return ValidationResult(isValid: false, violations: [violation], failedSegment: failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func gameUnreachable(
|
||||||
|
gameId: UUID,
|
||||||
|
arrivalTime: Date,
|
||||||
|
gameTime: Date
|
||||||
|
) -> ValidationResult {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .short
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
let violation = ConstraintViolation(
|
||||||
|
type: .gameReachability,
|
||||||
|
description: "Cannot arrive (\(formatter.string(from: arrivalTime))) before game starts (\(formatter.string(from: gameTime)))",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
return ValidationResult(isValid: false, violations: [violation], failedSegment: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SegmentFailure {
|
||||||
|
let segmentDescription: String
|
||||||
|
let requiredHours: Double
|
||||||
|
let limitHours: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
let constraints: DrivingConstraints
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(constraints: DrivingConstraints = .default) {
|
||||||
|
self.constraints = constraints
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from preferences: TripPreferences) {
|
||||||
|
self.constraints = DrivingConstraints(from: preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
/// Validates that a single travel segment is feasible within daily driving limits.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - drivingHours: Required driving hours for this segment
|
||||||
|
/// - origin: Description of the origin location
|
||||||
|
/// - destination: Description of the destination location
|
||||||
|
/// - Returns: ValidationResult indicating if the segment is feasible
|
||||||
|
func validateSegment(
|
||||||
|
drivingHours: Double,
|
||||||
|
origin: String,
|
||||||
|
destination: String
|
||||||
|
) -> ValidationResult {
|
||||||
|
let maxDaily = constraints.maxDailyDrivingHours
|
||||||
|
|
||||||
|
// A segment is valid if it can be completed in one day OR
|
||||||
|
// can be split across multiple days with overnight stops
|
||||||
|
if drivingHours <= maxDaily {
|
||||||
|
return .valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it can be reasonably split across multiple days
|
||||||
|
// We allow up to 2 driving days for a single segment
|
||||||
|
let maxTwoDayDriving = maxDaily * 2
|
||||||
|
if drivingHours <= maxTwoDayDriving {
|
||||||
|
return .valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment requires more than 2 days of driving - too long
|
||||||
|
return .drivingExceeded(
|
||||||
|
segment: "\(origin) → \(destination)",
|
||||||
|
requiredHours: drivingHours,
|
||||||
|
limitHours: maxDaily
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates that a game can be reached in time given departure time and driving duration.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - gameId: ID of the game to reach
|
||||||
|
/// - gameTime: When the game starts
|
||||||
|
/// - departureTime: When we leave the previous stop
|
||||||
|
/// - drivingHours: Hours of driving required
|
||||||
|
/// - bufferHours: Buffer time needed before game (default 1 hour for parking, etc.)
|
||||||
|
/// - Returns: ValidationResult indicating if we can reach the game in time
|
||||||
|
func validateGameReachability(
|
||||||
|
gameId: UUID,
|
||||||
|
gameTime: Date,
|
||||||
|
departureTime: Date,
|
||||||
|
drivingHours: Double,
|
||||||
|
bufferHours: Double = 1.0
|
||||||
|
) -> ValidationResult {
|
||||||
|
// Calculate arrival time
|
||||||
|
let drivingSeconds = drivingHours * 3600
|
||||||
|
let bufferSeconds = bufferHours * 3600
|
||||||
|
let arrivalTime = departureTime.addingTimeInterval(drivingSeconds)
|
||||||
|
let requiredArrivalTime = gameTime.addingTimeInterval(-bufferSeconds)
|
||||||
|
|
||||||
|
if arrivalTime <= requiredArrivalTime {
|
||||||
|
return .valid
|
||||||
|
} else {
|
||||||
|
return .gameUnreachable(
|
||||||
|
gameId: gameId,
|
||||||
|
arrivalTime: arrivalTime,
|
||||||
|
gameTime: gameTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates an entire itinerary's travel segments for driving feasibility.
|
||||||
|
///
|
||||||
|
/// - Parameter segments: Array of travel segments to validate
|
||||||
|
/// - Returns: ValidationResult with first failure found, or valid if all pass
|
||||||
|
func validateItinerary(segments: [TravelSegment]) -> ValidationResult {
|
||||||
|
for segment in segments {
|
||||||
|
let result = validateSegment(
|
||||||
|
drivingHours: segment.estimatedDrivingHours,
|
||||||
|
origin: segment.fromLocation.name,
|
||||||
|
destination: segment.toLocation.name
|
||||||
|
)
|
||||||
|
if !result.isValid {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .valid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates how many travel days are needed for a given driving distance.
|
||||||
|
///
|
||||||
|
/// - Parameter drivingHours: Total hours of driving required
|
||||||
|
/// - Returns: Number of calendar days the travel will span
|
||||||
|
func travelDaysRequired(for drivingHours: Double) -> Int {
|
||||||
|
let maxDaily = constraints.maxDailyDrivingHours
|
||||||
|
if drivingHours <= maxDaily {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return Int(ceil(drivingHours / maxDaily))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines if an overnight stop is needed between two points.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - drivingHours: Hours of driving between points
|
||||||
|
/// - departureTime: When we plan to leave
|
||||||
|
/// - Returns: True if an overnight stop is recommended
|
||||||
|
func needsOvernightStop(drivingHours: Double, departureTime: Date) -> Bool {
|
||||||
|
// If driving exceeds daily limit, we need an overnight
|
||||||
|
if drivingHours > constraints.maxDailyDrivingHours {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if arrival would be unreasonably late
|
||||||
|
let arrivalTime = departureTime.addingTimeInterval(drivingHours * 3600)
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let arrivalHour = calendar.component(.hour, from: arrivalTime)
|
||||||
|
|
||||||
|
// Arriving after 11 PM suggests we should have stopped
|
||||||
|
return arrivalHour >= 23
|
||||||
|
}
|
||||||
|
}
|
||||||
229
SportsTime/Planning/Validators/GeographicSanityChecker.swift
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
//
|
||||||
|
// GeographicSanityChecker.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Validates geographic sanity of routes - no zig-zagging or excessive backtracking.
|
||||||
|
/// Priority 5 in the rule hierarchy.
|
||||||
|
///
|
||||||
|
/// For Scenario C (directional routes with start+end):
|
||||||
|
/// - Route MUST make net progress toward the end location
|
||||||
|
/// - Temporary increases in distance are allowed only if minor and followed by progress
|
||||||
|
/// - Large backtracking or oscillation is prohibited
|
||||||
|
///
|
||||||
|
/// For all scenarios:
|
||||||
|
/// - Detects obvious zig-zag patterns (e.g., Chicago → Dallas → San Diego → Minnesota → NY)
|
||||||
|
struct GeographicSanityChecker {
|
||||||
|
|
||||||
|
// MARK: - Validation Result
|
||||||
|
|
||||||
|
struct ValidationResult {
|
||||||
|
let isValid: Bool
|
||||||
|
let violations: [ConstraintViolation]
|
||||||
|
let backtrackingDetails: BacktrackingInfo?
|
||||||
|
|
||||||
|
static let valid = ValidationResult(isValid: true, violations: [], backtrackingDetails: nil)
|
||||||
|
|
||||||
|
static func backtracking(info: BacktrackingInfo) -> ValidationResult {
|
||||||
|
let violation = ConstraintViolation(
|
||||||
|
type: .geographicSanity,
|
||||||
|
description: info.description,
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
return ValidationResult(isValid: false, violations: [violation], backtrackingDetails: info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BacktrackingInfo {
|
||||||
|
let fromCity: String
|
||||||
|
let toCity: String
|
||||||
|
let distanceIncreasePercent: Double
|
||||||
|
let description: String
|
||||||
|
|
||||||
|
init(fromCity: String, toCity: String, distanceIncreasePercent: Double) {
|
||||||
|
self.fromCity = fromCity
|
||||||
|
self.toCity = toCity
|
||||||
|
self.distanceIncreasePercent = distanceIncreasePercent
|
||||||
|
self.description = "Route backtracks from \(fromCity) to \(toCity) (distance to destination increased by \(String(format: "%.0f", distanceIncreasePercent))%)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
/// Maximum allowed distance increase before flagging as backtracking (percentage)
|
||||||
|
private let maxAllowedDistanceIncrease: Double = 0.15 // 15%
|
||||||
|
|
||||||
|
/// Number of consecutive distance increases before flagging as zig-zag
|
||||||
|
private let maxConsecutiveIncreases: Int = 2
|
||||||
|
|
||||||
|
// MARK: - Scenario C: Directional Route Validation
|
||||||
|
|
||||||
|
/// Validates that a route makes monotonic progress toward the end location.
|
||||||
|
/// This is the primary validation for Scenario C (start + end location).
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - stops: Ordered array of stops in the route
|
||||||
|
/// - endCoordinate: The target destination coordinate
|
||||||
|
/// - Returns: ValidationResult indicating if route has valid directional progress
|
||||||
|
func validateDirectionalProgress(
|
||||||
|
stops: [ItineraryStop],
|
||||||
|
endCoordinate: CLLocationCoordinate2D
|
||||||
|
) -> ValidationResult {
|
||||||
|
guard stops.count >= 2 else {
|
||||||
|
return .valid // Single stop or empty route is trivially valid
|
||||||
|
}
|
||||||
|
|
||||||
|
var consecutiveIncreases = 0
|
||||||
|
var previousDistance: CLLocationDistance?
|
||||||
|
var previousCity: String?
|
||||||
|
|
||||||
|
for stop in stops {
|
||||||
|
guard let coordinate = stop.coordinate else { continue }
|
||||||
|
|
||||||
|
let currentDistance = distance(from: coordinate, to: endCoordinate)
|
||||||
|
|
||||||
|
if let prevDist = previousDistance, let prevCity = previousCity {
|
||||||
|
if currentDistance > prevDist {
|
||||||
|
// Distance to end increased - potential backtracking
|
||||||
|
let increasePercent = (currentDistance - prevDist) / prevDist
|
||||||
|
consecutiveIncreases += 1
|
||||||
|
|
||||||
|
// Check if this increase is too large
|
||||||
|
if increasePercent > maxAllowedDistanceIncrease {
|
||||||
|
return .backtracking(info: BacktrackingInfo(
|
||||||
|
fromCity: prevCity,
|
||||||
|
toCity: stop.city,
|
||||||
|
distanceIncreasePercent: increasePercent * 100
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for oscillation (too many consecutive increases)
|
||||||
|
if consecutiveIncreases >= maxConsecutiveIncreases {
|
||||||
|
return .backtracking(info: BacktrackingInfo(
|
||||||
|
fromCity: prevCity,
|
||||||
|
toCity: stop.city,
|
||||||
|
distanceIncreasePercent: increasePercent * 100
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Making progress - reset counter
|
||||||
|
consecutiveIncreases = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousDistance = currentDistance
|
||||||
|
previousCity = stop.city
|
||||||
|
}
|
||||||
|
|
||||||
|
return .valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - General Geographic Sanity
|
||||||
|
|
||||||
|
/// Validates that a route doesn't have obvious zig-zag patterns.
|
||||||
|
/// Uses compass bearing analysis to detect direction reversals.
|
||||||
|
///
|
||||||
|
/// - Parameter stops: Ordered array of stops in the route
|
||||||
|
/// - Returns: ValidationResult indicating if route is geographically sane
|
||||||
|
func validateNoZigZag(stops: [ItineraryStop]) -> ValidationResult {
|
||||||
|
guard stops.count >= 3 else {
|
||||||
|
return .valid // Need at least 3 stops to detect zig-zag
|
||||||
|
}
|
||||||
|
|
||||||
|
var bearingReversals = 0
|
||||||
|
var previousBearing: Double?
|
||||||
|
|
||||||
|
for i in 0..<(stops.count - 1) {
|
||||||
|
guard let from = stops[i].coordinate,
|
||||||
|
let to = stops[i + 1].coordinate else { continue }
|
||||||
|
|
||||||
|
let currentBearing = bearing(from: from, to: to)
|
||||||
|
|
||||||
|
if let prevBearing = previousBearing {
|
||||||
|
// Check if we've reversed direction (>90 degree change)
|
||||||
|
let bearingChange = abs(normalizedBearingDifference(prevBearing, currentBearing))
|
||||||
|
if bearingChange > 90 {
|
||||||
|
bearingReversals += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousBearing = currentBearing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow at most one major direction change (e.g., going east then north is fine)
|
||||||
|
// But multiple reversals indicate zig-zagging
|
||||||
|
if bearingReversals > 1 {
|
||||||
|
return .backtracking(info: BacktrackingInfo(
|
||||||
|
fromCity: stops.first?.city ?? "Start",
|
||||||
|
toCity: stops.last?.city ?? "End",
|
||||||
|
distanceIncreasePercent: Double(bearingReversals) * 30 // Rough estimate
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .valid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a complete route for both directional progress (if end is specified)
|
||||||
|
/// and general geographic sanity.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - stops: Ordered array of stops
|
||||||
|
/// - endCoordinate: Optional end coordinate for directional validation
|
||||||
|
/// - Returns: Combined validation result
|
||||||
|
func validate(
|
||||||
|
stops: [ItineraryStop],
|
||||||
|
endCoordinate: CLLocationCoordinate2D?
|
||||||
|
) -> ValidationResult {
|
||||||
|
// If we have an end coordinate, validate directional progress
|
||||||
|
if let end = endCoordinate {
|
||||||
|
let directionalResult = validateDirectionalProgress(stops: stops, endCoordinate: end)
|
||||||
|
if !directionalResult.isValid {
|
||||||
|
return directionalResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always check for zig-zag patterns
|
||||||
|
return validateNoZigZag(stops: stops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
|
/// Calculates distance between two coordinates in meters.
|
||||||
|
private func distance(
|
||||||
|
from: CLLocationCoordinate2D,
|
||||||
|
to: CLLocationCoordinate2D
|
||||||
|
) -> CLLocationDistance {
|
||||||
|
let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||||
|
let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||||
|
return fromLocation.distance(from: toLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates bearing (direction) from one coordinate to another in degrees.
|
||||||
|
private func bearing(
|
||||||
|
from: CLLocationCoordinate2D,
|
||||||
|
to: CLLocationCoordinate2D
|
||||||
|
) -> Double {
|
||||||
|
let lat1 = from.latitude * .pi / 180
|
||||||
|
let lat2 = to.latitude * .pi / 180
|
||||||
|
let dLon = (to.longitude - from.longitude) * .pi / 180
|
||||||
|
|
||||||
|
let y = sin(dLon) * cos(lat2)
|
||||||
|
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
|
||||||
|
|
||||||
|
var bearing = atan2(y, x) * 180 / .pi
|
||||||
|
bearing = (bearing + 360).truncatingRemainder(dividingBy: 360)
|
||||||
|
|
||||||
|
return bearing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the normalized difference between two bearings (-180 to 180).
|
||||||
|
private func normalizedBearingDifference(_ bearing1: Double, _ bearing2: Double) -> Double {
|
||||||
|
var diff = bearing2 - bearing1
|
||||||
|
while diff > 180 { diff -= 360 }
|
||||||
|
while diff < -180 { diff += 360 }
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
}
|
||||||
253
SportsTime/Planning/Validators/MustStopValidator.swift
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
//
|
||||||
|
// MustStopValidator.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Validates that must-stop locations are reachable by the route.
|
||||||
|
/// Priority 6 in the rule hierarchy (lowest priority).
|
||||||
|
///
|
||||||
|
/// A route "passes" a must-stop location if:
|
||||||
|
/// - Any travel segment comes within the proximity threshold (default 25 miles)
|
||||||
|
/// - The must-stop does NOT require a separate overnight stay
|
||||||
|
struct MustStopValidator {
|
||||||
|
|
||||||
|
// MARK: - Validation Result
|
||||||
|
|
||||||
|
struct ValidationResult {
|
||||||
|
let isValid: Bool
|
||||||
|
let violations: [ConstraintViolation]
|
||||||
|
let unreachableLocations: [String]
|
||||||
|
|
||||||
|
static let valid = ValidationResult(isValid: true, violations: [], unreachableLocations: [])
|
||||||
|
|
||||||
|
static func unreachable(locations: [String]) -> ValidationResult {
|
||||||
|
let violations = locations.map { location in
|
||||||
|
ConstraintViolation(
|
||||||
|
type: .mustStop,
|
||||||
|
description: "Required stop '\(location)' is not reachable within \(Int(MustStopConfig.defaultProximityMiles)) miles of any route segment",
|
||||||
|
severity: .error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ValidationResult(isValid: false, violations: violations, unreachableLocations: locations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
let config: MustStopConfig
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(config: MustStopConfig = MustStopConfig()) {
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
/// Validates that all must-stop locations can be reached by the route.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - mustStopLocations: Array of locations that must be visited/passed
|
||||||
|
/// - stops: The planned stops in the itinerary
|
||||||
|
/// - segments: The travel segments between stops
|
||||||
|
/// - Returns: ValidationResult indicating if all must-stops are reachable
|
||||||
|
func validate(
|
||||||
|
mustStopLocations: [LocationInput],
|
||||||
|
stops: [ItineraryStop],
|
||||||
|
segments: [TravelSegment]
|
||||||
|
) -> ValidationResult {
|
||||||
|
guard !mustStopLocations.isEmpty else {
|
||||||
|
return .valid
|
||||||
|
}
|
||||||
|
|
||||||
|
var unreachable: [String] = []
|
||||||
|
|
||||||
|
for mustStop in mustStopLocations {
|
||||||
|
if !isReachable(mustStop: mustStop, stops: stops, segments: segments) {
|
||||||
|
unreachable.append(mustStop.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if unreachable.isEmpty {
|
||||||
|
return .valid
|
||||||
|
} else {
|
||||||
|
return .unreachable(locations: unreachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates must-stop locations from a planning request.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - request: The planning request with must-stop preferences
|
||||||
|
/// - stops: The planned stops
|
||||||
|
/// - segments: The travel segments
|
||||||
|
/// - Returns: ValidationResult
|
||||||
|
func validate(
|
||||||
|
request: PlanningRequest,
|
||||||
|
stops: [ItineraryStop],
|
||||||
|
segments: [TravelSegment]
|
||||||
|
) -> ValidationResult {
|
||||||
|
return validate(
|
||||||
|
mustStopLocations: request.preferences.mustStopLocations,
|
||||||
|
stops: stops,
|
||||||
|
segments: segments
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reachability Check
|
||||||
|
|
||||||
|
/// Checks if a must-stop location is reachable by the route.
|
||||||
|
/// A location is reachable if:
|
||||||
|
/// 1. It's within proximity of any stop, OR
|
||||||
|
/// 2. It's within proximity of any travel segment path
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - mustStop: The location to check
|
||||||
|
/// - stops: Planned stops
|
||||||
|
/// - segments: Travel segments
|
||||||
|
/// - Returns: True if the location is reachable
|
||||||
|
private func isReachable(
|
||||||
|
mustStop: LocationInput,
|
||||||
|
stops: [ItineraryStop],
|
||||||
|
segments: [TravelSegment]
|
||||||
|
) -> Bool {
|
||||||
|
guard let mustStopCoord = mustStop.coordinate else {
|
||||||
|
// If we don't have coordinates, we can't validate - assume reachable
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any stop is within proximity
|
||||||
|
for stop in stops {
|
||||||
|
if let stopCoord = stop.coordinate {
|
||||||
|
let distance = distanceInMiles(from: mustStopCoord, to: stopCoord)
|
||||||
|
if distance <= config.proximityMiles {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any segment passes within proximity
|
||||||
|
for segment in segments {
|
||||||
|
if isNearSegment(point: mustStopCoord, segment: segment) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a point is near a travel segment.
|
||||||
|
/// Uses perpendicular distance to the segment line.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - point: The point to check
|
||||||
|
/// - segment: The travel segment
|
||||||
|
/// - Returns: True if within proximity
|
||||||
|
private func isNearSegment(
|
||||||
|
point: CLLocationCoordinate2D,
|
||||||
|
segment: TravelSegment
|
||||||
|
) -> Bool {
|
||||||
|
guard let originCoord = segment.fromLocation.coordinate,
|
||||||
|
let destCoord = segment.toLocation.coordinate else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate perpendicular distance from point to segment
|
||||||
|
let distance = perpendicularDistance(
|
||||||
|
point: point,
|
||||||
|
lineStart: originCoord,
|
||||||
|
lineEnd: destCoord
|
||||||
|
)
|
||||||
|
|
||||||
|
return distance <= config.proximityMiles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the minimum distance from a point to a line segment in miles.
|
||||||
|
/// Uses the perpendicular distance if the projection falls on the segment,
|
||||||
|
/// otherwise uses the distance to the nearest endpoint.
|
||||||
|
private func perpendicularDistance(
|
||||||
|
point: CLLocationCoordinate2D,
|
||||||
|
lineStart: CLLocationCoordinate2D,
|
||||||
|
lineEnd: CLLocationCoordinate2D
|
||||||
|
) -> Double {
|
||||||
|
let pointLoc = CLLocation(latitude: point.latitude, longitude: point.longitude)
|
||||||
|
let startLoc = CLLocation(latitude: lineStart.latitude, longitude: lineStart.longitude)
|
||||||
|
let endLoc = CLLocation(latitude: lineEnd.latitude, longitude: lineEnd.longitude)
|
||||||
|
|
||||||
|
let lineLength = startLoc.distance(from: endLoc)
|
||||||
|
|
||||||
|
// Handle degenerate case where start == end
|
||||||
|
if lineLength < 1 {
|
||||||
|
return pointLoc.distance(from: startLoc) / 1609.34 // meters to miles
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate projection parameter t
|
||||||
|
// t = ((P - A) · (B - A)) / |B - A|²
|
||||||
|
let dx = endLoc.coordinate.longitude - startLoc.coordinate.longitude
|
||||||
|
let dy = endLoc.coordinate.latitude - startLoc.coordinate.latitude
|
||||||
|
let px = point.longitude - lineStart.longitude
|
||||||
|
let py = point.latitude - lineStart.latitude
|
||||||
|
|
||||||
|
let t = max(0, min(1, (px * dx + py * dy) / (dx * dx + dy * dy)))
|
||||||
|
|
||||||
|
// Calculate closest point on segment
|
||||||
|
let closestLat = lineStart.latitude + t * dy
|
||||||
|
let closestLon = lineStart.longitude + t * dx
|
||||||
|
let closestLoc = CLLocation(latitude: closestLat, longitude: closestLon)
|
||||||
|
|
||||||
|
// Return distance in miles
|
||||||
|
return pointLoc.distance(from: closestLoc) / 1609.34
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates distance between two coordinates in miles.
|
||||||
|
private func distanceInMiles(
|
||||||
|
from: CLLocationCoordinate2D,
|
||||||
|
to: CLLocationCoordinate2D
|
||||||
|
) -> Double {
|
||||||
|
let fromLoc = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||||
|
let toLoc = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||||
|
return fromLoc.distance(from: toLoc) / 1609.34 // meters to miles
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Modification
|
||||||
|
|
||||||
|
/// Finds the best position to insert a must-stop location into an itinerary.
|
||||||
|
/// Used when we need to add an explicit stop for a must-stop location.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - mustStop: The location to insert
|
||||||
|
/// - stops: Current stops in order
|
||||||
|
/// - Returns: The index where the stop should be inserted (1-based, between existing stops)
|
||||||
|
func bestInsertionIndex(
|
||||||
|
for mustStop: LocationInput,
|
||||||
|
in stops: [ItineraryStop]
|
||||||
|
) -> Int {
|
||||||
|
guard let mustStopCoord = mustStop.coordinate, stops.count >= 2 else {
|
||||||
|
return 1 // Insert after first stop
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestIndex = 1
|
||||||
|
var minDetour = Double.greatestFiniteMagnitude
|
||||||
|
|
||||||
|
for i in 0..<(stops.count - 1) {
|
||||||
|
guard let fromCoord = stops[i].coordinate,
|
||||||
|
let toCoord = stops[i + 1].coordinate else { continue }
|
||||||
|
|
||||||
|
// Calculate detour: (from→mustStop + mustStop→to) - (from→to)
|
||||||
|
let direct = distanceInMiles(from: fromCoord, to: toCoord)
|
||||||
|
let via = distanceInMiles(from: fromCoord, to: mustStopCoord) +
|
||||||
|
distanceInMiles(from: mustStopCoord, to: toCoord)
|
||||||
|
let detour = via - direct
|
||||||
|
|
||||||
|
if detour < minDetour {
|
||||||
|
minDetour = detour
|
||||||
|
bestIndex = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
76457
SportsTime/Resources/games.json
Normal file
1382
SportsTime/Resources/stadiums.json
Normal file
16
SportsTime/SportsTime.entitlements
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>iCloud.com.sportstime.app</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
39
SportsTime/SportsTimeApp.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// SportsTimeApp.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Created by Trey Tartt on 1/6/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SportsTimeApp: App {
|
||||||
|
var sharedModelContainer: ModelContainer = {
|
||||||
|
let schema = Schema([
|
||||||
|
SavedTrip.self,
|
||||||
|
TripVote.self,
|
||||||
|
UserPreferences.self,
|
||||||
|
CachedSchedule.self,
|
||||||
|
])
|
||||||
|
let modelConfiguration = ModelConfiguration(
|
||||||
|
schema: schema,
|
||||||
|
isStoredInMemoryOnly: false,
|
||||||
|
cloudKitDatabase: .none // Local only; CloudKit used separately for schedules
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||||
|
} catch {
|
||||||
|
fatalError("Could not create ModelContainer: \(error)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
HomeView()
|
||||||
|
}
|
||||||
|
.modelContainer(sharedModelContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
655
SportsTimeTests/ScenarioAPlannerTests.swift
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
//
|
||||||
|
// ScenarioAPlannerTests.swift
|
||||||
|
// SportsTimeTests
|
||||||
|
//
|
||||||
|
// Tests for ScenarioAPlanner tree exploration logic.
|
||||||
|
// Verifies that we correctly find all geographically sensible route variations.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
import CoreLocation
|
||||||
|
@testable import SportsTime
|
||||||
|
|
||||||
|
final class ScenarioAPlannerTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - Test Helpers
|
||||||
|
|
||||||
|
/// Creates a stadium at a specific coordinate
|
||||||
|
private func makeStadium(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
city: String,
|
||||||
|
lat: Double,
|
||||||
|
lon: Double
|
||||||
|
) -> Stadium {
|
||||||
|
Stadium(
|
||||||
|
id: id,
|
||||||
|
name: "\(city) Arena",
|
||||||
|
city: city,
|
||||||
|
state: "ST",
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
capacity: 20000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a game at a stadium on a specific day
|
||||||
|
private func makeGame(
|
||||||
|
stadiumId: UUID,
|
||||||
|
daysFromNow: Int
|
||||||
|
) -> Game {
|
||||||
|
let date = Calendar.current.date(byAdding: .day, value: daysFromNow, to: Date())!
|
||||||
|
return Game(
|
||||||
|
id: UUID(),
|
||||||
|
homeTeamId: UUID(),
|
||||||
|
awayTeamId: UUID(),
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
dateTime: date,
|
||||||
|
sport: .nba,
|
||||||
|
season: "2025-26"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a date range from now
|
||||||
|
private func makeDateRange(days: Int) -> DateInterval {
|
||||||
|
let start = Date()
|
||||||
|
let end = Calendar.current.date(byAdding: .day, value: days, to: start)!
|
||||||
|
return DateInterval(start: start, end: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs ScenarioA planning and returns the result
|
||||||
|
private func plan(
|
||||||
|
games: [Game],
|
||||||
|
stadiums: [Stadium],
|
||||||
|
dateRange: DateInterval
|
||||||
|
) -> ItineraryResult {
|
||||||
|
let stadiumDict = Dictionary(uniqueKeysWithValues: stadiums.map { ($0.id, $0) })
|
||||||
|
|
||||||
|
// Create preferences with the date range
|
||||||
|
let preferences = TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
startDate: dateRange.start,
|
||||||
|
endDate: dateRange.end,
|
||||||
|
numberOfDrivers: 1,
|
||||||
|
maxDrivingHoursPerDriver: 8.0
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = PlanningRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
availableGames: games,
|
||||||
|
teams: [:], // Not needed for ScenarioA tests
|
||||||
|
stadiums: stadiumDict
|
||||||
|
)
|
||||||
|
|
||||||
|
let planner = ScenarioAPlanner()
|
||||||
|
return planner.plan(request: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 1: Empty games returns failure
|
||||||
|
|
||||||
|
func test_emptyGames_returnsNoGamesInRangeFailure() {
|
||||||
|
let result = plan(
|
||||||
|
games: [],
|
||||||
|
stadiums: [],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .failure(let failure) = result {
|
||||||
|
XCTAssertEqual(failure.reason, .noGamesInRange)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected failure, got success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 2: Single game always succeeds
|
||||||
|
|
||||||
|
func test_singleGame_alwaysSucceeds() {
|
||||||
|
let stadium = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||||
|
let game = makeGame(stadiumId: stadium.id, daysFromNow: 1)
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: [game],
|
||||||
|
stadiums: [stadium],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
XCTAssertEqual(options.count, 1)
|
||||||
|
XCTAssertEqual(options[0].stops.count, 1)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 3: Two games always succeeds (no zig-zag possible)
|
||||||
|
|
||||||
|
func test_twoGames_alwaysSucceeds() {
|
||||||
|
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [ny, la],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
XCTAssertGreaterThanOrEqual(options.count, 1)
|
||||||
|
// Should have option with both games
|
||||||
|
let twoGameOption = options.first { $0.stops.count == 2 }
|
||||||
|
XCTAssertNotNil(twoGameOption)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 4: Linear route (West to East) - all games included
|
||||||
|
|
||||||
|
func test_linearRouteWestToEast_allGamesIncluded() {
|
||||||
|
// LA → Denver → Chicago → New York (linear progression)
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
let den = makeStadium(city: "Denver", lat: 39.7, lon: -104.9)
|
||||||
|
let chi = makeStadium(city: "Chicago", lat: 41.8, lon: -87.6)
|
||||||
|
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: den.id, daysFromNow: 3),
|
||||||
|
makeGame(stadiumId: chi.id, daysFromNow: 5),
|
||||||
|
makeGame(stadiumId: ny.id, daysFromNow: 7)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [la, den, chi, ny],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
// Best option should include all 4 games
|
||||||
|
XCTAssertEqual(options[0].stops.count, 4)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 5: Linear route (North to South) - all games included
|
||||||
|
|
||||||
|
func test_linearRouteNorthToSouth_allGamesIncluded() {
|
||||||
|
// Seattle → SF → LA → San Diego (linear south)
|
||||||
|
let sea = makeStadium(city: "Seattle", lat: 47.6, lon: -122.3)
|
||||||
|
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
let sd = makeStadium(city: "San Diego", lat: 32.7, lon: -117.1)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: sea.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: sf.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 3),
|
||||||
|
makeGame(stadiumId: sd.id, daysFromNow: 4)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [sea, sf, la, sd],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
XCTAssertEqual(options[0].stops.count, 4)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 6: Zig-zag pattern creates multiple options (NY → TX → SC)
|
||||||
|
|
||||||
|
func test_zigZagPattern_createsMultipleOptions() {
|
||||||
|
// NY (day 1) → TX (day 2) → SC (day 3) = zig-zag
|
||||||
|
// Should create options: [NY,TX], [NY,SC], [TX,SC], etc.
|
||||||
|
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||||
|
let tx = makeStadium(city: "Dallas", lat: 32.7, lon: -96.8)
|
||||||
|
let sc = makeStadium(city: "Charleston", lat: 32.7, lon: -79.9)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: tx.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: sc.id, daysFromNow: 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [ny, tx, sc],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
// Should have multiple options due to zig-zag
|
||||||
|
XCTAssertGreaterThan(options.count, 1)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success with multiple options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 7: Cross-country zig-zag creates many branches
|
||||||
|
|
||||||
|
func test_crossCountryZigZag_createsManyBranches() {
|
||||||
|
// NY → TX → SC → CA → MN = extreme zig-zag
|
||||||
|
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||||
|
let tx = makeStadium(city: "Dallas", lat: 32.7, lon: -96.8)
|
||||||
|
let sc = makeStadium(city: "Charleston", lat: 32.7, lon: -79.9)
|
||||||
|
let ca = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
let mn = makeStadium(city: "Minneapolis", lat: 44.9, lon: -93.2)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: tx.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: sc.id, daysFromNow: 3),
|
||||||
|
makeGame(stadiumId: ca.id, daysFromNow: 4),
|
||||||
|
makeGame(stadiumId: mn.id, daysFromNow: 5)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [ny, tx, sc, ca, mn],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
// Should have many options from all the branching
|
||||||
|
XCTAssertGreaterThan(options.count, 3)
|
||||||
|
// No option should have all 5 games (too much zig-zag)
|
||||||
|
let maxGames = options.map { $0.stops.count }.max() ?? 0
|
||||||
|
XCTAssertLessThan(maxGames, 5)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 8: Fork at third game - both branches explored
|
||||||
|
|
||||||
|
func test_forkAtThirdGame_bothBranchesExplored() {
|
||||||
|
// NY → Chicago → ? (fork: either Dallas OR Miami, not both)
|
||||||
|
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||||
|
let chi = makeStadium(city: "Chicago", lat: 41.8, lon: -87.6)
|
||||||
|
let dal = makeStadium(city: "Dallas", lat: 32.7, lon: -96.8)
|
||||||
|
let mia = makeStadium(city: "Miami", lat: 25.7, lon: -80.2)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: chi.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: dal.id, daysFromNow: 3),
|
||||||
|
makeGame(stadiumId: mia.id, daysFromNow: 4)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [ny, chi, dal, mia],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
// Should have options including Dallas and options including Miami
|
||||||
|
let citiesInOptions = options.flatMap { $0.stops.map { $0.city } }
|
||||||
|
XCTAssertTrue(citiesInOptions.contains("Dallas") || citiesInOptions.contains("Miami"))
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 9: All games in same city - single option with all games
|
||||||
|
|
||||||
|
func test_allGamesSameCity_singleOptionWithAllGames() {
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [la],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
// All games at same stadium = 1 stop with 3 games
|
||||||
|
XCTAssertEqual(options[0].stops.count, 1)
|
||||||
|
XCTAssertEqual(options[0].stops[0].games.count, 3)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 10: Nearby cities - all included (no zig-zag)
|
||||||
|
|
||||||
|
func test_nearbyCities_allIncluded() {
|
||||||
|
// LA → Anaheim → San Diego (all nearby, < 100 miles)
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
let ana = makeStadium(city: "Anaheim", lat: 33.8, lon: -117.9)
|
||||||
|
let sd = makeStadium(city: "San Diego", lat: 32.7, lon: -117.1)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: ana.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: sd.id, daysFromNow: 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [la, ana, sd],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
// All nearby = should have option with all 3
|
||||||
|
XCTAssertEqual(options[0].stops.count, 3)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 11: Options sorted by game count (most games first)
|
||||||
|
|
||||||
|
func test_optionsSortedByGameCount_mostGamesFirst() {
|
||||||
|
// Create a scenario with varying option sizes
|
||||||
|
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||||
|
let chi = makeStadium(city: "Chicago", lat: 41.8, lon: -87.6)
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: chi.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [ny, chi, la],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
// Options should be sorted: most games first
|
||||||
|
for i in 0..<(options.count - 1) {
|
||||||
|
XCTAssertGreaterThanOrEqual(
|
||||||
|
options[i].stops.count,
|
||||||
|
options[i + 1].stops.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 12: Rank numbers are sequential
|
||||||
|
|
||||||
|
func test_rankNumbers_areSequential() {
|
||||||
|
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||||
|
let tx = makeStadium(city: "Dallas", lat: 32.7, lon: -96.8)
|
||||||
|
let sc = makeStadium(city: "Charleston", lat: 32.7, lon: -79.9)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: tx.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: sc.id, daysFromNow: 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [ny, tx, sc],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
for (index, option) in options.enumerated() {
|
||||||
|
XCTAssertEqual(option.rank, index + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 13: Games outside date range are excluded
|
||||||
|
|
||||||
|
func test_gamesOutsideDateRange_areExcluded() {
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 1), // In range
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 15), // Out of range
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 3) // In range
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [la],
|
||||||
|
dateRange: makeDateRange(days: 5) // Only 5 days
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
// Should only have 2 games (day 1 and day 3)
|
||||||
|
XCTAssertEqual(options[0].stops[0].games.count, 2)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 14: Maximum 10 options returned
|
||||||
|
|
||||||
|
func test_maximum10Options_returned() {
|
||||||
|
// Create many cities that could generate lots of combinations
|
||||||
|
let cities: [(String, Double, Double)] = [
|
||||||
|
("City1", 40.0, -74.0),
|
||||||
|
("City2", 38.0, -90.0),
|
||||||
|
("City3", 35.0, -106.0),
|
||||||
|
("City4", 33.0, -117.0),
|
||||||
|
("City5", 37.0, -122.0),
|
||||||
|
("City6", 45.0, -93.0),
|
||||||
|
("City7", 42.0, -83.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
let stadiums = cities.map { makeStadium(city: $0.0, lat: $0.1, lon: $0.2) }
|
||||||
|
let games = stadiums.enumerated().map { makeGame(stadiumId: $1.id, daysFromNow: $0 + 1) }
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: stadiums,
|
||||||
|
dateRange: makeDateRange(days: 15)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
XCTAssertLessThanOrEqual(options.count, 10)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 15: Each option has travel segments
|
||||||
|
|
||||||
|
func test_eachOption_hasTravelSegments() {
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||||
|
let sea = makeStadium(city: "Seattle", lat: 47.6, lon: -122.3)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: sf.id, daysFromNow: 3),
|
||||||
|
makeGame(stadiumId: sea.id, daysFromNow: 5)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [la, sf, sea],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
for option in options {
|
||||||
|
// Invariant: travelSegments.count == stops.count - 1
|
||||||
|
if option.stops.count > 1 {
|
||||||
|
XCTAssertEqual(
|
||||||
|
option.travelSegments.count,
|
||||||
|
option.stops.count - 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 16: Single game options are included
|
||||||
|
|
||||||
|
func test_singleGameOptions_areIncluded() {
|
||||||
|
let ny = makeStadium(city: "New York", lat: 40.7, lon: -74.0)
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
let mia = makeStadium(city: "Miami", lat: 25.7, lon: -80.2)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: ny.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: mia.id, daysFromNow: 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [ny, la, mia],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
// Should include single-game options
|
||||||
|
let singleGameOptions = options.filter { $0.stops.count == 1 }
|
||||||
|
XCTAssertGreaterThan(singleGameOptions.count, 0)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 17: Chronological order preserved in each option
|
||||||
|
|
||||||
|
func test_chronologicalOrder_preservedInEachOption() {
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
let den = makeStadium(city: "Denver", lat: 39.7, lon: -104.9)
|
||||||
|
let chi = makeStadium(city: "Chicago", lat: 41.8, lon: -87.6)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: den.id, daysFromNow: 3),
|
||||||
|
makeGame(stadiumId: chi.id, daysFromNow: 5)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [la, den, chi],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
for option in options {
|
||||||
|
// Verify stops are in chronological order
|
||||||
|
for i in 0..<(option.stops.count - 1) {
|
||||||
|
XCTAssertLessThanOrEqual(
|
||||||
|
option.stops[i].arrivalDate,
|
||||||
|
option.stops[i + 1].arrivalDate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 18: Geographic rationale includes city names
|
||||||
|
|
||||||
|
func test_geographicRationale_includesCityNames() {
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: sf.id, daysFromNow: 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [la, sf],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
let twoStopOption = options.first { $0.stops.count == 2 }
|
||||||
|
XCTAssertNotNil(twoStopOption)
|
||||||
|
XCTAssertTrue(twoStopOption!.geographicRationale.contains("Los Angeles"))
|
||||||
|
XCTAssertTrue(twoStopOption!.geographicRationale.contains("San Francisco"))
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 19: Total driving hours calculated for each option
|
||||||
|
|
||||||
|
func test_totalDrivingHours_calculatedForEachOption() {
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: sf.id, daysFromNow: 3)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [la, sf],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
let twoStopOption = options.first { $0.stops.count == 2 }
|
||||||
|
XCTAssertNotNil(twoStopOption)
|
||||||
|
// LA to SF is ~380 miles, ~6 hours
|
||||||
|
XCTAssertGreaterThan(twoStopOption!.totalDrivingHours, 4)
|
||||||
|
XCTAssertLessThan(twoStopOption!.totalDrivingHours, 10)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test 20: Coastal route vs inland route - both explored
|
||||||
|
|
||||||
|
func test_coastalVsInlandRoute_bothExplored() {
|
||||||
|
// SF → either Sacramento (inland) or Monterey (coastal) → LA
|
||||||
|
let sf = makeStadium(city: "San Francisco", lat: 37.7, lon: -122.4)
|
||||||
|
let sac = makeStadium(city: "Sacramento", lat: 38.5, lon: -121.4) // Inland
|
||||||
|
let mon = makeStadium(city: "Monterey", lat: 36.6, lon: -121.9) // Coastal
|
||||||
|
let la = makeStadium(city: "Los Angeles", lat: 34.0, lon: -118.2)
|
||||||
|
|
||||||
|
let games = [
|
||||||
|
makeGame(stadiumId: sf.id, daysFromNow: 1),
|
||||||
|
makeGame(stadiumId: sac.id, daysFromNow: 2),
|
||||||
|
makeGame(stadiumId: mon.id, daysFromNow: 3),
|
||||||
|
makeGame(stadiumId: la.id, daysFromNow: 4)
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = plan(
|
||||||
|
games: games,
|
||||||
|
stadiums: [sf, sac, mon, la],
|
||||||
|
dateRange: makeDateRange(days: 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .success(let options) = result {
|
||||||
|
let citiesInOptions = Set(options.flatMap { $0.stops.map { $0.city } })
|
||||||
|
// Both Sacramento and Monterey should appear in some option
|
||||||
|
XCTAssertTrue(citiesInOptions.contains("Sacramento") || citiesInOptions.contains("Monterey"))
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
459
SportsTimeTests/SportsTimeTests.swift
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
//
|
||||||
|
// SportsTimeTests.swift
|
||||||
|
// SportsTimeTests
|
||||||
|
//
|
||||||
|
// Created by Trey Tartt on 1/6/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
@testable import SportsTime
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - DayCard Tests
|
||||||
|
|
||||||
|
/// Tests for DayCard conflict detection and display logic
|
||||||
|
struct DayCardTests {
|
||||||
|
|
||||||
|
// MARK: - Test Data Helpers
|
||||||
|
|
||||||
|
private func makeGame(id: UUID, dateTime: Date, stadiumId: UUID, sport: Sport = .mlb) -> Game {
|
||||||
|
Game(
|
||||||
|
id: id,
|
||||||
|
homeTeamId: UUID(),
|
||||||
|
awayTeamId: UUID(),
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
dateTime: dateTime,
|
||||||
|
sport: sport,
|
||||||
|
season: "2026"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRichGame(game: Game, homeTeamName: String = "Home", awayTeamName: String = "Away") -> RichGame {
|
||||||
|
let stadiumId = game.stadiumId
|
||||||
|
let homeTeam = Team(
|
||||||
|
id: game.homeTeamId,
|
||||||
|
name: homeTeamName,
|
||||||
|
abbreviation: "HOM",
|
||||||
|
sport: game.sport,
|
||||||
|
city: "Home City",
|
||||||
|
stadiumId: stadiumId
|
||||||
|
)
|
||||||
|
let awayTeam = Team(
|
||||||
|
id: game.awayTeamId,
|
||||||
|
name: awayTeamName,
|
||||||
|
abbreviation: "AWY",
|
||||||
|
sport: game.sport,
|
||||||
|
city: "Away City",
|
||||||
|
stadiumId: UUID()
|
||||||
|
)
|
||||||
|
let stadium = Stadium(
|
||||||
|
id: stadiumId,
|
||||||
|
name: "Stadium",
|
||||||
|
city: "City",
|
||||||
|
state: "ST",
|
||||||
|
latitude: 40.0,
|
||||||
|
longitude: -100.0,
|
||||||
|
capacity: 40000
|
||||||
|
)
|
||||||
|
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStop(
|
||||||
|
city: String,
|
||||||
|
arrivalDate: Date,
|
||||||
|
departureDate: Date,
|
||||||
|
games: [UUID]
|
||||||
|
) -> TripStop {
|
||||||
|
TripStop(
|
||||||
|
stopNumber: 1,
|
||||||
|
city: city,
|
||||||
|
state: "ST",
|
||||||
|
arrivalDate: arrivalDate,
|
||||||
|
departureDate: departureDate,
|
||||||
|
games: games
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflict Detection Tests
|
||||||
|
|
||||||
|
@Test("DayCard with specificStop shows only that stop's games")
|
||||||
|
func dayCard_WithSpecificStop_ShowsOnlyThatStopsGames() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||||
|
|
||||||
|
let denverGameId = UUID()
|
||||||
|
let atlantaGameId = UUID()
|
||||||
|
let denverGameTime = Calendar.current.date(bySettingHour: 20, minute: 10, second: 0, of: apr4)!
|
||||||
|
let atlantaGameTime = Calendar.current.date(bySettingHour: 23, minute: 15, second: 0, of: apr4)!
|
||||||
|
|
||||||
|
let denverGame = makeGame(id: denverGameId, dateTime: denverGameTime, stadiumId: UUID())
|
||||||
|
let atlantaGame = makeGame(id: atlantaGameId, dateTime: atlantaGameTime, stadiumId: UUID())
|
||||||
|
|
||||||
|
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [denverGameId])
|
||||||
|
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [atlantaGameId])
|
||||||
|
|
||||||
|
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [denverStop, atlantaStop], travelSegments: [])
|
||||||
|
let games: [UUID: RichGame] = [
|
||||||
|
denverGameId: makeRichGame(game: denverGame),
|
||||||
|
atlantaGameId: makeRichGame(game: atlantaGame)
|
||||||
|
]
|
||||||
|
|
||||||
|
// Denver card shows only Denver game
|
||||||
|
let denverCard = DayCard(day: day, games: games, specificStop: denverStop)
|
||||||
|
#expect(denverCard.gamesOnThisDay.count == 1)
|
||||||
|
#expect(denverCard.gamesOnThisDay.first?.game.id == denverGameId)
|
||||||
|
#expect(denverCard.primaryCityForDay == "Denver")
|
||||||
|
|
||||||
|
// Atlanta card shows only Atlanta game
|
||||||
|
let atlantaCard = DayCard(day: day, games: games, specificStop: atlantaStop)
|
||||||
|
#expect(atlantaCard.gamesOnThisDay.count == 1)
|
||||||
|
#expect(atlantaCard.gamesOnThisDay.first?.game.id == atlantaGameId)
|
||||||
|
#expect(atlantaCard.primaryCityForDay == "Atlanta")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DayCard shows conflict warning when conflictInfo provided")
|
||||||
|
func dayCard_ShowsConflictWarning_WhenConflictInfoProvided() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||||
|
|
||||||
|
let denverGameId = UUID()
|
||||||
|
let denverGameTime = Calendar.current.date(bySettingHour: 20, minute: 10, second: 0, of: apr4)!
|
||||||
|
let denverGame = makeGame(id: denverGameId, dateTime: denverGameTime, stadiumId: UUID())
|
||||||
|
|
||||||
|
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [denverGameId])
|
||||||
|
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
|
||||||
|
|
||||||
|
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [denverStop, atlantaStop], travelSegments: [])
|
||||||
|
let games: [UUID: RichGame] = [denverGameId: makeRichGame(game: denverGame)]
|
||||||
|
|
||||||
|
let conflictInfo = DayConflictInfo(
|
||||||
|
hasConflict: true,
|
||||||
|
conflictingStops: [denverStop, atlantaStop],
|
||||||
|
conflictingCities: ["Denver", "Atlanta"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let dayCard = DayCard(day: day, games: games, specificStop: denverStop, conflictInfo: conflictInfo)
|
||||||
|
|
||||||
|
#expect(dayCard.hasConflict == true)
|
||||||
|
#expect(dayCard.otherConflictingCities == ["Atlanta"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DayCard without conflict shows no warning")
|
||||||
|
func dayCard_WithoutConflict_ShowsNoWarning() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||||
|
|
||||||
|
let gameId = UUID()
|
||||||
|
let gameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr4)!
|
||||||
|
let game = makeGame(id: gameId, dateTime: gameTime, stadiumId: UUID())
|
||||||
|
|
||||||
|
let stop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [gameId])
|
||||||
|
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
|
||||||
|
let games: [UUID: RichGame] = [gameId: makeRichGame(game: game)]
|
||||||
|
|
||||||
|
let dayCard = DayCard(day: day, games: games)
|
||||||
|
|
||||||
|
#expect(dayCard.hasConflict == false)
|
||||||
|
#expect(dayCard.otherConflictingCities.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DayConflictInfo lists all conflicting cities in warning message")
|
||||||
|
func dayConflictInfo_ListsAllCitiesInWarningMessage() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||||
|
|
||||||
|
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
|
||||||
|
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
|
||||||
|
let chicagoStop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
|
||||||
|
|
||||||
|
let conflictInfo = DayConflictInfo(
|
||||||
|
hasConflict: true,
|
||||||
|
conflictingStops: [denverStop, atlantaStop, chicagoStop],
|
||||||
|
conflictingCities: ["Denver", "Atlanta", "Chicago"]
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(conflictInfo.hasConflict == true)
|
||||||
|
#expect(conflictInfo.conflictingCities.count == 3)
|
||||||
|
#expect(conflictInfo.warningMessage.contains("Denver"))
|
||||||
|
#expect(conflictInfo.warningMessage.contains("Atlanta"))
|
||||||
|
#expect(conflictInfo.warningMessage.contains("Chicago"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("otherConflictingCities excludes current city")
|
||||||
|
func otherConflictingCities_ExcludesCurrentCity() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||||
|
|
||||||
|
let denverGameId = UUID()
|
||||||
|
let denverGameTime = Calendar.current.date(bySettingHour: 20, minute: 0, second: 0, of: apr4)!
|
||||||
|
let denverGame = makeGame(id: denverGameId, dateTime: denverGameTime, stadiumId: UUID())
|
||||||
|
|
||||||
|
let denverStop = makeStop(city: "Denver", arrivalDate: apr4, departureDate: apr5, games: [denverGameId])
|
||||||
|
let atlantaStop = makeStop(city: "Atlanta", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
|
||||||
|
let chicagoStop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [UUID()])
|
||||||
|
|
||||||
|
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [denverStop, atlantaStop, chicagoStop], travelSegments: [])
|
||||||
|
let games: [UUID: RichGame] = [denverGameId: makeRichGame(game: denverGame)]
|
||||||
|
|
||||||
|
let conflictInfo = DayConflictInfo(
|
||||||
|
hasConflict: true,
|
||||||
|
conflictingStops: [denverStop, atlantaStop, chicagoStop],
|
||||||
|
conflictingCities: ["Denver", "Atlanta", "Chicago"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let dayCard = DayCard(day: day, games: games, specificStop: denverStop, conflictInfo: conflictInfo)
|
||||||
|
|
||||||
|
// Should exclude Denver (current city), include Atlanta and Chicago
|
||||||
|
#expect(dayCard.otherConflictingCities.count == 2)
|
||||||
|
#expect(dayCard.otherConflictingCities.contains("Atlanta"))
|
||||||
|
#expect(dayCard.otherConflictingCities.contains("Chicago"))
|
||||||
|
#expect(!dayCard.otherConflictingCities.contains("Denver"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Basic DayCard Tests
|
||||||
|
|
||||||
|
@Test("DayCard handles single stop correctly")
|
||||||
|
func dayCard_HandlesSingleStop_Correctly() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||||
|
|
||||||
|
let gameId = UUID()
|
||||||
|
let gameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr4)!
|
||||||
|
let game = makeGame(id: gameId, dateTime: gameTime, stadiumId: UUID())
|
||||||
|
|
||||||
|
let stop = makeStop(city: "Chicago", arrivalDate: apr4, departureDate: apr5, games: [gameId])
|
||||||
|
|
||||||
|
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
|
||||||
|
let games: [UUID: RichGame] = [gameId: makeRichGame(game: game)]
|
||||||
|
|
||||||
|
let dayCard = DayCard(day: day, games: games)
|
||||||
|
|
||||||
|
#expect(dayCard.gamesOnThisDay.count == 1)
|
||||||
|
#expect(dayCard.primaryCityForDay == "Chicago")
|
||||||
|
#expect(dayCard.hasConflict == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DayCard handles no stops gracefully")
|
||||||
|
func dayCard_HandlesNoStops_Gracefully() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
|
||||||
|
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [], travelSegments: [])
|
||||||
|
let dayCard = DayCard(day: day, games: [:])
|
||||||
|
|
||||||
|
#expect(dayCard.gamesOnThisDay.isEmpty)
|
||||||
|
#expect(dayCard.primaryCityForDay == nil)
|
||||||
|
#expect(dayCard.hasConflict == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DayCard handles stop with no games on the specific day")
|
||||||
|
func dayCard_HandlesStopWithNoGamesOnDay() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||||
|
let apr6 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 6))!
|
||||||
|
|
||||||
|
// Game is on Apr 5, but we're looking at Apr 4
|
||||||
|
let gameId = UUID()
|
||||||
|
let gameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr5)!
|
||||||
|
let game = makeGame(id: gameId, dateTime: gameTime, stadiumId: UUID())
|
||||||
|
|
||||||
|
// Stop spans Apr 4-6, but game is on Apr 5
|
||||||
|
let stop = makeStop(city: "Boston", arrivalDate: apr4, departureDate: apr6, games: [gameId])
|
||||||
|
|
||||||
|
// Looking at Apr 4 (arrival day, no game)
|
||||||
|
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
|
||||||
|
let games: [UUID: RichGame] = [gameId: makeRichGame(game: game)]
|
||||||
|
|
||||||
|
let dayCard = DayCard(day: day, games: games)
|
||||||
|
|
||||||
|
// No games should show on Apr 4 even though the stop has a game (it's on Apr 5)
|
||||||
|
#expect(dayCard.gamesOnThisDay.isEmpty, "No games on Apr 4, game is on Apr 5")
|
||||||
|
#expect(dayCard.primaryCityForDay == "Boston", "Still shows the city even without games")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DayCard handles multiple games at same stop on same day (doubleheader)")
|
||||||
|
func dayCard_HandlesMultipleGamesAtSameStop() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||||
|
|
||||||
|
// Two games in same city on same day (doubleheader)
|
||||||
|
let game1Id = UUID()
|
||||||
|
let game2Id = UUID()
|
||||||
|
let game1Time = Calendar.current.date(bySettingHour: 13, minute: 0, second: 0, of: apr4)!
|
||||||
|
let game2Time = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr4)!
|
||||||
|
|
||||||
|
let game1 = makeGame(id: game1Id, dateTime: game1Time, stadiumId: UUID())
|
||||||
|
let game2 = makeGame(id: game2Id, dateTime: game2Time, stadiumId: UUID())
|
||||||
|
|
||||||
|
let stop = makeStop(city: "New York", arrivalDate: apr4, departureDate: apr5, games: [game1Id, game2Id])
|
||||||
|
|
||||||
|
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [stop], travelSegments: [])
|
||||||
|
let games: [UUID: RichGame] = [
|
||||||
|
game1Id: makeRichGame(game: game1),
|
||||||
|
game2Id: makeRichGame(game: game2)
|
||||||
|
]
|
||||||
|
|
||||||
|
let dayCard = DayCard(day: day, games: games)
|
||||||
|
|
||||||
|
#expect(dayCard.gamesOnThisDay.count == 2, "Should show both games from same city")
|
||||||
|
#expect(dayCard.hasConflict == false, "Same city doubleheader is not a conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DayCard selects stop with game when first stop has no game on that day")
|
||||||
|
func dayCard_SelectsStopWithGame_WhenFirstStopHasNoGame() {
|
||||||
|
let apr4 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 4))!
|
||||||
|
let apr5 = Calendar.current.date(from: DateComponents(year: 2026, month: 4, day: 5))!
|
||||||
|
|
||||||
|
// First stop has game on different day
|
||||||
|
let firstStopGameId = UUID()
|
||||||
|
let firstStopGameTime = Calendar.current.date(bySettingHour: 19, minute: 0, second: 0, of: apr5)!
|
||||||
|
let firstStopGame = makeGame(id: firstStopGameId, dateTime: firstStopGameTime, stadiumId: UUID())
|
||||||
|
|
||||||
|
// Second stop has game on Apr 4
|
||||||
|
let secondStopGameId = UUID()
|
||||||
|
let secondStopGameTime = Calendar.current.date(bySettingHour: 20, minute: 0, second: 0, of: apr4)!
|
||||||
|
let secondStopGame = makeGame(id: secondStopGameId, dateTime: secondStopGameTime, stadiumId: UUID())
|
||||||
|
|
||||||
|
let firstStop = makeStop(city: "Philadelphia", arrivalDate: apr4, departureDate: apr5, games: [firstStopGameId])
|
||||||
|
let secondStop = makeStop(city: "Baltimore", arrivalDate: apr4, departureDate: apr5, games: [secondStopGameId])
|
||||||
|
|
||||||
|
let day = ItineraryDay(dayNumber: 1, date: apr4, stops: [firstStop, secondStop], travelSegments: [])
|
||||||
|
let games: [UUID: RichGame] = [
|
||||||
|
firstStopGameId: makeRichGame(game: firstStopGame),
|
||||||
|
secondStopGameId: makeRichGame(game: secondStopGame)
|
||||||
|
]
|
||||||
|
|
||||||
|
let dayCard = DayCard(day: day, games: games)
|
||||||
|
|
||||||
|
// Should select Baltimore (has game on Apr 4) not Philadelphia (game on Apr 5)
|
||||||
|
#expect(dayCard.gamesOnThisDay.count == 1)
|
||||||
|
#expect(dayCard.gamesOnThisDay.first?.game.id == secondStopGameId)
|
||||||
|
#expect(dayCard.primaryCityForDay == "Baltimore")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DayConflictInfo Tests
|
||||||
|
|
||||||
|
@Test("DayConflictInfo with no conflict has empty warning")
|
||||||
|
func dayConflictInfo_NoConflict_EmptyWarning() {
|
||||||
|
let conflictInfo = DayConflictInfo(
|
||||||
|
hasConflict: false,
|
||||||
|
conflictingStops: [],
|
||||||
|
conflictingCities: []
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(conflictInfo.hasConflict == false)
|
||||||
|
#expect(conflictInfo.warningMessage.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Duplicate Game ID Regression Tests
|
||||||
|
|
||||||
|
/// Tests for handling duplicate game IDs without crashing (regression test for fatal error)
|
||||||
|
struct DuplicateGameIdTests {
|
||||||
|
|
||||||
|
private func makeStadium() -> Stadium {
|
||||||
|
Stadium(
|
||||||
|
id: UUID(),
|
||||||
|
name: "Test Stadium",
|
||||||
|
city: "Test City",
|
||||||
|
state: "TS",
|
||||||
|
latitude: 40.0,
|
||||||
|
longitude: -100.0,
|
||||||
|
capacity: 40000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTeam(sport: Sport = .mlb, stadiumId: UUID) -> Team {
|
||||||
|
Team(
|
||||||
|
id: UUID(),
|
||||||
|
name: "Test Team",
|
||||||
|
abbreviation: "TST",
|
||||||
|
sport: sport,
|
||||||
|
city: "Test City",
|
||||||
|
stadiumId: stadiumId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeGame(id: UUID, homeTeamId: UUID, awayTeamId: UUID, stadiumId: UUID, dateTime: Date) -> Game {
|
||||||
|
Game(
|
||||||
|
id: id,
|
||||||
|
homeTeamId: homeTeamId,
|
||||||
|
awayTeamId: awayTeamId,
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
dateTime: dateTime,
|
||||||
|
sport: .mlb,
|
||||||
|
season: "2026"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("GameCandidate array with duplicate game IDs can build dictionary without crashing")
|
||||||
|
func candidateMap_HandlesDuplicateGameIds() {
|
||||||
|
// This test reproduces the bug: Dictionary(uniqueKeysWithValues:) crashes on duplicate keys
|
||||||
|
// Fix: Use reduce(into:) to handle duplicates gracefully
|
||||||
|
|
||||||
|
let stadium = makeStadium()
|
||||||
|
let homeTeam = makeTeam(stadiumId: stadium.id)
|
||||||
|
let awayTeam = makeTeam(stadiumId: UUID())
|
||||||
|
let gameId = UUID() // Same ID for both candidates (simulates duplicate in JSON)
|
||||||
|
let dateTime = Date()
|
||||||
|
|
||||||
|
let game = makeGame(id: gameId, homeTeamId: homeTeam.id, awayTeamId: awayTeam.id, stadiumId: stadium.id, dateTime: dateTime)
|
||||||
|
|
||||||
|
// Create two candidates with the same game ID (simulating duplicate JSON data)
|
||||||
|
let candidate1 = GameCandidate(
|
||||||
|
id: gameId,
|
||||||
|
game: game,
|
||||||
|
stadium: stadium,
|
||||||
|
homeTeam: homeTeam,
|
||||||
|
awayTeam: awayTeam,
|
||||||
|
detourDistance: 0,
|
||||||
|
score: 1.0
|
||||||
|
)
|
||||||
|
let candidate2 = GameCandidate(
|
||||||
|
id: gameId,
|
||||||
|
game: game,
|
||||||
|
stadium: stadium,
|
||||||
|
homeTeam: homeTeam,
|
||||||
|
awayTeam: awayTeam,
|
||||||
|
detourDistance: 0,
|
||||||
|
score: 2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
let candidates = [candidate1, candidate2]
|
||||||
|
|
||||||
|
// This is the fix pattern - should not crash
|
||||||
|
let candidateMap = candidates.reduce(into: [UUID: GameCandidate]()) { dict, candidate in
|
||||||
|
if dict[candidate.game.id] == nil {
|
||||||
|
dict[candidate.game.id] = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only have one entry (first one wins)
|
||||||
|
#expect(candidateMap.count == 1)
|
||||||
|
#expect(candidateMap[gameId]?.score == 1.0, "First candidate should be kept")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Duplicate games are deduplicated at load time")
|
||||||
|
func gamesArray_DeduplicatesById() {
|
||||||
|
// Simulate the deduplication logic used in StubDataProvider
|
||||||
|
let gameId = UUID()
|
||||||
|
let dateTime = Date()
|
||||||
|
|
||||||
|
let game1 = makeGame(id: gameId, homeTeamId: UUID(), awayTeamId: UUID(), stadiumId: UUID(), dateTime: dateTime)
|
||||||
|
let game2 = makeGame(id: gameId, homeTeamId: UUID(), awayTeamId: UUID(), stadiumId: UUID(), dateTime: dateTime.addingTimeInterval(3600))
|
||||||
|
|
||||||
|
let games = [game1, game2]
|
||||||
|
|
||||||
|
// Deduplication logic from StubDataProvider
|
||||||
|
var seenIds = Set<UUID>()
|
||||||
|
let uniqueGames = games.filter { game in
|
||||||
|
if seenIds.contains(game.id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seenIds.insert(game.id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(uniqueGames.count == 1)
|
||||||
|
#expect(uniqueGames.first?.dateTime == game1.dateTime, "First occurrence should be kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
530
SportsTimeTests/TripPlanningEngineTests.swift
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
//
|
||||||
|
// TripPlanningEngineTests.swift
|
||||||
|
// SportsTimeTests
|
||||||
|
//
|
||||||
|
// Fresh test suite for the rewritten trip planning engine.
|
||||||
|
// Organized by scenario and validation type.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
import CoreLocation
|
||||||
|
@testable import SportsTime
|
||||||
|
|
||||||
|
final class TripPlanningEngineTests: XCTestCase {
|
||||||
|
|
||||||
|
var engine: TripPlanningEngine!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
engine = TripPlanningEngine()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
engine = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test Data Helpers
|
||||||
|
|
||||||
|
func makeGame(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
dateTime: Date,
|
||||||
|
stadiumId: UUID = UUID(),
|
||||||
|
homeTeamId: UUID = UUID(),
|
||||||
|
awayTeamId: UUID = UUID(),
|
||||||
|
sport: Sport = .mlb
|
||||||
|
) -> Game {
|
||||||
|
Game(
|
||||||
|
id: id,
|
||||||
|
homeTeamId: homeTeamId,
|
||||||
|
awayTeamId: awayTeamId,
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
dateTime: dateTime,
|
||||||
|
sport: sport,
|
||||||
|
season: "2026"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeStadium(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String = "Test Stadium",
|
||||||
|
city: String = "Test City",
|
||||||
|
state: String = "TS",
|
||||||
|
latitude: Double = 40.0,
|
||||||
|
longitude: Double = -74.0
|
||||||
|
) -> Stadium {
|
||||||
|
Stadium(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
capacity: 40000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTeam(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String = "Test Team",
|
||||||
|
city: String = "Test City",
|
||||||
|
stadiumId: UUID = UUID()
|
||||||
|
) -> Team {
|
||||||
|
Team(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
abbreviation: "TST",
|
||||||
|
sport: .mlb,
|
||||||
|
city: city,
|
||||||
|
stadiumId: stadiumId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makePreferences(
|
||||||
|
startDate: Date = Date(),
|
||||||
|
endDate: Date = Date().addingTimeInterval(86400 * 7),
|
||||||
|
sports: Set<Sport> = [.mlb],
|
||||||
|
mustSeeGameIds: Set<UUID> = [],
|
||||||
|
startLocation: LocationInput? = nil,
|
||||||
|
endLocation: LocationInput? = nil,
|
||||||
|
numberOfDrivers: Int = 1,
|
||||||
|
maxDrivingHoursPerDriver: Double = 8.0
|
||||||
|
) -> TripPreferences {
|
||||||
|
TripPreferences(
|
||||||
|
planningMode: .dateRange,
|
||||||
|
startLocation: startLocation,
|
||||||
|
endLocation: endLocation,
|
||||||
|
sports: sports,
|
||||||
|
mustSeeGameIds: mustSeeGameIds,
|
||||||
|
travelMode: .drive,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
numberOfStops: nil,
|
||||||
|
tripDuration: nil,
|
||||||
|
leisureLevel: .moderate,
|
||||||
|
mustStopLocations: [],
|
||||||
|
preferredCities: [],
|
||||||
|
routePreference: .balanced,
|
||||||
|
needsEVCharging: false,
|
||||||
|
lodgingType: .hotel,
|
||||||
|
numberOfDrivers: numberOfDrivers,
|
||||||
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||||
|
catchOtherSports: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeRequest(
|
||||||
|
preferences: TripPreferences,
|
||||||
|
games: [Game],
|
||||||
|
teams: [UUID: Team] = [:],
|
||||||
|
stadiums: [UUID: Stadium] = [:]
|
||||||
|
) -> PlanningRequest {
|
||||||
|
PlanningRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
availableGames: games,
|
||||||
|
teams: teams,
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scenario A Tests (Date Range)
|
||||||
|
|
||||||
|
func test_ScenarioA_ValidDateRange_ReturnsItineraries() {
|
||||||
|
// Given: A date range with games
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
||||||
|
|
||||||
|
let stadiumId = UUID()
|
||||||
|
let homeTeamId = UUID()
|
||||||
|
let awayTeamId = UUID()
|
||||||
|
|
||||||
|
let stadium = makeStadium(id: stadiumId, city: "New York", latitude: 40.7128, longitude: -74.0060)
|
||||||
|
let homeTeam = makeTeam(id: homeTeamId, name: "Yankees", city: "New York")
|
||||||
|
let awayTeam = makeTeam(id: awayTeamId, name: "Red Sox", city: "Boston")
|
||||||
|
|
||||||
|
let game = makeGame(
|
||||||
|
dateTime: startDate.addingTimeInterval(86400 * 2),
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
homeTeamId: homeTeamId,
|
||||||
|
awayTeamId: awayTeamId
|
||||||
|
)
|
||||||
|
|
||||||
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
||||||
|
let request = makeRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
games: [game],
|
||||||
|
teams: [homeTeamId: homeTeam, awayTeamId: awayTeam],
|
||||||
|
stadiums: [stadiumId: stadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
XCTAssertTrue(result.isSuccess, "Should return success for valid date range with games")
|
||||||
|
XCTAssertFalse(result.options.isEmpty, "Should return at least one itinerary option")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_ScenarioA_EmptyDateRange_ReturnsFailure() {
|
||||||
|
// Given: An invalid date range (end before start)
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(-86400) // End before start
|
||||||
|
|
||||||
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
||||||
|
let request = makeRequest(preferences: preferences, games: [])
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
XCTAssertFalse(result.isSuccess, "Should fail for invalid date range")
|
||||||
|
if case .failure(let failure) = result {
|
||||||
|
XCTAssertEqual(failure.reason, .missingDateRange, "Should fail with missingDateRange")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_ScenarioA_NoGamesInRange_ReturnsFailure() {
|
||||||
|
// Given: A valid date range but no games
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
||||||
|
|
||||||
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
||||||
|
let request = makeRequest(preferences: preferences, games: [])
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
XCTAssertFalse(result.isSuccess, "Should fail when no games in range")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scenario B Tests (Selected Games)
|
||||||
|
|
||||||
|
func test_ScenarioB_SelectedGamesWithinRange_ReturnsSuccess() {
|
||||||
|
// Given: Selected games within date range
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
||||||
|
|
||||||
|
let gameId = UUID()
|
||||||
|
let stadiumId = UUID()
|
||||||
|
let homeTeamId = UUID()
|
||||||
|
let awayTeamId = UUID()
|
||||||
|
|
||||||
|
let stadium = makeStadium(id: stadiumId, city: "Chicago", latitude: 41.8781, longitude: -87.6298)
|
||||||
|
let homeTeam = makeTeam(id: homeTeamId, name: "Cubs", city: "Chicago")
|
||||||
|
let awayTeam = makeTeam(id: awayTeamId, name: "Cardinals", city: "St. Louis")
|
||||||
|
|
||||||
|
let game = makeGame(
|
||||||
|
id: gameId,
|
||||||
|
dateTime: startDate.addingTimeInterval(86400 * 3),
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
homeTeamId: homeTeamId,
|
||||||
|
awayTeamId: awayTeamId
|
||||||
|
)
|
||||||
|
|
||||||
|
let preferences = makePreferences(
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
mustSeeGameIds: [gameId]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = makeRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
games: [game],
|
||||||
|
teams: [homeTeamId: homeTeam, awayTeamId: awayTeam],
|
||||||
|
stadiums: [stadiumId: stadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
XCTAssertTrue(result.isSuccess, "Should succeed when selected games are within date range")
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_ScenarioB_SelectedGameOutsideDateRange_ReturnsFailure() {
|
||||||
|
// Given: A selected game outside the date range
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
||||||
|
|
||||||
|
let gameId = UUID()
|
||||||
|
let stadiumId = UUID()
|
||||||
|
let homeTeamId = UUID()
|
||||||
|
let awayTeamId = UUID()
|
||||||
|
|
||||||
|
// Game is 10 days after start, but range is only 7 days
|
||||||
|
let game = makeGame(
|
||||||
|
id: gameId,
|
||||||
|
dateTime: startDate.addingTimeInterval(86400 * 10),
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
homeTeamId: homeTeamId,
|
||||||
|
awayTeamId: awayTeamId
|
||||||
|
)
|
||||||
|
|
||||||
|
let preferences = makePreferences(
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
mustSeeGameIds: [gameId]
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = makeRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
games: [game],
|
||||||
|
teams: [:],
|
||||||
|
stadiums: [:]
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
XCTAssertFalse(result.isSuccess, "Should fail when selected game is outside date range")
|
||||||
|
if case .failure(let failure) = result {
|
||||||
|
if case .dateRangeViolation(let games) = failure.reason {
|
||||||
|
XCTAssertEqual(games.count, 1, "Should report one game out of range")
|
||||||
|
XCTAssertEqual(games.first?.id, gameId, "Should report the correct game")
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected dateRangeViolation failure reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Scenario C Tests (Start + End Locations)
|
||||||
|
|
||||||
|
func test_ScenarioC_LinearRoute_ReturnsSuccess() {
|
||||||
|
// Given: Start and end locations with games along the way
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
||||||
|
|
||||||
|
let startLocation = LocationInput(
|
||||||
|
name: "Chicago",
|
||||||
|
coordinate: CLLocationCoordinate2D(latitude: 41.8781, longitude: -87.6298)
|
||||||
|
)
|
||||||
|
let endLocation = LocationInput(
|
||||||
|
name: "New York",
|
||||||
|
coordinate: CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stadium in Cleveland (along the route)
|
||||||
|
let stadiumId = UUID()
|
||||||
|
let homeTeamId = UUID()
|
||||||
|
let awayTeamId = UUID()
|
||||||
|
|
||||||
|
let stadium = makeStadium(
|
||||||
|
id: stadiumId,
|
||||||
|
city: "Cleveland",
|
||||||
|
latitude: 41.4993,
|
||||||
|
longitude: -81.6944
|
||||||
|
)
|
||||||
|
|
||||||
|
let game = makeGame(
|
||||||
|
dateTime: startDate.addingTimeInterval(86400 * 2),
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
homeTeamId: homeTeamId,
|
||||||
|
awayTeamId: awayTeamId
|
||||||
|
)
|
||||||
|
|
||||||
|
let preferences = makePreferences(
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
startLocation: startLocation,
|
||||||
|
endLocation: endLocation
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = makeRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
games: [game],
|
||||||
|
teams: [homeTeamId: makeTeam(id: homeTeamId), awayTeamId: makeTeam(id: awayTeamId)],
|
||||||
|
stadiums: [stadiumId: stadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
XCTAssertTrue(result.isSuccess, "Should succeed for linear route with games")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Travel Segment Invariant Tests
|
||||||
|
|
||||||
|
func test_TravelSegmentCount_EqualsStopsMinusOne() {
|
||||||
|
// Given: A multi-stop itinerary
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
||||||
|
|
||||||
|
var stadiums: [UUID: Stadium] = [:]
|
||||||
|
var teams: [UUID: Team] = [:]
|
||||||
|
var games: [Game] = []
|
||||||
|
|
||||||
|
// Create 3 games in 3 cities
|
||||||
|
let cities = [
|
||||||
|
("New York", 40.7128, -74.0060),
|
||||||
|
("Philadelphia", 39.9526, -75.1652),
|
||||||
|
("Washington DC", 38.9072, -77.0369)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (index, (city, lat, lon)) in cities.enumerated() {
|
||||||
|
let stadiumId = UUID()
|
||||||
|
let homeTeamId = UUID()
|
||||||
|
let awayTeamId = UUID()
|
||||||
|
|
||||||
|
stadiums[stadiumId] = makeStadium(id: stadiumId, city: city, latitude: lat, longitude: lon)
|
||||||
|
teams[homeTeamId] = makeTeam(id: homeTeamId, city: city)
|
||||||
|
teams[awayTeamId] = makeTeam(id: awayTeamId)
|
||||||
|
|
||||||
|
let game = makeGame(
|
||||||
|
dateTime: startDate.addingTimeInterval(86400 * Double(index + 1)),
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
homeTeamId: homeTeamId,
|
||||||
|
awayTeamId: awayTeamId
|
||||||
|
)
|
||||||
|
games.append(game)
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
||||||
|
let request = makeRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
games: games,
|
||||||
|
teams: teams,
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if case .success(let options) = result, let option = options.first {
|
||||||
|
let expectedSegments = option.stops.count - 1
|
||||||
|
XCTAssertEqual(
|
||||||
|
option.travelSegments.count,
|
||||||
|
max(0, expectedSegments),
|
||||||
|
"Travel segments should equal stops - 1"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(option.isValid, "Itinerary should pass validity check")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_SingleStopItinerary_HasZeroTravelSegments() {
|
||||||
|
// Given: A single game (single stop)
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
||||||
|
|
||||||
|
let stadiumId = UUID()
|
||||||
|
let homeTeamId = UUID()
|
||||||
|
let awayTeamId = UUID()
|
||||||
|
|
||||||
|
let stadium = makeStadium(id: stadiumId, latitude: 40.7128, longitude: -74.0060)
|
||||||
|
let homeTeam = makeTeam(id: homeTeamId)
|
||||||
|
let awayTeam = makeTeam(id: awayTeamId)
|
||||||
|
|
||||||
|
let game = makeGame(
|
||||||
|
dateTime: startDate.addingTimeInterval(86400 * 2),
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
homeTeamId: homeTeamId,
|
||||||
|
awayTeamId: awayTeamId
|
||||||
|
)
|
||||||
|
|
||||||
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
||||||
|
let request = makeRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
games: [game],
|
||||||
|
teams: [homeTeamId: homeTeam, awayTeamId: awayTeam],
|
||||||
|
stadiums: [stadiumId: stadium]
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if case .success(let options) = result, let option = options.first {
|
||||||
|
if option.stops.count == 1 {
|
||||||
|
XCTAssertEqual(option.travelSegments.count, 0, "Single stop should have zero travel segments")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Driving Constraints Tests
|
||||||
|
|
||||||
|
func test_DrivingConstraints_MultipleDrivers_IncreasesCapacity() {
|
||||||
|
// Given: Two drivers instead of one
|
||||||
|
let constraints1 = DrivingConstraints(numberOfDrivers: 1, maxHoursPerDriverPerDay: 8.0)
|
||||||
|
let constraints2 = DrivingConstraints(numberOfDrivers: 2, maxHoursPerDriverPerDay: 8.0)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
XCTAssertEqual(constraints1.maxDailyDrivingHours, 8.0, "Single driver = 8 hours max")
|
||||||
|
XCTAssertEqual(constraints2.maxDailyDrivingHours, 16.0, "Two drivers = 16 hours max")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ranking Tests
|
||||||
|
|
||||||
|
func test_ItineraryOptions_AreRanked() {
|
||||||
|
// Given: Multiple games that could form different routes
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(86400 * 14)
|
||||||
|
|
||||||
|
var stadiums: [UUID: Stadium] = [:]
|
||||||
|
var teams: [UUID: Team] = [:]
|
||||||
|
var games: [Game] = []
|
||||||
|
|
||||||
|
// Create games with coordinates
|
||||||
|
let locations = [
|
||||||
|
("City1", 40.0, -74.0),
|
||||||
|
("City2", 40.5, -73.5),
|
||||||
|
("City3", 41.0, -73.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (index, (city, lat, lon)) in locations.enumerated() {
|
||||||
|
let stadiumId = UUID()
|
||||||
|
let homeTeamId = UUID()
|
||||||
|
let awayTeamId = UUID()
|
||||||
|
|
||||||
|
stadiums[stadiumId] = makeStadium(id: stadiumId, city: city, latitude: lat, longitude: lon)
|
||||||
|
teams[homeTeamId] = makeTeam(id: homeTeamId)
|
||||||
|
teams[awayTeamId] = makeTeam(id: awayTeamId)
|
||||||
|
|
||||||
|
let game = makeGame(
|
||||||
|
dateTime: startDate.addingTimeInterval(86400 * Double(index + 1)),
|
||||||
|
stadiumId: stadiumId,
|
||||||
|
homeTeamId: homeTeamId,
|
||||||
|
awayTeamId: awayTeamId
|
||||||
|
)
|
||||||
|
games.append(game)
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
||||||
|
let request = makeRequest(
|
||||||
|
preferences: preferences,
|
||||||
|
games: games,
|
||||||
|
teams: teams,
|
||||||
|
stadiums: stadiums
|
||||||
|
)
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if case .success(let options) = result {
|
||||||
|
for (index, option) in options.enumerated() {
|
||||||
|
XCTAssertEqual(option.rank, index + 1, "Options should be ranked 1, 2, 3, ...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Edge Case Tests
|
||||||
|
|
||||||
|
func test_NoGamesAvailable_ReturnsExplicitFailure() {
|
||||||
|
// Given: Empty games array
|
||||||
|
let startDate = Date()
|
||||||
|
let endDate = startDate.addingTimeInterval(86400 * 7)
|
||||||
|
|
||||||
|
let preferences = makePreferences(startDate: startDate, endDate: endDate)
|
||||||
|
let request = makeRequest(preferences: preferences, games: [])
|
||||||
|
|
||||||
|
// When
|
||||||
|
let result = engine.planItineraries(request: request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
XCTAssertFalse(result.isSuccess, "Should return failure for no games")
|
||||||
|
XCTAssertNotNil(result.failure, "Should have explicit failure reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
41
SportsTimeUITests/SportsTimeUITests.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// SportsTimeUITests.swift
|
||||||
|
// SportsTimeUITests
|
||||||
|
//
|
||||||
|
// Created by Trey Tartt on 1/6/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class SportsTimeUITests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||||
|
|
||||||
|
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||||
|
continueAfterFailure = false
|
||||||
|
|
||||||
|
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testExample() throws {
|
||||||
|
// UI tests must launch the application that they test.
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunchPerformance() throws {
|
||||||
|
// This measures how long it takes to launch your application.
|
||||||
|
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||||
|
XCUIApplication().launch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
SportsTimeUITests/SportsTimeUITestsLaunchTests.swift
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// SportsTimeUITestsLaunchTests.swift
|
||||||
|
// SportsTimeUITests
|
||||||
|
//
|
||||||
|
// Created by Trey Tartt on 1/6/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class SportsTimeUITestsLaunchTests: XCTestCase {
|
||||||
|
|
||||||
|
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunch() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||||
|
// such as logging into a test account or navigating somewhere in the app
|
||||||
|
|
||||||
|
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||||
|
attachment.name = "Launch Screen"
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
sample_screens/Screenshot 2026-01-06 at 11.18.58 AM.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
sample_screens/Screenshot 2026-01-06 at 11.23.49 AM.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
sample_screens/Screenshot 2026-01-06 at 11.42.57 AM.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
sample_screens/Screenshot 2026-01-06 at 11.43.05 AM.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
sample_screens/Screenshot 2026-01-06 at 11.43.12 AM.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
sample_screens/Screenshot 2026-01-06 at 11.44.13 AM.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
sample_screens/Screenshot 2026-01-06 at 11.44.21 AM.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |